Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.2.0] - 2026-06-14

### Added
- **Integrity**: every backup now writes a `<archive>.sha256` sidecar, and a new
`claude-backup verify [--from <archive>]` re-checks the gzip stream, the
checksum and the archive's path safety (`--json` supported).
- **Off-site copy**: `--remote 'cmd:<command>'` (and a `claude-backup push`
subcommand) push a finished backup through any command — `{}` is replaced by
the archive path, otherwise it is appended. Honours `CLAUDE_BACKUP_REMOTE`.
Pushes the `.sha256` too; a failed push never deletes the local backup.
- **Scheduling**: `claude-backup schedule [--daily|--weekly|--hourly] [--at HH:MM]`
installs a recurring backup via launchd (macOS) or a systemd user timer with a
crontab fallback (Linux); `--status` and `claude-backup unschedule` manage it.
Options after `--` are passed to each scheduled run.
- **Selective restore**: `claude-restore --only <cat[,cat…]>` restores just the
chosen categories (`settings agents commands hooks skills mcp claude-md
history plugins`), and `--list-contents` shows what an archive holds.
- **curl | bash install**: `install.sh` now bootstraps its sources when piped
(downloads the repo tarball); overridable via `CCB_REPO` / `CCB_REF`.

### Changed
- `_chk` status helper moved into `lib/common.sh` (shared by doctor, verify,
schedule status and the selective-restore listing).
- Updated bash/zsh completions and `--help` for the new subcommands and flags.
- Test suite expanded with verify, remote, selective-restore and schedule suites.

## [0.1.1] - 2026-06-05

### Added
Expand Down Expand Up @@ -56,6 +82,7 @@ First release.
- Documentation: architecture, restore safety, banner, plugin; plus
`SECURITY.md`, `CONTRIBUTING.md`.

[Unreleased]: https://github.com/dberuben/claude-code-backup/compare/v0.1.1...HEAD
[Unreleased]: https://github.com/dberuben/claude-code-backup/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/dberuben/claude-code-backup/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/dberuben/claude-code-backup/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/dberuben/claude-code-backup/releases/tag/v0.1.0
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ control, and restores it on the same or a new machine.

## Install

One-liner (downloads sources, installs to `~/.local`):

```bash
curl -fsSL https://raw.githubusercontent.com/dberuben/claude-code-backup/main/install.sh | bash
```

Or from a checkout:

```bash
git clone https://github.com/dberuben/claude-code-backup.git
cd claude-code-backup
Expand All @@ -40,13 +48,52 @@ claude-backup doctor
claude-backup # back up → ~/Backups/claude-code
claude-backup --dry-run # preview only, writes nothing
claude-backup list # list existing backups
claude-backup verify # check the latest archive (gzip + checksum)
claude-restore # restore the latest backup (asks first)
```

Handy flags: `--no-history` (skip the large `projects/` history), `--full`
(everything), `--no-project`, `--dest <dir>`, `--strict-secrets`, `--json`.
Full reference: `claude-backup --help`.

Every backup writes a `<archive>.sha256` sidecar; `claude-backup verify`
re-checks the gzip stream, the checksum and the path safety of an archive.

### Off-site copy (opt-in)

Backups are local by default. Push them anywhere with a command of your choice —
`{}` is replaced by the archive path (no extra dependency required):

```bash
claude-backup --remote 'cmd:rclone copy {} mydrive:claude/'
claude-backup --remote 'cmd:rsync -a {} nas:/backups/'
claude-backup push --remote 'cmd:aws s3 cp {} s3://bucket/claude/' # push the latest
```

Set `CLAUDE_BACKUP_REMOTE` to make it the default. A failed push never deletes
the local backup.

### Schedule it

```bash
claude-backup schedule --daily --at 02:30 # launchd / systemd timer / cron
claude-backup schedule --status
claude-backup unschedule
```

Pass options to each scheduled run after `--`, e.g.
`schedule --daily -- --no-history --remote 'cmd:rclone copy {} d:/'`.

### Restore a subset

