Compare commits

..
4 Commits
Author SHA1 Message Date
mathias 025f01aa80 feat(install): serve several sources instead of displacing them
CI / Check / Test (push) Successful in 0s
CI / Tag release (push) Failing after 1s
~/.agents/skills was a single link straight at this checkout. That encoded an
assumption nobody had stated — that this checkout is the only source of skills
on the host. On flamingo it was not: Crush's directory held Anthropic's
official set, and the only way to install this library was to move that set
aside. A capability loss dressed up as a tidy-up.

The canonical location becomes a real directory of per-skill symlinks, fed by
the checkout plus anything in SKILLS_EXTRA_SOURCES. Harnesses keep their one
directory link, so the count is N + one per harness rather than N x harnesses,
which is what #23 was actually about.

The three requirements #27 named, each with a test:
- collisions are logged with both paths and the checkout wins, never resolved
  in silence;
- removing a skill from a source prunes its link;
- anything in the managed directory that is not our symlink is reported and
  left in place — the installer does not delete content it did not create.

doctor learns the same model: sources are recorded at ~/.agents/skills-sources,
a link into a configured source is healthy, a link into an unknown one is not,
and the old count check becomes a per-skill check because a per-skill layout
can lose one skill without changing any total. The pre-#27 wiring is named as
legacy rather than reported as damage. count_skills needed find -L; without it
symlinked skill directories count as zero.

Verified live on koala: migrated in place to 42 per-skill links, then a second
source added a 43rd alongside them, then a re-run without that source pruned it
back to 42. Only doctor finding throughout was the genuine stale plugin.

