Skip to content

Instantly share code, notes, and snippets.

@feliperohdee
Created June 8, 2026 18:04
Show Gist options
  • Select an option

  • Save feliperohdee/35be04923990a30e405de2c4a9643985 to your computer and use it in GitHub Desktop.

Select an option

Save feliperohdee/35be04923990a30e405de2c4a9643985 to your computer and use it in GitHub Desktop.
claude bkp
#!/bin/bash
#
# Backup of Claude Code + Claude Desktop (cowork) durable state.
# Allowlist approach: copies ONLY what is hard to recreate (instructions,
# settings, skills, plugin config, and all `memory/` dirs). Everything
# ephemeral or secret (caches, telemetry, sessions, cookies, tokens) is
# left out on purpose — it regenerates on the next login.
#
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLAUDE_HOME="$HOME/.claude"
DESKTOP_HOME="$HOME/Library/Application Support/Claude"
DOCS_HOME="$HOME/Documents/Claude"
DOT_DEST="$REPO/dot-claude"
COWORK_DEST="$REPO/cowork"
DOCS_DEST="$REPO/documents-claude"
RSYNC_EXCLUDES=(--exclude='.DS_Store' --exclude='*.tmp' --exclude='*.lock')
# Copy every `memory/` directory found under $1 into $2, preserving the
# relative path so the structure can be restored verbatim.
copy_memories() {
local src="$1" dest="$2"
[ -d "$src" ] || return 0
( cd "$src" && find . -type d -name memory -print0 ) | while IFS= read -r -d '' dir; do
mkdir -p "$dest/$dir"
rsync -a "${RSYNC_EXCLUDES[@]}" "$src/$dir/" "$dest/$dir/"
done
}
# Fresh snapshot every run so deletions propagate to the backup.
rm -rf "$DOT_DEST" "$COWORK_DEST" "$DOCS_DEST"
mkdir -p "$DOT_DEST"
echo "==> ~/.claude essentials"
# Top-level config files: global instructions, settings and keybindings.
for f in CLAUDE.md settings.json settings.local.json keybindings.json CLAUDE.local.md; do
if [ -f "$CLAUDE_HOME/$f" ]; then
cp "$CLAUDE_HOME/$f" "$DOT_DEST/$f"
fi
done
echo "==> ~/.claude config dirs (skills + agents/commands/output-styles/rules if present)"
# Whole config directories: your custom skills, subagents, slash commands,
# output styles and path-scoped rules (only the ones that exist are copied).
for d in skills agents commands output-styles rules; do
if [ -d "$CLAUDE_HOME/$d" ]; then
rsync -a "${RSYNC_EXCLUDES[@]}" "$CLAUDE_HOME/$d/" "$DOT_DEST/$d/"
fi
done
echo "==> ~/.claude/plugins config (lets you reinstall the same plugins)"
# Plugin *manifest* only: which marketplaces are known + which plugins are
# installed. The plugin code lives in git-cloned marketplaces and is fetched
# again from these files on restore — like package.json, not node_modules.
mkdir -p "$DOT_DEST/plugins"
for f in known_marketplaces.json installed_plugins.json; do
if [ -f "$CLAUDE_HOME/plugins/$f" ]; then
cp "$CLAUDE_HOME/plugins/$f" "$DOT_DEST/plugins/$f"
fi
done
echo "==> user-scoped MCP servers (extracted from ~/.claude.json, no tokens/caches)"
# ~/.claude.json mixes durable config with OAuth tokens, caches and per-project
# history — so we pull ONLY the `mcpServers` key into a small standalone file.
if [ -f "$HOME/.claude.json" ]; then
python3 - "$HOME/.claude.json" "$DOT_DEST/mcp-servers.json" <<'PY'
import json, sys
src, dest = sys.argv[1], sys.argv[2]
data = json.load(open(src))
mcp = data.get("mcpServers") or {}
if mcp:
json.dump({"mcpServers": mcp}, open(dest, "w"), indent=2, sort_keys=True)
print(f" saved {len(mcp)} MCP server(s)")
else:
print(" no user-scoped MCP servers")
PY
fi
echo "==> ~/.claude/projects memories"
copy_memories "$CLAUDE_HOME/projects" "$DOT_DEST/projects"
mkdir -p "$COWORK_DEST"
echo "==> Claude Desktop config (globalShortcut, preferences, MCP servers)"
# Desktop app config: keyboard shortcut, UI preferences (trusted folders,
# starred spaces...) and Desktop MCP servers. No auth tokens live here.
if [ -f "$DESKTOP_HOME/claude_desktop_config.json" ]; then
cp "$DESKTOP_HOME/claude_desktop_config.json" "$COWORK_DEST/claude_desktop_config.json"
fi
echo "==> cowork (Claude Desktop) memories"
copy_memories "$DESKTOP_HOME/local-agent-mode-sessions" "$COWORK_DEST/local-agent-mode-sessions"
echo "==> cowork registries (spaces.json, settings, scheduled tasks)"
# spaces.json maps a space id -> name/folder. Without it a restored
# `spaces/<id>/memory` is orphaned: cowork wouldn't know that space exists.
# The account/org path levels above it are your account & org UUIDs, which are
# recreated identically on the same login — so the space memories reconnect.
if [ -d "$DESKTOP_HOME/local-agent-mode-sessions" ]; then
( cd "$DESKTOP_HOME/local-agent-mode-sessions" \
&& find . -maxdepth 3 \( -name spaces.json -o -name cowork_settings.json -o -name scheduled-tasks.json \) -print0 ) \
| while IFS= read -r -d '' f; do
mkdir -p "$COWORK_DEST/local-agent-mode-sessions/$(dirname "$f")"
cp "$DESKTOP_HOME/local-agent-mode-sessions/$f" "$COWORK_DEST/local-agent-mode-sessions/$f"
done
fi
echo "==> ~/Documents/Claude (cowork space project & scheduled folders)"
# This is where cowork spaces keep their working files (spaces.json folder
# paths point here). Backing it up makes restored spaces self-contained.
if [ -d "$DOCS_HOME" ]; then
rsync -a "${RSYNC_EXCLUDES[@]}" "$DOCS_HOME/" "$DOCS_DEST/"
fi
echo "==> done."
# Append-only run log: one line per execution (date + time). Lives at the repo
# root so the snapshot wipe above never touches it, and it gets committed below.
printf '%s\n' "$(date '+%Y-%m-%d %H:%M:%S')" >> "$REPO/log.txt"
# Commit & push the snapshot (always commits — the log line just changed).
cd "$REPO"
git add -A
git diff --cached --quiet || { git commit -m "backup $(date +%Y-%m-%d_%H:%M)" && git push; }
#!/bin/bash
#
# Restore Claude Code + Claude Desktop (cowork) durable state onto this machine.
# Merges the backup into your live dirs WITHOUT deleting anything already there,
# so it is safe to run on a fresh install or an existing setup.
#
# Run AFTER you have logged into Claude Code / Desktop at least once (so the
# base dirs and fresh auth tokens exist). This only puts back the durable bits.
#
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLAUDE_HOME="$HOME/.claude"
DESKTOP_HOME="$HOME/Library/Application Support/Claude"
DOCS_HOME="$HOME/Documents/Claude"
DOT_SRC="$REPO/dot-claude"
COWORK_SRC="$REPO/cowork"
DOCS_SRC="$REPO/documents-claude"
echo "This will merge the backup into:"
echo " $CLAUDE_HOME"
echo " $DESKTOP_HOME"
echo " $DOCS_HOME"
read -r -p "Continue? [y/N] " ans
case "$ans" in
y|Y) ;;
*) echo "Aborted."; exit 1 ;;
esac
mkdir -p "$CLAUDE_HOME"
echo "==> ~/.claude (CLAUDE.md, settings, skills, plugins config, memories)"
# mcp-servers.json is merged into ~/.claude.json below, not dropped here as-is.
rsync -a --exclude='mcp-servers.json' "$DOT_SRC/" "$CLAUDE_HOME/"
if [ -f "$DOT_SRC/mcp-servers.json" ]; then
echo "==> merging user-scoped MCP servers into ~/.claude.json"
python3 - "$DOT_SRC/mcp-servers.json" "$HOME/.claude.json" <<'PY'
import json, os, sys
backup, target = sys.argv[1], sys.argv[2]
servers = json.load(open(backup)).get("mcpServers", {})
data = json.load(open(target)) if os.path.exists(target) else {}
data.setdefault("mcpServers", {}).update(servers)
json.dump(data, open(target, "w"), indent=2)
print(f" merged {len(servers)} MCP server(s)")
PY
fi
if [ -f "$COWORK_SRC/claude_desktop_config.json" ]; then
echo "==> merging Claude Desktop config"
mkdir -p "$DESKTOP_HOME"
python3 - "$COWORK_SRC/claude_desktop_config.json" "$DESKTOP_HOME/claude_desktop_config.json" <<'PY'
import json, os, sys
backup, target = sys.argv[1], sys.argv[2]
src = json.load(open(backup))
data = json.load(open(target)) if os.path.exists(target) else {}
data.update(src)
json.dump(data, open(target, "w"), indent=2)
print(" done")
PY
fi
if [ -d "$COWORK_SRC/local-agent-mode-sessions" ]; then
echo "==> cowork memories + registries (spaces.json, settings, scheduled tasks)"
mkdir -p "$DESKTOP_HOME/local-agent-mode-sessions"
rsync -a "$COWORK_SRC/local-agent-mode-sessions/" "$DESKTOP_HOME/local-agent-mode-sessions/"
fi
if [ -d "$DOCS_SRC" ]; then
echo "==> ~/Documents/Claude (cowork space project & scheduled folders)"
mkdir -p "$DOCS_HOME"
rsync -a "$DOCS_SRC/" "$DOCS_HOME/"
fi
echo "==> done. Restart Claude Code / Desktop to pick up the restored state."
#!/bin/bash
#
# Enable / disable the daily backup schedule (runs backup.sh at 20:00).
# Uses macOS launchd, so a missed run (laptop asleep) fires on next wake.
#
# Usage:
# ./schedule.sh enable # install + activate the 20:00 daily schedule
# ./schedule.sh disable # deactivate + remove the schedule
# ./schedule.sh run # run backup.sh now (to test)
# ./schedule.sh status # show whether the schedule is active
#
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LABEL="com.feliperohde.claude-backup"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
DOMAIN="gui/$(id -u)"
HOUR=20
MINUTE=0
usage() {
cat <<EOF
schedule.sh — manage the daily Claude backup schedule
WHAT IT DOES
Schedules backup.sh to run every day at $(printf '%02d:%02d' "$HOUR" "$MINUTE")
via macOS launchd. Because it uses launchd (not cron), a run missed while the
laptop is asleep fires automatically on the next wake.
COMMANDS
enable Generate the launchd plist and activate the daily schedule.
disable Deactivate the schedule and remove the plist.
run Trigger backup.sh right now (use this to test).
status Show whether the schedule is currently active.
FILES
Schedule : $PLIST
Logs : /tmp/claude-backup.log (output)
/tmp/claude-backup.err (errors — check this first if something fails)
BEFORE ENABLING — two prerequisites, or it runs but nothing reaches GitHub:
1. Commit/push: uncomment the 'git add/commit/push' block at the end of
backup.sh, and make sure 'git push' works without a password (SSH key or
a stored credential), since it runs unattended.
2. Full Disk Access: grant it to /bin/bash in
System Settings > Privacy & Security > Full Disk Access — otherwise macOS
blocks a background job from reading ~/Documents and Application Support.
EXAMPLE
./schedule.sh enable # turn it on
./schedule.sh run # test immediately
cat /tmp/claude-backup.err
EOF
}
write_plist() {
mkdir -p "$HOME/Library/LaunchAgents"
cat > "$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>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>$REPO/backup.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>$HOUR</integer>
<key>Minute</key>
<integer>$MINUTE</integer>
</dict>
<key>StandardOutPath</key>
<string>/tmp/claude-backup.log</string>
<key>StandardErrorPath</key>
<string>/tmp/claude-backup.err</string>
</dict>
</plist>
EOF
}
case "${1:-}" in
enable)
write_plist
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
launchctl bootstrap "$DOMAIN" "$PLIST"
launchctl enable "$DOMAIN/$LABEL"
printf '✓ enabled — backup.sh runs daily at %02d:%02d\n' "$HOUR" "$MINUTE"
echo " test it now with: ./schedule.sh run"
;;
disable)
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
rm -f "$PLIST"
echo "✓ disabled — schedule removed"
;;
run)
launchctl kickstart -k "$DOMAIN/$LABEL" \
&& echo "✓ started — check /tmp/claude-backup.log and /tmp/claude-backup.err" \
|| { echo "not enabled yet — run ./schedule.sh enable first"; exit 1; }
;;
status)
if launchctl list | grep -q "$LABEL"; then
echo "active:"
launchctl list | grep "$LABEL"
else
echo "inactive (not scheduled)"
fi
;;
"")
usage
;;
*)
echo "unknown command: $1"
echo
usage
exit 1
;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment