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.
@@ -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 (providers, plans, optimization)
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 profile = await runOnboarding({ interactive: true });
92
- saveProfile(profile, { cwd: process.cwd() });
93
- const rt = await detectRuntime();
94
- const providers = getAvailableProviders(profile);
95
- const providerSummary = providers.length
96
- ? providers.map(p => `${p.name === 'claude' ? 'Claude' : 'OpenAI'} (${p.plan})`).join(', ')
97
- : 'none';
98
- console.log(`Profile saved. Providers: ${providerSummary}. Mode: ${profile.mode}. Runtime: ${rt.runtime}`);
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 (!cmd) { await cmdCard(); return; }
409
- if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
590
+ if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
410
591
 
411
- if (cmd === 'init') { await cmdInit(); return; }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.0.0",
3
+ "version": "7.0.2",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/profile.mjs CHANGED
@@ -25,7 +25,7 @@
25
25
  import { createInterface } from 'readline';
26
26
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
27
27
  import { homedir } from 'os';
28
- import { join } from 'path';
28
+ import { dirname, join } from 'path';
29
29
 
30
30
  // ---------------------------------------------------------------------------
31
31
  // Claude Code memory integration
@@ -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
- const rl = createInterface({ input: process.stdin, output: process.stdout });
544
+ // Accept an externally-provided readline instance (shared with REPL/auth setup)
545
+ // or create one internally if not provided. Only close if we created it.
546
+ const rlProvided = !!opts.rl;
547
+ const rl = opts.rl || createInterface({ input: process.stdin, output: process.stdout });
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.close();
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
  };