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

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 everymod-templaterenamed, a fresh git history and one commit. Delete this note in the new repo.
/mod-template [on|off|status]: show or set whether the mod is on for this session.
| Option | Default | What it does | |
|---|---|---|---|
enabled | true | Turn the mod on for new sessions. `/mod-template on | off` overrides it per session. |
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.
claude plugin marketplace update mod-template
claude plugin update mod-template@mod-template
Then restart Claude Code.
scripts/check.sh
It runs claude plugin validate ., claude plugin test . and a TypeScript check. Commit messages follow Conventional Commits.
MIT. See LICENSE.
hooks/register.ts 56 lines1import { 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}
56hooks/policy.ts 36 lines1// 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}
36types/index.d.ts 21 lines1export 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