dual-brain 7.0.2 → 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 +524 -102
- package/package.json +1 -1
- package/src/profile.mjs +196 -26
- package/src/tui.mjs +188 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -29,6 +29,8 @@ import { dispatch, detectRuntime, dispatchDualBrain } from '../src/dispatch.mjs'
|
|
|
29
29
|
import { loadRepoCache } from '../src/repo.mjs';
|
|
30
30
|
import { loadSession, saveSession, formatSessionCard } from '../src/session.mjs';
|
|
31
31
|
|
|
32
|
+
import { box, bar, badge, menu, separator } from '../src/tui.mjs';
|
|
33
|
+
|
|
32
34
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
33
35
|
|
|
34
36
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -61,13 +63,11 @@ Commands:
|
|
|
61
63
|
remember "preference" Save a project-scoped preference
|
|
62
64
|
forget "preference" Remove a preference by fuzzy match
|
|
63
65
|
|
|
64
|
-
Interactive
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
help Show this help
|
|
70
|
-
exit / quit / q Exit the REPL
|
|
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
71
|
|
|
72
72
|
Options:
|
|
73
73
|
--version Print version
|
|
@@ -84,7 +84,7 @@ Options:
|
|
|
84
84
|
*/
|
|
85
85
|
function printAuthTable(auth) {
|
|
86
86
|
const W = 55; // inner width (wide enough for source labels)
|
|
87
|
-
const
|
|
87
|
+
const hbar = '═'.repeat(W);
|
|
88
88
|
const pad = (s) => {
|
|
89
89
|
const visible = s.replace(/[̀-ͯ]/g, ''); // strip combining chars for length
|
|
90
90
|
return s + ' '.repeat(Math.max(0, W - visible.length));
|
|
@@ -104,52 +104,14 @@ function printAuthTable(auth) {
|
|
|
104
104
|
? ` ${auth.openai.masked}`
|
|
105
105
|
: ` run: dual-brain auth setup`;
|
|
106
106
|
|
|
107
|
-
console.log(`╔${
|
|
107
|
+
console.log(`╔${hbar}╗`);
|
|
108
108
|
console.log(`║${pad(' Auth Status')}║`);
|
|
109
|
-
console.log(`╠${
|
|
109
|
+
console.log(`╠${hbar}╣`);
|
|
110
110
|
console.log(`║${pad(claudeLine1)}║`);
|
|
111
111
|
console.log(`║${pad(claudeLine2)}║`);
|
|
112
112
|
console.log(`║${pad(openaiLine1)}║`);
|
|
113
113
|
console.log(`║${pad(openaiLine2)}║`);
|
|
114
|
-
console.log(`╚${
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ─── Card command (default) ──────────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
async function cmdCard() {
|
|
120
|
-
const cwd = process.cwd();
|
|
121
|
-
const { homedir } = await import('node:os');
|
|
122
|
-
const globalPath = join(homedir(), '.config', 'dual-brain', 'profile.json');
|
|
123
|
-
const projectPath = join(cwd, '.dualbrain', 'profile.json');
|
|
124
|
-
|
|
125
|
-
if (!existsSync(projectPath) && !existsSync(globalPath)) {
|
|
126
|
-
console.log('Welcome to dual-brain! Let\'s set up your profile.\n');
|
|
127
|
-
await cmdInit();
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const repo = loadRepoCache(cwd);
|
|
132
|
-
const session = loadSession(cwd);
|
|
133
|
-
const health = getHealth(cwd);
|
|
134
|
-
const card = formatSessionCard(session, repo, health);
|
|
135
|
-
console.log(card);
|
|
136
|
-
|
|
137
|
-
// Auth status warnings (non-blocking)
|
|
138
|
-
const auth = await detectAuth();
|
|
139
|
-
const warnings = [];
|
|
140
|
-
if (!auth.claude.found) warnings.push('Claude auth not found — run: dual-brain auth setup');
|
|
141
|
-
if (!auth.openai.found) warnings.push('OpenAI auth not found — run: dual-brain auth setup');
|
|
142
|
-
if (warnings.length > 0) {
|
|
143
|
-
console.log('\nAuth warnings:');
|
|
144
|
-
for (const w of warnings) console.log(` ⚠ ${w}`);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Environment info
|
|
148
|
-
const env = detectEnvironment();
|
|
149
|
-
if (env.isReplit || env.hasReplitTools) {
|
|
150
|
-
const envLabel = env.hasReplitTools ? 'Replit + replit-tools' : 'Replit';
|
|
151
|
-
console.log(`\nRuntime: ${envLabel}`);
|
|
152
|
-
}
|
|
114
|
+
console.log(`╚${hbar}╝`);
|
|
153
115
|
}
|
|
154
116
|
|
|
155
117
|
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
@@ -521,63 +483,514 @@ function cmdForget(text) {
|
|
|
521
483
|
console.log('Preference removed (if matched).');
|
|
522
484
|
}
|
|
523
485
|
|
|
524
|
-
// ───
|
|
486
|
+
// ─── Screen helpers ───────────────────────────────────────────────────────────
|
|
525
487
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
|
|
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
|
+
}
|
|
531
495
|
|
|
532
|
-
|
|
496
|
+
// ─── Screen: welcomeScreen ────────────────────────────────────────────────────
|
|
533
497
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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('');
|
|
538
504
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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('');
|
|
543
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) {
|
|
544
687
|
try {
|
|
545
|
-
|
|
546
|
-
printHelp();
|
|
547
|
-
} else if (line === 'status') {
|
|
548
|
-
await cmdStatus([]);
|
|
549
|
-
} else if (line === 'auth setup' || line === 'auth-setup') {
|
|
550
|
-
await cmdAuthSetup(rl);
|
|
551
|
-
} else if (line === 'auth') {
|
|
552
|
-
await cmdAuth([], rl);
|
|
553
|
-
} else if (line.startsWith('go ')) {
|
|
554
|
-
await cmdGo(line.slice(3).trim().split(/\s+/));
|
|
555
|
-
} else if (line.startsWith('remember ')) {
|
|
556
|
-
cmdRemember(line.slice(9).trim());
|
|
557
|
-
} else if (line.startsWith('forget ')) {
|
|
558
|
-
cmdForget(line.slice(7).trim());
|
|
559
|
-
} else if (line.startsWith('hot ')) {
|
|
560
|
-
cmdHot(line.slice(4).trim());
|
|
561
|
-
} else if (line.startsWith('cool ')) {
|
|
562
|
-
cmdCool(line.slice(5).trim());
|
|
563
|
-
} else if (line === 'init') {
|
|
564
|
-
await cmdInit(rl);
|
|
565
|
-
} else {
|
|
566
|
-
// Treat as a task description → go
|
|
567
|
-
await cmdGo([line]);
|
|
568
|
-
}
|
|
688
|
+
await cmdGo([taskDesc]);
|
|
569
689
|
} catch (e) {
|
|
570
|
-
|
|
690
|
+
console.error(`Error: ${e.message}`);
|
|
571
691
|
}
|
|
692
|
+
}
|
|
693
|
+
return { next: 'dashboard' };
|
|
694
|
+
}
|
|
572
695
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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 ────────────────────────────────────────────────────
|
|
576
769
|
|
|
577
|
-
|
|
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
|
+
}
|
|
578
820
|
|
|
579
|
-
|
|
580
|
-
|
|
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();
|
|
581
994
|
}
|
|
582
995
|
|
|
583
996
|
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
@@ -589,21 +1002,30 @@ async function main() {
|
|
|
589
1002
|
if (cmd === '--help' || cmd === '-h') { printHelp(); return; }
|
|
590
1003
|
if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
|
|
591
1004
|
|
|
592
|
-
// Interactive-only commands: enter
|
|
1005
|
+
// Interactive-only commands: enter screen state machine (only when TTY)
|
|
593
1006
|
const isInteractive = process.stdin.isTTY;
|
|
594
1007
|
|
|
595
1008
|
if (!cmd) {
|
|
596
|
-
|
|
597
|
-
|
|
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
|
+
}
|
|
598
1022
|
return;
|
|
599
1023
|
}
|
|
600
1024
|
|
|
601
1025
|
if (cmd === 'init') {
|
|
602
1026
|
if (isInteractive) {
|
|
603
|
-
//
|
|
604
|
-
|
|
605
|
-
await cmdInit(rl);
|
|
606
|
-
await startRepl(rl);
|
|
1027
|
+
// Run welcome wizard then dashboard
|
|
1028
|
+
await runScreens('welcome');
|
|
607
1029
|
} else {
|
|
608
1030
|
await cmdInit();
|
|
609
1031
|
}
|
package/package.json
CHANGED
package/src/profile.mjs
CHANGED
|
@@ -177,14 +177,22 @@ async function detectAuth() {
|
|
|
177
177
|
// --- Claude: check .dualbrain/auth.json (before env var) ---
|
|
178
178
|
if (!results.claude.found) {
|
|
179
179
|
const storedAuth = loadAuthKeys();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
}));
|
|
188
196
|
}
|
|
189
197
|
}
|
|
190
198
|
|
|
@@ -225,14 +233,22 @@ async function detectAuth() {
|
|
|
225
233
|
// --- OpenAI: check .dualbrain/auth.json (before env var) ---
|
|
226
234
|
if (!results.openai.found) {
|
|
227
235
|
const storedAuth = loadAuthKeys();
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
+
}));
|
|
236
252
|
}
|
|
237
253
|
}
|
|
238
254
|
|
|
@@ -252,14 +268,82 @@ async function detectAuth() {
|
|
|
252
268
|
|
|
253
269
|
const AUTH_FILE = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'auth.json');
|
|
254
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
|
+
*/
|
|
255
308
|
function loadAuthKeys(cwd) {
|
|
256
309
|
try {
|
|
257
|
-
|
|
310
|
+
const raw = JSON.parse(readFileSync(AUTH_FILE(cwd), 'utf8'));
|
|
311
|
+
return _migrateAuthFormat(raw);
|
|
258
312
|
} catch {
|
|
259
313
|
return {};
|
|
260
314
|
}
|
|
261
315
|
}
|
|
262
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
|
+
*/
|
|
263
347
|
function saveAuthKey(provider, key, opts = {}) {
|
|
264
348
|
const cwd = opts.cwd || process.cwd();
|
|
265
349
|
const authFile = AUTH_FILE(cwd);
|
|
@@ -267,16 +351,97 @@ function saveAuthKey(provider, key, opts = {}) {
|
|
|
267
351
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
268
352
|
|
|
269
353
|
const auth = loadAuthKeys(cwd);
|
|
270
|
-
auth[provider] =
|
|
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({
|
|
271
364
|
key,
|
|
365
|
+
label: opts.label || defaultLabel,
|
|
272
366
|
savedAt: new Date().toISOString(),
|
|
273
367
|
expiresAt: opts.expiresAt || null,
|
|
274
|
-
|
|
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);
|
|
275
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);
|
|
276
442
|
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
if (provider === 'openai') process.env.OPENAI_API_KEY = key;
|
|
443
|
+
// Reload and get the next valid key
|
|
444
|
+
return getActiveKey(provider, cwd);
|
|
280
445
|
}
|
|
281
446
|
|
|
282
447
|
/**
|
|
@@ -298,7 +463,9 @@ async function setupAuth(rl) {
|
|
|
298
463
|
if (choice === '1') {
|
|
299
464
|
const key = (await ask('Paste your Anthropic API key: ')).trim();
|
|
300
465
|
if (key && (key.startsWith('sk-ant-') || key.startsWith('sk-'))) {
|
|
301
|
-
const
|
|
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();
|
|
302
469
|
let expiresAt = null;
|
|
303
470
|
if (expiryStr && /^\d+$/.test(expiryStr)) {
|
|
304
471
|
const d = new Date();
|
|
@@ -306,7 +473,7 @@ async function setupAuth(rl) {
|
|
|
306
473
|
expiresAt = d.toISOString();
|
|
307
474
|
console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
|
|
308
475
|
}
|
|
309
|
-
saveAuthKey('claude', key, { expiresAt });
|
|
476
|
+
saveAuthKey('claude', key, { expiresAt, label });
|
|
310
477
|
console.log('✓ Claude API key saved');
|
|
311
478
|
} else {
|
|
312
479
|
console.log('Invalid key format. Expected sk-ant-... or sk-...');
|
|
@@ -326,7 +493,9 @@ async function setupAuth(rl) {
|
|
|
326
493
|
if (choice === '1') {
|
|
327
494
|
const key = (await ask('Paste your OpenAI API key: ')).trim();
|
|
328
495
|
if (key && key.startsWith('sk-')) {
|
|
329
|
-
const
|
|
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();
|
|
330
499
|
let expiresAt = null;
|
|
331
500
|
if (expiryStr && /^\d+$/.test(expiryStr)) {
|
|
332
501
|
const d = new Date();
|
|
@@ -334,7 +503,7 @@ async function setupAuth(rl) {
|
|
|
334
503
|
expiresAt = d.toISOString();
|
|
335
504
|
console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
|
|
336
505
|
}
|
|
337
|
-
saveAuthKey('openai', key, { expiresAt });
|
|
506
|
+
saveAuthKey('openai', key, { expiresAt, label });
|
|
338
507
|
console.log('✓ OpenAI API key saved');
|
|
339
508
|
} else {
|
|
340
509
|
console.log('Invalid key format. Expected sk-...');
|
|
@@ -714,4 +883,5 @@ export {
|
|
|
714
883
|
detectPlans, syncPreferencesToMemory,
|
|
715
884
|
detectAuth, detectEnvironment,
|
|
716
885
|
setupAuth, saveAuthKey, loadAuthKeys,
|
|
886
|
+
getActiveKey, removeAuthKey, disableKey, rotateToNextKey,
|
|
717
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
|
+
}
|