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.
- package/bin/dual-brain.mjs +606 -68
- package/package.json +1 -1
- package/src/profile.mjs +305 -3
- package/src/tui.mjs +188 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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:
|
|
105
|
+
: ` run: dual-brain auth setup`;
|
|
96
106
|
|
|
97
|
-
console.log(`╔${
|
|
107
|
+
console.log(`╔${hbar}╗`);
|
|
98
108
|
console.log(`║${pad(' Auth Status')}║`);
|
|
99
|
-
console.log(`╠${
|
|
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(`╚${
|
|
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.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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
|
|
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!
|
|
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,
|
|
182
|
-
if (!auth.claude.found) {
|
|
183
|
-
console.log('\
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 (
|
|
500
|
-
|
|
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
|
|
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')
|
|
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
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
|
-
|
|
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
|
|
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
|
+
}
|