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


tests/mcp.test.js (8231 bytes)

1 import assert from 'node:assert/strict';
2 import { spawn } from 'node:child_process';
3 import fs from 'node:fs';
4 import os from 'node:os';
5 import path from 'node:path';
6 import readline from 'node:readline';
7 import { after, before, test } from 'node:test';
8 import { fileURLToPath } from 'node:url';
9 
10 const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
11 const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-proj-'));
12 let server;
13 let nextId = 1;
14 const pending = new Map();
15 
16 function request(method, params = {}) {
17   const id = nextId++;
18   server.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
19   return new Promise((resolve) => pending.set(id, resolve));
20 }
21 
22 async function call(name, args = {}) {
23   const res = await request('tools/call', { name, arguments: args });
24   return { text: res.result.content[0].text, isError: !!res.result.isError };
25 }
26 
27 before(async () => {
28   const env = {
29     ...process.env,
30     IDEAMINE_HOME: fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-mcp-')),
31     IDEAMINE_CLAUDE_BIN: fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url)),
32   };
33   env.CLAUDE_CONFIG_DIR = env.IDEAMINE_HOME; // no .claude.json: the projects of this machine stay out of the tests
34   server = spawn(process.execPath, [BIN, 'mcp'], { cwd: project, env, stdio: ['pipe', 'pipe', 'inherit'] });
35   readline.createInterface({ input: server.stdout }).on('line', (line) => {
36     const msg = JSON.parse(line); // stdout must carry nothing but JSON-RPC
37     pending.get(msg.id)?.(msg);
38   });
39   const init = await request('initialize', {
40     protocolVersion: '2025-06-18',
41     capabilities: {},
42     clientInfo: { name: 'test', version: '0' },
43   });
44   assert.equal(init.result.protocolVersion, '2025-06-18');
45   assert.equal(init.result.serverInfo.name, 'ideamine');
46   assert.ok(init.result.capabilities.tools);
47   assert.ok(init.result.capabilities.prompts);
48   server.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
49 });
50 
51 after(() => server.kill());
52 
53 test('lists the six tools with schemas', async () => {
54   const res = await request('tools/list');
55   assert.deepEqual(res.result.tools.map((t) => t.name), ['idea_add', 'idea_list', 'idea_triage', 'idea_update', 'idea_next', 'idea_remove']);
56   for (const t of res.result.tools) assert.equal(t.inputSchema.type, 'object');
57 });
58 
59 test('full loop: add, triage, next, update', async () => {
60   const added = await call('idea_add', { text: '- cache the API responses\n- rename helpers.js to utils.js' });
61   assert.match(added.text, /Saved 2 ideas: #1, #2/);
62 
63   const work = await call('idea_triage');
64   assert.match(work.text, /Ideas to triage \(2\)/);
65   assert.match(work.text, /haiku/);
66   assert.match(work.text, new RegExp(`project: ${path.basename(project)}`));
67 
68   const saved = await call('idea_triage', {
69     by: 'test-model',
70     verdicts: [
71       { id: 1, verdict: 'do', impact: 4, size: 'm', model: 'sonnet', title: 'Cache API responses', why: 'slow pages', brief: 'Add an LRU cache.' },
72       { id: 2, verdict: 'do', impact: 2, size: 'xs', model: 'haiku', title: 'Rename helpers.js', why: 'trivial', brief: 'Rename and fix imports.' },
73     ],
74   });
75   assert.match(saved.text, /Saved 2 verdicts: 2 do · 0 maybe · 0 skip/);
76 
77   const next = await call('idea_next');
78   assert.match(next.text, /^#2 Rename helpers\.js/); // 2/1 value per effort beats 4/3
79   assert.match(next.text, /recommended model: haiku/);
80 
81   const upd = await call('idea_update', { id: 2, status: 'done', note: 'renamed' });
82   assert.match(upd.text, /Updated #2 Rename helpers\.js · done · note added/);
83 
84   const board = await call('idea_list');
85   assert.match(board.text, /1 open \(1 do\) · 1 done/);
86   const one = await call('idea_list', { id: 1 });
87   assert.match(one.text, /brief {4}Add an LRU cache\./);
88   assert.match(one.text, /triaged .* by test-model/);
89 });
90 
91 test('headless triage through the tool, while other requests stay responsive', async () => {
92   await call('idea_add', { text: '- idea x\n- idea y' });
93   const [triaged, pong] = await Promise.all([call('idea_triage', { headless: true }), request('ping')]);
94   assert.deepEqual(pong.result, {});
95   assert.match(triaged.text, /Saved 2 verdicts: 2 do · 0 maybe · 0 skip \(sonnet, 321 in \/ 45 out tokens\)/);
96   assert.match((await call('idea_triage', { headless: true })).text, /inbox is empty/);
97 });
98 
99 test('tool errors come back as isError results, not protocol errors', async () => {
100   const res = await call('idea_update', { id: 999, status: 'done' });
101   assert.equal(res.isError, true);
102   assert.match(res.text, /no idea #999/);
103   const unknown = await request('tools/call', { name: 'nope', arguments: {} });
104   assert.equal(unknown.error.code, -32602);
105   const method = await request('does/not/exist');
106   assert.equal(method.error.code, -32601);
107 });
108 
109 test('prompts mirror the skills', async () => {
110   const list = await request('prompts/list');
111   const names = list.result.prompts.map((p) => p.name);
112   assert.deepEqual(names, ['idea', 'ideas', 'ideas-ls', 'ideas-cat', 'ideas-rm', 'ideas-find', 'ideas-groups', 'ideas-done', 'ideas-reopen', 'ideas-go', 'ideas-all', 'ideas-sort', 'ideas-watch', 'ideas-web', 'ideas-sync']);
113   // One prompt for each skill, so that other MCP clients get the same commands as the plugin.
114   assert.deepEqual([...names].sort(), fs.readdirSync(new URL('../skills', import.meta.url)).sort());
115   const go = await request('prompts/get', { name: 'ideas-go', arguments: { id: '12' } });
116   assert.match(go.result.messages[0].content.text, /Requested idea: 12\b[\s\S]*idea_next[\s\S]*run_in_background/);
117   const got = await request('prompts/get', { name: 'idea', arguments: { text: 'teleport the cat' } });
118   const text = got.result.messages[0].content.text;
119   assert.match(text, /teleport the cat/);
120   assert.doesNotMatch(text, /^---/);
121   assert.match(text, /idea_add/);
122 });
123 
124 // The tests below continue from the archive that the tests above left: #1, #3, #4 do; #2 done.
125 
126 test('idea_list full shows the queue in full, so /ideas all can judge each idea; idea_remove deletes ideas', async () => {
127   assert.match((await call('idea_list', { full: true })).text, /^#3 Idea 3\n[^]*\n\n#4 Idea 4\n[^]*\n\n#1 Cache API responses\n/);
128   const none = await call('idea_remove', { ids: [] });
129   assert.equal(none.isError, true);
130   assert.match((await call('idea_remove', { ids: [1, 3, 4] })).text, /^Removed 3 ideas\.\n\n#1 Cache API responses\n[^]*\n\n#3 Idea 3\n/);
131   assert.match((await call('idea_list')).text, /^ideamine: 0 open · 1 done/);
132   assert.equal((await call('idea_list', { full: true })).text, 'No ideas match.');
133 });
134 
135 test('idea_next with triage: true triages first, so /ideas go never has to stop', async () => {
136   await call('idea_add', { text: '- first\n- second\n- third' }); // #5 #6 #7
137   assert.match((await call('idea_next')).text, /^No idea is ready: 3 untriaged in the inbox\. Pass triage: true/);
138   const next = await call('idea_next', { triage: true });
139   assert.match(next.text, /^Saved 3 verdicts: 2 do · 0 maybe · 1 skip/);
140   assert.match(next.text, /\n\n#5 Idea 5\n[^]*recommended model: sonnet\nproject dir: [^\n]*ideamine-proj-\w+ \(exists\)\n/);
141   assert.match(next.text, /\n\nThe queue:\nideamine: 2 open \(2 do\)[^]*#5[^]*#6/); // to choose an idea that fits the chat
142 
143   // An explicit id triages only that idea. A saved folder that is gone is marked.
144   await call('idea_add', { text: '- eighth\n- ninth', project: path.join(project, 'gone') }); // #8 #9
145   const nine = (await call('idea_next', { id: 9, triage: true })).text;
146   assert.match(nine, /^Saved 1 verdict: 1 do[^]*\n\n#9 Idea 9\n/);
147   assert.match(nine, /project dir: [^\n]*gone \(does not exist\)$/);
148   assert.match((await call('idea_list', { filter: 'inbox' })).text, /#8 +eighth/);
149 });
150 
151 test('a manual triage shows the whole queue afterwards', async () => {
152   const out = await call('idea_triage', { headless: true }); // #8, the last one in the inbox
153   assert.match(out.text, /^Saved 1 verdict: 1 do[^]*\n\nideamine: 4 open \(4 do\) · 1 done\n\nDO \(best first\)\n[^]*#8 +sonnet/);
154   assert.match((await call('idea_triage', { headless: true })).text, /^Nothing to triage: the inbox is empty\.\n\nideamine: 4 open/);
155 });