dual-brain 7.0.0 → 7.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dual-brain.mjs +221 -13
- package/package.json +1 -1
- package/src/profile.mjs +248 -3
package/bin/dual-brain.mjs
CHANGED
|
@@ -5,11 +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,
|
|
14
|
+
detectAuth, detectEnvironment, setupAuth,
|
|
13
15
|
} from '../src/profile.mjs';
|
|
14
16
|
|
|
15
17
|
import { detectTask } from '../src/detect.mjs';
|
|
@@ -44,7 +46,9 @@ function printHelp() {
|
|
|
44
46
|
dual-brain <command> [options]
|
|
45
47
|
|
|
46
48
|
Commands:
|
|
47
|
-
init First-time setup
|
|
49
|
+
init First-time setup → flows into interactive REPL
|
|
50
|
+
auth Show authentication status for all providers
|
|
51
|
+
auth setup Paste API keys directly (recommended for Replit)
|
|
48
52
|
install Install Claude Code hooks into the current project
|
|
49
53
|
go "task description" Detect → decide → dispatch a task
|
|
50
54
|
--dry-run Show routing decision without executing
|
|
@@ -57,6 +61,14 @@ Commands:
|
|
|
57
61
|
remember "preference" Save a project-scoped preference
|
|
58
62
|
forget "preference" Remove a preference by fuzzy match
|
|
59
63
|
|
|
64
|
+
Interactive REPL (entered after init or npx dual-brain with no args):
|
|
65
|
+
<task description> Dispatch a task directly
|
|
66
|
+
go <task> Same as dual-brain go
|
|
67
|
+
status / auth / init Run commands without exiting
|
|
68
|
+
auth setup Re-run API key setup
|
|
69
|
+
help Show this help
|
|
70
|
+
exit / quit / q Exit the REPL
|
|
71
|
+
|
|
60
72
|
Options:
|
|
61
73
|
--version Print version
|
|
62
74
|
--help Show this help
|
|
@@ -64,6 +76,44 @@ Options:
|
|
|
64
76
|
`.trim());
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
// ─── Auth helpers ─────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Print a compact auth status table to stdout.
|
|
83
|
+
* @param {{ claude: object, openai: object }} auth Result from detectAuth()
|
|
84
|
+
*/
|
|
85
|
+
function printAuthTable(auth) {
|
|
86
|
+
const W = 55; // inner width (wide enough for source labels)
|
|
87
|
+
const bar = '═'.repeat(W);
|
|
88
|
+
const pad = (s) => {
|
|
89
|
+
const visible = s.replace(/[̀-ͯ]/g, ''); // strip combining chars for length
|
|
90
|
+
return s + ' '.repeat(Math.max(0, W - visible.length));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const claudeLine1 = auth.claude.found
|
|
94
|
+
? ` Claude: ✓ found via ${auth.claude.source}`
|
|
95
|
+
: ` Claude: ✗ not found`;
|
|
96
|
+
const claudeLine2 = auth.claude.found
|
|
97
|
+
? ` ${auth.claude.masked}`
|
|
98
|
+
: ` run: dual-brain auth setup`;
|
|
99
|
+
|
|
100
|
+
const openaiLine1 = auth.openai.found
|
|
101
|
+
? ` OpenAI: ✓ found via ${auth.openai.source}`
|
|
102
|
+
: ` OpenAI: ✗ not found`;
|
|
103
|
+
const openaiLine2 = auth.openai.found
|
|
104
|
+
? ` ${auth.openai.masked}`
|
|
105
|
+
: ` run: dual-brain auth setup`;
|
|
106
|
+
|
|
107
|
+
console.log(`╔${bar}╗`);
|
|
108
|
+
console.log(`║${pad(' Auth Status')}║`);
|
|
109
|
+
console.log(`╠${bar}╣`);
|
|
110
|
+
console.log(`║${pad(claudeLine1)}║`);
|
|
111
|
+
console.log(`║${pad(claudeLine2)}║`);
|
|
112
|
+
console.log(`║${pad(openaiLine1)}║`);
|
|
113
|
+
console.log(`║${pad(openaiLine2)}║`);
|
|
114
|
+
console.log(`╚${bar}╝`);
|
|
115
|
+
}
|
|
116
|
+
|
|
67
117
|
// ─── Card command (default) ──────────────────────────────────────────────────
|
|
68
118
|
|
|
69
119
|
async function cmdCard() {
|
|
@@ -83,19 +133,92 @@ async function cmdCard() {
|
|
|
83
133
|
const health = getHealth(cwd);
|
|
84
134
|
const card = formatSessionCard(session, repo, health);
|
|
85
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
|
+
}
|
|
86
153
|
}
|
|
87
154
|
|
|
88
155
|
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
89
156
|
|
|
90
|
-
async function cmdInit() {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
157
|
+
async function cmdInit(rl) {
|
|
158
|
+
const cwd = process.cwd();
|
|
159
|
+
|
|
160
|
+
// --- Step 1: Auth preflight ---
|
|
161
|
+
const auth = await detectAuth();
|
|
162
|
+
printAuthTable(auth);
|
|
163
|
+
|
|
164
|
+
const noneFound = !auth.claude.found && !auth.openai.found;
|
|
165
|
+
if (noneFound) {
|
|
166
|
+
console.log('\nNo AI provider credentials found. Let\'s set up at least one now.\n');
|
|
167
|
+
// Use the provided rl (REPL instance) or create a temporary one
|
|
168
|
+
const rlOwned = !rl;
|
|
169
|
+
if (!rl) rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
170
|
+
try {
|
|
171
|
+
await setupAuth(rl);
|
|
172
|
+
} finally {
|
|
173
|
+
if (rlOwned) rl.close();
|
|
174
|
+
}
|
|
175
|
+
// Re-check after setup
|
|
176
|
+
const authAfter = await detectAuth();
|
|
177
|
+
if (!authAfter.claude.found && !authAfter.openai.found) {
|
|
178
|
+
console.log('\nNo credentials configured. You can run "auth setup" in the REPL anytime.');
|
|
179
|
+
// Still flow into REPL — don't exit
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Step 2: Run onboarding wizard (pass shared rl so it isn't closed) ---
|
|
185
|
+
const profile = await runOnboarding({ interactive: true, detectedAuth: auth, rl });
|
|
186
|
+
saveProfile(profile, { cwd });
|
|
187
|
+
|
|
188
|
+
// --- Step 3: Show dashboard ---
|
|
189
|
+
console.log('');
|
|
190
|
+
const repo = loadRepoCache(cwd);
|
|
191
|
+
const session = loadSession(cwd);
|
|
192
|
+
const health = getHealth(cwd);
|
|
193
|
+
const card = formatSessionCard(session, repo, health);
|
|
194
|
+
console.log(card);
|
|
195
|
+
console.log('\nReady! Type a task below, or "help" for commands.\n');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function cmdAuth(subArgs = [], rl) {
|
|
199
|
+
const sub = subArgs[0];
|
|
200
|
+
|
|
201
|
+
if (sub === 'setup') {
|
|
202
|
+
return cmdAuthSetup(rl);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const auth = await detectAuth();
|
|
206
|
+
printAuthTable(auth);
|
|
207
|
+
|
|
208
|
+
// If anything is missing, point to setup command
|
|
209
|
+
if (!auth.claude.found || !auth.openai.found) {
|
|
210
|
+
console.log('\nRun "dual-brain auth setup" (or "auth setup" in REPL) to paste API keys.');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function cmdAuthSetup(rl) {
|
|
215
|
+
const rlOwned = !rl;
|
|
216
|
+
if (!rl) rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
217
|
+
try {
|
|
218
|
+
await setupAuth(rl);
|
|
219
|
+
} finally {
|
|
220
|
+
if (rlOwned) rl.close();
|
|
221
|
+
}
|
|
99
222
|
}
|
|
100
223
|
|
|
101
224
|
async function cmdGo(args) {
|
|
@@ -398,6 +521,65 @@ function cmdForget(text) {
|
|
|
398
521
|
console.log('Preference removed (if matched).');
|
|
399
522
|
}
|
|
400
523
|
|
|
524
|
+
// ─── Interactive REPL ────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
async function startRepl(rl) {
|
|
527
|
+
// rl may have been created by cmdCard/cmdInit — reuse it.
|
|
528
|
+
// If not provided, create a fresh one.
|
|
529
|
+
const rlOwned = !rl;
|
|
530
|
+
if (!rl) rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
531
|
+
|
|
532
|
+
console.log('\nType a task or command. Type "help" for commands, "exit" to quit.\n');
|
|
533
|
+
|
|
534
|
+
const prompt = () => {
|
|
535
|
+
rl.question('dual-brain> ', async (input) => {
|
|
536
|
+
const line = input.trim();
|
|
537
|
+
if (!line) { prompt(); return; }
|
|
538
|
+
|
|
539
|
+
if (line === 'exit' || line === 'quit' || line === 'q') {
|
|
540
|
+
if (rlOwned) rl.close();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
if (line === 'help') {
|
|
546
|
+
printHelp();
|
|
547
|
+
} else if (line === 'status') {
|
|
548
|
+
await cmdStatus([]);
|
|
549
|
+
} else if (line === 'auth setup' || line === 'auth-setup') {
|
|
550
|
+
await cmdAuthSetup(rl);
|
|
551
|
+
} else if (line === 'auth') {
|
|
552
|
+
await cmdAuth([], rl);
|
|
553
|
+
} else if (line.startsWith('go ')) {
|
|
554
|
+
await cmdGo(line.slice(3).trim().split(/\s+/));
|
|
555
|
+
} else if (line.startsWith('remember ')) {
|
|
556
|
+
cmdRemember(line.slice(9).trim());
|
|
557
|
+
} else if (line.startsWith('forget ')) {
|
|
558
|
+
cmdForget(line.slice(7).trim());
|
|
559
|
+
} else if (line.startsWith('hot ')) {
|
|
560
|
+
cmdHot(line.slice(4).trim());
|
|
561
|
+
} else if (line.startsWith('cool ')) {
|
|
562
|
+
cmdCool(line.slice(5).trim());
|
|
563
|
+
} else if (line === 'init') {
|
|
564
|
+
await cmdInit(rl);
|
|
565
|
+
} else {
|
|
566
|
+
// Treat as a task description → go
|
|
567
|
+
await cmdGo([line]);
|
|
568
|
+
}
|
|
569
|
+
} catch (e) {
|
|
570
|
+
process.stderr.write(`Error: ${e.message}\n`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
prompt(); // loop back
|
|
574
|
+
});
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
prompt();
|
|
578
|
+
|
|
579
|
+
// Return a promise that resolves when rl closes (exit/quit)
|
|
580
|
+
return new Promise(resolve => rl.on('close', resolve));
|
|
581
|
+
}
|
|
582
|
+
|
|
401
583
|
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
402
584
|
|
|
403
585
|
async function main() {
|
|
@@ -405,11 +587,37 @@ async function main() {
|
|
|
405
587
|
const cmd = args[0];
|
|
406
588
|
|
|
407
589
|
if (cmd === '--help' || cmd === '-h') { printHelp(); return; }
|
|
408
|
-
if (
|
|
409
|
-
if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
|
|
590
|
+
if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
|
|
410
591
|
|
|
411
|
-
|
|
592
|
+
// Interactive-only commands: enter REPL after completing (only when TTY)
|
|
593
|
+
const isInteractive = process.stdin.isTTY;
|
|
594
|
+
|
|
595
|
+
if (!cmd) {
|
|
596
|
+
await cmdCard();
|
|
597
|
+
if (isInteractive) await startRepl();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (cmd === 'init') {
|
|
602
|
+
if (isInteractive) {
|
|
603
|
+
// Create the shared rl upfront so init wizard and REPL share it
|
|
604
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
605
|
+
await cmdInit(rl);
|
|
606
|
+
await startRepl(rl);
|
|
607
|
+
} else {
|
|
608
|
+
await cmdInit();
|
|
609
|
+
}
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// One-shot commands — run and exit
|
|
412
614
|
if (cmd === 'install') { await cmdInstall(); return; }
|
|
615
|
+
if (cmd === 'auth') {
|
|
616
|
+
const sub = args[1];
|
|
617
|
+
if (sub === 'setup') { await cmdAuthSetup(); return; }
|
|
618
|
+
await cmdAuth(args.slice(1));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
413
621
|
if (cmd === 'go') { await cmdGo(args.slice(1)); return; }
|
|
414
622
|
if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
|
|
415
623
|
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
|
|
@@ -106,6 +106,245 @@ function syncPreferencesToMemory(profile, cwd) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Environment detection
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Detect the runtime environment.
|
|
115
|
+
* Returns { isReplit, hasReplitTools, isCI }.
|
|
116
|
+
*/
|
|
117
|
+
function detectEnvironment() {
|
|
118
|
+
const isReplit = !!(process.env.REPL_ID || process.env.REPLIT_DB_URL);
|
|
119
|
+
const hasReplitTools = existsSync(join(process.cwd(), '.replit-tools'));
|
|
120
|
+
const isCI = !!(process.env.CI || process.env.GITHUB_ACTIONS);
|
|
121
|
+
return { isReplit, hasReplitTools, isCI };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Auth detection
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Mask a credential string: show first 4 + "..." + last 4 chars.
|
|
130
|
+
* For short strings (< 8 chars), just returns "***".
|
|
131
|
+
* @param {string} str
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
function _maskCredential(str) {
|
|
135
|
+
if (!str || str.length < 8) return '***';
|
|
136
|
+
return str.slice(0, 4) + '...' + str.slice(-4);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Detect authentication credentials from all known sources.
|
|
141
|
+
* Checks in priority order: config files first, then env vars.
|
|
142
|
+
* Never makes network calls — validation is always null in v1.
|
|
143
|
+
*
|
|
144
|
+
* @returns {{ claude: AuthEntry, openai: AuthEntry }}
|
|
145
|
+
* @typedef {{ found: boolean, source: string|null, masked: string|null, validated: null }} AuthEntry
|
|
146
|
+
*/
|
|
147
|
+
async function detectAuth() {
|
|
148
|
+
const results = {
|
|
149
|
+
claude: { found: false, source: null, masked: null, validated: null },
|
|
150
|
+
openai: { found: false, source: null, masked: null, validated: null },
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// --- Claude: check .claude.json for oauthAccount or apiKey ---
|
|
154
|
+
const claudePaths = [
|
|
155
|
+
'/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
|
|
156
|
+
join(homedir(), '.claude', '.claude.json'),
|
|
157
|
+
];
|
|
158
|
+
for (const p of claudePaths) {
|
|
159
|
+
try {
|
|
160
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
161
|
+
if (data?.oauthAccount) {
|
|
162
|
+
// OAuth session found
|
|
163
|
+
results.claude.found = true;
|
|
164
|
+
results.claude.source = p.includes('.replit-tools') ? '.claude.json (replit-tools)' : '.claude.json';
|
|
165
|
+
results.claude.masked = 'oauth:configured';
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (data?.apiKey && typeof data.apiKey === 'string') {
|
|
169
|
+
results.claude.found = true;
|
|
170
|
+
results.claude.source = p.includes('.replit-tools') ? '.claude.json (replit-tools)' : '.claude.json';
|
|
171
|
+
results.claude.masked = _maskCredential(data.apiKey);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
} catch { continue; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- Claude: check .dualbrain/auth.json (before env var) ---
|
|
178
|
+
if (!results.claude.found) {
|
|
179
|
+
const storedAuth = loadAuthKeys();
|
|
180
|
+
if (storedAuth.claude?.key) {
|
|
181
|
+
const expired = storedAuth.claude.expiresAt && new Date(storedAuth.claude.expiresAt) <= new Date();
|
|
182
|
+
if (!expired) {
|
|
183
|
+
results.claude.found = true;
|
|
184
|
+
results.claude.source = '.dualbrain/auth.json';
|
|
185
|
+
results.claude.masked = _maskCredential(storedAuth.claude.key);
|
|
186
|
+
process.env.ANTHROPIC_API_KEY = storedAuth.claude.key;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- Claude: fallback to ANTHROPIC_API_KEY env var ---
|
|
192
|
+
if (!results.claude.found && process.env.ANTHROPIC_API_KEY) {
|
|
193
|
+
results.claude.found = true;
|
|
194
|
+
results.claude.source = 'env:ANTHROPIC_API_KEY';
|
|
195
|
+
results.claude.masked = _maskCredential(process.env.ANTHROPIC_API_KEY);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- OpenAI/Codex: check auth.json for access_token or id_token ---
|
|
199
|
+
const codexPaths = [
|
|
200
|
+
'/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
|
|
201
|
+
join(homedir(), '.codex', 'auth.json'),
|
|
202
|
+
];
|
|
203
|
+
for (const p of codexPaths) {
|
|
204
|
+
try {
|
|
205
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
206
|
+
const accessToken = data?.tokens?.access_token || data?.access_token;
|
|
207
|
+
const idToken = data?.tokens?.id_token || data?.id_token;
|
|
208
|
+
const apiKey = data?.apiKey ?? data?.api_key ?? null;
|
|
209
|
+
|
|
210
|
+
if (accessToken || idToken) {
|
|
211
|
+
results.openai.found = true;
|
|
212
|
+
results.openai.source = p.includes('.replit-tools') ? 'codex auth.json (replit-tools)' : 'codex auth.json';
|
|
213
|
+
results.openai.masked = 'oauth:configured';
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
if (apiKey && typeof apiKey === 'string') {
|
|
217
|
+
results.openai.found = true;
|
|
218
|
+
results.openai.source = p.includes('.replit-tools') ? 'codex auth.json (replit-tools)' : 'codex auth.json';
|
|
219
|
+
results.openai.masked = _maskCredential(apiKey);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
} catch { continue; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- OpenAI: check .dualbrain/auth.json (before env var) ---
|
|
226
|
+
if (!results.openai.found) {
|
|
227
|
+
const storedAuth = loadAuthKeys();
|
|
228
|
+
if (storedAuth.openai?.key) {
|
|
229
|
+
const expired = storedAuth.openai.expiresAt && new Date(storedAuth.openai.expiresAt) <= new Date();
|
|
230
|
+
if (!expired) {
|
|
231
|
+
results.openai.found = true;
|
|
232
|
+
results.openai.source = '.dualbrain/auth.json';
|
|
233
|
+
results.openai.masked = _maskCredential(storedAuth.openai.key);
|
|
234
|
+
process.env.OPENAI_API_KEY = storedAuth.openai.key;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- OpenAI: fallback to OPENAI_API_KEY env var ---
|
|
240
|
+
if (!results.openai.found && process.env.OPENAI_API_KEY) {
|
|
241
|
+
results.openai.found = true;
|
|
242
|
+
results.openai.source = 'env:OPENAI_API_KEY';
|
|
243
|
+
results.openai.masked = _maskCredential(process.env.OPENAI_API_KEY);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return results;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// API key storage (.dualbrain/auth.json)
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
const AUTH_FILE = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'auth.json');
|
|
254
|
+
|
|
255
|
+
function loadAuthKeys(cwd) {
|
|
256
|
+
try {
|
|
257
|
+
return JSON.parse(readFileSync(AUTH_FILE(cwd), 'utf8'));
|
|
258
|
+
} catch {
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function saveAuthKey(provider, key, opts = {}) {
|
|
264
|
+
const cwd = opts.cwd || process.cwd();
|
|
265
|
+
const authFile = AUTH_FILE(cwd);
|
|
266
|
+
const dir = dirname(authFile);
|
|
267
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
268
|
+
|
|
269
|
+
const auth = loadAuthKeys(cwd);
|
|
270
|
+
auth[provider] = {
|
|
271
|
+
key,
|
|
272
|
+
savedAt: new Date().toISOString(),
|
|
273
|
+
expiresAt: opts.expiresAt || null,
|
|
274
|
+
};
|
|
275
|
+
writeFileSync(authFile, JSON.stringify(auth, null, 2));
|
|
276
|
+
|
|
277
|
+
// Inject into process.env for this session so dispatch can use it
|
|
278
|
+
if (provider === 'claude') process.env.ANTHROPIC_API_KEY = key;
|
|
279
|
+
if (provider === 'openai') process.env.OPENAI_API_KEY = key;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Interactive setup flow: walks user through entering API keys for missing providers.
|
|
284
|
+
* Accepts an existing readline Interface (rl) — does NOT close it.
|
|
285
|
+
* @param {import('readline').Interface} rl
|
|
286
|
+
*/
|
|
287
|
+
async function setupAuth(rl) {
|
|
288
|
+
const ask = (q) => new Promise(res => rl.question(q, res));
|
|
289
|
+
const auth = await detectAuth();
|
|
290
|
+
|
|
291
|
+
// Claude setup
|
|
292
|
+
if (!auth.claude.found) {
|
|
293
|
+
console.log('\n— Claude Setup —');
|
|
294
|
+
console.log('Options:');
|
|
295
|
+
console.log(' (1) Paste API key (recommended for Replit)');
|
|
296
|
+
console.log(' (2) Skip for now');
|
|
297
|
+
const choice = (await ask('> ')).trim();
|
|
298
|
+
if (choice === '1') {
|
|
299
|
+
const key = (await ask('Paste your Anthropic API key: ')).trim();
|
|
300
|
+
if (key && (key.startsWith('sk-ant-') || key.startsWith('sk-'))) {
|
|
301
|
+
const expiryStr = (await ask('Set key expiry? (enter days, or press Enter to skip)\n> ')).trim();
|
|
302
|
+
let expiresAt = null;
|
|
303
|
+
if (expiryStr && /^\d+$/.test(expiryStr)) {
|
|
304
|
+
const d = new Date();
|
|
305
|
+
d.setDate(d.getDate() + parseInt(expiryStr, 10));
|
|
306
|
+
expiresAt = d.toISOString();
|
|
307
|
+
console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
|
|
308
|
+
}
|
|
309
|
+
saveAuthKey('claude', key, { expiresAt });
|
|
310
|
+
console.log('✓ Claude API key saved');
|
|
311
|
+
} else {
|
|
312
|
+
console.log('Invalid key format. Expected sk-ant-... or sk-...');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
console.log(`\n✓ Claude: already configured via ${auth.claude.source}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// OpenAI setup
|
|
320
|
+
if (!auth.openai.found) {
|
|
321
|
+
console.log('\n— OpenAI Setup —');
|
|
322
|
+
console.log('Options:');
|
|
323
|
+
console.log(' (1) Paste API key (recommended for Replit)');
|
|
324
|
+
console.log(' (2) Skip for now');
|
|
325
|
+
const choice = (await ask('> ')).trim();
|
|
326
|
+
if (choice === '1') {
|
|
327
|
+
const key = (await ask('Paste your OpenAI API key: ')).trim();
|
|
328
|
+
if (key && key.startsWith('sk-')) {
|
|
329
|
+
const expiryStr = (await ask('Set key expiry? (enter days, or press Enter to skip)\n> ')).trim();
|
|
330
|
+
let expiresAt = null;
|
|
331
|
+
if (expiryStr && /^\d+$/.test(expiryStr)) {
|
|
332
|
+
const d = new Date();
|
|
333
|
+
d.setDate(d.getDate() + parseInt(expiryStr, 10));
|
|
334
|
+
expiresAt = d.toISOString();
|
|
335
|
+
console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
|
|
336
|
+
}
|
|
337
|
+
saveAuthKey('openai', key, { expiresAt });
|
|
338
|
+
console.log('✓ OpenAI API key saved');
|
|
339
|
+
} else {
|
|
340
|
+
console.log('Invalid key format. Expected sk-...');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
console.log(`\n✓ OpenAI: already configured via ${auth.openai.source}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
109
348
|
// ---------------------------------------------------------------------------
|
|
110
349
|
// Auto-detect subscription plans from provider config files
|
|
111
350
|
// ---------------------------------------------------------------------------
|
|
@@ -302,7 +541,10 @@ function saveProfile(profile, opts = {}) {
|
|
|
302
541
|
async function runOnboarding(opts = {}) {
|
|
303
542
|
if (!opts.interactive) return defaultProfile();
|
|
304
543
|
|
|
305
|
-
|
|
544
|
+
// Accept an externally-provided readline instance (shared with REPL/auth setup)
|
|
545
|
+
// or create one internally if not provided. Only close if we created it.
|
|
546
|
+
const rlProvided = !!opts.rl;
|
|
547
|
+
const rl = opts.rl || createInterface({ input: process.stdin, output: process.stdout });
|
|
306
548
|
const ask = (q) => new Promise(res => rl.question(q, res));
|
|
307
549
|
const profile = defaultProfile();
|
|
308
550
|
|
|
@@ -328,7 +570,8 @@ async function runOnboarding(opts = {}) {
|
|
|
328
570
|
profile.mode = n >= 2 ? 'dual' : profile.providers.claude.enabled ? 'solo-claude' : 'solo-openai';
|
|
329
571
|
process.stdout.write('\nProfile saved.\n');
|
|
330
572
|
} finally {
|
|
331
|
-
rl
|
|
573
|
+
// Only close if we created the rl instance (not if it was passed in)
|
|
574
|
+
if (!rlProvided) rl.close();
|
|
332
575
|
}
|
|
333
576
|
return profile;
|
|
334
577
|
}
|
|
@@ -469,4 +712,6 @@ export {
|
|
|
469
712
|
rememberPreference, forgetPreference, getActivePreferences,
|
|
470
713
|
getAvailableProviders, isSoloBrain, getHeadModel,
|
|
471
714
|
detectPlans, syncPreferencesToMemory,
|
|
715
|
+
detectAuth, detectEnvironment,
|
|
716
|
+
setupAuth, saveAuthKey, loadAuthKeys,
|
|
472
717
|
};
|