brainstorm-companion 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,441 @@
1
+ 'use strict';
2
+
3
+ const { parseArgs } = require('node:util');
4
+ const { exec, spawn } = require('node:child_process');
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const { SessionManager } = require('./session');
8
+
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Usage
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function printUsage() {
15
+ console.log(`Usage: brainstorm-companion <command> [options]
16
+
17
+ Commands:
18
+ start Start the brainstorm server
19
+ push Push HTML content to the browser
20
+ events Read user interaction events
21
+ clear Clear content or events
22
+ stop Stop the server
23
+ status Show server status
24
+
25
+ Options:
26
+ --mcp Run as MCP server
27
+ --help Show this help`);
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function sleep(ms) {
35
+ return new Promise(resolve => setTimeout(resolve, ms));
36
+ }
37
+
38
+ async function pollForServerInfo(sessionDir, timeoutMs = 5000, intervalMs = 100) {
39
+ const serverInfoPath = path.join(sessionDir, '.server-info');
40
+ const deadline = Date.now() + timeoutMs;
41
+ while (Date.now() < deadline) {
42
+ if (fs.existsSync(serverInfoPath)) {
43
+ try {
44
+ const raw = fs.readFileSync(serverInfoPath, 'utf8');
45
+ const info = JSON.parse(raw);
46
+ if (info.url) return info;
47
+ } catch {
48
+ // file may be partially written, retry
49
+ }
50
+ }
51
+ await sleep(intervalMs);
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function openBrowser(url) {
57
+ const platform = process.platform;
58
+ let cmd;
59
+ if (platform === 'darwin') {
60
+ cmd = `open "${url}"`;
61
+ } else if (platform === 'linux') {
62
+ cmd = `xdg-open "${url}"`;
63
+ } else {
64
+ cmd = `start "${url}"`;
65
+ }
66
+ exec(cmd, (err) => {
67
+ if (err) {
68
+ console.error(`Warning: could not open browser: ${err.message}`);
69
+ }
70
+ });
71
+ }
72
+
73
+ function getActiveOrExit(projectDir) {
74
+ const session = new SessionManager(projectDir);
75
+ const active = session.getActive();
76
+ if (!active) {
77
+ console.error('No active session found.');
78
+ process.exit(1);
79
+ }
80
+ return { session, active };
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Command: start
85
+ // ---------------------------------------------------------------------------
86
+
87
+ async function start(argv) {
88
+ const { values } = parseArgs({
89
+ args: argv,
90
+ options: {
91
+ 'project-dir': { type: 'string' },
92
+ 'port': { type: 'string', default: '0' },
93
+ 'host': { type: 'string', default: '127.0.0.1' },
94
+ 'foreground': { type: 'boolean', default: false },
95
+ 'no-open': { type: 'boolean', default: false },
96
+ },
97
+ strict: false,
98
+ });
99
+
100
+ const projectDir = values['project-dir'] || null;
101
+ const host = values['host'];
102
+ const port = parseInt(values['port'], 10) || 0;
103
+ const foreground = values['foreground'];
104
+ const noOpen = values['no-open'];
105
+
106
+ const session = new SessionManager(projectDir);
107
+ const { sessionDir } = session.create();
108
+
109
+ if (foreground) {
110
+ // Run server in-process
111
+ const { startServer } = require('./server');
112
+ const instance = startServer({
113
+ screenDir: sessionDir,
114
+ host,
115
+ port,
116
+ ownerPid: process.pid,
117
+ });
118
+
119
+ instance.server.once('listening', () => {
120
+ console.log(`Server started: ${instance.url}`);
121
+ if (!noOpen) {
122
+ openBrowser(instance.url);
123
+ }
124
+ });
125
+
126
+ instance.server.once('error', (err) => {
127
+ console.error(`Server error: ${err.message}`);
128
+ process.exit(1);
129
+ });
130
+
131
+ // Keep process alive
132
+ process.on('SIGINT', () => {
133
+ instance.shutdown('sigint');
134
+ process.exit(0);
135
+ });
136
+ process.on('SIGTERM', () => {
137
+ instance.shutdown('sigterm');
138
+ process.exit(0);
139
+ });
140
+ } else {
141
+ // Background the server
142
+ const serverScript = path.join(__dirname, 'server.js');
143
+ const child = spawn(process.execPath, [serverScript], {
144
+ detached: true,
145
+ stdio: 'ignore',
146
+ env: {
147
+ ...process.env,
148
+ BRAINSTORM_DIR: sessionDir,
149
+ BRAINSTORM_HOST: host,
150
+ BRAINSTORM_PORT: String(port),
151
+ BRAINSTORM_OWNER_PID: String(process.pid),
152
+ },
153
+ });
154
+ child.unref();
155
+
156
+ // Poll for .server-info
157
+ const serverInfo = await pollForServerInfo(sessionDir, 5000, 100);
158
+ if (!serverInfo) {
159
+ console.error('Timed out waiting for server to start.');
160
+ process.exit(1);
161
+ }
162
+
163
+ console.log(`Server started: ${serverInfo.url}`);
164
+
165
+ if (!noOpen) {
166
+ openBrowser(serverInfo.url);
167
+ }
168
+ }
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Command: push
173
+ // ---------------------------------------------------------------------------
174
+
175
+ async function push(argv) {
176
+ const { values, positionals } = parseArgs({
177
+ args: argv,
178
+ options: {
179
+ 'project-dir': { type: 'string' },
180
+ 'slot': { type: 'string' },
181
+ 'label': { type: 'string' },
182
+ 'html': { type: 'string' },
183
+ },
184
+ allowPositionals: true,
185
+ strict: false,
186
+ });
187
+
188
+ const projectDir = values['project-dir'] || null;
189
+ const slot = values['slot'];
190
+ const label = values['label'];
191
+ let html = values['html'];
192
+
193
+ if (!html) {
194
+ const fileArg = positionals[0];
195
+ if (!fileArg) {
196
+ console.error('Usage: brainstorm-companion push <file|-> [--slot a|b|c] [--label "name"] [--html "<content>"]');
197
+ process.exit(1);
198
+ }
199
+ if (fileArg === '-') {
200
+ // Read from stdin
201
+ html = fs.readFileSync('/dev/stdin', 'utf8');
202
+ } else {
203
+ if (!fs.existsSync(fileArg)) {
204
+ console.error(`File not found: ${fileArg}`);
205
+ process.exit(1);
206
+ }
207
+ html = fs.readFileSync(fileArg, 'utf8');
208
+ }
209
+ }
210
+
211
+ const session = new SessionManager(projectDir);
212
+ const active = session.getActive();
213
+ if (!active) {
214
+ console.error('No active session found.');
215
+ process.exit(1);
216
+ }
217
+
218
+ const result = session.pushScreen(html, { slot, label });
219
+ console.log(`Content pushed to ${result.path}`);
220
+ if (slot) {
221
+ console.log(`Slot: ${slot}${label ? ` (${label})` : ''}`);
222
+ }
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Command: events
227
+ // ---------------------------------------------------------------------------
228
+
229
+ function events(argv) {
230
+ const { values } = parseArgs({
231
+ args: argv,
232
+ options: {
233
+ 'project-dir': { type: 'string' },
234
+ 'format': { type: 'string', default: 'json' },
235
+ 'clear': { type: 'boolean', default: false },
236
+ },
237
+ strict: false,
238
+ });
239
+
240
+ const projectDir = values['project-dir'] || null;
241
+ const format = values['format'];
242
+ const doClear = values['clear'];
243
+
244
+ const session = new SessionManager(projectDir);
245
+ const eventList = session.readEvents();
246
+
247
+ if (format === 'text') {
248
+ if (eventList.length === 0) {
249
+ console.log('No events.');
250
+ } else {
251
+ for (const ev of eventList) {
252
+ const ts = ev.timestamp ? new Date(ev.timestamp).toISOString() : '';
253
+ console.log(`[${ts}] ${ev.type || 'event'}: ${ev.choice !== undefined ? ev.choice : JSON.stringify(ev)}`);
254
+ }
255
+ }
256
+ } else {
257
+ // Default: json
258
+ console.log(JSON.stringify(eventList, null, 2));
259
+ }
260
+
261
+ if (doClear) {
262
+ session.clearEvents();
263
+ }
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Command: clear
268
+ // ---------------------------------------------------------------------------
269
+
270
+ function clear(argv) {
271
+ const { values } = parseArgs({
272
+ args: argv,
273
+ options: {
274
+ 'project-dir': { type: 'string' },
275
+ 'slot': { type: 'string' },
276
+ 'all': { type: 'boolean', default: false },
277
+ 'events': { type: 'boolean', default: false },
278
+ },
279
+ strict: false,
280
+ });
281
+
282
+ const projectDir = values['project-dir'] || null;
283
+ const slot = values['slot'];
284
+ const all = values['all'];
285
+ const eventsOnly = values['events'];
286
+
287
+ const session = new SessionManager(projectDir);
288
+ const active = session.getActive();
289
+ if (!active) {
290
+ console.error('No active session found.');
291
+ process.exit(1);
292
+ }
293
+
294
+ if (eventsOnly) {
295
+ session.clearEvents();
296
+ console.log('Events cleared.');
297
+ } else if (slot) {
298
+ session.clearSlot(slot);
299
+ console.log(`Slot ${slot} cleared.`);
300
+ } else if (all) {
301
+ session.clearAll();
302
+ console.log('All content cleared.');
303
+ } else {
304
+ console.error('Specify --slot <a|b|c>, --all, or --events');
305
+ process.exit(1);
306
+ }
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Command: stop
311
+ // ---------------------------------------------------------------------------
312
+
313
+ function stop(argv) {
314
+ const { values } = parseArgs({
315
+ args: argv,
316
+ options: {
317
+ 'project-dir': { type: 'string' },
318
+ },
319
+ strict: false,
320
+ });
321
+
322
+ const projectDir = values['project-dir'] || null;
323
+ const session = new SessionManager(projectDir);
324
+ const active = session.getActive();
325
+ if (!active) {
326
+ console.error('No active session found.');
327
+ process.exit(1);
328
+ }
329
+
330
+ const { serverInfo } = active;
331
+ const pid = serverInfo.pid || serverInfo.serverPid;
332
+ if (!pid) {
333
+ console.error('No PID found in server info.');
334
+ process.exit(1);
335
+ }
336
+
337
+ try {
338
+ process.kill(pid, 'SIGTERM');
339
+ console.log(`Server (PID ${pid}) stopped.`);
340
+ } catch (err) {
341
+ console.error(`Failed to stop server: ${err.message}`);
342
+ process.exit(1);
343
+ }
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Command: status
348
+ // ---------------------------------------------------------------------------
349
+
350
+ function status(argv) {
351
+ const { values } = parseArgs({
352
+ args: argv,
353
+ options: {
354
+ 'project-dir': { type: 'string' },
355
+ },
356
+ strict: false,
357
+ });
358
+
359
+ const projectDir = values['project-dir'] || null;
360
+ const session = new SessionManager(projectDir);
361
+ const statusInfo = session.getStatus();
362
+
363
+ if (!statusInfo) {
364
+ console.error('No active session found.');
365
+ process.exit(1);
366
+ }
367
+
368
+ const { sessionId, sessionDir, slots, eventCount, uptime, url } = statusInfo;
369
+
370
+ console.log(`Session ID : ${sessionId}`);
371
+ console.log(`Session Dir: ${sessionDir}`);
372
+ console.log(`URL : ${url || '(unknown)'}`);
373
+
374
+ if (uptime !== null) {
375
+ const seconds = Math.floor(uptime / 1000);
376
+ const minutes = Math.floor(seconds / 60);
377
+ const hours = Math.floor(minutes / 60);
378
+ let uptimeStr;
379
+ if (hours > 0) {
380
+ uptimeStr = `${hours}h ${minutes % 60}m ${seconds % 60}s`;
381
+ } else if (minutes > 0) {
382
+ uptimeStr = `${minutes}m ${seconds % 60}s`;
383
+ } else {
384
+ uptimeStr = `${seconds}s`;
385
+ }
386
+ console.log(`Uptime : ${uptimeStr}`);
387
+ }
388
+
389
+ console.log(`Events : ${eventCount}`);
390
+
391
+ if (slots.length > 0) {
392
+ console.log('Slots:');
393
+ for (const s of slots) {
394
+ const label = s.label ? ` (${s.label})` : '';
395
+ const content = s.hasContent ? 'has content' : 'empty';
396
+ console.log(` [${s.slot}]${label}: ${content}`);
397
+ }
398
+ } else {
399
+ console.log('Slots : none');
400
+ }
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Command dispatch
405
+ // ---------------------------------------------------------------------------
406
+
407
+ const COMMANDS = { start, push, events, clear, stop, status };
408
+
409
+ function main() {
410
+ const args = process.argv.slice(2);
411
+ const command = args[0];
412
+
413
+ if (!command || command === '--help' || command === '-h') {
414
+ printUsage();
415
+ return;
416
+ }
417
+
418
+ const handler = COMMANDS[command];
419
+ if (!handler) {
420
+ console.error(`Unknown command: ${command}`);
421
+ printUsage();
422
+ process.exitCode = 1;
423
+ return;
424
+ }
425
+
426
+ // Wrap async handlers
427
+ const result = handler(args.slice(1));
428
+ if (result && typeof result.then === 'function') {
429
+ result.catch((err) => {
430
+ console.error(`Error: ${err.message}`);
431
+ process.exitCode = 1;
432
+ });
433
+ }
434
+ }
435
+
436
+ if (!process.argv.includes('--mcp')) {
437
+ main();
438
+ } else {
439
+ const { McpServer } = require('./mcp');
440
+ new McpServer().start();
441
+ }
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // detectLibraries — scan HTML for library usage signals
5
+ // ---------------------------------------------------------------------------
6
+
7
+ function detectLibraries(html) {
8
+ const mermaid = /class=["']mermaid["']/.test(html);
9
+ const prism = /class=["']language-/.test(html);
10
+ const katex = /\$\$/.test(html) || /class=["']math["']/.test(html);
11
+ return { mermaid, prism, katex };
12
+ }
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // buildInjections — produce <script>/<link> tags for detected libraries
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function buildInjections(needs, cdnBase) {
19
+ if (!cdnBase) {
20
+ cdnBase = process.env.BRAINSTORM_CDN_BASE || 'https://cdn.jsdelivr.net';
21
+ }
22
+
23
+ const parts = [];
24
+
25
+ if (needs.mermaid) {
26
+ parts.push(
27
+ `<script src="${cdnBase}/npm/mermaid/dist/mermaid.min.js"></script>`,
28
+ `<script>mermaid.initialize({startOnLoad:true,theme:window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'default'});</script>`
29
+ );
30
+ }
31
+
32
+ if (needs.prism) {
33
+ parts.push(
34
+ `<link rel="stylesheet" href="${cdnBase}/npm/prismjs/themes/prism-tomorrow.min.css">`,
35
+ `<script src="${cdnBase}/npm/prismjs/prism.min.js"></script>`,
36
+ `<script src="${cdnBase}/npm/prismjs/plugins/autoloader/prism-autoloader.min.js"></script>`
37
+ );
38
+ }
39
+
40
+ if (needs.katex) {
41
+ parts.push(
42
+ `<link rel="stylesheet" href="${cdnBase}/npm/katex/dist/katex.min.css">`,
43
+ `<script src="${cdnBase}/npm/katex/dist/katex.min.js"></script>`,
44
+ `<script src="${cdnBase}/npm/katex/dist/contrib/auto-render.min.js"></script>`,
45
+ `<script>document.addEventListener('DOMContentLoaded',function(){renderMathInElement(document.body)});</script>`
46
+ );
47
+ }
48
+
49
+ return parts.join('\n');
50
+ }
51
+
52
+ module.exports = { detectLibraries, buildInjections };