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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

Expand Down
72 changes: 72 additions & 0 deletions handoff-sync.sh
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions tests/handoff-sync.bats
Original file line number Diff line number Diff line change
@@ -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}" ]
}
Loading