dual-brain 7.0.1 → 7.0.2

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.
@@ -5,12 +5,13 @@ import { existsSync, readFileSync } from 'node:fs';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { execSync } from 'node:child_process';
8
+ import { createInterface } from 'node:readline';
8
9
 
9
10
  import {
10
11
  ensureProfile, loadProfile, saveProfile, runOnboarding,
11
12
  rememberPreference, forgetPreference, getActivePreferences,
12
13
  getAvailableProviders, isSoloBrain, getHeadModel,
13
- detectAuth, detectEnvironment,
14
+ detectAuth, detectEnvironment, setupAuth,
14
15
  } from '../src/profile.mjs';
15
16
 
16
17
  import { detectTask } from '../src/detect.mjs';
@@ -45,8 +46,9 @@ function printHelp() {
45
46
  dual-brain <command> [options]
46
47
 
47
48
  Commands:
48
- init First-time setup (providers, plans, optimization)
49
+ init First-time setup flows into interactive REPL
49
50
  auth Show authentication status for all providers
51
+ auth setup Paste API keys directly (recommended for Replit)
50
52
  install Install Claude Code hooks into the current project
51
53
  go "task description" Detect → decide → dispatch a task
52
54
  --dry-run Show routing decision without executing
@@ -59,6 +61,14 @@ Commands:
59
61
  remember "preference" Save a project-scoped preference
60
62
  forget "preference" Remove a preference by fuzzy match
61
63
 
64
+ Interactive REPL (entered after init or npx dual-brain with no args):
65
+ <task description> Dispatch a task directly
66
+ go <task> Same as dual-brain go
67
+ status / auth / init Run commands without exiting
68
+ auth setup Re-run API key setup
69
+ help Show this help
70
+ exit / quit / q Exit the REPL
71
+
62
72
  Options:
63
73
  --version Print version
64
74
  --help Show this help
@@ -85,14 +95,14 @@ function printAuthTable(auth) {
85
95
  : ` Claude: ✗ not found`;
86
96
  const claudeLine2 = auth.claude.found
87
97
  ? ` ${auth.claude.masked}`
88
- : ` run: claude auth login`;
98
+ : ` run: dual-brain auth setup`;
89
99
 
90
100
  const openaiLine1 = auth.openai.found
91
101
  ? ` OpenAI: ✓ found via ${auth.openai.source}`
92
102
  : ` OpenAI: ✗ not found`;
93
103
  const openaiLine2 = auth.openai.found
94
104
  ? ` ${auth.openai.masked}`
95
- : ` run: codex auth OR export OPENAI_API_KEY=sk-...`;
105
+ : ` run: dual-brain auth setup`;
96
106
 
97
107
  console.log(`╔${bar}╗`);
98
108
  console.log(`║${pad(' Auth Status')}║`);
@@ -127,8 +137,8 @@ async function cmdCard() {
127
137
  // Auth status warnings (non-blocking)
128
138
  const auth = await detectAuth();
129
139
  const warnings = [];
130
- if (!auth.claude.found) warnings.push('Claude auth not found — run: claude auth login');
131
- if (!auth.openai.found) warnings.push('OpenAI auth not found — run: codex auth OR export OPENAI_API_KEY=sk-...');
140
+ if (!auth.claude.found) warnings.push('Claude auth not found — run: dual-brain auth setup');
141
+ if (!auth.openai.found) warnings.push('OpenAI auth not found — run: dual-brain auth setup');
132
142
  if (warnings.length > 0) {
133
143
  console.log('\nAuth warnings:');
134
144
  for (const w of warnings) console.log(` ⚠ ${w}`);
@@ -144,7 +154,7 @@ async function cmdCard() {
144
154
 
145
155
  // ─── Commands ─────────────────────────────────────────────────────────────────
146
156
 
147
- async function cmdInit() {
157
+ async function cmdInit(rl) {
148
158
  const cwd = process.cwd();
149
159
 
150
160
  // --- Step 1: Auth preflight ---
@@ -153,39 +163,61 @@ async function cmdInit() {
153
163
 
154
164
  const noneFound = !auth.claude.found && !auth.openai.found;
155
165
  if (noneFound) {
156
- console.log('\nNo AI provider credentials found. Set up at least one before continuing:\n');
157
- console.log(' Claude : claude auth login');
158
- console.log(' OpenAI : codex auth OR export OPENAI_API_KEY=sk-...\n');
159
- console.log('Re-run "dual-brain init" after authenticating.');
160
- return;
166
+ console.log('\nNo AI provider credentials found. Let\'s set up at least one now.\n');
167
+ // Use the provided rl (REPL instance) or create a temporary one
168
+ const rlOwned = !rl;
169
+ if (!rl) rl = createInterface({ input: process.stdin, output: process.stdout });
170
+ try {
171
+ await setupAuth(rl);
172
+ } finally {
173
+ if (rlOwned) rl.close();
174
+ }
175
+ // Re-check after setup
176
+ const authAfter = await detectAuth();
177
+ if (!authAfter.claude.found && !authAfter.openai.found) {
178
+ console.log('\nNo credentials configured. You can run "auth setup" in the REPL anytime.');
179
+ // Still flow into REPL — don't exit
180
+ return;
181
+ }
161
182
  }
162
183
 
163
- // --- Step 2: Run onboarding wizard, skipping tiers for auto-detected plans ---
164
- const profile = await runOnboarding({ interactive: true, detectedAuth: auth });
184
+ // --- Step 2: Run onboarding wizard (pass shared rl so it isn't closed) ---
185
+ const profile = await runOnboarding({ interactive: true, detectedAuth: auth, rl });
165
186
  saveProfile(profile, { cwd });
166
187
 
167
- // --- Step 3: Show dashboard + next step ---
188
+ // --- Step 3: Show dashboard ---
168
189
  console.log('');
169
190
  const repo = loadRepoCache(cwd);
170
191
  const session = loadSession(cwd);
171
192
  const health = getHealth(cwd);
172
193
  const card = formatSessionCard(session, repo, health);
173
194
  console.log(card);
174
- console.log('\nReady! Try: dual-brain go "your task here"\n');
195
+ console.log('\nReady! Type a task below, or "help" for commands.\n');
175
196
  }
176
197
 
177
- async function cmdAuth() {
198
+ async function cmdAuth(subArgs = [], rl) {
199
+ const sub = subArgs[0];
200
+
201
+ if (sub === 'setup') {
202
+ return cmdAuthSetup(rl);
203
+ }
204
+
178
205
  const auth = await detectAuth();
179
206
  printAuthTable(auth);
180
207
 
181
- // If anything is missing, print setup commands
182
- if (!auth.claude.found) {
183
- console.log('\nTo set up Claude:');
184
- console.log(' claude auth login');
208
+ // If anything is missing, point to setup command
209
+ if (!auth.claude.found || !auth.openai.found) {
210
+ console.log('\nRun "dual-brain auth setup" (or "auth setup" in REPL) to paste API keys.');
185
211
  }
186
- if (!auth.openai.found) {
187
- console.log('\nTo set up OpenAI:');
188
- console.log(' codex auth OR export OPENAI_API_KEY=sk-...');
212
+ }
213
+
214
+ async function cmdAuthSetup(rl) {
215
+ const rlOwned = !rl;
216
+ if (!rl) rl = createInterface({ input: process.stdin, output: process.stdout });
217
+ try {
218
+ await setupAuth(rl);
219
+ } finally {
220
+ if (rlOwned) rl.close();
189
221
  }
190
222
  }
191
223
 
@@ -489,6 +521,65 @@ function cmdForget(text) {
489
521
  console.log('Preference removed (if matched).');
490
522
  }
491
523
 
524
+ // ─── Interactive REPL ────────────────────────────────────────────────────────
525
+
526
+ async function startRepl(rl) {
527
+ // rl may have been created by cmdCard/cmdInit — reuse it.
528
+ // If not provided, create a fresh one.
529
+ const rlOwned = !rl;
530
+ if (!rl) rl = createInterface({ input: process.stdin, output: process.stdout });
531
+
532
+ console.log('\nType a task or command. Type "help" for commands, "exit" to quit.\n');
533
+
534
+ const prompt = () => {
535
+ rl.question('dual-brain> ', async (input) => {
536
+ const line = input.trim();
537
+ if (!line) { prompt(); return; }
538
+
539
+ if (line === 'exit' || line === 'quit' || line === 'q') {
540
+ if (rlOwned) rl.close();
541
+ return;
542
+ }
543
+
544
+ try {
545
+ if (line === 'help') {
546
+ printHelp();
547
+ } else if (line === 'status') {
548
+ await cmdStatus([]);
549
+ } else if (line === 'auth setup' || line === 'auth-setup') {
550
+ await cmdAuthSetup(rl);
551
+ } else if (line === 'auth') {
552
+ await cmdAuth([], rl);
553
+ } else if (line.startsWith('go ')) {
554
+ await cmdGo(line.slice(3).trim().split(/\s+/));
555
+ } else if (line.startsWith('remember ')) {
556
+ cmdRemember(line.slice(9).trim());
557
+ } else if (line.startsWith('forget ')) {
558
+ cmdForget(line.slice(7).trim());
559
+ } else if (line.startsWith('hot ')) {
560
+ cmdHot(line.slice(4).trim());
561
+ } else if (line.startsWith('cool ')) {
562
+ cmdCool(line.slice(5).trim());
563
+ } else if (line === 'init') {
564
+ await cmdInit(rl);
565
+ } else {
566
+ // Treat as a task description → go
567
+ await cmdGo([line]);
568
+ }
569
+ } catch (e) {
570
+ process.stderr.write(`Error: ${e.message}\n`);
571
+ }
572
+
573
+ prompt(); // loop back
574
+ });
575
+ };
576
+
577
+ prompt();
578
+
579
+ // Return a promise that resolves when rl closes (exit/quit)
580
+ return new Promise(resolve => rl.on('close', resolve));
581
+ }
582
+
492
583
  // ─── Entry point ─────────────────────────────────────────────────────────────
493
584
 
494
585
  async function main() {
@@ -496,12 +587,37 @@ async function main() {
496
587
  const cmd = args[0];
497
588
 
498
589
  if (cmd === '--help' || cmd === '-h') { printHelp(); return; }
499
- if (!cmd) { await cmdCard(); return; }
500
- if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
590
+ if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
591
+
592
+ // Interactive-only commands: enter REPL after completing (only when TTY)
593
+ const isInteractive = process.stdin.isTTY;
501
594
 
502
- if (cmd === 'init') { await cmdInit(); return; }
595
+ if (!cmd) {
596
+ await cmdCard();
597
+ if (isInteractive) await startRepl();
598
+ return;
599
+ }
600
+
601
+ if (cmd === 'init') {
602
+ if (isInteractive) {
603
+ // Create the shared rl upfront so init wizard and REPL share it
604
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
605
+ await cmdInit(rl);
606
+ await startRepl(rl);
607
+ } else {
608
+ await cmdInit();
609
+ }
610
+ return;
611
+ }
612
+
613
+ // One-shot commands — run and exit
503
614
  if (cmd === 'install') { await cmdInstall(); return; }
504
- if (cmd === 'auth') { await cmdAuth(); return; }
615
+ if (cmd === 'auth') {
616
+ const sub = args[1];
617
+ if (sub === 'setup') { await cmdAuthSetup(); return; }
618
+ await cmdAuth(args.slice(1));
619
+ return;
620
+ }
505
621
  if (cmd === 'go') { await cmdGo(args.slice(1)); return; }
506
622
  if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
507
623
  if (cmd === 'hot') { cmdHot(args[1]); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.0.1",
3
+ "version": "7.0.2",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/profile.mjs CHANGED
@@ -25,7 +25,7 @@
25
25
  import { createInterface } from 'readline';
26
26
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
27
27
  import { homedir } from 'os';
28
- import { join } from 'path';
28
+ import { dirname, join } from 'path';
29
29
 
30
30
  // ---------------------------------------------------------------------------
31
31
  // Claude Code memory integration
@@ -174,6 +174,20 @@ async function detectAuth() {
174
174
  } catch { continue; }
175
175
  }
176
176
 
177
+ // --- Claude: check .dualbrain/auth.json (before env var) ---
178
+ if (!results.claude.found) {
179
+ const storedAuth = loadAuthKeys();
180
+ if (storedAuth.claude?.key) {
181
+ const expired = storedAuth.claude.expiresAt && new Date(storedAuth.claude.expiresAt) <= new Date();
182
+ if (!expired) {
183
+ results.claude.found = true;
184
+ results.claude.source = '.dualbrain/auth.json';
185
+ results.claude.masked = _maskCredential(storedAuth.claude.key);
186
+ process.env.ANTHROPIC_API_KEY = storedAuth.claude.key;
187
+ }
188
+ }
189
+ }
190
+
177
191
  // --- Claude: fallback to ANTHROPIC_API_KEY env var ---
178
192
  if (!results.claude.found && process.env.ANTHROPIC_API_KEY) {
179
193
  results.claude.found = true;
@@ -208,6 +222,20 @@ async function detectAuth() {
208
222
  } catch { continue; }
209
223
  }
210
224
 
225
+ // --- OpenAI: check .dualbrain/auth.json (before env var) ---
226
+ if (!results.openai.found) {
227
+ const storedAuth = loadAuthKeys();
228
+ if (storedAuth.openai?.key) {
229
+ const expired = storedAuth.openai.expiresAt && new Date(storedAuth.openai.expiresAt) <= new Date();
230
+ if (!expired) {
231
+ results.openai.found = true;
232
+ results.openai.source = '.dualbrain/auth.json';
233
+ results.openai.masked = _maskCredential(storedAuth.openai.key);
234
+ process.env.OPENAI_API_KEY = storedAuth.openai.key;
235
+ }
236
+ }
237
+ }
238
+
211
239
  // --- OpenAI: fallback to OPENAI_API_KEY env var ---
212
240
  if (!results.openai.found && process.env.OPENAI_API_KEY) {
213
241
  results.openai.found = true;
@@ -218,6 +246,105 @@ async function detectAuth() {
218
246
  return results;
219
247
  }
220
248
 
249
+ // ---------------------------------------------------------------------------
250
+ // API key storage (.dualbrain/auth.json)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ const AUTH_FILE = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'auth.json');
254
+
255
+ function loadAuthKeys(cwd) {
256
+ try {
257
+ return JSON.parse(readFileSync(AUTH_FILE(cwd), 'utf8'));
258
+ } catch {
259
+ return {};
260
+ }
261
+ }
262
+
263
+ function saveAuthKey(provider, key, opts = {}) {
264
+ const cwd = opts.cwd || process.cwd();
265
+ const authFile = AUTH_FILE(cwd);
266
+ const dir = dirname(authFile);
267
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
268
+
269
+ const auth = loadAuthKeys(cwd);
270
+ auth[provider] = {
271
+ key,
272
+ savedAt: new Date().toISOString(),
273
+ expiresAt: opts.expiresAt || null,
274
+ };
275
+ writeFileSync(authFile, JSON.stringify(auth, null, 2));
276
+
277
+ // Inject into process.env for this session so dispatch can use it
278
+ if (provider === 'claude') process.env.ANTHROPIC_API_KEY = key;
279
+ if (provider === 'openai') process.env.OPENAI_API_KEY = key;
280
+ }
281
+
282
+ /**
283
+ * Interactive setup flow: walks user through entering API keys for missing providers.
284
+ * Accepts an existing readline Interface (rl) — does NOT close it.
285
+ * @param {import('readline').Interface} rl
286
+ */
287
+ async function setupAuth(rl) {
288
+ const ask = (q) => new Promise(res => rl.question(q, res));
289
+ const auth = await detectAuth();
290
+
291
+ // Claude setup
292
+ if (!auth.claude.found) {
293
+ console.log('\n— Claude Setup —');
294
+ console.log('Options:');
295
+ console.log(' (1) Paste API key (recommended for Replit)');
296
+ console.log(' (2) Skip for now');
297
+ const choice = (await ask('> ')).trim();
298
+ if (choice === '1') {
299
+ const key = (await ask('Paste your Anthropic API key: ')).trim();
300
+ if (key && (key.startsWith('sk-ant-') || key.startsWith('sk-'))) {
301
+ const expiryStr = (await ask('Set key expiry? (enter days, or press Enter to skip)\n> ')).trim();
302
+ let expiresAt = null;
303
+ if (expiryStr && /^\d+$/.test(expiryStr)) {
304
+ const d = new Date();
305
+ d.setDate(d.getDate() + parseInt(expiryStr, 10));
306
+ expiresAt = d.toISOString();
307
+ console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
308
+ }
309
+ saveAuthKey('claude', key, { expiresAt });
310
+ console.log('✓ Claude API key saved');
311
+ } else {
312
+ console.log('Invalid key format. Expected sk-ant-... or sk-...');
313
+ }
314
+ }
315
+ } else {
316
+ console.log(`\n✓ Claude: already configured via ${auth.claude.source}`);
317
+ }
318
+
319
+ // OpenAI setup
320
+ if (!auth.openai.found) {
321
+ console.log('\n— OpenAI Setup —');
322
+ console.log('Options:');
323
+ console.log(' (1) Paste API key (recommended for Replit)');
324
+ console.log(' (2) Skip for now');
325
+ const choice = (await ask('> ')).trim();
326
+ if (choice === '1') {
327
+ const key = (await ask('Paste your OpenAI API key: ')).trim();
328
+ if (key && key.startsWith('sk-')) {
329
+ const expiryStr = (await ask('Set key expiry? (enter days, or press Enter to skip)\n> ')).trim();
330
+ let expiresAt = null;
331
+ if (expiryStr && /^\d+$/.test(expiryStr)) {
332
+ const d = new Date();
333
+ d.setDate(d.getDate() + parseInt(expiryStr, 10));
334
+ expiresAt = d.toISOString();
335
+ console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
336
+ }
337
+ saveAuthKey('openai', key, { expiresAt });
338
+ console.log('✓ OpenAI API key saved');
339
+ } else {
340
+ console.log('Invalid key format. Expected sk-...');
341
+ }
342
+ }
343
+ } else {
344
+ console.log(`\n✓ OpenAI: already configured via ${auth.openai.source}`);
345
+ }
346
+ }
347
+
221
348
  // ---------------------------------------------------------------------------
222
349
  // Auto-detect subscription plans from provider config files
223
350
  // ---------------------------------------------------------------------------
@@ -414,7 +541,10 @@ function saveProfile(profile, opts = {}) {
414
541
  async function runOnboarding(opts = {}) {
415
542
  if (!opts.interactive) return defaultProfile();
416
543
 
417
- const rl = createInterface({ input: process.stdin, output: process.stdout });
544
+ // Accept an externally-provided readline instance (shared with REPL/auth setup)
545
+ // or create one internally if not provided. Only close if we created it.
546
+ const rlProvided = !!opts.rl;
547
+ const rl = opts.rl || createInterface({ input: process.stdin, output: process.stdout });
418
548
  const ask = (q) => new Promise(res => rl.question(q, res));
419
549
  const profile = defaultProfile();
420
550
 
@@ -440,7 +570,8 @@ async function runOnboarding(opts = {}) {
440
570
  profile.mode = n >= 2 ? 'dual' : profile.providers.claude.enabled ? 'solo-claude' : 'solo-openai';
441
571
  process.stdout.write('\nProfile saved.\n');
442
572
  } finally {
443
- rl.close();
573
+ // Only close if we created the rl instance (not if it was passed in)
574
+ if (!rlProvided) rl.close();
444
575
  }
445
576
  return profile;
446
577
  }
@@ -582,4 +713,5 @@ export {
582
713
  getAvailableProviders, isSoloBrain, getHeadModel,
583
714
  detectPlans, syncPreferencesToMemory,
584
715
  detectAuth, detectEnvironment,
716
+ setupAuth, saveAuthKey, loadAuthKeys,
585
717
  };