diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-07 08:18:23 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-07 08:18:23 -0500 |
| commit | c84e8a03336f8d44301652aadc2b177a7f2502df (patch) | |
| tree | c0872a98be5099c35f450ad340f07428aa8a9222 /scripts/doctor.sh | |
| parent | e26264ac85ac07af8716770063e636abccf2d9fe (diff) | |
| download | rulesets-c84e8a03336f8d44301652aadc2b177a7f2502df.tar.gz rulesets-c84e8a03336f8d44301652aadc2b177a7f2502df.zip | |
feat(make): add doctor target for ~/.claude drift detection
=make doctor= scans =~/.claude/= and reports drift against the repo + settings.json. Read-only diagnostic. Eight checks cover skills, rules, default hooks, claude config, settings.json hook references, enabledPlugins, MCP server registrations, and dangling symlinks. Each line prints =ok= / =WARN= / =FAIL= with a final summary. Exit 1 on any FAIL.
A sweep last night found =~/.claude/hooks/= didn't exist on this machine even though =settings.json= referenced a PreCompact hook there. Compaction would have silently failed to invoke it. doctor catches that kind of drift in one command instead of relying on a manual look.
The MCP drift check reads =~/.claude.json= directly rather than parsing =claude mcp list=. The CLI has no JSON output and runs a per-server health probe (~10s). The JSON file is the user-scope source of truth for registrations and parses in well under a second.
I verified by injecting four drift scenarios — removed hook symlink, removed skill symlink, moved-aside plugin data dir, unregistered MCP server. Each produced the expected =FAIL= line and exit 1. After restoring state, doctor came back clean (33 ok).
Bundling four other improvement TODOs from the same sweep — =mcp/README.org=, =make uninstall-mcp= and =mcp/install.py --check=, a README.org section for the MCP install pipeline, and a token-rotation helper for =@a-bonus/google-docs-mcp= OAuth refresh. Plus a stale-bullet note on the existing =make remove= TODO (the bridge symlink it references was removed earlier).
Diffstat (limited to 'scripts/doctor.sh')
| -rwxr-xr-x | scripts/doctor.sh | 177 |
1 files changed, 177 insertions, 0 deletions
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 |
