SLOPSHOPPER

prompt-library

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

newpanerowscommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-library
│ ┃ Prompt library ✕ ⟨Claude Code's own drawing⟩ │ ┃ Search : fuzzy search saved prompts │ ┃ Nothing saved yet. Hover a past prompt and ⏺ Read(src/auth.ts) │ ┃ press Save. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /prompts │ ⎿ prompt-library: Prompt library opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompt library
Search : fuzzy search saved prompts Nothing saved yet. Hover a past prompt and press Save.
Your message
⟨Claude Code's own drawing⟩
README

claude-plugins

Claude Code plugins by @josselinonduty.

PluginDescription
commit-changesBand above the prompt that splits uncommitted changes into logical sections and commits them one by one or automatically, with clean canonical messages.
secret-guardDetects secrets in prompts and conversation rows, moves them to the project's .env file and redacts them from the history.
prompt-librarySave past prompts to a searchable library; fuzzy search, then Copy or Apply to the prompt field.

Install

/plugin marketplace add josselinonduty/claude-plugins
/plugin install commit-changes@josselinonduty-plugins
/plugin install secret-guard@josselinonduty-plugins
/plugin install prompt-library@josselinonduty-plugins
Source 3 files
hooks/register.tsx 181 lines
1import { 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}
181
hooks/fuzzy.ts 29 lines
1// 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}
29
types/index.d.ts 8 lines
1export 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