feat(install): serve several sources instead of displacing them
~/.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:
+48
-22
@@ -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"'
|
||||
|
||||
|
||||
@@ -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 ]
|
||||
Reference in New Issue
Block a user