release / tag (push) Successful in 1s
Found on flamingo during the first Mac install. ~/.config/crush/skills held real directories from a pre-plugin install, and doctor said: FAIL outside: ~/.config/crush/skills resolves to ~/.config/crush/skills, outside the active checkout ~/.local/share/skills/skills Naming a directory as its own resolution target is true and useless. Worse, it pointed at the wrong fix: "outside" means a link into a foreign checkout, which you repair by re-pointing the link. A real directory is repaired by moving it aside and re-running install.sh — install.sh already refuses to delete it, correctly, since it did not create it and cannot know what is in there. Now reported as its own class, with the skill count so it is obvious whether the directory holds a stale copy of the library or something unique that belongs in the repo: FAIL unmanaged: <dir> is a real directory, not a link into the checkout — it holds N unmanaged skill(s); move it aside and re-run install.sh The distinction matters because content in that directory reaches exactly one harness and cannot receive new skills — the failure mode is silent divergence, which is the same family as everything else this tool exists to catch. Bump-Type: patch Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
195 lines
7.7 KiB
Bash
Executable File
195 lines
7.7 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
|
|
|
|
# --- output -----------------------------------------------------------------
|
|
if [ "$JSON" -eq 1 ]; then
|
|
printf '{"checkout":"%s","expected":%s,"findings":[%s]}\n' \
|
|
"$CHECKOUT" "$EXPECTED" "${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 ]
|