SLOPSHOPPER

mod-template

A template for Claude Code mods: a command, session state, options, tests and a check script.

newcommand
v0.1.0MITupdated 2026-10-25chrisvaillancourt/claude-mod-template
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-template
› fix the failing auth test and add an audit log call ● mod-template: mod-template: main turn turn-1 started ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mod-template ⎿ mod-template: on (from the enabled option); 1 main turns seen. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mod-template

A Claude Code mod (function-hook plugin). Replace this paragraph with what the mod does and why, in two or three sentences.

This repository is also the template for new mods: scripts/new-mod.sh <mod-name> creates ../claude-<mod-name> with every mod-template renamed, a fresh git history and one commit. Delete this note in the new repo.

Behavior

  • What the mod does, when, and to which conversations (main only, or subagents too).
  • What it costs (model calls, tokens) and how the options bound it.
  • What it never does.

Command

/mod-template [on|off|status]: show or set whether the mod is on for this session.

Options

OptionDefaultWhat it does
enabledtrueTurn the mod on for new sessions. `/mod-template onoff` overrides it per session.

Install

claude plugin marketplace add chrisvaillancourt/claude-mod-template
claude plugin install mod-template@mod-template --scope user

Or from inside Claude Code: /plugin install mod-template --marketplace chrisvaillancourt/claude-mod-template.

To work on it locally, add your clone as the marketplace instead (claude plugin marketplace add <path to clone>). The plugin is read from that folder; after editing, run /reload-plugins.

Update

claude plugin marketplace update mod-template
claude plugin update mod-template@mod-template

Then restart Claude Code.

Develop

scripts/check.sh

It runs claude plugin validate ., claude plugin test . and a TypeScript check. Commit messages follow Conventional Commits.

License

MIT. See LICENSE.

Source 3 files
hooks/register.ts 56 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { INITIAL, USAGE, configFrom, isActive, parseArgs, statusText } from './policy'
5import type { Config } from '../types'
6
7const NAME = 'mod-template'
8
9// Change the shape tag when Session's fields change: a value an older version
10// stored (it outlives a hot reload) then reads as INITIAL instead of breaking.
11const sessionAtom = atom({ plugin: 'mod-template', key: 'session' } as const, INITIAL, { shape: 'session-1' })
12
13// Module variables reset on a hot reload; register and session.start run
14// again then. Anything that must persist goes in $.state or $.store.
15let config: Config = configFrom({})
16
17/** Best effort: reporting must never throw out of a hook or a timer. */
18function log($: EngineInterface, text: string) {
19  try {
20    $.ui.log(`${NAME}: ${text}`)
21  } catch {
22    // nothing to do
23  }
24}
25
26export const register: Register = (on, options) => {
27  config = configFrom(options)
28
29  on('session.start', async ($, e, next) => {
30    await $.command.register({
31      name: NAME,
32      description: 'Show or set whether mod-template is on for this session',
33      argumentHint: '[on|off|status]',
34    })
35    return next(e)
36  })
37
38  on('command.run', { command: NAME }, async ($, e) => {
39    const cmd = parseArgs(e.args)
40    if (cmd.kind === 'help') return { text: cmd.error ? `${cmd.error}\n${USAGE}` : USAGE }
41    if (cmd.kind === 'set') await update($, sessionAtom, s => ({ ...s, mode: cmd.mode }))
42    return { text: statusText(config, await read($, sessionAtom)) }
43  })
44
45  on('turn.start', async ($, e, next) => {
46    // Subagent loops carry agentId; this mod acts on the main conversation only.
47    if (e.agentId !== undefined) return next(e)
48    const session = await read($, sessionAtom)
49    if (isActive(config, session)) {
50      await update($, sessionAtom, s => ({ ...s, turns: s.turns + 1 }))
51      log($, `main turn ${e.turnId} started`)
52    }
53    return next(e)
54  })
55}
56
hooks/policy.ts 36 lines
1// Pure decisions: no `$`, no I/O. Put the mod's rules here and unit-test them
2// in policy.test.ts; register.ts only wires them to events.
3import type { Config, Mode, Session } from '../types'
4
5export const INITIAL: Session = { mode: null, turns: 0 }
6
7export function num(v: unknown, fallback: number): number {
8  return typeof v === 'number' && Number.isFinite(v) ? v : fallback
9}
10
11export function configFrom(options: Record<string, unknown>): Config {
12  return { enabled: options.enabled !== false }
13}
14
15export function isActive(config: Config, session: Session): boolean {
16  return session.mode === null ? config.enabled : session.mode === 'on'
17}
18
19export type Command = { kind: 'set'; mode: Mode } | { kind: 'status' } | { kind: 'help'; error?: string }
20
21export function parseArgs(args: string): Command {
22  const word = args.trim().toLowerCase()
23  if (word === '' || word === 'status') return { kind: 'status' }
24  if (word === 'on' || word === 'off') return { kind: 'set', mode: word }
25  if (word === 'help') return { kind: 'help' }
26  return { kind: 'help', error: `Unknown argument: ${args.trim()}` }
27}
28
29export const USAGE = 'Usage: /mod-template [on|off|status]'
30
31export function statusText(config: Config, session: Session): string {
32  const state = isActive(config, session) ? 'on' : 'off'
33  const source = session.mode === null ? 'from the enabled option' : 'set for this session'
34  return `${state} (${source}); ${session.turns} main turns seen.`
35}
36
types/index.d.ts 21 lines
1export type Mode = 'on' | 'off'
2
3/** The options in plugin.json's userConfig, parsed with defaults filled in. */
4export type Config = {
5  enabled: boolean
6}
7
8/** What the mod keeps for the session. Lives in $.state, so it survives a hot reload. */
9export type Session = {
10  /** A /mod-template on|off override for this session; null follows the `enabled` option. */
11  mode: Mode | null
12  /** Main-loop turns seen this session. */
13  turns: number
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'mod-template': { session: Shaped<Session> }
19  }
20}
21