blob: 93a4d22975dbdf89f533c67b01fe0865423b0f0f (
plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
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
|