The Mod Skeleton: counts tool calls, holds destructive shell commands and secret files, and shows a tally above the prompt

A small Claude Code mod built in the Mod Skeleton tutorial. It counts Claude's tool calls, holds destructive shell commands (rm -rf, git push --force, git reset --hard), refuses @-mentioned secret files (.env, *.pem, SSH keys), and shows a tally on the spinner and in a band above the prompt. /skeleton prints the counts.
It is a teaching mod, not a safety control. Its guards fail closed when they throw (each has a .catch), but a determined command can still get past a list of patterns.
Type this at the prompt of a Claude Code terminal session (2.1.287 or later), answer y to add the marketplace, then pick a scope:
/plugin install skeleton-guard --marketplace your-github-name/my-mod
To try it for one session without installing it:
claude --plugin-dir path/to/my-mod
claude plugin validate . lists it. In short: it hooks session.start, command.run, classic.SessionStart, tool.call, prompt.mention and ui.render (Spinner and AbovePrompt), and it calls $.command.register, $.state, $.store and $.ui.resolve. It makes no network requests and starts no processes.
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 96 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Tally } from '../types'
5
6// The Mod Skeleton, finished. Each block names the module that added it.
7
8// Module 6, widened in module 14: shell commands this mod holds.
9const DESTRUCTIVE = [
10 /\brm\s+-[a-z]*r[a-z]*f\b|\brm\s+-[a-z]*f[a-z]*r\b/,
11 /\bgit\s+push\b.*(--force\b|\s-f\b)/,
12 /\bgit\s+reset\s+--hard\b/,
13]
14// Module 8: files a prompt may not pull in with @.
15const SECRET = /(^|\/)\.env(\.|$)|\.pem$|(^|\/)id_(rsa|ed25519)$/
16
17// Modules 9 and 10: per-session counts in $.state, all-time counts in $.store.
18const ZERO: Tally = { sessions: 0, tools: 0, held: 0 }
19const KEY = 'all-time'
20const session = atom({ plugin: 'skeleton-guard', key: 'session' } as const, ZERO)
21const allTime = atom({ plugin: 'skeleton-guard', key: 'allTime' } as const, ZERO)
22
23async function stored($: EngineInterface): Promise<Tally> {
24 return ((await $.store.get(KEY)) as Tally | undefined) ?? ZERO
25}
26
27// $.store is the source of truth: every write starts from what is stored, never from $.state.
28async function bump($: EngineInterface, field: keyof Tally) {
29 if (field !== 'sessions') await update($, session, t => ({ ...t, [field]: t[field] + 1 }))
30 const before = await stored($)
31 const total = { ...before, [field]: before[field] + 1 }
32 await $.store.set(KEY, total)
33 await update($, allTime, () => total)
34}
35
36export const register: Register = on => {
37 // Module 5: a slash command. Register it when the session starts, answer it in command.run.
38 on('session.start', async ($, e, next) => {
39 await $.command.register({ name: 'skeleton', description: 'Show what skeleton-guard has counted and held' })
40 await bump($, 'sessions')
41 return next(e)
42 })
43
44 on('command.run', { command: 'skeleton' }, async $ => {
45 const now = await read($, session)
46 const ever = await read($, allTime)
47 return { text: `skeleton-guard: this session ${now.tools} tool calls, ${now.held} held; all time ${ever.tools} tool calls over ${ever.sessions} sessions.` }
48 })
49
50 // Module 10: /clear, /resume and /branch reset $.state and do not fire session.start again.
51 on('classic.SessionStart', async ($, e, next) => {
52 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
53 const total = await stored($)
54 await update($, allTime, () => total)
55 }
56 return next(e)
57 })
58
59 // Modules 1, 6 and 7: count every tool call, hold destructive shell commands, fail closed.
60 on('tool.call', async ($, e, next) => {
61 if (e.tool === 'Bash') {
62 const command = e.command.trim()
63 if (DESTRUCTIVE.some(re => re.test(command))) {
64 await bump($, 'held')
65 return { deny: `skeleton-guard: "${command}" is destructive; ask the person to run it themselves.` }
66 }
67 }
68 await bump($, 'tools')
69 return next(e)
70 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'skeleton-guard could not check this call, so it is held.' }))
71
72 // Module 8: the second door. An @-mentioned file is read without any tool.call.
73 on('prompt.mention', { path: SECRET }, () => ({ deny: 'skeleton-guard: that file may hold secrets.' })).catch(
74 ($, e, next) => (next.called ? next(e) : { deny: 'skeleton-guard could not check this file, so it is held.' }),
75 )
76
77 // Module 4: rewrite what Claude Code draws. Keep the spinner, add a suffix.
78 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
79 const now = await read($, session)
80 return next({ ...e, props: { ...e.props, suffix: ` · tool calls: ${now.tools}…` } })
81 })
82
83 // Module 9: a band above the prompt, drawn from $.state, so a state write redraws it.
84 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
85 const now = await read($, session)
86 const ever = await read($, allTime)
87 if (e.props.hasSurvey || ever.sessions === 0) return next(e)
88 const { Text } = $.ui.resolve(e)
89 return (
90 <Text dimColor>
91 skeleton-guard · this session {now.tools} tools, {now.held} held · all time {ever.tools} tools over {ever.sessions} sessions
92 </Text>
93 )
94 })
95}
96types/index.d.ts 8 lines1export type Tally = { sessions: number; tools: number; held: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'skeleton-guard': { session: Tally; allTime: Tally }
6 }
7}
8