Files
skills/bin/doctor
T
mathias a2afb35d1d
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 0s
feat(doctor): report the installed plugin generation, not just its presence
Every other check in doctor is about symlink paths. That is the wrong shape
for Claude Code, which has no symlinks — it has the plugin, and it gates
updates on plugin.json's version. A host that never picked up a release loads
every skill successfully and is simply a generation behind, silently.

Found live while writing this: koala had skills@mathias 0.6.0 installed, a
marketplace clone at 0.7.0 and a checkout at 0.8.4, and doctor said "clean".

doctor now compares the version recorded in installed_plugins.json against the
newest manifest already on the host — the active checkout and any marketplace
clone, filtered to plugin.json files that actually name this plugin — and fails
naming both versions. Offline, like the rest of doctor: "available" never means
a fetch.

A host with no install record is a valid symlink-only configuration and stays
clean. --json grows a "plugin":{"installed","available"} object so per-host
results can be collected without parsing prose.

Four new fixture cases: match, stale, absent record, newer marketplace clone.
Live output on koala:

  FAIL plugin-version: Claude Code has skills@mathias 0.6.0 installed but
  0.8.4 is available on this host

Closes #30
Bump-Type: minor
2026-08-29 10:00:11 +02:00

248 lines
9.9 KiB
Bash
Executable File

#!/bin/sh
# doctor — report skills-library drift on this host.
#
# Written because every failure this fleet actually hit was SILENT: 243
# dangling symlinks hidden from git by the **/.claude/skills/ ignore rule, a
# checkout that still resolved but was 20 skills behind, a loose .md that
# loaded in no harness, and two machines where the library was simply absent.
# None of it errored. Exit status here is the signal that was missing.
#
# doctor human report, non-zero exit on drift
# doctor --quiet print only when something is wrong (SessionStart hook)
# doctor --json machine-readable findings
#
# POSIX sh on purpose: it runs on macOS and Linux, before any toolchain is
# guaranteed present, and inside a session-start hook where latency shows.
# It does no network I/O and never shells out to git for a fetch.
#
# Test seams: $HOME drives harness paths, $DOCTOR_CHECKOUT names the checkout.
set -u
QUIET=0
JSON=0
for arg in "$@"; do
case "$arg" in
--quiet) QUIET=1 ;;
--json) JSON=1 ;;
-h|--help)
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) printf 'doctor: unknown option %s\n' "$arg" >&2; exit 2 ;;
esac
done
SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
SELF_CHECKOUT=$(CDPATH= cd -- "$SELF_DIR/.." && pwd)
# The canonical checkout is whatever ~/.agents/skills resolves to — NOT the
# copy doctor happens to be running from. When invoked as a plugin hook, $0
# lives in the plugin cache, which is legitimately a second copy: judging the
# harness links against it would flag every one of them as "outside".
if [ -n "${DOCTOR_CHECKOUT:-}" ]; then
CHECKOUT="$DOCTOR_CHECKOUT"
elif [ -d "$HOME/.agents/skills" ]; then
# Resolve the link FIRST, then take the parent. "cd X/.." resolves ".."
# lexically against the link path, which lands back in ~/.agents.
_resolved=$(CDPATH= cd -- "$HOME/.agents/skills" 2>/dev/null && pwd -P)
CHECKOUT=$(dirname -- "$_resolved")
else
CHECKOUT="$SELF_CHECKOUT"
fi
SKILLS_DIR="$CHECKOUT/skills"
findings=0
report=""
# add <severity> <check> <detail>
add() {
findings=$((findings + 1))
if [ "$JSON" -eq 1 ]; then
report="${report}{\"severity\":\"$1\",\"check\":\"$2\",\"detail\":\"$(printf '%s' "$3" | sed 's/"/\\"/g')\"},"
else
report="${report}$1 $2: $3
"
fi
}
# count_skills <dir> — directories containing a SKILL.md, one level down.
count_skills() {
[ -d "$1" ] || { printf '0'; return; }
find "$1"/ -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
}
# --- the checkout itself ----------------------------------------------------
if [ ! -d "$SKILLS_DIR" ]; then
add FAIL checkout "no skills/ directory at $CHECKOUT"
# Nothing downstream can be judged without it.
printf 'FAIL checkout: no skills/ directory at %s\n' "$CHECKOUT" >&2
exit 1
fi
EXPECTED=$(count_skills "$SKILLS_DIR")
[ "$EXPECTED" -gt 0 ] || add FAIL checkout "skills/ holds no SKILL.md at all"
# --- stray entries in the checkout -----------------------------------------
# A loose foo.md where foo/SKILL.md was meant loads in NO harness and errors
# in none either. That is exactly how secret-hygiene.md sat dead for months.
for entry in "$SKILLS_DIR"/*; do
[ -e "$entry" ] || continue
name=$(basename "$entry")
if [ ! -d "$entry" ]; then
add FAIL stray "$SKILLS_DIR/$name is not a directory — it loads nowhere"
elif [ ! -f "$entry/SKILL.md" ]; then
add FAIL stray "$SKILLS_DIR/$name has no SKILL.md"
fi
done
# --- harness wiring ---------------------------------------------------------
# Canonical location first; Pi reads it directly and the others link to it.
for dir in \
"$HOME/.agents/skills" \
"$HOME/.config/crush/skills" \
"$HOME/.config/opencode/skills"
do
[ -L "$dir" ] || [ -d "$dir" ] || continue
if [ -L "$dir" ] && [ ! -e "$dir" ]; then
add FAIL dangling "$dir -> $(readlink "$dir") (target does not exist)"
continue
fi
# A real directory where a link belongs holds content nothing manages: it
# cannot receive new skills and silently shadows the checkout. Distinguished
# from the foreign-checkout case below because the fix differs — move it
# aside and re-run install.sh, rather than re-point a link.
if [ ! -L "$dir" ] && [ -d "$dir" ]; then
add FAIL unmanaged "$dir is a real directory, not a link into the checkout — it holds $(count_skills "$dir") unmanaged skill(s); move it aside and re-run install.sh"
continue
fi
# Resolve to a physical path and confirm it lands inside THIS checkout.
# A link that resolves to a different checkout is the dangerous case: it
# works, so nothing complains, while serving stale content.
resolved=$(CDPATH= cd -- "$dir" 2>/dev/null && pwd -P)
canonical=$(CDPATH= cd -- "$SKILLS_DIR" 2>/dev/null && pwd -P)
if [ -n "$resolved" ] && [ "$resolved" != "$canonical" ]; then
add FAIL outside "$dir resolves to $resolved, outside the active checkout $canonical"
continue
fi
got=$(count_skills "$dir")
if [ "$got" != "$EXPECTED" ]; then
add FAIL count "$dir sees $got skills, checkout has $EXPECTED"
fi
done
# --- dangling links anywhere under the harness dirs -------------------------
for dir in "$HOME/.agents/skills" "$HOME/.config/crush/skills" \
"$HOME/.config/opencode/skills" "$HOME/.claude/skills"
do
[ -d "$dir" ] || continue
# Plain glob, not find|while: a while loop on the right of a pipe runs in a
# subshell, so add() would increment a counter that dies with it and doctor
# would exit 0 having found faults.
for link in "$dir"/*; do
[ -L "$link" ] || continue
[ -e "$link" ] && continue
add FAIL dangling "$link -> $(readlink "$link") (target does not exist)"
done
done
# --- Claude Code: plugin vs leftover per-skill symlinks ---------------------
# Plugin skills do NOT override same-named user skills — both stay visible. So
# leftover links from the pre-plugin era mean every skill appears twice.
CLAUDE_SKILLS="$HOME/.claude/skills"
if [ -d "$CLAUDE_SKILLS" ]; then
legacy=$(find "$CLAUDE_SKILLS"/ -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')
if [ "$legacy" -gt 0 ]; then
add FAIL legacy "$CLAUDE_SKILLS holds $legacy per-skill symlink(s) from the pre-plugin wiring — remove them or every skill loads twice"
fi
fi
# Two delivery mechanisms means two copies that can drift apart: the plugin
# (Claude Code) and the checkout the other harnesses link to. Same host,
# different skill sets, no error — compare them.
# Skipped when DOCTOR_CHECKOUT is set: that scopes doctor to an explicit
# target, so comparing it against whatever copy the script lives in is
# meaningless rather than informative.
if [ -z "${DOCTOR_CHECKOUT:-}" ] && [ -f "$SELF_DIR/../.claude-plugin/plugin.json" ] \
&& [ "$SELF_CHECKOUT" != "$CHECKOUT" ]; then
plugin_count=$(count_skills "$SELF_CHECKOUT/skills")
if [ "$plugin_count" != "$EXPECTED" ]; then
add FAIL drift "Claude Code plugin serves $plugin_count skills, the other harnesses see $EXPECTED — the two copies have diverged"
fi
fi
INSTALLED="$HOME/.claude/plugins/installed_plugins.json"
if [ -f "$INSTALLED" ]; then
grep -q '"skills@mathias"' "$INSTALLED" 2>/dev/null \
|| add WARN plugin "skills@mathias is not installed for Claude Code — /plugin marketplace add https://git.d-ma.be/mathias/skills.git"
fi
# --- Claude Code: which GENERATION of the plugin is installed ---------------
# Every check above is about symlink paths, which is the wrong shape for the
# plugin: it is the only channel Claude Code has, and Claude Code gates updates
# on plugin.json's version. A host that never picked up a release loads all the
# skills successfully and is simply a generation behind — silently. Found on
# koala 2026-08-29: installed 0.6.0, marketplace clone 0.7.0, repo 0.8.5,
# doctor said "clean" (#30).
#
# Offline by construction, like the rest of doctor: "available" means the
# newest manifest already on this host — the active checkout and any
# marketplace clone — never a fetch.
manifest_version() {
[ -f "$1" ] || return 1
grep -q '"name"[[:space:]]*:[[:space:]]*"skills"' "$1" || return 1
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
# newer_version a b — echoes whichever sorts higher on major.minor.patch.
newer_version() {
printf '%s\n%s\n' "$1" "$2" | awk -F. '
{ raw[NR] = $0; for (i = 1; i <= 3; i++) n[NR, i] = ($i == "" ? 0 : $i + 0) }
END {
for (i = 1; i <= 3; i++) {
if (n[1, i] > n[2, i]) { print raw[1]; exit }
if (n[1, i] < n[2, i]) { print raw[2]; exit }
}
print raw[1]
}'
}
installed_v=''
if [ -f "$INSTALLED" ]; then
installed_v=$(sed -n '/"skills@mathias"/,/\]/p' "$INSTALLED" \
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
fi
available_v=''
for manifest in "$CHECKOUT/.claude-plugin/plugin.json" \
"$HOME"/.claude/plugins/marketplaces/*/.claude-plugin/plugin.json
do
v=$(manifest_version "$manifest") || continue
[ -n "$v" ] || continue
if [ -z "$available_v" ]; then
available_v=$v
else
available_v=$(newer_version "$available_v" "$v")
fi
done
if [ -n "$installed_v" ] && [ -n "$available_v" ] && [ "$installed_v" != "$available_v" ]; then
add FAIL plugin-version "Claude Code has skills@mathias $installed_v installed but $available_v is available on this host — /plugin update skills@mathias (a host stale enough to matter is running the old doctor, so the first detection may need this run by hand)"
fi
# --- output -----------------------------------------------------------------
if [ "$JSON" -eq 1 ]; then
printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \
"$CHECKOUT" "$EXPECTED" "$installed_v" "$available_v" "${report%,}"
elif [ "$findings" -gt 0 ]; then
printf 'skills doctor — %s finding(s), checkout %s\n\n%s\n' \
"$findings" "$CHECKOUT" "$report"
elif [ "$QUIET" -eq 0 ]; then
printf 'skills doctor: clean — %s skills, checkout %s\n' "$EXPECTED" "$CHECKOUT"
fi
[ "$findings" -eq 0 ]