Keeps your saved prompts, per project: /prompts add, use, rename, remove. The moddeck side bar shows them.

Install (Claude Code 2.1.295 or later): /plugin install prompt-library --marketplace samuelbfernandes/moddeck
Keeps your saved prompts, per project, one click away. It has no pane of its own: the moddeck side bar reads the list and draws it. Nothing is ever sent for you and no model is called.
/prompts or /prompts list: numbered list, name and the first 60 characters./prompts add <name> | <prompt text>: save one. The name is one word of lowercase letters, digits and hyphens (1 to 24 characters); the text is at most 4000 characters. A name already in use is refused./prompts use <name>: puts the text in the prompt box. You press Enter. If the box cannot take it, it says so and you type it yourself./prompts rename <old> <new> and /prompts remove <name>./ is required.The first time a project has no saved list you get four: explain, tests, review, eda. Remove them and they do not come back.
enabled (default on).maxItems (default 50): adding past this is refused.The list is the state value { plugin: 'prompt-library', key: 'items' }, an array of { name, text }, loaded at session start. Read it with $.state.get.
items:<project folder>; if the folder cannot be read, one shared list is used./prompts command.hooks/register.tsx 144 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PromptItem } from '../types'
5import { HELP, MAX_TEXT, STARTERS, checkName, clean, listText, maxOf, storeKey } from './lib'
6
7type Dollar = EngineInterface
8
9const COMMAND = 'prompts'
10const FAILED = 'Prompts · that did not work, nothing changed'
11
12/** The list the moddeck side bar reads with $.state.get. */
13const items = atom({ plugin: 'prompt-library', key: 'items' } as const, [])
14
15/** This project's key: per project when the cwd is known, otherwise one shared list. */
16async function keyOf($: Dollar): Promise<string> {
17 try {
18 const cwd = await $.session.cwd()
19 if (typeof cwd === 'string' && cwd !== '') return storeKey(cwd)
20 } catch {
21 // one list for everything
22 }
23 return 'items'
24}
25
26/** The saved list; the starters the first time the store has never been written. */
27async function load($: Dollar): Promise<{ key: string; list: PromptItem[] }> {
28 const key = await keyOf($)
29 const raw = await $.store.get(key)
30 if (raw === undefined) {
31 const list = STARTERS.map(s => ({ ...s }))
32 await $.store.set(key, list)
33 return { key, list }
34 }
35 return { key, list: clean(raw) }
36}
37
38async function save($: Dollar, key: string, list: PromptItem[]): Promise<void> {
39 await $.store.set(key, list)
40 await update($, items, () => list)
41}
42
43async function mirror($: Dollar): Promise<void> {
44 try {
45 const { list } = await load($)
46 await update($, items, () => list)
47 } catch {
48 // the side bar then shows nothing
49 }
50}
51
52async function run($: Dollar, maxItems: number, args: string): Promise<string> {
53 const text = (args ?? '').trim()
54 const m = /^(\S+)(?:\s+([\s\S]*))?$/.exec(text)
55 const sub = (m?.[1] ?? 'list').toLowerCase()
56 const rest = (m?.[2] ?? '').trim()
57 const { key, list } = await load($)
58 await update($, items, () => list)
59
60 if (sub === 'list') return listText(list)
61
62 if (sub === 'add') {
63 const at = rest.indexOf(' | ')
64 if (at < 0) return 'Prompts · use /prompts add <name> | <prompt text>'
65 const c = checkName(rest.slice(0, at))
66 if ('error' in c) return c.error
67 const body = rest.slice(at + 3).trim()
68 if (body === '') return 'Prompts · the prompt text is empty'
69 if (body.length > MAX_TEXT) return `Prompts · text is too long (most ${MAX_TEXT} characters)`
70 if (list.some(i => i.name === c.name)) return `Prompts · "${c.name}" already exists · pick another name or /prompts remove ${c.name}`
71 if (list.length >= maxItems) return `Prompts · full (${maxItems}) · remove one first`
72 await save($, key, [...list, { name: c.name, text: body }])
73 return `Prompts · saved "${c.name}"`
74 }
75
76 if (sub === 'remove') {
77 const n = rest.toLowerCase()
78 if (n === '') return 'Prompts · use /prompts remove <name>'
79 if (!list.some(i => i.name === n)) return `Prompts · no prompt named "${n}"`
80 await save($, key, list.filter(i => i.name !== n))
81 return `Prompts · removed "${n}"`
82 }
83
84 if (sub === 'use') {
85 const n = rest.toLowerCase()
86 if (n === '') return 'Prompts · use /prompts use <name>'
87 const it = list.find(i => i.name === n)
88 if (!it) return `Prompts · no prompt named "${n}"`
89 let filled = false
90 try {
91 const out = (await $.prompt.fill({ text: it.text })) as { isFilled?: boolean } | undefined
92 filled = out?.isFilled === true
93 } catch {
94 filled = false
95 }
96 return filled ? `Prompts · "${n}" is in the prompt box · press Enter to send` : 'Prompts · could not fill the box, type it yourself'
97 }
98
99 if (sub === 'rename') {
100 const parts = rest.split(/\s+/).filter(Boolean)
101 if (parts.length !== 2) return 'Prompts · use /prompts rename <old> <new>'
102 const old = parts[0].toLowerCase()
103 const c = checkName(parts[1])
104 if ('error' in c) return c.error
105 if (!list.some(i => i.name === old)) return `Prompts · no prompt named "${old}"`
106 if (c.name !== old && list.some(i => i.name === c.name)) return `Prompts · "${c.name}" already exists`
107 await save($, key, list.map(i => (i.name === old ? { ...i, name: c.name } : i)))
108 return `Prompts · renamed "${old}" to "${c.name}"`
109 }
110
111 return HELP
112}
113
114export const register: Register = (on, options) => {
115 const isEnabled = options.enabled !== false
116 const maxItems = maxOf(options.maxItems)
117
118 on('session.start', async ($, e, next) => {
119 if (isEnabled) {
120 await mirror($)
121 try {
122 await $.command.register({
123 name: COMMAND,
124 description: 'Your saved prompts: /prompts [list | add <name> | <text> | use <name> | rename <old> <new> | remove <name>]',
125 argumentHint: 'list | add | use | rename | remove',
126 immediate: true,
127 })
128 } catch {
129 // fail-open: without the command the mod is simply quiet
130 }
131 }
132 return next(e)
133 })
134
135 on('command.run', { command: COMMAND }, async ($, e) => {
136 if (!isEnabled) return { text: 'Prompts · disabled · the enabled setting is false (change it in /config)' }
137 try {
138 return { text: await run($, maxItems, e.args) }
139 } catch {
140 return { text: FAILED }
141 }
142 }).catch(() => ({ text: FAILED }))
143}
144hooks/lib.ts 59 lines1import type { PromptItem } from '../types'
2
3export const MAX_TEXT = 4000
4export const MAX_NAME = 24
5export const DEFAULT_MAX = 50
6
7export const STARTERS: PromptItem[] = [
8 { name: 'explain', text: 'Explain this code step by step, then list anything risky or unclear' },
9 { name: 'tests', text: 'Write tests for the code I just changed; run them and fix failures' },
10 { name: 'review', text: 'Review my last change for bugs, edge cases and missing tests; do not edit anything' },
11 { name: 'eda', text: 'Profile this dataset: shape, dtypes, missing values, duplicates, outliers; summarise in a table and suggest next steps' },
12]
13
14export const HELP = [
15 'Prompts · /prompts list',
16 '/prompts add <name> | <prompt text>',
17 '/prompts use <name> (puts it in the prompt box, nothing is sent)',
18 '/prompts rename <old> <new>',
19 '/prompts remove <name>',
20].join('\n')
21
22export const maxOf = (v: unknown): number => {
23 const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : DEFAULT_MAX
24 return n < 1 ? DEFAULT_MAX : Math.min(n, 200)
25}
26
27export const storeKey = (cwd: string) => `items:${cwd}`
28
29/** Keeps only well-formed items from whatever the store held. */
30export function clean(raw: unknown): PromptItem[] {
31 if (!Array.isArray(raw)) return []
32 const out: PromptItem[] = []
33 for (const r of raw) {
34 const o = r as { name?: unknown; text?: unknown } | null
35 if (o && typeof o.name === 'string' && typeof o.text === 'string' && !out.some(i => i.name === o.name)) {
36 out.push({ name: o.name, text: o.text })
37 }
38 }
39 return out
40}
41
42/** A name as typed -> a valid name, or a plain reason. */
43export function checkName(raw: string): { name: string } | { error: string } {
44 const name = raw.trim().toLowerCase()
45 if (name === '') return { error: 'Prompts · give it a name' }
46 if (!/^[a-z0-9-]+$/.test(name)) return { error: 'Prompts · names use lowercase letters, digits and hyphens only' }
47 if (name.length > MAX_NAME) return { error: `Prompts · name is too long (most ${MAX_NAME} characters)` }
48 return { name }
49}
50
51export function listText(items: PromptItem[]): string {
52 if (items.length === 0) return 'Prompts · none saved · /prompts add <name> | <prompt text>'
53 const rows = items.map((it, i) => {
54 const one = it.text.replace(/\s+/g, ' ').trim()
55 return `${i + 1}. ${it.name} · ${one.length > 60 ? one.slice(0, 60) + '…' : one}`
56 })
57 return `Prompts · ${items.length} saved\n${rows.join('\n')}`
58}
59types/index.d.ts 17 lines1/** One saved prompt. Plain JSON: it lives in $.store and in $.state. */
2export type PromptItem = {
3 /** Lowercase letters, digits and hyphens, 1 to 24 characters. */
4 name: string
5 /** The prompt text, at most 4000 characters. */
6 text: string
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'prompt-library': {
12 /** This project's saved prompts, in the order added; read by the moddeck side bar. */
13 items: PromptItem[]
14 }
15 }
16}
17