Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
025f01aa80 | ||
|
|
eee62e4fc0 | ||
|
|
a2afb35d1d | ||
|
|
ad17a7c6c0 | ||
|
|
21dbb35526 | ||
|
|
8ad2079f86 | ||
|
|
b188965161 | ||
|
|
ae66840a03 | ||
|
|
e7e514390b | ||
|
|
087685f2e3 |
@@ -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.1",
|
"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.
|
||||||
|
|
||||||
|
|||||||
+24
-18
@@ -39,7 +39,6 @@ This index lists all available engineering skills. Load the full SKILL.md on dem
|
|||||||
| `superpowers-dispatching-parallel-agents` | Fan work out across parallel subagents | Decomposing work for concurrent agents |
|
| `superpowers-dispatching-parallel-agents` | Fan work out across parallel subagents | Decomposing work for concurrent agents |
|
||||||
| `superpowers-subagent-driven-development` | Subagent-driven development workflow | Building via orchestrated subagents |
|
| `superpowers-subagent-driven-development` | Subagent-driven development workflow | Building via orchestrated subagents |
|
||||||
| `superpowers-systematic-debugging` | Hypothesis-first systematic debugging | Diagnosing a failure methodically |
|
| `superpowers-systematic-debugging` | Hypothesis-first systematic debugging | Diagnosing a failure methodically |
|
||||||
| `superpowers-test-driven-development` | TDD discipline (superpowers variant) | Red → green → refactor on a change |
|
|
||||||
| `superpowers-verification-before-completion` | Verify behavior before claiming done | Before marking any task complete |
|
| `superpowers-verification-before-completion` | Verify behavior before claiming done | Before marking any task complete |
|
||||||
| `superpowers-requesting-code-review` | Requesting a code review well | Asking another agent/human to review |
|
| `superpowers-requesting-code-review` | Requesting a code review well | Asking another agent/human to review |
|
||||||
| `superpowers-receiving-code-review` | Acting on received review feedback | Responding to review comments |
|
| `superpowers-receiving-code-review` | Acting on received review feedback | Responding to review comments |
|
||||||
@@ -71,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
|
||||||
@@ -95,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
|
||||||
|
|
||||||
@@ -109,14 +113,16 @@ problem-analysis
|
|||||||
│
|
│
|
||||||
├── spec-driven-dev (PM-kickoff lifecycle)
|
├── spec-driven-dev (PM-kickoff lifecycle)
|
||||||
│ │
|
│ │
|
||||||
│ └── feature-spec (per-feature, mid-implementation)
|
│ ├── feature-spec (per-feature, mid-implementation)
|
||||||
│ │
|
│ │ │
|
||||||
│ └── planning
|
│ │ └── planning
|
||||||
│ │
|
│ │ │
|
||||||
│ └── atdd ──→ tdd (foundation)
|
│ │ └── atdd ──→ tdd (foundation)
|
||||||
│ ├──→ debug (when tests fail)
|
│ │ ├──→ debug (when tests fail)
|
||||||
│ ├──→ clean-code (REFACTOR phase)
|
│ │ ├──→ clean-code (REFACTOR phase)
|
||||||
│ └──→ test-design (review)
|
│ │ └──→ test-design (review)
|
||||||
|
│ │
|
||||||
|
│ └── experiment-spec (research/ML phase, instead of feature-spec)
|
||||||
│
|
│
|
||||||
└── planning
|
└── planning
|
||||||
|
|
||||||
|
|||||||
+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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
---
|
|
||||||
name: test-driven-development
|
|
||||||
description: Use when implementing any feature or bugfix, before writing implementation code
|
|
||||||
---
|
|
||||||
|
|
||||||
# Test-Driven Development (TDD)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Write the test first. Watch it fail. Write minimal code to pass.
|
|
||||||
|
|
||||||
**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing.
|
|
||||||
|
|
||||||
**Violating the letter of the rules is violating the spirit of the rules.**
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
|
|
||||||
**Always:**
|
|
||||||
- New features
|
|
||||||
- Bug fixes
|
|
||||||
- Refactoring
|
|
||||||
- Behavior changes
|
|
||||||
|
|
||||||
**Exceptions (ask your human partner):**
|
|
||||||
- Throwaway prototypes
|
|
||||||
- Generated code
|
|
||||||
- Configuration files
|
|
||||||
|
|
||||||
Thinking "skip TDD just this once"? Stop. That's rationalization.
|
|
||||||
|
|
||||||
## The Iron Law
|
|
||||||
|
|
||||||
```
|
|
||||||
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
|
|
||||||
```
|
|
||||||
|
|
||||||
Write code before the test? Delete it. Start over.
|
|
||||||
|
|
||||||
**No exceptions:**
|
|
||||||
- Don't keep it as "reference"
|
|
||||||
- Don't "adapt" it while writing tests
|
|
||||||
- Don't look at it
|
|
||||||
- Delete means delete
|
|
||||||
|
|
||||||
Implement fresh from tests. Period.
|
|
||||||
|
|
||||||
## Red-Green-Refactor
|
|
||||||
|
|
||||||
```dot
|
|
||||||
digraph tdd_cycle {
|
|
||||||
rankdir=LR;
|
|
||||||
red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"];
|
|
||||||
verify_red [label="Verify fails\ncorrectly", shape=diamond];
|
|
||||||
green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"];
|
|
||||||
verify_green [label="Verify passes\nAll green", shape=diamond];
|
|
||||||
refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"];
|
|
||||||
next [label="Next", shape=ellipse];
|
|
||||||
|
|
||||||
red -> verify_red;
|
|
||||||
verify_red -> green [label="yes"];
|
|
||||||
verify_red -> red [label="wrong\nfailure"];
|
|
||||||
green -> verify_green;
|
|
||||||
verify_green -> refactor [label="yes"];
|
|
||||||
verify_green -> green [label="no"];
|
|
||||||
refactor -> verify_green [label="stay\ngreen"];
|
|
||||||
verify_green -> next;
|
|
||||||
next -> red;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### RED - Write Failing Test
|
|
||||||
|
|
||||||
Write one minimal test showing what should happen.
|
|
||||||
|
|
||||||
<Good>
|
|
||||||
```typescript
|
|
||||||
test('retries failed operations 3 times', async () => {
|
|
||||||
let attempts = 0;
|
|
||||||
const operation = () => {
|
|
||||||
attempts++;
|
|
||||||
if (attempts < 3) throw new Error('fail');
|
|
||||||
return 'success';
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await retryOperation(operation);
|
|
||||||
|
|
||||||
expect(result).toBe('success');
|
|
||||||
expect(attempts).toBe(3);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
Clear name, tests real behavior, one thing
|
|
||||||
</Good>
|
|
||||||
|
|
||||||
<Bad>
|
|
||||||
```typescript
|
|
||||||
test('retry works', async () => {
|
|
||||||
const mock = jest.fn()
|
|
||||||
.mockRejectedValueOnce(new Error())
|
|
||||||
.mockRejectedValueOnce(new Error())
|
|
||||||
.mockResolvedValueOnce('success');
|
|
||||||
await retryOperation(mock);
|
|
||||||
expect(mock).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
Vague name, tests mock not code
|
|
||||||
</Bad>
|
|
||||||
|
|
||||||
**Requirements:**
|
|
||||||
- One behavior
|
|
||||||
- Clear name
|
|
||||||
- Real code (no mocks unless unavoidable)
|
|
||||||
|
|
||||||
### Verify RED - Watch It Fail
|
|
||||||
|
|
||||||
**MANDATORY. Never skip.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test path/to/test.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm:
|
|
||||||
- Test fails (not errors)
|
|
||||||
- Failure message is expected
|
|
||||||
- Fails because feature missing (not typos)
|
|
||||||
|
|
||||||
**Test passes?** You're testing existing behavior. Fix test.
|
|
||||||
|
|
||||||
**Test errors?** Fix error, re-run until it fails correctly.
|
|
||||||
|
|
||||||
### GREEN - Minimal Code
|
|
||||||
|
|
||||||
Write simplest code to pass the test.
|
|
||||||
|
|
||||||
<Good>
|
|
||||||
```typescript
|
|
||||||
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (e) {
|
|
||||||
if (i === 2) throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('unreachable');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Just enough to pass
|
|
||||||
</Good>
|
|
||||||
|
|
||||||
<Bad>
|
|
||||||
```typescript
|
|
||||||
async function retryOperation<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
options?: {
|
|
||||||
maxRetries?: number;
|
|
||||||
backoff?: 'linear' | 'exponential';
|
|
||||||
onRetry?: (attempt: number) => void;
|
|
||||||
}
|
|
||||||
): Promise<T> {
|
|
||||||
// YAGNI
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Over-engineered
|
|
||||||
</Bad>
|
|
||||||
|
|
||||||
Don't add features, refactor other code, or "improve" beyond the test.
|
|
||||||
|
|
||||||
### Verify GREEN - Watch It Pass
|
|
||||||
|
|
||||||
**MANDATORY.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test path/to/test.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm:
|
|
||||||
- Test passes
|
|
||||||
- Other tests still pass
|
|
||||||
- Output pristine (no errors, warnings)
|
|
||||||
|
|
||||||
**Test fails?** Fix code, not test.
|
|
||||||
|
|
||||||
**Other tests fail?** Fix now.
|
|
||||||
|
|
||||||
### REFACTOR - Clean Up
|
|
||||||
|
|
||||||
After green only:
|
|
||||||
- Remove duplication
|
|
||||||
- Improve names
|
|
||||||
- Extract helpers
|
|
||||||
|
|
||||||
Keep tests green. Don't add behavior.
|
|
||||||
|
|
||||||
### Repeat
|
|
||||||
|
|
||||||
Next failing test for next feature.
|
|
||||||
|
|
||||||
## Good Tests
|
|
||||||
|
|
||||||
| Quality | Good | Bad |
|
|
||||||
|---------|------|-----|
|
|
||||||
| **Minimal** | One thing. "and" in name? Split it. | `test('validates email and domain and whitespace')` |
|
|
||||||
| **Clear** | Name describes behavior | `test('test1')` |
|
|
||||||
| **Shows intent** | Demonstrates desired API | Obscures what code should do |
|
|
||||||
|
|
||||||
## Why Order Matters
|
|
||||||
|
|
||||||
**"I'll write tests after to verify it works"**
|
|
||||||
|
|
||||||
Tests written after code pass immediately. Passing immediately proves nothing:
|
|
||||||
- Might test wrong thing
|
|
||||||
- Might test implementation, not behavior
|
|
||||||
- Might miss edge cases you forgot
|
|
||||||
- You never saw it catch the bug
|
|
||||||
|
|
||||||
Test-first forces you to see the test fail, proving it actually tests something.
|
|
||||||
|
|
||||||
**"I already manually tested all the edge cases"**
|
|
||||||
|
|
||||||
Manual testing is ad-hoc. You think you tested everything but:
|
|
||||||
- No record of what you tested
|
|
||||||
- Can't re-run when code changes
|
|
||||||
- Easy to forget cases under pressure
|
|
||||||
- "It worked when I tried it" ≠ comprehensive
|
|
||||||
|
|
||||||
Automated tests are systematic. They run the same way every time.
|
|
||||||
|
|
||||||
**"Deleting X hours of work is wasteful"**
|
|
||||||
|
|
||||||
Sunk cost fallacy. The time is already gone. Your choice now:
|
|
||||||
- Delete and rewrite with TDD (X more hours, high confidence)
|
|
||||||
- Keep it and add tests after (30 min, low confidence, likely bugs)
|
|
||||||
|
|
||||||
The "waste" is keeping code you can't trust. Working code without real tests is technical debt.
|
|
||||||
|
|
||||||
**"TDD is dogmatic, being pragmatic means adapting"**
|
|
||||||
|
|
||||||
TDD IS pragmatic:
|
|
||||||
- Finds bugs before commit (faster than debugging after)
|
|
||||||
- Prevents regressions (tests catch breaks immediately)
|
|
||||||
- Documents behavior (tests show how to use code)
|
|
||||||
- Enables refactoring (change freely, tests catch breaks)
|
|
||||||
|
|
||||||
"Pragmatic" shortcuts = debugging in production = slower.
|
|
||||||
|
|
||||||
**"Tests after achieve the same goals - it's spirit not ritual"**
|
|
||||||
|
|
||||||
No. Tests-after answer "What does this do?" Tests-first answer "What should this do?"
|
|
||||||
|
|
||||||
Tests-after are biased by your implementation. You test what you built, not what's required. You verify remembered edge cases, not discovered ones.
|
|
||||||
|
|
||||||
Tests-first force edge case discovery before implementing. Tests-after verify you remembered everything (you didn't).
|
|
||||||
|
|
||||||
30 minutes of tests after ≠ TDD. You get coverage, lose proof tests work.
|
|
||||||
|
|
||||||
## Common Rationalizations
|
|
||||||
|
|
||||||
| Excuse | Reality |
|
|
||||||
|--------|---------|
|
|
||||||
| "Too simple to test" | Simple code breaks. Test takes 30 seconds. |
|
|
||||||
| "I'll test after" | Tests passing immediately prove nothing. |
|
|
||||||
| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" |
|
|
||||||
| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. |
|
|
||||||
| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. |
|
|
||||||
| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. |
|
|
||||||
| "Need to explore first" | Fine. Throw away exploration, start with TDD. |
|
|
||||||
| "Test hard = design unclear" | Listen to test. Hard to test = hard to use. |
|
|
||||||
| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. |
|
|
||||||
| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. |
|
|
||||||
| "Existing code has no tests" | You're improving it. Add tests for existing code. |
|
|
||||||
|
|
||||||
## Red Flags - STOP and Start Over
|
|
||||||
|
|
||||||
- Code before test
|
|
||||||
- Test after implementation
|
|
||||||
- Test passes immediately
|
|
||||||
- Can't explain why test failed
|
|
||||||
- Tests added "later"
|
|
||||||
- Rationalizing "just this once"
|
|
||||||
- "I already manually tested it"
|
|
||||||
- "Tests after achieve the same purpose"
|
|
||||||
- "It's about spirit not ritual"
|
|
||||||
- "Keep as reference" or "adapt existing code"
|
|
||||||
- "Already spent X hours, deleting is wasteful"
|
|
||||||
- "TDD is dogmatic, I'm being pragmatic"
|
|
||||||
- "This is different because..."
|
|
||||||
|
|
||||||
**All of these mean: Delete code. Start over with TDD.**
|
|
||||||
|
|
||||||
## Example: Bug Fix
|
|
||||||
|
|
||||||
**Bug:** Empty email accepted
|
|
||||||
|
|
||||||
**RED**
|
|
||||||
```typescript
|
|
||||||
test('rejects empty email', async () => {
|
|
||||||
const result = await submitForm({ email: '' });
|
|
||||||
expect(result.error).toBe('Email required');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify RED**
|
|
||||||
```bash
|
|
||||||
$ npm test
|
|
||||||
FAIL: expected 'Email required', got undefined
|
|
||||||
```
|
|
||||||
|
|
||||||
**GREEN**
|
|
||||||
```typescript
|
|
||||||
function submitForm(data: FormData) {
|
|
||||||
if (!data.email?.trim()) {
|
|
||||||
return { error: 'Email required' };
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify GREEN**
|
|
||||||
```bash
|
|
||||||
$ npm test
|
|
||||||
PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
**REFACTOR**
|
|
||||||
Extract validation for multiple fields if needed.
|
|
||||||
|
|
||||||
## Verification Checklist
|
|
||||||
|
|
||||||
Before marking work complete:
|
|
||||||
|
|
||||||
- [ ] Every new function/method has a test
|
|
||||||
- [ ] Watched each test fail before implementing
|
|
||||||
- [ ] Each test failed for expected reason (feature missing, not typo)
|
|
||||||
- [ ] Wrote minimal code to pass each test
|
|
||||||
- [ ] All tests pass
|
|
||||||
- [ ] Output pristine (no errors, warnings)
|
|
||||||
- [ ] Tests use real code (mocks only if unavoidable)
|
|
||||||
- [ ] Edge cases and errors covered
|
|
||||||
|
|
||||||
Can't check all boxes? You skipped TDD. Start over.
|
|
||||||
|
|
||||||
## When Stuck
|
|
||||||
|
|
||||||
| Problem | Solution |
|
|
||||||
|---------|----------|
|
|
||||||
| Don't know how to test | Write wished-for API. Write assertion first. Ask your human partner. |
|
|
||||||
| Test too complicated | Design too complicated. Simplify interface. |
|
|
||||||
| Must mock everything | Code too coupled. Use dependency injection. |
|
|
||||||
| Test setup huge | Extract helpers. Still complex? Simplify design. |
|
|
||||||
|
|
||||||
## Debugging Integration
|
|
||||||
|
|
||||||
Bug found? Write failing test reproducing it. Follow TDD cycle. Test proves fix and prevents regression.
|
|
||||||
|
|
||||||
Never fix bugs without a test.
|
|
||||||
|
|
||||||
## Testing Anti-Patterns
|
|
||||||
|
|
||||||
When adding mocks or test utilities, read @testing-anti-patterns.md to avoid common pitfalls:
|
|
||||||
- Testing mock behavior instead of real behavior
|
|
||||||
- Adding test-only methods to production classes
|
|
||||||
- Mocking without understanding dependencies
|
|
||||||
|
|
||||||
## Final Rule
|
|
||||||
|
|
||||||
```
|
|
||||||
Production code → test exists and failed first
|
|
||||||
Otherwise → not TDD
|
|
||||||
```
|
|
||||||
|
|
||||||
No exceptions without your human partner's permission.
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# Testing Anti-Patterns
|
|
||||||
|
|
||||||
**Load this reference when:** writing or changing tests, adding mocks, or tempted to add test-only methods to production code.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Tests must verify real behavior, not mock behavior. Mocks are a means to isolate, not the thing being tested.
|
|
||||||
|
|
||||||
**Core principle:** Test what the code does, not what the mocks do.
|
|
||||||
|
|
||||||
**Following strict TDD prevents these anti-patterns.**
|
|
||||||
|
|
||||||
## The Iron Laws
|
|
||||||
|
|
||||||
```
|
|
||||||
1. NEVER test mock behavior
|
|
||||||
2. NEVER add test-only methods to production classes
|
|
||||||
3. NEVER mock without understanding dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Pattern 1: Testing Mock Behavior
|
|
||||||
|
|
||||||
**The violation:**
|
|
||||||
```typescript
|
|
||||||
// ❌ BAD: Testing that the mock exists
|
|
||||||
test('renders sidebar', () => {
|
|
||||||
render(<Page />);
|
|
||||||
expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is wrong:**
|
|
||||||
- You're verifying the mock works, not that the component works
|
|
||||||
- Test passes when mock is present, fails when it's not
|
|
||||||
- Tells you nothing about real behavior
|
|
||||||
|
|
||||||
**your human partner's correction:** "Are we testing the behavior of a mock?"
|
|
||||||
|
|
||||||
**The fix:**
|
|
||||||
```typescript
|
|
||||||
// ✅ GOOD: Test real component or don't mock it
|
|
||||||
test('renders sidebar', () => {
|
|
||||||
render(<Page />); // Don't mock sidebar
|
|
||||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// OR if sidebar must be mocked for isolation:
|
|
||||||
// Don't assert on the mock - test Page's behavior with sidebar present
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gate Function
|
|
||||||
|
|
||||||
```
|
|
||||||
BEFORE asserting on any mock element:
|
|
||||||
Ask: "Am I testing real component behavior or just mock existence?"
|
|
||||||
|
|
||||||
IF testing mock existence:
|
|
||||||
STOP - Delete the assertion or unmock the component
|
|
||||||
|
|
||||||
Test real behavior instead
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Pattern 2: Test-Only Methods in Production
|
|
||||||
|
|
||||||
**The violation:**
|
|
||||||
```typescript
|
|
||||||
// ❌ BAD: destroy() only used in tests
|
|
||||||
class Session {
|
|
||||||
async destroy() { // Looks like production API!
|
|
||||||
await this._workspaceManager?.destroyWorkspace(this.id);
|
|
||||||
// ... cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In tests
|
|
||||||
afterEach(() => session.destroy());
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is wrong:**
|
|
||||||
- Production class polluted with test-only code
|
|
||||||
- Dangerous if accidentally called in production
|
|
||||||
- Violates YAGNI and separation of concerns
|
|
||||||
- Confuses object lifecycle with entity lifecycle
|
|
||||||
|
|
||||||
**The fix:**
|
|
||||||
```typescript
|
|
||||||
// ✅ GOOD: Test utilities handle test cleanup
|
|
||||||
// Session has no destroy() - it's stateless in production
|
|
||||||
|
|
||||||
// In test-utils/
|
|
||||||
export async function cleanupSession(session: Session) {
|
|
||||||
const workspace = session.getWorkspaceInfo();
|
|
||||||
if (workspace) {
|
|
||||||
await workspaceManager.destroyWorkspace(workspace.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In tests
|
|
||||||
afterEach(() => cleanupSession(session));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gate Function
|
|
||||||
|
|
||||||
```
|
|
||||||
BEFORE adding any method to production class:
|
|
||||||
Ask: "Is this only used by tests?"
|
|
||||||
|
|
||||||
IF yes:
|
|
||||||
STOP - Don't add it
|
|
||||||
Put it in test utilities instead
|
|
||||||
|
|
||||||
Ask: "Does this class own this resource's lifecycle?"
|
|
||||||
|
|
||||||
IF no:
|
|
||||||
STOP - Wrong class for this method
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Pattern 3: Mocking Without Understanding
|
|
||||||
|
|
||||||
**The violation:**
|
|
||||||
```typescript
|
|
||||||
// ❌ BAD: Mock breaks test logic
|
|
||||||
test('detects duplicate server', () => {
|
|
||||||
// Mock prevents config write that test depends on!
|
|
||||||
vi.mock('ToolCatalog', () => ({
|
|
||||||
discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
|
|
||||||
}));
|
|
||||||
|
|
||||||
await addServer(config);
|
|
||||||
await addServer(config); // Should throw - but won't!
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is wrong:**
|
|
||||||
- Mocked method had side effect test depended on (writing config)
|
|
||||||
- Over-mocking to "be safe" breaks actual behavior
|
|
||||||
- Test passes for wrong reason or fails mysteriously
|
|
||||||
|
|
||||||
**The fix:**
|
|
||||||
```typescript
|
|
||||||
// ✅ GOOD: Mock at correct level
|
|
||||||
test('detects duplicate server', () => {
|
|
||||||
// Mock the slow part, preserve behavior test needs
|
|
||||||
vi.mock('MCPServerManager'); // Just mock slow server startup
|
|
||||||
|
|
||||||
await addServer(config); // Config written
|
|
||||||
await addServer(config); // Duplicate detected ✓
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gate Function
|
|
||||||
|
|
||||||
```
|
|
||||||
BEFORE mocking any method:
|
|
||||||
STOP - Don't mock yet
|
|
||||||
|
|
||||||
1. Ask: "What side effects does the real method have?"
|
|
||||||
2. Ask: "Does this test depend on any of those side effects?"
|
|
||||||
3. Ask: "Do I fully understand what this test needs?"
|
|
||||||
|
|
||||||
IF depends on side effects:
|
|
||||||
Mock at lower level (the actual slow/external operation)
|
|
||||||
OR use test doubles that preserve necessary behavior
|
|
||||||
NOT the high-level method the test depends on
|
|
||||||
|
|
||||||
IF unsure what test depends on:
|
|
||||||
Run test with real implementation FIRST
|
|
||||||
Observe what actually needs to happen
|
|
||||||
THEN add minimal mocking at the right level
|
|
||||||
|
|
||||||
Red flags:
|
|
||||||
- "I'll mock this to be safe"
|
|
||||||
- "This might be slow, better mock it"
|
|
||||||
- Mocking without understanding the dependency chain
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Pattern 4: Incomplete Mocks
|
|
||||||
|
|
||||||
**The violation:**
|
|
||||||
```typescript
|
|
||||||
// ❌ BAD: Partial mock - only fields you think you need
|
|
||||||
const mockResponse = {
|
|
||||||
status: 'success',
|
|
||||||
data: { userId: '123', name: 'Alice' }
|
|
||||||
// Missing: metadata that downstream code uses
|
|
||||||
};
|
|
||||||
|
|
||||||
// Later: breaks when code accesses response.metadata.requestId
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is wrong:**
|
|
||||||
- **Partial mocks hide structural assumptions** - You only mocked fields you know about
|
|
||||||
- **Downstream code may depend on fields you didn't include** - Silent failures
|
|
||||||
- **Tests pass but integration fails** - Mock incomplete, real API complete
|
|
||||||
- **False confidence** - Test proves nothing about real behavior
|
|
||||||
|
|
||||||
**The Iron Rule:** Mock the COMPLETE data structure as it exists in reality, not just fields your immediate test uses.
|
|
||||||
|
|
||||||
**The fix:**
|
|
||||||
```typescript
|
|
||||||
// ✅ GOOD: Mirror real API completeness
|
|
||||||
const mockResponse = {
|
|
||||||
status: 'success',
|
|
||||||
data: { userId: '123', name: 'Alice' },
|
|
||||||
metadata: { requestId: 'req-789', timestamp: 1234567890 }
|
|
||||||
// All fields real API returns
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gate Function
|
|
||||||
|
|
||||||
```
|
|
||||||
BEFORE creating mock responses:
|
|
||||||
Check: "What fields does the real API response contain?"
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
1. Examine actual API response from docs/examples
|
|
||||||
2. Include ALL fields system might consume downstream
|
|
||||||
3. Verify mock matches real response schema completely
|
|
||||||
|
|
||||||
Critical:
|
|
||||||
If you're creating a mock, you must understand the ENTIRE structure
|
|
||||||
Partial mocks fail silently when code depends on omitted fields
|
|
||||||
|
|
||||||
If uncertain: Include all documented fields
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Pattern 5: Integration Tests as Afterthought
|
|
||||||
|
|
||||||
**The violation:**
|
|
||||||
```
|
|
||||||
✅ Implementation complete
|
|
||||||
❌ No tests written
|
|
||||||
"Ready for testing"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is wrong:**
|
|
||||||
- Testing is part of implementation, not optional follow-up
|
|
||||||
- TDD would have caught this
|
|
||||||
- Can't claim complete without tests
|
|
||||||
|
|
||||||
**The fix:**
|
|
||||||
```
|
|
||||||
TDD cycle:
|
|
||||||
1. Write failing test
|
|
||||||
2. Implement to pass
|
|
||||||
3. Refactor
|
|
||||||
4. THEN claim complete
|
|
||||||
```
|
|
||||||
|
|
||||||
## When Mocks Become Too Complex
|
|
||||||
|
|
||||||
**Warning signs:**
|
|
||||||
- Mock setup longer than test logic
|
|
||||||
- Mocking everything to make test pass
|
|
||||||
- Mocks missing methods real components have
|
|
||||||
- Test breaks when mock changes
|
|
||||||
|
|
||||||
**your human partner's question:** "Do we need to be using a mock here?"
|
|
||||||
|
|
||||||
**Consider:** Integration tests with real components often simpler than complex mocks
|
|
||||||
|
|
||||||
## TDD Prevents These Anti-Patterns
|
|
||||||
|
|
||||||
**Why TDD helps:**
|
|
||||||
1. **Write test first** → Forces you to think about what you're actually testing
|
|
||||||
2. **Watch it fail** → Confirms test tests real behavior, not mocks
|
|
||||||
3. **Minimal implementation** → No test-only methods creep in
|
|
||||||
4. **Real dependencies** → You see what the test actually needs before mocking
|
|
||||||
|
|
||||||
**If you're testing mock behavior, you violated TDD** - you added mocks without watching test fail against real code first.
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
| Anti-Pattern | Fix |
|
|
||||||
|--------------|-----|
|
|
||||||
| Assert on mock elements | Test real component or unmock it |
|
|
||||||
| Test-only methods in production | Move to test utilities |
|
|
||||||
| Mock without understanding | Understand dependencies first, mock minimally |
|
|
||||||
| Incomplete mocks | Mirror real API completely |
|
|
||||||
| Tests as afterthought | TDD - tests first |
|
|
||||||
| Over-complex mocks | Consider integration tests |
|
|
||||||
|
|
||||||
## Red Flags
|
|
||||||
|
|
||||||
- Assertion checks for `*-mock` test IDs
|
|
||||||
- Methods only called in test files
|
|
||||||
- Mock setup is >50% of test
|
|
||||||
- Test fails when you remove mock
|
|
||||||
- Can't explain why mock is needed
|
|
||||||
- Mocking "just to be safe"
|
|
||||||
|
|
||||||
## The Bottom Line
|
|
||||||
|
|
||||||
**Mocks are tools to isolate, not things to test.**
|
|
||||||
|
|
||||||
If TDD reveals you're testing mock behavior, you've gone wrong.
|
|
||||||
|
|
||||||
Fix: Test real behavior or question why you're mocking at all.
|
|
||||||
@@ -22,18 +22,28 @@ Screenshot a locally-running web UI without installing a browser on the agent's
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
`shot.sh` ships beside this SKILL.md, so resolve it from wherever the harness
|
||||||
|
installed the skill rather than from a checkout path — an absolute `~/dev/...`
|
||||||
|
went stale the first time the repo moved and cost a caller three hand-rolled
|
||||||
|
Job attempts before they found the working script (#26).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Claude Code installs the plugin; Crush/Pi/OpenCode get ~/.agents/skills.
|
||||||
|
SHOT=$(ls ~/.claude/plugins/marketplaces/*/skills/web-shot/shot.sh \
|
||||||
|
~/.agents/skills/web-shot/shot.sh 2>/dev/null | head -1 || true)
|
||||||
|
[ -n "$SHOT" ] || { echo "web-shot not installed on this host: run install.sh or the plugin install" >&2; exit 1; }
|
||||||
|
|
||||||
# auto-detect Tailscale hostname, screenshot port 8099 root
|
# auto-detect Tailscale hostname, screenshot port 8099 root
|
||||||
~/dev/.skills/web-shot/shot.sh --port 8099
|
"$SHOT" --port 8099
|
||||||
|
|
||||||
# explicit URL
|
# explicit URL
|
||||||
~/dev/.skills/web-shot/shot.sh --url http://flamingo:5173
|
"$SHOT" --url http://flamingo:5173
|
||||||
|
|
||||||
# capture a search state too (types into input[name=q])
|
# capture a search state too (types into input[name=q])
|
||||||
~/dev/.skills/web-shot/shot.sh --port 8181 --search agentsquad
|
"$SHOT" --port 8181 --search agentsquad
|
||||||
|
|
||||||
# different output dir
|
# different output dir
|
||||||
~/dev/.skills/web-shot/shot.sh --port 3000 --out /tmp/shots
|
"$SHOT" --port 3000 --out /tmp/shots
|
||||||
```
|
```
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|||||||
+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