/** * pi-stash — Prompt stash for Pi * * Save, list, and restore prompt drafts. Like git stash for prompts. * * Commands: * /stash save [name] — save a prompt * /stash list — list saved prompts * /stash pop [name|id] — restore and delete * /stash apply [name|id] — restore without deleting * /stash drop [name|id] — delete a stash * /stash clear — delete all */ import type { ExtensionAPI } from '@anthropic-ai/claude-code' import { readFileSync, writeFileSync, mkdirSync } from 'fs' import { join } from 'path' import { homedir } from 'os' interface StashEntry { id: number name: string text: string timestamp: number } const STASH_FILE = join(homedir(), '.pi', 'stash', 'prompts.json') let nextId = 1 function load(): StashEntry[] { try { const data = JSON.parse(readFileSync(STASH_FILE, 'utf-8')) if (data.length > 0) nextId = Math.max(...data.map((e: StashEntry) => e.id)) + 1 return data } catch { return [] } } function save(entries: StashEntry[]) { mkdirSync(join(homedir(), '.pi', 'stash'), { recursive: true }) writeFileSync(STASH_FILE, JSON.stringify(entries, null, 2)) } function findEntry(entries: StashEntry[], query: string): StashEntry | undefined { const num = parseInt(query, 10) if (!isNaN(num)) return entries.find(e => e.id === num) return entries.find(e => e.name.toLowerCase() === query.toLowerCase()) || entries.find(e => e.name.toLowerCase().includes(query.toLowerCase())) } function formatList(entries: StashEntry[]): string { if (entries.length === 0) return 'Stash is empty.' const lines = entries.map(e => { const age = Math.round((Date.now() - e.timestamp) / 60000) const ageStr = age < 60 ? `${age}m` : age < 1440 ? `${Math.floor(age / 60)}h` : `${Math.floor(age / 1440)}d` const preview = e.text.length > 60 ? e.text.slice(0, 57) + '...' : e.text return `**${e.id}** \`${e.name}\` _(${ageStr} ago)_: ${preview}` }) return `## Stash (${entries.length})\n\n${lines.join('\n')}` } export default function init(pi: ExtensionAPI) { pi.addCommand({ name: 'stash', description: 'Save and restore prompt drafts', handler: async (args) => { const entries = load() const parts = args.trim().split(/\s+/) const sub = parts[0]?.toLowerCase() if (!sub || sub === 'list') { pi.sendMessage({ content: formatList(entries), display: true }, { triggerTurn: false }) return } if (sub === 'save' || sub === 'push') { const rest = args.replace(/^(save|push)\s+/, '') let name: string, text: string const nameMatch = rest.match(/^(\S+)\s+(.+)$/s) if (nameMatch) { name = nameMatch[1] text = nameMatch[2] } else { name = `stash-${nextId}` text = rest } if (!text.trim()) { pi.sendMessage({ content: 'Usage: /stash save [name] ', display: true }, { triggerTurn: false }) return } entries.push({ id: nextId++, name, text: text.trim(), timestamp: Date.now() }) save(entries) pi.sendMessage({ content: `Saved as **${name}** (id ${nextId - 1}).`, display: true }, { triggerTurn: false }) return } if (sub === 'pop') { const query = parts[1] || '' const entry = query ? findEntry(entries, query) : entries[entries.length - 1] if (!entry) { pi.sendMessage({ content: 'Not found.', display: true }, { triggerTurn: false }); return } const idx = entries.indexOf(entry) entries.splice(idx, 1) save(entries) pi.sendMessage({ content: entry.text, display: true }, { triggerTurn: true }) return } if (sub === 'apply') { const query = parts[1] || '' const entry = query ? findEntry(entries, query) : entries[entries.length - 1] if (!entry) { pi.sendMessage({ content: 'Not found.', display: true }, { triggerTurn: false }); return } pi.sendMessage({ content: entry.text, display: true }, { triggerTurn: true }) return } if (sub === 'drop') { const query = parts[1] if (!query) { pi.sendMessage({ content: 'Usage: /stash drop ', display: true }, { triggerTurn: false }); return } const entry = findEntry(entries, query) if (!entry) { pi.sendMessage({ content: 'Not found.', display: true }, { triggerTurn: false }); return } entries.splice(entries.indexOf(entry), 1) save(entries) pi.sendMessage({ content: `Dropped **${entry.name}**.`, display: true }, { triggerTurn: false }) return } if (sub === 'clear') { save([]) pi.sendMessage({ content: 'Stash cleared.', display: true }, { triggerTurn: false }) return } pi.sendMessage({ content: '**Usage:**\n- `/stash save [name] ` — save prompt\n- `/stash list` — list all\n- `/stash pop [name|id]` — restore + delete\n- `/stash apply [name|id]` — restore\n- `/stash drop [name|id]` — delete\n- `/stash clear` — clear all', display: true, }, { triggerTurn: false }) }, }) pi.addTool({ name: 'stash_save', description: 'Save a prompt draft for later. Like git stash for prompts.', parameters: { type: 'object', properties: { text: { type: 'string', description: 'Prompt text to save' }, name: { type: 'string', description: 'Optional name for the stash' }, }, required: ['text'], }, handler: async (params: { text: string; name?: string }) => { const entries = load() const name = params.name || `stash-${nextId}` entries.push({ id: nextId++, name, text: params.text, timestamp: Date.now() }) save(entries) return `Saved as "${name}" (id ${nextId - 1}).` }, }) pi.addTool({ name: 'stash_list', description: 'List all saved prompt stashes.', parameters: { type: 'object', properties: {} }, handler: async () => formatList(load()), }) pi.addTool({ name: 'stash_pop', description: 'Restore a saved prompt and delete it from stash.', parameters: { type: 'object', properties: { query: { type: 'string', description: 'Name or ID of stash to pop (default: latest)' }, }, }, handler: async (params: { query?: string }) => { const entries = load() const entry = params.query ? findEntry(entries, params.query) : entries[entries.length - 1] if (!entry) return 'Stash not found.' entries.splice(entries.indexOf(entry), 1) save(entries) return entry.text }, }) }