diff --git a/bin/doctor b/bin/doctor index 784bdf5..e86be33 100755 --- a/bin/doctor +++ b/bin/doctor @@ -180,10 +180,63 @@ if [ -f "$INSTALLED" ]; then || 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,"findings":[%s]}\n' \ - "$CHECKOUT" "$EXPECTED" "${report%,}" + 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" diff --git a/tests/doctor_test.sh b/tests/doctor_test.sh index 3265961..5a26ddd 100644 --- a/tests/doctor_test.sh +++ b/tests/doctor_test.sh @@ -133,5 +133,63 @@ 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 — the Claude Code install record. +installed_state() { + mkdir -p "$1/home/.claude/plugins" + cat > "$1/home/.claude/plugins/installed_plugins.json" < — a .claude-plugin/plugin.json under . +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 ]