Claude Code skill that builds, debugs, audits and packages Claude Code mods from a plain-language description

Lets a short list of commands you trust run without the permission question, and nothing else.
Claude Code is Anthropic's assistant for working with code, in the terminal or in the Claude desktop app. A mod is a small add-on for it: it can add a panel, a bar above the box where you type, a new /command, or a rule that checks what Claude is about to do. This folder is one complete mod you can install and use as it is, or copy as a starting point.
By default Claude Code asks before running a command. That is safe, but answering "yes" to npm test for the tenth time gets old. This mod lets you write a list of commands that may run without asking. Anything not on the list gets the normal question, exactly as before.
auto-approved by allow-list entry "npm test *": npm test -- --watch./approvals shows your list and what was approved in this session.git status, npm test *.* at the end to also accept anything after it: npm test * allows npm test and npm test -- --watch./approvals to check what is on the list.None of its own.
| Type | What it does |
|---|---|
/approvals | Show the list and the approvals of this session |
Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.
| Setting | What it means | Default |
|---|---|---|
allowList (Allowed commands) | Commands that may run without asking, separated by commas. End one with a space and * to accept arguments. | (empty) |
enabled (Enabled) | Turn the mod off without uninstalling it. | true |
;, &&, |, redirections or $( ) are never approved automatically, even if they start with an allowed command.You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.
_claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.claude --plugin-dir path/to/scoped-approve
To keep it installed for good, ask the claude-mod-forge skill to package it ("package this mod").
From the skill folder (skills/claude-mod-forge), this runs the manifest check, a strict type check and the 8 automated tests of this mod:
node scripts/check-mod.mjs examples/scoped-approve
The tests cover the mod's logic and what it draws in the terminal and the desktop app. How it looks in your own window is worth a quick look in a real session.
For developers: the examples table lists the hooks and API calls this mod demonstrates.
hooks/register.ts 51 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { allowedBy, parseList } from './allow'
5
6// An APPROVER, not a guard. Rules it never breaks:
7// - it only turns the engine's "ask" into "allow": a deny stays a deny, an allow stays an allow;
8// - only the main conversation (no agentId): subagents keep the normal prompts;
9// - on failure it returns the engine's own verdict (never allow, never deny-everything).
10const approvals = atom({ plugin: 'scoped-approve', key: 'approvals' } as const, [])
11
12export const register: Register = (on, options) => {
13 const list = parseList(String(options.allowList ?? ''))
14 const isEnabled = options.enabled !== false
15
16 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
17 const decided = await next(e)
18 if (!isEnabled || decided.decision !== 'ask' || e.agentId !== undefined) return decided
19
20 const command = String((e.input as { command?: unknown }).command ?? '')
21 const entry = allowedBy(command, list)
22 if (entry === undefined) return decided
23
24 // Every approval is visible: a dim transcript line, and the session list behind /approvals.
25 $.ui.log(`auto-approved by allow-list entry "${entry}": ${command.slice(0, 120)}`)
26 const at = await $.clock.now()
27 await update($, approvals, l => [...l, { command: command.slice(0, 200), at }].slice(-50))
28 return { decision: 'allow', reason: `scoped-approve: allow-list entry "${entry}"` }
29 }).catch(($, e, next) => next(e))
30
31 on('session.start', async ($, e, next) => {
32 try {
33 await $.command.register({ name: 'approvals', description: 'Show the allow-list and what it approved this session', immediate: true })
34 } catch {
35 // name taken: approvals still work and still log
36 }
37 return next(e)
38 })
39
40 on('command.run', { command: 'approvals' }, async $ => {
41 const done = await read($, approvals)
42 const header = !isEnabled
43 ? 'scoped-approve is off.'
44 : list.length === 0
45 ? 'Allow-list is empty: nothing is auto-approved.'
46 : `Allow-list: ${list.join(' · ')}`
47 const body = done.length === 0 ? 'Nothing approved this session.' : done.map(a => `- ${a.command}`).join('\n')
48 return { text: `${header}\n${body}` }
49 })
50}
51hooks/allow.ts 34 lines1// Pure allow-list logic. No $ here, so tests cover it directly.
2
3// Anything that chains, substitutes or redirects is never auto-approved:
4// "git status && rm -rf x" must not ride along with "git status".
5const COMPOUND = /[;&|`<>\n\r]|\$\(/
6
7// An entry is EXACT by default: "git diff" approves "git diff" and nothing else, because arguments can
8// change what a command does ("git diff --output=FILE" overwrites a file). Arguments are accepted only
9// when the entry says so with a trailing " *": "npm test *" approves "npm test" and "npm test -- foo".
10// A "*" anywhere else, or an entry that is only "*", is dropped: it would approve far more than it says.
11const ARGS = ' *'
12
13export function parseList(raw: string): string[] {
14 return raw
15 .split(',')
16 .map(s => s.trim().replace(/\s+/g, ' '))
17 .filter(s => s.length > 0 && !COMPOUND.test(s))
18 .filter(s => {
19 const base = s.endsWith(ARGS) ? s.slice(0, -ARGS.length) : s
20 return base.length > 0 && !base.includes('*')
21 })
22}
23
24// Returns the entry that allows the command, or undefined.
25export function allowedBy(command: string, list: readonly string[]): string | undefined {
26 const cmd = command.trim().replace(/\s+/g, ' ')
27 if (cmd === '' || COMPOUND.test(cmd)) return undefined
28 return list.find(entry => {
29 if (!entry.endsWith(ARGS)) return cmd === entry
30 const base = entry.slice(0, -ARGS.length)
31 return cmd === base || cmd.startsWith(`${base} `)
32 })
33}
34types/index.d.ts 8 lines1export type Approval = { command: string; at: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'scoped-approve': { approvals: Approval[] }
6 }
7}
8