diff --git a/README.md b/README.md index 3ef61db..ca1c9f9 100644 --- a/README.md +++ b/README.md @@ -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/` | `$SKILLS_CHECKOUT_DIR/skills/`, 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.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. diff --git a/SKILLS_INDEX.md b/SKILLS_INDEX.md index d4c8bd3..b1ee51d 100644 --- a/SKILLS_INDEX.md +++ b/SKILLS_INDEX.md @@ -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 diff --git a/bin/doctor b/bin/doctor index e86be33..2cdac4e 100755 --- a/bin/doctor +++ b/bin/doctor @@ -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 — 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 — 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 diff --git a/install.sh b/install.sh index 6ee5a73..bfbd1cd 100755 --- a/install.sh +++ b/install.sh @@ -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.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 # 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" } diff --git a/tests/doctor_test.sh b/tests/doctor_test.sh index 5a26ddd..bcdc390 100644 --- a/tests/doctor_test.sh +++ b/tests/doctor_test.sh @@ -37,11 +37,21 @@ new_fixture() { printf '%s' "$_f" } -# wire_clean — the correct wiring: one canonical link, harnesses -# pointing at it. +# wire_clean [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"' diff --git a/tests/install_test.sh b/tests/install_test.sh new file mode 100644 index 0000000..90f8908 --- /dev/null +++ b/tests/install_test.sh @@ -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 — a minimal valid skill. +skill() { + mkdir -p "$1/$2" + printf -- '---\nname: %s\ndescription: d\n---\n' "$2" > "$1/$2/SKILL.md" +} + +# new_fixture [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 [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 ]