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