dual-brain 7.1.21 → 7.1.23

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/src/profile.mjs CHANGED
@@ -10,9 +10,12 @@
10
10
  * rememberPreference(text, opts) → add/update preference
11
11
  * forgetPreference(text, cwd) → remove preference by fuzzy match
12
12
  * getActivePreferences(cwd) → enabled global + project preferences
13
- * getAvailableProviders(profile) → enabled providers with plan info
13
+ * getAvailableProviders(profile) → enabled providers
14
14
  * isSoloBrain(profile) → true if only one provider enabled
15
15
  * getHeadModel(profile) → suggested head model string
16
+ * detectCapabilities(cwd) → what we can actually verify
17
+ * getOnboardingMessage(caps, ws) → honest 2-3 line status message
18
+ * needsApiGuardrail(caps) → true if metered API key detected
16
19
  *
17
20
  * CLI:
18
21
  * node src/profile.mjs # show current profile
@@ -26,7 +29,7 @@ import { createInterface } from 'readline';
26
29
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
27
30
  import { homedir } from 'os';
28
31
  import { join } from 'path';
29
- import { execFile } from 'child_process';
32
+ import { execSync } from 'child_process';
30
33
 
31
34
  // ---------------------------------------------------------------------------
32
35
  // Claude Code memory integration
@@ -123,190 +126,150 @@ function detectEnvironment() {
123
126
  }
124
127
 
125
128
  // ---------------------------------------------------------------------------
126
- // Auth detection
129
+ // Capability detection — only what we can actually verify
127
130
  // ---------------------------------------------------------------------------
128
131
 
129
132
  /**
130
- * Detect CLI login status for Claude and Codex.
131
- * Checks config files on disk never makes network calls.
133
+ * Detect what providers and tools are actually available.
134
+ * Never makes network calls, never claims to know configured plan or price.
132
135
  *
133
- * @returns {{ claude: AuthEntry, openai: AuthEntry }}
134
- * @typedef {{ found: boolean, source: string|null, loginType: 'oauth'|'cli'|null }} AuthEntry
136
+ * @param {string} [cwd]
137
+ * @returns {Promise<{
138
+ * claude: { available: boolean, source: string|null },
139
+ * openai: { available: boolean, source: string|null, metered: boolean },
140
+ * codex: { available: boolean, source: string|null },
141
+ * replitTools: { available: boolean, checkpoints: boolean },
142
+ * }>}
135
143
  */
