Save past prompts to a searchable library; fuzzy search, then Copy or Apply to the prompt field.

Claude Code plugins by @josselinonduty.
| Plugin | Description |
|---|---|
commit-changes | Band above the prompt that splits uncommitted changes into logical sections and commits them one by one or automatically, with clean canonical messages. |
secret-guard | Detects secrets in prompts and conversation rows, moves them to the project's .env file and redacts them from the history. |
prompt-library | Save past prompts to a searchable library; fuzzy search, then Copy or Apply to the prompt field. |
/plugin marketplace add josselinonduty/claude-plugins
/plugin install commit-changes@josselinonduty-plugins
/plugin install secret-guard@josselinonduty-plugins
/plugin install prompt-library@josselinonduty-pluginshooks/register.tsx 181 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { SavedPrompt } from '../types'
5import { fuzzyFilter } from './fuzzy'
6
7const PANE = 'prompt-library'
8const library = atom({ plugin: 'prompt-library', key: 'library' } as const, [])
9const query = atom({ plugin: 'prompt-library', key: 'query' } as const, '')
10
11const idOf = (text: string): string => {
12 let h = 5381
13 for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0
14 return h.toString(36)
15}
16
17const preview = (text: string): string => {
18 const flat = text.replace(/\s+/g, ' ').trim()
19 return flat.length > 140 ? `${flat.slice(0, 139)}…` : flat
20}
21
22// Fixed, user-level file: the mod folder (and so $.store) is per session.
23const FILE = '.claude/prompt-library.json'
24
25async function filePath($: any): Promise<string> {
26 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
27 return `${home}/${FILE}`
28}
29
30async function load($: any): Promise<SavedPrompt[]> {
31 try {
32 const parsed = JSON.parse(await $.fs.read(await filePath($)))
33 return Array.isArray(parsed) ? parsed : []
34 } catch {
35 return []
36 }
37}
38
39async function persist($: any, list: SavedPrompt[]) {
40 await $.fs.write(await filePath($), JSON.stringify(list, null, 2))
41 await update($, library, () => list)
42}
43
44async function save($: any, text: string) {
45 const clean = text.trim()
46 if (clean === '') return
47 const id = idOf(clean)
48 const current = await load($)
49 if (current.some(p => p.id === id)) {
50 $.ui.toast('Already in prompt library')
51 return
52 }
53 const list = [{ id, text: clean, savedAt: await $.clock.now() }, ...current]
54 await persist($, list)
55 $.ui.toast('Saved to prompt library')
56}
57
58async function remove($: any, id: string) {
59 const list = (await load($)).filter(p => p.id !== id)
60 await persist($, list)
61}
62
63export const register: Register = on => {
64 on('session.start', async ($, e, next) => {
65 const fresh = await load($)
66 await update($, library, () => fresh)
67 await $.command.register({
68 name: 'prompts',
69 description: 'Open the prompt library',
70 })
71
72 return next(e)
73 })
74
75 on('command.run', { command: 'prompts' }, async $ => {
76 const fresh = await load($)
77 await update($, library, () => fresh)
78 await $.ui.open({ id: PANE, title: 'Prompt library', focus: true })
79
80 return { text: 'Prompt library opened.' }
81 })
82
83 // A save button revealed on hover over the person's own past prompts.
84 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
85 const original = await next(e)
86 if (e.props.origin.kind !== 'composer' || e.props.text.trim() === '') {
87 return original
88 }
89 const { Box, Button } = $.ui.resolve(e)
90 const id = idOf(e.props.text.trim())
91
92 return (
93 <Box key={`row:${id}`} flexDirection="column">
94 {original}
95 <Box
96 flexDirection="row"
97 justifyContent="flex-start"
98 flexShrink={0}
99 display="none"
100 hover={{ display: 'flex' }}
101 >
102 <Button
103 key={`save:${id}`}
104 label="Save"
105 onPress={() => save($, e.props.text)}
106 />
107 <Button
108 key={`copy:${id}`}
109 label="Copy"
110 onPress={async press => {
111 const r = await $.ui.copy({ text: e.props.text, surface: press.surface })
112 $.ui.toast(r.isCopied ? 'Copied' : 'Copy failed')
113 }}
114 />
115 </Box>
116 </Box>
117 )
118 })
119
120 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
121 const { Box, Text, Button, Input } = $.ui.resolve(e)
122 const q = await read($, query)
123 const all = await read($, library)
124 const shown = fuzzyFilter(q, all)
125 const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 24) - 6) / 3))
126
127 return (
128 <Box flexDirection="column">
129 <Input
130 key="search"
131 label="Search "
132 placeholder="fuzzy search saved prompts"
133 value={q}
134 autoFocus
135 onInput={v => update($, query, () => v)}
136 onSubmit={v => update($, query, () => v)}
137 />
138 {all.length === 0 && (
139 <Text dimColor>
140 Nothing saved yet. Hover a past prompt and press Save.
141 </Text>
142 )}
143 {all.length > 0 && shown.length === 0 && (
144 <Text dimColor>No match.</Text>
145 )}
146 {shown.slice(0, room).map(p => (
147 <Box key={`item:${p.id}`} flexDirection="column" marginTop={1}>
148 <Text>{preview(p.text)}</Text>
149 <Box>
150 <Button
151 key={`copy:${p.id}`}
152 label="Copy"
153 onPress={async press => {
154 const r = await $.ui.copy({ text: p.text, surface: press.surface })
155 $.ui.toast(r.isCopied ? 'Copied' : 'Copy failed')
156 }}
157 />
158 <Button
159 key={`apply:${p.id}`}
160 label="Apply"
161 onPress={async () => {
162 const r = await $.prompt.fill({ text: p.text, mode: 'replace' })
163 $.ui.toast(r.isFilled ? 'Applied to prompt' : 'Prompt field unavailable')
164 }}
165 />
166 <Button
167 key={`del:${p.id}`}
168 label="Delete"
169 onPress={() => remove($, p.id)}
170 />
171 </Box>
172 </Box>
173 ))}
174 {shown.length > room && (
175 <Text dimColor>+{shown.length - room} more, refine your search</Text>
176 )}
177 </Box>
178 )
179 })
180}
181hooks/fuzzy.ts 29 lines1// Classic fuzzy subsequence matching: every query character must appear in
2// order; consecutive runs and word starts score higher. Returns null on no match.
3export function fuzzyScore(query: string, text: string): number | null {
4 const q = query.toLowerCase().replace(/\s+/g, '')
5 if (q === '') return 0
6 const t = text.toLowerCase()
7 let score = 0
8 let ti = 0
9 let streak = 0
10 for (const ch of q) {
11 const at = t.indexOf(ch, ti)
12 if (at === -1) return null
13 streak = at === ti && ti > 0 ? streak + 1 : 0
14 const isWordStart = at === 0 || /[\s\-_/.]/.test(t[at - 1] ?? '')
15 score += 1 + streak * 3 + (isWordStart ? 4 : 0) - Math.min(at - ti, 10) * 0.1
16 ti = at + 1
17 }
18 return score
19}
20
21export function fuzzyFilter<T extends { text: string }>(query: string, items: T[]): T[] {
22 if (query.trim() === '') return items
23 return items
24 .map(item => ({ item, score: fuzzyScore(query, item.text) }))
25 .filter((r): r is { item: T; score: number } => r.score !== null)
26 .sort((a, b) => b.score - a.score)
27 .map(r => r.item)
28}
29types/index.d.ts 8 lines1export type SavedPrompt = { id: string; text: string; savedAt: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'prompt-library': { library: SavedPrompt[]; query: string }
6 }
7}
8