dual-brain 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +97 -0
- package/CLAUDE.md +147 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/agents/implementer.md +22 -0
- package/agents/researcher.md +25 -0
- package/agents/verifier.md +30 -0
- package/bin/dual-brain.mjs +2868 -0
- package/hooks/auto-update-wrapper.mjs +102 -0
- package/hooks/auto-update.sh +67 -0
- package/hooks/budget-balancer.mjs +679 -0
- package/hooks/control-panel.mjs +1195 -0
- package/hooks/cost-logger.mjs +286 -0
- package/hooks/cost-report.mjs +351 -0
- package/hooks/decision-ledger.mjs +299 -0
- package/hooks/dual-brain-review.mjs +404 -0
- package/hooks/dual-brain-think.mjs +393 -0
- package/hooks/enforce-tier.mjs +469 -0
- package/hooks/failure-detector.mjs +138 -0
- package/hooks/gpt-work-dispatcher.mjs +512 -0
- package/hooks/head-guard.mjs +105 -0
- package/hooks/health-check.mjs +444 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/model-registry.mjs +859 -0
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +254 -0
- package/hooks/quality-gate.mjs +355 -0
- package/hooks/risk-classifier.mjs +41 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/summary-checkpoint.mjs +432 -0
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/test-orchestrator.mjs +1077 -0
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +387 -0
- package/hooks/wave-orchestrator.mjs +1397 -0
- package/install.mjs +1541 -0
- package/mcp-server/README.md +81 -0
- package/mcp-server/index.mjs +388 -0
- package/orchestrator.json +215 -0
- package/package.json +108 -0
- package/playbooks/debug.json +49 -0
- package/playbooks/refactor.json +57 -0
- package/playbooks/security-audit.json +57 -0
- package/playbooks/security.json +38 -0
- package/playbooks/test-gen.json +48 -0
- package/plugin.json +22 -0
- package/review-rules.md +17 -0
- package/shell-hook.sh +26 -0
- package/skills/go.md +22 -0
- package/skills/review.md +19 -0
- package/skills/status.md +13 -0
- package/skills/think.md +22 -0
- package/src/brief.mjs +266 -0
- package/src/decide.mjs +635 -0
- package/src/decompose.mjs +331 -0
- package/src/detect.mjs +345 -0
- package/src/dispatch.mjs +942 -0
- package/src/health.mjs +253 -0
- package/src/index.mjs +44 -0
- package/src/install-hooks.mjs +100 -0
- package/src/playbook.mjs +257 -0
- package/src/profile.mjs +990 -0
- package/src/redact.mjs +192 -0
- package/src/repo.mjs +292 -0
- package/src/session.mjs +1036 -0
- package/src/tui.mjs +197 -0
- package/src/update-check.mjs +35 -0
package/src/profile.mjs
ADDED
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* profile.mjs — User profile module for the Dual-Brain Orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Exported API:
|
|
6
|
+
* loadProfile(cwd) → profile (or defaults)
|
|
7
|
+
* saveProfile(profile, opts) → write project or global file
|
|
8
|
+
* ensureProfile(cwd, opts) → load or onboard
|
|
9
|
+
* runOnboarding(opts) → interactive 3-question setup
|
|
10
|
+
* rememberPreference(text, opts) → add/update preference
|
|
11
|
+
* forgetPreference(text, cwd) → remove preference by fuzzy match
|
|
12
|
+
* getActivePreferences(cwd) → enabled global + project preferences
|
|
13
|
+
* getAvailableProviders(profile) → enabled providers with plan info
|
|
14
|
+
* isSoloBrain(profile) → true if only one provider enabled
|
|
15
|
+
* getHeadModel(profile) → suggested head model string
|
|
16
|
+
*
|
|
17
|
+
* CLI:
|
|
18
|
+
* node src/profile.mjs # show current profile
|
|
19
|
+
* node src/profile.mjs --init # run onboarding
|
|
20
|
+
* node src/profile.mjs --remember "…" # add preference
|
|
21
|
+
* node src/profile.mjs --forget "…" # remove preference
|
|
22
|
+
* node src/profile.mjs --providers # show available providers
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createInterface } from 'readline';
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
27
|
+
import { homedir } from 'os';
|
|
28
|
+
import { join } from 'path';
|
|
29
|
+
import { execFile } from 'child_process';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Claude Code memory integration
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const MEMORY_FILE_NAME = 'dual_brain_preferences.md';
|
|
36
|
+
const MEMORY_INDEX_ENTRY =
|
|
37
|
+
'- [Dual-brain preferences](dual_brain_preferences.md) — Active routing preferences for model/provider selection';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Derive the Claude Code memory directory for the given project root.
|
|
41
|
+
* Returns null when the directory doesn't exist (i.e. not running on Replit).
|
|
42
|
+
*/
|
|
43
|
+
function _memoryDir(cwd) {
|
|
44
|
+
const root = cwd || process.cwd();
|
|
45
|
+
// Replit persistent memory lives at a fixed path derived from the workspace root.
|
|
46
|
+
// Convert e.g. /home/runner/workspace → -home-runner-workspace
|
|
47
|
+
const encoded = root.replace(/\//g, '-');
|
|
48
|
+
const candidate = join(
|
|
49
|
+
root,
|
|
50
|
+
'.replit-tools',
|
|
51
|
+
'.claude-persistent',
|
|
52
|
+
'projects',
|
|
53
|
+
encoded,
|
|
54
|
+
'memory',
|
|
55
|
+
);
|
|
56
|
+
return existsSync(candidate) ? candidate : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write (or update) the dual_brain_preferences.md file in the Claude Code
|
|
61
|
+
* memory directory, and ensure MEMORY.md has an index entry for it.
|
|
62
|
+
* Fails silently if the memory directory is absent or unwritable.
|
|
63
|
+
*/
|
|
64
|
+
function syncPreferencesToMemory(profile, cwd) {
|
|
65
|
+
try {
|
|
66
|
+
const memDir = _memoryDir(cwd);
|
|
67
|
+
if (!memDir) return; // not on Replit / memory dir missing — skip silently
|
|
68
|
+
|
|
69
|
+
const prefs = (profile.preferences || []).filter(p => p.enabled);
|
|
70
|
+
|
|
71
|
+
// Build markdown body
|
|
72
|
+
const prefLines = prefs.length
|
|
73
|
+
? prefs.map(p => `- ${p.text} (scope: ${p.scope || 'project'})`).join('\n')
|
|
74
|
+
: '_(no active preferences)_';
|
|
75
|
+
|
|
76
|
+
const content = [
|
|
77
|
+
'---',
|
|
78
|
+
'name: dual-brain-preferences',
|
|
79
|
+
'description: Active dual-brain routing preferences — affects model selection, provider choice, and dual-brain consensus',
|
|
80
|
+
'metadata:',
|
|
81
|
+
' type: project',
|
|
82
|
+
'---',
|
|
83
|
+
'',
|
|
84
|
+
'Active dual-brain preferences:',
|
|
85
|
+
'',
|
|
86
|
+
prefLines,
|
|
87
|
+
'',
|
|
88
|
+
'These preferences are enforced by the dual-brain orchestrator routing engine.',
|
|
89
|
+
'Provider routing, model selection, and dual-brain consensus decisions',
|
|
90
|
+
'respect these preferences automatically via src/decide.mjs.',
|
|
91
|
+
'',
|
|
92
|
+
].join('\n');
|
|
93
|
+
|
|
94
|
+
const prefFile = join(memDir, MEMORY_FILE_NAME);
|
|
95
|
+
writeFileSync(prefFile, content, 'utf8');
|
|
96
|
+
|
|
97
|
+
// Update MEMORY.md index — add entry only if not already present
|
|
98
|
+
const indexFile = join(memDir, 'MEMORY.md');
|
|
99
|
+
if (existsSync(indexFile)) {
|
|
100
|
+
const existing = readFileSync(indexFile, 'utf8');
|
|
101
|
+
if (!existing.includes(MEMORY_FILE_NAME)) {
|
|
102
|
+
writeFileSync(indexFile, existing.trimEnd() + '\n' + MEMORY_INDEX_ENTRY + '\n', 'utf8');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Non-fatal — the profile JSON remains the source of truth
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Environment detection
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Detect the runtime environment.
|
|
116
|
+
* Returns { isReplit, hasReplitTools, isCI }.
|
|
117
|
+
*/
|
|
118
|
+
function detectEnvironment() {
|
|
119
|
+
const isReplit = !!(process.env.REPL_ID || process.env.REPLIT_DB_URL);
|
|
120
|
+
const hasReplitTools = existsSync(join(process.cwd(), '.replit-tools'));
|
|
121
|
+
const isCI = !!(process.env.CI || process.env.GITHUB_ACTIONS);
|
|
122
|
+
return { isReplit, hasReplitTools, isCI };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Auth detection
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Detect CLI login status for Claude and Codex.
|
|
131
|
+
* Checks config files on disk — never makes network calls.
|
|
132
|
+
*
|
|
133
|
+
* @returns {{ claude: AuthEntry, openai: AuthEntry }}
|
|
134
|
+
* @typedef {{ found: boolean, source: string|null, loginType: 'oauth'|'cli'|null }} AuthEntry
|
|
135
|
+
*/
|
|
136
|
+
async function detectAuth() {
|
|
137
|
+
const results = {
|
|
138
|
+
claude: { found: false, source: null, loginType: null },
|
|
139
|
+
openai: { found: false, source: null, loginType: null },
|
|
140
|
+
};
|
|
141
|
+
|
|
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; }
|
|
164
|
+
}
|
|
165
|
+
|
|
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;
|
|
176
|
+
|
|
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; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return results;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Subscription management (.dualbrain/profile.json)
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
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
|
+
}
|
|
209
|
+
|
|
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 || {};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Auto-detect subscription plans from provider config files
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Decode a JWT payload without verifying the signature.
|
|
226
|
+
* Returns the payload object, or null on failure.
|
|
227
|
+
* @param {string} token
|
|
228
|
+
*/
|
|
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
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
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.
|
|
246
|
+
*
|
|
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.
|
|
251
|
+
*/
|
|
252
|
+
function detectPlans() {
|
|
253
|
+
const plans = { claude: null, openai: null };
|
|
254
|
+
|
|
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; }
|
|
272
|
+
}
|
|
273
|
+
|
|
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'));
|
|
283
|
+
|
|
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
|
+
}
|
|
298
|
+
|
|
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; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return plans;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Paths & defaults
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
const GLOBAL_DIR = join(homedir(), '.config', 'dual-brain');
|
|
317
|
+
const GLOBAL_PATH = join(GLOBAL_DIR, 'profile.json');
|
|
318
|
+
const projectPath = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'profile.json');
|
|
319
|
+
|
|
320
|
+
function defaultProfile() {
|
|
321
|
+
const now = new Date().toISOString();
|
|
322
|
+
return {
|
|
323
|
+
schemaVersion: 1,
|
|
324
|
+
createdAt: now,
|
|
325
|
+
updatedAt: now,
|
|
326
|
+
providers: {
|
|
327
|
+
claude: { plan: '$20', enabled: true },
|
|
328
|
+
openai: { plan: '$20', enabled: false },
|
|
329
|
+
},
|
|
330
|
+
mode: 'auto',
|
|
331
|
+
bias: 'balanced',
|
|
332
|
+
preferences: [],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Schema migration
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
function migrateProfile(profile) {
|
|
341
|
+
// v5.x compat: convert old `subscriptions` field to `providers`
|
|
342
|
+
if (profile.subscriptions && !profile.providers) {
|
|
343
|
+
profile.providers = {};
|
|
344
|
+
for (const [key, sub] of Object.entries(profile.subscriptions)) {
|
|
345
|
+
profile.providers[key] = {
|
|
346
|
+
plan: sub.plan || '$20',
|
|
347
|
+
enabled: true,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
delete profile.subscriptions;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!profile.schemaVersion || profile.schemaVersion < 1) {
|
|
354
|
+
// v0 → v1: add missing fields with defaults
|
|
355
|
+
profile.schemaVersion = 1;
|
|
356
|
+
profile.mode = profile.mode || 'auto';
|
|
357
|
+
profile.bias = profile.bias || 'balanced';
|
|
358
|
+
profile.preferences = profile.preferences || [];
|
|
359
|
+
profile.providers = profile.providers || {};
|
|
360
|
+
}
|
|
361
|
+
// Future migrations go here:
|
|
362
|
+
// if (profile.schemaVersion < 2) { ... profile.schemaVersion = 2; }
|
|
363
|
+
return profile;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Load / save
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
function loadProfile(cwd) {
|
|
371
|
+
let profile;
|
|
372
|
+
for (const p of [projectPath(cwd), GLOBAL_PATH]) {
|
|
373
|
+
if (existsSync(p)) {
|
|
374
|
+
try { profile = migrateProfile(JSON.parse(readFileSync(p, 'utf8'))); break; } catch { /* skip */ }
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
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
|
+
return profile;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function saveProfile(profile, opts = {}) {
|
|
400
|
+
const target = opts.global ? GLOBAL_PATH : projectPath(opts.cwd);
|
|
401
|
+
const dir = target.slice(0, target.lastIndexOf('/'));
|
|
402
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
403
|
+
profile.updatedAt = new Date().toISOString();
|
|
404
|
+
const tmp = target + '.tmp.' + process.pid;
|
|
405
|
+
writeFileSync(tmp, JSON.stringify(profile, null, 2) + '\n');
|
|
406
|
+
renameSync(tmp, target);
|
|
407
|
+
return target;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// Onboarding
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
async function runOnboarding(opts = {}) {
|
|
415
|
+
if (!opts.interactive) return defaultProfile();
|
|
416
|
+
|
|
417
|
+
// Accept an externally-provided readline instance (shared with REPL/auth setup)
|
|
418
|
+
// or create one internally if not provided. Only close if we created it.
|
|
419
|
+
const rlProvided = !!opts.rl;
|
|
420
|
+
const rl = opts.rl || createInterface({ input: process.stdin, output: process.stdout });
|
|
421
|
+
const ask = (q) => new Promise(res => rl.question(q, res));
|
|
422
|
+
const profile = defaultProfile();
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
process.stdout.write('\nDual-Brain Orchestrator — First-time setup\n\n');
|
|
426
|
+
|
|
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; }
|
|
430
|
+
|
|
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';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const q3 = (await ask('\nDefault optimization?\n (1) Save usage (2) Balanced (3) Best quality\n> ')).trim();
|
|
440
|
+
profile.bias = ({ '1': 'cost-saver', '3': 'quality-first' })[q3] || 'balanced';
|
|
441
|
+
|
|
442
|
+
const n = Object.values(profile.providers).filter(p => p.enabled).length;
|
|
443
|
+
profile.mode = n >= 2 ? 'dual' : profile.providers.claude.enabled ? 'solo-claude' : 'solo-openai';
|
|
444
|
+
process.stdout.write('\nProfile saved.\n');
|
|
445
|
+
} finally {
|
|
446
|
+
// Only close if we created the rl instance (not if it was passed in)
|
|
447
|
+
if (!rlProvided) rl.close();
|
|
448
|
+
}
|
|
449
|
+
return profile;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function ensureProfile(cwd, opts = {}) {
|
|
453
|
+
for (const p of [projectPath(cwd), GLOBAL_PATH]) {
|
|
454
|
+
if (existsSync(p)) {
|
|
455
|
+
try { return migrateProfile(JSON.parse(readFileSync(p, 'utf8'))); } catch { /* skip */ }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const profile = await runOnboarding(opts);
|
|
459
|
+
saveProfile(profile, { cwd, global: opts.global });
|
|
460
|
+
return profile;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// Preferences
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
const VALID_SCOPES = ['one-off', 'project', 'global'];
|
|
468
|
+
|
|
469
|
+
function rememberPreference(text, opts = {}) {
|
|
470
|
+
const scope = VALID_SCOPES.includes(opts.scope) ? opts.scope : 'project';
|
|
471
|
+
const cwd = opts.cwd || process.cwd();
|
|
472
|
+
const profile = loadProfile(cwd);
|
|
473
|
+
const needle = text.toLowerCase();
|
|
474
|
+
const idx = profile.preferences.findIndex(p =>
|
|
475
|
+
p.text.toLowerCase().includes(needle) || needle.includes(p.text.toLowerCase()));
|
|
476
|
+
if (idx >= 0) profile.preferences[idx] = { text, enabled: true, scope };
|
|
477
|
+
else profile.preferences.push({ text, enabled: true, scope });
|
|
478
|
+
saveProfile(profile, { cwd, global: opts.global || scope === 'global' });
|
|
479
|
+
syncPreferencesToMemory(profile, cwd);
|
|
480
|
+
return profile;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function forgetPreference(text, cwd) {
|
|
484
|
+
const profile = loadProfile(cwd);
|
|
485
|
+
const needle = text.toLowerCase();
|
|
486
|
+
profile.preferences = profile.preferences.filter(p => !p.text.toLowerCase().includes(needle));
|
|
487
|
+
saveProfile(profile, { cwd });
|
|
488
|
+
syncPreferencesToMemory(profile, cwd);
|
|
489
|
+
return profile;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function getActivePreferences(cwd) {
|
|
493
|
+
const seen = new Set();
|
|
494
|
+
const result = [];
|
|
495
|
+
for (const p of [GLOBAL_PATH, projectPath(cwd)]) {
|
|
496
|
+
if (!existsSync(p)) continue;
|
|
497
|
+
try {
|
|
498
|
+
for (const pref of JSON.parse(readFileSync(p, 'utf8')).preferences || []) {
|
|
499
|
+
if (pref.enabled && !seen.has(pref.text)) { seen.add(pref.text); result.push(pref); }
|
|
500
|
+
}
|
|
501
|
+
} catch { /* skip */ }
|
|
502
|
+
}
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// Provider helpers
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
const PLAN_RANK = { '$20': 1, '$100': 2, '$200': 3 };
|
|
511
|
+
|
|
512
|
+
function getAvailableProviders(profile) {
|
|
513
|
+
return Object.entries(profile.providers || {})
|
|
514
|
+
.filter(([, p]) => p.enabled)
|
|
515
|
+
.map(([name, p]) => ({ name, plan: p.plan, rank: PLAN_RANK[p.plan] || 1 }));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function isSoloBrain(profile) {
|
|
519
|
+
return getAvailableProviders(profile).length === 1;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function getHeadModel(profile) {
|
|
523
|
+
const providers = getAvailableProviders(profile);
|
|
524
|
+
if (providers.length === 0) return 'sonnet';
|
|
525
|
+
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';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
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)
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Attempt to configure a profile entirely from detected state — no user input.
|
|
589
|
+
*
|
|
590
|
+
* Returns:
|
|
591
|
+
* {
|
|
592
|
+
* confident: boolean, // true when at least one provider was found
|
|
593
|
+
* profile: object|null, // fully-built profile ready to save, or null
|
|
594
|
+
* warnings: string[], // non-fatal issues (e.g. missing provider)
|
|
595
|
+
* actions: string[], // human-readable lines for the summary box
|
|
596
|
+
* }
|
|
597
|
+
*
|
|
598
|
+
* IMPORTANT: this function NEVER stores credentials — it only reads what's
|
|
599
|
+
* already present on disk / in environment variables.
|
|
600
|
+
*/
|
|
601
|
+
async function autoSetup(cwd) {
|
|
602
|
+
const env = detectEnvironment();
|
|
603
|
+
const auth = await detectAuth();
|
|
604
|
+
const plans = detectPlans();
|
|
605
|
+
|
|
606
|
+
const result = {
|
|
607
|
+
confident: false,
|
|
608
|
+
profile: null,
|
|
609
|
+
warnings: [],
|
|
610
|
+
actions: [],
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
// Need at least one provider authenticated
|
|
614
|
+
if (!auth.claude.found && !auth.openai.found) {
|
|
615
|
+
result.warnings.push('No provider credentials found');
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Build profile from detected state
|
|
620
|
+
const profile = defaultProfile();
|
|
621
|
+
|
|
622
|
+
// Claude
|
|
623
|
+
if (auth.claude.found) {
|
|
624
|
+
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})`);
|
|
630
|
+
} else {
|
|
631
|
+
profile.providers.claude.enabled = false;
|
|
632
|
+
result.warnings.push('Claude CLI not logged in — run: claude login');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// OpenAI
|
|
636
|
+
if (auth.openai.found) {
|
|
637
|
+
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})`);
|
|
643
|
+
} else {
|
|
644
|
+
profile.providers.openai.enabled = false;
|
|
645
|
+
result.warnings.push('Codex CLI not logged in — run: codex login');
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Mode
|
|
649
|
+
const enabledCount = [auth.claude.found, auth.openai.found].filter(Boolean).length;
|
|
650
|
+
profile.mode = enabledCount >= 2 ? 'dual'
|
|
651
|
+
: auth.claude.found ? 'solo-claude'
|
|
652
|
+
: 'solo-openai';
|
|
653
|
+
profile.bias = 'balanced';
|
|
654
|
+
|
|
655
|
+
// Environment note
|
|
656
|
+
if (env.isReplit && env.hasReplitTools) {
|
|
657
|
+
result.actions.push('Replit + replit-tools detected');
|
|
658
|
+
} else if (env.isReplit) {
|
|
659
|
+
result.actions.push('Replit environment detected');
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
result.confident = true;
|
|
663
|
+
result.profile = profile;
|
|
664
|
+
return result;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
// OAuth token auto-refresh
|
|
669
|
+
// ---------------------------------------------------------------------------
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* 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
|
+
*
|
|
676
|
+
* Returns one of:
|
|
677
|
+
* { status: 'valid', hoursRemaining }
|
|
678
|
+
* { status: 'refreshed', hoursRemaining }
|
|
679
|
+
* { status: 'expiring_no_refresh' | 'expired', hoursRemaining }
|
|
680
|
+
* { status: 'no_credentials' | 'parse_error' | 'no_expiry' }
|
|
681
|
+
* { status: 'refresh_failed', error }
|
|
682
|
+
*
|
|
683
|
+
* @param {string} [cwd]
|
|
684
|
+
*/
|
|
685
|
+
async function autoRefreshToken(cwd) {
|
|
686
|
+
const home = process.env.HOME || '/root';
|
|
687
|
+
const credPaths = [
|
|
688
|
+
join(home, '.claude', '.credentials.json'),
|
|
689
|
+
join(cwd || '.', '.replit-tools', '.claude-persistent', '.credentials.json'),
|
|
690
|
+
];
|
|
691
|
+
|
|
692
|
+
let credPath = null;
|
|
693
|
+
for (const p of credPaths) {
|
|
694
|
+
if (existsSync(p)) { credPath = p; break; }
|
|
695
|
+
}
|
|
696
|
+
if (!credPath) return { status: 'no_credentials' };
|
|
697
|
+
|
|
698
|
+
let creds;
|
|
699
|
+
try {
|
|
700
|
+
creds = JSON.parse(readFileSync(credPath, 'utf8'));
|
|
701
|
+
} catch { return { status: 'parse_error' }; }
|
|
702
|
+
|
|
703
|
+
const oauth = creds?.claudeAiOauth;
|
|
704
|
+
if (!oauth?.expiresAt) return { status: 'no_expiry' };
|
|
705
|
+
|
|
706
|
+
const now = Date.now();
|
|
707
|
+
const remainingMs = oauth.expiresAt - now;
|
|
708
|
+
const remainingHours = Math.floor(remainingMs / 1000 / 60 / 60);
|
|
709
|
+
|
|
710
|
+
// More than 2 hours left — no refresh needed
|
|
711
|
+
if (remainingHours >= 2) {
|
|
712
|
+
return { status: 'valid', hoursRemaining: remainingHours };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Need refresh
|
|
716
|
+
if (!oauth.refreshToken) {
|
|
717
|
+
return { status: remainingMs > 0 ? 'expiring_no_refresh' : 'expired', hoursRemaining: remainingHours };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
const res = await fetch('https://console.anthropic.com/v1/oauth/token', {
|
|
722
|
+
method: 'POST',
|
|
723
|
+
headers: { 'Content-Type': 'application/json' },
|
|
724
|
+
body: JSON.stringify({
|
|
725
|
+
grant_type: 'refresh_token',
|
|
726
|
+
refresh_token: oauth.refreshToken,
|
|
727
|
+
client_id: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
|
728
|
+
}),
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
if (!res.ok) return { status: 'refresh_failed', error: `HTTP ${res.status}` };
|
|
732
|
+
|
|
733
|
+
const data = await res.json();
|
|
734
|
+
if (!data.access_token) return { status: 'refresh_failed', error: 'no access_token' };
|
|
735
|
+
|
|
736
|
+
// Update credentials
|
|
737
|
+
const newExpiresAt = now + (data.expires_in * 1000);
|
|
738
|
+
creds.claudeAiOauth.accessToken = data.access_token;
|
|
739
|
+
if (data.refresh_token) creds.claudeAiOauth.refreshToken = data.refresh_token;
|
|
740
|
+
creds.claudeAiOauth.expiresAt = newExpiresAt;
|
|
741
|
+
|
|
742
|
+
// Backup then write
|
|
743
|
+
try { writeFileSync(credPath + '.backup', readFileSync(credPath)); } catch {}
|
|
744
|
+
writeFileSync(credPath, JSON.stringify(creds));
|
|
745
|
+
|
|
746
|
+
const newHours = Math.floor((data.expires_in) / 60 / 60);
|
|
747
|
+
return { status: 'refreshed', hoursRemaining: newHours };
|
|
748
|
+
} catch (e) {
|
|
749
|
+
return { status: 'refresh_failed', error: e.message };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// detectExistingAuth — silent onboarding scan
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
/**
|
|
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
|
+
* }
|
|
824
|
+
*
|
|
825
|
+
* @param {string} [cwd]
|
|
826
|
+
*/
|
|
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' },
|
|
839
|
+
};
|
|
840
|
+
|
|
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',
|
|
849
|
+
];
|
|
850
|
+
for (const credPath of credPaths) {
|
|
851
|
+
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
|
+
}
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
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)';
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// -------------------------------------------------------------------------
|
|
898
|
+
// 3. Codex CLI / OpenAI auth detection
|
|
899
|
+
// -------------------------------------------------------------------------
|
|
900
|
+
const codexConfigPaths = [
|
|
901
|
+
join(root, '.replit-tools', '.codex-persistent', 'auth.json'),
|
|
902
|
+
'/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
|
|
903
|
+
join(home, '.codex', 'auth.json'),
|
|
904
|
+
];
|
|
905
|
+
for (const p of codexConfigPaths) {
|
|
906
|
+
try {
|
|
907
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
908
|
+
const accessToken = data?.tokens?.access_token || data?.access_token;
|
|
909
|
+
const idToken = data?.tokens?.id_token || data?.id_token;
|
|
910
|
+
if (accessToken || idToken) {
|
|
911
|
+
result.openai.found = true;
|
|
912
|
+
result.openai.source = p.includes('.replit-tools') ? 'codex CLI (replit-tools)' : 'codex CLI';
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
} catch { /* non-fatal */ }
|
|
916
|
+
}
|
|
917
|
+
|
|
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
|
+
}
|
|
926
|
+
|
|
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
|
+
}
|
|
936
|
+
|
|
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';
|
|
975
|
+
}
|
|
976
|
+
// else: no auth found — defaults remain (claude-sonnet-4-6 / $20 / balanced)
|
|
977
|
+
|
|
978
|
+
return result;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export {
|
|
982
|
+
loadProfile, saveProfile, ensureProfile, runOnboarding,
|
|
983
|
+
rememberPreference, forgetPreference, getActivePreferences,
|
|
984
|
+
getAvailableProviders, isSoloBrain, getHeadModel,
|
|
985
|
+
detectPlans, syncPreferencesToMemory,
|
|
986
|
+
detectAuth, detectEnvironment,
|
|
987
|
+
saveSubscription, listSubscriptions,
|
|
988
|
+
defaultProfile, autoSetup, autoRefreshToken,
|
|
989
|
+
detectExistingAuth,
|
|
990
|
+
};
|