From a65ea6673649050f968123b0263040edbce99495 Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Tue, 11 Aug 2026 19:45:55 +0200 Subject: [PATCH] centralise project agent configuration, with a check that it is still read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project configuration moves into the ~/.claude repository, one directory per project, reached from the project through a relative symlink. Relative is what makes a single link text work on both machines: projects sit two levels below home on each, so ../../.claude/project-config/ resolves whether the level above is ~/misc or ~/workspace, with no rewrite step. laptop/adopt-project-config.sh performs the move on the laptop, where the ~/.claude git repository is; an adoption done on the box would be overwritten by the next delivery and could never be committed. It asserts the two-levels-below- home depth rather than trusting it, refuses to merge into an existing directory, and restores the directory in place if the link it creates does not resolve. server/lib/22-claude-config.sh delivers project-config and creates the same links on the box from the delivered payload, so a rebuild reproduces them instead of needing every project re-pushed. Its subject list is the payload itself, never a written-down list, so adopting a project needs no second edit here or in verify.sh. A real .claude holding anything the payload does not is deferred rather than replaced; a box-local settings.local.json is moved into the directory the link points at, since that is where it will be read from. settings.local.json and *.lock are excluded from the delivery. With --delete that does two things at once: it declines to send files the payload should never carry, and it protects the box's own from deletion, since rsync does not delete an excluded file on the receiving side. Two checks, because one cannot do this job. verify.sh asserts the links resolve — structural, cheap, and blind to Claude Code withdrawing support for a symlinked .claude, which would leave every link perfect and every rule unread. server/bin/check-project-config.sh runs a real session against a throwaway project in the same shape and fails loudly if a SessionStart hook does not fire. It is not called from verify.sh: a drift check costing a model call would report an API outage as drift. The behavioural check was wrong first and said so loudly, which is the argument for having built it. It started the session from the repository root rather than the throwaway project, so it reported on a different project and read as the symlink not working. Four control shapes — real directory, absolute link, relative link outside ~/.claude, and the shape actually used — all fire. --- CLAUDE.md | 8 ++ laptop/adopt-project-config.sh | 114 ++++++++++++++++++++++++++++ server/bin/check-project-config.sh | 115 +++++++++++++++++++++++++++++ server/lib/22-claude-config.sh | 96 +++++++++++++++++++++++- sync-design.md | 44 ++++++++--- verify.sh | 27 +++++++ 6 files changed, 392 insertions(+), 12 deletions(-) create mode 100755 laptop/adopt-project-config.sh create mode 100755 server/bin/check-project-config.sh 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" ]]' -- 2.51.2