From 39a5127f78ca3873cd9a3d42991ef6563f0f11eb Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 1 Oct 2026 01:53:13 +0200 Subject: [PATCH] feat(global-settings): derive the update list from settings.json and verify installed files (v2.7.0) check-settings-version.sh: - The UPDATE REQUIRED notice no longer carries a hand-maintained file list. It fetches the canonical settings.json from the same source as VERSION and emits one command block per hook script it registers, plus settings.json first and VERSION last. The list had fallen behind settings.json twice: v2.4.1 added two hooks it had missed, and block-polling.sh, registered since v2.5.0, was never on it, so following the notice left that guard uninstalled while the version read as current. Hook names are validated before they land in a command; if the canonical settings.json cannot be fetched, the list falls back to the installed one and a warning says so. - When the installed version matches the online one, every registered hook is compared with its canonical copy (sha256, trailing newlines ignored: the printf-based install writes exactly one) and settings.json as parsed JSON without `model` (the VSCode picker writes that key under relock option B; a model choice is a preference, not policy). A missing or changed file produces a FILES OUT OF SYNC notice with the same unlock/contract/relock steps and repair blocks for the affected files only; the phrase is "repair my global settings". A canonical copy that cannot be fetched is reported as not verified, never assumed fine. - The status panel gains a Files line (7/7 verified, or the missing and changed counts). - The GitHub path fetches settings.json and then all hooks in one curl call each; the git-fetch path uses git show. The emitted command shapes are unchanged, so block-write-commands.sh allows them as before. README.md and docs/claude/global-claude-settings.md describe the derived list, the integrity check and the new notice. Tests: new tests/test-check-settings-version.sh runs offline against a file:// origin through the git-fetch path and, via a curl shim in PATH, the GitHub path; wired into hook-tests.yml. Suites: check-settings-version 78/78, polling 29/29. ShellCheck clean at all severities. VERSION 2.6.0 -> 2.7.0. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/hook-tests.yml | 3 + docs/claude/global-claude-settings.md | 13 +- global-settings/README.md | 11 + global-settings/VERSION | 2 +- global-settings/check-settings-version.sh | 431 +++++++++++++----- .../tests/test-check-settings-version.sh | 304 ++++++++++++ 6 files changed, 651 insertions(+), 113 deletions(-) create mode 100644 global-settings/tests/test-check-settings-version.sh diff --git a/.github/workflows/hook-tests.yml b/.github/workflows/hook-tests.yml index 6e0739ec1..c9c3fa2e9 100644 --- a/.github/workflows/hook-tests.yml +++ b/.github/workflows/hook-tests.yml @@ -32,3 +32,6 @@ jobs: - name: Run no-polling hook tests run: bash global-settings/tests/test-block-polling.sh + + - name: Run version-check hook tests + run: bash global-settings/tests/test-check-settings-version.sh diff --git a/docs/claude/global-claude-settings.md b/docs/claude/global-claude-settings.md index 93edf007c..be815a535 100644 --- a/docs/claude/global-claude-settings.md +++ b/docs/claude/global-claude-settings.md @@ -71,6 +71,15 @@ The source name is included so a stale mirror is visible at a glance: `GitHub` f The relock step always offers both options, each with its own command, and says why (B) is there. It is the same choice described under [Troubleshooting → keep `settings.json` unlocked](#model-or-the-model-picker-fails-with-eperm-operation-not-permitted-open-claudesettingsjson). Since v2.5.2 it is part of the update message itself, not a note in the docs. +**Files out of sync** (prominently displayed, since v2.7.0): + +> NEW SESSION — GLOBAL CLAUDE SETTINGS: FILES OUT OF SYNC +> Installed: v2.7.0 (matches origin/main), but 1 of 7 installed files does not match the canonical copies +> missing: block-polling.sh +> Say "repair my global settings" to reinstall the listed files. + +The version matched, but a file the canonical `settings.json` registers is missing or differs from its canonical copy. The notice carries the same unlock, contract and relock steps as an update and a command block for each affected file only. See [Which files the notice installs, and what "up to date" means](../../global-settings/README.md#which-files-the-notice-installs-and-what-up-to-date-means-v270) for what is compared and what is deliberately not counted as drift. + **Configuration error** (prominently displayed): > NEW SESSION — GLOBAL CLAUDE SETTINGS: CONFIGURATION ERROR @@ -353,7 +362,9 @@ The phrase must be in the last message the human typed — plain text, text sent - **Git fetch (fallback):** If the GitHub method is not configured or fails, and `~/.claude/settings-repo-path` points to a valid local clone, fetches via `git fetch origin --depth=1` and reads the `VERSION` file from that ref. - Reads the local branch version from `$REPO_DIR/global-settings/VERSION` (if a local repo is configured). - Compares all versions using semver and prints a colored status panel to stderr (visible in the terminal/CLI). -- Always injects a session-start message into Claude's context via stdout — "up to date", "update required", or "configuration error" — which Claude relays at the top of its first response. +- **Derives the managed file list** (since v2.7.0) from the hook scripts the canonical `settings.json` registers, fetched from the same source as `VERSION`; the update notice emits one command block per registered hook, plus `settings.json` first and `VERSION` last. Hook names are validated before they are placed in a command. If the canonical `settings.json` cannot be fetched, the list falls back to the installed one and a warning says so. +- **Verifies the installed files** (since v2.7.0) when the versions match: every registered hook against its canonical copy (`sha256`, trailing newlines ignored) and `settings.json` as parsed JSON without `model`. A missing or changed file produces the "files out of sync" notice with repair blocks for those files only; a canonical copy that could not be fetched is reported as not verified. The panel gains a `Files` line (`7/7 verified ✓`, or the missing/changed counts). +- Always injects a session-start message into Claude's context via stdout — "up to date", "update required", "files out of sync", or "configuration error" — which Claude relays at the top of its first response. - Never silently skips: configuration issues (missing config files, unreachable remote, `curl` not installed) are shown in the panel and forwarded to Claude. ## Optional: notification sounds (opt-in) diff --git a/global-settings/README.md b/global-settings/README.md index d57b8b8a6..b55bcdfb0 100644 --- a/global-settings/README.md +++ b/global-settings/README.md @@ -243,6 +243,17 @@ When you see a version warning at session start: > ⚠️ Don't skip step 4. Without it, the kernel-level protection stays off until the next time you run `sudo chattr +i`. The hooks still defend in depth, but the strongest layer is unarmed. (B) unlocks one file on purpose. Leaving `hooks/*.sh` unlocked as well disarms the guards themselves. +### Which files the notice installs, and what "up to date" means (v2.7.0) + +The file blocks in the notice are not a hand-maintained list. `check-settings-version.sh` reads the canonical `settings.json` from the same source it took `VERSION` from and emits one block per hook script it registers, plus `settings.json` itself first and `VERSION` last. A hook that is wired is therefore a hook that gets installed. Before v2.7.0 the list lived in the script and fell behind `settings.json` twice: v2.4.1 added two hooks it had missed, and `block-polling.sh`, registered since v2.5.0, was never on it, so following the notice left that guard uninstalled while the version read as current. + +When the installed version already matches the online one, the hook also compares every managed file with its canonical copy: + +- hook scripts by `sha256`, ignoring trailing newlines (the `printf '%s\n'` install writes exactly one, the `git show` install copies the bytes verbatim; neither is drift); +- `settings.json` as parsed JSON, without the `model` key. The VSCode model picker writes `{"model": …}` into it under relock option (B), and a model choice is a preference, not policy. Every other difference in that file counts, and the notice names the top-level keys that differ. + +A missing or changed file turns the session-start message into **FILES OUT OF SYNC**. It names the files, the phrase for step 3 is **"repair my global settings"**, and the unlock, contract and relock steps are the same as for an update. Only the affected files are rewritten; `VERSION` is left alone. On the GitHub path the check costs two extra fetches per session (`settings.json`, then every registered hook in one `curl` call); on the git-fetch path it costs none. A canonical file that could not be fetched is reported as not verified, never as fine. + ### The update contract Claude reinstalls each file with `chmod 555` (hooks) or `chmod 444` (`settings-version`) because `block-write-commands.sh` permits only those two modes on protected paths — `chmod 644`, `chmod u+w` and `chattr` are all hard-denied to Claude, by design, so it can never widen its own access. The consequence is the one step 1 covers: read-only mode is *sticky* across updates and only you can clear it. diff --git a/global-settings/VERSION b/global-settings/VERSION index e70b4523a..24ba9a38d 100644 --- a/global-settings/VERSION +++ b/global-settings/VERSION @@ -1 +1 @@ -2.6.0 +2.7.0 diff --git a/global-settings/check-settings-version.sh b/global-settings/check-settings-version.sh index ecf0c9c2d..d622c9a0f 100644 --- a/global-settings/check-settings-version.sh +++ b/global-settings/check-settings-version.sh @@ -4,6 +4,13 @@ # panel with the installed, local-branch, and online (origin/main) versions of # the global Claude settings. Warns clearly when an update is available online. # +# The set of managed files is not a hand-maintained list (v2.7.0): it is derived +# from the hook scripts the canonical settings.json registers, plus settings.json +# itself and VERSION. When the installed version already matches the online one, +# every managed file is also compared with its canonical copy, so a hook that is +# missing or has drifted is reported and repaired instead of passing as +# "up to date" on the strength of the version number alone. +# # Required setup in ~/.claude/: # settings-version — installed semver (e.g. "1.0.0") # settings-repo-url — (optional) GitHub repo slug for online version check @@ -37,6 +44,8 @@ VERSION_FILE="$HOME/.claude/settings-version" validate_ref() { [[ "$1" =~ ^[a-zA-Z0-9._/-]+$ ]]; } validate_repo_slug() { [[ "$1" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]]; } validate_semver() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; } +# Hook basenames end up inside commands Claude runs: a plain name, no leading dot. +validate_hook_name() { [[ "$1" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]*\.sh$ ]]; } # ── timeout wrapper (falls back to direct execution if timeout is missing) ─── run_with_timeout() { @@ -122,6 +131,7 @@ fi local_branch="(unknown)" local_version="(unknown)" git_root="" +rel_base="global-settings" # global-settings/ relative to the repo root (git show needs it) has_local_repo=false if [ -n "$REPO_DIR" ]; then @@ -129,6 +139,10 @@ if [ -n "$REPO_DIR" ]; then git_root=$(git -C "$REPO_DIR" rev-parse --show-toplevel 2>/dev/null) local_branch=$(git -C "$REPO_DIR" branch --show-current 2>/dev/null) [ -z "$local_branch" ] && local_branch="(detached HEAD)" + if [ -n "$git_root" ]; then + _rb=$(realpath --relative-to="$git_root" "$REPO_DIR/global-settings" 2>/dev/null) + [ -n "$_rb" ] && rel_base="$_rb" + fi REPO_VERSION_FILE="$REPO_DIR/global-settings/VERSION" if [ -f "$REPO_VERSION_FILE" ]; then @@ -174,17 +188,15 @@ fi # ── Online version (git fetch — fallback method) ───────────────────────────── if ! $online_fetch_ok && [ -n "$REPO_DIR" ] && [ -n "$git_root" ]; then - rel_path=$(realpath --relative-to="$git_root" "$REPO_DIR/global-settings/VERSION" 2>/dev/null) - if run_with_timeout 5 git -C "$git_root" fetch origin "${tracking_ref}" --quiet --depth=1 2>/dev/null; then - fetched=$(git -C "$git_root" show "origin/${tracking_ref}:${rel_path}" 2>/dev/null | tr -d '[:space:]') + fetched=$(git -C "$git_root" show "origin/${tracking_ref}:${rel_base}/VERSION" 2>/dev/null | tr -d '[:space:]') if [ -n "$fetched" ]; then online_version="$fetched" online_fetch_ok=true online_source="git-fetch" else online_version="(not on remote)" - config_warnings+=("global-settings/VERSION not found on origin/${tracking_ref} (path: ${rel_path}). The canonical global settings may not be committed to this remote — your settings may be outdated.") + config_warnings+=("global-settings/VERSION not found on origin/${tracking_ref} (path: ${rel_base}/VERSION). The canonical global settings may not be committed to this remote — your settings may be outdated.") fi else online_version="(fetch failed)" @@ -197,6 +209,163 @@ if ! $online_fetch_ok && [ -z "$online_repo_slug" ] && [ -z "$REPO_DIR" ]; then config_warnings+=("Neither ~/.claude/settings-repo-url nor ~/.claude/settings-repo-path is configured — cannot check for updates. Your global settings may be outdated.") fi +# ── Canonical copies ───────────────────────────────────────────────────────── +# fetch_canonical ... — writes the canonical copy of each +# global-settings/ to /, from the same source the version +# came from. A file that could not be fetched is left absent or empty; callers +# test each one with `-s`. The GitHub path fetches every name in one curl call +# (one connection, one timeout), so the check costs one round trip, not one per +# file. +fetch_canonical() { + local dest="$1"; shift + local name + case "$online_source" in + github-raw) + local -a args=() + for name in "$@"; do + args+=(-o "${dest}/${name}" "https://raw.githubusercontent.com/${online_repo_slug}/${tracking_ref}/global-settings/${name}") + done + [ ${#args[@]} -gt 0 ] && run_with_timeout 20 curl -fsSL --max-time 20 "${args[@]}" 2>/dev/null + ;; + git-fetch) + for name in "$@"; do + git -C "$git_root" show "origin/${tracking_ref}:${rel_base}/${name}" > "${dest}/${name}" 2>/dev/null \ + || rm -f "${dest}/${name}" + done + ;; + esac + return 0 +} + +canon_dir="" +if $online_fetch_ok; then + canon_dir=$(mktemp -d "${TMPDIR:-/tmp}/claude-settings-canon.XXXXXX" 2>/dev/null) || canon_dir="" + [ -n "$canon_dir" ] && trap 'rm -rf "$canon_dir"' EXIT +fi + +# ── Managed files: derived from the canonical settings.json ────────────────── +# The update notice used to carry a hand-maintained file list, and it fell +# behind settings.json twice: v2.4.1 added two hooks it had missed, and +# block-polling.sh (registered since v2.5.0) was never on it. The list is now +# read from the hook registrations, so a hook that is wired is a hook that gets +# installed. settings.json itself and VERSION are always managed. +# +# hooks_from_settings — the basename of every ~/.claude/hooks/*.sh +# a hook command references, one per line, sorted and de-duplicated. Only the +# hook registrations are read, not the deny list. +hooks_from_settings() { + jq -r '.hooks // {} | .[]? | .[]? | (.hooks // [])[]? | .command? // empty' "$1" 2>/dev/null \ + | grep -oE '\.claude/hooks/[a-zA-Z0-9_.-]+\.sh' \ + | sed 's#.*/##' | sort -u +} + +managed_hooks=() +hooks_source="" # "canonical" | "installed" | "" +if $online_fetch_ok && [ -n "$canon_dir" ]; then + fetch_canonical "$canon_dir" settings.json + if [ -s "$canon_dir/settings.json" ]; then + while IFS= read -r _h; do + [ -n "$_h" ] && managed_hooks+=("$_h") + done < <(hooks_from_settings "$canon_dir/settings.json") + [ ${#managed_hooks[@]} -gt 0 ] && hooks_source="canonical" + fi + if [ -z "$hooks_source" ]; then + config_warnings+=("Could not read the canonical settings.json from origin/${tracking_ref} — the hook list is derived from the installed ~/.claude/settings.json instead and may be incomplete; the installed files were not verified.") + if [ -f "$HOME/.claude/settings.json" ]; then + while IFS= read -r _h; do + [ -n "$_h" ] && managed_hooks+=("$_h") + done < <(hooks_from_settings "$HOME/.claude/settings.json") + [ ${#managed_hooks[@]} -gt 0 ] && hooks_source="installed" + fi + fi +fi +_valid_hooks=() +for _h in "${managed_hooks[@]}"; do + if validate_hook_name "$_h"; then + _valid_hooks+=("$_h") + else + config_warnings+=("Ignoring a hook with an unexpected name in settings.json: '${_h}'.") + fi +done +managed_hooks=("${_valid_hooks[@]}") + +# ── Integrity: do the installed files match the canonical ones? ────────────── +# Only when the installed version equals the online one: an outdated install +# gets the full update anyway, and without an online source there is nothing to +# compare against. VERSION is covered by the version compare itself. +integrity_ran=false +integrity_checked=0 +files_missing=() +files_changed=() # "name" or "name (detail)" +files_unverified=() + +# norm_sha — sha256 of the file with its trailing newlines stripped. The +# curl-based install writes `printf '%s\n' "$content"`, which collapses the +# trailing newlines of the canonical file to exactly one, while the git-based +# install copies the bytes verbatim. Neither may count as drift. +norm_sha() { + if command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$(cat "$1")" | sha256sum | cut -d' ' -f1 + else + printf '%s' "$(cat "$1")" | shasum -a 256 | cut -d' ' -f1 + fi +} + +# settings_diff — empty when both settings.json files are +# the same document, otherwise the top-level keys that differ. Compared as parsed +# JSON (formatting is not drift) and without `model`: the VSCode extension +# writes {"model": …} into ~/.claude/settings.json on every model switch, which +# is exactly what relock option (B) allows, and a model choice is a preference, +# not policy (README → Updating, step 4). Everything else in that file is. +settings_diff() { + local a b keys + a=$(jq -S 'del(.model)' "$1" 2>/dev/null) || { echo "not valid JSON"; return; } + b=$(jq -S 'del(.model)' "$2" 2>/dev/null) || { echo "canonical copy not valid JSON"; return; } + [ "$a" = "$b" ] && return + keys=$(jq -n -r --argjson a "$a" --argjson b "$b" \ + '[($a | keys[]), ($b | keys[])] | unique | map(select($a[.] != $b[.])) | join(", ")' 2>/dev/null) + echo "${keys:-content differs}" +} + +if $online_fetch_ok && $installed_ok && semver_eq "$online_version" "$installed_version" \ + && [ "$hooks_source" = "canonical" ]; then + integrity_ran=true + fetch_canonical "$canon_dir" "${managed_hooks[@]}" + + integrity_checked=$((integrity_checked + 1)) + if [ ! -f "$HOME/.claude/settings.json" ]; then + files_missing+=("settings.json") + else + _d=$(settings_diff "$HOME/.claude/settings.json" "$canon_dir/settings.json") + [ -n "$_d" ] && files_changed+=("settings.json (top-level keys: ${_d})") + fi + + for _h in "${managed_hooks[@]}"; do + integrity_checked=$((integrity_checked + 1)) + if [ ! -s "$canon_dir/$_h" ]; then + files_unverified+=("$_h") + elif [ ! -f "$HOME/.claude/hooks/$_h" ]; then + files_missing+=("$_h") + elif [ "$(norm_sha "$HOME/.claude/hooks/$_h")" != "$(norm_sha "$canon_dir/$_h")" ]; then + files_changed+=("$_h") + fi + done + + if [ ${#files_unverified[@]} -gt 0 ]; then + config_warnings+=("Could not fetch the canonical copy of: ${files_unverified[*]} — these installed files were not verified.") + fi +fi +integrity_issues=$(( ${#files_missing[@]} + ${#files_changed[@]} )) + +# needs_repair — is in files_missing or files_changed? +needs_repair() { + local e + for e in "${files_missing[@]}" "${files_changed[@]}"; do + [ "${e%% (*}" = "$1" ] && return 0 + done + return 1 +} + # ── Color: installed ────────────────────────────────────────────────────────── if ! $installed_ok; then ic="${RED}" ii="✗" @@ -273,6 +442,18 @@ fi fi printf " ${BOLD}%-11s${NC}: ${oc}v%s${NC}${online_label}\n" \ "Online" "$online_version" + if $integrity_ran; then + if [ "$integrity_issues" -gt 0 ]; then + printf " ${BOLD}%-11s${NC}: ${RED}%d missing, %d changed ✗${NC}\n" \ + "Files" "${#files_missing[@]}" "${#files_changed[@]}" + elif [ ${#files_unverified[@]} -gt 0 ]; then + printf " ${BOLD}%-11s${NC}: ${YELLOW}%d/%d verified, %d not verified ?${NC}\n" \ + "Files" "$((integrity_checked - ${#files_unverified[@]}))" "$integrity_checked" "${#files_unverified[@]}" + else + printf " ${BOLD}%-11s${NC}: ${GREEN}%d/%d verified ✓${NC}\n" \ + "Files" "$integrity_checked" "$integrity_checked" + fi + fi if [ ${#config_warnings[@]} -gt 0 ]; then echo "" @@ -283,38 +464,16 @@ fi echo "" } >&2 -# ── Stdout context injected into Claude's prompt ────────────────────────────── - -# Config problems — must be very prominent in chat -if [ ${#config_warnings[@]} -gt 0 ]; then - echo "==========================================" - echo "🚨 NEW SESSION — GLOBAL CLAUDE SETTINGS: CONFIGURATION ERROR" - echo "==========================================" - echo "" - echo "One or more configuration issues were found. The settings version check could not complete." - echo "" - for w in "${config_warnings[@]}"; do - echo " ❌ ${w}" - done - echo "" - echo "INSTRUCTION FOR CLAUDE: At the very start of your response, mention that a new session has" - echo "started and that you checked the Global Claude Settings. Then immediately display the error" - echo "block prominently — use bold text and/or a code block so the user cannot overlook it." - echo "Suggest they check ~/.claude/settings-repo-url, ~/.claude/settings-repo-path, and ~/.claude/settings-version." - echo "==========================================" - echo "" -fi +# ── Emitters for the stdout notices ────────────────────────────────────────── +# One notice for an outdated install (every managed file) and one for an install +# whose version matches but whose files do not (only the affected files). They +# share the unlock/relock steps, the contract and the per-file command shape. +# That shape is what block-write-commands.sh allows — the literal canonical URL +# or the literal 'origin/main:' path in the same command — so it lives in one +# place. -# Online update available — must be very prominent in chat -if $online_fetch_ok && semver_gt "$online_version" "$installed_version"; then - echo "==========================================" - echo "⚠️ NEW SESSION — GLOBAL CLAUDE SETTINGS: UPDATE REQUIRED" - echo "==========================================" - echo "" - echo " Installed : v${installed_version} ❌ (outdated)" - echo " Latest : v${online_version} ✅ (on origin/${tracking_ref})" - echo "" - echo " To apply the update (v1.7.0+ — kernel-level chattr +i lock):" +emit_unlock_steps() { # $1 = "the update" | "the repair", $2 = the phrase the user says in step 3 + echo " To apply $1 (v1.7.0+ — kernel-level chattr +i lock):" echo " 1. First, in your own terminal (not through Claude), unlock the files. TWO things need" echo " clearing — the kernel immutable flag AND the read-only file mode the previous update" echo " left behind (Claude installs the hooks as 555 and settings-version as 444, and is" @@ -326,7 +485,7 @@ if $online_fetch_ok && semver_gt "$online_version" "$installed_version"; then echo " 2. Verify BOTH cleared — no 'i' in the lsattr flags, and a 'w' in the owner bits:" echo " lsattr \$HOME/.claude/settings.json \$HOME/.claude/hooks/*.sh \$HOME/.claude/settings-version" echo " ls -l \$HOME/.claude/settings.json \$HOME/.claude/hooks/*.sh \$HOME/.claude/settings-version" - echo " 3. Then say: \"update my global settings to ${online_version}\"" + echo " 3. Then say: \"$2\"" echo " 4. After Claude finishes, re-apply the immutable lock in your own terminal. Pick ONE:" echo " (A) Full lock — every file, all four protection layers (strongest):" echo " sudo chattr +i \$HOME/.claude/settings.json \$HOME/.claude/hooks/*.sh \$HOME/.claude/settings-version" @@ -341,8 +500,9 @@ if $online_fetch_ok && semver_gt "$online_version" "$installed_version"; then echo " processes running as you), and one write that slips past them can unregister them." echo " A limited but real risk. (A) plus a project-local model pin keeps all four layers and" echo " stays the recommendation: docs/claude/global-claude-settings.md#troubleshooting." +} - echo "" +emit_contract() { echo " CONTRACT (must be respected by every emitted command, or block-write-commands.sh will deny it):" echo " (a) Every curl write must use the literal prefix 'https://raw.githubusercontent.com/ConductionNL/.github/'" echo " in the same command (no variable indirection for the URL). Every git-show write" @@ -360,91 +520,62 @@ if $online_fetch_ok && semver_gt "$online_version" "$installed_version"; then echo " you get via git-fetch reflects main — merge your branch to main first, or use the" echo " GitHub raw path (which uses the branch ref directly) by ensuring" echo " ~/.claude/settings-repo-url is set." - echo "" +} + +# emit_file_blocks ... — one command block per managed file, in the order +# given. settings.json goes to ~/.claude/settings.json, VERSION to +# ~/.claude/settings-version (444), anything else to ~/.claude/hooks/ (555). +# Callers put VERSION last so the version bump only lands if every other write +# succeeded. +emit_file_blocks() { + local name target mode first_hook=true if [ "$online_source" = "github-raw" ]; then echo " When they do, run each block below (one per file) to pull files directly from GitHub" echo " (${online_repo_slug}, branch: ${tracking_ref}). URLs are inlined so the hook can verify the canonical source:" - echo "" - echo " # settings.json" - echo " content=\$(curl -fsSL --max-time 10 'https://raw.githubusercontent.com/${online_repo_slug}/${tracking_ref}/global-settings/settings.json')" - printf '%s\n' " printf '%s\\n' \"\$content\" > \"\$HOME/.claude/settings.json\"" - echo "" - echo " # block-write-commands.sh" - echo " mkdir -p ~/.claude/hooks" - echo " content=\$(curl -fsSL --max-time 10 'https://raw.githubusercontent.com/${online_repo_slug}/${tracking_ref}/global-settings/block-write-commands.sh')" - printf '%s\n' " printf '%s\\n' \"\$content\" > \"\$HOME/.claude/hooks/block-write-commands.sh\"" - echo " chmod 555 \"\$HOME/.claude/hooks/block-write-commands.sh\"" - echo "" - echo " # check-settings-version.sh" - echo " content=\$(curl -fsSL --max-time 10 'https://raw.githubusercontent.com/${online_repo_slug}/${tracking_ref}/global-settings/check-settings-version.sh')" - printf '%s\n' " printf '%s\\n' \"\$content\" > \"\$HOME/.claude/hooks/check-settings-version.sh\"" - echo " chmod 555 \"\$HOME/.claude/hooks/check-settings-version.sh\"" - echo "" - echo " # block-config-tool-writes.sh (added in v1.7.0)" - echo " content=\$(curl -fsSL --max-time 10 'https://raw.githubusercontent.com/${online_repo_slug}/${tracking_ref}/global-settings/block-config-tool-writes.sh')" - printf '%s\n' " printf '%s\\n' \"\$content\" > \"\$HOME/.claude/hooks/block-config-tool-writes.sh\"" - echo " chmod 555 \"\$HOME/.claude/hooks/block-config-tool-writes.sh\"" - echo "" - echo " # sound-notify.sh (added in v2.2.0)" - echo " content=\$(curl -fsSL --max-time 10 'https://raw.githubusercontent.com/${online_repo_slug}/${tracking_ref}/global-settings/sound-notify.sh')" - printf '%s\n' " printf '%s\\n' \"\$content\" > \"\$HOME/.claude/hooks/sound-notify.sh\"" - echo " chmod 555 \"\$HOME/.claude/hooks/sound-notify.sh\"" - echo "" - echo " # user-hooks-dispatch.sh (added in v2.4.0)" - echo " content=\$(curl -fsSL --max-time 10 'https://raw.githubusercontent.com/${online_repo_slug}/${tracking_ref}/global-settings/user-hooks-dispatch.sh')" - printf '%s\n' " printf '%s\\n' \"\$content\" > \"\$HOME/.claude/hooks/user-hooks-dispatch.sh\"" - echo " chmod 555 \"\$HOME/.claude/hooks/user-hooks-dispatch.sh\"" - echo "" - echo " # VERSION → settings-version (write last, so the version bump only lands if all files succeeded)" - echo " content=\$(curl -fsSL --max-time 10 'https://raw.githubusercontent.com/${online_repo_slug}/${tracking_ref}/global-settings/VERSION')" - printf '%s\n' " printf '%s\\n' \"\$content\" > \"\$HOME/.claude/settings-version\"" - echo " chmod 444 \"\$HOME/.claude/settings-version\"" - echo "" - echo " This pulls files directly from GitHub (branch: ${tracking_ref}) — no local repo clone needed." else echo " When they do, run each block below (one per file) to pull files directly from origin/${tracking_ref}" echo " (not the local branch). Paths are inlined so the hook can verify the canonical source:" - _git_root_resolved=$(git -C "${REPO_DIR}" rev-parse --show-toplevel 2>/dev/null) - _rel_base=$(realpath --relative-to="${_git_root_resolved:-${REPO_DIR}}" "${REPO_DIR}/global-settings" 2>/dev/null) - [ -z "$_rel_base" ] && _rel_base="global-settings" echo "" echo " # Refresh origin/${tracking_ref} once before the copies below" echo " git -C '${REPO_DIR}' fetch origin '${tracking_ref}' --depth=1" + fi + for name in "$@"; do echo "" - echo " # settings.json" - echo " git -C '${REPO_DIR}' show 'origin/main:${_rel_base}/settings.json' > \"\$HOME/.claude/settings.json\"" - echo "" - echo " # block-write-commands.sh" - echo " mkdir -p ~/.claude/hooks" - echo " git -C '${REPO_DIR}' show 'origin/main:${_rel_base}/block-write-commands.sh' > \"\$HOME/.claude/hooks/block-write-commands.sh\"" - echo " chmod 555 \"\$HOME/.claude/hooks/block-write-commands.sh\"" - echo "" - echo " # check-settings-version.sh" - echo " git -C '${REPO_DIR}' show 'origin/main:${_rel_base}/check-settings-version.sh' > \"\$HOME/.claude/hooks/check-settings-version.sh\"" - echo " chmod 555 \"\$HOME/.claude/hooks/check-settings-version.sh\"" - echo "" - echo " # block-config-tool-writes.sh (added in v1.7.0)" - echo " git -C '${REPO_DIR}' show 'origin/main:${_rel_base}/block-config-tool-writes.sh' > \"\$HOME/.claude/hooks/block-config-tool-writes.sh\"" - echo " chmod 555 \"\$HOME/.claude/hooks/block-config-tool-writes.sh\"" - echo "" - echo " # sound-notify.sh (added in v2.2.0)" - echo " git -C '${REPO_DIR}' show 'origin/main:${_rel_base}/sound-notify.sh' > \"\$HOME/.claude/hooks/sound-notify.sh\"" - echo " chmod 555 \"\$HOME/.claude/hooks/sound-notify.sh\"" - echo "" - echo " # user-hooks-dispatch.sh (added in v2.4.0)" - echo " git -C '${REPO_DIR}' show 'origin/main:${_rel_base}/user-hooks-dispatch.sh' > \"\$HOME/.claude/hooks/user-hooks-dispatch.sh\"" - echo " chmod 555 \"\$HOME/.claude/hooks/user-hooks-dispatch.sh\"" - echo "" - echo " # VERSION → settings-version (write last)" - echo " git -C '${REPO_DIR}' show 'origin/main:${_rel_base}/VERSION' > \"\$HOME/.claude/settings-version\"" - echo " chmod 444 \"\$HOME/.claude/settings-version\"" - echo "" + case "$name" in + settings.json) + target="\$HOME/.claude/settings.json"; mode="" + echo " # settings.json" ;; + VERSION) + target="\$HOME/.claude/settings-version"; mode="444" + echo " # VERSION → settings-version (write last, so the version bump only lands if all files succeeded)" ;; + *) + target="\$HOME/.claude/hooks/${name}"; mode="555" + echo " # ${name}" + if $first_hook; then + echo " mkdir -p ~/.claude/hooks" + first_hook=false + fi ;; + esac + if [ "$online_source" = "github-raw" ]; then + echo " content=\$(curl -fsSL --max-time 10 'https://raw.githubusercontent.com/${online_repo_slug}/${tracking_ref}/global-settings/${name}')" + printf '%s\n' " printf '%s\\n' \"\$content\" > \"${target}\"" + else + echo " git -C '${REPO_DIR}' show 'origin/main:${rel_base}/${name}' > \"${target}\"" + fi + [ -n "$mode" ] && echo " chmod ${mode} \"${target}\"" + done + echo "" + if [ "$online_source" = "github-raw" ]; then + echo " This pulls files directly from GitHub (branch: ${tracking_ref}) — no local repo clone needed." + else echo " Note: the hook only accepts 'origin/main:' (not 'origin/${tracking_ref}:') for git-show writes." echo " Do NOT copy from the local working tree — always use the origin/main: ref." fi - echo "" +} + +emit_claude_instruction() { # $1 = "the update" | "the repair" echo "INSTRUCTION FOR CLAUDE: At the very start of your response, mention that a new session has" - echo "started and that you checked the Global Claude Settings. Then immediately display the update" + echo "started and that you checked the Global Claude Settings. Then immediately display the" echo "notice as a clearly visible warning block — bold, a header, or a highlighted quote block." echo "The user must not be able to overlook it. Do not bury it in the middle of your response." echo "Also remind the user that the unlock is TWO commands — 'sudo chattr -i' for the kernel flag" @@ -459,18 +590,96 @@ if $online_fetch_ok && semver_gt "$online_version" "$installed_version"; then echo "pick for them and do not shrink (B) to a side note. Claude can run neither 'sudo chattr' nor a" echo "write-enabling chmod on these files (both hard-blocked); when a write fails with 'Permission" echo "denied', stop and ask the user for the chmod rather than working around it." - echo "When you proceed with the update, emit each file's command block as a separate Bash call, verbatim" + echo "When you proceed with $1, emit each file's command block as a separate Bash call, verbatim" echo "from the blocks above — do not introduce loops, variables for the repo slug or GitHub URL, or chmod 644/chattr." +} + +# ── Stdout context injected into Claude's prompt ────────────────────────────── + +# Config problems — must be very prominent in chat +if [ ${#config_warnings[@]} -gt 0 ]; then + echo "==========================================" + echo "🚨 NEW SESSION — GLOBAL CLAUDE SETTINGS: CONFIGURATION ERROR" + echo "==========================================" + echo "" + echo "One or more configuration issues were found. The settings version check could not complete." + echo "" + for w in "${config_warnings[@]}"; do + echo " ❌ ${w}" + done + echo "" + echo "INSTRUCTION FOR CLAUDE: At the very start of your response, mention that a new session has" + echo "started and that you checked the Global Claude Settings. Then immediately display the error" + echo "block prominently — use bold text and/or a code block so the user cannot overlook it." + echo "Suggest they check ~/.claude/settings-repo-url, ~/.claude/settings-repo-path, and ~/.claude/settings-version." + echo "==========================================" + echo "" +fi + +# Online update available — must be very prominent in chat +if $online_fetch_ok && semver_gt "$online_version" "$installed_version"; then + echo "==========================================" + echo "⚠️ NEW SESSION — GLOBAL CLAUDE SETTINGS: UPDATE REQUIRED" + echo "==========================================" + echo "" + echo " Installed : v${installed_version} ❌ (outdated)" + echo " Latest : v${online_version} ✅ (on origin/${tracking_ref})" + echo "" + emit_unlock_steps "the update" "update my global settings to ${online_version}" + echo "" + emit_contract + echo "" + emit_file_blocks settings.json "${managed_hooks[@]}" VERSION + echo "" + emit_claude_instruction "the update" + echo "==========================================" + echo "" + +# Version matches, files do not — just as prominent, repairs only what differs +elif $integrity_ran && [ "$integrity_issues" -gt 0 ]; then + echo "==========================================" + echo "⚠️ NEW SESSION — GLOBAL CLAUDE SETTINGS: FILES OUT OF SYNC" + echo "==========================================" + echo "" + echo " Installed : v${installed_version} ✅ (matches origin/${tracking_ref})" + echo " Files : ${integrity_issues} of ${integrity_checked} installed files do not match the canonical v${installed_version} copies ❌" + for f in "${files_missing[@]}"; do echo " ❌ missing : ${f}"; done + for f in "${files_changed[@]}"; do echo " ❌ changed : ${f}"; done + echo "" + echo " A hook that settings.json registers but that is missing never runs, so its guard is off;" + echo " a changed file is not the reviewed version. The version number alone does not show this," + echo " which is why the files themselves are checked. Only the files listed above are rewritten." + echo "" + emit_unlock_steps "the repair" "repair my global settings" + echo "" + emit_contract + echo "" + _repair=() + needs_repair settings.json && _repair+=(settings.json) + for _h in "${managed_hooks[@]}"; do + needs_repair "$_h" && _repair+=("$_h") + done + emit_file_blocks "${_repair[@]}" + echo "" + emit_claude_instruction "the repair" echo "==========================================" echo "" # Settings up to date — brief session-start acknowledgement elif $online_fetch_ok && semver_eq "$online_version" "$installed_version"; then + _files_note="" + if $integrity_ran; then + if [ ${#files_unverified[@]} -eq 0 ]; then + _files_note=" All ${integrity_checked} installed files match the canonical copies." + else + _files_note=" ${#files_unverified[@]} of ${integrity_checked} installed files could not be verified (see the configuration warning above)." + fi + fi if [ -n "$online_source_name" ]; then - echo "NEW SESSION — Global Claude Settings checked. ✅ Settings are up to date (v${installed_version}, via ${online_source_name})." + echo "NEW SESSION — Global Claude Settings checked. ✅ Settings are up to date (v${installed_version}, via ${online_source_name}).${_files_note}" echo "Briefly mention at the start of your response that a new session has started, that the global Claude settings are up to date, and name the source (${online_source_name})." else - echo "NEW SESSION — Global Claude Settings checked. ✅ Settings are up to date (v${installed_version})." + echo "NEW SESSION — Global Claude Settings checked. ✅ Settings are up to date (v${installed_version}).${_files_note}" echo "Briefly mention at the start of your response that a new session has started and the global Claude settings are up to date." fi echo "" diff --git a/global-settings/tests/test-check-settings-version.sh b/global-settings/tests/test-check-settings-version.sh new file mode 100644 index 000000000..11dffc55c --- /dev/null +++ b/global-settings/tests/test-check-settings-version.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # literal $HOME tokens in expected output are fixtures, not expansions +# test-check-settings-version.sh — offline test harness for check-settings-version.sh. +# +# Builds a throwaway canonical repo (a file:// origin holding this directory's +# global-settings/), a clone the hook can `git fetch` from, and a fake home with +# an installed copy. The first part runs without settings-repo-url, so the hook +# takes the git-fetch path; the second part configures it and puts a curl shim +# in PATH that serves the same canonical files, so the GitHub path is exercised +# too. Nothing touches the network. Asserts: +# - the update notice lists every hook the canonical settings.json registers, +# derived rather than hand-maintained: a hook registered only in +# settings.json appears without any change to the hook script itself +# - a matching install is reported up to date, with every file verified +# - a missing or changed hook, or a changed settings.json, is reported +# FILES OUT OF SYNC with repair blocks for those files only +# - a settings.json that only gained `model` (the VSCode picker's write under +# relock option B) and a trailing-newline difference are not drift +# +# Usage: +# ./tests/test-check-settings-version.sh # run all +# ./tests/test-check-settings-version.sh -v # verbose +# HOOK=/path/to/hook.sh ./tests/test-check-settings-version.sh +# +# Exit code: 0 if all tests pass, 1 if any fail. The harness carries its own +# positive control: it first proves the hook reports an install pinned to v0.0.1 +# as outdated, so an unreadable or empty hook cannot read as green. + +set -u +SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)" # the global-settings/ under test +HOOK="${HOOK:-$SRC_DIR/check-settings-version.sh}" +VERBOSE=0; [[ "${1:-}" == "-v" ]] && VERBOSE=1 + +for tool in git jq realpath md5sum; do + command -v "$tool" >/dev/null 2>&1 || { echo "ERROR: $tool is required" >&2; exit 1; } +done +if [[ ! -r "$HOOK" ]]; then + echo "ERROR: hook not found or unreadable: $HOOK" >&2 + exit 1 +fi + +# ── fixture: canonical origin, clone, fake home ────────────────────────────── +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +FAKE_HOME="$TMP/home" +CLAUDE_DIR="$FAKE_HOME/.claude" +HOOKS_DIR="$CLAUDE_DIR/hooks" +CANON="$TMP/canon"; CANON_GS="$CANON/global-settings"; CLONE="$TMP/clone" +mkdir -p "$HOOKS_DIR" "$TMP/run" "$CANON_GS" + +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.invalid +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.invalid + +cp -R "$SRC_DIR"/. "$CANON_GS/" +rm -rf "$CANON_GS/tests" +git -C "$CANON" init -q +git -C "$CANON" symbolic-ref HEAD refs/heads/main +git -C "$CANON" add -A +git -C "$CANON" commit -q -m "canonical global settings" +git clone -q "file://$CANON" "$CLONE" +echo "$CLONE" > "$CLAUDE_DIR/settings-repo-path" + +# canon_hooks — the hook basenames the canonical settings.json registers. +canon_hooks() { + jq -r '.hooks // {} | .[]? | .[]? | (.hooks // [])[]? | .command? // empty' "$CANON_GS/settings.json" \ + | grep -oE '\.claude/hooks/[a-zA-Z0-9_.-]+\.sh' | sed 's#.*/##' | sort -u +} + +# install_all — a fresh install of the canonical files into the fake home, the +# way the README's Install step does it. +install_all() { + rm -rf "$HOOKS_DIR"; mkdir -p "$HOOKS_DIR" + cp "$CANON_GS/settings.json" "$CLAUDE_DIR/settings.json" + local h + for h in $(canon_hooks); do cp "$CANON_GS/$h" "$HOOKS_DIR/$h"; done + cp "$CANON_GS/VERSION" "$CLAUDE_DIR/settings-version" +} + +# run_hook — runs the hook against the fake home; stdout in $OUT, stderr in $ERR. +# A fresh transcript path per run defeats the session-once guard. RUN_PATH is +# the PATH the hook sees; the GitHub part prepends the curl shim to it. +n=0 +RUN_PATH="$PATH" +run_hook() { + n=$((n + 1)) + OUT=$(printf '{"transcript_path":"%s/transcript-%d.jsonl"}' "$TMP" "$n" \ + | HOME="$FAKE_HOME" XDG_RUNTIME_DIR="$TMP/run" PATH="$RUN_PATH" bash "$HOOK" 2>"$TMP/stderr") + ERR=$(cat "$TMP/stderr") +} + +pass=0; fail=0; details=() +check() { # $1 = description, $2 = exit code of the assertion (0 = pass) + if [[ "$2" -eq 0 ]]; then + pass=$((pass + 1)); [[ $VERBOSE -eq 1 ]] && echo "ok $1" + else + fail=$((fail + 1)); details+=("$1") + fi +} +has() { grep -qF -- "$1" <<<"$OUT"; } +lacks() { ! grep -qF -- "$1" <<<"$OUT"; } +eq() { [[ "$1" == "$2" ]]; } +# fixture_restored — the canonical repo is back to this directory's contents +fixture_restored() { + [[ "$(cat "$CANON_GS/VERSION")" == "$(cat "$SRC_DIR/VERSION")" && ! -e "$CANON_GS/extra-guard.sh" ]] +} +# block_for — the fetch line of the update/repair block for , in the +# shape of the path under test (git show, or the literal canonical URL). +MODE=git +block_for() { + if [[ "$MODE" == raw ]]; then + has "content=\$(curl -fsSL --max-time 10 'https://raw.githubusercontent.com/ConductionNL/.github/main/global-settings/$1')" + else + has "show 'origin/main:global-settings/$1' >" + fi +} +# last_fetched — the name in the last update/repair block; VERSION must be last. +last_fetched() { + if [[ "$MODE" == raw ]]; then + grep -oE "/global-settings/[A-Za-z0-9_.-]+'\)" <<<"$OUT" | tail -1 | sed -E "s#/global-settings/([^']+)'\)#\1#" + else + grep -oE "origin/main:global-settings/[A-Za-z0-9_.-]+" <<<"$OUT" | tail -1 | sed 's#.*/##' + fi +} + +# ── positive control ───────────────────────────────────────────────────────── +install_all +echo 0.0.1 > "$CLAUDE_DIR/settings-version" +run_hook +if ! has 'UPDATE REQUIRED'; then + echo "POSITIVE CONTROL FAILED: an install pinned to v0.0.1 was not reported as outdated" >&2 + echo "--- stdout ---"; echo "$OUT"; echo "--- stderr ---"; echo "$ERR" + exit 1 +fi + +# ── 1. the update notice lists every registered hook, and VERSION last ─────── +for h in block-polling.sh block-write-commands.sh check-settings-version.sh \ + block-config-tool-writes.sh sound-notify.sh user-hooks-dispatch.sh; do + canon_hooks | grep -qx "$h"; check "canonical settings.json registers $h (extraction sanity)" $? +done +block_for settings.json; check "update: block for settings.json" $? +for h in $(canon_hooks); do + block_for "$h"; check "update: block for registered hook $h" $? +done +block_for VERSION; check "update: block for VERSION" $? +eq "$(last_fetched)" VERSION; check "update: VERSION block is emitted last" $? +has 'Then say: "update my global settings to'; check "update: phrase present" $? +has 'sudo chattr -i'; check "update: unlock steps present" $? +has 'sudo chattr +i'; check "update: relock steps present" $? +has 'mkdir -p ~/.claude/hooks'; check "update: hooks directory is created before the first hook" $? +lacks 'OUT OF SYNC'; check "update: no OUT OF SYNC notice alongside UPDATE REQUIRED" $? + +# ── 2. the list follows settings.json: register a new hook upstream ────────── +printf '#!/bin/bash\nexit 0\n' > "$CANON_GS/extra-guard.sh" +jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":"bash ~/.claude/hooks/extra-guard.sh"}]}]' \ + "$CANON_GS/settings.json" > "$TMP/settings.new" +cat "$TMP/settings.new" > "$CANON_GS/settings.json" +echo 9.9.9 > "$CANON_GS/VERSION" +git -C "$CANON" add -A +git -C "$CANON" commit -q -m "register extra-guard.sh" +run_hook +has 'UPDATE REQUIRED'; check "derived: a bumped canonical VERSION triggers the update notice" $? +block_for extra-guard.sh; check "derived: a hook registered only in settings.json appears in the update list" $? +git -C "$CANON" revert --no-edit HEAD >/dev/null +fixture_restored; check "derived: canonical repo restored for the remaining scenarios (fixture sanity)" $? + +# ── 3. matching install: up to date, every file verified ───────────────────── +install_all +run_hook +has 'Settings are up to date'; check "clean: reported up to date" $? +has 'installed files match the canonical copies'; check "clean: files reported as verified" $? +lacks 'OUT OF SYNC'; check "clean: no OUT OF SYNC notice" $? +lacks 'UPDATE REQUIRED'; check "clean: no UPDATE REQUIRED notice" $? +grep -q 'verified ✓' <<<"$ERR"; check "clean: panel shows files verified" $? + +# ── 4. a registered hook is missing ────────────────────────────────────────── +install_all +rm "$HOOKS_DIR/block-polling.sh" +run_hook +has 'FILES OUT OF SYNC'; check "missing: OUT OF SYNC notice" $? +has 'missing : block-polling.sh'; check "missing: the hook is named" $? +block_for block-polling.sh; check "missing: repair block for the missing hook" $? +lacks "origin/main:global-settings/sound-notify.sh"; check "missing: no block for an intact hook" $? +lacks "origin/main:global-settings/settings.json"; check "missing: no block for an intact settings.json" $? +lacks "origin/main:global-settings/VERSION"; check "missing: VERSION is not rewritten" $? +has 'Then say: "repair my global settings"'; check "missing: repair phrase present" $? +has 'sudo chattr -i'; check "missing: unlock steps present" $? +lacks 'Settings are up to date'; check "missing: not reported up to date" $? +grep -q '1 missing, 0 changed' <<<"$ERR"; check "missing: panel counts it" $? + +# ── 5. a registered hook has changed ───────────────────────────────────────── +install_all +echo '# local edit' >> "$HOOKS_DIR/sound-notify.sh" +run_hook +has 'FILES OUT OF SYNC'; check "changed: OUT OF SYNC notice" $? +has 'changed : sound-notify.sh'; check "changed: the hook is named" $? +block_for sound-notify.sh; check "changed: repair block for the changed hook" $? +lacks "origin/main:global-settings/block-polling.sh"; check "changed: no block for an intact hook" $? + +# ── 6. trailing-newline differences are not drift ──────────────────────────── +install_all +printf '\n\n' >> "$HOOKS_DIR/block-polling.sh" +run_hook +lacks 'OUT OF SYNC'; check "newline: extra trailing newlines are not drift" $? +has 'Settings are up to date'; check "newline: reported up to date" $? + +# ── 7. settings.json with only `model` added is not drift ──────────────────── +install_all +jq '.model = "opus"' "$CANON_GS/settings.json" > "$CLAUDE_DIR/settings.json" # also reformats +run_hook +lacks 'OUT OF SYNC'; check "model: settings.json with only model added is not drift" $? +has 'Settings are up to date'; check "model: reported up to date" $? + +# ── 8. settings.json with a permission change is drift, and names the key ──── +install_all +jq '.permissions.allow += ["Bash(rm -rf /)"]' "$CANON_GS/settings.json" > "$CLAUDE_DIR/settings.json" +run_hook +has 'FILES OUT OF SYNC'; check "permissions: OUT OF SYNC notice" $? +has 'changed : settings.json (top-level keys: permissions)'; check "permissions: the differing key is named" $? +block_for settings.json; check "permissions: repair block for settings.json" $? +lacks "origin/main:global-settings/block-polling.sh"; check "permissions: no block for an intact hook" $? + +# ── 9. settings.json that is not valid JSON is drift ───────────────────────── +install_all +echo '{ "hooks": ' > "$CLAUDE_DIR/settings.json" +run_hook +has 'changed : settings.json (top-level keys: not valid JSON)'; check "broken: invalid JSON is reported as changed" $? +block_for settings.json; check "broken: repair block for settings.json" $? + +# ── GitHub path: the same checks through a curl shim ───────────────────────── +# In the field the GitHub raw URL is the primary source. The shim serves +# https://raw.githubusercontent.com///global-settings/ from +# $CURL_SHIM_ROOT/: a bare URL prints to stdout (the VERSION probe), each +# `-o ` pair writes a file (the one-call multi-file fetch), and a +# name it does not have fails the way -f does: exit 22 and no output file. +mkdir -p "$TMP/bin" "$TMP/shimroot" +cat > "$TMP/bin/curl" <<'SHIM' +#!/usr/bin/env bash +rc=0; dest="" +while [ $# -gt 0 ]; do + case "$1" in + -o) dest="$2"; shift 2; continue ;; + --max-time) shift 2; continue ;; + -*) shift; continue ;; + esac + name="${1##*/}"; src="$CURL_SHIM_ROOT/$name"; shift + if [ ! -f "$src" ]; then rc=22; dest=""; continue; fi + if [ -n "$dest" ]; then cat "$src" > "$dest"; dest=""; else cat "$src"; fi +done +exit $rc +SHIM +chmod +x "$TMP/bin/curl" +cp -R "$CANON_GS"/. "$TMP/shimroot/" +export CURL_SHIM_ROOT="$TMP/shimroot" +echo "ConductionNL/.github" > "$CLAUDE_DIR/settings-repo-url" +RUN_PATH="$TMP/bin:$PATH" +MODE=raw + +# R1. outdated install: every registered hook, curl-shaped, VERSION last +install_all +echo 0.0.1 > "$CLAUDE_DIR/settings-version" +run_hook +has 'UPDATE REQUIRED'; check "raw update: notice present" $? +has 'directly from GitHub'; check "raw update: GitHub path taken" $? +block_for settings.json; check "raw update: block for settings.json" $? +for h in $(canon_hooks); do + block_for "$h"; check "raw update: block for registered hook $h" $? +done +block_for VERSION; check "raw update: block for VERSION" $? +eq "$(last_fetched)" VERSION; check "raw update: VERSION block is emitted last" $? +has "printf '%s\\n' \"\$content\" >"; check "raw update: content is written with printf, not curl -o" $? + +# R2. matching install: up to date via GitHub, every file verified +install_all +run_hook +has 'Settings are up to date'; check "raw clean: reported up to date" $? +has 'via GitHub'; check "raw clean: source named" $? +has 'installed files match the canonical copies'; check "raw clean: files reported as verified" $? +lacks 'OUT OF SYNC'; check "raw clean: no OUT OF SYNC notice" $? +grep -q 'verified ✓' <<<"$ERR"; check "raw clean: panel shows files verified" $? + +# R3. missing hook: repair block for that hook only, curl-shaped +install_all +rm "$HOOKS_DIR/block-polling.sh" +run_hook +has 'FILES OUT OF SYNC'; check "raw missing: OUT OF SYNC notice" $? +has 'missing : block-polling.sh'; check "raw missing: the hook is named" $? +block_for block-polling.sh; check "raw missing: repair block for the missing hook" $? +lacks "global-settings/sound-notify.sh')"; check "raw missing: no block for an intact hook" $? +lacks "global-settings/VERSION')"; check "raw missing: VERSION is not rewritten" $? + +# R4. a canonical copy that cannot be fetched is reported, never assumed fine +install_all +rm "$TMP/shimroot/sound-notify.sh" +run_hook +lacks 'OUT OF SYNC'; check "raw unverified: an unfetchable canonical copy is not drift" $? +has 'could not be verified'; check "raw unverified: the up-to-date message says so" $? +has 'Could not fetch the canonical copy of: sound-notify.sh'; check "raw unverified: the file is named" $? +grep -q 'not verified' <<<"$ERR"; check "raw unverified: panel shows it" $? +cp "$CANON_GS/sound-notify.sh" "$TMP/shimroot/sound-notify.sh" + +# ── summary ────────────────────────────────────────────────────────────────── +echo "check-settings-version: ${pass} passed, ${fail} failed" +for d in "${details[@]}"; do echo " FAIL: $d"; done +[[ $fail -eq 0 ]]