Read it, then pass the check at the end to open step 8.
Test, gate and install your mods
Updated
What this step is for
A mod that loads is not a mod you can trust. Before any of ours is installed, a gate runs four
checks and refuses the install if one fails. This step gives the turn band a test, writes the
gate, and installs your mods from a folder you control, so an edit reaches every session with
one /reload-plugins.
Do it
A test
claude plugin test runs every *.test.ts in the mod against the engine itself. Your test
hooks sit beneath the mod and stand in for the engine. Create
~/claude-mods/turn-band/hooks/register.test.ts:
import { expect, test } from 'claude-code/testing'
const BAND = {
plugin: 'turn-band',
component: 'AbovePrompt',
viewport: { columns: 100, rows: 30 },
props: { hasSurvey: false, isWorking: false, maxRows: 5, bodyColumns: 95, scroll: { offset: 0, bodyRows: 5 } },
} as const
const turn = (agentId?: string) =>
({ turnId: 't', answer: '', durationMs: 1, isAborted: false, reason: 'answer', ...(agentId ? { agentId } : {}) }) as const
test('counts main-thread turns, on both surfaces', async ($, on) => {
on('turn.complete', () => ({ text: '' }))
on('ui.render', () => ({ type: 'Text', props: {}, children: ['engine'] }))
await $.turn.complete(turn())
await $.turn.complete(turn('subagent-1'))
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({ ...BAND, surface })
expect(await ui.find({ type: 'Text', text: 'turns 1' })).toBeDefined()
await ui.unmount()
}
})
claude plugin test ~/claude-mods/turn-band
The guard needs the test that matters most: when the guard itself breaks, the call is refused.
~/claude-mods/db-guard/hooks/register.test.ts:
import { expect, mock, test } from 'claude-code/testing'
import type { On } from 'claude-code'
const PROBE = 'echo drizzle-kit push postgres://u:p@prod-db.internal/db'
// Beneath the mod: a hosts file, a question with an answer (or no one there), and Bash.
function world(on: On, opts: { answer?: string; envThrows?: boolean }) {
const seen = { ran: 0 }
if (opts.envThrows) on('env.get', () => { throw new Error('the guard itself broke') })
else mock.env(on, { HOME: '/home/you' })
on('fs.read', () => ({ value: 'prod-db.internal\n' }))
on('tool.call', { tool: 'AskUserQuestion' }, ($, e) =>
opts.answer === undefined
? { deny: 'no one to ask' }
: { result: { questions: e.questions, answers: { [e.questions[0]!.question]: opts.answer } } },
)
on('tool.call', { tool: 'Bash' }, () => {
seen.ran++
return { result: { stdout: 'ran', stderr: '', interrupted: false } }
})
return seen
}
test('headless: refuses, names the host, never the password', async ($, on) => {
const seen = world(on, {})
const r = await $.tool.call({ tool: 'Bash', command: PROBE })
expect(r.deny).toContain('prod-db.internal')
expect(r.deny).not.toContain('u:p@')
expect(seen.ran).toBe(0)
})
test('a person says yes: it runs', async ($, on) => {
const seen = world(on, { answer: 'Run on production' })
const r = await $.tool.call({ tool: 'Bash', command: PROBE })
expect(r.deny).toBeUndefined()
expect(seen.ran).toBe(1)
})
test('the guard breaks: it fails closed', async ($, on) => {
const seen = world(on, { envThrows: true })
const r = await $.tool.call({ tool: 'Bash', command: PROBE })
expect(r.deny).toContain('db-guard failed')
expect(seen.ran).toBe(0)
})
Delete the .catch(...) from db-guard and run its tests: the guard breaks fails. Put it
back. And the pane, ~/claude-mods/run-pane/hooks/register.test.ts, proves nothing runs
before a press:
import { expect, test } from 'claude-code/testing'
const PANE = {
plugin: 'run-pane',
component: 'Pane',
requestId: 'handed',
viewport: { columns: 160, rows: 40, isFullscreen: true },
props: { title: 'Handed commands', isFocused: true, bodyColumns: 150, placement: 'dock', scroll: { offset: 0, bodyRows: 30 }, view: {} },
} as const
const answer = (text: string) => ({ turnId: 't1', answer: text, durationMs: 1, isAborted: false, reason: 'answer' }) as const
test('a press runs the command once and hands the result back as a prompt', async ($, on) => {
const runs: (readonly string[])[] = []
const prompts: string[] = []
on('command.register', () => ({ value: undefined }))
on('session.start', () => ({ cwd: '/repo' }))
on('session.cwd', () => ({ value: '/repo' }))
on('ui.toast', () => ({ value: undefined }))
on('turn.complete', () => ({ text: '' }))
on('process.run', ($, e) => {
runs.push(e.argv)
return { value: { exitCode: 0, stdout: 'Thu Oct 8\n', stderr: '' } }
})
on('prompt.submit', ($, e) => {
prompts.push(e.text)
return { text: e.text }
})
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/repo' })
await $.turn.complete(answer('Run it yourself:\n\n```bash\n! date\n```\n\n```bash\n! vercel inspect <deployment-url>\n```'))
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({ ...PANE, surface })
expect(await ui.findAll({ type: 'Button', text: 'Run' })).toHaveLength(1) // the unfilled one gets none
await ui.unmount()
}
expect(runs).toEqual([]) // nothing runs before a press
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
await ui.press({ key: (await ui.find({ type: 'Button', text: 'Run' }))!.key! })
expect(runs).toEqual([['bash', '-lc', 'date']])
expect(prompts[0]).toContain('Operator ran: date → exit 0')
})
claude plugin test ~/claude-mods/db-guard
claude plugin test ~/claude-mods/run-pane
The gate
~/claude-mods/check-mod.sh:
#!/usr/bin/env bash
# Usage: check-mod.sh <mod-dir>. Exits non-zero on the first failure.
set -euo pipefail
mod="$(cd "$1" && pwd)"; name="$(basename "$mod")"
# 1. Validate, and refuse any gating hook without a .catch: it would fail open.
report="$(claude plugin validate "$mod" --json)" || { echo "✗ $name: validate"; echo "$report"; exit 1; }
python3 -c '
import json, sys
r = json.loads(sys.argv[1])
parts = [r.get("manifest", {})] + r.get("contents", [])
bad = [g["hook"] for p in parts for g in p.get("gatingHooks", []) if not g.get("hasCatch")]
if bad: sys.exit("gating hook without .catch: " + ", ".join(bad))
' "$report" || { echo "✗ $name: fails open"; exit 1; }
# 2. No hook approves. Only a person does.
if grep -rEn "decision:[[:space:]]*['\"]allow['\"]" "$mod" --include='*.ts' --include='*.tsx' --exclude-dir=types --exclude='*.test.*'; then
echo "✗ $name: a hook answers allow"; exit 1
fi
# 3. At least one test, all passing.
claude plugin test "$mod" >/dev/null 2>&1 || { echo "✗ $name: tests"; exit 1; }
# 4. Types, once the engine has laid them beside the mod (after its first load). Tests are left
# out: step 3 already ran them. The tsc is the one this folder pinned, never `bunx tsc`.
tsc="$(dirname "$0")/node_modules/typescript/bin/tsc"
if [ -d "$mod/.claude-plugin/types/claude-code" ] && [ -f "$tsc" ]; then
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
cat > "$tmp/tsconfig.json" <<JSON
{ "compilerOptions": { "target": "es2023", "lib": ["es2023"], "types": [], "module": "esnext",
"moduleResolution": "bundler", "strict": true, "noEmit": true, "skipLibCheck": true,
"jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment" },
"include": ["$mod/.claude-plugin/types", "$mod/hooks", "$mod/types"],
"exclude": ["$mod/**/*.test.ts"] }
JSON
bun "$tsc" -p "$tmp/tsconfig.json" || { echo "✗ $name: types"; exit 1; }
else
echo " ⚠ $name: type-check skipped (load the mod once so the engine lays its types)"
fi
echo "✓ $name"
Pin the type-checker in the folder once, then run the gate:
cd ~/claude-mods && bun add -d typescript@5.9.3
chmod +x ~/claude-mods/check-mod.sh
~/claude-mods/check-mod.sh ~/claude-mods/db-guard
Break it on purpose: delete the .catch(...) from db-guard and run the gate again. It
refuses with fails open before it even reaches the tests. Put it back.
Install from your own folder
A folder becomes a marketplace with one file, ~/claude-mods/.claude-plugin/marketplace.json:
{
"name": "my-mods",
"owner": { "name": "you" },
"plugins": [
{ "name": "turn-band", "source": "./turn-band", "description": "Counts turns above the prompt." },
{ "name": "db-guard", "source": "./db-guard", "description": "Holds migrations aimed at production." },
{ "name": "run-pane", "source": "./run-pane", "description": "Handed commands, one press each." }
]
}
claude plugin marketplace add ~/claude-mods
for m in turn-band db-guard run-pane; do ~/claude-mods/check-mod.sh ~/claude-mods/$m && claude plugin install $m@my-mods --scope user; done
The marketplace is read in place: after an edit, run the gate again and /reload-plugins.
Check it
Each mod prints ✓ <name>, and claude plugin list shows all three under @my-mods. Start a
plain claude session, with no --plugin-dir: the turn band counts your first reply.
Check what you read in “Test, gate and install your mods”
Three questions drawn from a larger pool, so no two papers are quite the same. Two right passes it and opens the next step. The questions are public; scoring them needs an account, and the key never leaves the server.
Loading the check…