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
This commit is contained in:
2026-08-29 10:05:46 +02:00
parent eee62e4fc0
commit 025f01aa80
6 changed files with 443 additions and 66 deletions
+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 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** Idempotent. It clones or fast-forwards the checkout, then populates one
symlinks — one directory link per harness, never one per skill: **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/crush/skills` | `~/.agents/skills` |
| `~/.config/opencode/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 ### Where the checkout must live
@@ -74,6 +100,7 @@ default to point into `~/dev`.
| `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` | | `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` |
| `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` | | `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` |
| `OPENCODE_SKILLS_DIR` | `$HOME/.config/opencode/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. 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` ### Crush, Pi, OpenCode — run `install.sh`
These have no plugin system, so they get symlinks — but **one directory link These have no plugin system, so they get symlinks — but through one managed
each**, never one per skill: 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/crush/skills -> ~/.agents/skills
~/.config/opencode/skills -> ~/.agents/skills ~/.config/opencode/skills -> ~/.agents/skills
``` ```
Adding a skill therefore wires it everywhere with no re-run, and a broken link Total links are N + one per harness. `SKILLS_EXTRA_SOURCES` (colon-separated
is one broken link instead of 42. 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 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 ### Check a host
`task doctor` (or `bin/doctor`) reports dangling links, links resolving into a `task doctor` (or `bin/doctor`) reports dangling links, links into a source
foreign checkout, count mismatches, stray entries, and leftover Claude nobody configured, skills in the checkout with no link, stray entries, leftover
symlinks. It also runs automatically at session start via the plugin, so drift Claude symlinks, and a Claude Code plugin sitting on an older generation than
announces itself instead of waiting to be found. 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 ## Skill Relationships
+78 -24
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 # 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 # 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". # 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 if [ -n "${DOCTOR_CHECKOUT:-}" ]; then
CHECKOUT="$DOCTOR_CHECKOUT" CHECKOUT="$DOCTOR_CHECKOUT"
elif [ -d "$HOME/.agents/skills" ]; then elif [ -f "$SOURCES_FILE" ]; then
# Resolve the link FIRST, then take the parent. "cd X/.." resolves ".." # Since #27 the canonical location is a real directory fed by several
# lexically against the link path, which lands back in ~/.agents. # sources, so it can no longer be resolved to a checkout. install.sh records
_resolved=$(CDPATH= cd -- "$HOME/.agents/skills" 2>/dev/null && pwd -P) # 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") CHECKOUT=$(dirname -- "$_resolved")
else else
CHECKOUT="$SELF_CHECKOUT" CHECKOUT="$SELF_CHECKOUT"
@@ -66,9 +75,26 @@ add() {
} }
# count_skills <dir> — directories containing a SKILL.md, one level down. # 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() { count_skills() {
[ -d "$1" ] || { printf '0'; return; } [ -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 ---------------------------------------------------- # --- the checkout itself ----------------------------------------------------
@@ -95,10 +121,47 @@ for entry in "$SKILLS_DIR"/*; do
fi fi
done 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 --------------------------------------------------------- # --- 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 \ for dir in \
"$HOME/.agents/skills" \
"$HOME/.config/crush/skills" \ "$HOME/.config/crush/skills" \
"$HOME/.config/opencode/skills" "$HOME/.config/opencode/skills"
do do
@@ -110,32 +173,23 @@ do
fi fi
# A real directory where a link belongs holds content nothing manages: it # A real directory where a link belongs holds content nothing manages: it
# cannot receive new skills and silently shadows the checkout. Distinguished # cannot receive new skills and silently shadows the canonical dir. Since
# from the foreign-checkout case below because the fix differs — move it # #27 the fix is not only "move it aside" — it can be added as an extra
# aside and re-run install.sh, rather than re-point a link. # source instead, which is how flamingo keeps the official Anthropic set.
if [ ! -L "$dir" ] && [ -d "$dir" ]; then 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 continue
fi 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) resolved=$(CDPATH= cd -- "$dir" 2>/dev/null && pwd -P)
canonical=$(CDPATH= cd -- "$SKILLS_DIR" 2>/dev/null && pwd -P) canonical=$(CDPATH= cd -- "$AGENTS_DIR" 2>/dev/null && pwd -P)
if [ -n "$resolved" ] && [ "$resolved" != "$canonical" ]; then if [ -n "$resolved" ] && [ -n "$canonical" ] && [ "$resolved" != "$canonical" ]; then
add FAIL outside "$dir resolves to $resolved, outside the active checkout $canonical" add FAIL outside "$dir resolves to $resolved, not the canonical $canonical"
continue
fi
got=$(count_skills "$dir")
if [ "$got" != "$EXPECTED" ]; then
add FAIL count "$dir sees $got skills, checkout has $EXPECTED"
fi fi
done done
# --- dangling links anywhere under the harness dirs ------------------------- # --- 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" "$HOME/.config/opencode/skills" "$HOME/.claude/skills"
do do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
+111 -6
View File
@@ -20,14 +20,24 @@
# cannot. This script covers the harnesses with no plugin system: Crush, Pi # cannot. This script covers the harnesses with no plugin system: Crush, Pi
# and OpenCode. # 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/crush/skills -> ~/.agents/skills
# ~/.config/opencode/skills -> ~/.agents/skills # ~/.config/opencode/skills -> ~/.agents/skills
# #
# Adding a skill to the repo therefore wires it everywhere with no re-run, # ~/.agents/skills was itself a link to this checkout until #27. That encoded
# and a broken link is one broken link rather than 42. # 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 # CHECKOUT_DIR deliberately defaults OUTSIDE any file-synced or
# cross-platform-shared directory. Do not "fix" it to point into ~/dev: on a # 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}" AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}" CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}"
OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/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' "$*"; } 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 ' ' 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> # link_dir <link_path> <target_path>
# Replaces whatever is there — including a directory left behind by the old # Replaces whatever is there — including a directory left behind by the old
# per-skill wiring — with a single symlink. # per-skill wiring — with a single symlink.
@@ -104,12 +207,14 @@ main() {
# Canonical location. Pi reads ~/.agents/skills directly, so it needs no # Canonical location. Pi reads ~/.agents/skills directly, so it needs no
# harness-specific link of its own. # 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 "$CRUSH_DIR" "$AGENTS_DIR"
link_dir "$OPENCODE_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" log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL"
} }
+48 -22
View File
@@ -37,11 +37,21 @@ new_fixture() {
printf '%s' "$_f" printf '%s' "$_f"
} }
# wire_clean <fixture> — the correct wiring: one canonical link, harnesses # wire_clean <fixture> [extra-source-dir] — the correct wiring since #27: the
# pointing at it. # 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() { wire_clean() {
mkdir -p "$1/home/.agents" "$1/home/.config/crush" "$1/home/.config/opencode" mkdir -p "$1/home/.agents/skills" "$1/home/.config/crush" "$1/home/.config/opencode"
ln -sfn "$1/ck/skills" "$1/home/.agents/skills" 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/crush/skills"
ln -sfn "$1/home/.agents/skills" "$1/home/.config/opencode/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) ---------- # --- 2. dangling link (the 243-link case, gitignored and invisible) ----------
f=$(new_fixture dangling); wire_clean "$f" f=$(new_fixture dangling); wire_clean "$f"
ln -sfn "$f/ck/skills-that-moved" "$f/home/.agents/skills" ln -sfn "$f/ck/skills/vanished" "$f/home/.agents/skills/alpha"
assert_fails "$f" 'dangling' 'dangling canonical link is reported' 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 # 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) f=$(new_fixture stale)
mkdir -p "$f/other/skills/alpha" "$f/other/skills/beta" mkdir -p "$f/other/alpha"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/other/skills/alpha/SKILL.md" printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/other/alpha/SKILL.md"
printf -- '---\nname: beta\ndescription: b\n---\n' > "$f/other/skills/beta/SKILL.md"
wire_clean "$f" wire_clean "$f"
ln -sfn "$f/other/skills" "$f/home/.agents/skills" ln -sfn "$f/other/alpha" "$f/home/.agents/skills/alpha"
assert_fails "$f" 'outside' 'link into a foreign checkout is reported' assert_fails "$f" 'unknown source' 'link into an unconfigured source is reported'
# --- 4. count mismatch (a harness seeing fewer skills than the checkout) ----- # --- 3b. a link into a CONFIGURED extra source is healthy -------------------
f=$(new_fixture count) # The whole point of #27: Anthropic's official set alongside this library.
mkdir -p "$f/home/.config/crush/skills/alpha" f=$(new_fixture multisource)
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/home/.config/crush/skills/alpha/SKILL.md" mkdir -p "$f/other/xlsx"
mkdir -p "$f/home/.agents" 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" 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) -------- # --- 5. stray entry (the loose secret-hygiene.md that loaded nowhere) --------
f=$(new_fixture stray); wire_clean "$f" 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 # 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 # 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. # from the fix for a link into a foreign checkout.
f=$(new_fixture realdir) f=$(new_fixture realdir); wire_clean "$f"
mkdir -p "$f/home/.agents" "$f/home/.config/crush/skills/alpha" rm -f "$f/home/.config/crush/skills"
ln -sfn "$f/ck/skills" "$f/home/.agents/skills" mkdir -p "$f/home/.config/crush/skills/alpha"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/home/.config/crush/skills/alpha/SKILL.md" 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"' assert_fails "$f" 'not a link' 'real directory is reported as unmanaged, not as "outside"'
+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 ]