The repo distributes three ways — the Claude Code plugin, the symlinked library for Crush/Pi/OpenCode, and claude.ai, which has no fetch path at all — and treated every skill as belonging to all of them. claude.ai is not a fourth host to mirror: gitea-ci, playwright and web-shot have no shell and no cluster there, so a flat mirror installs skills that can never fire and degrades selection for the ones that can. Every SKILL.md now carries `surfaces:`, defaulting to [claude-code, library]; dream, own-the-task and grill-me add `chat`, matching what is actually installed on claude.ai today. Claude Code accepts the extra key — `claude plugin validate` passes and the skills still load. bin/check-surfaces validates values, not just presence, because the failure mode here is silent: a wrong surface removes a skill from a harness rather than erroring. install.sh links only skills whose surfaces include `library`, and a source that declares nothing at all is treated as foreign and linked as-is — Anthropic's official set has no reason to know this convention. doctor's missing-link check skips non-library skills for the same reason. Refs #29 Bump-Type: minor
303 lines
12 KiB
Bash
Executable File
303 lines
12 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".
|
|
AGENTS_DIR="$HOME/.agents/skills"
|
|
SOURCES_FILE="$(dirname -- "$AGENTS_DIR")/$(basename -- "$AGENTS_DIR")-sources"
|
|
|
|
if [ -n "${DOCTOR_CHECKOUT:-}" ]; then
|
|
CHECKOUT="$DOCTOR_CHECKOUT"
|
|
elif [ -f "$SOURCES_FILE" ]; then
|
|
# Since #27 the canonical location is a real directory fed by several
|
|
# sources, so it can no longer be resolved to a checkout. install.sh records
|
|
# the list; the first line is always this repo's skills/ directory.
|
|
CHECKOUT=$(dirname -- "$(head -n 1 "$SOURCES_FILE")")
|
|
elif [ -L "$AGENTS_DIR" ]; then
|
|
# Pre-#27 wiring: one link straight at the checkout. Resolve the link FIRST,
|
|
# then take the parent. "cd X/.." resolves ".." lexically against the link
|
|
# path, which lands back in ~/.agents.
|
|
_resolved=$(CDPATH= cd -- "$AGENTS_DIR" 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.
|
|
# -L on purpose: since #27 the entries are per-skill symlinks, and find does
|
|
# not descend into a symlinked directory without it.
|
|
count_skills() {
|
|
[ -d "$1" ] || { printf '0'; return; }
|
|
find -L "$1"/ -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
|
|
}
|
|
|
|
# under_known_source <physical-path> — 0 when the path lies inside a source
|
|
# install.sh recorded, or inside the active checkout.
|
|
under_known_source() {
|
|
_path=$1
|
|
_list="$SKILLS_DIR"
|
|
[ -f "$SOURCES_FILE" ] && _list="$_list
|
|
$(cat "$SOURCES_FILE")"
|
|
printf '%s\n' "$_list" | while IFS= read -r _src; do
|
|
[ -n "$_src" ] || continue
|
|
_real=$(CDPATH= cd -- "$_src" 2>/dev/null && pwd -P) || continue
|
|
[ -n "$_real" ] || continue
|
|
case "$_path" in "$_real"/*|"$_real") printf 'yes'; break ;; esac
|
|
done | grep -q yes
|
|
}
|
|
|
|
# --- 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
|
|
|
|
# --- the canonical directory --------------------------------------------------
|
|
# Pre-#27 this was a symlink at the checkout. It is a real directory now, one
|
|
# per-skill link per skill, fed by every configured source — that is what lets
|
|
# a second library (Anthropic's official set on flamingo) coexist instead of
|
|
# being displaced.
|
|
if [ -L "$AGENTS_DIR" ]; then
|
|
add WARN legacy "$AGENTS_DIR is the pre-#27 single-link wiring — re-run install.sh to adopt the multi-source layout"
|
|
elif [ -d "$AGENTS_DIR" ]; then
|
|
for entry in "$AGENTS_DIR"/*; do
|
|
[ -e "$entry" ] || [ -L "$entry" ] || continue
|
|
name=$(basename "$entry")
|
|
|
|
if [ ! -L "$entry" ]; then
|
|
add FAIL unmanaged "$AGENTS_DIR/$name is not a link into any source — nothing manages it and nothing updates it"
|
|
continue
|
|
fi
|
|
if [ ! -e "$entry" ]; then
|
|
add FAIL dangling "$entry -> $(readlink "$entry") (target does not exist)"
|
|
continue
|
|
fi
|
|
|
|
resolved=$(CDPATH= cd -- "$entry" 2>/dev/null && pwd -P)
|
|
if [ -n "$resolved" ] && ! under_known_source "$resolved"; then
|
|
add FAIL outside "$entry resolves to $resolved, an unknown source — it works, so nothing complains, while serving content nobody updates"
|
|
fi
|
|
done
|
|
|
|
# A per-skill layout can lose one skill without changing any total, so the
|
|
# invariant is per skill, not a count.
|
|
for dir in "$SKILLS_DIR"/*/; do
|
|
[ -f "$dir/SKILL.md" ] || continue
|
|
name=$(basename "$dir")
|
|
# Only skills that ship to the library belong here. A chat-only skill
|
|
# missing from a Crush directory is correct, not drift (#29).
|
|
surfaces=$(sed -n 's/^surfaces:[[:space:]]*//p' "$dir/SKILL.md" | head -1)
|
|
if [ -n "$surfaces" ] \
|
|
&& ! printf '%s' "$surfaces" | tr -d '[]' | tr ',' ' ' | grep -qw library; then
|
|
continue
|
|
fi
|
|
[ -L "$AGENTS_DIR/$name" ] \
|
|
|| add FAIL missing "$name is in the checkout but has no link in $AGENTS_DIR — re-run install.sh"
|
|
done
|
|
fi
|
|
|
|
# --- harness wiring ---------------------------------------------------------
|
|
# Crush and OpenCode keep their single directory link at the canonical dir;
|
|
# Pi reads it directly and needs no link of its own.
|
|
for dir in \
|
|
"$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 canonical dir. Since
|
|
# #27 the fix is not only "move it aside" — it can be added as an extra
|
|
# source instead, which is how flamingo keeps the official Anthropic set.
|
|
if [ ! -L "$dir" ] && [ -d "$dir" ]; then
|
|
add FAIL unmanaged "$dir is a real directory, not a link into the canonical dir — it holds $(count_skills "$dir") unmanaged skill(s); add it to SKILLS_EXTRA_SOURCES and re-run install.sh, or move it aside"
|
|
continue
|
|
fi
|
|
|
|
resolved=$(CDPATH= cd -- "$dir" 2>/dev/null && pwd -P)
|
|
canonical=$(CDPATH= cd -- "$AGENTS_DIR" 2>/dev/null && pwd -P)
|
|
if [ -n "$resolved" ] && [ -n "$canonical" ] && [ "$resolved" != "$canonical" ]; then
|
|
add FAIL outside "$dir resolves to $resolved, not the canonical $canonical"
|
|
fi
|
|
done
|
|
|
|
# --- dangling links anywhere under the harness dirs -------------------------
|
|
for dir in "$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.
|
|
# Lives in bin/newer-version because `task check` needs the same ordering (#34)
|
|
# and a second copy is a second thing to get wrong.
|
|
newer_version() {
|
|
sh "$SELF_DIR/newer-version" "$1" "$2"
|
|
}
|
|
|
|
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 ]
|