Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


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));