SLOPSHOPPER

mod-template

Starter mod: a /mod-template command, a status line entry and a band above the prompt.

newbandcommandstatus
v0.1.0no licenseupdated 2026-10-02cskwork/claude-code-mods/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 ⏺ 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: 1 turns so far. mod-template: 1 turns ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ mod-template: mod-template: 1 turns

Draws

Band
mod-template: 1 turns
README

claude-code-mods

Mods for Claude Code (2.1.287+): plugins of function hooks that observe, rewrite or answer what Claude Code does and draw their own UI. Based on Getting started with Claude Code mods.

Landing page: https://cskwork.github.io/claude-code-mods/

ModWhat it does
token-weatherA one-line forecast of the context window above the prompt: ☀ Clear → ↯ Compact soon, tokens used, a sparkline of the last 12 turns, and how much the last turn added.
blast-radiusBefore a risky Bash command runs (recursive rm, git reset --hard, git clean -f, force push, git branch -D, git stash drop/clear, kubectl delete, SQL DROP/TRUNCATE, migrations), dry-runs what it would touch and asks Proceed or Cancel. Cancel or dismiss denies the call.
templateStarter mod, not installed: a slash command, a status line entry, a band, and a $.state contract with a test.

Install

Inside Claude Code:

/plugin marketplace add cskwork/claude-code-mods
/plugin install token-weather@claude-code-mods
/plugin install blast-radius@claude-code-mods
/reload-plugins

Make a new mod

scripts/new-mod.sh my-mod "One line about what it does"
claude --plugin-dir "$PWD/my-mod"     # hot-reloads on every save

The script copies template/, renames it, adds it to .claude-plugin/marketplace.json, then runs claude plugin validate and claude plugin test on it.

A mod is three files plus an optional state contract:

my-mod/
├── .claude-plugin/plugin.json   name, version, description, "types"
├── hooks/hooks.json             { "modules": ["./register.tsx"] }
├── hooks/register.tsx           export const register: Register = on => { ... }
├── types/index.d.ts             PluginState contract for $.state values
└── tests/my-mod.test.ts         claude plugin test my-mod

Rules that bite:

  • Every hook is ($, e, next). Call next(e) to pass on, next({ ...e, x }) to rewrite, return without it to answer.
  • Keep values in $.state (atom / read / update); module variables reset on every hot reload.
  • No Node, no DOM: files, processes, clock and UI go through $. UI elements come from $.ui.resolve(e).
  • Test mocks are hooks too: on('process.run', ($, e) => ({ value: ... })).
  • After the engine loads a mod it writes .claude-plugin/types/ beside it, so tsc -p <mod> type-checks it (gitignored).

Check

for m in token-weather blast-radius template; do claude plugin validate $m && claude plugin test $m; done
Source 2 files
hooks/register.tsx 40 lines
1// mod-template: the three moves a mod makes, in one file.
2//   observe  -> `await next(e)`, then act on the result   (turn.complete)
3//   answer   -> return without `next`                     (command.run)
4//   draw     -> a ui.render hook returns a tree           (AbovePrompt band)
5// Values live in `$.state` (atoms), never module variables: a hot reload resets those.
6import { atom, read, update } from 'claude-code'
7import type { Register } from 'claude-code'
8
9const NAME = 'mod-template'
10const turns = atom({ plugin: 'mod-template', key: 'turns' } as const, 0)
11
12export const register: Register = on => {
13  on('session.start', async ($, e, next) => {
14    await $.command.register({ name: NAME, description: 'Say how many turns this session has run' })
15    return next(e)
16  })
17
18  on('turn.complete', async ($, e, next) => {
19    const result = await next(e)
20    if (!e.agentId) {
21      const count = await update($, turns, n => n + 1)
22      $.ui.status(`${NAME}: ${count} turns`)
23    }
24    return result
25  })
26
27  on('command.run', { command: NAME }, async $ => ({ text: `${await read($, turns)} turns so far.` }))
28
29  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
30    const count = await read($, turns)
31    if (e.props.hasSurvey || count === 0) return next(e)
32    const { Box, Text } = $.ui.resolve(e)
33    return (
34      <Box paddingX={1}>
35        <Text dimColor>{`${NAME}: ${count} turns`}</Text>
36      </Box>
37    )
38  })
39}
40
types/index.d.ts 9 lines
1// The mod's state contract: every `$.state` value the module names, under the mod's name.
2export type ModTemplateTurns = number
3
4declare module 'claude-code' {
5  interface PluginState {
6    'mod-template': { turns: ModTemplateTurns }
7  }
8}
9