Reusable prompts one command away: /snip review drops your saved review prompt into the prompt box, with {{selection}}, {{branch}}, {{date}} and {{args}}…

Your best prompts, one command away.
/snip reviewdrops your saved review prompt into the prompt box, with the branch, your selection and any extra text filled in.
<img src="../../../assets/screens/snippets.svg" alt="snippets in a real Claude Code session" width="100%">
> /snip review the token refresh
╭──────────────────────────────────────────────────────────────╮
│ > Review the changes on branch feat/oauth: the uncommitted │
│ ones and the commits not yet on the main branch. │
│ Pay extra attention to: the token refresh │
│ Look for bugs first, then security problems, then needless │
│ complexity. List each finding with file:line, ... │
╰──────────────────────────────────────────────────────────────╯
╭─ snippets ───────────────────────────────╮
│ /snip review is in the prompt: edit it │
│ or press Enter │
╰──────────────────────────────────────────╯
Snippets fill the prompt box and stop. Nothing is sent until you press Enter, so you can edit the text first. That's what makes them different from a custom slash command.
/snip <name> [extra] expands the snippet into the prompt box. If you've typed a draft, it's inserted at the cursor instead.| Placeholder | Becomes | |
|---|---|---|
{{args}} | the text after the name (appended at the end if the snippet has no {{args}}) | |
{{selection}} | what you last selected with the mouse (fullscreen terminal) | |
{{branch}} | the current git branch | |
{{date}} | today, YYYY-MM-DD | |
| `{{name\ | default}}` | the value, or default when it's empty |
A line whose placeholders all come out empty is dropped, so one snippet works with or without a selection.
review, explain, tests, commit-msg, plan. Override one by saving your own under the same name; remove one with /snip rm.claude -p), the expanded text is printed instead./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install snippets@awesome-claude-mods
Requires Claude Code 2.1.287 or later.
| Command | What it does |
|---|---|
/snip | Lists snippets with a preview, starters marked |
/snip <name> [extra text] | Expands it into the prompt box |
/snip save <name> <text> | Saves or replaces one (names: letters, digits, -, _) |
/snip show <name> | Prints the raw template |
/snip rm <name> | Removes it; removing a starter keeps it from coming back |
Example: /snip save bugfix Reproduce {{args}} with a failing test first, then fix it and show me the diff.
| Event or call | Why |
|---|---|
command.run on snip | Parses the verb and reads or writes $.store |
$.prompt.read, $.prompt.fill | Puts the expanded text in the box (replace, or insert at the cursor over a draft) |
$.ui.selection, $.process.run(git), $.clock.now | Fill {{selection}}, {{branch}} and {{date}}, each only when the snippet uses it |
claude plugin test mods/productivity/snippets # 15 tests
{{selection}} needs the fullscreen terminal. Elsewhere it's empty, and its line is dropped./snip save <name> (Shift+Enter, or \ then Enter) are kept in the snippet. Larger edits are easiest in the store file, ~/.claude/plugins/store/snippets_*.json.hooks/register.ts 103 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { expand, listing, merged, parseSnip, placeholders, STARTERS } from './snippets'
4
5type Saved = { own: Record<string, string>; hidden: string[] }
6
7async function loadSnippets($: EngineInterface): Promise<Saved> {
8 const own = ((await $.store.get('snippets')) as Record<string, string> | undefined) ?? {}
9 const hidden = ((await $.store.get('hidden')) as string[] | undefined) ?? []
10 return { own, hidden }
11}
12
13/** Fetches only the values the template names. */
14async function valuesFor($: EngineInterface, template: string, extra: string): Promise<Record<string, string>> {
15 const wanted = placeholders(template)
16 const values: Record<string, string> = { args: extra }
17 if (wanted.has('selection')) values.selection = (await $.ui.selection())?.text ?? ''
18 if (wanted.has('date')) values.date = new Date(await $.clock.now()).toISOString().slice(0, 10)
19 if (wanted.has('branch')) {
20 const git = await $.process
21 .run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 3000 })
22 .catch(() => undefined)
23 const branch = git?.exitCode === 0 ? git.stdout.trim() : ''
24 values.branch = branch === 'HEAD' ? '' : branch
25 }
26 return values
27}
28
29async function useSnippet($: EngineInterface, name: string, extra: string) {
30 const { own, hidden } = await loadSnippets($)
31 const all = merged(own, hidden)
32 const template = all[name]
33 if (template === undefined) {
34 return { text: `No snippet named "${name}". ${Object.keys(all).length > 0 ? `Try: ${Object.keys(all).sort().join(', ')}.` : ''}` }
35 }
36
37 const text = expand(template, await valuesFor($, template, extra))
38 const box = await $.prompt.read()
39 const hasDraft = box.text.trim() !== '' && !box.text.trimStart().startsWith('/snip')
40 const filled = await $.prompt.fill({ text: hasDraft ? `${text} ` : text, mode: hasDraft ? 'insert' : 'replace' })
41 if (filled.isFilled) {
42 $.ui.toast(`/snip ${name} is in the prompt: edit it or press Enter`)
43 return {}
44 }
45 // No prompt box (a -p run) or a dialog holds it: show the text instead.
46 return { text: `Snippet "${name}":\n\n${text}` }
47}
48
49export const register: Register = on => {
50 on('session.start', async ($, e, next) => {
51 try {
52 await $.command.register({
53 name: 'snip',
54 description: 'Put a saved prompt in the prompt box (/snip <name>), or list, save, show, rm',
55 argumentHint: '<name> [extra] | save <name> <text> | show <name> | rm <name>',
56 })
57 } catch (error) {
58 $.ui.log(`snippets: could not add /snip: ${String(error)}`, { to: 'debug' })
59 }
60 return next(e)
61 })
62
63 on('command.run', { command: 'snip' }, async ($, e) => {
64 const command = parseSnip(e.args)
65 switch (command.kind) {
66 case 'error':
67 return { text: command.message }
68 case 'list': {
69 const { own, hidden } = await loadSnippets($)
70 return { text: listing(merged(own, hidden), new Set(Object.keys(own))) }
71 }
72 case 'show': {
73 const { own, hidden } = await loadSnippets($)
74 const template = merged(own, hidden)[command.name]
75 return { text: template === undefined ? `No snippet named "${command.name}".` : `/snip ${command.name}:\n\n${template}` }
76 }
77 case 'save': {
78 const { own } = await loadSnippets($)
79 const existed = own[command.name] !== undefined || STARTERS[command.name] !== undefined
80 await $.store.set('snippets', { ...own, [command.name]: command.text })
81 return { text: `${existed ? 'Updated' : 'Saved'} /snip ${command.name}.` }
82 }
83 case 'rm': {
84 const { own, hidden } = await loadSnippets($)
85 const isOwn = own[command.name] !== undefined
86 const isStarter = STARTERS[command.name] !== undefined && !hidden.includes(command.name)
87 if (!isOwn && !isStarter) return { text: `No snippet named "${command.name}".` }
88 if (isOwn) {
89 const { [command.name]: _gone, ...rest } = own
90 await $.store.set('snippets', rest)
91 }
92 // Removing a name hides its starter too, so it doesn't come back.
93 if (STARTERS[command.name] !== undefined && !hidden.includes(command.name)) {
94 await $.store.set('hidden', [...hidden, command.name])
95 }
96 return { text: `Removed /snip ${command.name}.` }
97 }
98 case 'use':
99 return useSnippet($, command.name, command.extra)
100 }
101 })
102}
103hooks/snippets.ts 127 lines1// Pure snippet logic: no `$`, so tests import it directly.
2
3/** Ship-with snippets; the person can override or remove each. */
4export const STARTERS: Readonly<Record<string, string>> = {
5 review: [
6 'Review the changes on branch {{branch|the current branch}}: the uncommitted ones and the commits not yet on the main branch.',
7 'Pay extra attention to: {{args}}',
8 'Look for bugs first, then security problems, then needless complexity. List each finding with file:line, most severe first, and say how confident you are. Do not change any code yet.',
9 ].join('\n'),
10 explain: [
11 'Explain {{args|this code}}:',
12 '{{selection}}',
13 'Start with a two-sentence summary, then walk through the flow step by step with file:line references, then call out anything non-obvious or surprising.',
14 ].join('\n'),
15 tests: [
16 'Write tests for {{args|the code changed on this branch}}.',
17 '{{selection}}',
18 "Cover the happy path, edge cases and failure modes, in the project's existing test framework and style. Run them and fix any failures before you finish.",
19 ].join('\n'),
20 'commit-msg': [
21 'Write a commit message for the staged changes (read them with git diff --staged).',
22 'Context: {{args}}',
23 'Use an imperative subject line under 72 characters, a blank line, then a short body saying why the change was made. Show it to me; do not commit.',
24 ].join('\n'),
25 plan: [
26 'Before writing any code, make a plan for: {{args|the task we just discussed}}',
27 'List the files you will touch, the approach, the risks and how you will check that it works. Then wait for my go-ahead.',
28 ].join('\n'),
29}
30
31export const RESERVED = new Set(['save', 'rm', 'remove', 'delete', 'show', 'list', 'help', 'ls'])
32
33const PLACEHOLDER = /\{\{\s*(\w+)\s*(?:\|([^}]*))?\}\}/g
34
35export function isValidName(name: string): boolean {
36 return /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/.test(name) && !RESERVED.has(name.toLowerCase())
37}
38
39/** The placeholders a template names, so a caller fetches only those. */
40export function placeholders(template: string): Set<string> {
41 return new Set([...template.matchAll(PLACEHOLDER)].map(m => (m[1] ?? '').toLowerCase()))
42}
43
44/**
45 * Fills `{{name}}` and `{{name|default}}`. A line whose placeholders all came
46 * out empty (and had no default) is dropped, so a snippet reads well without
47 * a selection or extra text. Extra text with no `{{args}}` to go to is appended.
48 */
49export function expand(template: string, values: Readonly<Record<string, string>>): string {
50 const lines: string[] = []
51 for (const line of template.split('\n')) {
52 let named = 0
53 let empty = 0
54 const filled = line.replace(PLACEHOLDER, (_, rawName: string, fallback: string | undefined) => {
55 const value = (values[rawName.toLowerCase()] ?? '').trim()
56 named++
57 if (value !== '') return value
58 if (fallback !== undefined) return fallback.trim()
59 empty++
60 return ''
61 })
62 if (named > 0 && empty === named) continue
63 lines.push(filled)
64 }
65 let text = lines.join('\n').trim()
66 const extra = (values.args ?? '').trim()
67 if (extra !== '' && !placeholders(template).has('args')) text = `${text}\n\n${extra}`
68 return text
69}
70
71/** Starters the person has not removed, then their own (which win on a name). */
72export function merged(own: Readonly<Record<string, string>>, hidden: readonly string[]): Record<string, string> {
73 const out: Record<string, string> = {}
74 for (const [name, text] of Object.entries(STARTERS)) if (!hidden.includes(name)) out[name] = text
75 for (const [name, text] of Object.entries(own)) out[name] = text
76 return out
77}
78
79export type SnipCommand =
80 | { kind: 'list' }
81 | { kind: 'save'; name: string; text: string }
82 | { kind: 'rm'; name: string }
83 | { kind: 'show'; name: string }
84 | { kind: 'use'; name: string; extra: string }
85 | { kind: 'error'; message: string }
86
87export const USAGE =
88 'Usage: /snip · /snip <name> [extra text] · /snip save <name> <text> · /snip show <name> · /snip rm <name>'
89
90/** Reads what followed `/snip`. */
91export function parseSnip(args: string): SnipCommand {
92 const trimmed = args.trim()
93 if (trimmed === '' || trimmed === 'list' || trimmed === 'ls') return { kind: 'list' }
94 const [head = '', ...rest] = trimmed.split(/\s+/)
95 const verb = head.toLowerCase()
96 const afterVerb = trimmed.slice(head.length).trim()
97
98 if (verb === 'help') return { kind: 'error', message: USAGE }
99 if (verb === 'save') {
100 const name = rest[0] ?? ''
101 const text = afterVerb.slice(name.length).trim()
102 if (!isValidName(name)) return { kind: 'error', message: `"${name}" is not a usable name: letters, digits, - and _, up to 40, not a /snip verb. ${USAGE}` }
103 if (text === '') return { kind: 'error', message: `Nothing to save under "${name}". ${USAGE}` }
104 return { kind: 'save', name, text }
105 }
106 if (verb === 'rm' || verb === 'remove' || verb === 'delete') {
107 return rest[0] === undefined ? { kind: 'error', message: USAGE } : { kind: 'rm', name: rest[0] }
108 }
109 if (verb === 'show') {
110 return rest[0] === undefined ? { kind: 'error', message: USAGE } : { kind: 'show', name: rest[0] }
111 }
112 return { kind: 'use', name: head, extra: afterVerb }
113}
114
115/** One line per snippet: its name and the start of its text. */
116export function listing(all: Readonly<Record<string, string>>, ownNames: ReadonlySet<string>): string {
117 const names = Object.keys(all).sort()
118 if (names.length === 0) return `No snippets. ${USAGE}`
119 const width = Math.max(...names.map(n => n.length))
120 const rows = names.map(name => {
121 const first = (all[name] ?? '').split('\n')[0] ?? ''
122 const preview = first.length > 64 ? `${first.slice(0, 63)}…` : first
123 return ` ${name.padEnd(width)} ${preview}${ownNames.has(name) ? '' : ' (starter)'}`
124 })
125 return `Snippets:\n${rows.join('\n')}\n${USAGE}`
126}
127