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
196 lines
7.8 KiB
Bash
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 ]
|