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