Closes #27
Bump-Type: minor
2026-08-29 10:05:46 +02:00
mathias eee62e4fc0 chore(release): plugin.json version 0.9.0
Skip-Release: true
2026-08-29 08:01:01 +00:00
mathias a2afb35d1d feat(doctor): report the installed plugin generation, not just its presence
CI / Tag release (push) Successful in 0s
CI / Check / Test (push) Successful in 1s
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
mathias ad17a7c6c0 chore(release): plugin.json version 0.8.5
Skip-Release: true
2026-08-29 07:56:02 +00:00
7 changed files with 557 additions and 69 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "skills",
"description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.",
"version": "0.8.4",
"version": "0.9.0",
"author": {
"name": "Mathias",
"url": "https://git.d-ma.be/mathias"
+32 -5
View File
@@ -40,16 +40,42 @@ moves — CI bumps it on every release.
curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
```
Idempotent. It clones or fast-forwards the checkout, then creates **three**
symlinks — one directory link per harness, never one per skill:
Idempotent. It clones or fast-forwards the checkout, then populates one
**managed directory** and gives each harness a single link to it:
| Link | Target |
| Path | Target |
|---|---|
| `~/.agents/skills` | `$SKILLS_CHECKOUT_DIR/skills` (Pi reads this path directly) |
| `~/.agents/skills/` | a real directory, one symlink per skill (Pi reads this path directly) |
| `~/.agents/skills/<skill>` | `$SKILLS_CHECKOUT_DIR/skills/<skill>`, or another configured source |
| `~/.config/crush/skills` | `~/.agents/skills` |
| `~/.config/opencode/skills` | `~/.agents/skills` |
Adding a skill wires it everywhere with no re-run.
Total links are N + one per harness — not N x harnesses, which is what #23 was
actually about.
### More than one source of skills
`~/.agents/skills` used to be a single link straight at this checkout. That
encoded an assumption nobody had stated — that this checkout is the only source
of skills on the host — and on flamingo the only way to install this library
was to displace Anthropic's official set, which lived in Crush's directory.
Real capability loss dressed up as a tidy-up (#27).
Point `SKILLS_EXTRA_SOURCES` at any number of extra directories, colon
separated, each holding `<skill>/SKILL.md`:
```
SKILLS_EXTRA_SOURCES=~/.config/crush/skills.anthropic bash install.sh
```
- The checkout is always first and always wins a name collision — but the
collision is **logged**, never resolved in silence. Quietly picking one is
how stale content wins.
- Removing a skill from a source removes its link on the next run.
- Anything in the managed directory that is not a symlink is reported and left
alone. install.sh does not delete content it did not create.
- The source list is recorded at `~/.agents/skills-sources` so `bin/doctor`
can tell a link into a known source from a link into a forgotten one.
### Where the checkout must live
@@ -74,6 +100,7 @@ default to point into `~/dev`.
| `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` |
| `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` |
| `OPENCODE_SKILLS_DIR` | `$HOME/.config/opencode/skills` |
| `SKILLS_EXTRA_SOURCES` | unset (colon-separated extra source directories) |
Antigravity and Mistral Vibe targets were removed — unused.
+14 -9
View File
@@ -70,17 +70,21 @@ everything load twice.
### Crush, Pi, OpenCode — run `install.sh`
These have no plugin system, so they get symlinks — but **one directory link
each**, never one per skill:
These have no plugin system, so they get symlinks — but through one managed
directory, never one link per skill per harness:
```
~/.agents/skills -> $SKILLS_CHECKOUT_DIR/skills (Pi reads this directly)
~/.agents/skills/ (real directory; Pi reads it directly)
~/.agents/skills/tdd -> $SKILLS_CHECKOUT_DIR/skills/tdd
~/.agents/skills/xlsx -> $SKILLS_EXTRA_SOURCES entry
~/.config/crush/skills -> ~/.agents/skills
~/.config/opencode/skills -> ~/.agents/skills
```
Adding a skill therefore wires it everywhere with no re-run, and a broken link
is one broken link instead of 42.
Total links are N + one per harness. `SKILLS_EXTRA_SOURCES` (colon-separated
directories) lets a second library — Anthropic's official set, for instance —
live alongside this one instead of being displaced by it; name collisions are
logged rather than silently resolved, and the checkout wins (#27).
```
curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
@@ -94,10 +98,11 @@ absolute link stored there is broken on one side by construction. See #23.
### Check a host
`task doctor` (or `bin/doctor`) reports dangling links, links resolving into a
foreign checkout, count mismatches, stray entries, and leftover Claude
symlinks. It also runs automatically at session start via the plugin, so drift
announces itself instead of waiting to be found.
`task doctor` (or `bin/doctor`) reports dangling links, links into a source
nobody configured, skills in the checkout with no link, stray entries, leftover
Claude symlinks, and a Claude Code plugin sitting on an older generation than
the host already has. It also runs automatically at session start via the
plugin, so drift announces itself instead of waiting to be found.
## Skill Relationships
+133 -26
View File
@@ -39,12 +39,21 @@ SELF_CHECKOUT=$(CDPATH= cd -- "$SELF_DIR/.." && pwd)
# 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 [ -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)
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"
@@ -66,9 +75,26 @@ add() {
}
# 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 "$1"/ -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
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 ----------------------------------------------------
@@ -95,10 +121,47 @@ for entry in "$SKILLS_DIR"/*; do
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")
[ -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 ---------------------------------------------------------
# Canonical location first; Pi reads it directly and the others link to it.
# 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/.agents/skills" \
"$HOME/.config/crush/skills" \
"$HOME/.config/opencode/skills"
do
@@ -110,32 +173,23 @@ do
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.
# 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 checkout — it holds $(count_skills "$dir") unmanaged skill(s); move it aside and re-run install.sh"
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
# 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"
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/.agents/skills" "$HOME/.config/crush/skills" \
for dir in "$HOME/.config/crush/skills" \
"$HOME/.config/opencode/skills" "$HOME/.claude/skills"
do
[ -d "$dir" ] || continue
@@ -180,10 +234,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"
+111 -6
View File
@@ -20,14 +20,24 @@
# cannot. This script covers the harnesses with no plugin system: Crush, Pi
# and OpenCode.
#
# Wiring model — one directory link per harness, never one per skill:
# Wiring model — one managed directory, one link per harness:
#
# ~/.agents/skills -> $CHECKOUT_DIR/skills (canonical; Pi reads it directly)
# ~/.agents/skills/tdd -> $CHECKOUT_DIR/skills/tdd (canonical; Pi reads the dir directly)
# ~/.agents/skills/xlsx -> $OTHER_SOURCE/xlsx
# ~/.config/crush/skills -> ~/.agents/skills
# ~/.config/opencode/skills -> ~/.agents/skills
#
# Adding a skill to the repo therefore wires it everywhere with no re-run,
# and a broken link is one broken link rather than 42.
# ~/.agents/skills was itself a link to this checkout until #27. That encoded
# an assumption nobody had stated — that this checkout is the ONLY source of
# skills on the host — and on flamingo the only way to install this library
# was to displace Anthropic's official set, which lived in Crush's directory.
# A real directory of per-skill links serves several sources at once: total
# links are N + one per harness, still not N x harnesses (#23's actual point).
#
# Extra sources come from SKILLS_EXTRA_SOURCES, a colon-separated list of
# directories each holding <skill>/SKILL.md. The checkout is always first and
# always wins a name collision — but the collision is logged, never resolved
# in silence, because quietly picking one is how stale content wins.
#
# CHECKOUT_DIR deliberately defaults OUTSIDE any file-synced or
# cross-platform-shared directory. Do not "fix" it to point into ~/dev: on a
@@ -43,6 +53,10 @@ CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}"
AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}"
OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}"
EXTRA_SOURCES="${SKILLS_EXTRA_SOURCES:-}"
# Recorded beside the managed directory rather than inside it: a stray file in
# there would look like a skill to every harness reading the dir.
SOURCES_MANIFEST="$(dirname "$AGENTS_DIR")/$(basename "$AGENTS_DIR")-sources"
log() { printf '[skills] %s\n' "$*"; }
@@ -74,6 +88,95 @@ count_skills() {
find "$CHECKOUT_DIR/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
}
# sources — every configured source directory, primary first, one per line.
sources() {
printf '%s\n' "$CHECKOUT_DIR/skills"
[ -n "$EXTRA_SOURCES" ] || return 0
# printf '%s\n' deliberately: without the trailing newline `read` hits EOF
# on the final entry and the loop body never runs for it.
printf '%s\n' "$EXTRA_SOURCES" | tr ':' '\n' | while IFS= read -r src; do
[ -n "$src" ] || continue
if [ -d "$src" ]; then
printf '%s\n' "$src"
else
log "extra source $src does not exist — skipping"
fi
done
}
# prepare_agents_dir — the canonical location is a real directory now. A host
# still on the single-link wiring is migrated in place.
prepare_agents_dir() {
if [ -L "$AGENTS_DIR" ]; then
log "migrating $AGENTS_DIR from the single-link wiring to a per-skill directory"
rm -f "$AGENTS_DIR"
elif [ -e "$AGENTS_DIR" ] && [ ! -d "$AGENTS_DIR" ]; then
log "REFUSING to replace $AGENTS_DIR — it exists and is not a directory"
return 1
fi
mkdir -p "$AGENTS_DIR"
}
# wire_skills — one symlink per skill, from every configured source.
wire_skills() {
local src_list desired name src dir link target
src_list="$(mktemp)"
sources > "$src_list"
desired=""
while IFS= read -r src; do
[ -d "$src" ] || continue
for dir in "$src"/*/; do
[ -f "$dir/SKILL.md" ] || continue
name="$(basename "$dir")"
target="${dir%/}"
if printf '%s' "$desired" | grep -qx -- "$name"; then
log "COLLISION: $name is offered by $target too; keeping $AGENTS_DIR/$name -> $(readlink "$AGENTS_DIR/$name" 2>/dev/null || echo '?')"
continue
fi
link="$AGENTS_DIR/$name"
if [ -L "$link" ]; then
if [ "$(readlink "$link")" = "$target" ]; then
desired="$desired$name
"
continue
fi
rm -f "$link"
elif [ -e "$link" ]; then
log "unmanaged: $link is not a symlink — left in place; move it aside to let $target take the name"
continue
fi
ln -s "$target" "$link"
desired="$desired$name
"
done
done < "$src_list"
rm -f "$src_list"
# Removing a skill from a source must remove its link, or the previous
# generation's problem (links resolving to nothing, invisible to git)
# returns in a new shape.
for link in "$AGENTS_DIR"/*; do
[ -e "$link" ] || [ -L "$link" ] || continue
name="$(basename "$link")"
if [ ! -L "$link" ]; then
log "unmanaged: $link is not a symlink and no source offers it — left in place"
continue
fi
printf '%s' "$desired" | grep -qx -- "$name" && continue
rm -f "$link"
log "pruned $name — no configured source offers it any more"
done
sources > "$SOURCES_MANIFEST"
# Reported through a global, not stdout: wire_skills logs collisions and
# unmanaged entries as it goes, and capturing its stdout for a count would
# swallow exactly the warnings this change exists to surface.
LINKED_COUNT=$(printf '%s' "$desired" | grep -c . || true)
}
# link_dir <link_path> <target_path>
# Replaces whatever is there — including a directory left behind by the old
# per-skill wiring — with a single symlink.
@@ -104,12 +207,14 @@ main() {
# Canonical location. Pi reads ~/.agents/skills directly, so it needs no
# harness-specific link of its own.
link_dir "$AGENTS_DIR" "$CHECKOUT_DIR/skills"
prepare_agents_dir
LINKED_COUNT=0
wire_skills
link_dir "$CRUSH_DIR" "$AGENTS_DIR"
link_dir "$OPENCODE_DIR" "$AGENTS_DIR"
log "done — $(count_skills) skill(s) at ref=$REF, 3 links total"
log "done — $LINKED_COUNT skill(s) linked from $(sources | wc -l | tr -d ' ') source(s), ref=$REF"
log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL"
}
+106 -22
View File
@@ -37,11 +37,21 @@ new_fixture() {
printf '%s' "$_f"
}
# wire_clean <fixture> — the correct wiring: one canonical link, harnesses
# pointing at it.
# wire_clean <fixture> [extra-source-dir] — the correct wiring since #27: the
# canonical location is a REAL directory of per-skill links, fed by one or more
# sources, with each harness keeping its single directory link to 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"
mkdir -p "$1/home/.agents/skills" "$1/home/.config/crush" "$1/home/.config/opencode"
for _d in "$1"/ck/skills/*/; do
ln -sfn "${_d%/}" "$1/home/.agents/skills/$(basename "$_d")"
done
printf '%s\n' "$1/ck/skills" > "$1/home/.agents/skills-sources"
if [ -n "${2:-}" ]; then
for _d in "$2"/*/; do
ln -sfn "${_d%/}" "$1/home/.agents/skills/$(basename "$_d")"
done
printf '%s\n' "$2" >> "$1/home/.agents/skills-sources"
fi
ln -sfn "$1/home/.agents/skills" "$1/home/.config/crush/skills"
ln -sfn "$1/home/.agents/skills" "$1/home/.config/opencode/skills"
}
@@ -79,27 +89,43 @@ 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'
ln -sfn "$f/ck/skills/vanished" "$f/home/.agents/skills/alpha"
assert_fails "$f" 'dangling' 'dangling per-skill link is reported'
# --- 3. resolves, but to a DIFFERENT checkout (the silent-stale case) --------
# --- 3. resolves, but into a source nobody configured (silent-stale case) ----
# Worse than dangling: it works, so nothing complains, while serving content
# from a checkout that is months behind.
# from a checkout that is months behind. Since #27 a link may legitimately
# point outside the checkout — but only into a source the installer recorded.
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"
mkdir -p "$f/other/alpha"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/other/alpha/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'
ln -sfn "$f/other/alpha" "$f/home/.agents/skills/alpha"
assert_fails "$f" 'unknown source' 'link into an unconfigured source 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"
# --- 3b. a link into a CONFIGURED extra source is healthy -------------------
# The whole point of #27: Anthropic's official set alongside this library.
f=$(new_fixture multisource)
mkdir -p "$f/other/xlsx"
printf -- '---\nname: xlsx\ndescription: x\n---\n' > "$f/other/xlsx/SKILL.md"
wire_clean "$f" "$f/other"
assert_clean "$f" 'a link into a configured extra source is healthy'
# --- 3c. the pre-#27 single-link wiring is named, not mistaken for damage ----
f=$(new_fixture legacy)
mkdir -p "$f/home/.agents" "$f/home/.config/crush" "$f/home/.config/opencode"
ln -sfn "$f/ck/skills" "$f/home/.agents/skills"
assert_fails "$f" 'count' 'skill-count mismatch is reported'
ln -sfn "$f/home/.agents/skills" "$f/home/.config/crush/skills"
ln -sfn "$f/home/.agents/skills" "$f/home/.config/opencode/skills"
assert_fails "$f" 'single-link' 'legacy single-link wiring is reported as such'
# --- 4. a skill in the checkout with no link anywhere ------------------------
# The multi-source replacement for the old count check: with per-skill links a
# skill can be missing individually, which a total count would hide if an extra
# source happened to supply the same number.
f=$(new_fixture missing); wire_clean "$f"
rm -f "$f/home/.agents/skills/beta"
assert_fails "$f" 'beta' 'a checkout skill with no link is reported'
# --- 5. stray entry (the loose secret-hygiene.md that loaded nowhere) --------
f=$(new_fixture stray); wire_clean "$f"
@@ -119,9 +145,9 @@ assert_fails "$f" 'legacy' 'leftover per-skill Claude symlinks are reported'
# 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"
f=$(new_fixture realdir); wire_clean "$f"
rm -f "$f/home/.config/crush/skills"
mkdir -p "$f/home/.config/crush/skills/alpha"
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"'
@@ -133,5 +159,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 <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 ]
+160
View File
@@ -0,0 +1,160 @@
#!/bin/sh
# Tests for install.sh's wiring model.
#
# The regression these exist for: install.sh wired each harness with ONE
# directory symlink to this checkout, which quietly encoded "this checkout is
# the only source of skills on the host". On flamingo the Crush directory held
# Anthropic's official skill set, and the only way to install this library was
# to displace it — a real capability loss dressed up as a tidy-up (#27).
#
# Fixtures drive install.sh entirely through env seams, so no test touches the
# real host wiring.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
SUT="$ROOT/install.sh"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/install_test.$$
if [ ! -f "$SUT" ]; then
printf 'FAIL: %s does not exist\n' "$SUT"
exit 1
fi
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
no() { fail=$((fail + 1)); printf ' FAIL %s\n%s\n' "$1" "${2:-}"; }
# skill <dir> <name> — a minimal valid skill.
skill() {
mkdir -p "$1/$2"
printf -- '---\nname: %s\ndescription: d\n---\n' "$2" > "$1/$2/SKILL.md"
}
# new_fixture <name> [extra-source-names...] — checkout with alpha+beta.
new_fixture() {
_f="$WORK/$1"
mkdir -p "$_f/ck/skills" "$_f/home"
skill "$_f/ck/skills" alpha
skill "$_f/ck/skills" beta
printf '%s' "$_f"
}
# run_install <fixture> [extra-sources] — echoes output, sets $rc.
run_install() {
_f=$1
_extra=${2:-}
_out=$(HOME="$_f/home" \
SKILLS_SKIP_FETCH=1 \
SKILLS_CHECKOUT_DIR="$_f/ck" \
AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \
CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \
OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \
SKILLS_EXTRA_SOURCES="$_extra" \
bash "$SUT" 2>&1)
rc=$?
printf '%s' "$_out"
}
# --- 1. the new shape: a real directory of per-skill links -------------------
f=$(new_fixture fresh)
out=$(run_install "$f")
A="$f/home/.agents/skills"
if [ ! -d "$A" ] || [ -L "$A" ]; then
no 'agents dir is a real directory' "$out"
elif [ ! -L "$A/alpha" ] || [ ! -L "$A/beta" ]; then
no 'each skill is its own link' "$out"
elif [ ! -L "$f/home/.config/crush/skills" ] || [ ! -L "$f/home/.config/opencode/skills" ]; then
no 'harnesses keep one directory link each' "$out"
else
ok 'fresh install builds per-skill links under a real agents dir'
fi
# --- 2. a second source is served WITHOUT displacing the first --------------
f=$(new_fixture twosource)
mkdir -p "$f/other"
skill "$f/other" xlsx
out=$(run_install "$f" "$f/other")
if [ -L "$f/home/.agents/skills/xlsx" ] && [ -L "$f/home/.agents/skills/alpha" ]; then
ok 'a second source is linked alongside the checkout'
else
no 'a second source is linked alongside the checkout' "$out"
fi
# --- 3. name collisions are REPORTED, never silently resolved ---------------
# Two sources offering `code-review` is a real possibility, and picking one
# quietly is how stale content wins.
f=$(new_fixture collide)
mkdir -p "$f/other"
skill "$f/other" alpha
out=$(run_install "$f" "$f/other")
if printf '%s' "$out" | grep -qi 'collision' \
&& printf '%s' "$out" | grep -q "$f/other/alpha"; then
target=$(readlink "$f/home/.agents/skills/alpha")
if [ "$target" = "$f/ck/skills/alpha" ]; then
ok 'a name collision is reported and the checkout wins'
else
no 'a name collision is reported and the checkout wins' "resolved to $target"
fi
else
no 'a name collision is reported and the checkout wins' "$out"
fi
# --- 4. removing a skill from a source removes its link ---------------------
# Without this the previous generation's problem returns in a new shape: links
# that resolve to nothing, invisible to git.
f=$(new_fixture prune)
out=$(run_install "$f")
rm -rf "$f/ck/skills/beta"
out=$(run_install "$f")
if [ -e "$f/home/.agents/skills/beta" ] || [ -L "$f/home/.agents/skills/beta" ]; then
no 'a removed skill loses its link' "$out"
else
ok 'a removed skill loses its link'
fi
# --- 5. migration from the old single-link shape ----------------------------
f=$(new_fixture migrate)
mkdir -p "$f/home/.agents"
ln -s "$f/ck/skills" "$f/home/.agents/skills"
out=$(run_install "$f")
if [ -L "$f/home/.agents/skills" ]; then
no 'the old single-link wiring is migrated in place' "$out"
elif [ -L "$f/home/.agents/skills/alpha" ]; then
ok 'the old single-link wiring is migrated in place'
else
no 'the old single-link wiring is migrated in place' "$out"
fi
# --- 6. unmanaged real content in the agents dir is never deleted -----------
f=$(new_fixture protect)
mkdir -p "$f/home/.agents/skills"
skill "$f/home/.agents/skills" handwritten
out=$(run_install "$f")
if [ -f "$f/home/.agents/skills/handwritten/SKILL.md" ] \
&& printf '%s' "$out" | grep -qi 'unmanaged'; then
ok 'unmanaged content is reported and left in place'
else
no 'unmanaged content is reported and left in place' "$out"
fi
# --- 7. the source list is recorded for doctor ------------------------------
f=$(new_fixture manifest)
mkdir -p "$f/other"
skill "$f/other" xlsx
out=$(run_install "$f" "$f/other")
if [ -f "$f/home/.agents/skills-sources" ] \
&& grep -q "$f/other" "$f/home/.agents/skills-sources" \
&& grep -q "$f/ck/skills" "$f/home/.agents/skills-sources"; then
ok 'configured sources are recorded for doctor'
else
no 'configured sources are recorded for doctor' "$out"
fi
printf '\ninstall: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]