feat(mods): model-router active in every session — skills-dir link, mods suite, doctor section, CLAUDE.md

Tracked relative symlink skills/model-router -> ../mods/model-router: Claude
Code loads the mod in place as model-router@skills-dir wherever link.sh
links ~/.claude/skills (no CLAUDE_CODE_PLUGIN_DIRS: absolute paths in the
tracked settings.json). Engine-laid mods/*/tsconfig.json gitignored.
lib/tests/mods.test.sh: manifest name, link target, claude plugin validate
and test per mod, capability-probed, time-bounded, SKIP with reason.
doctor.sh: fail-soft Mods section (link by -ef, one guarded plugin list).
CLAUDE.md: mods/ section (loading, per-machine enabled:false switch,
dev-copy shadowing, tests).
This commit is contained in:
bchanot
2026-10-09 10:51:13 +02:00
parent 24e180ade0
commit 6430ac65ec
5 changed files with 172 additions and 0 deletions
+4
View File
@@ -262,3 +262,7 @@ skills-external/.higgsfield-stage.*/
# ── gitflow standard socle (added by gitflow_init; additive, safe to edit) ── # ── gitflow standard socle (added by gitflow_init; additive, safe to edit) ──
*.log *.log
!.claude/deploy/ !.claude/deploy/
# mods/: the engine lays tsconfig.json beside a loaded mod; its
# .claude-plugin/types/ ignores itself
mods/*/tsconfig.json
+22
View File
@@ -59,6 +59,28 @@ Gotcha, learned the hard way: `git rm --cached` keeps the working file,
but if the branch you merge into still tracks it, the merge deletes it but if the branch you merge into still tracks it, the merge deletes it
from disk. Untrack and merge, then restore with the command above. from disk. Untrack and merge, then restore with the command above.
## mods/ — function-hooks plugins (Claude Code mods)
A mod lives in `mods/<name>/` (`.claude-plugin/plugin.json` + hooks). It
loads through the tracked relative symlink `skills/<name>` -> `../mods/<name>`
(`~/.claude/skills` links to `skills/`) as `<name>@skills-dir`, in place,
live at the next session or `/reload-plugins`. New mod: `ln -s ../mods/<name>
skills/<name>` from the repo root (guard with `[ -L ]`, a re-run nests a link).
Not `CLAUDE_CODE_PLUGIN_DIRS` (absolute path, settings `env` has no `$HOME`
expansion, settings.json is tracked), nor a local marketplace (`add` writes
an absolute path into settings.json).
- The engine lays `mods/<name>/tsconfig.json` and `.claude-plugin/types/`;
both are gitignored.
- Optional user config: `~/.claude/<name>.json`. Its `"enabled": false` is the
per-machine off switch (untracked). `"<name>@skills-dir": false` in
`enabledPlugins` also works but lands in the TRACKED settings.json and
dirties every machine's tree.
- A dev copy of the same name (`--plugin-dir`, hot-reload link in
`~/.claude/dev-mods/<session>/`) shadows the skills-dir copy for that
session: remove it before reading `/reload-plugins` as a test of the link.
- Tests: `make test suite=lib/tests/mods.test.sh` (manifest, link,
`claude plugin validate`, `claude plugin test`). `doctor.sh` has a Mods section.
## Transient planning artifacts ## Transient planning artifacts
`docs/superpowers/specs/**` and `docs/superpowers/plans/**` are run-time `docs/superpowers/specs/**` and `docs/superpowers/plans/**` are run-time
+58
View File
@@ -155,6 +155,64 @@ unset _dv_active_profile _dv_profile_file
echo "" echo ""
# ────────────────────────────────────────────────────────────
# 2c. Mods (mods/<name>/ plugins, loaded through the tracked
# skills/<name> symlink as <name>@skills-dir). Fail-soft: a missing link
# is info (the user may have removed it on purpose), never an error.
# ────────────────────────────────────────────────────────────
echo "── Mods ──"
# Prints enabled|disabled|absent|unknown for $1 read from the JSON on stdin;
# always exits 0 so a bad payload cannot abort doctor under set -e.
mod_state() {
python3 -c '
import json, sys
try:
rows = json.load(sys.stdin)
row = [r for r in rows if r.get("id") == sys.argv[1] + "@skills-dir"]
print("absent" if not row else
"enabled" if row[0].get("enabled") is True else "disabled")
except Exception:
print("unknown")
' "$1" 2>/dev/null || true
}
_mods_list=""
if command -v claude &>/dev/null; then
if ! _mods_list=$(claude plugin list --json 2>/dev/null); then
warn "mods: claude plugin list failed — load state not checked"
_mods_list=""
fi
fi
_mods_seen=0
for _mod_manifest in "$REPO"/mods/*/.claude-plugin/plugin.json; do
[ -f "$_mod_manifest" ] || continue
_mods_seen=$((_mods_seen + 1))
_mod=$(basename "$(dirname "$(dirname "$_mod_manifest")")")
_mod_link="$HOME/.claude/skills/$_mod"
if ! { [ -L "$_mod_link" ] || [ -e "$_mod_link" ]; }; then
info "mod $_mod: not linked (skills/$_mod absent) — git checkout skills/$_mod if wanted"
continue
fi
if [ "$_mod_link" -ef "$REPO/mods/$_mod" ]; then
pass "mod $_mod: loading link ~/.claude/skills/$_mod"
else
warn "mod $_mod: ~/.claude/skills/$_mod does not resolve to $REPO/mods/$_mod"
fi
[ -n "$_mods_list" ] || continue
case "$(printf '%s' "$_mods_list" | mod_state "$_mod")" in
enabled) pass "mod $_mod: enabled as $_mod@skills-dir" ;;
disabled) warn "mod $_mod: disabled (\"$_mod@skills-dir\": false in enabledPlugins)" ;;
absent) warn "mod $_mod: not listed as @skills-dir — run: claude plugin validate mods/$_mod (policy, manifest or name conflict)" ;;
*) warn "mod $_mod: claude plugin list output not understood" ;;
esac
done
[ "$_mods_seen" -gt 0 ] || info "no mods"
unset _mods_list _mods_seen _mod_manifest _mod _mod_link
echo ""
# ── Playwright browsers (read-only report; NOT nested under gstack — 2 of # ── Playwright browsers (read-only report; NOT nested under gstack — 2 of
# the 3 registered installs are gsd-pi, not gstack) ── # the 3 registered installs are gsd-pi, not gstack) ──
echo "── Playwright browsers ──" echo "── Playwright browsers ──"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# lib/tests/mods.test.sh — every mods/<name>/ plugin: the manifest name
# equals the folder, skills/<name> is the relative loading symlink
# ../mods/<name>, and (when the CLI offers `claude plugin test`) the mod
# passes `claude plugin validate` without warning and `claude plugin test`.
# MODS_ROOT overrides the repo root (fixture controls). Fails when no mod
# is found, so it can never pass vacuously.
set -u
ROOT="${MODS_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
CLI_TIMEOUT=120
pass=0; fail=0
ok() { pass=$((pass+1)); echo "PASS $1"; }
ko() { fail=$((fail+1)); echo "FAIL $1"; }
check() { if [ "$2" = "$3" ]; then ok "$1"; else ko "$1: got[$2] want[$3]"; fi; }
# bounded CMD...: stdout+stderr on stdout, rc 124 on timeout.
bounded() {
local t
t=$(command -v timeout || command -v gtimeout || true)
if [ -n "$t" ]; then "$t" "$CLI_TIMEOUT" "$@" 2>&1; return; fi
local out rc=0 pid i=0
out=$(mktemp) || return 1
"$@" >"$out" 2>&1 & pid=$!
while kill -0 "$pid" 2>/dev/null && [ "$i" -lt "$CLI_TIMEOUT" ]; do
sleep 1; i=$((i+1))
done
if kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null; rc=124
else wait "$pid" || rc=$?; fi
cat "$out"; rm -f "$out"; return "$rc"
}
manifest_name() {
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("name",""))' \
"$1" 2>/dev/null
}
# cli_unavailable: prints the reason and returns 0 when the capability is missing.
cli_unavailable() {
command -v claude >/dev/null 2>&1 || { echo "claude not found"; return 0; }
local rc=0
bounded claude plugin test --help >/dev/null || rc=$?
[ "$rc" -eq 124 ] && { echo "probe timed out after ${CLI_TIMEOUT}s"; return 0; }
[ "$rc" -ne 0 ] && { echo "no 'claude plugin test' command"; return 0; }
return 1
}
check_cli() {
local name="$1" dir="$2" out rc=0
out=$(bounded claude plugin validate "$dir") || rc=$?
if [ "$rc" -eq 124 ]; then ko "$name: validate timed out after ${CLI_TIMEOUT}s"
elif ! printf '%s' "$out" | grep -q 'Validation passed'; then
ko "$name: validate did not pass: $(printf '%s' "$out" | head -3 | tr '\n' ' ')"
elif printf '%s' "$out" | grep -qi 'warning'; then
ko "$name: validate printed a warning"
else ok "$name: validate passed, no warning"; fi
rc=0
out=$(bounded claude plugin test "$dir") || rc=$?
if [ "$rc" -eq 124 ]; then ko "$name: plugin test timed out after ${CLI_TIMEOUT}s"
elif [ "$rc" -ne 0 ]; then
ko "$name: plugin test rc=$rc: $(printf '%s' "$out" | tail -3 | tr '\n' ' ')"
else ok "$name: plugin test passed"; fi
}
manifests=()
for m in "$ROOT"/mods/*/.claude-plugin/plugin.json; do
[ -f "$m" ] && manifests+=("$m")
done
if [ "${#manifests[@]}" -eq 0 ]; then ko "no mod found under $ROOT/mods"; fi
use_cli=1
if reason=$(cli_unavailable); then
use_cli=0
echo "SKIP: claude plugin test unavailable ($reason) — validate/test not run"
fi
for m in ${manifests[@]+"${manifests[@]}"}; do
dir="$(dirname "$(dirname "$m")")"; name="$(basename "$dir")"
check "$name: manifest name matches folder" "$(manifest_name "$m")" "$name"
link="$ROOT/skills/$name"
if [ -L "$link" ]; then
check "$name: loading link target" "$(readlink "$link")" "../mods/$name"
else ko "$name: skills/$name is not a symlink"; fi
[ "$use_cli" -eq 1 ] && check_cli "$name" "$dir"
done
echo "mods: $pass pass, $fail fail"
[ "$fail" -eq 0 ]
+1
View File
@@ -0,0 +1 @@
../mods/model-router