diff --git a/CLAUDE.md b/CLAUDE.md index ebdcb4d..db8dc75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,6 +170,14 @@ Each of these was a deliberate decision; changing it re-breaks something. name, and the tailnet hostname are unchanged by the project rename to flit. What changed is what the operator types: `ssh $FLIT_ALIAS` now resolves to `HostName $FLIT_SERVER` (§4.3, §4.4). +- **Project config has two checks, and neither replaces the other** — + `verify.sh`'s "project config linked" assertions are structural and run every + timer; they pass just as happily on the day Claude Code stops reading a + symlinked `.claude`. `server/bin/check-project-config.sh` runs a real session + and observes a hook fire, which is the only thing that distinguishes + configuration loaded from configuration silently ignored. It is deliberately + not called from `verify.sh`: it starts a session, and a drift check that costs + a model call would fail on an API outage and read as drift. - **The backup-escrow assertion SKIPs from `bootstrap.sh` on a valid token, and PASSes from the timer on the same token** — `flit_remote_env()` deliberately does not forward `PASS_CLI_TOKEN`, so `phase_11_verify()`'s remote run never diff --git a/laptop/adopt-project-config.sh b/laptop/adopt-project-config.sh new file mode 100755 index 0000000..65aec6c --- /dev/null +++ b/laptop/adopt-project-config.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# laptop/adopt-project-config.sh — move a project's agent configuration into the +# ~/.claude repository and leave a relative symlink behind. +# +# laptop/adopt-project-config.sh ... +# +# ~/.claude/project-config// the real directory, versioned +# /.claude -> ../../.claude/project-config/ +# +# WHY THIS EXISTS: a project's .claude/ is not reliably committed to that +# project's own repository, so agent configuration written on one machine is +# invisible on the other. Held in the ~/.claude repository instead, it is +# versioned once and reaches the box through the payload that already goes +# there, with no per-project transport. +# +# WHY THIS IS IN laptop/: the ~/.claude git repository exists only on the +# laptop. The box holds a rendered copy delivered by +# server/lib/22-claude-config.sh, so an adoption performed there would be +# overwritten by the next delivery and could never be committed. +# +# WHY THE LINK IS RELATIVE: an absolute target cannot work on both machines, +# whose homes differ. Projects sit at ~/misc/ on the laptop and +# ~/workspace/ on the box — both exactly two levels below home — so +# ../../.claude/project-config/ resolves correctly on either with no +# rewrite step. That depth is the assumption the whole scheme rests on, so it +# is asserted below rather than trusted. + +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 +# shellcheck source=lib/common.sh +source lib/common.sh + +CONFIG_ROOT="$HOME/.claude/project-config" +LINK_PREFIX="../../.claude/project-config" + +[[ $# -gt 0 ]] || die "usage: laptop/adopt-project-config.sh ..." + +# adopt +adopt() { + local project=$1 + project=${project%/} + [[ -d "$project" ]] || die "$project is not a directory" + + # Resolve before measuring depth: a path reached through a symlink or given + # relative would measure as something other than what the link will have to + # resolve through at read time. + project=$(cd "$project" && pwd -P) || die "could not resolve $project" + + local name parent + name=$(basename "$project") + parent=$(dirname "$(dirname "$project")") + # The link text hardcodes two levels. A project anywhere else would produce a + # link that resolves on neither machine, and a dangling .claude reads exactly + # like a project with no configuration — silent, which is the failure this + # whole scheme is meant to avoid. + [[ "$parent" == "$HOME" ]] \ + || die "$project is not two levels below $HOME; the relative link would not resolve (parent is $parent)" + + local src="$project/.claude" dest="$CONFIG_ROOT/$name" + + # Already adopted is not an error: this must be safe to re-run over a list + # that includes projects done on an earlier pass. + if [[ -L "$src" ]]; then + local resolved + resolved=$(cd "$(dirname "$src")" && cd "$(readlink "$src")" 2>/dev/null && pwd -P) + if [[ "$resolved" == "$dest" ]]; then + info "already satisfied: $name is linked to $dest" + return 0 + fi + die "$src is already a symlink, but to $(readlink "$src") rather than $LINK_PREFIX/$name" + fi + + [[ -e "$src" ]] || die "$src does not exist — nothing to adopt for $name" + [[ -d "$src" ]] || die "$src is not a directory" + # Refuse rather than merge. Two directories of the same name are two different + # configurations, and picking one silently loses the other (§1 criterion 8). + [[ -e "$dest" ]] && die "$dest already exists; move or remove it before adopting $name" + + if [[ $PRINT_ONLY -eq 1 ]]; then + info "would move $src -> $dest and link $src -> $LINK_PREFIX/$name" + return 0 + fi + + install -d -m 700 "$CONFIG_ROOT" || die "could not create $CONFIG_ROOT" + mv "$src" "$dest" || die "could not move $src to $dest" + + if ! ln -s "$LINK_PREFIX/$name" "$src"; then + mv "$dest" "$src" || warn "could not restore $src from $dest — the configuration is at $dest" + die "could not create the symlink at $src" + fi + + # Prove the link resolves to what was just moved, on this machine, now. A link + # created from the wrong depth or a mistyped prefix dangles, and a dangling + # .claude is indistinguishable from a project that never had configuration. + local check + check=$(cd "$src" 2>/dev/null && pwd -P) + if [[ "$check" != "$dest" ]]; then + rm -f "$src" + mv "$dest" "$src" || warn "could not restore $src from $dest — the configuration is at $dest" + die "the link at $src resolved to '${check:-nothing}', not $dest; restored the directory in place" + fi + + log "adopted $name: $src -> $LINK_PREFIX/$name" +} + +for p in "$@"; do adopt "$p"; done + +# settings.local.json is deliberately not moved aside. It is already ignored by +# ~/.claude/.gitignore at any depth, so it stays out of the repository while +# remaining where Claude Code expects it, and it is excluded from the delivery +# to the box (server/lib/22-claude-config.sh) so each machine keeps its own. +info "settings.local.json stays per-machine: gitignored in the repository, excluded from the delivery" + +finish 0 diff --git a/server/bin/check-project-config.sh b/server/bin/check-project-config.sh new file mode 100755 index 0000000..8b3c281 --- /dev/null +++ b/server/bin/check-project-config.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# check-project-config.sh — does Claude Code still load project configuration +# through a relative symlink? +# +# Runs on either machine, by hand, and after every Claude Code upgrade. It is +# in server/bin/ because the box is where the answer matters and where nobody +# is watching a session to notice. +# +# WHY A BEHAVIOURAL CHECK: Claude Code reads project configuration from a +# hard-coded /.claude and offers no supported way to relocate it; +# CLAUDE_CONFIG_DIR moves user-level configuration only. A symlinked .claude +# works, but it is undocumented, so a release can withdraw it. The failure is +# the worst kind: sessions keep starting and nothing looks wrong, while every +# rule, hook and skill in the project's configuration is silently absent — +# exactly how chook ran on this box for weeks loading no rules. +# +# A structural check cannot see that. verify.sh asserts the links exist and +# resolve, which catches a broken link but passes just as happily on the day +# Claude Code stops reading them. Only running a session and observing an +# effect distinguishes "configuration loaded" from "session started and +# configuration ignored". +# +# The subject is the mechanism, not any real project: this builds a throwaway +# project and a throwaway project-config entry, in the same two-levels-below- +# home shape the real ones use, so a run neither depends on nor disturbs them. + +set -uo pipefail +cd "$(dirname "$0")/../.." || exit 1 +# shellcheck source=lib/common.sh +source lib/common.sh + +need_cmd claude + +NAME="_selftest-$$" +PROJECT_ROOT="$HOME/.flit-project-config-check" +PROJECT="$PROJECT_ROOT/$NAME" +CONFIG="$HOME/.claude/project-config/$NAME" +MARKER="$PROJECT/hook-fired" + +# ONE trap, covering both directories. A second `trap ... EXIT` would replace +# this rather than add to it and leave an entry sitting inside the real +# ~/.claude/project-config, where the next delivery would carry it to the box. +cleanup() { rm -rf "$PROJECT_ROOT" "$CONFIG"; } +trap cleanup EXIT + +[[ -e "$CONFIG" ]] && die "$CONFIG already exists; refusing to touch it" + +install -d -m 700 "$CONFIG" || die "could not create $CONFIG" +install -d -m 700 "$PROJECT" || die "could not create $PROJECT" + +# SessionStart rather than PreToolUse: it fires on a session that calls no +# tools, so the check needs no prompt that provokes tool use and cannot fail +# because the model chose not to call one. +cat >"$CONFIG/settings.json" </dev/null && pwd -P) +[[ "$resolved" == "$CONFIG" ]] \ + || die "the throwaway link resolved to '${resolved:-nothing}', not $CONFIG" + +# macOS has no timeout(1) and this box's Linux does. Guard where possible and +# say so where not, rather than silently running unguarded on one machine. +# +# `env` is the neutral prefix rather than an empty array: bash 3.2, which is +# what /bin/bash is on macOS, treats "${arr[@]}" on an empty array as an unbound +# variable under `set -u` and aborts. env runs the command unchanged. +TIMEOUT=(env) +if command -v timeout >/dev/null 2>&1; then TIMEOUT=(timeout 120) +elif command -v gtimeout >/dev/null 2>&1; then TIMEOUT=(gtimeout 120) +else warn "no timeout(1) on this machine; a hung session will not be cut short" +fi + +# Output captured, never piped into a matcher: the exit status is half the +# evidence here, and a pipeline ending in an early-exit consumer loses it +# (standing rule 13). +# Run from inside the throwaway project. Project configuration is located from +# the session's working directory, so a session started anywhere else loads a +# different project's configuration entirely and reports on it — which reads +# exactly like the symlink not working. Subshell rather than a bare cd, so the +# rest of the script keeps the repo-root directory it was entered with. +out=$(cd "$PROJECT" && "${TIMEOUT[@]}" claude -p 'reply with the single word ok' 2>&1) +rc=$? + +# The session's own status is checked before the marker, because a session that +# never started leaves no marker either — and that reads identically to +# configuration being ignored. +if [[ $rc -ne 0 ]]; then + die "the probe session failed (exit $rc) — not evidence about symlinked config: ${out:0:200}" +fi + +if [[ ! -e "$MARKER" ]]; then + die "SESSION RAN BUT THE HOOK DID NOT FIRE: Claude Code no longer loads project +configuration through a symlinked .claude. Every project adopted into +~/.claude/project-config is running with no project configuration, silently. +Fall back to a real .claude/ per project, copied in from the repository." +fi + +log "project configuration loads through a relative symlink (hook fired)" +finish 0 diff --git a/server/lib/22-claude-config.sh b/server/lib/22-claude-config.sh index ac0a253..3fce531 100755 --- a/server/lib/22-claude-config.sh +++ b/server/lib/22-claude-config.sh @@ -36,7 +36,16 @@ DST="$H/.claude" # ---- content entries -------------------------------------------------------- # The whole tracked payload minus settings.json and chook.toml, which carry # laptop-only paths and so get their own rewrite-and-deliver handling below. -CONTENT=(CLAUDE.md statusline.sh agents commands docs hooks skills) +# +# project-config holds per-project agent configuration, one directory per +# project, reached from the project itself through a relative symlink +# (laptop/adopt-project-config.sh). It is delivered like any other content +# entry: the symlink travels with the project, and because it is relative it +# resolves to this directory on the box exactly as it does on the laptop. +# Nothing here rewrites paths inside it, so a project config carrying an +# absolute laptop path arrives verbatim and is wrong on the box — keep those +# paths relative or in settings.local.json. +CONTENT=(CLAUDE.md statusline.sh agents commands docs hooks skills project-config) if [[ $PRINT_ONLY -eq 1 ]]; then for entry in "${CONTENT[@]}"; do [[ -e "$SRC/$entry" ]] || continue @@ -46,7 +55,18 @@ else install -d -m700 "$DST" for entry in "${CONTENT[@]}"; do [[ -e "$SRC/$entry" ]] || continue - rsync -a --delete "$SRC/$entry" "$DST/" \ + # --exclude, with --delete, does two things at once: it declines to send a + # settings.local.json the payload should never have carried, and it + # protects the box's own from deletion — rsync does not delete an excluded + # file on the receiving side. Without it, every delivery would wipe the + # per-project machine-local file, which is the one file here that holds + # box-specific state and can capture credentials from an approved command + # line. + # *.lock is runtime state Claude Code writes beside a project's + # configuration; it belongs to the machine that wrote it, and is excluded + # here for the same two reasons as settings.local.json. + rsync -a --delete --exclude=settings.local.json --exclude='*.lock' \ + "$SRC/$entry" "$DST/" \ || die "could not sync $entry into $DST" done fi @@ -171,6 +191,78 @@ else info "linked $CHOOK_LINK -> $CHOOK_DST" fi +# ---- project config links --------------------------------------------------- +# Each delivered project-config/ is reached from ~/workspace/ +# through a relative symlink. On the laptop laptop/adopt-project-config.sh +# creates it; here it is created from the delivered payload, so a rebuilt box +# reproduces the links instead of needing a project re-pushed to carry them. +# +# The subject list is the delivered payload, never a written-down list, for the +# same reason verify.sh derives its assertions from it: a project adopted on the +# laptop must not need a second edit here. +# +# settings.local.json is machine-local and excluded from the delivery, so a box +# that already has one keeps it — moved into the config directory it will be +# read from, since that is where Claude Code looks once .claude is the link. +for cfg in "$DST"/project-config/*/; do + [[ -d "$cfg" ]] || continue # no match: the glob stays literal + pname=$(basename "$cfg") + proj="$H/workspace/$pname" + [[ -d "$proj" ]] || continue # not on this box; absent, not broken + link="$proj/.claude" + want="../../.claude/project-config/$pname" + + if [[ -L "$link" ]]; then + if [[ "$(readlink "$link")" == "$want" ]]; then + info "already satisfied: $proj/.claude -> $want" + else + defer "$link" \ + "a symlink pointing somewhere other than the delivered project config" \ + "ln -sfn '$want' '$link'" + fi + continue + fi + + if [[ -d "$link" ]]; then + # Anything beyond machine-local files is real configuration this box holds + # and the payload does not. Replacing it would silently change what every + # session in that project loads, so it is deferred with the commands rather + # than decided here (§1 criterion 8). + extra=$(find "$link" -mindepth 1 -not -name settings.local.json -not -name '*.lock' -print -quit 2>/dev/null) + if [[ -n "$extra" ]]; then + defer "$link" \ + "a real directory holding configuration that is not in the delivered payload; reconcile it into the ~/.claude repository on the laptop, then re-run" \ + "mv '$link' '$link.$FLIT_NAME.bak' && ln -sfn '$want' '$link'" + continue + fi + if [[ $PRINT_ONLY -eq 1 ]]; then + info "would replace $link with a link to $want, keeping settings.local.json" + continue + fi + # Move the machine-local file into the directory the link will point at, + # but never over one already delivered there. + if [[ -f "$link/settings.local.json" && ! -e "$cfg/settings.local.json" ]]; then + mv "$link/settings.local.json" "$cfg/settings.local.json" \ + || die "could not preserve $link/settings.local.json" + info "kept box-local settings.local.json for $pname" + fi + # Backed up rather than removed: nothing here destroys a config directory. + mv "$link" "$link.$FLIT_NAME.$(date +%Y%m%d%H%M%S).bak" \ + || die "could not back up $link" + elif [[ -e "$link" ]]; then + defer "$link" \ + "a file sits where the project config link belongs" \ + "mv '$link' '$link.bak' && ln -sfn '$want' '$link'" + continue + elif [[ $PRINT_ONLY -eq 1 ]]; then + info "would link $link -> $want" + continue + fi + + ln -s "$want" "$link" || die "could not link $link" + log "linked $link -> $want" +done + # ---- settings.json ----------------------------------------------------------- SETTINGS_SRC="$SRC/settings.json" SETTINGS_DST="$DST/settings.json" diff --git a/sync-design.md b/sync-design.md index 64d742c..483166a 100644 --- a/sync-design.md +++ b/sync-design.md @@ -1,6 +1,8 @@ # Syncing configuration and source between the laptop and the box -A proposal. Nothing here has been run. +Both pieces of work below are built. Project agent configuration is adopted for +the three projects that had any shareable content, with the two checks described +under "Project agent configuration". ## What is missing @@ -56,20 +58,42 @@ per-machine by design. **The symlink works — measured, not assumed.** Claude Code reads project configuration from a hard-coded `/.claude/` and offers no way to relocate it; `CLAUDE_CONFIG_DIR` moves user-level configuration only, and a symlinked -`.claude/` directory is undocumented. It was therefore tested directly: a project -whose `.claude` was a relative symlink, with a `PreToolUse` hook declared in the -symlinked `settings.json`, ran a session and the hook fired. +`.claude/` directory is undocumented. It was therefore tested directly, with a +`SessionStart` hook declared in the symlinked `settings.json` writing a marker +file. Four shapes were run, and all four fired: a real directory (the control), an +absolute symlink, a relative symlink to a target outside `~/.claude`, and the +shape actually used — a relative symlink into `~/.claude/project-config/`. + +`SessionStart` rather than `PreToolUse`: it fires on a session that calls no +tools, so the check cannot fail merely because the model chose not to call one. A hook is the right observable because it distinguishes the two outcomes that otherwise look identical — configuration loaded, versus session started and configuration silently ignored. That distinction is the whole risk here: chook ran on the box for weeks with no rules while every hook still fired, and nothing looked -wrong. For the same reason the test checks the session's own exit status before -reading the marker, since a session that never started leaves no marker either. - -Being undocumented, it can change. The fallback, if it ever does, is a real -`.claude/` per project with the sync copying it in from the repository — an -explicit step rather than a guess. +wrong. For the same reason the check reads the session's own exit status before +the marker, since a session that never started leaves no marker either. + +**Project configuration is located from the session's working directory.** The +first version of the behavioural check started the session from the repository +root instead of the throwaway project, so it reported on a different project +entirely and read as the symlink not working. A check of this kind proves nothing +until a control run has shown it can pass. + +Being undocumented, it can change. That is what +`server/bin/check-project-config.sh` exists to notice: it builds a throwaway +project and project-config entry in the same shape as the real ones, runs a +session, and fails loudly if the hook does not fire. `verify.sh` asserts only that +the links resolve — structural, cheap enough for the timer, and blind to exactly +this failure. The fallback, if support is ever withdrawn, is a real `.claude/` per +project with the sync copying it in from the repository — an explicit step rather +than a guess. + +**Only projects with shareable content are adopted.** Of the ten projects on the +box, seven hold nothing in `.claude/` but `settings.local.json` and lock files, +which are per-machine by design and stay out of the repository either way. +Linking those would centralise nothing while adding a link that can break, so +`untense`, `hezo` and `peek` are adopted and the rest are left alone. ## The two pieces of work diff --git a/verify.sh b/verify.sh index 12afa80..465ca7a 100755 --- a/verify.sh +++ b/verify.sh @@ -138,6 +138,33 @@ echo "=== claude config ===" check "chook config resolves to the delivered file" "$HOME/.claude/chook.toml" \ readlink -f "$HOME/.config/chook.toml" +# Per-project agent configuration lives in the delivered payload, one directory +# per project, and each project reaches it through a relative symlink created by +# laptop/adopt-project-config.sh. The link is what can break: rsync carries it +# verbatim, so a project pushed from a laptop where it was never adopted, or one +# whose directory was replaced by a real .claude, silently runs with different +# configuration than the laptop does. +# +# The subject list is derived from the delivered payload rather than written +# down here, so adopting a project needs no second edit and cannot drift from +# what was actually delivered (§13.2). Projects that are not on this box are not +# asserted — they are absent, not broken. +# +# This is structural only. It cannot see Claude Code dropping support for a +# symlinked .claude, which would leave every link resolving perfectly while the +# configuration behind it went unread; server/bin/check-project-config.sh is the +# behavioural check that catches that, and it is deliberately not run from here +# because it starts a real session. +echo "=== project config ===" +for cfg in "$HOME"/.claude/project-config/*/; do + [[ -d "$cfg" ]] || continue # no match: the glob stays literal + pname=$(basename "$cfg") + proj="$HOME/workspace/$pname" + [[ -d "$proj" ]] || continue + check "project config linked: $pname" "$HOME/.claude/project-config/$pname" \ + bash -c 'cd "$1/.claude" 2>/dev/null && pwd -P' _ "$proj" +done + echo "=== locale (§4.3) ===" check_true "UTF-8 locale active" bash -c '[[ "$(locale charmap)" == "UTF-8" ]]'