Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
025f01aa80 | ||
|
|
eee62e4fc0 | ||
|
|
a2afb35d1d | ||
|
|
ad17a7c6c0 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "skills",
|
"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.",
|
"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": {
|
"author": {
|
||||||
"name": "Mathias",
|
"name": "Mathias",
|
||||||
"url": "https://git.d-ma.be/mathias"
|
"url": "https://git.d-ma.be/mathias"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
+133
-26
@@ -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
|
||||||
@@ -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"
|
|| add WARN plugin "skills@mathias is not installed for Claude Code — /plugin marketplace add https://git.d-ma.be/mathias/skills.git"
|
||||||
fi
|
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 -----------------------------------------------------------------
|
# --- output -----------------------------------------------------------------
|
||||||
if [ "$JSON" -eq 1 ]; then
|
if [ "$JSON" -eq 1 ]; then
|
||||||
printf '{"checkout":"%s","expected":%s,"findings":[%s]}\n' \
|
printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \
|
||||||
"$CHECKOUT" "$EXPECTED" "${report%,}"
|
"$CHECKOUT" "$EXPECTED" "$installed_v" "$available_v" "${report%,}"
|
||||||
elif [ "$findings" -gt 0 ]; then
|
elif [ "$findings" -gt 0 ]; then
|
||||||
printf 'skills doctor — %s finding(s), checkout %s\n\n%s\n' \
|
printf 'skills doctor — %s finding(s), checkout %s\n\n%s\n' \
|
||||||
"$findings" "$CHECKOUT" "$report"
|
"$findings" "$CHECKOUT" "$report"
|
||||||
|
|||||||
+111
-6
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+106
-22
@@ -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"'
|
||||||
|
|
||||||
@@ -133,5 +159,63 @@ mkdir -p "$f/home/.claude/skills"
|
|||||||
ln -sfn "$f/ck/skills/vanished" "$f/home/.claude/skills/vanished"
|
ln -sfn "$f/ck/skills/vanished" "$f/home/.claude/skills/vanished"
|
||||||
assert_fails "$f" 'dangling' 'dangling link inside a harness dir is reported'
|
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"
|
printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|||||||
@@ -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