| name | da-cli-deviantart-sync |
| description | Sync DeviantArt galleries to local folders with OAuth 2.1 PKCE, SQLite indexing, and scheduled syncs using da-cli |
| triggers | ["sync my DeviantArt gallery","download art from DeviantArt","set up DeviantArt backup","authenticate with DeviantArt API","schedule automatic DeviantArt sync","search DeviantArt from command line","backup watched artists on DeviantArt","configure da-cli for DeviantArt"] |
da-cli DeviantArt Sync Skill
Skill by ara.so — Devtools Skills collection.
Overview
da-cli is a zero-dependency Python CLI tool that syncs DeviantArt galleries to local folders. It uses OAuth 2.1 with PKCE for authentication, maintains a SQLite index for incremental syncs, and supports scheduled backups via launchd (macOS) or systemd (Linux). Key features:
- Zero runtime dependencies — pure Python 3.10+ stdlib
- Incremental sync — SQLite index tracks what's downloaded; re-runs cost one API call when nothing's new
- OAuth 2.1 PKCE — secure authentication with automatic token refresh
- Multiple sync modes — watch feed, specific artists, all watched users
- Scheduled automation — launchd/systemd integration for unattended syncs
- Search & browse — tags, topics, daily deviations, user profiles
- Secrets in Keychain — macOS Keychain integration for client secrets
Installation
Option A: pipx (recommended)
pipx install da-sync
da --version
Option B: git clone (for development)
git clone https://github.com/FZ2000/da-cli.git ~/Documents/da-cli
cd ~/Documents/da-cli
./install.sh
da --version
Requirements: Python 3.10+ (uses argparse.BooleanOptionalAction and X | None syntax)
Initial Setup
1. Create DeviantArt OAuth App
2. Configure da-cli
da config set client_id YOUR_CLIENT_ID
da config set client_secret YOUR_CLIENT_SECRET
da config set destination ~/Pictures/DA
da config set scope "browse collection user"
da config show
3. Authenticate
da auth
da whoami
da refresh
Core Commands
Authentication
da auth
da whoami
da refresh
da auth logout
da auth status
Syncing Art
da sync feed
da sync feed --no-mature
da sync artist username
da sync watched
da sync watched --via-feed
da sync feed --jitter 0.4
da sync feed --max-minutes 30
da sync feed
Search & Browse
da search tag nature
da search tag "digital art" --limit 50
da search topic digitalart
da search topics
da daily 2026-01-15
da daily today
da search user deviantart
da user profile username
da deviation show 123456789
da deviation morelikethis 123456789
da watch list
Configuration Management
da config show
da config path
da config set key value
da config set max_retries 5
da config set sleep_between_requests 1.5
da config get client_secret
da config get client_secret --unmask
da config unset key
Index & Maintenance
da index show
da index rebuild
da diagnose
da bench
Scheduling Automated Syncs
macOS (launchd)
./install_schedule.sh
cat > ~/Library/LaunchAgents/com.user.da-sync.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.da-sync</string>
<key>ProgramArguments</key>
<array>
<string>/Users/USERNAME/.local/bin/da</string>
<string>sync</string>
<string>feed</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/USERNAME/.local/state/da-cli/sync.log</string>
<key>StandardErrorPath</key>
<string>/Users/USERNAME/.local/state/da-cli/sync.log</string>
</dict>
</plist>
EOF
launchctl load ~/Library/LaunchAgents/com.user.da-sync.plist
launchctl list | grep da-sync
Important: Grant Full Disk Access to Terminal/iTerm in System Preferences → Privacy & Security → Full Disk Access
Linux (systemd)
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/da-sync.service <<'EOF'
[Unit]
Description=DeviantArt sync
[Service]
Type=oneshot
ExecStart=/home/USERNAME/.local/bin/da sync feed
StandardOutput=append:/home/USERNAME/.local/state/da-cli/sync.log
StandardError=append:/home/USERNAME/.local/state/da-cli/sync.log
EOF
cat > ~/.config/systemd/user/da-sync.timer <<'EOF'
[Unit]
Description=Daily DeviantArt sync
[Timer]
OnCalendar=daily
OnCalendar=03:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl --user daemon-reload
systemctl --user enable da-sync.timer
systemctl --user start da-sync.timer
loginctl enable-linger $USER
systemctl --user list-timers
systemctl --user status da-sync.timer
Python Scripting
Export Sync Index to CSV
import sqlite3
import csv
from pathlib import Path
def export_index_to_csv(output_path: str = "sync_index.csv"):
"""Export da-cli sync index to CSV."""
db_path = Path.home() / ".local/state/da-cli/sync.db"
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("""
SELECT deviation_id, title, author, published_time,
content_url, is_mature, local_path
FROM deviations
ORDER BY published_time DESC
""")
with open(output_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['deviation_id', 'title', 'author', 'published_time',
'content_url', 'is_mature', 'local_path'])
writer.writerows(cursor.fetchall())
conn.close()
print(f"Exported to {output_path}")
if __name__ == "__main__":
export_index_to_csv()
Post-Sync Webhook
import json
import sqlite3
import subprocess
from pathlib import Path
from datetime import datetime, timedelta
def check_new_deviations(since_hours: int = 24):
"""Check for deviations synced in the last N hours."""
db_path = Path.home() / ".local/state/da-cli/sync.db"
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cutoff = datetime.now() - timedelta(hours=since_hours)
cutoff_str = cutoff.isoformat()
cursor.execute("""
SELECT COUNT(*), author
FROM deviations
WHERE synced_at > ?
GROUP BY author
ORDER BY COUNT(*) DESC
""", (cutoff_str,))
results = cursor.fetchall()
conn.close()
if results:
print(f"New deviations in last {since_hours}h:")
for count, author in results:
print(f" {author}: {count} new")
return True
return False
def send_notification(message: str):
"""Send macOS notification."""
subprocess.run([
'osascript', '-e',
f'display notification "{message}" with title "da-cli sync"'
])
if __name__ == "__main__":
if check_new_deviations():
send_notification()
Query Specific Artist
import sqlite3
from pathlib import Path
def get_artist_stats(username: str):
"""Get stats for a specific artist in sync index."""
db_path = Path.home() / ".local/state/da-cli/sync.db"
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("""
SELECT COUNT(*), MIN(published_time), MAX(published_time)
FROM deviations
WHERE author = ?
""", (username,))
count, first_date, last_date = cursor.fetchone()
cursor.execute("""
SELECT title, published_time, local_path
FROM deviations
WHERE author = ?
ORDER BY published_time DESC
LIMIT 5
""", (username,))
recent = cursor.fetchall()
conn.close()
print(f"\n{username}:")
print(f" Total deviations: {count}")
print(f" Date range: {first_date} to {last_date}")
print(f"\n Recent 5:")
for title, pub_time, path in recent:
print(f" - {title} ({pub_time})")
print(f" {path}")
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print()
sys.exit()
get_artist_stats(sys.argv[])
Configuration Reference
Config File Locations
~/.config/da-cli/config.json
~/.local/state/da-cli/state.json
~/.local/state/da-cli/sync.db
~/.local/state/da-cli/sync.log
Key Settings
{
"client_id": "12345",
"destination": "/Users/username/Pictures/DA",
"scope": "browse collection user",
"mature_content": true,
"sleep_between_requests": 1.0,
"max_retries": 3,
"checkpoint_interval": 50,
"default_limit": 24
}
Environment Variables
export DA_CLI_CONFIG_DIR=~/custom/config/path
export DA_CLI_STATE_DIR=~/custom/state/path
export DA_CLIENT_ID=your_client_id
export DA_CLIENT_SECRET=your_client_secret
export DA_DESTINATION=~/Pictures/DA
Common Patterns
Initial Full Sync of Watched Artists
da sync feed --limit 500
da sync watched
da sync artist artist1
da sync artist artist2
Sync with Rate Limiting
da sync feed --jitter 0.5
da config set sleep_between_requests 2.0
da sync feed
da sync feed --max-minutes 15
Filter Content
da sync feed --no-mature
Resume Interrupted Sync
da sync feed
da index show | grep checkpoint
da index rebuild
Troubleshooting
Redirect URI Mismatch
da config get redirect_uri
Expired Refresh Token
da diagnose
da auth logout
da auth
Scheduled Job Not Running
da diagnose
launchctl list | grep da-sync
launchctl start com.user.da-sync
systemctl --user status da-sync.timer
systemctl --user status da-sync.service
journalctl --user -u da-sync.service
Permission Denied on macOS
ls -la ~/.local/state/da-cli/
Missing Dependencies (dev)
cd ~/Documents/da-cli
make dev-setup
Keychain Access Issues
da config set client_secret YOUR_SECRET
Index Corruption
da index rebuild
sqlite3 ~/.local/state/da-cli/sync.db "PRAGMA integrity_check;"
API Scope Reference
da config set scope "browse collection user"
da config set scope "browse"
da config set scope "browse collection user message stash"
Best Practices
- Use pipx for installation — keeps da-cli isolated from system Python
- Store secrets in Keychain — never commit
config.json with secrets
- Enable jitter for large syncs —
--jitter 0.4 randomizes request timing
- Run
da diagnose before troubleshooting — checks all common issues
- Schedule syncs during off-hours — reduces API load and bandwidth usage
- Use
--via-feed for watched sync — works without user scope
- Set checkpoint interval —
da config set checkpoint_interval 100 for long syncs
- Monitor logs —
tail -f ~/.local/state/da-cli/sync.log
Security Notes
- Client secret: stored in macOS Keychain (service:
da-cli, account: client_secret)
- Tokens: stored in
~/.local/state/da-cli/state.json with 0600 permissions
- PKCE mandatory:
code_verifier generated per-auth, never leaves machine
- No telemetry: zero network calls except to DeviantArt API and image CDNs
- All HTTPS: API and image downloads are TLS-encrypted
.gitignore included: secrets-bearing files cannot be accidentally committed
Resources