136
- async function detectAuth() {
137
- const results = {
138
- claude: { found: false, source: null, loginType: null },
139
- openai: { found: false, source: null, loginType: null },
140
- };
144
+ async function detectCapabilities(cwd) {
145
+ const root = cwd || process.cwd();
141
146
 
142
- // --- Claude: check .claude.json for oauthAccount (CLI login) ---
143
- const claudePaths = [
144
- '/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
145
- join(homedir(), '.claude', '.claude.json'),
146
- ];
147
- for (const p of claudePaths) {
148
- try {
149
- const data = JSON.parse(readFileSync(p, 'utf8'));
150
- if (data?.oauthAccount) {
151
- results.claude.found = true;
152
- results.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
153
- results.claude.loginType = 'oauth';
154
- break;
155
- }
156
- // Legacy: apiKey field in .claude.json (set by claude CLI in some versions)
157
- if (data?.apiKey && typeof data.apiKey === 'string') {
158
- results.claude.found = true;
159
- results.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
160
- results.claude.loginType = 'cli';
161
- break;
162
- }
163
- } catch { continue; }
147
+ // --- Claude: running inside Claude Code or has ANTHROPIC_API_KEY or ~/.claude dir ---
148
+ let claudeAvailable = false;
149
+ let claudeSource = null;
150
+
151
+ if (process.env.CLAUDE_CODE) {
152
+ claudeAvailable = true;
153
+ claudeSource = 'claude-code';
154
+ } else if (process.env.ANTHROPIC_API_KEY) {
155
+ claudeAvailable = true;
156
+ claudeSource = 'env-key';
157
+ } else {
158
+ // Check for ~/.claude directory (Claude Code installation)
159
+ const claudeDir = join(homedir(), '.claude');
160
+ const replitClaudeDir = join(root, '.replit-tools', '.claude-persistent');
161
+ if (existsSync(claudeDir) || existsSync(replitClaudeDir)) {
162
+ claudeAvailable = true;
163
+ claudeSource = existsSync(replitClaudeDir) ? 'claude-code' : 'claude-dir';
164
+ }
164
165
  }
165
166
 
166
- // --- OpenAI/Codex: check auth.json for access_token or id_token (CLI login) ---
167
- const codexPaths = [
168
- '/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
169
- join(homedir(), '.codex', 'auth.json'),
170
- ];
171
- for (const p of codexPaths) {
172
- try {
173
- const data = JSON.parse(readFileSync(p, 'utf8'));
174
- const accessToken = data?.tokens?.access_token || data?.access_token;
175
- const idToken = data?.tokens?.id_token || data?.id_token;
167
+ // --- OpenAI: check for OPENAI_API_KEY (metered billing) ---
168
+ const openaiKey = process.env.OPENAI_API_KEY;
169
+ const openaiAvailable = !!(openaiKey && openaiKey.length > 0);
176
170
 
177
- if (accessToken || idToken) {
178
- results.openai.found = true;
179
- results.openai.source = p.includes('.replit-tools') ? 'codex CLI (replit-tools)' : 'codex CLI';
180
- results.openai.loginType = 'oauth';
181
- break;
182
- }
183
- } catch { continue; }
171
+ // --- Codex: check if 'codex' is in PATH ---
172
+ let codexAvailable = false;
173
+ let codexSource = null;
174
+ try {
175
+ execSync('which codex', { stdio: 'pipe', timeout: 2000 });
176
+ codexAvailable = true;
177
+ codexSource = 'cli';
178
+ } catch {
179
+ // not in PATH
184
180
  }
185
181
 
186
- return results;
187
- }
188
-
189
- // ---------------------------------------------------------------------------
190
- // Subscription management (.dualbrain/profile.json)
191
- // ---------------------------------------------------------------------------
182
+ // --- replit-tools: check if directory exists or binary in PATH ---
183
+ const replitToolsDir = join(root, '.replit-tools');
184
+ let replitToolsAvailable = existsSync(replitToolsDir);
185
+ if (!replitToolsAvailable) {
186
+ try {
187
+ execSync('which replit-tools', { stdio: 'pipe', timeout: 2000 });
188
+ replitToolsAvailable = true;
189
+ } catch {
190
+ // not in PATH
191
+ }
192
+ }
192
193
 
193
- /**
194
- * Save subscription config for a provider into .dualbrain/profile.json.
195
- * @param {string} provider — 'claude' or 'openai'
196
- * @param {{ plan: string, label?: string, expiresAt?: string }} config
197
- * @param {string} [cwd]
198
- */
199
- function saveSubscription(provider, config, cwd) {
200
- const profile = loadProfile(cwd);
201
- if (!profile.providers[provider]) profile.providers[provider] = { enabled: true };
202
- profile.providers[provider].plan = config.plan;
203
- profile.providers[provider].enabled = true;
204
- if (config.label) profile.providers[provider].label = config.label;
205
- if (config.expiresAt) profile.providers[provider].expiresAt = config.expiresAt;
206
- saveProfile(profile, { cwd: cwd || process.cwd() });
207
- return profile;
208
- }
194
+ // Check for checkpoint capability (replit-specific)
195
+ const checkpointsBin = existsSync(join(replitToolsDir, 'checkpoints'))
196
+ || existsSync('/usr/local/bin/replit-checkpoint');
209
197
 
210
- /**
211
- * Return subscription configs for all providers from the saved profile.
212
- * @param {string} [cwd]
213
- * @returns {{ [provider: string]: { plan: string, enabled: boolean, label?: string, expiresAt?: string } }}
214
- */
215
- function listSubscriptions(cwd) {
216
- const profile = loadProfile(cwd);
217
- return profile.providers || {};
198
+ return {
199
+ claude: {
200
+ available: claudeAvailable,
201
+ source: claudeSource,
202
+ },
203
+ openai: {
204
+ available: openaiAvailable,
205
+ source: openaiAvailable ? 'env-key' : null,
206
+ metered: openaiAvailable, // API key = metered billing
207
+ },
208
+ codex: {
209
+ available: codexAvailable,
210
+ source: codexSource,
211
+ },
212
+ replitTools: {
213
+ available: replitToolsAvailable,
214
+ checkpoints: checkpointsBin,
215
+ },
216
+ };
218
217
  }
219
218
 
220
- // ---------------------------------------------------------------------------
221
- // Auto-detect subscription plans from provider config files
222
- // ---------------------------------------------------------------------------
223
-
224
219
  /**
225
- * Decode a JWT payload without verifying the signature.
226
- * Returns the payload object, or null on failure.
227
- * @param {string} token
220
+ * Return true if any metered API key is detected.
221
+ * When true, the system defaults to conservative API usage and should
222
+ * confirm before expensive operations.
223
+ *
224
+ * @param {ReturnType<typeof detectCapabilities> extends Promise<infer T> ? T : never} capabilities
225
+ * @returns {boolean}
228
226
  */
229
- function decodeJwtPayload(token) {
230
- try {
231
- const parts = token.split('.');
232
- if (parts.length < 2) return null;
233
- // Base64url → base64 → Buffer
234
- const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
235
- const json = Buffer.from(b64, 'base64').toString('utf8');
236
- return JSON.parse(json);
237
- } catch {
238
- return null;
239
- }
227
+ function needsApiGuardrail(capabilities) {
228
+ return !!(capabilities?.openai?.metered);
240
229
  }
241
230
 
242
231
  /**
243
- * Infer plan tier from Claude Code and Codex auth config files.
244
- * Returns { claude: '$20'|'$100'|'$200'|null, openai: '$20'|'$100'|'$200'|null }.
245
- * Returns nulls for any provider whose config cannot be read — never throws.
232
+ * Generate an honest 2-3 line onboarding/status message based on
233
+ * what we can actually verify.
246
234
  *
247
- * NOTE: This reads rate-limit tier signals (organizationRateLimitTier for Claude,
248
- * chatgpt_plan_type JWT claim for OpenAI) and maps them to price tiers.
249
- * It does NOT retrieve the actual subscription plan name from the provider —
250
- * labels like "Max x5" or "Pro" are our own interpretations of those signals.
235
+ * @param {object} capabilities result of detectCapabilities()
236
+ * @param {string} [workStyle] — 'balanced' | 'cost-saver' | 'quality-first'
237
+ * @returns {string}
251
238
  */
252
- function detectPlans() {
253
- const plans = { claude: null, openai: null };
239
+ function getOnboardingMessage(capabilities, workStyle = 'balanced') {
240
+ const found = [];
241
+ if (capabilities?.claude?.available) found.push('Claude Code');
242
+ if (capabilities?.openai?.available) found.push('OpenAI API');
243
+ if (capabilities?.codex?.available && !capabilities?.openai?.available) found.push('Codex CLI');
244
+
245
+ const styleLabels = {
246
+ 'balanced': 'Balanced — smart routing, reviews on important changes',
247
+ 'cost-saver': 'Cost-saver — prefers faster models, skips dual-brain for low-risk tasks',
248
+ 'quality-first': 'Quality-first — dual-brain for medium+ risk, stricter reviews',
249
+ };
250
+ const modeLabel = styleLabels[workStyle] || styleLabels['balanced'];
254
251
 
255
- // --- Claude: read organizationRateLimitTier from .claude.json ---
256
- const claudePaths = [
257
- // Replit-tools persistent path (takes precedence)
258
- '/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
259
- join(homedir(), '.claude', '.claude.json'),
260
- ];
261
- for (const p of claudePaths) {
262
- try {
263
- const data = JSON.parse(readFileSync(p, 'utf8'));
264
- const tier = data?.oauthAccount?.organizationRateLimitTier;
265
- if (tier) {
266
- if (tier.includes('max_20x')) plans.claude = '$200';
267
- else if (tier.includes('max_5x')) plans.claude = '$100';
268
- else plans.claude = '$20';
269
- }
270
- break;
271
- } catch { continue; }
252
+ const lines = [];
253
+ if (found.length === 0) {
254
+ lines.push('No providers detected');
255
+ lines.push(' Set ANTHROPIC_API_KEY or install Claude Code to get started');
256
+ return lines.join('\n');
272
257
  }
273
258
 
274
- // --- OpenAI/Codex: read plan from auth.json (direct field or JWT payload) ---
275
- const codexPaths = [
276
- // Replit-tools persistent path (takes precedence)
277
- '/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
278
- join(homedir(), '.codex', 'auth.json'),
279
- ];
280
- for (const p of codexPaths) {
281
- try {
282
- const data = JSON.parse(readFileSync(p, 'utf8'));
259
+ lines.push(`Found: ${found.join(', ')}`);
260
+ lines.push(` Mode: ${modeLabel}`);
283
261
 
284
- // Try a top-level `plan` field first
285
- let planType = data.plan ?? null;
286
-
287
- // Fall back to decoding the JWT id_token or access_token
288
- if (!planType) {
289
- for (const key of ['id_token', 'access_token']) {
290
- const token = data?.tokens?.[key];
291
- if (!token) continue;
292
- const payload = decodeJwtPayload(token);
293
- planType =
294
- payload?.['https://api.openai.com/auth']?.chatgpt_plan_type ?? null;
295
- if (planType) break;
296
- }
297
- }
262
+ // Tip: suggest OpenAI if only Claude is available
263
+ if (capabilities?.claude?.available && !capabilities?.openai?.available && !capabilities?.codex?.available) {
264
+ lines.push(' Tip: Add OPENAI_API_KEY for dual-brain collaboration');
265
+ }
298
266
 
299
- if (planType) {
300
- // pro / prolite → $100 | plus → $20 | pro200 / team → $200
301
- if (planType === 'pro200' || planType === 'team') plans.openai = '$200';
302
- else if (planType === 'pro' || planType === 'prolite') plans.openai = '$100';
303
- else plans.openai = '$20';
304
- }
305
- break;
306
- } catch { continue; }
267
+ // Warn about metered billing
268
+ if (capabilities?.openai?.metered) {
269
+ lines.push(' Note: OpenAI API key detected usage is metered, guardrails enabled');
307
270
  }
308
271
 
309
- return plans;
272
+ return lines.join('\n');
310
273
  }
311
274
 
312
275
  // ---------------------------------------------------------------------------
@@ -320,16 +283,18 @@ const projectPath = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'profile.j
320
283
  function defaultProfile() {
321
284
  const now = new Date().toISOString();
322
285
  return {
323
- schemaVersion: 1,
286
+ schemaVersion: 2,
324
287
  createdAt: now,
325
288
  updatedAt: now,
289
+ workStyle: 'balanced',
326
290
  providers: {
327
- claude: { plan: '$20', enabled: true },
328
- openai: { plan: '$20', enabled: false },
291
+ claude: { enabled: true },
292
+ openai: { enabled: false },
329
293
  },
330
294
  mode: 'auto',
331
295
  bias: 'balanced',
332
296
  preferences: [],
297
+ apiGuardrail: false,
333
298
  };
334
299
  }
335
300
 
@@ -342,10 +307,9 @@ function migrateProfile(profile) {
342
307
  if (profile.subscriptions && !profile.providers) {
343
308
  profile.providers = {};
344
309
  for (const [key, sub] of Object.entries(profile.subscriptions)) {
345
- profile.providers[key] = {
346
- plan: sub.plan || '$20',
347
- enabled: true,
348
- };
310
+ profile.providers[key] = { enabled: true };
311
+ // Drop plan/price fields — we no longer track subscription tier
312
+ void sub;
349
313
  }
350
314
  delete profile.subscriptions;
351
315
  }
@@ -358,8 +322,27 @@ function migrateProfile(profile) {
358
322
  profile.preferences = profile.preferences || [];
359
323
  profile.providers = profile.providers || {};
360
324
  }
361
- // Future migrations go here:
362
- // if (profile.schemaVersion < 2) { ... profile.schemaVersion = 2; }
325
+
326
+ if (profile.schemaVersion < 2) {
327
+ // v1 → v2: remove fake subscription fields, add workStyle + apiGuardrail
328
+ profile.schemaVersion = 2;
329
+ profile.workStyle = profile.workStyle || profile.bias || 'balanced';
330
+ profile.apiGuardrail = profile.apiGuardrail ?? false;
331
+
332
+ // Strip price/plan/budget fields — they were never accurate
333
+ for (const prov of Object.values(profile.providers || {})) {
334
+ delete prov.plan;
335
+ delete prov.label;
336
+ delete prov.expiresAt;
337
+ delete prov.subs;
338
+ }
339
+ delete profile.plan;
340
+ delete profile.price;
341
+ delete profile.subscription; // doctor:verified — removing legacy field from stored config
342
+ delete profile.budget;
343
+ delete profile.detectedPlan;
344
+ }
345
+
363
346
  return profile;
364
347
  }
365
348
 
@@ -375,24 +358,6 @@ function loadProfile(cwd) {
375
358
  }
376
359
  }
377
360
  if (!profile) profile = defaultProfile();
378
-
379
- // Read plan tier from auth config files (JWT or organizationRateLimitTier) and
380
- // apply if it differs from the stored profile value.
381
- // NOTE: detectPlans() reads rate-limit tier data from the auth config — it infers
382
- // a price tier ($20/$100/$200) from that signal, not from the subscription name itself.
383
- // The plan label (e.g. "Max x5") comes from our own mapping, not from Claude/OpenAI.
384
- const detected = detectPlans();
385
- for (const [provider, detectedPlan] of Object.entries(detected)) {
386
- if (!detectedPlan) continue;
387
- if (!profile.providers[provider]) continue;
388
- const stored = profile.providers[provider].plan;
389
- if (stored !== detectedPlan) {
390
- const providerName = provider === 'claude' ? 'Claude' : 'OpenAI';
391
- process.stderr.write(`[dual-brain] ${providerName}: plan updated to ${detectedPlan} (from auth config)\n`);
392
- profile.providers[provider].plan = detectedPlan;
393
- }
394
- }
395
-
396
361
  return profile;
397
362
  }
398
363
 
@@ -424,20 +389,37 @@ async function runOnboarding(opts = {}) {
424
389
  try {
425
390
  process.stdout.write('\nDual-Brain Orchestrator — First-time setup\n\n');
426
391
 
427
- const q1 = (await ask('Which AI subscriptions do you have?\n (1) Claude only (2) OpenAI only (3) Both\n> ')).trim();
428
- if (q1 === '2') { profile.providers.claude.enabled = false; profile.providers.openai.enabled = true; }
429
- else if (q1 === '3') { profile.providers.openai.enabled = true; }
392
+ // Detect what's actually available
393
+ const capabilities = await detectCapabilities(opts.cwd);
394
+
395
+ // Show what we found honestly
396
+ const foundProviders = [];
397
+ if (capabilities.claude.available) foundProviders.push('Claude Code');
398
+ if (capabilities.openai.available) foundProviders.push('OpenAI API (metered)');
399
+ if (capabilities.codex.available && !capabilities.openai.available) foundProviders.push('Codex CLI');
430
400
 
431
- const PLANS = { '1': '$20', '2': '$100', '3': '$200' };
432
- for (const [key, prov] of Object.entries(profile.providers)) {
433
- if (!prov.enabled) continue;
434
- const label = key === 'claude' ? 'Claude' : 'OpenAI/ChatGPT';
435
- const q2 = (await ask(`\n${label} tier?\n (1) $20/mo (2) $100/mo (3) $200/mo\n> `)).trim();
436
- prov.plan = PLANS[q2] || '$20';
401
+ if (foundProviders.length > 0) {
402
+ process.stdout.write(`Detected: ${foundProviders.join(', ')}\n\n`);
403
+ } else {
404
+ process.stdout.write('No providers detected automatically.\n\n');
405
+ }
406
+
407
+ // Enable providers based on what's available
408
+ profile.providers.claude.enabled = capabilities.claude.available;
409
+ profile.providers.openai.enabled = capabilities.openai.available || capabilities.codex.available;
410
+ profile.apiGuardrail = needsApiGuardrail(capabilities);
411
+
412
+ // If detection missed something, ask
413
+ if (!capabilities.claude.available && !capabilities.openai.available && !capabilities.codex.available) {
414
+ const q1 = (await ask('Which AI providers do you have access to?\n (1) Claude Code only (2) OpenAI API only (3) Both (4) Neither\n> ')).trim();
415
+ if (q1 === '1') { profile.providers.claude.enabled = true; }
416
+ else if (q1 === '2') { profile.providers.claude.enabled = false; profile.providers.openai.enabled = true; profile.apiGuardrail = true; }
417
+ else if (q1 === '3') { profile.providers.claude.enabled = true; profile.providers.openai.enabled = true; profile.apiGuardrail = true; }
437
418
  }
438
419
 
439
- const q3 = (await ask('\nDefault optimization?\n (1) Save usage (2) Balanced (3) Best quality\n> ')).trim();
420
+ const q3 = (await ask('\nDefault work style?\n (1) Save usage (2) Balanced (3) Best quality\n> ')).trim();
440
421
  profile.bias = ({ '1': 'cost-saver', '3': 'quality-first' })[q3] || 'balanced';
422
+ profile.workStyle = profile.bias;
441
423
 
442
424
  const n = Object.values(profile.providers).filter(p => p.enabled).length;
443
425
  profile.mode = n >= 2 ? 'dual' : profile.providers.claude.enabled ? 'solo-claude' : 'solo-openai';
@@ -507,12 +489,10 @@ function getActivePreferences(cwd) {
507
489
  // Provider helpers
508
490
  // ---------------------------------------------------------------------------
509
491
 
510
- const PLAN_RANK = { '$20': 1, '$100': 2, '$200': 3 };
511
-
512
492
  function getAvailableProviders(profile) {
513
493
  return Object.entries(profile.providers || {})
514
494
  .filter(([, p]) => p.enabled)
515
- .map(([name, p]) => ({ name, plan: p.plan, rank: PLAN_RANK[p.plan] || 1 }));
495
+ .map(([name, p]) => ({ name, ...p }));
516
496
  }
517
497
 
518
498
  function isSoloBrain(profile) {
@@ -523,85 +503,28 @@ function getHeadModel(profile) {
523
503
  const providers = getAvailableProviders(profile);
524
504
  if (providers.length === 0) return 'sonnet';
525
505
  if (providers.length === 1) return providers[0].name === 'openai' ? 'gpt-4o' : 'sonnet';
526
- const top = providers.reduce((a, b) => (b.rank > a.rank ? b : a));
527
- return top.name === 'openai' ? 'gpt-4o' : 'sonnet';
506
+ // Both available default to Claude (we're running in Claude Code)
507
+ return 'sonnet';
528
508
  }
529
509
 
530
510
  // ---------------------------------------------------------------------------
531
- // CLI
532
- // ---------------------------------------------------------------------------
533
-
534
- async function main() {
535
- const args = process.argv.slice(2);
536
- const cwd = process.cwd();
537
- const flag = args[0];
538
- const val = args[1];
539
-
540
- if (flag === '--init') {
541
- const profile = await runOnboarding({ interactive: true });
542
- saveProfile(profile, { cwd });
543
- return;
544
- }
545
- if (flag === '--remember') {
546
- if (!val) { process.stderr.write('Usage: --remember "text"\n'); process.exit(1); }
547
- const p = rememberPreference(val, { cwd });
548
- process.stdout.write(`Preference saved. Total: ${p.preferences.length}\n`);
549
- return;
550
- }
551
- if (flag === '--forget') {
552
- if (!val) { process.stderr.write('Usage: --forget "text"\n'); process.exit(1); }
553
- forgetPreference(val, cwd);
554
- process.stdout.write('Preference removed (if matched).\n');
555
- return;
556
- }
557
- if (flag === '--providers') {
558
- const providers = getAvailableProviders(loadProfile(cwd));
559
- if (!providers.length) { process.stdout.write('No providers enabled.\n'); return; }
560
- providers.forEach(p => process.stdout.write(`${p.name} plan=${p.plan}\n`));
561
- return;
562
- }
563
-
564
- // default: show profile
565
- const profile = loadProfile(cwd);
566
- const providers = getAvailableProviders(profile);
567
- [
568
- `mode : ${profile.mode}`,
569
- `bias : ${profile.bias}`,
570
- `head model : ${getHeadModel(profile)}`,
571
- `providers : ${providers.map(p => `${p.name} (${p.plan})`).join(', ') || 'none'}`,
572
- `prefs : ${profile.preferences?.filter(p => p.enabled).length || 0} active`,
573
- ].forEach(l => process.stdout.write(l + '\n'));
574
- }
575
-
576
- const isMain = process.argv[1]?.endsWith('profile.mjs');
577
- if (isMain) main().catch(e => { process.stderr.write(e.message + '\n'); process.exit(1); });
578
-
579
- // ---------------------------------------------------------------------------
580
- // Exports
581
- // ---------------------------------------------------------------------------
582
-
583
- // ---------------------------------------------------------------------------
584
- // Auto-setup (1-click, no user input required)
511
+ // Capability-based auto-setup (replaces subscription-based autoSetup)
585
512
  // ---------------------------------------------------------------------------
586
513
 
587
514
  /**
588
- * Attempt to configure a profile entirely from detected state — no user input.
515
+ * Silently configure a profile from detected capabilities — no user input.
589
516
  *
590
517
  * Returns:
591
518
  * {
592
519
  * confident: boolean, // true when at least one provider was found
593
520
  * profile: object|null, // fully-built profile ready to save, or null
594
- * warnings: string[], // non-fatal issues (e.g. missing provider)
521
+ * warnings: string[], // non-fatal issues
595
522
  * actions: string[], // human-readable lines for the summary box
596
523
  * }
597
- *
598
- * IMPORTANT: this function NEVER stores credentials — it only reads what's
599
- * already present on disk / in environment variables.
600
524
  */
601
525
  async function autoSetup(cwd) {
602
- const env = detectEnvironment();
603
- const auth = await detectAuth();
604
- const plans = detectPlans();
526
+ const capabilities = await detectCapabilities(cwd);
527
+ const env = detectEnvironment();
605
528
 
606
529
  const result = {
607
530
  confident: false,
@@ -610,47 +533,45 @@ async function autoSetup(cwd) {
610
533
  actions: [],
611
534
  };
612
535
 
613
- // Need at least one provider authenticated
614
- if (!auth.claude.found && !auth.openai.found) {
536
+ // Need at least one provider
537
+ if (!capabilities.claude.available && !capabilities.openai.available && !capabilities.codex.available) {
615
538
  result.warnings.push('No provider credentials found');
616
539
  return result;
617
540
  }
618
541
 
619
- // Build profile from detected state
620
542
  const profile = defaultProfile();
621
543
 
622
544
  // Claude
623
- if (auth.claude.found) {
545
+ if (capabilities.claude.available) {
624
546
  profile.providers.claude.enabled = true;
625
- profile.providers.claude.plan = plans.claude || '$20';
626
- // Plan tier is inferred from auth config signal — show tier with "configured",
627
- // not a plan name we didn't actually detect.
628
- const claudeTierLabel = plans.claude ? `${plans.claude} configured` : 'connected';
629
- result.actions.push(`Claude: ${claudeTierLabel} (${auth.claude.source})`);
547
+ result.actions.push(`Claude: available (${capabilities.claude.source})`);
630
548
  } else {
631
549
  profile.providers.claude.enabled = false;
632
- result.warnings.push('Claude CLI not logged in run: claude login');
550
+ result.warnings.push('Claude not detectedinstall Claude Code or set ANTHROPIC_API_KEY');
633
551
  }
634
552
 
635
- // OpenAI
636
- if (auth.openai.found) {
553
+ // OpenAI / Codex
554
+ if (capabilities.openai.available) {
555
+ profile.providers.openai.enabled = true;
556
+ result.actions.push('OpenAI: API key detected (metered billing — guardrails enabled)');
557
+ } else if (capabilities.codex.available) {
637
558
  profile.providers.openai.enabled = true;
638
- profile.providers.openai.plan = plans.openai || '$20';
639
- // Plan tier is inferred from JWT claim in auth config — show tier with "configured",
640
- // not a plan name we didn't actually detect.
641
- const openaiTierLabel = plans.openai ? `${plans.openai} configured` : 'connected';
642
- result.actions.push(`OpenAI: ${openaiTierLabel} (${auth.openai.source})`);
559
+ result.actions.push('Codex CLI: available');
643
560
  } else {
644
561
  profile.providers.openai.enabled = false;
645
- result.warnings.push('Codex CLI not logged in run: codex login');
562
+ result.warnings.push('OpenAI not detectedadd OPENAI_API_KEY or install Codex CLI');
646
563
  }
647
564
 
648
565
  // Mode
649
- const enabledCount = [auth.claude.found, auth.openai.found].filter(Boolean).length;
566
+ const enabledCount = Object.values(profile.providers).filter(p => p.enabled).length;
650
567
  profile.mode = enabledCount >= 2 ? 'dual'
651
- : auth.claude.found ? 'solo-claude'
568
+ : profile.providers.claude.enabled ? 'solo-claude'
652
569
  : 'solo-openai';
653
570
  profile.bias = 'balanced';
571
+ profile.workStyle = 'balanced';
572
+ profile.apiGuardrail = needsApiGuardrail(capabilities);
573
+ profile.capabilities = capabilities;
574
+ profile.detectedAt = new Date().toISOString();
654
575
 
655
576
  // Environment note
656
577
  if (env.isReplit && env.hasReplitTools) {
@@ -665,13 +586,11 @@ async function autoSetup(cwd) {
665
586
  }
666
587
 
667
588
  // ---------------------------------------------------------------------------
668
- // OAuth token auto-refresh
589
+ // OAuth token auto-refresh (unchanged — token refresh is still valid)
669
590
  // ---------------------------------------------------------------------------
670
591
 
671
592
  /**
672
593
  * Silently refresh the Claude OAuth token before it expires.
673
- * Mirrors the approach used by replit-tools/data-tools claude-auth-refresh.sh,
674
- * but implemented in JavaScript.
675
594
  *
676
595
  * Returns one of:
677
596
  * { status: 'valid', hoursRemaining }
@@ -751,240 +670,193 @@ async function autoRefreshToken(cwd) {
751
670
  }
752
671
 
753
672
  // ---------------------------------------------------------------------------
754
- // detectExistingAuthsilent onboarding scan
673
+ // detectAuthkept for backward compat, now delegates to detectCapabilities
755
674
  // ---------------------------------------------------------------------------
756
675
 
757
676
  /**
758
- * Run a CLI command with a timeout, returning stdout as a string.
759
- * Resolves with null on timeout, error, or non-zero exit.
760
- * @param {string} cmd
761
- * @param {string[]} args
762
- * @param {number} timeoutMs
763
- * @returns {Promise<string|null>}
764
- */
765
- function _runWithTimeout(cmd, args, timeoutMs) {
766
- return new Promise(resolve => {
767
- let settled = false;
768
- const done = (val) => { if (!settled) { settled = true; resolve(val); } };
769
-
770
- let child;
771
- try {
772
- child = execFile(cmd, args, { timeout: timeoutMs, windowsHide: true }, (err, stdout) => {
773
- done(err ? null : (stdout || '').trim());
774
- });
775
- } catch {
776
- done(null);
777
- return;
778
- }
779
-
780
- // Belt-and-suspenders timeout fallback
781
- const timer = setTimeout(() => {
782
- try { child.kill('SIGTERM'); } catch {}
783
- done(null);
784
- }, timeoutMs + 500);
785
-
786
- if (child?.on) {
787
- child.on('close', () => clearTimeout(timer));
788
- }
789
- });
790
- }
791
-
792
- /**
793
- * Derive a human-readable plan label from a plan tier string.
794
- * @param {'claude'|'openai'} provider
795
- * @param {string} plan e.g. '$20' | '$100' | '$200'
796
- */
797
- function _planLabel(provider, plan) {
798
- const labels = {
799
- claude: { '$20': 'Claude Pro ($20)', '$100': 'Claude Max x5 ($100)', '$200': 'Claude Max x20 ($200)' },
800
- openai: { '$20': 'ChatGPT Plus ($20)', '$100': 'ChatGPT Pro ($100)', '$200': 'ChatGPT Pro ($200)' },
801
- };
802
- return labels[provider]?.[plan] ?? `${provider} ${plan}`;
803
- }
804
-
805
- /**
806
- * Silently scan for existing auth from all known sources and return what was
807
- * found, together with smart setup recommendations.
808
- *
809
- * Checks (in order, all non-throwing):
810
- * 1. data-tools / replit-tools — ~/.claude/credentials.json or
811
- * .replit-tools/.claude-persistent/.credentials.json for a session key
812
- * 2. Claude CLI — `claude auth status` with 3 s timeout
813
- * 3. Codex CLI — `codex auth status` with 3 s timeout or
814
- * ~/.codex/ config files
815
- * 4. Existing dual-brain config — .dualbrain/profile.json
816
- *
817
- * Returns:
818
- * {
819
- * claude: { found: boolean, source: string|null, plan: string|null, expiresAt: string|null },
820
- * openai: { found: boolean, source: string|null, plan: string|null },
821
- * existingProfile: boolean,
822
- * recommendations: { headModel: string, budget: string, profile: string },
823
- * }
677
+ * Detect CLI login status for Claude and Codex.
678
+ * Checks config files on disk never makes network calls.
824
679
  *
825
- * @param {string} [cwd]
680
+ * @returns {{ claude: AuthEntry, openai: AuthEntry }}
681
+ * @typedef {{ found: boolean, source: string|null, loginType: 'oauth'|'cli'|null }} AuthEntry
826
682
  */
827
- async function detectExistingAuth(cwd) {
828
- const home = homedir();
829
- const root = cwd || process.cwd();
830
-
831
- // -------------------------------------------------------------------------
832
- // Result skeleton
833
- // -------------------------------------------------------------------------
834
- const result = {
835
- claude: { found: false, source: null, plan: null, expiresAt: null },
836
- openai: { found: false, source: null, plan: null },
837
- existingProfile: false,
838
- recommendations: { headModel: 'claude-sonnet-4-6', budget: '$20', profile: 'balanced' },
683
+ async function detectAuth() {
684
+ const results = {
685
+ claude: { found: false, source: null, loginType: null },
686
+ openai: { found: false, source: null, loginType: null },
839
687
  };
840
688
 
841
- // -------------------------------------------------------------------------
842
- // 1. data-tools / replit-tools — credentials.json session key
843
- // -------------------------------------------------------------------------
844
- const credPaths = [
845
- join(root, '.replit-tools', '.claude-persistent', '.credentials.json'),
846
- join(home, '.claude', '.credentials.json'),
847
- // legacy replit persistent path
848
- '/home/runner/workspace/.replit-tools/.claude-persistent/.credentials.json',
689
+ // --- Claude: check .claude.json for oauthAccount (CLI login) ---
690
+ const claudePaths = [
691
+ '/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
692
+ join(homedir(), '.claude', '.claude.json'),
849
693
  ];
850
- for (const credPath of credPaths) {
694
+ for (const p of claudePaths) {
851
695
  try {
852
- const creds = JSON.parse(readFileSync(credPath, 'utf8'));
853
- const oauth = creds?.claudeAiOauth;
854
- if (oauth?.accessToken || oauth?.sessionKey) {
855
- result.claude.found = true;
856
- result.claude.source = credPath.includes('.replit-tools') ? 'data-tools' : 'credentials.json';
857
- // Expiry
858
- if (oauth.expiresAt) {
859
- try { result.claude.expiresAt = new Date(oauth.expiresAt).toISOString(); } catch {}
860
- }
696
+ const data = JSON.parse(readFileSync(p, 'utf8'));
697
+ if (data?.oauthAccount) {
698
+ results.claude.found = true;
699
+ results.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
700
+ results.claude.loginType = 'oauth';
861
701
  break;
862
702
  }
863
- } catch { /* non-fatal */ }
864
- }
865
-
866
- // -------------------------------------------------------------------------
867
- // 2. Claude CLI auth detection (config files + `claude auth status`)
868
- // -------------------------------------------------------------------------
869
- if (!result.claude.found) {
870
- // Config-file scan (same paths as detectAuth)
871
- const claudeConfigPaths = [
872
- join(root, '.replit-tools', '.claude-persistent', '.claude.json'),
873
- '/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
874
- join(home, '.claude', '.claude.json'),
875
- ];
876
- for (const p of claudeConfigPaths) {
877
- try {
878
- const data = JSON.parse(readFileSync(p, 'utf8'));
879
- if (data?.oauthAccount || (data?.apiKey && typeof data.apiKey === 'string')) {
880
- result.claude.found = true;
881
- result.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
882
- break;
883
- }
884
- } catch { /* non-fatal */ }
885
- }
886
-
887
- // CLI fallback: `claude auth status`
888
- if (!result.claude.found) {
889
- const out = await _runWithTimeout('claude', ['auth', 'status'], 3000);
890
- if (out && /logged.in|authenticated|signed.in/i.test(out)) {
891
- result.claude.found = true;
892
- result.claude.source = 'claude CLI (auth status)';
703
+ if (data?.apiKey && typeof data.apiKey === 'string') {
704
+ results.claude.found = true;
705
+ results.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
706
+ results.claude.loginType = 'cli';
707
+ break;
893
708
  }
894
- }
709
+ } catch { continue; }
895
710
  }
896
711
 
897
- // -------------------------------------------------------------------------
898
- // 3. Codex CLI / OpenAI auth detection
899
- // -------------------------------------------------------------------------
900
- const codexConfigPaths = [
901
- join(root, '.replit-tools', '.codex-persistent', 'auth.json'),
712
+ // --- OpenAI/Codex: check auth.json for access_token or id_token (CLI login) ---
713
+ const codexPaths = [
902
714
  '/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
903
- join(home, '.codex', 'auth.json'),
715
+ join(homedir(), '.codex', 'auth.json'),
904
716
  ];
905
- for (const p of codexConfigPaths) {
717
+ for (const p of codexPaths) {
906
718
  try {
907
- const data = JSON.parse(readFileSync(p, 'utf8'));
719
+ const data = JSON.parse(readFileSync(p, 'utf8'));
908
720
  const accessToken = data?.tokens?.access_token || data?.access_token;
909
721
  const idToken = data?.tokens?.id_token || data?.id_token;
722
+
910
723
  if (accessToken || idToken) {
911
- result.openai.found = true;
912
- result.openai.source = p.includes('.replit-tools') ? 'codex CLI (replit-tools)' : 'codex CLI';
724
+ results.openai.found = true;
725
+ results.openai.source = p.includes('.replit-tools') ? 'codex CLI (replit-tools)' : 'codex CLI';
726
+ results.openai.loginType = 'oauth';
913
727
  break;
914
728
  }
915
- } catch { /* non-fatal */ }
729
+ } catch { continue; }
916
730
  }
917
731
 
918
- // CLI fallback: `codex auth status`
919
- if (!result.openai.found) {
920
- const out = await _runWithTimeout('codex', ['auth', 'status'], 3000);
921
- if (out && /logged.in|authenticated|signed.in/i.test(out)) {
922
- result.openai.found = true;
923
- result.openai.source = 'codex CLI (auth status)';
924
- }
925
- }
732
+ return results;
733
+ }
926
734
 
927
- // -------------------------------------------------------------------------
928
- // 4. Existing dual-brain profile
929
- // -------------------------------------------------------------------------
930
- for (const p of [projectPath(root), GLOBAL_PATH]) {
931
- if (existsSync(p)) {
932
- result.existingProfile = true;
933
- break;
934
- }
935
- }
735
+ // ---------------------------------------------------------------------------
736
+ // Removed: detectExistingAuth, detectPlans, decodeJwtPayload, saveSubscription,
737
+ // listSubscriptions, _planLabel, _runWithTimeout
738
+ // These claimed to detect subscription tier/price from auth files — that was
739
+ // never accurate. Use detectCapabilities() instead for honest detection.
740
+ // ---------------------------------------------------------------------------
936
741
 
937
- // -------------------------------------------------------------------------
938
- // Plan tier inference (re-uses detectPlans which reads auth config files)
939
- // NOTE: This is NOT subscription detection — we infer a price tier ($20/$100/$200)
940
- // from rate-limit tier signals in the auth config (organizationRateLimitTier for
941
- // Claude, JWT chatgpt_plan_type for OpenAI). The CLI does not report the actual
942
- // plan name or price. Any plan label shown to the user comes from our own mapping.
943
- // -------------------------------------------------------------------------
944
- const plans = detectPlans();
945
- if (result.claude.found && plans.claude) result.claude.plan = plans.claude;
946
- if (result.openai.found && plans.openai) result.openai.plan = plans.openai;
947
-
948
- // -------------------------------------------------------------------------
949
- // Smart recommendations
950
- // -------------------------------------------------------------------------
951
- const claudeRank = PLAN_RANK[result.claude.plan] || 0;
952
- const openaiRank = PLAN_RANK[result.openai.plan] || 0;
953
-
954
- if (result.claude.found && !result.openai.found) {
955
- // Solo Claude
956
- result.recommendations.headModel = 'claude-sonnet-4-6';
957
- result.recommendations.budget = result.claude.plan || '$20';
958
- result.recommendations.profile = claudeRank >= 2 ? 'quality-first' : 'balanced';
959
- } else if (result.openai.found && !result.claude.found) {
960
- // Solo OpenAI
961
- result.recommendations.headModel = 'gpt-4o';
962
- result.recommendations.budget = result.openai.plan || '$20';
963
- result.recommendations.profile = openaiRank >= 2 ? 'quality-first' : 'balanced';
964
- } else if (result.claude.found && result.openai.found) {
965
- // Both available — higher-ranked provider drives HEAD model
966
- if (openaiRank > claudeRank) {
967
- result.recommendations.headModel = 'gpt-4o';
968
- } else {
969
- result.recommendations.headModel = 'claude-sonnet-4-6';
970
- }
971
- const topPlan = openaiRank >= claudeRank ? result.openai.plan : result.claude.plan;
972
- result.recommendations.budget = topPlan || '$20';
973
- const topRank = Math.max(claudeRank, openaiRank);
974
- result.recommendations.profile = topRank >= 2 ? 'quality-first' : 'balanced';
742
+ // Thin stubs retained only so any callers that weren't updated yet
743
+ // fail gracefully with a clear message rather than a crash.
744
+
745
+ /** @deprecated Use detectCapabilities() instead. */
746
+ async function detectExistingAuth(cwd) {
747
+ const caps = await detectCapabilities(cwd);
748
+ return {
749
+ claude: {
750
+ found: caps.claude.available,
751
+ source: caps.claude.source,
752
+ plan: null, // not detectable
753
+ expiresAt: null,
754
+ },
755
+ openai: {
756
+ found: caps.openai.available || caps.codex.available,
757
+ source: caps.openai.source || caps.codex.source,
758
+ plan: null, // not detectable
759
+ },
760
+ existingProfile: [projectPath(cwd), GLOBAL_PATH].some(p => existsSync(p)),
761
+ recommendations: {
762
+ headModel: caps.claude.available ? 'claude-sonnet-4-6' : 'gpt-4o',
763
+ // budget field removed we don't track subscription price
764
+ profile: 'balanced',
765
+ },
766
+ };
767
+ }
768
+
769
+ /** @deprecated Price-based plan tiers removed. Returns null for all providers. */
770
+ function detectPlans() {
771
+ return { claude: null, openai: null };
772
+ }
773
+
774
+ /** @deprecated Plan tracking removed. Use provider enabled flag instead. */
775
+ function saveSubscription(provider, config, cwd) {
776
+ const profile = loadProfile(cwd);
777
+ if (!profile.providers[provider]) profile.providers[provider] = { enabled: true };
778
+ profile.providers[provider].enabled = true;
779
+ saveProfile(profile, { cwd: cwd || process.cwd() });
780
+ return profile;
781
+ }
782
+
783
+ /** @deprecated Plan tracking removed. Use getAvailableProviders() instead. */
784
+ function listSubscriptions(cwd) {
785
+ const profile = loadProfile(cwd);
786
+ return profile.providers || {};
787
+ }
788
+
789
+ // ---------------------------------------------------------------------------
790
+ // CLI
791
+ // ---------------------------------------------------------------------------
792
+
793
+ async function main() {
794
+ const args = process.argv.slice(2);
795
+ const cwd = process.cwd();
796
+ const flag = args[0];
797
+ const val = args[1];
798
+
799
+ if (flag === '--init') {
800
+ const profile = await runOnboarding({ interactive: true, cwd });
801
+ saveProfile(profile, { cwd });
802
+ return;
803
+ }
804
+ if (flag === '--remember') {
805
+ if (!val) { process.stderr.write('Usage: --remember "text"\n'); process.exit(1); }
806
+ const p = rememberPreference(val, { cwd });
807
+ process.stdout.write(`Preference saved. Total: ${p.preferences.length}\n`);
808
+ return;
809
+ }
810
+ if (flag === '--forget') {
811
+ if (!val) { process.stderr.write('Usage: --forget "text"\n'); process.exit(1); }
812
+ forgetPreference(val, cwd);
813
+ process.stdout.write('Preference removed (if matched).\n');
814
+ return;
815
+ }
816
+ if (flag === '--providers') {
817
+ const providers = getAvailableProviders(loadProfile(cwd));
818
+ if (!providers.length) { process.stdout.write('No providers enabled.\n'); return; }
819
+ providers.forEach(p => process.stdout.write(`${p.name} enabled=${p.enabled}\n`));
820
+ return;
821
+ }
822
+ if (flag === '--capabilities') {
823
+ const caps = await detectCapabilities(cwd);
824
+ process.stdout.write(JSON.stringify(caps, null, 2) + '\n');
825
+ return;
975
826
  }
976
- // else: no auth found — defaults remain (claude-sonnet-4-6 / $20 / balanced)
977
827
 
978
- return result;
828
+ // default: show profile
829
+ const profile = loadProfile(cwd);
830
+ const providers = getAvailableProviders(profile);
831
+ const caps = await detectCapabilities(cwd);
832
+ [
833
+ `mode : ${profile.mode}`,
834
+ `workStyle : ${profile.workStyle || profile.bias}`,
835
+ `head model : ${getHeadModel(profile)}`,
836
+ `providers : ${providers.map(p => p.name).join(', ') || 'none'}`,
837
+ `prefs : ${profile.preferences?.filter(p => p.enabled).length || 0} active`,
838
+ `guardrail : ${needsApiGuardrail(caps) ? 'enabled (metered API key detected)' : 'off'}`,
839
+ '',
840
+ getOnboardingMessage(caps, profile.workStyle || profile.bias),
841
+ ].forEach(l => process.stdout.write(l + '\n'));
979
842
  }
980
843
 
844
+ const isMain = process.argv[1]?.endsWith('profile.mjs');
845
+ if (isMain) main().catch(e => { process.stderr.write(e.message + '\n'); process.exit(1); });
846
+
847
+ // ---------------------------------------------------------------------------
848
+ // Exports
849
+ // ---------------------------------------------------------------------------
850
+
981
851
  export {
982
852
  loadProfile, saveProfile, ensureProfile, runOnboarding,
983
853
  rememberPreference, forgetPreference, getActivePreferences,
984
854
  getAvailableProviders, isSoloBrain, getHeadModel,
985
- detectPlans, syncPreferencesToMemory,
855
+ detectCapabilities, getOnboardingMessage, needsApiGuardrail,
856
+ syncPreferencesToMemory,
986
857
  detectAuth, detectEnvironment,
987
- saveSubscription, listSubscriptions,
988
- defaultProfile, autoSetup, autoRefreshToken,
989
- detectExistingAuth,
858
+ autoSetup, autoRefreshToken,
859
+ // backward-compat stubs (deprecated)
860
+ detectExistingAuth, detectPlans, saveSubscription, listSubscriptions,
861
+ defaultProfile,
990
862
  };