Created
August 7, 2026 21:47
-
-
Save PProvost/b7eea8b66e63c52847f01b2aafe59263 to your computer and use it in GitHub Desktop.
Back up recently modified Claude Code session transcripts and their associated subagent session directories.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # backup-claude-sessions.sh | |
| # | |
| # Back up recently modified Claude Code session transcripts and their | |
| # associated subagent session directories. | |
| # | |
| # Claude Code project transcript directories can contain root-level session | |
| # files such as: | |
| # | |
| # ff8d36ca-4de1-42de-9870-4a98cdd0db76.jsonl | |
| # | |
| # A session that used subagents may also have a corresponding directory: | |
| # | |
| # ff8d36ca-4de1-42de-9870-4a98cdd0db76/ | |
| # | |
| # containing additional session artifacts. Some of those files may be older | |
| # than the requested backup window even though the parent session itself was | |
| # recently active. | |
| # | |
| # This script therefore performs two steps: | |
| # | |
| # 1. Recursively find and copy all files modified within the requested | |
| # number of days, preserving their paths relative to SOURCE_DIR. | |
| # | |
| # 2. For every root-level .jsonl transcript selected by that time window, | |
| # check for a same-named directory in SOURCE_DIR. If one exists, copy | |
| # the ENTIRE directory regardless of the modification times of the files | |
| # inside it. This ensures subagent transcripts belonging to the selected | |
| # session are included. | |
| # | |
| # DAYS is interpreted as a rolling time window rather than calendar days. | |
| # For example: | |
| # | |
| # DAYS=3 -> files modified within the last 72 hours | |
| # DAYS=2 -> files modified within the last 48 hours | |
| # | |
| # Existing files in DEST_DIR may be updated, but this script does NOT delete | |
| # files from the destination. | |
| # | |
| # Requirements: | |
| # | |
| # - bash | |
| # - find | |
| # - rsync | |
| # | |
| # Usage: | |
| # | |
| # backup-claude-sessions.sh [--dry-run] SOURCE_DIR DEST_DIR DAYS | |
| # | |
| # Arguments: | |
| # | |
| # SOURCE_DIR Claude Code project transcript directory | |
| # DEST_DIR Directory where session files should be backed up | |
| # DAYS Positive integer specifying the lookback window | |
| # | |
| # Options: | |
| # | |
| # --dry-run Show what would be copied without making changes | |
| # -h, --help Show usage information | |
| # | |
| # Example: | |
| # | |
| # backup-claude-sessions.sh \ | |
| # ~/.claude/projects/-home-example-project \ | |
| # ~/backups/claude-sessions \ | |
| # 3 | |
| # | |
| # Dry run: | |
| # | |
| # backup-claude-sessions.sh --dry-run \ | |
| # ~/.claude/projects/-home-example-project \ | |
| # ~/backups/claude-sessions \ | |
| # 3 | |
| # | |
| set -euo pipefail | |
| usage() { | |
| cat <<'EOF' | |
| Usage: | |
| backup-claude-sessions.sh [--dry-run] SOURCE_DIR DEST_DIR DAYS | |
| Arguments: | |
| SOURCE_DIR Claude Code project transcript directory | |
| DEST_DIR Backup destination directory | |
| DAYS Number of days to look back (rolling 24-hour periods) | |
| Options: | |
| --dry-run Show what would be copied without changing anything | |
| -h, --help Show this help | |
| Example: | |
| backup-claude-sessions.sh \ | |
| ~/.claude/projects/-home-example-project \ | |
| ~/backups/claude-sessions \ | |
| 3 | |
| Dry run: | |
| backup-claude-sessions.sh --dry-run \ | |
| ~/.claude/projects/-home-example-project \ | |
| ~/backups/claude-sessions \ | |
| 3 | |
| EOF | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Parse arguments | |
| # --------------------------------------------------------------------------- | |
| dry_run=false | |
| positional=() | |
| for arg in "$@"; do | |
| case "$arg" in | |
| --dry-run) | |
| dry_run=true | |
| ;; | |
| -h|--help) | |
| usage | |
| exit 0 | |
| ;; | |
| -*) | |
| echo "Error: unknown option: $arg" >&2 | |
| echo >&2 | |
| usage >&2 | |
| exit 1 | |
| ;; | |
| *) | |
| positional+=("$arg") | |
| ;; | |
| esac | |
| done | |
| if [[ ${#positional[@]} -ne 3 ]]; then | |
| echo "Error: expected SOURCE_DIR, DEST_DIR, and DAYS." >&2 | |
| echo >&2 | |
| usage >&2 | |
| exit 1 | |
| fi | |
| src="${positional[0]%/}" | |
| dst="${positional[1]%/}" | |
| days="${positional[2]}" | |
| # --------------------------------------------------------------------------- | |
| # Validate arguments | |
| # --------------------------------------------------------------------------- | |
| if [[ ! -d "$src" ]]; then | |
| echo "Error: source directory does not exist: $src" >&2 | |
| exit 1 | |
| fi | |
| if [[ ! "$days" =~ ^[0-9]+$ ]] || (( days < 1 )); then | |
| echo "Error: DAYS must be a positive integer." >&2 | |
| exit 1 | |
| fi | |
| minutes=$((days * 24 * 60)) | |
| # --------------------------------------------------------------------------- | |
| # Configure rsync | |
| # --------------------------------------------------------------------------- | |
| rsync_opts=(-av) | |
| if "$dry_run"; then | |
| rsync_opts+=(-n) | |
| echo "DRY RUN: no files will be changed." | |
| else | |
| mkdir -p "$dst" | |
| fi | |
| echo | |
| echo "Source: $src" | |
| echo "Destination: $dst" | |
| echo "Lookback: $days day(s) / $minutes minutes" | |
| echo | |
| # --------------------------------------------------------------------------- | |
| # Phase 1 | |
| # | |
| # Copy every recently modified file below the project transcript directory. | |
| # Paths are kept relative to SOURCE_DIR so nested artifacts retain their | |
| # original directory structure. | |
| # --------------------------------------------------------------------------- | |
| echo "=== Copying recently modified files ===" | |
| ( | |
| cd "$src" | |
| find . -type f -mmin "-$minutes" -print0 | | |
| rsync -0 "${rsync_opts[@]}" --files-from=- ./ "$dst/" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Phase 2 | |
| # | |
| # Identify root-level .jsonl transcripts that fall within the requested | |
| # window. If SOURCE_DIR contains a same-named directory, copy that directory | |
| # in full. Files inside it are intentionally NOT subject to the date filter. | |
| # | |
| # Example: | |
| # | |
| # abc123.jsonl -> abc123/ | |
| # | |
| # This captures older subagent artifacts associated with a recently active | |
| # parent session. | |
| # --------------------------------------------------------------------------- | |
| echo | |
| echo "=== Copying complete subagent directories for selected sessions ===" | |
| while IFS= read -r -d '' transcript; do | |
| filename="${transcript#./}" | |
| session_id="${filename%.jsonl}" | |
| if [[ -d "$src/$session_id" ]]; then | |
| echo | |
| echo "Session has subagent directory: $session_id/" | |
| rsync "${rsync_opts[@]}" \ | |
| "$src/$session_id/" \ | |
| "$dst/$session_id/" | |
| fi | |
| done < <( | |
| cd "$src" | |
| find . \ | |
| -maxdepth 1 \ | |
| -type f \ | |
| -name '*.jsonl' \ | |
| -mmin "-$minutes" \ | |
| -print0 | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Done | |
| # --------------------------------------------------------------------------- | |
| echo | |
| if "$dry_run"; then | |
| echo "Dry run complete. No files were changed." | |
| else | |
| echo "Backup complete." | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment