Files
skills/tests/doctor_test.sh
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

196 lines
7.8 KiB
Bash

#!/bin/sh
# Tests for bin/doctor.
#
# Every case here is a real state this fleet was found in on 2026-08-23, and
# every one of them was SILENT — nothing errored, git status was clean, the
# harness simply ran with the wrong skills or none at all. The point of doctor
# is that none of these can be silent again, so each fixture asserts both a
# non-zero exit AND that the report names the fault.
#
# Fixtures drive doctor through $HOME and $DOCTOR_CHECKOUT, so no test touches
# the real host.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
DOCTOR="$ROOT/bin/doctor"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/doctor_test.$$
if [ ! -f "$DOCTOR" ]; then
printf 'FAIL: %s does not exist\n' "$DOCTOR"
exit 1
fi
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
# new_fixture <name> — builds $WORK/<name>/{ck,home} with a 2-skill checkout
# and an empty fake HOME. Echoes the fixture root.
new_fixture() {
_f="$WORK/$1"
mkdir -p "$_f/ck/skills/alpha" "$_f/ck/skills/beta" "$_f/home"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$_f/ck/skills/alpha/SKILL.md"
printf -- '---\nname: beta\ndescription: b\n---\n' > "$_f/ck/skills/beta/SKILL.md"
printf '%s' "$_f"
}
# wire_clean <fixture> — the correct wiring: one canonical link, harnesses
# pointing at it.
wire_clean() {
mkdir -p "$1/home/.agents" "$1/home/.config/crush" "$1/home/.config/opencode"
ln -sfn "$1/ck/skills" "$1/home/.agents/skills"
ln -sfn "$1/home/.agents/skills" "$1/home/.config/crush/skills"
ln -sfn "$1/home/.agents/skills" "$1/home/.config/opencode/skills"
}
# assert_fails <fixture> <substring> <label>
# Note: doctor must be invoked directly here, not via a helper called in a
# command substitution — $? from inside $( ) belongs to the subshell and the
# exit status, which is the whole point of these assertions, would be lost.
assert_fails() {
_out=$(HOME="$1/home" DOCTOR_CHECKOUT="$1/ck" sh "$DOCTOR" 2>&1); rc=$?
if [ "$rc" -eq 0 ]; then
fail=$((fail + 1))
printf ' FAIL %s\n doctor exited 0; drift went undetected\n' "$3"
elif ! printf '%s' "$_out" | grep -qi -- "$2"; then
fail=$((fail + 1))
printf ' FAIL %s\n exited %s but report never mentions "%s"\n got: %s\n' "$3" "$rc" "$2" "$_out"
else
pass=$((pass + 1))
printf ' ok %s\n' "$3"
fi
}
assert_clean() {
_out=$(HOME="$1/home" DOCTOR_CHECKOUT="$1/ck" sh "$DOCTOR" 2>&1); rc=$?
if [ "$rc" -eq 0 ]; then
pass=$((pass + 1)); printf ' ok %s\n' "$2"
else
fail=$((fail + 1)); printf ' FAIL %s\n exited %s on a healthy fixture\n got: %s\n' "$2" "$rc" "$_out"
fi
}
# --- 1. healthy host stays silent -------------------------------------------
f=$(new_fixture clean); wire_clean "$f"
assert_clean "$f" 'clean fixture exits 0'
# --- 2. dangling link (the 243-link case, gitignored and invisible) ----------
f=$(new_fixture dangling); wire_clean "$f"
ln -sfn "$f/ck/skills-that-moved" "$f/home/.agents/skills"
assert_fails "$f" 'dangling' 'dangling canonical link is reported'
# --- 3. resolves, but to a DIFFERENT checkout (the silent-stale case) --------
# Worse than dangling: it works, so nothing complains, while serving content
# from a checkout that is months behind.
f=$(new_fixture stale)
mkdir -p "$f/other/skills/alpha" "$f/other/skills/beta"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/other/skills/alpha/SKILL.md"
printf -- '---\nname: beta\ndescription: b\n---\n' > "$f/other/skills/beta/SKILL.md"
wire_clean "$f"
ln -sfn "$f/other/skills" "$f/home/.agents/skills"
assert_fails "$f" 'outside' 'link into a foreign checkout is reported'
# --- 4. count mismatch (a harness seeing fewer skills than the checkout) -----
f=$(new_fixture count)
mkdir -p "$f/home/.config/crush/skills/alpha"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/home/.config/crush/skills/alpha/SKILL.md"
mkdir -p "$f/home/.agents"
ln -sfn "$f/ck/skills" "$f/home/.agents/skills"
assert_fails "$f" 'count' 'skill-count mismatch is reported'
# --- 5. stray entry (the loose secret-hygiene.md that loaded nowhere) --------
f=$(new_fixture stray); wire_clean "$f"
printf 'orphan\n' > "$f/ck/skills/loose-note.md"
assert_fails "$f" 'stray' 'stray non-directory entry is reported'
# --- 6. legacy Claude symlinks alongside the plugin (duplicate skills) -------
f=$(new_fixture dupe); wire_clean "$f"
mkdir -p "$f/home/.claude/skills"
ln -sfn "$f/ck/skills/alpha" "$f/home/.claude/skills/alpha"
assert_fails "$f" 'legacy' 'leftover per-skill Claude symlinks are reported'
# --- 7b. harness dir is a REAL directory, not a link into the checkout -------
# Found on flamingo 2026-08-26: ~/.config/crush/skills held real directories
# from a pre-plugin install. The old message read "resolves to
# <path>, outside the active checkout" — naming the directory as its own
# resolution target, which is true but useless. It must say the dir is not a
# link at all, because the fix (move it aside, re-run install.sh) is different
# from the fix for a link into a foreign checkout.
f=$(new_fixture realdir)
mkdir -p "$f/home/.agents" "$f/home/.config/crush/skills/alpha"
ln -sfn "$f/ck/skills" "$f/home/.agents/skills"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/home/.config/crush/skills/alpha/SKILL.md"
assert_fails "$f" 'not a link' 'real directory is reported as unmanaged, not as "outside"'
# --- 7. dangling link inside a harness dir (not the canonical link itself) --
# Exercises the per-entry scan rather than the canonical-link check: this is
# the shape the 243 dead links actually had.
f=$(new_fixture ghost); wire_clean "$f"
mkdir -p "$f/home/.claude/skills"
ln -sfn "$f/ck/skills/vanished" "$f/home/.claude/skills/vanished"
assert_fails "$f" 'dangling' 'dangling link inside a harness dir is reported'
# --- 8. Claude Code plugin pinned to an older version than is available -----
# The plugin is the ONLY delivery channel for Claude Code, and Claude Code
# gates updates on plugin.json's version field. A host that never picked the
# new one up loads every skill successfully — just the wrong generation. Found
# live on koala 2026-08-29: installed 0.6.0, marketplace 0.7.0, repo 0.8.5,
# and doctor said "clean" (#30).
# installed_state <fixture> <version> — the Claude Code install record.
installed_state() {
mkdir -p "$1/home/.claude/plugins"
cat > "$1/home/.claude/plugins/installed_plugins.json" <<JSON
{
"version": 2,
"plugins": {
"skills@mathias": [
{
"scope": "user",
"installPath": "/somewhere/$2",
"version": "$2"
}
]
}
}
JSON
}
# manifest <dir> <version> — a .claude-plugin/plugin.json under <dir>.
manifest() {
mkdir -p "$1/.claude-plugin"
printf '{\n "name": "skills",\n "version": "%s"\n}\n' "$2" > "$1/.claude-plugin/plugin.json"
}
f=$(new_fixture pluginmatch); wire_clean "$f"
installed_state "$f" 0.8.0
manifest "$f/ck" 0.8.0
assert_clean "$f" 'plugin on the current version stays silent'
f=$(new_fixture pluginstale); wire_clean "$f"
installed_state "$f" 0.7.0
manifest "$f/ck" 0.8.0
assert_fails "$f" '0.7.0' 'stale plugin version is reported'
assert_fails "$f" '0.8.0' 'stale plugin report names the available version'
# A host that only uses the symlink path has no install record at all. That is
# a valid configuration and must not fail.
f=$(new_fixture pluginabsent); wire_clean "$f"
manifest "$f/ck" 0.8.0
assert_clean "$f" 'no Claude Code install record is not a fault'
# The marketplace clone is what Claude Code compares against, so a newer
# version there means an update is waiting even when the local checkout agrees
# with what is installed.
f=$(new_fixture pluginmarket); wire_clean "$f"
installed_state "$f" 0.7.0
manifest "$f/ck" 0.7.0
manifest "$f/home/.claude/plugins/marketplaces/mathias" 0.9.0
assert_fails "$f" '0.9.0' 'a newer marketplace clone is reported'
printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]