diff --git a/.gitignore b/.gitignore index a45eacc..42c29f2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 5e0bfcf..c58878d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//` (`.claude-plugin/plugin.json` + hooks). It +loads through the tracked relative symlink `skills/` -> `../mods/` +(`~/.claude/skills` links to `skills/`) as `@skills-dir`, in place, +live at the next session or `/reload-plugins`. New mod: `ln -s ../mods/ +skills/` 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//tsconfig.json` and `.claude-plugin/types/`; + both are gitignored. +- Optional user config: `~/.claude/.json`. Its `"enabled": false` is the + per-machine off switch (untracked). `"@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//`) 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 diff --git a/doctor.sh b/doctor.sh index 63f281b..8bfe308 100644 --- a/doctor.sh +++ b/doctor.sh @@ -155,6 +155,64 @@ unset _dv_active_profile _dv_profile_file echo "" +# ──────────────────────────────────────────────────────────── +# 2c. Mods (mods// plugins, loaded through the tracked +# skills/ symlink as @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 ──" diff --git a/lib/tests/mods.test.sh b/lib/tests/mods.test.sh new file mode 100644 index 0000000..dcc7f9c --- /dev/null +++ b/lib/tests/mods.test.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# lib/tests/mods.test.sh — every mods// plugin: the manifest name +# equals the folder, skills/ is the relative loading symlink +# ../mods/, 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 ] diff --git a/skills/model-router b/skills/model-router new file mode 120000 index 0000000..e051553 --- /dev/null +++ b/skills/model-router @@ -0,0 +1 @@ +../mods/model-router \ No newline at end of file