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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Internal repo; not intended for public consumption.
| --- | --- |
| `1password-mcp-launcher.sh` | Reads `OP_SERVICE_ACCOUNT_TOKEN` from the macOS Keychain and launches the 1Password MCP server at a pinned version — avoids handing the keychain-held token to whatever `npx -y` happens to resolve. |
| `instapaper-mcp-launcher.sh` | Reads the four Instapaper API credentials from the macOS Keychain and launches the Instapaper MCP server. The server calls bare `dotenv.config()`, which resolves `.env` against the process working directory — a Desktop-spawned server never runs from the repo, so exporting here is what actually reaches it. |
| `claude-config-backup.sh` | Snapshots machine-local Claude config files into a git repo and pushes. Gates each file on quiescence (unchanged size+mtime across a settle window), valid JSON, and a `gitleaks`-first secret scan before committing. If the remote has commits the clone lacks, it runs `git pull --rebase` and pushes once more. A rebase conflict is aborted and logged as `ERROR` with the manual fix; the script exits 1 and keeps the local commits. Other push failures log `WARN` with git's first error line. A failed push is retried next run, even when nothing new was committed. It never force-pushes. Driven by `com.asiago.claude-config-backup` every 15 min. Tests are in `tests/claude-config-backup.bats`. |
| `claude-config-backup.sh` | Snapshots machine-local Claude config files into a git repo and pushes. Each host writes only its own folder: `<HOST>/cli/claude.json`, `<HOST>/desktop/claude_desktop_config.json` and `<HOST>/machine-settings.json`. The last is the `--settings` overlay that claude-wrapper passes; `CLAUDE_MACHINE_SETTINGS` overrides its path. `<HOST>` is `CLAUDE_CONFIG_BACKUP_HOST` if set, else `hostname -s`, uppercased. It must match `^[A-Z0-9][A-Z0-9-]*$`, or the script logs `ERROR` and exits 1. It refuses to run on arich-mac (company machine), detected by host name or by the `arich` login. On its first run with this layout, it moves root-level `cli/` and `desktop/` into `<HOST>/` with `git mv`, in a commit of its own. If both the old and new folders exist, it logs `ERROR`, changes nothing and exits 1. Gates each file on quiescence (unchanged size+mtime across a settle window), valid JSON, and a `gitleaks`-first secret scan before committing. If the remote has commits the clone lacks, it runs `git pull --rebase` and pushes once more. A rebase conflict is aborted and logged as `ERROR` with the manual fix; the script exits 1 and keeps the local commits. Other push failures log `WARN` with git's first error line. A failed push is retried next run, even when nothing new was committed. It never force-pushes. Driven by `com.asiago.claude-config-backup` every 15 min. Tests are in `tests/claude-config-backup.bats`. |
| `handoff-sync.sh` | Two-way `rclone bisync` of `~/.local/state/claude-handoffs` with the Google Drive folder `Claude Handoff`, for hosts with no Drive app (TILSIT). Runs from `andrewrich`'s crontab every 5 minutes, not a LaunchAgent, because that user has no GUI session there. Log: `~/.local/state/rclone/handoff-sync.log`. The `gdrive:` remote uses rclone's shared client ID, which Google may retire. Tests in `tests/handoff-sync.bats`. |
| `refresh-claude-secrets.sh` | Resolves `op://` references from `secrets.op` and writes plaintext values into `claude-secrets.md` for Claude via the Filesystem MCP. Re-run after rotating any referenced credential. Must run from `~/.config/claude-code` (the symlink target). |
| `ssh` | Wrapper over the real `ssh` that intercepts `# op: VAR="op://..."` annotations in `~/.ssh/config`, resolves them via 1Password, exports, then `exec`s real `ssh`. Unannotated hosts pass straight through. Spec: [`docs/plans/2026-04-16-ssh-wrapper-spec.md`](docs/plans/2026-04-16-ssh-wrapper-spec.md). Tests in `tests/ssh-wrapper.bats`. |
Expand Down
91 changes: 84 additions & 7 deletions claude-config-backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,6 @@ set -euo pipefail

REPO="${XDG_DATA_HOME:-${HOME}/.local/share}/claude-config-backup"

# Tracked files: "<destination path inside repo>|<absolute source path>"
# Add a line here to track another file.
TRACKED=(
"desktop/claude_desktop_config.json|${HOME}/Library/Application Support/Claude/claude_desktop_config.json"
"cli/claude.json|${HOME}/.claude/.claude.json"
)

# Seconds to wait between the two stat samples used for the quiescence check.
SETTLE_SECONDS="${SETTLE_SECONDS:-5}"

