aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile5
-rwxr-xr-xscripts/doctor.sh177
-rw-r--r--todo.org83
3 files changed, 264 insertions, 1 deletions
diff --git a/Makefile b/Makefile
index e6f6f56..c924289 100644
--- a/Makefile
+++ b/Makefile
@@ -62,7 +62,7 @@ endef
.PHONY: help install uninstall list install-hooks uninstall-hooks \
install-lang install-elisp install-python list-languages \
- install-mcp diff lint deps
+ install-mcp diff lint doctor deps
##@ General
@@ -333,3 +333,6 @@ diff: ## Show drift between installed ruleset and repo source ([LANG=<lang>] [PR
lint: ## Validate ruleset structure (headings, Applies-to, shebangs, exec bits)
@bash scripts/lint.sh
+
+doctor: ## Verify ~/.claude/ live state matches repo + settings.json (drift detector)
+ @bash scripts/doctor.sh
diff --git a/scripts/doctor.sh b/scripts/doctor.sh
new file mode 100755
index 0000000..93a4d22
--- /dev/null
+++ b/scripts/doctor.sh
@@ -0,0 +1,177 @@
+#!/usr/bin/env bash
+# doctor.sh — verify ~/.claude/ live state matches rulesets repo + settings.json.
+#
+# Read-only diagnostic. Reports drift line-by-line: ok / WARN / FAIL.
+# Exit 0 on clean, 1 if any FAIL was emitted. Warnings do not block.
+#
+# Run from the repo root via `make doctor`.
+
+set -uo pipefail
+
+REPO="$(cd "$(dirname "$0")/.." && pwd)"
+CLAUDE_DIR="$HOME/.claude"
+SETTINGS="$REPO/.claude/settings.json"
+
+ok_count=0
+warn_count=0
+fail_count=0
+
+ok() { printf ' ok %s\n' "$1"; ok_count=$((ok_count+1)); }
+warn() { printf ' WARN %s\n' "$1"; warn_count=$((warn_count+1)); }
+fail() { printf ' FAIL %s\n' "$1"; fail_count=$((fail_count+1)); }
+
+require() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "ERROR: required tool '$1' not found in PATH" >&2
+ exit 2
+ fi
+}
+
+is_live_symlink() {
+ [ -L "$1" ] && [ -e "$1" ]
+}
+
+# Reports ok / dangling / missing for a single symlink.
+# Args: <label> <link-path> <severity: fail|warn> [<hint>]
+check_symlink() {
+ local label="$1" link="$2" severity="$3" hint="${4:-}"
+ local suffix=""
+ [ -n "$hint" ] && suffix=" ($hint)"
+ if is_live_symlink "$link"; then
+ ok "$label"
+ elif [ -L "$link" ]; then
+ "$severity" "$label: dangling symlink"
+ else
+ "$severity" "$label: not installed$suffix"
+ fi
+}
+
+require jq
+
+# ----- 1. Skills -----
+echo "Skills:"
+for f in "$REPO"/*/SKILL.md; do
+ name="$(basename "$(dirname "$f")")"
+ check_symlink "skill $name" "$CLAUDE_DIR/skills/$name" fail
+done
+
+# ----- 2. Rules -----
+echo
+echo "Rules:"
+for f in "$REPO"/claude-rules/*.md; do
+ name="$(basename "$f")"
+ check_symlink "rule $name" "$CLAUDE_DIR/rules/$name" fail
+done
+
+# ----- 3. Default hooks (warn-only — opt-out is legitimate) -----
+echo
+echo "Default hooks:"
+shopt -s nullglob
+for f in "$REPO"/hooks/*.sh "$REPO"/hooks/*.py; do
+ name="$(basename "$f")"
+ case "$name" in
+ destructive-bash-confirm.py) continue ;; # opt-in
+ esac
+ check_symlink "hook $name" "$CLAUDE_DIR/hooks/$name" warn "run: make install-hooks"
+done
+shopt -u nullglob
+
+# ----- 4. Claude config (settings.json, .mcp.json, commands dir) -----
+echo
+echo "Claude config:"
+shopt -s nullglob dotglob
+for f in "$REPO"/.claude/*.json; do
+ name="$(basename "$f")"
+ case "$name" in
+ settings.local.json) continue ;; # per-machine, gitignored
+ esac
+ check_symlink "config $name" "$CLAUDE_DIR/$name" fail
+done
+shopt -u nullglob dotglob
+
+check_symlink "config commands/" "$CLAUDE_DIR/commands" fail
+
+# ----- 5. Hook commands referenced by settings.json all exist -----
+echo
+echo "Hooks referenced by settings.json:"
+hook_cmds=$(jq -r '
+ [.hooks // {} | to_entries[] | .value[]?.hooks[]?.command] | unique | .[]
+' "$SETTINGS" 2>/dev/null)
+if [ -z "$hook_cmds" ]; then
+ ok "no hooks declared"
+else
+ while IFS= read -r cmd; do
+ expanded="${cmd/#\~/$HOME}"
+ if [ -e "$expanded" ]; then
+ ok "hook reference $cmd"
+ else
+ fail "hook reference $cmd: file not found at $expanded"
+ fi
+ done <<< "$hook_cmds"
+fi
+
+# ----- 6. enabledPlugins each have an install dir under ~/.claude/plugins/data/ -----
+echo
+echo "Plugins:"
+enabled_keys=$(jq -r '
+ .enabledPlugins // {} | to_entries[] | select(.value == true) | .key
+' "$SETTINGS" 2>/dev/null)
+if [ -z "$enabled_keys" ]; then
+ ok "no plugins enabled"
+else
+ while IFS= read -r key; do
+ # key shape: "<plugin>@<marketplace>" → data dir: "<plugin>-<marketplace>"
+ dir_name="${key/@/-}"
+ if [ -d "$CLAUDE_DIR/plugins/data/$dir_name" ]; then
+ ok "plugin $key"
+ else
+ fail "plugin $key: no install dir at plugins/data/$dir_name"
+ fi
+ done <<< "$enabled_keys"
+fi
+
+# ----- 7. MCP drift: servers.json keys vs ~/.claude.json mcpServers keys -----
+echo
+echo "MCP servers (user-scope):"
+SERVERS="$REPO/mcp/servers.json"
+USER_CLAUDE="$HOME/.claude.json"
+if [ ! -f "$SERVERS" ]; then
+ warn "mcp/servers.json missing — skipping drift check"
+elif [ ! -f "$USER_CLAUDE" ]; then
+ fail "~/.claude.json missing — cannot check MCP drift"
+else
+ want=$(jq -r 'keys[]' "$SERVERS" | sort)
+ have=$(jq -r '.mcpServers // {} | keys[]' "$USER_CLAUDE" | sort)
+ while IFS= read -r s; do
+ [ -z "$s" ] && continue
+ if grep -qx "$s" <<< "$have"; then
+ ok "mcp $s"
+ else
+ fail "mcp $s: declared in servers.json but not registered"
+ fi
+ done <<< "$want"
+ while IFS= read -r s; do
+ [ -z "$s" ] && continue
+ if ! grep -qx "$s" <<< "$want"; then
+ warn "mcp $s: registered but not declared in servers.json"
+ fi
+ done <<< "$have"
+fi
+
+# ----- 8. Dangling symlinks anywhere under ~/.claude/ -----
+echo
+echo "Dangling symlinks under ~/.claude/:"
+dangle=0
+while IFS= read -r link; do
+ if [ ! -e "$link" ]; then
+ fail "$link → $(readlink "$link")"
+ dangle=$((dangle+1))
+ fi
+done < <(find "$CLAUDE_DIR" -maxdepth 4 -type l 2>/dev/null)
+[ "$dangle" -eq 0 ] && ok "no dangling symlinks"
+
+# ----- summary -----
+echo
+echo "Summary: $ok_count ok, $warn_count warnings, $fail_count failures"
+[ "$fail_count" -gt 0 ] && exit 1
+exit 0
diff --git a/todo.org b/todo.org
index 763b646..9dda3e8 100644
--- a/todo.org
+++ b/todo.org
@@ -1528,3 +1528,86 @@ removed links if needed (the install loop is idempotent).
accidental select-all).
- =--source= flag that also runs =git rm= against the rulesets source for
the selected item. Probably bad idea — too easy to lose work.
+- The =bridge → $(SKILLS_DIR)/claude-rules= entry above is stale — the
+ bridge symlink got removed in a later commit. Drop that bullet when the
+ recipe lands.
+
+* DONE [#A] Add =make doctor= — verify ~/.claude/ matches repo + settings.json :feature:
+
+A drift detector that scans =~/.claude/= and reports anything inconsistent with what the repo expects. Single-command answer to "is my machine consistent with rulesets?"
+
+** Why this matters
+
+A 2026-05-06 sweep found =~/.claude/hooks/= didn't exist on this machine even though =settings.json= referenced =~/.claude/hooks/precompact-priorities.sh= as a PreCompact hook. Compaction would have silently failed to invoke the hook. The fix was =make install-hooks=, but the breakage was invisible until I happened to grep for it. =make doctor= run regularly (or even as part of session start) would catch this kind of drift in seconds instead of after the fact.
+
+** Checks
+
+- Every entry in =settings.json= ="hooks"= block points at a file that exists.
+- Every entry in =enabledPlugins= has a matching install under =~/.claude/plugins/data/=.
+- Every skill in =$(SKILLS)= has a working symlink at =~/.claude/skills/<name>=.
+- Every rule in =$(RULES)= has a working symlink at =~/.claude/rules/<name>=.
+- Every default hook has a symlink at =~/.claude/hooks/<name>= (warn-only — opt-out is legitimate).
+- =settings.json= and =.mcp.json= symlinks resolve to the rulesets versions.
+- =mcp/install.py= state matches =claude mcp list= (every server in =servers.json= is registered).
+- No dangling symlinks anywhere under =~/.claude/=.
+
+** Output
+
+One line per check: =ok= / =WARN= / =FAIL=. Final summary: =N ok, M warnings, K failures=. Exit non-zero on any failure so it can ride a pre-flight check.
+
+* TODO [#B] Document the =mcp/= install pipeline in =mcp/README.org=
+
+=mcp/= has =install.py=, =servers.json=, =secrets.env.gpg=, =gcp-oauth.keys.json= (gitignored, regenerated at install). No README. Coming back to this in three months I'll re-discover how the bundle is structured, what =install.py= does, and how to rotate tokens. Saving that re-discovery is the whole point.
+
+** What to cover
+
+- Layout: what each file is, which are tracked vs gitignored.
+- Secrets bundle shape: how vars are listed in =secrets.env=, the symmetric-encryption pattern (=gpg -c --cipher-algo AES256=), the base64-bundled OAuth artifacts (=GCP_OAUTH_KEYS_JSON_B64=, =GOOGLE_DOCS_PERSONAL_TOKEN_B64=, =GOOGLE_DOCS_WORK_TOKEN_B64=).
+- Install flow: =make install-mcp= → =install.py= decrypts, writes the keys file and Google Docs token caches at mode 600, expands =${VAR}= in =servers.json=, calls =claude mcp add --scope user= for unregistered servers. Idempotent.
+- Token rotation: when a refresh token gets revoked, the recovery flow (re-auth on one machine, re-bundle, recommit).
+- Adding a new server: edit =servers.json=, add any new =${VAR}= placeholders to the bundle, re-encrypt.
+- The OAuth dance for HTTP-transport servers (linear, notion) versus stdio (google-docs-*) — different paths, different gotchas.
+
+* TODO [#C] Add =make uninstall-mcp= + =mcp/install.py --check= for symmetry
+
+Currently the MCP install pipeline only flows one direction. No way to remove rulesets-managed MCP servers in one command. No way to ask "what's the drift between =servers.json= and =claude mcp list=" without eyeballing.
+
+** =make uninstall-mcp=
+
+Iterate over =servers.json=, run =claude mcp remove <name> -s user= for each. Ignore "not registered" errors. Idempotent.
+
+** =mcp/install.py --check=
+
+Dry-run mode. Decrypt secrets, but instead of registering, print the drift report:
+
+- Servers in =servers.json= not in =claude mcp list= → =MISSING=
+- Servers in =claude mcp list= not in =servers.json= → =EXTRA=
+- Servers in both → =ok=
+
+Useful for diagnosing connection failures and for the eventual =make doctor= integration.
+
+* TODO [#C] Update =README.org= with MCP install pipeline section
+
+=README.org= covers global install, per-project language bundles, and design principles, but doesn't mention =make install-mcp= or the =mcp/= directory. Add a short section after "Per-project language bundles" describing the user-scope MCP install pattern (decrypt → expand → register) and pointing at the eventual =mcp/README.org=.
+
+* TODO [#C] Token-rotation helper for =@a-bonus/google-docs-mcp= OAuth refresh
+
+When a Google refresh token gets revoked (re-grant scopes, removed Connected App, account password reset), recovery is currently manual: run =npx -y @a-bonus/google-docs-mcp= with the right env, follow the URL in a browser, kill the process, base64-encode the new =token.json=, decrypt =secrets.env.gpg=, replace the var, re-encrypt. A small =mcp/refresh-google-docs-token.sh <profile>= would chain that into one command.
+
+** Sketch
+
+#+begin_src bash
+# usage: mcp/refresh-google-docs-token.sh personal
+profile="$1"
+gpg -d ... | grep -v "GOOGLE_DOCS_${profile^^}_TOKEN_B64" > /tmp/secrets.env.tmp
+GOOGLE_MCP_PROFILE="$profile" npx -y @a-bonus/google-docs-mcp &
+xdg-open <captured-url>
+# wait for ~/.config/google-docs-mcp/$profile/token.json to land
+kill %1
+echo "GOOGLE_DOCS_${profile^^}_TOKEN_B64=$(base64 -w0 ~/.config/google-docs-mcp/$profile/token.json)" >> /tmp/secrets.env.tmp
+gpg -c --cipher-algo AES256 -o mcp/secrets.env.gpg.new /tmp/secrets.env.tmp
+mv mcp/secrets.env.gpg.new mcp/secrets.env.gpg
+rm /tmp/secrets.env.tmp
+#+end_src
+
+The flow tonight worked but took a handful of manual steps. One script collapses it.