flowingkhaosSign in
Build a Claude Code mod that fails closedstep 7 of 8 · 0 of 18 carriedSign in to keep it
Step 7 of 8 · weight 2 · about 30 minutes

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.

The gate · a reading check closes this stepweight 2 of Σw 15

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…

All 8 stepsTake the check

The columns, fixes and workshops continue in the community: SEO, AI, and making money with code.

Join on Whop

Luke Sidney writes and builds everything here, alone, in the open. lukesidney.me