```bash
claude-restore --list-contents # what's inside an archive
claude-restore --only agents,commands # restore just those
claude-restore --only settings --home-only
```

Categories: `settings agents commands hooks skills mcp claude-md history plugins`.

By default a backup includes your config **and** conversation history, but
**prunes** big regenerable data (plugin code, caches, venvs). Plugin *code* is
not stored — the install manifests are, so you know what to reinstall.
Expand Down
61 changes: 53 additions & 8 deletions bin/claude-backup
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,18 @@ for _cand in \
done
[ -n "$CCB_LIB_DIR" ] || { echo "claude-backup: cannot locate lib dir (set CLAUDE_BACKUP_LIB)" >&2; exit 1; }
export CCB_LIB_DIR
# Absolute path to this very binary, used by `schedule` to write the cron/timer
# command. _self is the dir of the fully symlink-resolved script.
CCB_SELF_BIN="$_self/$(basename "${BASH_SOURCE[0]}")"
export CCB_SELF_BIN
# shellcheck source=../lib/common.sh
. "$CCB_LIB_DIR/common.sh"
# shellcheck source=../lib/archive.sh
. "$CCB_LIB_DIR/archive.sh"
# shellcheck source=../lib/restore.sh
. "$CCB_LIB_DIR/restore.sh"
# shellcheck source=../lib/verify.sh
. "$CCB_LIB_DIR/verify.sh"

usage() {
cat <<'EOF'
Expand All @@ -41,6 +47,10 @@ claude-backup - back up Claude Code configuration
USAGE:
claude-backup [options] Create a backup (default)
claude-backup list List existing backups
claude-backup verify [opts] Check an archive's integrity (gzip + checksum)
claude-backup push [opts] Push an existing backup to a remote
claude-backup schedule [opts] Install a recurring backup (launchd/systemd/cron)
claude-backup unschedule Remove the recurring backup
claude-backup doctor Diagnose the environment
claude-backup banner Print the status banner

Expand All @@ -52,12 +62,29 @@ OPTIONS (backup):
--full Include everything (no pruning of caches/plugins/etc.)
--include-env Include .env.claude and .envrc (may contain secrets)
--strict-secrets Abort if likely secrets are detected
--remote <spec> After backup, push the archive to a remote (see REMOTE)
--dry-run Show what would be backed up
--json Machine-readable JSON output
--quiet Minimal output
--help Show this help
--version Show version

OPTIONS (verify / push):
--from <archive> Operate on a specific archive (default: latest)
--remote <spec> Remote spec for `push` (see REMOTE)

REMOTE:
A remote is a user command, so any transport works with no extra dependency:
--remote 'cmd:rclone copy {} mydrive:claude/' # {} = archive path
--remote 'cmd:rsync -a {} nas:/backups/' # appended if no {}
Or set CLAUDE_BACKUP_REMOTE in the environment.

SCHEDULE:
claude-backup schedule [--daily|--weekly|--hourly] [--at HH:MM] [-- <backup opts>]
claude-backup schedule --status
Extra options after `--` are passed to each scheduled backup, e.g.:
claude-backup schedule --daily --at 02:30 -- --no-history --remote 'cmd:rclone copy {} d:/'

DESTINATION ORDER:
--dest > $CLAUDE_BACKUP_DIR > $HOME/Backups/claude-code
EOF
Expand All @@ -66,14 +93,6 @@ EOF
# ---------------------------------------------------------------------------
# doctor
# ---------------------------------------------------------------------------
_chk() { # _chk <ok|warn|fail> <message>
case "$1" in
ok) printf ' %s✓%s %s\n' "$C_GREEN" "$C_RESET" "$2" ;;
warn) printf ' %s⚠%s %s\n' "$C_YELLOW" "$C_RESET" "$2" ;;
fail) printf ' %s✗%s %s\n' "$C_RED" "$C_RESET" "$2" ;;
esac
}

