dual-brain 7.0.1 → 7.1.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.
@@ -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';
@@ -28,6 +29,8 @@ import { dispatch, detectRuntime, dispatchDualBrain } from '../src/dispatch.mjs'
28
29
  import { loadRepoCache } from '../src/repo.mjs';
29
30
  import { loadSession, saveSession, formatSessionCard } from '../src/session.mjs';
30
31
 
32
+ import { box, bar, badge, menu, separator } from '../src/tui.mjs';
33
+
31
34
  // ─── Helpers ─────────────────────────────────────────────────────────────────
32
35
 
33
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -45,8 +48,9 @@ function printHelp() {
45
48
  dual-brain <command> [options]
46
49
 
47
50
  Commands:
48
- init First-time setup (providers, plans, optimization)
51
+ init First-time setup flows into interactive REPL
49
52
  auth Show authentication status for all providers
53
+ auth setup Paste API keys directly (recommended for Replit)
50
54
  install Install Claude Code hooks into the current project
51
55
  go "task description" Detect → decide → dispatch a task
52
56
  --dry-run Show routing decision without executing
@@ -59,6 +63,12 @@ Commands:
59
63
  remember "preference" Save a project-scoped preference
60
64
  forget "preference" Remove a preference by fuzzy match
61
65
 
66
+ Interactive mode (entered with no args on a TTY):
67
+ Shows dashboard screen with menu-driven navigation.
68
+ [g] Go — dispatch a task
69
+ [s] Status, [p] Profile, [a] Auth, [d] Diagnostics
70
+ [c] Command mode (REPL), [q] Exit
71
+
62
72
  Options:
63
73
  --version Print version
64
74
  --help Show this help
@@ -74,7 +84,7 @@ Options:
74
84
  */
75
85
  function printAuthTable(auth) {
76
86
  const W = 55; // inner width (wide enough for source labels)
77
- const bar = '═'.repeat(W);
87
+ const hbar = '═'.repeat(W);
78
88
  const pad = (s) => {
79
89
  const visible = s.replace(/[̀-ͯ]/g, ''); // strip combining chars for length
80
90
  return s + ' '.repeat(Math.max(0, W - visible.length));
@@ -85,66 +95,28 @@ 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
- console.log(`╔${bar}╗`);
107
+ console.log(`╔${hbar}╗`);
98
108
  console.log(`║${pad(' Auth Status')}║`);
99
- console.log(`╠${bar}╣`);
109
+ console.log(`╠${hbar}╣`);
100
110
  console.log(`║${pad(claudeLine1)}║`);
101
111
  console.log(`║${pad(claudeLine2)}║`);
102
112
  console.log(`║${pad(openaiLine1)}║`);
103
113
  console.log(`║${pad(openaiLine2)}║`);
104
- console.log(`╚${bar}╝`);
105
- }
106
-
107
- // ─── Card command (default) ──────────────────────────────────────────────────
108
-
109
- async function cmdCard() {
110
- const cwd = process.cwd();
111
- const { homedir } = await import('node:os');
112
- const globalPath = join(homedir(), '.config', 'dual-brain', 'profile.json');
113
- const projectPath = join(cwd, '.dualbrain', 'profile.json');
114
-
115
- if (!existsSync(projectPath) && !existsSync(globalPath)) {
116
- console.log('Welcome to dual-brain! Let\'s set up your profile.\n');
117
- await cmdInit();
118
- return;
119
- }
120
-
121
- const repo = loadRepoCache(cwd);
122
- const session = loadSession(cwd);
123
- const health = getHealth(cwd);
124
- const card = formatSessionCard(session, repo, health);
125
- console.log(card);
126
-
127
- // Auth status warnings (non-blocking)
128
- const auth = await detectAuth();
129
- 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-...');
132
- if (warnings.length > 0) {
133
- console.log('\nAuth warnings:');
134
- for (const w of warnings) console.log(` ⚠ ${w}`);
135
- }
136
-
137
- // Environment info
138
- const env = detectEnvironment();
139
- if (env.isReplit || env.hasReplitTools) {
140
- const envLabel = env.hasReplitTools ? 'Replit + replit-tools' : 'Replit';
141
- console.log(`\nRuntime: ${envLabel}`);
142
- }
114
+ console.log(`╚${hbar}╝`);
143
115
  }
144
116
 
145
117
  // ─── Commands ─────────────────────────────────────────────────────────────────
146
118
 
147
- async function cmdInit() {
119
+ async function cmdInit(rl) {
148
120
  const cwd = process.cwd();
149
121
 
150
122
  // --- Step 1: Auth preflight ---
@@ -153,39 +125,61 @@ async function cmdInit() {
153
125
 
154
126
  const noneFound = !auth.claude.found && !auth.openai.found;
155
127
  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;
128
+ console.log('\nNo AI provider credentials found. Let\'s set up at least one now.\n');
129
+ // Use the provided rl (REPL instance) or create a temporary one
130
+ const rlOwned = !rl;
131
+ if (!rl) rl = createInterface({ input: process.stdin, output: process.stdout });
132
+ try {
133
+ await setupAuth(rl);
134
+ } finally {
135
+ if (rlOwned) rl.close();
136
+ }
137
+ // Re-check after setup
138
+ const authAfter = await detectAuth();
139
+ if (!authAfter.claude.found && !authAfter.openai.found) {
140
+ console.log('\nNo credentials configured. You can run "auth setup" in the REPL anytime.');
141
+ // Still flow into REPL — don't exit
142
+ return;
143
+ }
161
144
  }
162
145
 
163
- // --- Step 2: Run onboarding wizard, skipping tiers for auto-detected plans ---
164
- const profile = await runOnboarding({ interactive: true, detectedAuth: auth });
146
+ // --- Step 2: Run onboarding wizard (pass shared rl so it isn't closed) ---
147
+ const profile = await runOnboarding({ interactive: true, detectedAuth: auth, rl });
165
148
  saveProfile(profile, { cwd });
166
149
 
167
- // --- Step 3: Show dashboard + next step ---
150
+ // --- Step 3: Show dashboard ---
168
151
  console.log('');
169
152
  const repo = loadRepoCache(cwd);
170
153
  const session = loadSession(cwd);
171
154
  const health = getHealth(cwd);
172
155
  const card = formatSessionCard(session, repo, health);
173
156
  console.log(card);
174
- console.log('\nReady! Try: dual-brain go "your task here"\n');
157
+ console.log('\nReady! Type a task below, or "help" for commands.\n');
175
158
  }
176
159
 
177
- async function cmdAuth() {
160
+ async function cmdAuth(subArgs = [], rl) {
161
+ const sub = subArgs[0];
162
+
163
+ if (sub === 'setup') {
164
+ return cmdAuthSetup(rl);
165
+ }
166
+
178
167
  const auth = await detectAuth();
179
168
  printAuthTable(auth);
180
169
 
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');
170
+ // If anything is missing, point to setup command
171
+ if (!auth.claude.found || !auth.openai.found) {
172
+ console.log('\nRun "dual-brain auth setup" (or "auth setup" in REPL) to paste API keys.');
185
173
  }
186
- if (!auth.openai.found) {
187
- console.log('\nTo set up OpenAI:');
188
- console.log(' codex auth OR export OPENAI_API_KEY=sk-...');
174
+ }
175
+
176
+ async function cmdAuthSetup(rl) {
177
+ const rlOwned = !rl;
178
+ if (!rl) rl = createInterface({ input: process.stdin, output: process.stdout });
179
+ try {
180
+ await setupAuth(rl);
181
+ } finally {
182
+ if (rlOwned) rl.close();
189
183
  }
190
184
  }
191
185
 
@@ -489,6 +483,516 @@ function cmdForget(text) {
489
483
  console.log('Preference removed (if matched).');
490
484
  }
491
485
 
486
+ // ─── Screen helpers ───────────────────────────────────────────────────────────
487
+
488
+ function profileExists() {
489
+ const { homedir } = { homedir: () => process.env.HOME || '/root' };
490
+ const cwd = process.cwd();
491
+ const globalPath = join(process.env.HOME || '/root', '.config', 'dual-brain', 'profile.json');
492
+ const projectPath = join(cwd, '.dualbrain', 'profile.json');
493
+ return existsSync(projectPath) || existsSync(globalPath);
494
+ }
495
+
496
+ // ─── Screen: welcomeScreen ────────────────────────────────────────────────────
497
+
498
+ async function welcomeScreen(rl, ask) {
499
+ const version = readVersion();
500
+ console.log(box(`🧠 Dual-Brain v${version} — First-Time Setup`, [
501
+ 'Let\'s configure your AI providers.',
502
+ ]));
503
+ console.log('');
504
+
505
+ // --- Claude provider selection ---
506
+ console.log(separator('Claude (Anthropic)'));
507
+ console.log(' (1) $20/mo Pro');
508
+ console.log(' (2) $100/mo Max 5x');
509
+ console.log(' (3) $200/mo Max 20x');
510
+ console.log(' (4) API key only');
511
+ console.log(' (5) Skip — don\'t use Claude');
512
+ const claudeChoice = (await ask('> ')).trim();
513
+
514
+ let claudePlan = null;
515
+ let claudeEnabled = true;
516
+ if (claudeChoice === '1') { claudePlan = 'pro'; }
517
+ else if (claudeChoice === '2') { claudePlan = 'max5'; }
518
+ else if (claudeChoice === '3') { claudePlan = 'max20'; }
519
+ else if (claudeChoice === '4') {
520
+ claudePlan = 'api';
521
+ // Ask for API key immediately
522
+ const key = (await ask('Paste your Anthropic API key: ')).trim();
523
+ if (key) {
524
+ const { saveAuthKey } = await import('../src/profile.mjs').then(m => m).catch(() => ({}));
525
+ // Inline: set env var for this session, profile will persist
526
+ process.env.ANTHROPIC_API_KEY = key;
527
+ console.log('✓ Claude API key set for this session');
528
+ }
529
+ } else if (claudeChoice === '5') {
530
+ claudeEnabled = false;
531
+ claudePlan = null;
532
+ } else {
533
+ // Default: pro
534
+ claudePlan = 'pro';
535
+ }
536
+
537
+ console.log('');
538
+
539
+ // --- OpenAI provider selection ---
540
+ console.log(separator('OpenAI (ChatGPT/Codex)'));
541
+ console.log(' (1) $20/mo Plus');
542
+ console.log(' (2) $100/mo Pro');
543
+ console.log(' (3) $200/mo Pro (higher limits)');
544
+ console.log(' (4) API key only');
545
+ console.log(' (5) Skip — don\'t use OpenAI');
546
+ const openaiChoice = (await ask('> ')).trim();
547
+
548
+ let openaiPlan = null;
549
+ let openaiEnabled = true;
550
+ if (openaiChoice === '1') { openaiPlan = 'plus'; }
551
+ else if (openaiChoice === '2') { openaiPlan = 'pro'; }
552
+ else if (openaiChoice === '3') { openaiPlan = 'pro200'; }
553
+ else if (openaiChoice === '4') {
554
+ openaiPlan = 'api';
555
+ const key = (await ask('Paste your OpenAI API key: ')).trim();
556
+ if (key) {
557
+ process.env.OPENAI_API_KEY = key;
558
+ console.log('✓ OpenAI API key set for this session');
559
+ }
560
+ } else if (openaiChoice === '5') {
561
+ openaiEnabled = false;
562
+ openaiPlan = null;
563
+ } else {
564
+ openaiPlan = 'plus';
565
+ }
566
+
567
+ console.log('');
568
+
569
+ // --- Optimization mode ---
570
+ console.log(separator('Optimization'));
571
+ console.log(' (1) Save usage — prefer cheaper models');
572
+ console.log(' (2) Balanced — best model per tier (recommended)');
573
+ console.log(' (3) Quality first — always use best available');
574
+ const modeChoice = (await ask('> ')).trim();
575
+
576
+ let mode = 'balanced';
577
+ if (modeChoice === '1') { mode = 'cost-saver'; }
578
+ else if (modeChoice === '3') { mode = 'quality-first'; }
579
+
580
+ // --- Build and save profile ---
581
+ const cwd = process.cwd();
582
+ const existingProfile = loadProfile(cwd);
583
+ const profile = {
584
+ ...existingProfile,
585
+ mode,
586
+ providers: {
587
+ claude: {
588
+ enabled: claudeEnabled,
589
+ plan: claudePlan || 'pro',
590
+ },
591
+ openai: {
592
+ enabled: openaiEnabled,
593
+ plan: openaiPlan || 'plus',
594
+ },
595
+ },
596
+ };
597
+ saveProfile(profile, { cwd });
598
+
599
+ // --- Detect environment for summary ---
600
+ const env = detectEnvironment();
601
+ const auth = await detectAuth();
602
+
603
+ const summaryLines = [
604
+ `Mode: ${mode}`,
605
+ claudeEnabled
606
+ ? `Claude: ${claudePlan} plan ${auth.claude.found ? badge('connected') : badge('missing')}`
607
+ : 'Claude: disabled',
608
+ openaiEnabled
609
+ ? `OpenAI: ${openaiPlan} plan ${auth.openai.found ? badge('connected') : badge('missing')}`
610
+ : 'OpenAI: disabled',
611
+ env.isReplit ? '🌀 Replit environment detected' : '',
612
+ ].filter(Boolean);
613
+
614
+ console.log('');
615
+ console.log(box('Setup Complete', summaryLines));
616
+ console.log('');
617
+
618
+ return { next: 'dashboard' };
619
+ }
620
+
621
+ // ─── Screen: dashboardScreen ──────────────────────────────────────────────────
622
+
623
+ async function dashboardScreen(rl, ask) {
624
+ const cwd = process.cwd();
625
+ const version = readVersion();
626
+ const profile = loadProfile(cwd);
627
+ const auth = await detectAuth();
628
+ const env = detectEnvironment();
629
+
630
+ // Build status lines for box
631
+ const claudeStatus = auth.claude.found ? `🟢 Claude ${badge('connected')}` : `🔴 Claude ${badge('missing')}`;
632
+ const openaiStatus = auth.openai.found ? `🟢 OpenAI ${badge('connected')}` : `🔴 OpenAI ${badge('missing')}`;
633
+ const envLabel = env.hasReplitTools ? 'Replit + replit-tools' : env.isReplit ? 'Replit' : 'local';
634
+
635
+ // Enforcement check
636
+ let guardCount = 0;
637
+ try {
638
+ const settingsFile = join(cwd, '.claude', 'settings.json');
639
+ if (existsSync(settingsFile)) {
640
+ const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
641
+ const preToolUse = settings?.hooks?.PreToolUse ?? [];
642
+ const guardCmd = 'bash .claude/hooks/head-guard.sh';
643
+ const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
644
+ const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
645
+ const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
646
+ const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
647
+ const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
648
+ guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
649
+ }
650
+ } catch { /* ignore */ }
651
+
652
+ const authSummary = (auth.claude.found && auth.openai.found)
653
+ ? 'both providers connected'
654
+ : auth.claude.found
655
+ ? 'Claude connected, OpenAI missing'
656
+ : auth.openai.found
657
+ ? 'OpenAI connected, Claude missing'
658
+ : 'no providers connected';
659
+
660
+ const dashLines = [
661
+ `${claudeStatus} ${openaiStatus}`,
662
+ `🌀 ${envLabel}`,
663
+ '',
664
+ `✓ Profile: ${profile.mode} · ${profile.providers?.claude?.enabled && profile.providers?.openai?.enabled ? 'dual' : 'solo'} mode`,
665
+ `✓ Enforcement: ${guardCount} guards active`,
666
+ `✓ Auth: ${authSummary}`,
667
+ ];
668
+
669
+ console.log(box(`🧠 Dual-Brain v${version}`, dashLines));
670
+ console.log('');
671
+ console.log(menu([
672
+ { key: 'g', label: 'Go — dispatch a task', section: 'Actions' },
673
+ { key: 's', label: 'Status — detailed provider info', section: 'Actions' },
674
+ { key: 'p', label: 'Profile & preferences', section: 'Settings' },
675
+ { key: 'a', label: 'Auth management', section: 'Settings' },
676
+ { key: 'd', label: 'Diagnostics', section: 'Settings' },
677
+ { key: 'c', label: 'Command mode (REPL)', section: 'Session' },
678
+ { key: 'q', label: 'Exit', section: 'Session' },
679
+ ]));
680
+ console.log('');
681
+
682
+ const choice = (await ask(' Choice: ')).trim().toLowerCase();
683
+
684
+ if (choice === 'g') {
685
+ const taskDesc = (await ask(' Task description: ')).trim();
686
+ if (taskDesc) {
687
+ try {
688
+ await cmdGo([taskDesc]);
689
+ } catch (e) {
690
+ console.error(`Error: ${e.message}`);
691
+ }
692
+ }
693
+ return { next: 'dashboard' };
694
+ }
695
+
696
+ if (choice === 's') {
697
+ await cmdStatus([]);
698
+ await ask('\n Press Enter to return to dashboard...');
699
+ return { next: 'dashboard' };
700
+ }
701
+
702
+ if (choice === 'p') { return { next: 'profile' }; }
703
+ if (choice === 'a') { return { next: 'auth' }; }
704
+ if (choice === 'd') { return { next: 'diagnostics' }; }
705
+ if (choice === 'c') { return { next: 'repl' }; }
706
+ if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
707
+
708
+ // Unknown choice — stay on dashboard
709
+ return { next: 'dashboard' };
710
+ }
711
+
712
+ // ─── Screen: authScreen ───────────────────────────────────────────────────────
713
+
714
+ async function authScreen(rl, ask) {
715
+ const auth = await detectAuth();
716
+
717
+ const authLines = [
718
+ 'Claude:',
719
+ ];
720
+
721
+ if (auth.claude.found) {
722
+ authLines.push(` source: ${auth.claude.source} ${badge('connected')}`);
723
+ authLines.push(` key: ${auth.claude.masked}`);
724
+ } else {
725
+ authLines.push(` not configured ${badge('missing')}`);
726
+ }
727
+
728
+ authLines.push('');
729
+ authLines.push('OpenAI:');
730
+
731
+ if (auth.openai.found) {
732
+ authLines.push(` source: ${auth.openai.source} ${badge('connected')}`);
733
+ authLines.push(` key: ${auth.openai.masked}`);
734
+ } else {
735
+ authLines.push(` not configured ${badge('missing')}`);
736
+ }
737
+
738
+ console.log(box('🔑 Auth Management', authLines));
739
+ console.log('');
740
+ console.log(menu([
741
+ { key: 'a', label: 'Add API key', section: '' },
742
+ { key: 't', label: 'Test keys', section: '' },
743
+ { key: 'b', label: 'Back to dashboard', section: '' },
744
+ ]));
745
+ console.log('');
746
+
747
+ const choice = (await ask(' Choice: ')).trim().toLowerCase();
748
+
749
+ if (choice === 'a') {
750
+ await setupAuth(rl);
751
+ return { next: 'auth' }; // refresh
752
+ }
753
+
754
+ if (choice === 't') {
755
+ console.log('\n Testing auth...');
756
+ const authNow = await detectAuth();
757
+ console.log(` Claude: ${authNow.claude.found ? 'OK — ' + authNow.claude.source : 'NOT FOUND'}`);
758
+ console.log(` OpenAI: ${authNow.openai.found ? 'OK — ' + authNow.openai.source : 'NOT FOUND'}`);
759
+ await ask('\n Press Enter to continue...');
760
+ return { next: 'auth' };
761
+ }
762
+
763
+ if (choice === 'b' || choice === 'back') { return { next: 'dashboard' }; }
764
+
765
+ return { next: 'auth' };
766
+ }
767
+
768
+ // ─── Screen: profileScreen ────────────────────────────────────────────────────
769
+
770
+ async function profileScreen(rl, ask) {
771
+ const cwd = process.cwd();
772
+ const profile = loadProfile(cwd);
773
+ const prefs = getActivePreferences(cwd);
774
+
775
+ const profileLines = [
776
+ `Mode: ${profile.mode}`,
777
+ `Claude plan: ${profile.providers?.claude?.enabled ? (profile.providers?.claude?.plan || 'n/a') : 'disabled'}`,
778
+ `OpenAI plan: ${profile.providers?.openai?.enabled ? (profile.providers?.openai?.plan || 'n/a') : 'disabled'}`,
779
+ `Solo brain: ${isSoloBrain(profile) ? 'yes' : 'no'}`,
780
+ `Head model: ${getHeadModel(profile)}`,
781
+ '',
782
+ `Preferences (${prefs.length}):`,
783
+ ...prefs.map(p => ` [${p.scope}] ${p.text}`),
784
+ ...(prefs.length === 0 ? [' (none)'] : []),
785
+ ];
786
+
787
+ console.log(box('Profile & Preferences', profileLines));
788
+ console.log('');
789
+ console.log(menu([
790
+ { key: '1', label: 'Switch to cost-saver mode', section: 'Mode' },
791
+ { key: '2', label: 'Switch to balanced mode', section: 'Mode' },
792
+ { key: '3', label: 'Switch to quality-first mode',section: 'Mode' },
793
+ { key: 'r', label: 'Add preference', section: 'Preferences' },
794
+ { key: 'f', label: 'Remove preference', section: 'Preferences' },
795
+ { key: 'b', label: 'Back to dashboard', section: '' },
796
+ ]));
797
+ console.log('');
798
+
799
+ const choice = (await ask(' Choice: ')).trim().toLowerCase();
800
+
801
+ if (choice === '1' || choice === '2' || choice === '3') {
802
+ const modeMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
803
+ profile.mode = modeMap[choice];
804
+ saveProfile(profile, { cwd });
805
+ console.log(` Mode set to: ${profile.mode}`);
806
+ return { next: 'profile' };
807
+ }
808
+
809
+ if (choice === 'r') {
810
+ const text = (await ask(' Preference text: ')).trim();
811
+ if (text) cmdRemember(text);
812
+ return { next: 'profile' };
813
+ }
814
+
815
+ if (choice === 'f') {
816
+ const text = (await ask(' Preference to remove (fuzzy): ')).trim();
817
+ if (text) cmdForget(text);
818
+ return { next: 'profile' };
819
+ }
820
+
821
+ if (choice === 'b' || choice === 'back') { return { next: 'dashboard' }; }
822
+
823
+ return { next: 'profile' };
824
+ }
825
+
826
+ // ─── Screen: diagnosticsScreen ────────────────────────────────────────────────
827
+
828
+ async function diagnosticsScreen(rl, ask) {
829
+ const cwd = process.cwd();
830
+ const version = readVersion();
831
+ const env = detectEnvironment();
832
+ const rt = await detectRuntime();
833
+
834
+ // Enforcement check
835
+ let guardCount = 0;
836
+ let guardDetails = 'NOT INSTALLED';
837
+ try {
838
+ const settingsFile = join(cwd, '.claude', 'settings.json');
839
+ if (existsSync(settingsFile)) {
840
+ const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
841
+ const preToolUse = settings?.hooks?.PreToolUse ?? [];
842
+ const guardCmd = 'bash .claude/hooks/head-guard.sh';
843
+ const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
844
+ const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
845
+ const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
846
+ const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
847
+ const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
848
+ guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
849
+ guardDetails = guardCount === 4
850
+ ? `${guardCount}/4 guards active (Edit, Write, Bash, Agent)`
851
+ : `${guardCount}/4 guards — run: dual-brain install`;
852
+ }
853
+ } catch { guardDetails = 'unknown (could not read settings)'; }
854
+
855
+ // Hook health: check if hook files exist
856
+ const hooksDir = join(cwd, '.claude', 'hooks');
857
+ const expectedHooks = [
858
+ 'head-guard.sh', 'enforce-tier.mjs', 'budget-balancer.mjs',
859
+ 'session-report.mjs', 'quality-gate.mjs', 'health-check.mjs',
860
+ ];
861
+ const hookStatus = expectedHooks.map(h => {
862
+ const present = existsSync(join(hooksDir, h));
863
+ return ` ${present ? '✓' : '✗'} ${h}`;
864
+ });
865
+
866
+ const diagLines = [
867
+ `Version: ${version}`,
868
+ `Enforcement: ${guardDetails}`,
869
+ '',
870
+ 'Environment:',
871
+ ` Replit: ${env.isReplit ? 'yes' : 'no'}`,
872
+ ` replit-tools:${env.hasReplitTools ? 'yes' : 'no'}`,
873
+ ` CI: ${env.isCI ? 'yes' : 'no'}`,
874
+ '',
875
+ 'Runtime:',
876
+ ` claude CLI: ${rt.claudeAvailable ? 'available' : 'not found'}`,
877
+ ` codex CLI: ${rt.codexAvailable ? 'available' : 'not found'}`,
878
+ ` runtime: ${rt.runtime}`,
879
+ '',
880
+ 'Hook files:',
881
+ ...hookStatus,
882
+ ];
883
+
884
+ console.log(box('Diagnostics', diagLines));
885
+ console.log('');
886
+ console.log(menu([
887
+ { key: 'h', label: 'Run health check', section: '' },
888
+ { key: 'i', label: 'Install hooks', section: '' },
889
+ { key: 'b', label: 'Back to dashboard', section: '' },
890
+ ]));
891
+ console.log('');
892
+
893
+ const choice = (await ask(' Choice: ')).trim().toLowerCase();
894
+
895
+ if (choice === 'h') {
896
+ const hookScript = join(cwd, '.claude', 'hooks', 'health-check.mjs');
897
+ if (existsSync(hookScript)) {
898
+ try {
899
+ execSync(`node "${hookScript}"`, { stdio: 'inherit', cwd });
900
+ } catch { /* hook exits non-zero on issues — already printed output */ }
901
+ } else {
902
+ console.log(' health-check.mjs not found — run: dual-brain install');
903
+ }
904
+ await ask('\n Press Enter to continue...');
905
+ return { next: 'diagnostics' };
906
+ }
907
+
908
+ if (choice === 'i') {
909
+ await cmdInstall();
910
+ return { next: 'diagnostics' };
911
+ }
912
+
913
+ if (choice === 'b' || choice === 'back') { return { next: 'dashboard' }; }
914
+
915
+ return { next: 'diagnostics' };
916
+ }
917
+
918
+ // ─── Screen: replScreen ───────────────────────────────────────────────────────
919
+
920
+ async function replScreen(rl, ask) {
921
+ console.log('\nCommand mode. Type a task or command. "help" for commands, "back" to return.\n');
922
+
923
+ while (true) {
924
+ const input = (await ask('dual-brain> ')).trim();
925
+ const line = input;
926
+
927
+ if (!line) continue;
928
+
929
+ if (line === 'back' || line === 'exit' || line === 'quit' || line === 'q') {
930
+ return { next: 'dashboard' };
931
+ }
932
+
933
+ try {
934
+ if (line === 'help') {
935
+ printHelp();
936
+ } else if (line === 'status') {
937
+ await cmdStatus([]);
938
+ } else if (line === 'auth setup' || line === 'auth-setup') {
939
+ await cmdAuthSetup(rl);
940
+ } else if (line === 'auth') {
941
+ await cmdAuth([], rl);
942
+ } else if (line.startsWith('go ')) {
943
+ await cmdGo(line.slice(3).trim().split(/\s+/));
944
+ } else if (line.startsWith('remember ')) {
945
+ cmdRemember(line.slice(9).trim());
946
+ } else if (line.startsWith('forget ')) {
947
+ cmdForget(line.slice(7).trim());
948
+ } else if (line.startsWith('hot ')) {
949
+ cmdHot(line.slice(4).trim());
950
+ } else if (line.startsWith('cool ')) {
951
+ cmdCool(line.slice(5).trim());
952
+ } else if (line === 'init') {
953
+ await cmdInit(rl);
954
+ } else if (line === 'dashboard') {
955
+ return { next: 'dashboard' };
956
+ } else {
957
+ // Treat as a task description → go
958
+ await cmdGo([line]);
959
+ }
960
+ } catch (e) {
961
+ process.stderr.write(`Error: ${e.message}\n`);
962
+ }
963
+ }
964
+ }
965
+
966
+ // ─── Screen state machine ─────────────────────────────────────────────────────
967
+
968
+ const SCREENS = {
969
+ welcome: welcomeScreen,
970
+ dashboard: dashboardScreen,
971
+ auth: authScreen,
972
+ profile: profileScreen,
973
+ diagnostics: diagnosticsScreen,
974
+ repl: replScreen,
975
+ };
976
+
977
+ async function runScreens(startScreen = 'dashboard') {
978
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
979
+ const ask = (q) => new Promise(res => rl.question(q, res));
980
+
981
+ let current = startScreen;
982
+ while (current && current !== 'exit') {
983
+ const screen = SCREENS[current];
984
+ if (!screen) break;
985
+ try {
986
+ const result = await screen(rl, ask);
987
+ current = result?.next || 'exit';
988
+ } catch (e) {
989
+ console.error(`Error: ${e.message}`);
990
+ current = 'dashboard'; // recover to dashboard on error
991
+ }
992
+ }
993
+ rl.close();
994
+ }
995
+
492
996
  // ─── Entry point ─────────────────────────────────────────────────────────────
493
997
 
494
998
  async function main() {
@@ -496,12 +1000,46 @@ async function main() {
496
1000
  const cmd = args[0];
497
1001
 
498
1002
  if (cmd === '--help' || cmd === '-h') { printHelp(); return; }
499
- if (!cmd) { await cmdCard(); return; }
500
- if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
1003
+ if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
1004
+
1005
+ // Interactive-only commands: enter screen state machine (only when TTY)
1006
+ const isInteractive = process.stdin.isTTY;
501
1007
 
502
- if (cmd === 'init') { await cmdInit(); return; }
1008
+ if (!cmd) {
1009
+ if (isInteractive) {
1010
+ // Check if first-run (no profile) → welcome screen, else dashboard
1011
+ const startScreen = profileExists() ? 'dashboard' : 'welcome';
1012
+ await runScreens(startScreen);
1013
+ } else {
1014
+ // Non-TTY: print status card and exit
1015
+ const cwd = process.cwd();
1016
+ const repo = loadRepoCache(cwd);
1017
+ const session = loadSession(cwd);
1018
+ const health = getHealth(cwd);
1019
+ const card = formatSessionCard(session, repo, health);
1020
+ console.log(card);
1021
+ }
1022
+ return;
1023
+ }
1024
+
1025
+ if (cmd === 'init') {
1026
+ if (isInteractive) {
1027
+ // Run welcome wizard then dashboard
1028
+ await runScreens('welcome');
1029
+ } else {
1030
+ await cmdInit();
1031
+ }
1032
+ return;
1033
+ }
1034
+
1035
+ // One-shot commands — run and exit
503
1036
  if (cmd === 'install') { await cmdInstall(); return; }
504
- if (cmd === 'auth') { await cmdAuth(); return; }
1037
+ if (cmd === 'auth') {
1038
+ const sub = args[1];
1039
+ if (sub === 'setup') { await cmdAuthSetup(); return; }
1040
+ await cmdAuth(args.slice(1));
1041
+ return;
1042
+ }
505
1043
  if (cmd === 'go') { await cmdGo(args.slice(1)); return; }
506
1044
  if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
507
1045
  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.1.0",
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,28 @@ 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
+ const claudeKeys = storedAuth.claude || [];
181
+ const activeClaudeKey = getActiveKey('claude');
182
+ if (activeClaudeKey) {
183
+ results.claude.found = true;
184
+ results.claude.source = '.dualbrain/auth.json';
185
+ results.claude.masked = _maskCredential(activeClaudeKey.key);
186
+ process.env.ANTHROPIC_API_KEY = activeClaudeKey.key;
187
+ // Report all keys with masked values
188
+ const now = new Date();
189
+ results.claude.keys = claudeKeys.map(k => ({
190
+ label: k.label || 'unlabeled',
191
+ masked: _maskCredential(k.key),
192
+ priority: k.priority || 99,
193
+ enabled: k.enabled !== false,
194
+ expired: !!(k.expiresAt && new Date(k.expiresAt) <= now),
195
+ }));
196
+ }
197
+ }
198
+
177
199
  // --- Claude: fallback to ANTHROPIC_API_KEY env var ---
178
200
  if (!results.claude.found && process.env.ANTHROPIC_API_KEY) {
179
201
  results.claude.found = true;
@@ -208,6 +230,28 @@ async function detectAuth() {
208
230
  } catch { continue; }
209
231
  }
210
232
 
233
+ // --- OpenAI: check .dualbrain/auth.json (before env var) ---
234
+ if (!results.openai.found) {
235
+ const storedAuth = loadAuthKeys();
236
+ const openaiKeys = storedAuth.openai || [];
237
+ const activeOpenaiKey = getActiveKey('openai');
238
+ if (activeOpenaiKey) {
239
+ results.openai.found = true;
240
+ results.openai.source = '.dualbrain/auth.json';
241
+ results.openai.masked = _maskCredential(activeOpenaiKey.key);
242
+ process.env.OPENAI_API_KEY = activeOpenaiKey.key;
243
+ // Report all keys with masked values
244
+ const now = new Date();
245
+ results.openai.keys = openaiKeys.map(k => ({
246
+ label: k.label || 'unlabeled',
247
+ masked: _maskCredential(k.key),
248
+ priority: k.priority || 99,
249
+ enabled: k.enabled !== false,
250
+ expired: !!(k.expiresAt && new Date(k.expiresAt) <= now),
251
+ }));
252
+ }
253
+ }
254
+
211
255
  // --- OpenAI: fallback to OPENAI_API_KEY env var ---
212
256
  if (!results.openai.found && process.env.OPENAI_API_KEY) {
213
257
  results.openai.found = true;
@@ -218,6 +262,258 @@ async function detectAuth() {
218
262
  return results;
219
263
  }
220
264
 
265
+ // ---------------------------------------------------------------------------
266
+ // API key storage (.dualbrain/auth.json)
267
+ // ---------------------------------------------------------------------------
268
+
269
+ const AUTH_FILE = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'auth.json');
270
+
271
+ /**
272
+ * Migrate old single-key format to array format transparently.
273
+ * Old: { claude: { key, savedAt, expiresAt }, openai: { ... } }
274
+ * New: { claude: [{ key, label, savedAt, expiresAt, priority, enabled }], openai: [...] }
275
+ * @param {object} auth
276
+ * @returns {object} migrated auth object
277
+ */
278
+ function _migrateAuthFormat(auth) {
279
+ const migrated = {};
280
+ for (const [provider, value] of Object.entries(auth)) {
281
+ if (Array.isArray(value)) {
282
+ // Already new format
283
+ migrated[provider] = value;
284
+ } else if (value && typeof value === 'object' && value.key) {
285
+ // Old single-key format — wrap in array
286
+ migrated[provider] = [
287
+ {
288
+ key: value.key,
289
+ label: 'primary',
290
+ savedAt: value.savedAt || new Date().toISOString(),
291
+ expiresAt: value.expiresAt || null,
292
+ priority: 1,
293
+ enabled: true,
294
+ },
295
+ ];
296
+ } else {
297
+ migrated[provider] = value;
298
+ }
299
+ }
300
+ return migrated;
301
+ }
302
+
303
+ /**
304
+ * Load .dualbrain/auth.json, migrating old single-key format to array format.
305
+ * @param {string} [cwd]
306
+ * @returns {object} auth object with arrays per provider
307
+ */
308
+ function loadAuthKeys(cwd) {
309
+ try {
310
+ const raw = JSON.parse(readFileSync(AUTH_FILE(cwd), 'utf8'));
311
+ return _migrateAuthFormat(raw);
312
+ } catch {
313
+ return {};
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Returns the highest-priority, non-expired, enabled key for a provider.
319
+ * @param {string} provider
320
+ * @param {string} [cwd]
321
+ * @returns {{ key: string, label: string, priority: number, enabled: boolean, expiresAt: string|null }|null}
322
+ */
323
+ function getActiveKey(provider, cwd) {
324
+ const auth = loadAuthKeys(cwd);
325
+ const keys = auth[provider] || [];
326
+ const now = new Date();
327
+
328
+ const valid = keys
329
+ .filter(k => k.enabled)
330
+ .filter(k => !k.expiresAt || new Date(k.expiresAt) > now)
331
+ .sort((a, b) => (a.priority || 99) - (b.priority || 99));
332
+
333
+ return valid[0] || null;
334
+ }
335
+
336
+ /**
337
+ * Append a new key to the provider's array in .dualbrain/auth.json.
338
+ * Injects the highest-priority valid key into process.env.
339
+ * @param {string} provider
340
+ * @param {string} key
341
+ * @param {object} [opts]
342
+ * @param {string} [opts.label]
343
+ * @param {string|null} [opts.expiresAt]
344
+ * @param {number} [opts.priority]
345
+ * @param {string} [opts.cwd]
346
+ */
347
+ function saveAuthKey(provider, key, opts = {}) {
348
+ const cwd = opts.cwd || process.cwd();
349
+ const authFile = AUTH_FILE(cwd);
350
+ const dir = dirname(authFile);
351
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
352
+
353
+ const auth = loadAuthKeys(cwd);
354
+ if (!Array.isArray(auth[provider])) auth[provider] = [];
355
+
356
+ // Determine default label and priority
357
+ const existing = auth[provider];
358
+ const defaultLabel = `key-${existing.length + 1}`;
359
+ const defaultPriority = existing.length > 0
360
+ ? Math.max(...existing.map(k => k.priority || 1)) + 1
361
+ : 1;
362
+
363
+ existing.push({
364
+ key,
365
+ label: opts.label || defaultLabel,
366
+ savedAt: new Date().toISOString(),
367
+ expiresAt: opts.expiresAt || null,
368
+ priority: opts.priority !== undefined ? opts.priority : defaultPriority,
369
+ enabled: true,
370
+ });
371
+
372
+ writeFileSync(authFile, JSON.stringify(auth, null, 2));
373
+
374
+ // Inject highest-priority valid key into process.env for this session
375
+ const active = getActiveKey(provider, cwd);
376
+ if (active) {
377
+ if (provider === 'claude') process.env.ANTHROPIC_API_KEY = active.key;
378
+ if (provider === 'openai') process.env.OPENAI_API_KEY = active.key;
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Remove a key by index from the provider's array.
384
+ * @param {string} provider
385
+ * @param {number} index
386
+ * @param {string} [cwd]
387
+ */
388
+ function removeAuthKey(provider, index, cwd) {
389
+ const authFile = AUTH_FILE(cwd);
390
+ const dir = dirname(authFile);
391
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
392
+
393
+ const auth = loadAuthKeys(cwd);
394
+ if (!Array.isArray(auth[provider])) return;
395
+
396
+ auth[provider].splice(index, 1);
397
+ writeFileSync(authFile, JSON.stringify(auth, null, 2));
398
+ }
399
+
400
+ /**
401
+ * Mark a key as enabled:false (used during failover when a key hits rate limits).
402
+ * @param {string} provider
403
+ * @param {number} index
404
+ * @param {string} [cwd]
405
+ */
406
+ function disableKey(provider, index, cwd) {
407
+ const authFile = AUTH_FILE(cwd);
408
+ const dir = dirname(authFile);
409
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
410
+
411
+ const auth = loadAuthKeys(cwd);
412
+ if (!Array.isArray(auth[provider]) || !auth[provider][index]) return;
413
+
414
+ auth[provider][index].enabled = false;
415
+ writeFileSync(authFile, JSON.stringify(auth, null, 2));
416
+ }
417
+
418
+ /**
419
+ * Called when the active key hits a rate limit. Disables the current active key
420
+ * temporarily and returns the next valid key, or null if none available.
421
+ * @param {string} provider
422
+ * @param {string} [cwd]
423
+ * @returns {{ key: string, label: string, priority: number, enabled: boolean, expiresAt: string|null }|null}
424
+ */
425
+ function rotateToNextKey(provider, cwd) {
426
+ const auth = loadAuthKeys(cwd);
427
+ const keys = auth[provider] || [];
428
+ const now = new Date();
429
+
430
+ // Find current active key index
431
+ const sortedValid = keys
432
+ .map((k, i) => ({ ...k, _idx: i }))
433
+ .filter(k => k.enabled)
434
+ .filter(k => !k.expiresAt || new Date(k.expiresAt) > now)
435
+ .sort((a, b) => (a.priority || 99) - (b.priority || 99));
436
+
437
+ if (sortedValid.length === 0) return null;
438
+
439
+ // Disable the current active key
440
+ const currentIdx = sortedValid[0]._idx;
441
+ disableKey(provider, currentIdx, cwd);
442
+
443
+ // Reload and get the next valid key
444
+ return getActiveKey(provider, cwd);
445
+ }
446
+
447
+ /**
448
+ * Interactive setup flow: walks user through entering API keys for missing providers.
449
+ * Accepts an existing readline Interface (rl) — does NOT close it.
450
+ * @param {import('readline').Interface} rl
451
+ */
452
+ async function setupAuth(rl) {
453
+ const ask = (q) => new Promise(res => rl.question(q, res));
454
+ const auth = await detectAuth();
455
+
456
+ // Claude setup
457
+ if (!auth.claude.found) {
458
+ console.log('\n— Claude Setup —');
459
+ console.log('Options:');
460
+ console.log(' (1) Paste API key (recommended for Replit)');
461
+ console.log(' (2) Skip for now');
462
+ const choice = (await ask('> ')).trim();
463
+ if (choice === '1') {
464
+ const key = (await ask('Paste your Anthropic API key: ')).trim();
465
+ if (key && (key.startsWith('sk-ant-') || key.startsWith('sk-'))) {
466
+ const labelStr = (await ask('Label for this key (or Enter for "key-1"): ')).trim();
467
+ const label = labelStr || undefined;
468
+ const expiryStr = (await ask('Set expiry in days (or Enter to skip): ')).trim();
469
+ let expiresAt = null;
470
+ if (expiryStr && /^\d+$/.test(expiryStr)) {
471
+ const d = new Date();
472
+ d.setDate(d.getDate() + parseInt(expiryStr, 10));
473
+ expiresAt = d.toISOString();
474
+ console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
475
+ }
476
+ saveAuthKey('claude', key, { expiresAt, label });
477
+ console.log('✓ Claude API key saved');
478
+ } else {
479
+ console.log('Invalid key format. Expected sk-ant-... or sk-...');
480
+ }
481
+ }
482
+ } else {
483
+ console.log(`\n✓ Claude: already configured via ${auth.claude.source}`);
484
+ }
485
+
486
+ // OpenAI setup
487
+ if (!auth.openai.found) {
488
+ console.log('\n— OpenAI Setup —');
489
+ console.log('Options:');
490
+ console.log(' (1) Paste API key (recommended for Replit)');
491
+ console.log(' (2) Skip for now');
492
+ const choice = (await ask('> ')).trim();
493
+ if (choice === '1') {
494
+ const key = (await ask('Paste your OpenAI API key: ')).trim();
495
+ if (key && key.startsWith('sk-')) {
496
+ const labelStr = (await ask('Label for this key (or Enter for "key-1"): ')).trim();
497
+ const label = labelStr || undefined;
498
+ const expiryStr = (await ask('Set expiry in days (or Enter to skip): ')).trim();
499
+ let expiresAt = null;
500
+ if (expiryStr && /^\d+$/.test(expiryStr)) {
501
+ const d = new Date();
502
+ d.setDate(d.getDate() + parseInt(expiryStr, 10));
503
+ expiresAt = d.toISOString();
504
+ console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
505
+ }
506
+ saveAuthKey('openai', key, { expiresAt, label });
507
+ console.log('✓ OpenAI API key saved');
508
+ } else {
509
+ console.log('Invalid key format. Expected sk-...');
510
+ }
511
+ }
512
+ } else {
513
+ console.log(`\n✓ OpenAI: already configured via ${auth.openai.source}`);
514
+ }
515
+ }
516
+
221
517
  // ---------------------------------------------------------------------------
222
518
  // Auto-detect subscription plans from provider config files
223
519
  // ---------------------------------------------------------------------------
@@ -414,7 +710,10 @@ function saveProfile(profile, opts = {}) {
414
710
  async function runOnboarding(opts = {}) {
415
711
  if (!opts.interactive) return defaultProfile();
416
712
 
417
- const rl = createInterface({ input: process.stdin, output: process.stdout });
713
+ // Accept an externally-provided readline instance (shared with REPL/auth setup)
714
+ // or create one internally if not provided. Only close if we created it.
715
+ const rlProvided = !!opts.rl;
716
+ const rl = opts.rl || createInterface({ input: process.stdin, output: process.stdout });
418
717
  const ask = (q) => new Promise(res => rl.question(q, res));
419
718
  const profile = defaultProfile();
420
719
 
@@ -440,7 +739,8 @@ async function runOnboarding(opts = {}) {
440
739
  profile.mode = n >= 2 ? 'dual' : profile.providers.claude.enabled ? 'solo-claude' : 'solo-openai';
441
740
  process.stdout.write('\nProfile saved.\n');
442
741
  } finally {
443
- rl.close();
742
+ // Only close if we created the rl instance (not if it was passed in)
743
+ if (!rlProvided) rl.close();
444
744
  }
445
745
  return profile;
446
746
  }
@@ -582,4 +882,6 @@ export {
582
882
  getAvailableProviders, isSoloBrain, getHeadModel,
583
883
  detectPlans, syncPreferencesToMemory,
584
884
  detectAuth, detectEnvironment,
885
+ setupAuth, saveAuthKey, loadAuthKeys,
886
+ getActiveKey, removeAuthKey, disableKey, rotateToNextKey,
585
887
  };
package/src/tui.mjs ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * tui.mjs — Zero-dependency terminal UI renderer for the dual-brain CLI.
3
+ * All functions return strings; callers use console.log to print.
4
+ */
5
+
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ // ─── Unicode / ASCII mode ─────────────────────────────────────────────────────
9
+
10
+ export const useUnicode =
11
+ process.env.DUALBRAIN_ASCII !== '1' && process.stdout.isTTY !== false;
12
+
13
+ const CH = useUnicode
14
+ ? { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║', ts: '╠', te: '╣', fill: '█', empty: '░' }
15
+ : { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|', ts: '+', te: '+', fill: '#', empty: '.' };
16
+
17
+ // ─── ANSI / emoji helpers ─────────────────────────────────────────────────────
18
+
19
+ /** Strip ANSI escape codes from a string. */
20
+ export function stripAnsi(str) {
21
+ // eslint-disable-next-line no-control-regex
22
+ return String(str).replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
23
+ }
24
+
25
+ /**
26
+ * Visible display length of a string.
27
+ * Strips ANSI codes and counts each emoji as 2 columns wide.
28
+ */
29
+ export function visibleLength(str) {
30
+ const plain = stripAnsi(String(str));
31
+ let len = 0;
32
+ for (const ch of plain) {
33
+ const cp = ch.codePointAt(0);
34
+ // Emoji / wide symbol ranges (covers most common emoji)
35
+ if (
36
+ (cp >= 0x1f300 && cp <= 0x1faff) || // Misc symbols, emoji
37
+ (cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols
38
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // Variation selectors
39
+ (cp >= 0x1f1e0 && cp <= 0x1f1ff) || // Flags
40
+ cp === 0x20e3 // Combining enclosing keycap
41
+ ) {
42
+ len += 2;
43
+ } else {
44
+ len += 1;
45
+ }
46
+ }
47
+ return len;
48
+ }
49
+
50
+ /**
51
+ * Right-pad `str` with spaces so that its visible width equals `width`.
52
+ * Accounts for emoji (2-wide) and ANSI codes.
53
+ */
54
+ export function pad(str, width) {
55
+ const vl = visibleLength(str);
56
+ const spaces = Math.max(0, width - vl);
57
+ return String(str) + ' '.repeat(spaces);
58
+ }
59
+
60
+ // ─── box ─────────────────────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Renders a Unicode (or ASCII) box with a title bar.
64
+ * @param {string} title
65
+ * @param {string[]} lines
66
+ * @param {{ width?: number }} opts
67
+ * @returns {string}
68
+ */
69
+ export function box(title, lines = [], opts = {}) {
70
+ const inner = opts.width ?? 56;
71
+ const total = inner + 2; // 2 spaces padding on each side counted inside border
72
+
73
+ const top = CH.tl + CH.h.repeat(total) + CH.tr;
74
+ const divider = CH.ts + CH.h.repeat(total) + CH.te;
75
+ const bottom = CH.bl + CH.h.repeat(total) + CH.br;
76
+
77
+ // Title row: 2-space left pad
78
+ const titleContent = ' ' + title;
79
+ const titleRow = CH.v + pad(titleContent, total) + CH.v;
80
+
81
+ const bodyRows = lines.map(line => {
82
+ const content = ' ' + line;
83
+ return CH.v + pad(content, total) + CH.v;
84
+ });
85
+
86
+ return [top, titleRow, divider, ...bodyRows, bottom].join('\n');
87
+ }
88
+
89
+ // ─── bar ─────────────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Renders a percentage bar.
93
+ * @param {number} percent 0–100
94
+ * @param {number} width bar width in chars (default 20)
95
+ * @param {{ label?: string }} opts
96
+ * @returns {string}
97
+ */
98
+ export function bar(percent, width = 20, opts = {}) {
99
+ const pct = Math.max(0, Math.min(100, percent));
100
+ const filled = Math.round((pct / 100) * width);
101
+ const empty = width - filled;
102
+
103
+ const track = CH.fill.repeat(filled) + CH.empty.repeat(empty);
104
+ const pctStr = String(Math.round(pct)).padStart(3) + '%';
105
+ const label = opts.label ? ` ${opts.label}` : '';
106
+
107
+ return `${track} ${pctStr}${label}`;
108
+ }
109
+
110
+ // ─── badge ────────────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Returns a status badge emoji/symbol.
114
+ * @param {string} status
115
+ * @returns {string}
116
+ */
117
+ export function badge(status) {
118
+ const map = {
119
+ healthy: '🟢',
120
+ degraded: '🟡',
121
+ hot: '🔴',
122
+ probing: '🟠',
123
+ connected: '✅',
124
+ missing: '❌',
125
+ warning: '⚠️',
126
+ };
127
+ return map[status] ?? '❓';
128
+ }
129
+
130
+ // ─── separator ───────────────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Returns a section separator line.
134
+ * @param {string} label
135
+ * @returns {string}
136
+ */
137
+ export function separator(label = '') {
138
+ const dash = useUnicode ? '─' : '-';
139
+ return label
140
+ ? ` ${dash}${dash}${dash} ${label}`
141
+ : ` ${dash}${dash}${dash}`;
142
+ }
143
+
144
+ // ─── menu ────────────────────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * Renders a numbered/lettered menu grouped by section.
148
+ * @param {{ key: string, label: string, section?: string }[]} options
149
+ * @param {object} opts (reserved)
150
+ * @returns {string}
151
+ */
152
+ export function menu(options, opts = {}) {
153
+ const rows = [];
154
+ let lastSection = Symbol('none');
155
+
156
+ for (const opt of options) {
157
+ const section = opt.section ?? '';
158
+ if (section !== lastSection) {
159
+ if (section) {
160
+ rows.push(separator(section));
161
+ } else {
162
+ rows.push(separator());
163
+ }
164
+ lastSection = section;
165
+ }
166
+ rows.push(` [${opt.key}] ${opt.label}`);
167
+ }
168
+
169
+ return rows.join('\n');
170
+ }
171
+
172
+ // ─── Self-test ────────────────────────────────────────────────────────────────
173
+
174
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
175
+ console.log(box('🧠 Dual-Brain v7.0.2', [
176
+ '🟢 Claude ✅ 🟢 OpenAI ✅',
177
+ '🌀 Replit + replit-tools',
178
+ ]));
179
+ console.log(bar(75, 20, { label: 'Claude' }));
180
+ console.log(bar(25, 20, { label: 'OpenAI' }));
181
+ console.log(menu([
182
+ { key: 'c', label: 'Continue last session', section: 'Sessions' },
183
+ { key: 'n', label: 'New session', section: 'Sessions' },
184
+ { key: 'a', label: 'Auth management', section: 'Settings' },
185
+ { key: 'p', label: 'Profile settings', section: 'Settings' },
186
+ { key: 's', label: 'Exit to shell', section: '' },
187
+ ]));
188
+ }