aboutsummaryrefslogtreecommitdiff
path: root/scripts/doctor.sh
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