bin/ideamine.js (14921 bytes)
1 #!/usr/bin/env node 2 // ideamine CLI. Also the entry point the plugin uses for its MCP server (`mcp`) and hook (`hook`). 3 4 import { spawnSync } from 'node:child_process'; 5 import fs from 'node:fs'; 6 7 const HELP = `ideamine: an idea inbox for Claude Code 8 9 ideamine add <idea...> save an idea ("-" reads stdin; a bulleted list = one idea per bullet) 10 ideamine ls [lane|-a] [here] the queue. lanes: open inbox do maybe skip doing done dropped all 11 ideamine cat <id...> ideas in full 12 ideamine rm <id...> delete ideas for good 13 ideamine done|drop|start|reopen <id> [note...] 14 ideamine note <id> <text...> append a note 15 ideamine model <id> <haiku|sonnet|opus|fable> override the recommended model 16 ideamine next [--here] the idea to build next 17 ideamine go [id] [--print] open Claude Code on the idea's recommended model, in its project 18 ideamine go [id] --pipeline the same, with the agent pipeline (/pipeline) as the request 19 ideamine sort [--model sonnet] [--limit 20] [--dry-run] 20 triage the inbox with one headless \`claude -p\` call 21 ideamine watch [off] the watcher: Haiku triages new ideas and pairs them with projects 22 ideamine watch-pass one pass of the watcher, now, in the foreground 23 ideamine find <words...> search by meaning (nomic-embed-text), in every lane 24 ideamine groups [lane|-a] ideas grouped by meaning 25 ideamine embed embed new ideas, and show how the vectors and thresholds fit 26 ideamine publish [url|off] [--dir <folder>] 27 upload the dashboard (index.html, data.json) now; a url also turns 28 on the upload after each change; --dir writes the files to a folder 29 ideamine serve [--port 4332] the dashboard with a button for each command, at 127.0.0.1 30 ideamine sync [url|off] share the archive of every machine through an ideamine server 31 ideamine prompts import put the prompts of older Claude Code chats into the prompt log 32 ideamine usage [days] tokens, models, and price for each project (0 days: all time) 33 ideamine config [key [value]] show or change a setting (an empty value restores the default) 34 ideamine export [file.md] Markdown export of the whole archive 35 ideamine path where the archive lives (override with IDEAMINE_HOME) 36 ideamine mcp run the MCP server on stdio 37 ideamine hook run the UserPromptSubmit hook (reads the hook JSON on stdin)`; 38 39 function parseArgs(argv) { 40 const flags = {}; 41 const words = []; 42 for (let i = 0; i < argv.length; i++) { 43 const a = argv[i]; 44 if (a.startsWith('--')) { 45 const [key, inline] = a.slice(2).split('=', 2); 46 if (inline !== undefined) flags[key] = inline; 47 else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && ['model', 'limit', 'budget', 'query', 'dir', 'port'].includes(key)) flags[key] = argv[++i]; 48 else flags[key] = true; 49 } else words.push(a); 50 } 51 return { flags, words }; 52 } 53 54 async function readStdin() { 55 let data = ''; 56 for await (const chunk of process.stdin) data += chunk; 57 return data; 58 } 59 60 function fail(message) { 61 process.stderr.write(`ideamine: ${message}\n`); 62 // A window that the dashboard opened closes when the command ends. Wait for a key, so that the 63 // user can read the error. 64 if (process.env.IDEAMINE_WINDOW && process.stdin.isTTY) { 65 spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/c', 'pause'], { stdio: 'inherit' }); 66 } 67 process.exit(1); 68 } 69 70 async function main() { 71 const [command = 'help', ...rest] = process.argv.slice(2); 72 73 // The two machine entry points load only what they need. 74 if (command === 'mcp') return (await import('../src/mcp.js')).serve({ prompts: !rest.includes('--plugin') }); 75 if (command === 'hook') return (await import('../src/hook.js')).runHook(); 76 77 const store = await import('../src/store.js'); 78 const archive = await import('../src/archive.js'); 79 const render = await import('../src/render.js'); 80 const { splitIdeas, clip } = await import('../src/text.js'); 81 const { flags, words } = parseArgs(rest); 82 const cwd = process.cwd(); 83 const needId = () => words[0] || fail(`usage: ideamine ${command} <id>`); 84 // A change that waits for the ideamine server: say so, and go on. 85 const queued = (out) => out.queued && (console.log(archive.queuedText(out)), true); 86 87 switch (command) { 88 case 'add': { 89 const text = words.length === 1 && words[0] === '-' ? await readStdin() : words.join(' '); 90 const results = await archive.add(splitIdeas(text), { source: 'cli', project: flags['no-project'] ? null : cwd }); 91 if (!queued(results)) console.log(render.renderAdded(results, store.load())); 92 break; 93 } 94 case 'ls': { 95 const lower = words.map((w) => (w === '-a' ? 'all' : w.toLowerCase())); 96 const filter = lower.find((w) => store.FILTERS.includes(w)) || 'open'; 97 const here = lower.includes('here') || flags.here; 98 console.log(render.renderBoard(await archive.fresh(), { filter, project: here ? cwd : null, query: flags.query || '', cwd })); 99 break; 100 } 101 case 'cat': { 102 needId(); 103 const db = await archive.fresh(); 104 const ideas = words.map((id) => store.findIdea(db, id) || fail(`no idea #${id.replace(/^#/, '')}`)); 105 console.log(ideas.map(render.renderIdea).join('\n\n')); 106 break; 107 } 108 case 'rm': { 109 needId(); 110 const gone = await archive.remove(words); 111 if (!queued(gone)) for (const idea of gone) console.log(`Removed #${idea.id} ${clip(idea.title, 60)}`); 112 break; 113 } 114 case 'done': 115 case 'drop': 116 case 'start': 117 case 'doing': 118 case 'reopen': { 119 const status = { drop: 'dropped', start: 'doing' }[command] || command; 120 const note = words.slice(1).join(' '); 121 const idea = await archive.update(needId(), { status, note: note || undefined }); 122 if (!queued(idea)) console.log(`#${idea.id} ${clip(idea.title, 60)} → ${store.lane(idea)}`); 123 break; 124 } 125 case 'note': { 126 const note = words.slice(1).join(' ') || fail('usage: ideamine note <id> <text>'); 127 const idea = await archive.update(needId(), { note }); 128 if (!queued(idea)) console.log(`#${idea.id}: note added`); 129 break; 130 } 131 case 'model': { 132 const idea = await archive.update(needId(), { model: words[1] || fail('usage: ideamine model <id> <haiku|sonnet|opus|fable>') }); 133 if (!queued(idea)) console.log(`#${idea.id} will be built with ${idea.triage.model}`); 134 break; 135 } 136 case 'next': { 137 const idea = store.pickNext(await archive.fresh(), { project: cwd, only: !!flags.here }); 138 if (!idea) { 139 console.log('Nothing is ready to build. Triage the inbox first: ideamine sort'); 140 break; 141 } 142 console.log(render.renderIdea(idea)); 143 break; 144 } 145 case 'go': { 146 const { goPlan, launchSession, pipelinePrompt } = await import('../src/claude.js'); 147 const db = await archive.fresh(); 148 const idea = words[0] ? store.findIdea(db, words[0]) : store.pickNext(db, { project: cwd }); 149 if (!idea) fail(words[0] ? `no idea #${words[0]}` : 'nothing is ready to build; run: ideamine sort'); 150 const plan = goPlan(idea, cwd); 151 const { model, dir } = plan; 152 const prompt = flags.pipeline ? pipelinePrompt(idea, { dir }) : plan.prompt; 153 if (flags.print) { 154 console.log(`directory: ${dir}\nmodel: ${model}\n\n${prompt}`); 155 break; 156 } 157 queued(await archive.update(idea.id, { status: 'doing', note: flags.pipeline ? 'pipeline: started' : undefined })); 158 console.log(`Opening Claude Code (${model}) in ${dir} for #${idea.id}${flags.pipeline ? ', through the agent pipeline' : ''}…`); 159 process.exitCode = launchSession({ model, prompt, cwd: dir }); 160 break; 161 } 162 case 'sort': { 163 const { headlessTriage } = await import('../src/claude.js'); 164 const out = await headlessTriage({ 165 model: typeof flags.model === 'string' ? flags.model : undefined, 166 limit: Number(flags.limit) || 20, 167 budget: Number(flags.budget) || 1, 168 dryRun: !!flags['dry-run'], 169 }); 170 if (out.message) { 171 console.log(out.message); 172 break; 173 } 174 const ok = out.results.filter((r) => !r.error); 175 const spend = out.tokens ? ` (${out.tokens.input} in / ${out.tokens.output} out tokens, ~$${Number(out.cost || 0).toFixed(4)})` : ''; 176 console.log(`Triaged ${ok.length}${spend}:`); 177 for (const r of ok) console.log(` #${r.id} ${r.verdict.padEnd(5)} ${r.verdict === 'skip' ? '' : `${r.model.padEnd(6)} ${r.size.toUpperCase().padEnd(2)} `}${clip(r.title, 60)}`); 178 for (const r of out.results.filter((x) => x.error)) console.log(` #${r.id} not saved: ${r.error}`); 179 break; 180 } 181 case 'watch': { 182 const watch = await import('../src/watch.js'); 183 if (words[0] === 'off') watch.turnOff(); 184 else { 185 watch.turnOn(); 186 watch.kick(); 187 } 188 console.log(watch.status()); 189 break; 190 } 191 case 'watch-pass': { 192 const watch = await import('../src/watch.js'); 193 if ((await watch.pass()) === 'error') process.exitCode = 1; 194 break; 195 } 196 case 'find': { 197 const embed = await import('../src/embed.js'); 198 const query = words.join(' ') || fail('usage: ideamine find <words...>'); 199 console.log(render.renderFound(await embed.find(await archive.fresh(), query), query, { cwd })); 200 break; 201 } 202 case 'groups': { 203 const embed = await import('../src/embed.js'); 204 const lower = words.map((w) => (w === '-a' ? 'all' : w.toLowerCase())); 205 const filter = lower.find((w) => store.FILTERS.includes(w)) || 'open'; 206 const ideas = store.listIdeas(await archive.fresh(), { filter }); 207 console.log(render.renderGroups(await embed.groupIdeas(ideas), ideas, { cwd, scope: filter })); 208 break; 209 } 210 case 'embed': { 211 const embed = await import('../src/embed.js'); 212 console.log(await embed.status(store.load())); 213 break; 214 } 215 case 'publish': { 216 const publish = await import('../src/publish.js'); 217 if (flags.background) { 218 process.exitCode = (await publish.backgroundPublish()) === 'error' ? 1 : 0; 219 break; 220 } 221 const config = await import('../src/config.js'); 222 if (words[0] === 'off') { 223 config.set('publish_url', ''); 224 console.log(publish.status()); 225 break; 226 } 227 if (words[0]) config.set('publish_url', words[0]); 228 const out = await publish.publish({ dir: typeof flags.dir === 'string' ? flags.dir : null }); 229 console.log(`Published ${out.ideas} ideas and ${out.groups} groups to ${out.where}`); 230 if (out.note) console.log(out.note); 231 break; 232 } 233 case 'serve': { 234 const serve = await import('../src/serve.js'); 235 const { server, url } = await serve.start({ 236 ...(flags.port ? { port: Number(flags.port) } : {}), 237 log: (text) => console.log(serve.logLine(text)), 238 }); 239 server.on('close', () => process.exit(0)); // /ideas-web off 240 console.log(serve.logLine(`the dashboard with buttons runs at ${url} (Ctrl+C stops it)`)); 241 // Only the server role holds the archive, so only it gives ideas to coop agents. 242 if (!(await import('../src/sync.js')).enabled()) (await import('../src/coop.js')).start({ log: (text) => console.log(serve.logLine(text)) }); 243 break; 244 } 245 case 'sync': { 246 const sync = await import('../src/sync.js'); 247 if (flags.background) { 248 process.exitCode = (await sync.backgroundSync()) === 'error' ? 1 : 0; 249 break; 250 } 251 if (words[0]) { 252 const backup = sync.setServer(words[0] === 'off' ? '' : words[0]); 253 if (backup) console.log(`The archive that was here is in ${backup}.`); 254 } 255 if (sync.enabled()) { 256 try { 257 await sync.pull({ timeoutMs: 30000, prompts: true }); 258 } catch (e) { 259 if (!(e instanceof sync.SyncError)) throw e; 260 process.exitCode = 1; 261 } 262 } 263 console.log(sync.status()); 264 break; 265 } 266 case 'prompts': { 267 const sync = await import('../src/sync.js'); 268 if (words[0] !== 'import') fail('usage: ideamine prompts import'); 269 if (!sync.enabled()) fail('the prompt log goes to an ideamine server. Turn sync on first: ideamine sync <url>'); 270 console.log(`Put ${sync.importPrompts()} prompts from ${sync.transcriptsDir()} into the prompt outbox. The next sync sends them.`); 271 break; 272 } 273 case 'usage': { 274 const usage = await import('../src/usage.js'); 275 const days = Number(words[0]) || 0; 276 const { rows, files } = usage.scan(); 277 const projects = usage.byProject(usage.since(rows, days)); 278 const money = (n) => `$${n.toFixed(2)}`; 279 const tokens = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n)); 280 const total = projects.reduce((n, p) => n + p.cost, 0); 281 console.log(`ideamine usage: ${money(total)} in ${days ? `the last ${days} days` : 'all time'}, from ${files} transcripts. The price is the price of the API.`); 282 for (const p of projects.slice(0, Number(process.env.IDEAMINE_USAGE_LIMIT) || 20)) { 283 console.log(`\n ${money(p.cost).padStart(9)} ${p.project}`); 284 console.log(` in ${tokens(p.input)} · out ${tokens(p.output)} · cache write ${tokens(p.write5m + p.write1h)} · cache read ${tokens(p.read)} · ${p.messages} answers`); 285 for (const m of Object.values(p.models).sort((a, b) => b.cost - a.cost)) { 286 console.log(` ${money(m.cost).padStart(9)} ${m.model}${usage.priced(m.model) ? '' : ' (no price for this model)'}`); 287 } 288 } 289 break; 290 } 291 case 'config': { 292 const config = await import('../src/config.js'); 293 if (words.length >= 1 && rest.length >= 2) config.set(words[0], words.slice(1).join(' ')); 294 else if (words.length === 1 && rest.length === 1) { 295 console.log(config.get(words[0])); 296 break; 297 } 298 console.log(config.describe()); 299 break; 300 } 301 case 'export': { 302 const md = render.renderMarkdown(await archive.fresh()); 303 if (words[0]) { 304 fs.writeFileSync(words[0], md); 305 console.log(`Wrote ${words[0]}`); 306 } else process.stdout.write(md); 307 break; 308 } 309 case 'path': 310 console.log(store.dbPath()); 311 break; 312 case 'version': 313 case '--version': 314 case '-v': 315 console.log(JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version); 316 break; 317 case 'help': 318 case '--help': 319 case '-h': 320 console.log(HELP); 321 break; 322 default: 323 fail(`unknown command "${command}". Try: ideamine help`); 324 } 325 } 326 327 main().catch((e) => fail(e.message));