do_doctor() {
local backup_dir; backup_dir="$(resolve_backup_dir "${OPT_DEST:-}")"
log_step "claude-code-backup doctor (v$CCB_VERSION)"
Expand Down Expand Up @@ -126,12 +145,34 @@ do_doctor() {
# ---------------------------------------------------------------------------
OPT_DEST=""; OPT_INCLUDE_PROJECT=1; OPT_INCLUDE_ENV=0
OPT_STRICT=0; OPT_DRY_RUN=0; OPT_FULL=0; OPT_NO_HISTORY=0
OPT_REMOTE=""; OPT_FROM=""

# Subcommand dispatch on the first argument.
case "${1:-}" in
list) shift
while [ $# -gt 0 ]; do case "$1" in --json) CCB_JSON=1;; --dest) OPT_DEST="$2"; shift;; *) ;; esac; shift; done
do_list; exit 0 ;;
verify) shift
while [ $# -gt 0 ]; do case "$1" in
--from) OPT_FROM="${2:-}"; shift;; --dest) OPT_DEST="${2:-}"; shift;;
--json) CCB_JSON=1;; --quiet) CCB_QUIET=1;;
-h|--help) usage; exit 0;; *) die "unknown option for verify: $1";;
esac; shift; done
do_verify; exit $? ;;
push) shift
while [ $# -gt 0 ]; do case "$1" in
--from) OPT_FROM="${2:-}"; shift;; --dest) OPT_DEST="${2:-}"; shift;;
--remote) OPT_REMOTE="${2:-}"; shift;;
--json) CCB_JSON=1;; --quiet) CCB_QUIET=1;;
-h|--help) usage; exit 0;; *) die "unknown option for push: $1";;
esac; shift; done
do_push; exit $? ;;
schedule|unschedule)
sub="$1"; shift
# shellcheck source=../lib/schedule.sh
. "$CCB_LIB_DIR/schedule.sh"
if [ "$sub" = "unschedule" ]; then do_unschedule "$@"; else do_schedule "$@"; fi
exit $? ;;
doctor) shift; do_doctor; exit 0 ;;
banner) shift
# shellcheck source=../lib/banner.sh
Expand All @@ -150,6 +191,7 @@ while [ $# -gt 0 ]; do
--full) OPT_FULL=1 ;;
--include-env) OPT_INCLUDE_ENV=1 ;;
--strict-secrets) OPT_STRICT=1 ;;
--remote) OPT_REMOTE="${2:-}"; shift ;;
--dry-run) OPT_DRY_RUN=1 ;;
--json) CCB_JSON=1 ;;
--quiet) CCB_QUIET=1 ;;
Expand All @@ -160,5 +202,8 @@ while [ $# -gt 0 ]; do
shift
done

# Fall back to the environment-configured remote when no --remote was given.
OPT_REMOTE="${OPT_REMOTE:-${CLAUDE_BACKUP_REMOTE:-}}"

platform_supported || log_warn "Unsupported platform ($(uname -s)); proceeding best-effort."
do_backup
19 changes: 18 additions & 1 deletion bin/claude-restore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ USAGE:
OPTIONS:
--from <archive> Restore from a specific archive (default: latest)
--list List available backups and exit
--list-contents Show which categories an archive contains, then exit
--only <cat[,cat…]> Restore only these categories (see CATEGORIES)
--dry-run Show the restore plan only
--force Do not ask for confirmation
--home-only Restore only global (~) config
Expand All @@ -53,18 +55,28 @@ OPTIONS:
--json Machine-readable JSON output
--help Show this help
--version Show version

CATEGORIES (for --only):
settings agents commands hooks skills mcp claude-md history plugins
e.g. claude-restore --only agents,commands
claude-restore --only settings --home-only
Note: home-level `settings` and `mcp` both live in ~/.claude.json, so
restoring either reverts that whole file.
EOF
}

OPT_FROM=""; OPT_DEST=""; OPT_DRY_RUN=0; OPT_FORCE=0
OPT_HOME_ONLY=0; OPT_PROJECT_ONLY=0; OPT_BACKUP_EXISTING=1
DO_LIST=0
OPT_ONLY=""
DO_LIST=0; DO_LIST_CONTENTS=0

