diff --git a/README.md b/README.md index 4dab42c..bcdf12f 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,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. Driven by `com.asiago.claude-config-backup` every 15 min. | +| `handoff-sync.sh` | Two-way `rclone bisync` of `~/.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`. | diff --git a/handoff-sync.sh b/handoff-sync.sh new file mode 100755 index 0000000..3368204 --- /dev/null +++ b/handoff-sync.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Two-way sync of ~/.claude/handoffs with Drive "Claude Handoff". TILSIT cron, every 5 min: no GUI session there for a LaunchAgent. + +# Auth errors in the log: rclone's shared client_id is likely retired. Fix with: rclone config reconnect gdrive: + +# Full path, not command -v: cron's PATH lacks /opt/homebrew/bin. Tests stub it; empty or non-executable = missing. +RCLONE_BIN="${RCLONE_BIN:-/opt/homebrew/bin/rclone}" + +HANDOFF_LOCAL_DIR="${HANDOFF_LOCAL_DIR:-${HOME}/.claude/handoffs}" +HANDOFF_REMOTE="${HANDOFF_REMOTE:-gdrive:Claude Handoff}" +HANDOFF_LOG_DIR="${HANDOFF_LOG_DIR:-${HOME}/.local/state/rclone}" + +# Above this many lines the log is cut back to its last LOG_KEEP_LINES. +LOG_MAX_LINES=2000 +LOG_KEEP_LINES=1000 + +# ========================================================= +# FUNCTIONS +# ========================================================= +function log_error() { + local timestamp + timestamp="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[${timestamp}] [ERROR] $*" >&2 +} + +# True when $1 names an executable file. An empty value is "missing". +function have_tool() { + [[ -n "${1:-}" && -x "${1}" ]] +} + +function trim_log() { + local log_file="$1" log_lines + + [[ -f "${log_file}" ]] || return 0 + log_lines=$(wc -l <"${log_file}") + if [[ "${log_lines}" -gt "${LOG_MAX_LINES}" ]]; then + tail -n "${LOG_KEEP_LINES}" "${log_file}" >"${log_file}.tmp" && mv "${log_file}.tmp" "${log_file}" + fi +} + +# ========================================================= +# MAIN EXECUTION +# ========================================================= +function main() { + local log_file="${HANDOFF_LOG_DIR}/handoff-sync.log" + + mkdir -p "${HANDOFF_LOCAL_DIR}" "${HANDOFF_LOG_DIR}" + trim_log "${log_file}" + + # cron discards stdout and stderr, so the log file is the only record. + if ! have_tool "${RCLONE_BIN}"; then + log_error "rclone not found at '${RCLONE_BIN}'" 2>>"${log_file}" + return 1 + fi + + # --resilient/--recover: retry after a transient failure instead of + # demanding a manual --resync. --max-lock: a crashed run's lock expires. + "${RCLONE_BIN}" bisync "${HANDOFF_LOCAL_DIR}" "${HANDOFF_REMOTE}" \ + --drive-skip-gdocs \ + --create-empty-src-dirs \ + --resilient --recover --max-lock 10m \ + --log-level NOTICE \ + --log-file "${log_file}" +} + +# Skip main when sourced (e.g., by bats tests loading function defs). +# Mirrors the guard in break-watcher.sh. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/tests/handoff-sync.bats b/tests/handoff-sync.bats new file mode 100755 index 0000000..63e8cbe --- /dev/null +++ b/tests/handoff-sync.bats @@ -0,0 +1,128 @@ +#!/usr/bin/env bats +# bats tests for handoff-sync.sh +# +# Run from repo root: bats tests/handoff-sync.bats +# +# The script runs rclone bisync. RCLONE_BIN points at a stub in the per-test +# temp dir that records one argv item per line to RCLONE_ARGS and exits with +# the code in rclone.rc. All three directories are under BATS_TEST_TMPDIR, +# which bats removes, so nothing touches a real Drive or ~/.claude. + +setup() { + REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:?must be run via bats}")/.." && pwd)" + TMPDIR_T="${BATS_TEST_TMPDIR:?bats 1.4 or later is required}" + STUBS="${TMPDIR_T}/stubs" + mkdir -p "${STUBS}" + export RCLONE_ARGS="${TMPDIR_T}/rclone-args" + + cat >"${STUBS}/rclone" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" >"${RCLONE_ARGS}" +rc_file="$(dirname "$0")/rclone.rc" +if [[ -f "${rc_file}" ]]; then exit "$(cat "${rc_file}")"; fi +exit 0 +EOF + chmod +x "${STUBS}/rclone" + + # Exported BEFORE sourcing: the script reads these as defaults at load + # time, so even if the source guard regressed, only the stub would run. + export RCLONE_BIN="${STUBS}/rclone" + export HANDOFF_LOCAL_DIR="${TMPDIR_T}/handoffs" + export HANDOFF_REMOTE="testremote:Claude Handoff" + export HANDOFF_LOG_DIR="${TMPDIR_T}/state" + LOG_FILE="${HANDOFF_LOG_DIR}/handoff-sync.log" + + source "${REPO_ROOT}/handoff-sync.sh" +} + +# --------------------------------------------------------------------------- +# The sync call +# --------------------------------------------------------------------------- + +@test "runs rclone bisync between the local folder and the remote" { + run main + [ "${status}" -eq 0 ] + [ "$(sed -n 1p "${RCLONE_ARGS}")" = "bisync" ] + [ "$(sed -n 2p "${RCLONE_ARGS}")" = "${HANDOFF_LOCAL_DIR}" ] + # The space in the folder name survives as one argument. + [ "$(sed -n 3p "${RCLONE_ARGS}")" = "testremote:Claude Handoff" ] +} + +@test "passes the self-healing and Drive flags" { + run main + local flag + for flag in --drive-skip-gdocs --create-empty-src-dirs --resilient --recover; do + grep -qx -- "${flag}" "${RCLONE_ARGS}" + done + grep -A1 -x -- --max-lock "${RCLONE_ARGS}" | grep -qx 10m +} + +@test "rclone writes to the log file in the log dir" { + run main + grep -A1 -x -- --log-file "${RCLONE_ARGS}" | grep -qx "${LOG_FILE}" +} + +@test "creates the local and log folders when missing" { + [ ! -d "${HANDOFF_LOCAL_DIR}" ] + run main + [ -d "${HANDOFF_LOCAL_DIR}" ] + [ -d "${HANDOFF_LOG_DIR}" ] +} + +# --------------------------------------------------------------------------- +# Failures are visible +# --------------------------------------------------------------------------- + +@test "an rclone failure is the script's exit status" { + echo 7 >"${STUBS}/rclone.rc" + run main + [ "${status}" -eq 7 ] +} + +@test "missing rclone exits 1 and says so in the log" { + RCLONE_BIN="${TMPDIR_T}/does-not-exist/rclone" + run main + [ "${status}" -eq 1 ] + grep -q "rclone not found at '${TMPDIR_T}/does-not-exist/rclone'" "${LOG_FILE}" +} + +@test "an empty RCLONE_BIN counts as missing" { + RCLONE_BIN="" + run main + [ "${status}" -eq 1 ] + [ ! -e "${RCLONE_ARGS}" ] +} + +# --------------------------------------------------------------------------- +# Log trimming +# --------------------------------------------------------------------------- + +@test "a log over 2000 lines is cut to its last 1000" { + mkdir -p "${HANDOFF_LOG_DIR}" + seq 1 2001 >"${LOG_FILE}" + run main + [ "$(wc -l <"${LOG_FILE}" | tr -d ' ')" -eq 1000 ] + [ "$(head -1 "${LOG_FILE}")" = "1002" ] + [ "$(tail -1 "${LOG_FILE}")" = "2001" ] +} + +@test "a log at exactly 2000 lines is left alone" { + mkdir -p "${HANDOFF_LOG_DIR}" + seq 1 2000 >"${LOG_FILE}" + run main + [ "$(wc -l <"${LOG_FILE}" | tr -d ' ')" -eq 2000 ] +} + +# --------------------------------------------------------------------------- +# Script shape +# --------------------------------------------------------------------------- + +@test "the default rclone is a full path, because cron's PATH lacks Homebrew" { + run env -u RCLONE_BIN bash -c "source '${REPO_ROOT}/handoff-sync.sh'; echo \"\${RCLONE_BIN}\"" + [ "${output}" = "/opt/homebrew/bin/rclone" ] +} + +@test "sourcing the script does not run main" { + [ "$(type -t main)" = "function" ] + [ ! -e "${RCLONE_ARGS}" ] +}