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:
@@ -262,3 +262,7 @@ skills-external/.higgsfield-stage.*/
|
||||
# ── gitflow standard socle (added by gitflow_init; additive, safe to edit) ──
|
||||
*.log
|
||||
!.claude/deploy/
|
||||
|
||||
# mods/: the engine lays tsconfig.json beside a loaded mod; its
|
||||
# .claude-plugin/types/ ignores itself
|
||||
mods/*/tsconfig.json
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
`docs/superpowers/specs/**` and `docs/superpowers/plans/**` are run-time
|
||||
|
||||
@@ -155,6 +155,64 @@ unset _dv_active_profile _dv_profile_file
|
||||
|
||||
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
|
||||
# the 3 registered installs are gsd-pi, not gstack) ──
|
||||
echo "── Playwright browsers ──"
|
||||
|
||||
@@ -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 ]
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../mods/model-router
|
||||
Reference in New Issue
Block a user