while [ $# -gt 0 ]; do
case "$1" in
--from) OPT_FROM="${2:-}"; shift ;;
--dest) OPT_DEST="${2:-}"; shift ;;
--list) DO_LIST=1 ;;
--list-contents) DO_LIST_CONTENTS=1 ;;
--only) OPT_ONLY="${2:-}"; shift ;;
--dry-run) OPT_DRY_RUN=1 ;;
--force) OPT_FORCE=1 ;;
--home-only) OPT_HOME_ONLY=1 ;;
Expand All @@ -89,4 +101,9 @@ if [ "$DO_LIST" = "1" ]; then
exit 0
fi

if [ "$DO_LIST_CONTENTS" = "1" ]; then
do_list_contents
exit 0
fi

do_restore
19 changes: 14 additions & 5 deletions completions/claude-backup.bash
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@ _claude_backup() {
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"

local subcommands="list doctor banner"
local subcommands="list verify push schedule unschedule doctor banner"
local opts="--dest --include-project --no-project --no-history --full --include-env \
--strict-secrets --dry-run --json --quiet --help --version"
--strict-secrets --remote --from --dry-run --json --quiet --help --version"

# Complete a directory after --dest.
# Complete a directory after --dest, a file after --from.
if [ "$prev" = "--dest" ]; then
COMPREPLY=( $(compgen -d -- "$cur") )
return 0
fi
if [ "$prev" = "--from" ]; then
COMPREPLY=( $(compgen -f -- "$cur") )
return 0
fi

if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=( $(compgen -W "$subcommands $opts" -- "$cur") )
else
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
COMPREPLY=( $(compgen -W "$opts --daily --weekly --hourly --at --status" -- "$cur") )
fi
}
complete -F _claude_backup claude-backup
Expand All @@ -32,12 +36,17 @@ _claude_restore() {
local cur prev
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
local opts="--from --list --dry-run --force --home-only --project-only \
local opts="--from --list --list-contents --only --dry-run --force --home-only --project-only \
--backup-existing --no-backup-existing --dest --json --help --version"
local cats="settings agents commands hooks skills mcp claude-md history plugins"
if [ "$prev" = "--from" ]; then
COMPREPLY=( $(compgen -f -- "$cur") )
return 0
fi
if [ "$prev" = "--only" ]; then
COMPREPLY=( $(compgen -W "$cats" -- "$cur") )
return 0
fi
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
}
complete -F _claude_restore claude-restore
13 changes: 13 additions & 0 deletions completions/claude-backup.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ _claude-backup() {
local -a subcommands opts
subcommands=(
'list:List existing backups'
'verify:Check an archive (gzip + checksum)'
'push:Push an existing backup to a remote'
'schedule:Install a recurring backup'
'unschedule:Remove the recurring backup'
'doctor:Diagnose the environment'
'banner:Print the status banner'
)
Expand All @@ -18,6 +22,13 @@ _claude-backup() {
'--full[Include everything (no pruning)]'
'--include-env[Include .env.claude/.envrc]'
'--strict-secrets[Abort if secrets detected]'
"--remote[Push the archive to a remote]:spec:"
'--from[Operate on a specific archive]:archive:_files'
'--daily[Schedule daily]'
'--weekly[Schedule weekly]'
'--hourly[Schedule hourly]'
'--at[Time HH:MM for schedule]:time:'
'--status[Show schedule status]'
'--dry-run[Show what would be backed up]'
'--json[Machine-readable output]'
'--quiet[Minimal output]'
Expand All @@ -34,6 +45,8 @@ _claude-restore() {
_arguments \
'--from[Restore from archive]:archive:_files' \
'--list[List available backups]' \
'--list-contents[Show categories in an archive]' \
'--only[Restore only these categories]:categories:(settings agents commands hooks skills mcp claude-md history plugins)' \
'--dry-run[Show restore plan only]' \
'--force[Do not ask confirmation]' \
'--home-only[Restore only global config]' \
Expand Down
Loading