Expand All @@ -35,6 +28,49 @@ log() {
echo "[${ts}] $*"
}

# Host label = this machine's top-level folder. hostname -s, not scutil (wrong inside the sandbox); the env var is for tests.
_host_short() { hostname -s 2>/dev/null || true; }
_current_user() { id -un 2>/dev/null || true; }
_upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]'; }

host_from_env="${CLAUDE_CONFIG_BACKUP_HOST:-}"
host_short="$(_host_short)"
host_short="$(_upper "${host_short}")"
host_env_upper="$(_upper "${host_from_env}")"
login="$(_current_user)"

# arich-mac's company config must never reach this personal repo. Either signal refuses: host name or the "arich" login.
if [[ "${host_env_upper}" == ARICH-MAC* || "${host_short}" == ARICH-MAC* \
|| "${login}" == "arich" ]]; then
log "ERROR: this looks like arich-mac (company machine); refusing to back up company config to a personal repo"
exit 1
fi

if [[ -n "${host_from_env}" ]]; then
HOST_LABEL="${host_from_env}"
else
HOST_LABEL="${host_short}"
fi
if [[ -z "${HOST_LABEL}" ]]; then
log "ERROR: no host label; set CLAUDE_CONFIG_BACKUP_HOST (e.g. ASIAGO) in the LaunchAgent"
exit 1
fi
# Uppercase letters, digits and '-' only: never empty, never '/' or '..'.
if [[ ! "${HOST_LABEL}" =~ ^[A-Z0-9][A-Z0-9-]*$ ]]; then
log "ERROR: invalid host label '${HOST_LABEL}' (allowed: A-Z, 0-9 and '-', not leading); set CLAUDE_CONFIG_BACKUP_HOST"
exit 1
fi

# "<repo path>|<source path>", all under ${HOST_LABEL}/. machine-settings.json is claude-wrapper's --settings overlay, in no other repo.
TRACKED=(
"${HOST_LABEL}/desktop/claude_desktop_config.json|${HOME}/Library/Application Support/Claude/claude_desktop_config.json"
"${HOST_LABEL}/cli/claude.json|${HOME}/.claude/.claude.json"
"${HOST_LABEL}/machine-settings.json|${CLAUDE_MACHINE_SETTINGS:-${HOME}/.config/claude-code/machine-settings.json}"
)

# Folders that lived at the repo root before the host-prefixed layout.
LEGACY_DIRS=(cli desktop)

if ! command -v git >/dev/null 2>&1; then
log "ERROR: git not found on PATH"
exit 1
Expand Down Expand Up @@ -181,6 +217,47 @@ if [[ ! -d "${REPO}/.git" ]]; then
exit 1
fi

# One-time move of the root-level layout into ${HOST_LABEL}/: a git mv, so history follows. GIT_BIN lets tests stub it.
_git_mv() { "${GIT_BIN:-git}" -C "${REPO}" mv -- "$1" "$2"; }

_migrate_legacy_layout() {
local d have_old=() clash=()
for d in "${LEGACY_DIRS[@]}"; do
[[ -d "${REPO}/${d}" ]] || continue
have_old+=("${d}")
[[ -e "${REPO}/${HOST_LABEL}/${d}" ]] && clash+=("${d}")
done
[[ "${#have_old[@]}" -eq 0 ]] && return 0

if [[ "${#clash[@]}" -gt 0 ]]; then
log "ERROR: both old and new layout present for: ${clash[*]} (root and ${HOST_LABEL}/); refusing to migrate or back up"
log " Resolve by hand in ${REPO}: keep one copy of each, commit, then re-run."
exit 1
fi
if ! git -C "${REPO}" diff --cached --quiet; then
log "ERROR: index of ${REPO} has staged changes; refusing to migrate the layout on top of them"
exit 1
fi

mkdir -p "${REPO}/${HOST_LABEL}"
for d in "${have_old[@]}"; do
if ! _git_mv "${d}" "${HOST_LABEL}/${d}"; then
log "ERROR: git mv ${d} ${HOST_LABEL}/${d} failed; inspect ${REPO} by hand"
exit 1
fi
done
if git -C "${REPO}" diff --cached --quiet; then
log "migrated layout: nothing tracked to move for ${have_old[*]}"
return 0
fi
git -C "${REPO}" commit --quiet \
-m "layout: move ${have_old[*]} into ${HOST_LABEL}/" \
-m "One-time move to the host-prefixed layout by claude-config-backup.sh; each host now writes only its own folder."
log "migrated layout: moved ${have_old[*]} into ${HOST_LABEL}/"
}

_migrate_legacy_layout

copied=0
skipped=0
matched_pattern=""
Expand Down
Loading
Loading