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/hook.test.js (11657 bytes)

1 import assert from 'node:assert/strict';
2 import { spawnSync } from 'node:child_process';
3 import fs from 'node:fs';
4 import os from 'node:os';
5 import path from 'node:path';
6 import { beforeEach, test } from 'node:test';
7 import { fileURLToPath } from 'node:url';
8 import { handlePrompt } from '../src/hook.js';
9 import * as store from '../src/store.js';
10 import { startFakeServer } from './fixtures/fake-server.js';
11 import { freePort, startServerProcess } from './fixtures/server-process.js';
12 
13 const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
14 
15 beforeEach(() => {
16   process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-hook-'));
17   // A pass of the watcher must never reach the real claude or the projects of this machine.
18   process.env.IDEAMINE_CLAUDE_BIN = fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url));
19   process.env.CLAUDE_CONFIG_DIR = process.env.IDEAMINE_HOME;
20   for (const name of ['IDEAMINE_SYNC_URL', 'IDEAMINE_PROMPT_LOG']) delete process.env[name];
21 });
22 
23 /** Run the real hook process the way Claude Code does: JSON on stdin, decision on stdout. */
24 function runHook(prompt, cwd = '/work/app') {
25   const input = JSON.stringify({ session_id: 's1', hook_event_name: 'UserPromptSubmit', cwd, prompt });
26   const r = spawnSync(process.execPath, [BIN, 'hook'], { input, encoding: 'utf8', env: process.env });
27   assert.equal(r.status, 0, r.stderr);
28   return r.stdout ? JSON.parse(r.stdout) : null;
29 }
30 
31 test('/idea saves the idea and blocks the prompt so no model call happens', () => {
32   const out = runHook('/idea add a read-aloud mode #a11y');
33   assert.equal(out.decision, 'block');
34   assert.match(out.reason, /Saved #1 · add a read-aloud mode/);
35   assert.deepEqual(out.hookSpecificOutput, { hookEventName: 'UserPromptSubmit', suppressOriginalPrompt: true });
36   const [idea] = store.load().ideas;
37   assert.equal(idea.source, 'hook');
38   assert.equal(idea.session, 's1');
39   assert.deepEqual(idea.tags, ['a11y']);
40   assert.equal(idea.project, path.resolve('/work/app'));
41 });
42 
43 test('plugin-qualified and multi-line forms work', () => {
44   assert.match(runHook('/ideamine:idea first').reason, /Saved #1/);
45   assert.match(runHook('/idea - two\n- three').reason, /Saved 2 ideas: #2, #3/);
46 });
47 
48 test('ordinary prompts, model commands, and questions pass straight through', () => {
49   const prompts = [
50     'fix the tests',
51     '/ideasx',
52     '/idea-go 3', // a name from before 0.3.0: no command now
53     'an /idea in the middle',
54     '/ideas-go',
55     '/ideamine:ideas-go 3',
56     '/ideas-pipeline',
57     '/ideamine:ideas-pipeline 3',
58     '/ideas-pipeline dark mode for the popup',
59     '/ideas-all',
60     '/ideas-sort',
61     '/ideas-cat',
62     '/ideas-ls ideas about scrolling', // not lanes: the skill searches with these words
63     '/ideas-rm the vague one',
64     '/ideas all', // with a space it is a question for the model, never a command
65     '/ideas what should I build today?',
66   ];
67   for (const p of prompts) assert.equal(runHook(p), null, p);
68   assert.equal(store.load().ideas.length, 0);
69 });
70 
71 test('/ideas and the dashed commands list, show, and edit ideas', () => {
72   store.addIdeas(['alpha', 'beta'], { project: '/work/app' });
73   assert.match(runHook('/ideas').reason, /2 open[\s\S]*#2 {3}beta[\s\S]*#1 {3}alpha/);
74   assert.equal(runHook('/ideas-ls').reason, runHook('/ideas').reason);
75   assert.match(runHook('/ideas-cat #1').reason, /^#1 alpha/);
76   assert.match(runHook('/ideas-cat 1 2').reason, /^#1 alpha\n[\s\S]*\n\n#2 beta\n/);
77   assert.match(runHook('/ideas-done 1 shipped it').reason, /#1 alpha → done \(note added\)/);
78   assert.match(runHook('/ideas-ls done').reason, /DONE[\s\S]*#1/);
79   assert.match(runHook('/ideas-ls -a').reason, /\[showing: all\][\s\S]*#2[\s\S]*#1/);
80   assert.match(runHook('/ideamine:ideas-reopen 1').reason, /#1 alpha → inbox/);
81   assert.equal(runHook('/ideas-cat 9').reason, 'No idea #9.');
82   assert.equal(runHook('/ideas-cat 1 8 9').reason, 'No idea #8, #9.');
83   assert.equal(runHook('/ideas-done 9').reason, 'No idea #9.');
84   assert.match(runHook('/idea').reason, /^Usage[\s\S]*\/ideas-rm N[\s\S]*\/ideas-pipeline/);
85 });
86 
87 test('/ideas-rm deletes ideas for good, without a model call', () => {
88   store.addIdeas(['alpha', 'beta', 'gamma', 'delta']);
89   const out = runHook('/ideas-rm 1 #3');
90   assert.equal(out.decision, 'block');
91   assert.match(out.reason, /Removed #1 · alpha\n.*Removed #3 · gamma$/);
92   assert.equal(runHook('/ideas-rm 2 9').reason, 'No idea #9.'); // an unknown id deletes nothing
93   assert.match(runHook('/ideamine:ideas-rm 4').reason, /Removed #4 · delta/);
94   assert.deepEqual(store.load().ideas.map((i) => i.text), ['beta']);
95 });
96 
97 test('the board names the dashed commands, and does not send you to a triage', () => {
98   store.addIdeas(['alpha']);
99   const board = runHook('/ideas').reason;
100   for (const tip of [/\/ideas-go/, /\/ideas-all/, /\/ideas-rm N/]) assert.match(board, tip);
101   assert.doesNotMatch(board, /\/idea-triage|\/ideas-sort/);
102 });
103 
104 test('/ideas-watch turns the watcher on and off, with no model call', () => {
105   const on = runHook('/ideas-watch');
106   assert.equal(on.decision, 'block');
107   assert.match(on.reason, /^ideamine watch: on since /);
108   assert.match(runHook('/ideas-watch').reason, /^ideamine watch: on since /);
109   assert.match(runHook('/ideamine:ideas-watch off').reason, /^ideamine watch: off/);
110   assert.equal(JSON.parse(fs.readFileSync(path.join(store.home(), 'watch.json'), 'utf8')).on, false);
111 });
112 
113 test('while the watcher is on, a saved idea is triaged in the background', async () => {
114   runHook('/ideas-watch');
115   runHook('/idea make the scroller faster');
116   // The hook returns at once. A separate process triages the idea with the stand-in claude.
117   const deadline = Date.now() + 20000;
118   while (store.load().ideas[0].status === 'inbox' && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
119   assert.equal(store.findIdea(store.load(), 1).triage?.by, 'haiku (headless)');
120 });
121 
122 test('handlePrompt filters by project with "here"', async () => {
123   store.addIdeas(['mine'], { project: '/a' });
124   store.addIdeas(['theirs'], { project: '/b' });
125   const board = await handlePrompt('/ideas-ls here', { cwd: '/a' });
126   assert.match(board, /mine/);
127   assert.doesNotMatch(board, /theirs/);
128 });
129 
130 test('when the archive is unreadable the prompt goes through instead of being lost', () => {
131   fs.mkdirSync(store.home(), { recursive: true });
132   fs.writeFileSync(store.dbPath(), 'not json');
133   const r = spawnSync(process.execPath, [BIN, 'hook'], {
134     input: JSON.stringify({ prompt: '/idea keep me', cwd: '/x' }),
135     encoding: 'utf8',
136     env: process.env,
137   });
138   assert.equal(r.status, 0);
139   assert.equal(r.stdout, '');
140   assert.match(r.stderr, /not valid JSON/);
141 });
142 
143 test('/ideas-find searches by meaning and /ideas-groups groups by meaning, with no model call', async () => {
144   const server = await startFakeServer();
145   process.env.IDEAMINE_EMBED_URL = `${server.url}/v1`;
146   process.env.IDEAMINE_GROUP_THRESHOLD = '0.5';
147   try {
148     store.addIdeas(['subtitles for audiobooks', 'subtitles for audiobooks on android', 'a tor exit relay']);
149     const found = await handlePrompt('/ideas-find audiobooks with subtitles');
150     assert.match(found, /^ideas like "audiobooks with subtitles", by meaning\n.*#1 .*\n.*#2 /);
151     assert.doesNotMatch(found, /tor exit/);
152     const groups = await handlePrompt('/ideas-groups');
153     assert.match(groups, /^ideamine groups: 1 group of open ideas/);
154     assert.match(groups, /IN NO GROUP\n.*#3 /);
155     assert.equal(await handlePrompt('/ideas-groups what is this'), null); // a question: the skill answers
156   } finally {
157     await server.close();
158     delete process.env.IDEAMINE_EMBED_URL;
159     delete process.env.IDEAMINE_GROUP_THRESHOLD;
160   }
161 });
162 
163 const serverIdeas = async (url) => (await (await fetch(`${url}api/db`)).json()).db.ideas.map((i) => i.text);
164 
165 test('with sync on, /idea saves on the server, /ideas shows every machine, and an idea waits when the server is away', async () => {
166   const remote = await startServerProcess();
167   try {
168     process.env.IDEAMINE_SYNC_URL = remote.url;
169     assert.match(runHook('/idea shared by every machine').reason, /Saved #1 · shared by every machine/);
170     assert.deepEqual(await serverIdeas(remote.url), ['shared by every machine']);
171     // Another machine adds an idea. The next /ideas here shows it.
172     const laptop = { ops: [{ op: 'add', texts: ['from the laptop'], oid: 'laptop-1' }] };
173     await fetch(`${remote.url}api/ops`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(laptop) });
174     assert.match(runHook('/ideas').reason, /#2 {3}from the laptop[\s\S]*#1 {3}shared by every machine/);
175 
176     process.env.IDEAMINE_SYNC_URL = `http://127.0.0.1:${await freePort()}/`; // the tunnel is down
177     assert.match(runHook('/idea written offline').reason, /^💡 Saved here: written offline\. The ideamine server does not answer, so it gets the idea later \(1 waiting\)\.$/);
178     assert.match(runHook('/ideas').reason, /^\(The ideamine server does not answer\. This is the copy from \d{4}-/);
179 
180     process.env.IDEAMINE_SYNC_URL = remote.url; // the tunnel is back
181     assert.match(runHook('/ideas').reason, /written offline/);
182     assert.deepEqual((await serverIdeas(remote.url)).sort(), ['from the laptop', 'shared by every machine', 'written offline']);
183   } finally {
184     await remote.stop();
185   }
186 });
187 
188 test('/ideas-sync turns sync on, keeps the archive that was here, and shows the state', async () => {
189   const remote = await startServerProcess();
190   try {
191     store.addIdeas(['only here']);
192     const on = runHook(`/ideas-sync ${remote.url}`).reason;
193     assert.match(on, /^ideamine sync: http:\/\/127\.0\.0\.1:\d+\/\nlast sync \d{4}-\d{2}-\d{2} \d{2}:\d{2}\.\n/); // a new archive has no revision yet
194     assert.match(on, /The archive that was here is in .*ideas\.before-sync-\d{4}-\d{2}-\d{2}\.json\.$/);
195     assert.match(runHook('/ideamine:ideas-sync').reason, /^ideamine sync: http/);
196     assert.match(runHook('/ideas-sync 10.66.0.1').reason, /^ideamine sync: "10\.66\.0\.1" is not an http:\/\/ or https:\/\/ address\. Usage/);
197     assert.match(runHook('/ideas-sync off').reason, /^ideamine sync: off/);
198   } finally {
199     await remote.stop();
200   }
201 });
202 
203 test('with the prompt log on, each prompt goes to the server in the background', async () => {
204   const remote = await startServerProcess();
205   try {
206     process.env.IDEAMINE_SYNC_URL = remote.url;
207     process.env.IDEAMINE_PROMPT_LOG = 'on';
208     assert.equal(runHook('please fix the flaky test'), null); // an ordinary prompt still goes to the model
209     let prompts = [];
210     for (const deadline = Date.now() + 20000; !prompts.length && Date.now() < deadline; await new Promise((r) => setTimeout(r, 200))) {
211       prompts = (await (await fetch(`${remote.url}api/prompts`)).json()).prompts;
212     }
213     assert.deepEqual(prompts.map((p) => [p.prompt, p.session, p.cwd]), [['please fix the flaky test', 's1', '/work/app']]);
214   } finally {
215     await remote.stop();
216   }
217 });
218 
219 test('/ideas-find falls back to words when the embedding server is away', async () => {
220   const closed = await startFakeServer();
221   await closed.close(); // nothing listens on its port now
222   process.env.IDEAMINE_EMBED_URL = `${closed.url}/v1`;
223   try {
224     store.addIdeas(['subtitles for audiobooks', 'dark mode']);
225     const out = runHook('/ideas-find subtitles');
226     assert.equal(out.decision, 'block');
227     assert.match(out.reason, /by words, because search by meaning is not available \(cannot reach/);
228     assert.match(out.reason, /#1 /);
229     assert.doesNotMatch(out.reason, /#2 /);
230   } finally {
231     delete process.env.IDEAMINE_EMBED_URL;
232   }
233 });