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/install.mjs
ADDED
|
@@ -0,0 +1,1541 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dual-brain — Dual-provider orchestrator for Claude Code.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx -y dual-brain # auto-detect, configure, done
|
|
7
|
+
* npx -y dual-brain update # refresh hooks to latest package version
|
|
8
|
+
* npx dual-brain --force # overwrite existing config
|
|
9
|
+
* npx dual-brain --dry-run # detect only, don't install
|
|
10
|
+
* npx dual-brain --help
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
|
|
13
|
+
import { createInterface } from 'readline';
|
|
14
|
+
import { dirname, join, resolve } from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import { spawnSync } from 'child_process';
|
|
17
|
+
import { createHash } from 'crypto';
|
|
18
|
+
|
|
19
|
+
// Skip hook installation during global npm install — hooks are installed
|
|
20
|
+
// when the user runs 'dual-brain install' in their project directory.
|
|
21
|
+
if (process.env.npm_config_global === 'true' || (process.env.npm_lifecycle_event === 'postinstall' && !process.env.INIT_CWD)) {
|
|
22
|
+
console.log('dual-brain: global install detected. Run "dual-brain install" in your project to set up hooks.');
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const VERSION = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')).version;
|
|
28
|
+
const VERSION_STAMP_FILE = '.claude/dual-brain.version.json';
|
|
29
|
+
const UPDATE_CACHE_FILE = '.claude/dual-brain.update-check.json';
|
|
30
|
+
const UPDATE_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
// ─── Replit Detection ──────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
|
|
35
|
+
|
|
36
|
+
function cmd(s) { return IS_REPLIT ? `! ${s}` : s; }
|
|
37
|
+
|
|
38
|
+
// ─── CLI ────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const argv = process.argv.slice(2);
|
|
41
|
+
const flag = (f) => argv.includes(f);
|
|
42
|
+
const force = flag('--force');
|
|
43
|
+
const dryRun = flag('--dry-run');
|
|
44
|
+
const jsonOut = flag('--json');
|
|
45
|
+
const restoreNpmFlag = flag('--restore-npm');
|
|
46
|
+
const positional = argv.filter(a => !a.startsWith('-'));
|
|
47
|
+
const subcommand = positional[0] || null;
|
|
48
|
+
|
|
49
|
+
if (flag('--version') || flag('-v')) {
|
|
50
|
+
console.log(`dual-brain v${VERSION}`);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (flag('--help') || flag('-h')) {
|
|
55
|
+
console.log(`
|
|
56
|
+
🧠 Data Tools — Dual Brain v${VERSION}
|
|
57
|
+
Dual-provider orchestrator for Claude Code
|
|
58
|
+
Powered by replit-tools by Steve Moraco
|
|
59
|
+
|
|
60
|
+
Usage: npx -y dual-brain [command] [options]
|
|
61
|
+
|
|
62
|
+
⌨️ Commands:
|
|
63
|
+
(none) 🧠 Auto-detect and install/update orchestrator
|
|
64
|
+
status 🟢 Open live control panel
|
|
65
|
+
auth 🔑 Show, login, or refresh provider auth
|
|
66
|
+
mode 🎛️ Show or switch profile
|
|
67
|
+
budget 💵 Set session/daily spend limits
|
|
68
|
+
explain 🧭 Explain last routing decision
|
|
69
|
+
update 🔄 Force re-install of latest hooks
|
|
70
|
+
init Alias for default install
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
--force Overwrite all existing config
|
|
74
|
+
--dry-run Detect environment only
|
|
75
|
+
--json Output detection as JSON
|
|
76
|
+
--restore-npm Restore persisted npm token before auth flows
|
|
77
|
+
--help Show this help
|
|
78
|
+
|
|
79
|
+
🎛️ Routing modes:
|
|
80
|
+
🤖 Auto (default) Adapts routing based on risk, health, outcomes
|
|
81
|
+
⚖️ Balanced Auto-routes, uses both providers evenly
|
|
82
|
+
🛡️ Conservative Fewer GPT dispatches, sticks to Claude
|
|
83
|
+
🚀 Aggressive Maximizes both subscriptions, dual-brain for medium+
|
|
84
|
+
|
|
85
|
+
🚀 Examples:
|
|
86
|
+
${cmd('npx dual-brain')} # install or update
|
|
87
|
+
${cmd('npx dual-brain status')} # open control panel
|
|
88
|
+
${cmd('npx dual-brain auth')} # show Claude/Codex auth
|
|
89
|
+
${cmd('npx dual-brain auth login')} # sign in missing providers
|
|
90
|
+
${cmd('npx dual-brain auth refresh')} # refresh provider auth
|
|
91
|
+
${cmd('npx dual-brain mode cost-saver')} # switch profile
|
|
92
|
+
${cmd('npx dual-brain budget 8 25')} # \$8 session / \$25 daily
|
|
93
|
+
${cmd('npx dual-brain explain')} # last routing decision
|
|
94
|
+
${cmd('npx dual-brain update')} # refresh installed hooks
|
|
95
|
+
`);
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const SUBCOMMANDS = ['init', 'status', 'auth', 'mode', 'budget', 'explain', 'update'];
|
|
100
|
+
if (subcommand && !SUBCOMMANDS.includes(subcommand)) {
|
|
101
|
+
console.error(` Unknown command: ${subcommand}`);
|
|
102
|
+
console.error(` Run: ${cmd('npx dual-brain --help')}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Box Drawing ────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
const W = 54;
|
|
109
|
+
const pad = (s, len = W - 2) => {
|
|
110
|
+
s = String(s);
|
|
111
|
+
return s.length >= len ? s.slice(0, len) : s + ' '.repeat(len - s.length);
|
|
112
|
+
};
|
|
113
|
+
const ln = (s) => `║ ${pad(s)} ║`;
|
|
114
|
+
const br = (l, r) => l + '═'.repeat(W) + r;
|
|
115
|
+
const sep = () => '╠' + '═'.repeat(W) + '╣';
|
|
116
|
+
|
|
117
|
+
// ─── Detection ──────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function run(cmd, args, opts = {}) {
|
|
120
|
+
return spawnSync(cmd, args, {
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
123
|
+
timeout: 8000,
|
|
124
|
+
...opts,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function compareVersions(a, b) {
|
|
129
|
+
const aParts = String(a || '').replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
|
|
130
|
+
const bParts = String(b || '').replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
|
|
131
|
+
const len = Math.max(aParts.length, bParts.length);
|
|
132
|
+
for (let i = 0; i < len; i++) {
|
|
133
|
+
const diff = (aParts[i] || 0) - (bParts[i] || 0);
|
|
134
|
+
if (diff !== 0) return diff;
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readJsonFile(path) {
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writeJsonFile(path, value) {
|
|
148
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getVersionStamp(workspace) {
|
|
152
|
+
return readJsonFile(join(workspace, VERSION_STAMP_FILE));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function writeVersionStamp(workspace) {
|
|
156
|
+
const target = join(workspace, VERSION_STAMP_FILE);
|
|
157
|
+
const now = new Date().toISOString();
|
|
158
|
+
const existing = readJsonFile(target);
|
|
159
|
+
const stamp = {
|
|
160
|
+
version: VERSION,
|
|
161
|
+
installed_at: existing?.installed_at || now,
|
|
162
|
+
updated_at: now,
|
|
163
|
+
};
|
|
164
|
+
writeJsonFile(target, stamp);
|
|
165
|
+
return stamp;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getUpdateCache(workspace) {
|
|
169
|
+
const cache = readJsonFile(join(workspace, UPDATE_CACHE_FILE));
|
|
170
|
+
if (!cache?.checked_at) return null;
|
|
171
|
+
const age = Date.now() - Date.parse(cache.checked_at);
|
|
172
|
+
if (!Number.isFinite(age) || age < 0 || age > UPDATE_CACHE_TTL_MS) return null;
|
|
173
|
+
return cache;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeUpdateCache(workspace, result) {
|
|
177
|
+
writeJsonFile(join(workspace, UPDATE_CACHE_FILE), {
|
|
178
|
+
checked_at: new Date().toISOString(),
|
|
179
|
+
...result,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function checkForUpdate(workspace, { force = false } = {}) {
|
|
184
|
+
const installedStamp = getVersionStamp(workspace);
|
|
185
|
+
const installed = installedStamp?.version || VERSION;
|
|
186
|
+
|
|
187
|
+
if (!force) {
|
|
188
|
+
const cached = getUpdateCache(workspace);
|
|
189
|
+
if (cached && cached.installed === installed) {
|
|
190
|
+
return {
|
|
191
|
+
updateAvailable: !!cached.updateAvailable,
|
|
192
|
+
installed: cached.installed,
|
|
193
|
+
latest: cached.latest || installed,
|
|
194
|
+
checkedAt: cached.checked_at,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const result = spawnSync('npm', ['view', 'dual-brain', 'version', '--json'], {
|
|
201
|
+
encoding: 'utf8',
|
|
202
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
203
|
+
timeout: 5000,
|
|
204
|
+
});
|
|
205
|
+
if (result.status !== 0 || !result.stdout.trim()) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const latestRaw = JSON.parse(result.stdout);
|
|
210
|
+
const latest = Array.isArray(latestRaw) ? latestRaw[latestRaw.length - 1] : latestRaw;
|
|
211
|
+
if (!latest) return null;
|
|
212
|
+
|
|
213
|
+
const updateAvailable = compareVersions(latest, installed) > 0;
|
|
214
|
+
const payload = { updateAvailable, installed, latest };
|
|
215
|
+
writeUpdateCache(workspace, payload);
|
|
216
|
+
return payload;
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function performUpdate(workspace, env, mode) {
|
|
223
|
+
return install(workspace, env, mode);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function promptForUpdate(updateInfo) {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
229
|
+
rl.question(`Update available: v${updateInfo.installed} → v${updateInfo.latest}. Press [u] to update or Enter to continue. `, (answer) => {
|
|
230
|
+
rl.close();
|
|
231
|
+
resolve(answer.trim().toLowerCase() === 'u');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isLoggedInStatus(result) {
|
|
237
|
+
const out = ((result?.stdout || '') + (result?.stderr || '')).toLowerCase();
|
|
238
|
+
if (/\b(not\s+logged\s+in|unauthenticated|logged\s+out|no\s+auth)\b/.test(out)) return false;
|
|
239
|
+
return result?.status === 0 ||
|
|
240
|
+
/\b(logged\s+in|authenticated|signed\s+in|valid\s+session)\b/.test(out);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function detectReplit() {
|
|
244
|
+
const isReplit = !!(process.env.REPL_ID || process.env.REPL_SLUG);
|
|
245
|
+
const hasReplitTools = existsSync(resolve(process.cwd(), '.replit-tools'));
|
|
246
|
+
return { isReplit, hasReplitTools };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function detectClaude() {
|
|
250
|
+
const result = { installed: false, version: null, authed: false };
|
|
251
|
+
|
|
252
|
+
const ver = run('claude', ['--version']);
|
|
253
|
+
if (ver.status === 0 && ver.stdout.trim()) {
|
|
254
|
+
result.installed = true;
|
|
255
|
+
result.version = ver.stdout.trim().split('\n')[0];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!result.installed) {
|
|
259
|
+
const which = run('which', ['claude']);
|
|
260
|
+
if (which.status === 0 && which.stdout.trim()) result.installed = true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const credPaths = [
|
|
264
|
+
join(process.env.HOME || '', '.claude', '.credentials.json'),
|
|
265
|
+
join(process.env.HOME || '', '.claude', 'credentials.json'),
|
|
266
|
+
resolve(process.cwd(), '.replit-tools', '.claude-persistent', '.credentials.json'),
|
|
267
|
+
];
|
|
268
|
+
for (const p of credPaths) {
|
|
269
|
+
try {
|
|
270
|
+
const cred = JSON.parse(readFileSync(p, 'utf8'));
|
|
271
|
+
if (cred.claudeAiOauth || cred.apiKey || cred.oauth_token) {
|
|
272
|
+
result.authed = true;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!result.authed && result.installed) {
|
|
279
|
+
const auth = run('claude', ['auth', 'status']);
|
|
280
|
+
const out = ((auth.stdout || '') + (auth.stderr || '')).toLowerCase();
|
|
281
|
+
if (out.includes('logged in') || out.includes('authenticated') || out.includes('valid')) {
|
|
282
|
+
result.authed = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function detectCodex() {
|
|
290
|
+
const result = { installed: false, version: null, authed: false, path: null, authMethod: null };
|
|
291
|
+
|
|
292
|
+
const which = run('which', ['codex']);
|
|
293
|
+
if (which.status === 0 && which.stdout.trim()) {
|
|
294
|
+
result.path = which.stdout.trim();
|
|
295
|
+
result.installed = true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!result.installed) {
|
|
299
|
+
const home = process.env.HOME || '';
|
|
300
|
+
const fallbacks = [
|
|
301
|
+
join(home, '.local', 'bin', 'codex'),
|
|
302
|
+
join(home, 'bin', 'codex'),
|
|
303
|
+
'/usr/local/bin/codex',
|
|
304
|
+
];
|
|
305
|
+
for (const p of fallbacks) {
|
|
306
|
+
if (existsSync(p)) { result.path = p; result.installed = true; break; }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (result.installed && result.path) {
|
|
311
|
+
const ver = run(result.path, ['--version']);
|
|
312
|
+
if (ver.status === 0) result.version = ver.stdout.trim().split('\n')[0];
|
|
313
|
+
|
|
314
|
+
const login = run(result.path, ['login', 'status']);
|
|
315
|
+
if (isLoggedInStatus(login)) {
|
|
316
|
+
result.authed = true;
|
|
317
|
+
result.authMethod = 'oauth';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!result.authed && process.env.OPENAI_API_KEY) {
|
|
322
|
+
result.authed = true;
|
|
323
|
+
result.authMethod = 'api_key_env';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function detectExisting(workspace) {
|
|
330
|
+
const claude = resolve(workspace, '.claude');
|
|
331
|
+
return {
|
|
332
|
+
hasClaudeDir: existsSync(claude),
|
|
333
|
+
hasOrchestrator: existsSync(join(claude, 'orchestrator.json')),
|
|
334
|
+
hasSettings: existsSync(join(claude, 'settings.json')),
|
|
335
|
+
hasHooks: existsSync(join(claude, 'hooks', 'enforce-tier.mjs')),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function detectEnvironment() {
|
|
340
|
+
return {
|
|
341
|
+
...detectReplit(),
|
|
342
|
+
claude: detectClaude(),
|
|
343
|
+
codex: detectCodex(),
|
|
344
|
+
existing: detectExisting(process.cwd()),
|
|
345
|
+
workspace: resolve(process.cwd()),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─── NPM Token Persistence ────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
const NPM_PERSIST = resolve(process.cwd(), '.replit-tools', '.npm-persistent', '.npmrc');
|
|
352
|
+
const NPMRC_HOME = join(process.env.HOME || '', '.npmrc');
|
|
353
|
+
|
|
354
|
+
function restoreNpmToken() {
|
|
355
|
+
if (existsSync(NPMRC_HOME)) return;
|
|
356
|
+
if (!existsSync(NPM_PERSIST)) return;
|
|
357
|
+
try {
|
|
358
|
+
const content = readFileSync(NPM_PERSIST, 'utf8');
|
|
359
|
+
if (content.includes('_authToken')) {
|
|
360
|
+
writeFileSync(NPMRC_HOME, content);
|
|
361
|
+
}
|
|
362
|
+
} catch {}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ─── Auth Self-Healing ─────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
function healClaudeAuth(env) {
|
|
368
|
+
if (env.claude.authed) return env;
|
|
369
|
+
const refreshScript = resolve(process.cwd(), '.replit-tools', 'scripts', 'claude-auth-refresh.sh');
|
|
370
|
+
if (existsSync(refreshScript)) {
|
|
371
|
+
const result = run('bash', [refreshScript, '--auto']);
|
|
372
|
+
const out = ((result.stdout || '') + (result.stderr || '')).toLowerCase();
|
|
373
|
+
if (out.includes('refreshed') || out.includes('success')) {
|
|
374
|
+
env.claude.authed = true;
|
|
375
|
+
console.log(' 🔄 Claude token refreshed via data-tools');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return env;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function healCodexAuth(env) {
|
|
382
|
+
if (env.codex.authed) return env;
|
|
383
|
+
if (!env.codex.installed || !env.codex.path) return env;
|
|
384
|
+
|
|
385
|
+
// Try restoring persisted credentials from data-tools
|
|
386
|
+
if (restoreCodexCredentials()) {
|
|
387
|
+
const login = run(env.codex.path, ['login', 'status']);
|
|
388
|
+
if (isLoggedInStatus(login)) {
|
|
389
|
+
env.codex.authed = true;
|
|
390
|
+
env.codex.authMethod = 'restored_persistent';
|
|
391
|
+
console.log(' 🔄 Codex credentials restored from data-tools');
|
|
392
|
+
return env;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (process.env.OPENAI_API_KEY) {
|
|
397
|
+
const pipe = spawnSync(env.codex.path, ['login', '--with-api-key'], {
|
|
398
|
+
input: process.env.OPENAI_API_KEY,
|
|
399
|
+
encoding: 'utf8',
|
|
400
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
401
|
+
timeout: 10000,
|
|
402
|
+
});
|
|
403
|
+
if (pipe.status === 0) {
|
|
404
|
+
env.codex.authed = true;
|
|
405
|
+
env.codex.authMethod = 'api_key_env';
|
|
406
|
+
saveCodexCredentials();
|
|
407
|
+
console.log(' 🔄 Codex authenticated via OPENAI_API_KEY');
|
|
408
|
+
return env;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return env;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function prompt(question) {
|
|
416
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
417
|
+
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function authGuidance(env) {
|
|
421
|
+
if (env.claude.authed && env.codex.authed) return env;
|
|
422
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return env;
|
|
423
|
+
|
|
424
|
+
console.log('');
|
|
425
|
+
console.log(' ┌────────────────────────────────────────────┐');
|
|
426
|
+
console.log(' │ 🔑 Auth Setup │');
|
|
427
|
+
console.log(' └────────────────────────────────────────────┘');
|
|
428
|
+
|
|
429
|
+
if (!env.claude.authed) {
|
|
430
|
+
console.log('');
|
|
431
|
+
console.log(' 🟠 Claude — not authenticated');
|
|
432
|
+
if (env.isReplit) {
|
|
433
|
+
console.log(' Run in your terminal: ! claude login');
|
|
434
|
+
console.log(' It will give you a URL + code to paste in your browser.');
|
|
435
|
+
} else {
|
|
436
|
+
console.log(' Run: claude login');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!env.codex.authed) {
|
|
441
|
+
console.log('');
|
|
442
|
+
console.log(' 🟢 Codex — not authenticated');
|
|
443
|
+
if (!env.codex.installed) {
|
|
444
|
+
console.log(' Install first: npm i -g @openai/codex');
|
|
445
|
+
console.log('');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (env.codex.installed && env.codex.path) {
|
|
449
|
+
console.log('');
|
|
450
|
+
console.log(' Sign in with your ChatGPT subscription (no API key needed):');
|
|
451
|
+
console.log('');
|
|
452
|
+
|
|
453
|
+
const answer = await prompt(' Press Enter to start device auth (or "skip" to skip): ');
|
|
454
|
+
if (answer.toLowerCase() === 'skip') {
|
|
455
|
+
console.log(' ⏭️ Skipped — GPT features disabled until Codex is authed');
|
|
456
|
+
} else {
|
|
457
|
+
console.log('');
|
|
458
|
+
if (runCodexDeviceAuth(env.codex.path)) {
|
|
459
|
+
env.codex.authed = true;
|
|
460
|
+
env.codex.authMethod = 'device_auth';
|
|
461
|
+
console.log('');
|
|
462
|
+
console.log(' ✅ Codex authenticated! (credentials saved for next session)');
|
|
463
|
+
} else {
|
|
464
|
+
console.log('');
|
|
465
|
+
console.log(' ❌ Auth failed — try again with: codex login --device-auth');
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
console.log('');
|
|
472
|
+
return env;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── Codex Credential Persistence ──────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
const CODEX_HOME = join(process.env.HOME || '', '.codex');
|
|
478
|
+
const CODEX_PERSIST = resolve(process.cwd(), '.replit-tools', '.codex-persistent');
|
|
479
|
+
|
|
480
|
+
function isGitignored(path) {
|
|
481
|
+
const result = run('git', ['check-ignore', '-q', path], { cwd: process.cwd() });
|
|
482
|
+
return result.status === 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function hasStrictFilePermissions(path) {
|
|
486
|
+
try {
|
|
487
|
+
return (statSync(path).mode & 0o777) === 0o600;
|
|
488
|
+
} catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function saveCodexCredentials() {
|
|
494
|
+
const authFile = join(CODEX_HOME, 'auth.json');
|
|
495
|
+
if (!existsSync(authFile)) return false;
|
|
496
|
+
if (!isGitignored('.replit-tools')) {
|
|
497
|
+
console.warn('WARNING: .replit-tools is not gitignored. Skipping credential persistence to avoid leaking secrets.');
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
const auth = readFileSync(authFile, 'utf8');
|
|
502
|
+
if (!auth.trim() || auth.trim() === '{}') return false;
|
|
503
|
+
mkdirSync(CODEX_PERSIST, { recursive: true });
|
|
504
|
+
const persisted = join(CODEX_PERSIST, 'auth.json');
|
|
505
|
+
writeFileSync(persisted, auth, { mode: 0o600 });
|
|
506
|
+
try { chmodSync(persisted, 0o600); } catch {}
|
|
507
|
+
if (!hasStrictFilePermissions(persisted)) {
|
|
508
|
+
console.warn(`WARNING: ${relPath(persisted)} permissions are not 0600.`);
|
|
509
|
+
}
|
|
510
|
+
return true;
|
|
511
|
+
} catch { return false; }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function restoreCodexCredentials() {
|
|
515
|
+
const persistedAuth = join(CODEX_PERSIST, 'auth.json');
|
|
516
|
+
const targetAuth = join(CODEX_HOME, 'auth.json');
|
|
517
|
+
if (existsSync(targetAuth)) return false;
|
|
518
|
+
if (!existsSync(persistedAuth)) return false;
|
|
519
|
+
if (!hasStrictFilePermissions(persistedAuth)) {
|
|
520
|
+
console.warn(`WARNING: ${relPath(persistedAuth)} permissions are not 0600. Skipping restore.`);
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
const auth = readFileSync(persistedAuth, 'utf8');
|
|
525
|
+
if (!auth.trim() || auth.trim() === '{}') return false;
|
|
526
|
+
mkdirSync(CODEX_HOME, { recursive: true });
|
|
527
|
+
writeFileSync(targetAuth, auth, { mode: 0o600 });
|
|
528
|
+
try { chmodSync(targetAuth, 0o600); } catch {}
|
|
529
|
+
return true;
|
|
530
|
+
} catch { return false; }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function parseDateValue(value) {
|
|
534
|
+
if (!value) return null;
|
|
535
|
+
if (typeof value === 'number') {
|
|
536
|
+
const ms = value > 1e12 ? value : value * 1000;
|
|
537
|
+
const d = new Date(ms);
|
|
538
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
539
|
+
}
|
|
540
|
+
const d = new Date(value);
|
|
541
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function formatExpiry(value) {
|
|
545
|
+
const d = parseDateValue(value);
|
|
546
|
+
if (!d) return 'unknown';
|
|
547
|
+
const iso = d.toISOString();
|
|
548
|
+
return d.getTime() <= Date.now() ? `${iso} (expired)` : iso;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function formatTimestamp(value) {
|
|
552
|
+
const d = parseDateValue(value);
|
|
553
|
+
return d ? d.toISOString() : 'unknown';
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function relPath(p) {
|
|
557
|
+
if (!p) return 'unknown';
|
|
558
|
+
const cwd = resolve(process.cwd());
|
|
559
|
+
const full = resolve(p);
|
|
560
|
+
if (full.startsWith(cwd + '/')) return full.slice(cwd.length + 1);
|
|
561
|
+
const home = process.env.HOME || '';
|
|
562
|
+
if (home && full.startsWith(home + '/')) return `~/${full.slice(home.length + 1)}`;
|
|
563
|
+
return full;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function readJsonIfExists(file) {
|
|
567
|
+
if (!file || !existsSync(file)) return null;
|
|
568
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function claudeCredentialCandidates() {
|
|
572
|
+
return [
|
|
573
|
+
join(process.env.HOME || '', '.claude', '.credentials.json'),
|
|
574
|
+
join(process.env.HOME || '', '.claude', 'credentials.json'),
|
|
575
|
+
resolve(process.cwd(), '.replit-tools', '.claude-persistent', '.credentials.json'),
|
|
576
|
+
];
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function getClaudeAuthDetails() {
|
|
580
|
+
const detected = detectClaude();
|
|
581
|
+
const status = detected.installed ? run('claude', ['auth', 'status']) : null;
|
|
582
|
+
let method = 'none';
|
|
583
|
+
let storage = null;
|
|
584
|
+
let expiry = null;
|
|
585
|
+
|
|
586
|
+
for (const file of claudeCredentialCandidates()) {
|
|
587
|
+
const cred = readJsonIfExists(file);
|
|
588
|
+
if (!cred) continue;
|
|
589
|
+
if (cred.claudeAiOauth) {
|
|
590
|
+
method = 'oauth';
|
|
591
|
+
storage = file;
|
|
592
|
+
expiry = cred.claudeAiOauth.expiresAt || null;
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
if (cred.apiKey || cred.oauth_token) {
|
|
596
|
+
method = cred.apiKey ? 'api_key' : 'oauth_token';
|
|
597
|
+
storage = file;
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const statusText = ((status?.stdout || '') + (status?.stderr || '')).trim();
|
|
603
|
+
return {
|
|
604
|
+
provider: 'claude',
|
|
605
|
+
installed: detected.installed,
|
|
606
|
+
version: detected.version,
|
|
607
|
+
authed: detected.authed,
|
|
608
|
+
method,
|
|
609
|
+
expiry,
|
|
610
|
+
expiryText: formatExpiry(expiry),
|
|
611
|
+
storage,
|
|
612
|
+
storageText: relPath(storage),
|
|
613
|
+
statusText,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function getCodexAuthDetails() {
|
|
618
|
+
const detected = detectCodex();
|
|
619
|
+
const status = (detected.installed && detected.path) ? run(detected.path, ['login', 'status']) : null;
|
|
620
|
+
const authFile = join(CODEX_HOME, 'auth.json');
|
|
621
|
+
const persisted = join(CODEX_PERSIST, 'auth.json');
|
|
622
|
+
const activeFile = existsSync(authFile) ? authFile : existsSync(persisted) ? persisted : null;
|
|
623
|
+
const auth = readJsonIfExists(activeFile);
|
|
624
|
+
|
|
625
|
+
let method = detected.authMethod || 'none';
|
|
626
|
+
if (auth?.auth_mode === 'chatgpt') method = 'chatgpt_device_auth';
|
|
627
|
+
else if (auth?.auth_mode === 'api_key') method = 'api_key';
|
|
628
|
+
else if (!detected.authed && process.env.OPENAI_API_KEY) method = 'api_key_env';
|
|
629
|
+
|
|
630
|
+
const lastRefresh = auth?.last_refresh || null;
|
|
631
|
+
const statusText = ((status?.stdout || '') + (status?.stderr || '')).trim();
|
|
632
|
+
return {
|
|
633
|
+
provider: 'codex',
|
|
634
|
+
installed: detected.installed,
|
|
635
|
+
version: detected.version,
|
|
636
|
+
authed: detected.authed,
|
|
637
|
+
method,
|
|
638
|
+
expiry: null,
|
|
639
|
+
expiryText: 'n/a',
|
|
640
|
+
lastRefresh,
|
|
641
|
+
lastRefreshText: formatTimestamp(lastRefresh),
|
|
642
|
+
storage: activeFile,
|
|
643
|
+
storageText: relPath(activeFile),
|
|
644
|
+
statusText,
|
|
645
|
+
path: detected.path,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function getAuthState() {
|
|
650
|
+
return {
|
|
651
|
+
claude: getClaudeAuthDetails(),
|
|
652
|
+
codex: getCodexAuthDetails(),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function printAuthStatusBox(state) {
|
|
657
|
+
const c = state.claude;
|
|
658
|
+
const x = state.codex;
|
|
659
|
+
const cIcon = c.authed ? '✅' : c.installed ? '⚠️' : '❌';
|
|
660
|
+
const xIcon = x.authed ? '✅' : x.installed ? '⚠️' : '❌';
|
|
661
|
+
|
|
662
|
+
console.log('');
|
|
663
|
+
console.log(` ${br('╔', '╗')}`);
|
|
664
|
+
console.log(` ${ln(`🔑 Auth Status`)}`);
|
|
665
|
+
console.log(` ${sep()}`);
|
|
666
|
+
console.log(` ${ln(`🟠 Claude ${cIcon} ${c.authed ? 'authenticated' : c.installed ? 'not authenticated' : 'not installed'}`)}`);
|
|
667
|
+
console.log(` ${ln(` Method: ${c.method}`)}`);
|
|
668
|
+
console.log(` ${ln(` Expiry: ${c.expiryText}`)}`);
|
|
669
|
+
console.log(` ${ln(` Storage: ${c.storageText}`)}`);
|
|
670
|
+
console.log(` ${sep()}`);
|
|
671
|
+
console.log(` ${ln(`🟢 Codex ${xIcon} ${x.authed ? 'authenticated' : x.installed ? 'not authenticated' : 'not installed'}`)}`);
|
|
672
|
+
console.log(` ${ln(` Method: ${x.method}`)}`);
|
|
673
|
+
console.log(` ${ln(` Expiry: ${x.expiryText}`)}`);
|
|
674
|
+
console.log(` ${ln(` Storage: ${x.storageText}`)}`);
|
|
675
|
+
if (x.lastRefresh) console.log(` ${ln(` Refreshed:${x.lastRefreshText}`)}`);
|
|
676
|
+
console.log(` ${br('╚', '╝')}`);
|
|
677
|
+
console.log('');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function runCodexDeviceAuth(codexPath) {
|
|
681
|
+
if (!codexPath) return false;
|
|
682
|
+
const result = spawnSync(codexPath, ['login', '--device-auth'], {
|
|
683
|
+
stdio: 'inherit',
|
|
684
|
+
timeout: 900000,
|
|
685
|
+
});
|
|
686
|
+
if (result.status === 0) {
|
|
687
|
+
saveCodexCredentials();
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ─── Mode Resolution ────────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
function resolveMode(env) {
|
|
696
|
+
const c = env.claude.authed || env.claude.installed;
|
|
697
|
+
const o = env.codex.authed;
|
|
698
|
+
if (c && o) return { mode: 'dual', claudeEnabled: true, openaiEnabled: true };
|
|
699
|
+
if (c) return { mode: 'claude-only', claudeEnabled: true, openaiEnabled: false };
|
|
700
|
+
if (o) return { mode: 'openai-only', claudeEnabled: false, openaiEnabled: true };
|
|
701
|
+
return { mode: 'detect-only', claudeEnabled: true, openaiEnabled: false };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const MODE_LABELS = {
|
|
705
|
+
'dual': 'dual-provider (full features)',
|
|
706
|
+
'claude-only': 'Claude only (GPT features available when Codex authed)',
|
|
707
|
+
'openai-only': 'OpenAI + Claude (auth Claude for full features)',
|
|
708
|
+
'detect-only': 'hooks installed (auth providers to activate)',
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
// ─── Config Generation ──────────────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
function generateOrchestrator(mode, workspace) {
|
|
714
|
+
const template = JSON.parse(readFileSync(join(__dirname, 'orchestrator.json'), 'utf8'));
|
|
715
|
+
const existing = {};
|
|
716
|
+
const existingPath = join(workspace, '.claude', 'orchestrator.json');
|
|
717
|
+
try { Object.assign(existing, JSON.parse(readFileSync(existingPath, 'utf8'))); } catch {}
|
|
718
|
+
|
|
719
|
+
const config = force ? { ...template } : { ...template, ...existing };
|
|
720
|
+
|
|
721
|
+
config.providers = config.providers || template.providers;
|
|
722
|
+
config.providers.claude = { ...(template.providers?.claude || {}), ...(config.providers?.claude || {}) };
|
|
723
|
+
config.providers.openai = { ...(template.providers?.openai || {}), ...(config.providers?.openai || {}) };
|
|
724
|
+
config.providers.claude.enabled = mode.claudeEnabled;
|
|
725
|
+
config.providers.openai.enabled = mode.openaiEnabled;
|
|
726
|
+
|
|
727
|
+
config.dual_thinking = config.dual_thinking || template.dual_thinking;
|
|
728
|
+
config.dual_thinking.enabled = mode.mode === 'dual';
|
|
729
|
+
|
|
730
|
+
config.subscriptions = config.subscriptions || template.subscriptions;
|
|
731
|
+
config.model_intelligence = config.model_intelligence || template.model_intelligence;
|
|
732
|
+
config.tiers = config.tiers || template.tiers;
|
|
733
|
+
config.quality_gate = force ? template.quality_gate : (config.quality_gate || template.quality_gate);
|
|
734
|
+
config.routing_rules = force ? template.routing_rules : (config.routing_rules || template.routing_rules);
|
|
735
|
+
config.budgets = force ? template.budgets : (config.budgets || template.budgets);
|
|
736
|
+
config.routing = force ? template.routing : (config.routing || template.routing);
|
|
737
|
+
config.codex_skills = template.codex_skills;
|
|
738
|
+
config.pricing_verified = new Date().toISOString().slice(0, 10);
|
|
739
|
+
|
|
740
|
+
return config;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function generateSettings(workspace) {
|
|
744
|
+
const settingsPath = join(workspace, '.claude', 'settings.json');
|
|
745
|
+
let existing = {};
|
|
746
|
+
try { existing = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch {}
|
|
747
|
+
|
|
748
|
+
const HEAD_GUARD_CMD = 'node .claude/hooks/head-guard.mjs';
|
|
749
|
+
const ENFORCE_TIER_CMD = 'node .claude/hooks/enforce-tier.mjs';
|
|
750
|
+
|
|
751
|
+
// All dual-brain PreToolUse hooks we manage
|
|
752
|
+
const DESIRED_PRE = [
|
|
753
|
+
{ matcher: 'Edit', command: HEAD_GUARD_CMD },
|
|
754
|
+
{ matcher: 'Write', command: HEAD_GUARD_CMD },
|
|
755
|
+
{ matcher: 'NotebookEdit', command: HEAD_GUARD_CMD },
|
|
756
|
+
{ matcher: 'Bash', command: HEAD_GUARD_CMD },
|
|
757
|
+
{ matcher: 'Agent', command: ENFORCE_TIER_CMD },
|
|
758
|
+
];
|
|
759
|
+
|
|
760
|
+
const DUAL_BRAIN_CMDS = [
|
|
761
|
+
HEAD_GUARD_CMD,
|
|
762
|
+
ENFORCE_TIER_CMD,
|
|
763
|
+
'node .claude/hooks/cost-logger.mjs',
|
|
764
|
+
'node .claude/hooks/auto-update-wrapper.mjs',
|
|
765
|
+
];
|
|
766
|
+
|
|
767
|
+
// Build merged PreToolUse: keep user entries that aren't ours, then add ours
|
|
768
|
+
const existingPre = (existing.hooks?.PreToolUse || []).filter(e =>
|
|
769
|
+
!e.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
|
|
770
|
+
);
|
|
771
|
+
const mergedPre = [...existingPre];
|
|
772
|
+
for (const { matcher, command } of DESIRED_PRE) {
|
|
773
|
+
mergedPre.push({ matcher, hooks: [{ type: 'command', command }] });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Build merged PostToolUse
|
|
777
|
+
const postHooks = [
|
|
778
|
+
{ matcher: '', hooks: [{ type: 'command', command: 'node .claude/hooks/cost-logger.mjs' }] },
|
|
779
|
+
{ matcher: '', hooks: [{ type: 'command', command: 'node .claude/hooks/auto-update-wrapper.mjs' }] },
|
|
780
|
+
];
|
|
781
|
+
const existingPost = (existing.hooks?.PostToolUse || []).filter(e =>
|
|
782
|
+
!e.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
|
|
783
|
+
);
|
|
784
|
+
const mergedPost = [...existingPost, ...postHooks];
|
|
785
|
+
|
|
786
|
+
const merged = {
|
|
787
|
+
...(existing.hooks || {}),
|
|
788
|
+
PreToolUse: mergedPre,
|
|
789
|
+
PostToolUse: mergedPost,
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
return { ...existing, hooks: merged };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function generateClaudeMd(mode) {
|
|
796
|
+
let md = readFileSync(join(__dirname, 'CLAUDE.md'), 'utf8');
|
|
797
|
+
|
|
798
|
+
if (mode.mode === 'claude-only') {
|
|
799
|
+
md = md.replace(
|
|
800
|
+
/## GPT Lane[\s\S]*?(?=## )/,
|
|
801
|
+
'## GPT Lane\n\nGPT features activate automatically when Codex CLI is authenticated (`npm i -g @openai/codex && codex login`).\n\n'
|
|
802
|
+
);
|
|
803
|
+
} else if (mode.mode === 'detect-only') {
|
|
804
|
+
md = '# Dual-Brain Orchestrator\n\nHooks installed but no providers authenticated yet.\nRun `npx dual-brain` again after authenticating Claude or Codex.\n\n' + md.split('\n').slice(3).join('\n');
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return md;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const CLAUDE_MD_MANAGED_START = '<!-- dual-brain:start -->';
|
|
811
|
+
const CLAUDE_MD_MANAGED_END = '<!-- dual-brain:end -->';
|
|
812
|
+
|
|
813
|
+
function renderManagedClaudeSection(content) {
|
|
814
|
+
return `${CLAUDE_MD_MANAGED_START}\n${content.replace(/\s+$/, '')}\n${CLAUDE_MD_MANAGED_END}\n`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function mergeClaudeMd(existingContent, managedContent) {
|
|
818
|
+
const managedSection = renderManagedClaudeSection(managedContent);
|
|
819
|
+
const startIndex = existingContent.indexOf(CLAUDE_MD_MANAGED_START);
|
|
820
|
+
const endIndex = existingContent.indexOf(CLAUDE_MD_MANAGED_END);
|
|
821
|
+
|
|
822
|
+
if (startIndex !== -1 && endIndex !== -1 && endIndex >= startIndex) {
|
|
823
|
+
const before = existingContent.slice(0, startIndex);
|
|
824
|
+
const after = existingContent.slice(endIndex + CLAUDE_MD_MANAGED_END.length);
|
|
825
|
+
const prefix = before.replace(/\s*$/, '');
|
|
826
|
+
const suffix = after.replace(/^\s*/, '');
|
|
827
|
+
return `${prefix}${prefix ? '\n\n' : ''}${managedSection}${suffix ? `\n${suffix}` : ''}`.replace(/\s+$/, '') + '\n';
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const trimmed = existingContent.replace(/\s+$/, '');
|
|
831
|
+
return `${trimmed}${trimmed ? '\n\n' : ''}${managedSection}`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function writeClaudeMd(targetPath, content) {
|
|
835
|
+
const managedContent = content.replace(/\s+$/, '');
|
|
836
|
+
if (force || !existsSync(targetPath)) {
|
|
837
|
+
writeFileSync(targetPath, renderManagedClaudeSection(managedContent));
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const existing = readFileSync(targetPath, 'utf8');
|
|
842
|
+
writeFileSync(targetPath, mergeClaudeMd(existing, managedContent));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function generateGitignoreEntries(workspace) {
|
|
846
|
+
const entries = [
|
|
847
|
+
'.claude/hooks/usage-*.jsonl',
|
|
848
|
+
'.claude/hooks/usage.jsonl',
|
|
849
|
+
'.claude/reviews/',
|
|
850
|
+
'.claude/hooks/.drift-warned',
|
|
851
|
+
'.claude/hooks/.budget-alerted',
|
|
852
|
+
'.claude/dual-brain.profile.json',
|
|
853
|
+
'.claude/hooks/usage-summary-*.json',
|
|
854
|
+
'.claude/hooks/decision-ledger.jsonl',
|
|
855
|
+
'.claude/.launched',
|
|
856
|
+
'.claude/dual-brain.memory.json',
|
|
857
|
+
'.claude/dual-brain.version.json',
|
|
858
|
+
'.claude/dual-brain.update-check.json',
|
|
859
|
+
'.claude/dual-brain.permissions.json',
|
|
860
|
+
];
|
|
861
|
+
let existing = '';
|
|
862
|
+
try { existing = readFileSync(join(workspace, '.gitignore'), 'utf8'); } catch {}
|
|
863
|
+
const needed = entries.filter(e => !existing.includes(e));
|
|
864
|
+
return { existing, needed };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ─── Hook Manifest ──────────────────────────────────────────────────────────
|
|
868
|
+
|
|
869
|
+
function hashString(s) {
|
|
870
|
+
return createHash('sha256').update(s).digest('hex');
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function generateHookManifest(settings) {
|
|
874
|
+
const hooks = settings.hooks || {};
|
|
875
|
+
const preHooks = (hooks.PreToolUse || []).flatMap(entry =>
|
|
876
|
+
(entry.hooks || []).map(h => hashString(h.command || ''))
|
|
877
|
+
);
|
|
878
|
+
const postHooks = (hooks.PostToolUse || []).flatMap(entry =>
|
|
879
|
+
(entry.hooks || []).map(h => hashString(h.command || ''))
|
|
880
|
+
);
|
|
881
|
+
const settingsHash = hashString(JSON.stringify(hooks));
|
|
882
|
+
return {
|
|
883
|
+
generatedAt: new Date().toISOString(),
|
|
884
|
+
expectedHooks: {
|
|
885
|
+
PreToolUse: preHooks,
|
|
886
|
+
PostToolUse: postHooks,
|
|
887
|
+
},
|
|
888
|
+
settingsHash,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function writeHookManifest(workspace, settings) {
|
|
893
|
+
const dualbrain = join(workspace, '.dualbrain');
|
|
894
|
+
mkdirSync(dualbrain, { recursive: true });
|
|
895
|
+
const manifest = generateHookManifest(settings);
|
|
896
|
+
writeFileSync(join(dualbrain, 'hook-manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
897
|
+
return manifest;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ─── Installation ───────────────────────────────────────────────────────────
|
|
901
|
+
|
|
902
|
+
function install(workspace, env, mode) {
|
|
903
|
+
const target = join(workspace, '.claude');
|
|
904
|
+
const actions = [];
|
|
905
|
+
|
|
906
|
+
mkdirSync(join(target, 'hooks'), { recursive: true });
|
|
907
|
+
|
|
908
|
+
const HOOKS = [
|
|
909
|
+
'enforce-tier.mjs', 'cost-logger.mjs', 'cost-report.mjs',
|
|
910
|
+
'dual-brain-review.mjs', 'dual-brain-think.mjs', 'quality-gate.mjs',
|
|
911
|
+
'test-orchestrator.mjs', 'setup-wizard.mjs', 'health-check.mjs',
|
|
912
|
+
'install-git-hooks.mjs', 'session-report.mjs', 'budget-balancer.mjs',
|
|
913
|
+
'gpt-work-dispatcher.mjs', 'profiles.mjs',
|
|
914
|
+
'summary-checkpoint.mjs', 'decision-ledger.mjs', 'control-panel.mjs',
|
|
915
|
+
'risk-classifier.mjs', 'failure-detector.mjs',
|
|
916
|
+
'vibe-router.mjs', 'plan-generator.mjs', 'vibe-memory.mjs',
|
|
917
|
+
'wave-orchestrator.mjs',
|
|
918
|
+
'task-classifier.mjs', 'model-registry.mjs',
|
|
919
|
+
'auto-update-wrapper.mjs',
|
|
920
|
+
'head-guard.mjs',
|
|
921
|
+
];
|
|
922
|
+
for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
|
|
923
|
+
|
|
924
|
+
// Copy bash hooks (auto-update.sh lives alongside .mjs hooks in the package)
|
|
925
|
+
const BASH_HOOKS = ['auto-update.sh'];
|
|
926
|
+
for (const h of BASH_HOOKS) {
|
|
927
|
+
const src = join(__dirname, 'hooks', h);
|
|
928
|
+
const dst = join(target, 'hooks', h);
|
|
929
|
+
if (existsSync(src)) {
|
|
930
|
+
cpSync(src, dst);
|
|
931
|
+
try { chmodSync(dst, 0o755); } catch {}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
actions.push(`✓ ${HOOKS.length} hook scripts`);
|
|
935
|
+
|
|
936
|
+
const RULES = [
|
|
937
|
+
'hookify.orchestrator-route.local.md',
|
|
938
|
+
'hookify.orchestrator-gate.local.md',
|
|
939
|
+
'hookify.orchestrator-cost.local.md',
|
|
940
|
+
];
|
|
941
|
+
for (const r of RULES) cpSync(join(__dirname, r), join(target, r));
|
|
942
|
+
actions.push(`✓ ${RULES.length} hookify rules`);
|
|
943
|
+
|
|
944
|
+
const orch = generateOrchestrator(mode, workspace);
|
|
945
|
+
writeFileSync(join(target, 'orchestrator.json'), JSON.stringify(orch, null, 2) + '\n');
|
|
946
|
+
actions.push(`✓ orchestrator.json (${mode.mode})`);
|
|
947
|
+
|
|
948
|
+
const settings = generateSettings(workspace);
|
|
949
|
+
writeFileSync(join(target, 'settings.json'), JSON.stringify(settings, null, 2) + '\n');
|
|
950
|
+
actions.push('✓ settings.json (hooks registered)');
|
|
951
|
+
|
|
952
|
+
writeHookManifest(workspace, settings);
|
|
953
|
+
actions.push('✓ .dualbrain/hook-manifest.json (integrity manifest)');
|
|
954
|
+
|
|
955
|
+
const claudeMd = generateClaudeMd(mode);
|
|
956
|
+
writeClaudeMd(join(target, 'CLAUDE.md'), claudeMd);
|
|
957
|
+
actions.push('✓ CLAUDE.md (session instructions)');
|
|
958
|
+
|
|
959
|
+
const rulesTarget = join(target, 'review-rules.md');
|
|
960
|
+
if (!existsSync(rulesTarget) || force) {
|
|
961
|
+
cpSync(join(__dirname, 'review-rules.md'), rulesTarget);
|
|
962
|
+
actions.push('✓ review-rules.md template');
|
|
963
|
+
} else {
|
|
964
|
+
actions.push('⊘ review-rules.md (kept yours)');
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const { existing: gi, needed } = generateGitignoreEntries(workspace);
|
|
968
|
+
if (needed.length > 0) {
|
|
969
|
+
writeFileSync(
|
|
970
|
+
join(workspace, '.gitignore'),
|
|
971
|
+
(gi && !gi.endsWith('\n') ? gi + '\n' : gi) + '\n# Dual-Brain Orchestrator\n' + needed.join('\n') + '\n'
|
|
972
|
+
);
|
|
973
|
+
actions.push('✓ .gitignore updated');
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const stamp = writeVersionStamp(workspace);
|
|
977
|
+
actions.push(`✓ version stamp (v${stamp.version})`);
|
|
978
|
+
|
|
979
|
+
return actions;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ─── Status Report ──────────────────────────────────────────────────────────
|
|
983
|
+
|
|
984
|
+
function printReport(env, mode, actions, isDryRun) {
|
|
985
|
+
const lines = [];
|
|
986
|
+
|
|
987
|
+
lines.push(br('╔', '╗'));
|
|
988
|
+
lines.push(ln(`🧠 Data Tools — Dual Brain v${VERSION}`));
|
|
989
|
+
lines.push(sep());
|
|
990
|
+
|
|
991
|
+
const cAuth = env.claude.authed ? '✅' : env.claude.installed ? '⚠️' : '❌';
|
|
992
|
+
const xAuth = env.codex.authed ? '✅' : env.codex.installed ? '⚠️' : '❌';
|
|
993
|
+
lines.push(ln(` 🟠 Claude ${cAuth} 🟢 Codex ${xAuth}`));
|
|
994
|
+
|
|
995
|
+
if (env.isReplit) {
|
|
996
|
+
lines.push(ln(` 🌀 Replit${env.hasReplitTools ? ' + replit-tools' : ''}`));
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (actions) {
|
|
1000
|
+
lines.push(sep());
|
|
1001
|
+
for (const a of actions) lines.push(ln(` ${a}`));
|
|
1002
|
+
lines.push(sep());
|
|
1003
|
+
lines.push(ln('✅ Installed — launching session manager...'));
|
|
1004
|
+
} else if (isDryRun) {
|
|
1005
|
+
lines.push(sep());
|
|
1006
|
+
lines.push(ln('Dry run — no files written'));
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
lines.push(br('╚', '╝'));
|
|
1010
|
+
|
|
1011
|
+
console.log('');
|
|
1012
|
+
for (const l of lines) console.log(` ${l}`);
|
|
1013
|
+
console.log('');
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ─── Profile System ────────────────────────────────────────────────────────
|
|
1017
|
+
|
|
1018
|
+
const PROFILE_FILE_REL = '.claude/dual-brain.profile.json';
|
|
1019
|
+
|
|
1020
|
+
function profilePath(workspace) {
|
|
1021
|
+
return join(workspace || process.cwd(), PROFILE_FILE_REL);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const PROFILES = {
|
|
1025
|
+
auto: {
|
|
1026
|
+
description: 'Adapts routing based on task risk, provider health, and outcomes',
|
|
1027
|
+
routing: { prefer_provider: 'auto', think_threshold: 'adaptive', gpt_dispatch_bias: 0 },
|
|
1028
|
+
budgets: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
|
|
1029
|
+
quality_gate: { sensitivity_floor: 'medium', dual_brain_minimum: 'high' },
|
|
1030
|
+
},
|
|
1031
|
+
balanced: {
|
|
1032
|
+
description: 'Auto-routes by complexity, uses both providers evenly',
|
|
1033
|
+
routing: { prefer_provider: 'auto', think_threshold: 'normal', gpt_dispatch_bias: 0 },
|
|
1034
|
+
budgets: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
|
|
1035
|
+
quality_gate: { sensitivity_floor: 'medium', dual_brain_minimum: 'high' },
|
|
1036
|
+
},
|
|
1037
|
+
'cost-saver': {
|
|
1038
|
+
description: 'Conservative — fewer GPT dispatches, sticks to Claude',
|
|
1039
|
+
routing: { prefer_provider: 'cheapest', think_threshold: 'strict', gpt_dispatch_bias: -20 },
|
|
1040
|
+
budgets: { session_warn_usd: 2, session_limit_usd: 5, daily_warn_usd: 8, daily_limit_usd: 20 },
|
|
1041
|
+
quality_gate: { sensitivity_floor: 'high', dual_brain_minimum: 'critical' },
|
|
1042
|
+
},
|
|
1043
|
+
'quality-first': {
|
|
1044
|
+
description: 'Aggressive — maximizes both subscriptions, dual-brain for medium+',
|
|
1045
|
+
routing: { prefer_provider: 'most-capable', think_threshold: 'relaxed', gpt_dispatch_bias: 10 },
|
|
1046
|
+
budgets: { session_warn_usd: 15, session_limit_usd: 30, daily_warn_usd: 50, daily_limit_usd: 100 },
|
|
1047
|
+
quality_gate: { sensitivity_floor: 'low', dual_brain_minimum: 'medium' },
|
|
1048
|
+
},
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
function loadProfile(workspace) {
|
|
1052
|
+
try {
|
|
1053
|
+
const data = JSON.parse(readFileSync(profilePath(workspace), 'utf8'));
|
|
1054
|
+
const name = data.active && PROFILES[data.active] ? data.active : 'auto';
|
|
1055
|
+
const profile = PROFILES[name];
|
|
1056
|
+
const custom = data.custom_overrides || {};
|
|
1057
|
+
return {
|
|
1058
|
+
name,
|
|
1059
|
+
...profile,
|
|
1060
|
+
budgets: { ...profile.budgets, ...custom.budgets },
|
|
1061
|
+
routing: { ...profile.routing, ...custom.routing },
|
|
1062
|
+
switched_at: data.switched_at || null,
|
|
1063
|
+
};
|
|
1064
|
+
} catch {
|
|
1065
|
+
return { name: 'auto', ...PROFILES.auto, switched_at: null };
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function saveProfile(workspace, name, customOverrides) {
|
|
1070
|
+
const data = { active: name, switched_at: new Date().toISOString() };
|
|
1071
|
+
if (customOverrides) data.custom_overrides = customOverrides;
|
|
1072
|
+
const target = profilePath(workspace);
|
|
1073
|
+
const tmp = target + '.tmp.' + process.pid;
|
|
1074
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
1075
|
+
renameSync(tmp, target);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ─── Subcommand: status ────────────────────────────────────────────────────
|
|
1079
|
+
|
|
1080
|
+
function launchPanel() {
|
|
1081
|
+
const panelPath = join(resolve(process.cwd()), '.claude', 'hooks', 'control-panel.mjs');
|
|
1082
|
+
const pkgPanel = join(__dirname, 'hooks', 'control-panel.mjs');
|
|
1083
|
+
const panel = existsSync(panelPath) ? panelPath : existsSync(pkgPanel) ? pkgPanel : null;
|
|
1084
|
+
if (panel) {
|
|
1085
|
+
const { status } = spawnSync(process.execPath, [panel], { stdio: 'inherit' });
|
|
1086
|
+
process.exit(status || 0);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ─── Subcommand: auth ──────────────────────────────────────────────────────
|
|
1091
|
+
|
|
1092
|
+
function cmdAuthStatus() {
|
|
1093
|
+
const state = getAuthState();
|
|
1094
|
+
if (jsonOut) {
|
|
1095
|
+
console.log(JSON.stringify({ version: VERSION, auth: state }, null, 2));
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
printAuthStatusBox(state);
|
|
1099
|
+
console.log(` Login: ${cmd('npx dual-brain auth login')}`);
|
|
1100
|
+
console.log(` Refresh: ${cmd('npx dual-brain auth refresh')}`);
|
|
1101
|
+
console.log('');
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function cmdAuthLogin() {
|
|
1105
|
+
const state = getAuthState();
|
|
1106
|
+
|
|
1107
|
+
console.log('');
|
|
1108
|
+
if (!state.claude.authed) {
|
|
1109
|
+
if (!state.claude.installed) {
|
|
1110
|
+
console.log(' 🟠 Claude is not installed.');
|
|
1111
|
+
} else {
|
|
1112
|
+
console.log(' 🟠 Claude needs login.');
|
|
1113
|
+
console.log(` Run: ${cmd('claude login')}`);
|
|
1114
|
+
console.log(' Claude uses its own browser/device flow from the CLI.');
|
|
1115
|
+
}
|
|
1116
|
+
console.log('');
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (!state.codex.authed) {
|
|
1120
|
+
if (!state.codex.installed || !state.codex.path) {
|
|
1121
|
+
console.log(' 🟢 Codex is not installed.');
|
|
1122
|
+
console.log(' Install first: npm i -g @openai/codex');
|
|
1123
|
+
console.log('');
|
|
1124
|
+
} else if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1125
|
+
console.log(' 🟢 Codex login requires an interactive terminal.');
|
|
1126
|
+
console.log(` Run: ${cmd('codex login --device-auth')}`);
|
|
1127
|
+
console.log('');
|
|
1128
|
+
} else {
|
|
1129
|
+
console.log(' 🟢 Starting Codex device auth...');
|
|
1130
|
+
console.log('');
|
|
1131
|
+
if (runCodexDeviceAuth(state.codex.path)) {
|
|
1132
|
+
console.log('');
|
|
1133
|
+
console.log(' ✅ Codex authenticated and credentials saved.');
|
|
1134
|
+
} else {
|
|
1135
|
+
console.log('');
|
|
1136
|
+
console.log(` ❌ Codex auth failed. Retry with: ${cmd('codex login --device-auth')}`);
|
|
1137
|
+
}
|
|
1138
|
+
console.log('');
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (state.claude.authed && state.codex.authed) {
|
|
1143
|
+
console.log(' ✅ Claude and Codex are already authenticated.');
|
|
1144
|
+
console.log('');
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const finalState = getAuthState();
|
|
1149
|
+
if (finalState.claude.authed && finalState.codex.authed) {
|
|
1150
|
+
printAuthStatusBox(finalState);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
printAuthStatusBox(finalState);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function cmdAuthRefresh() {
|
|
1158
|
+
let env = detectEnvironment();
|
|
1159
|
+
let codexRefreshed = false;
|
|
1160
|
+
|
|
1161
|
+
console.log('');
|
|
1162
|
+
console.log(' 🔄 Refreshing provider auth...');
|
|
1163
|
+
console.log('');
|
|
1164
|
+
|
|
1165
|
+
env = { ...env, claude: { ...env.claude, authed: false } };
|
|
1166
|
+
env = healClaudeAuth(env);
|
|
1167
|
+
|
|
1168
|
+
if (env.codex.installed && env.codex.path && process.stdin.isTTY && process.stdout.isTTY) {
|
|
1169
|
+
console.log(' 🟢 Codex device auth refresh');
|
|
1170
|
+
console.log('');
|
|
1171
|
+
codexRefreshed = runCodexDeviceAuth(env.codex.path);
|
|
1172
|
+
if (codexRefreshed) {
|
|
1173
|
+
env.codex.authed = true;
|
|
1174
|
+
env.codex.authMethod = 'device_auth';
|
|
1175
|
+
console.log('');
|
|
1176
|
+
console.log(' ✅ Codex auth refreshed and credentials saved.');
|
|
1177
|
+
console.log('');
|
|
1178
|
+
} else {
|
|
1179
|
+
console.log('');
|
|
1180
|
+
console.log(` ❌ Codex refresh failed. Retry with: ${cmd('codex login --device-auth')}`);
|
|
1181
|
+
console.log('');
|
|
1182
|
+
}
|
|
1183
|
+
} else {
|
|
1184
|
+
env = { ...env, codex: { ...env.codex, authed: false } };
|
|
1185
|
+
env = healCodexAuth(env);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (env.claude.installed && !env.claude.authed) {
|
|
1189
|
+
console.log(` 🟠 Claude refresh unavailable here. Run: ${cmd('claude login')}`);
|
|
1190
|
+
console.log('');
|
|
1191
|
+
}
|
|
1192
|
+
if (env.codex.installed && !env.codex.authed && !codexRefreshed) {
|
|
1193
|
+
console.log(` 🟢 Codex refresh incomplete. Run: ${cmd('codex login --device-auth')}`);
|
|
1194
|
+
console.log('');
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const finalState = getAuthState();
|
|
1198
|
+
if (jsonOut) {
|
|
1199
|
+
console.log(JSON.stringify({ version: VERSION, auth: finalState }, null, 2));
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
printAuthStatusBox(finalState);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function cmdAuth() {
|
|
1206
|
+
const action = positional[1] || 'status';
|
|
1207
|
+
if (action === 'status') { cmdAuthStatus(); return; }
|
|
1208
|
+
if (action === 'login') { cmdAuthLogin(); return; }
|
|
1209
|
+
if (action === 'refresh') { cmdAuthRefresh(); return; }
|
|
1210
|
+
|
|
1211
|
+
console.error(` Unknown auth command: ${action}`);
|
|
1212
|
+
console.error(` Run: ${cmd('npx dual-brain auth [status|login|refresh]')}`);
|
|
1213
|
+
process.exit(1);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// ─── Subcommand: mode ──────────────────────────────────────────────────────
|
|
1217
|
+
|
|
1218
|
+
function cmdMode() {
|
|
1219
|
+
const workspace = resolve(process.cwd());
|
|
1220
|
+
const modeArg = positional[1] || null;
|
|
1221
|
+
|
|
1222
|
+
if (!modeArg || modeArg === 'list') {
|
|
1223
|
+
const current = loadProfile(workspace);
|
|
1224
|
+
const PEMOJIS = { auto: '🤖', balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
|
|
1225
|
+
const UI_NAMES = { auto: 'Auto (default)', balanced: 'Balanced', 'cost-saver': 'Conservative', 'quality-first': 'Aggressive' };
|
|
1226
|
+
console.log('');
|
|
1227
|
+
console.log(' 🎛️ Routing modes:');
|
|
1228
|
+
console.log('');
|
|
1229
|
+
for (const [name, p] of Object.entries(PROFILES)) {
|
|
1230
|
+
const active = name === current.name ? ' ✅ active' : '';
|
|
1231
|
+
const label = UI_NAMES[name] || name;
|
|
1232
|
+
console.log(` ${PEMOJIS[name] || ' '} ${label.padEnd(15)} ${p.description}${active}`);
|
|
1233
|
+
}
|
|
1234
|
+
console.log('');
|
|
1235
|
+
console.log(` Switch: ${cmd('npx dual-brain mode <name>')}`);
|
|
1236
|
+
console.log('');
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
let resolvedMode = modeArg;
|
|
1241
|
+
if (!PROFILES[resolvedMode]) {
|
|
1242
|
+
// Try natural language alias resolution
|
|
1243
|
+
const cleaned = resolvedMode.toLowerCase().trim()
|
|
1244
|
+
.replace(/^(go|be|use|switch to|set|mode)\s+/i, '')
|
|
1245
|
+
.replace(/\s+mode$/i, '');
|
|
1246
|
+
const MODE_ALIASES = {
|
|
1247
|
+
'auto': 'auto', 'adaptive': 'auto', 'smart': 'auto', 'default': 'auto', 'normal': 'auto',
|
|
1248
|
+
'balanced': 'balanced', 'even': 'balanced', 'equal': 'balanced',
|
|
1249
|
+
'cost-saver': 'cost-saver', 'cheap': 'cost-saver', 'save': 'cost-saver', 'conservative': 'cost-saver', 'frugal': 'cost-saver', 'budget': 'cost-saver',
|
|
1250
|
+
'quality-first': 'quality-first', 'aggressive': 'quality-first', 'quality': 'quality-first', 'max': 'quality-first', 'full': 'quality-first', 'both': 'quality-first',
|
|
1251
|
+
};
|
|
1252
|
+
resolvedMode = MODE_ALIASES[cleaned] || null;
|
|
1253
|
+
if (!resolvedMode) {
|
|
1254
|
+
console.error(` Unknown profile: ${modeArg}`);
|
|
1255
|
+
console.error(` Available: ${Object.keys(PROFILES).join(', ')}`);
|
|
1256
|
+
console.error(` Aliases: cheap, aggressive, quality, budget, frugal, smart, adaptive, ...`);
|
|
1257
|
+
process.exit(1);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const profile = PROFILES[resolvedMode];
|
|
1262
|
+
|
|
1263
|
+
let customOverrides = null;
|
|
1264
|
+
try {
|
|
1265
|
+
const existing = JSON.parse(readFileSync(profilePath(workspace), 'utf8'));
|
|
1266
|
+
if (existing.custom_overrides?.budgets) {
|
|
1267
|
+
customOverrides = { budgets: existing.custom_overrides.budgets };
|
|
1268
|
+
}
|
|
1269
|
+
} catch {}
|
|
1270
|
+
|
|
1271
|
+
saveProfile(workspace, resolvedMode, customOverrides);
|
|
1272
|
+
|
|
1273
|
+
const PEMOJIS = { auto: '🤖', balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
|
|
1274
|
+
const UI_NAMES = { auto: 'Auto (default)', balanced: 'Balanced', 'cost-saver': 'Conservative', 'quality-first': 'Aggressive' };
|
|
1275
|
+
console.log('');
|
|
1276
|
+
console.log(` ✅ Mode switched: ${PEMOJIS[resolvedMode] || ''} ${UI_NAMES[resolvedMode] || resolvedMode}`);
|
|
1277
|
+
console.log(` ${profile.description}`);
|
|
1278
|
+
console.log('');
|
|
1279
|
+
console.log(' 🧭 Routing changes:');
|
|
1280
|
+
console.log(` Provider: ${profile.routing.prefer_provider}`);
|
|
1281
|
+
console.log(` 💵 Budget: $${profile.budgets.session_limit_usd}/session, $${profile.budgets.daily_limit_usd}/day`);
|
|
1282
|
+
console.log(` 🛡️ Reviews: ${profile.quality_gate.sensitivity_floor} risk+`);
|
|
1283
|
+
console.log(` 🧠 Dual-brain: ${profile.quality_gate.dual_brain_minimum} risk+`);
|
|
1284
|
+
console.log('');
|
|
1285
|
+
console.log(' 🟢 Active immediately, no restart needed.');
|
|
1286
|
+
console.log('');
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// ─── Subcommand: budget ────────────────────────────────────────────────────
|
|
1290
|
+
|
|
1291
|
+
function cmdBudget() {
|
|
1292
|
+
const workspace = resolve(process.cwd());
|
|
1293
|
+
const sessionArg = positional[1] ? parseFloat(positional[1]) : null;
|
|
1294
|
+
const dailyArg = positional[2] ? parseFloat(positional[2]) : null;
|
|
1295
|
+
|
|
1296
|
+
if (sessionArg == null) {
|
|
1297
|
+
const profile = loadProfile(workspace);
|
|
1298
|
+
console.log('');
|
|
1299
|
+
console.log(' 📊 Usage alert thresholds (estimated, not billing caps):');
|
|
1300
|
+
console.log(` Session: ⚠️ $${profile.budgets.session_warn_usd} warn · 🛑 $${profile.budgets.session_limit_usd} alert`);
|
|
1301
|
+
console.log(` Daily: ⚠️ $${profile.budgets.daily_warn_usd} warn · 🛑 $${profile.budgets.daily_limit_usd} alert`);
|
|
1302
|
+
console.log('');
|
|
1303
|
+
console.log(` Adjust: ${cmd('npx dual-brain budget <session$> [daily$]')}`);
|
|
1304
|
+
console.log(` Example: ${cmd('npx dual-brain budget 8 25')}`);
|
|
1305
|
+
console.log('');
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if (isNaN(sessionArg) || sessionArg <= 0) {
|
|
1310
|
+
console.error(' Session limit must be a positive number');
|
|
1311
|
+
process.exit(1);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const daily = (dailyArg != null && !isNaN(dailyArg) && dailyArg > 0) ? dailyArg : sessionArg * 3;
|
|
1315
|
+
|
|
1316
|
+
let existing = {};
|
|
1317
|
+
try { existing = JSON.parse(readFileSync(profilePath(workspace), 'utf8')); } catch {}
|
|
1318
|
+
|
|
1319
|
+
const customOverrides = existing.custom_overrides || {};
|
|
1320
|
+
customOverrides.budgets = {
|
|
1321
|
+
session_warn_usd: +(sessionArg * 0.6).toFixed(2),
|
|
1322
|
+
session_limit_usd: sessionArg,
|
|
1323
|
+
daily_warn_usd: +(daily * 0.6).toFixed(2),
|
|
1324
|
+
daily_limit_usd: daily,
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
const data = {
|
|
1328
|
+
active: existing.active || 'auto',
|
|
1329
|
+
switched_at: existing.switched_at || new Date().toISOString(),
|
|
1330
|
+
custom_overrides: customOverrides,
|
|
1331
|
+
};
|
|
1332
|
+
const budgetTarget = profilePath(workspace);
|
|
1333
|
+
const budgetTmp = budgetTarget + '.tmp.' + process.pid;
|
|
1334
|
+
writeFileSync(budgetTmp, JSON.stringify(data, null, 2) + '\n');
|
|
1335
|
+
renameSync(budgetTmp, budgetTarget);
|
|
1336
|
+
|
|
1337
|
+
console.log('');
|
|
1338
|
+
console.log(' ✅ Budget updated:');
|
|
1339
|
+
console.log(` Session: ⚠️ $${customOverrides.budgets.session_warn_usd} warn · 🛑 $${sessionArg} limit`);
|
|
1340
|
+
console.log(` Daily: ⚠️ $${customOverrides.budgets.daily_warn_usd} warn · 🛑 $${daily} limit`);
|
|
1341
|
+
console.log('');
|
|
1342
|
+
console.log(' 🟢 Active immediately, no restart needed.');
|
|
1343
|
+
console.log('');
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// ─── Subcommand: explain ───────────────────────────────────────────────────
|
|
1347
|
+
|
|
1348
|
+
function cmdExplain() {
|
|
1349
|
+
const workspace = resolve(process.cwd());
|
|
1350
|
+
const hooksDir = join(workspace, '.claude', 'hooks');
|
|
1351
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1352
|
+
const logFile = join(hooksDir, `usage-${today}.jsonl`);
|
|
1353
|
+
|
|
1354
|
+
if (!existsSync(logFile)) {
|
|
1355
|
+
console.log('');
|
|
1356
|
+
console.log(' 💤 No routing decisions recorded today.');
|
|
1357
|
+
console.log(' Start a Claude Code session and the tier enforcer will log decisions.');
|
|
1358
|
+
console.log('');
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
let lines;
|
|
1363
|
+
try {
|
|
1364
|
+
lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
|
|
1365
|
+
} catch {
|
|
1366
|
+
console.log(' Could not read usage log.');
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
let lastRec = null;
|
|
1371
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1372
|
+
try {
|
|
1373
|
+
const entry = JSON.parse(lines[i]);
|
|
1374
|
+
if (entry.type === 'tier_recommendation') { lastRec = entry; break; }
|
|
1375
|
+
} catch {}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
if (!lastRec) {
|
|
1379
|
+
console.log('');
|
|
1380
|
+
console.log(' 💤 No routing decisions found in today\'s log.');
|
|
1381
|
+
console.log(' The tier enforcer logs decisions when Agent tool is used.');
|
|
1382
|
+
console.log('');
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const profile = loadProfile(workspace);
|
|
1387
|
+
|
|
1388
|
+
console.log('');
|
|
1389
|
+
console.log(' 🧭 Last Routing Decision');
|
|
1390
|
+
console.log(' ' + '─'.repeat(40));
|
|
1391
|
+
console.log(` 🕐 Time: ${lastRec.timestamp?.slice(11, 19) || 'unknown'}`);
|
|
1392
|
+
console.log(` 🔎 Detected: ${lastRec.detected_tier || 'unknown'} tier`);
|
|
1393
|
+
console.log(` 🧠 Recommended: ${lastRec.recommended_model || 'unknown'}`);
|
|
1394
|
+
console.log(` 🎯 Actual: ${lastRec.actual_model || 'unknown'}`);
|
|
1395
|
+
console.log(` ${lastRec.followed ? '✅' : '⚠️'} Followed: ${lastRec.followed ? 'yes' : 'no'}`);
|
|
1396
|
+
console.log(` 🎛️ Profile: ${profile.name}`);
|
|
1397
|
+
console.log('');
|
|
1398
|
+
|
|
1399
|
+
if (!lastRec.followed) {
|
|
1400
|
+
console.log(' ⚠️ Recommendation was overridden. This may mean:');
|
|
1401
|
+
console.log(' - The task needed a different model (valid override)');
|
|
1402
|
+
console.log(' - The subagent_type forced a specific tier');
|
|
1403
|
+
console.log(` - Profile "${profile.name}" adjusted the threshold`);
|
|
1404
|
+
} else {
|
|
1405
|
+
console.log(' ✅ Routing matched the recommendation.');
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
let total = 0, followed = 0;
|
|
1409
|
+
for (const line of lines) {
|
|
1410
|
+
try {
|
|
1411
|
+
const e = JSON.parse(line);
|
|
1412
|
+
if (e.type === 'tier_recommendation') { total++; if (e.followed) followed++; }
|
|
1413
|
+
} catch {}
|
|
1414
|
+
}
|
|
1415
|
+
const pct = total > 0 ? Math.round((followed / total) * 100) : 0;
|
|
1416
|
+
console.log('');
|
|
1417
|
+
console.log(` Today: ${followed}/${total} recommendations followed (${pct}%)`);
|
|
1418
|
+
console.log('');
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
1422
|
+
|
|
1423
|
+
async function main() {
|
|
1424
|
+
if (subcommand === 'auth' || restoreNpmFlag) {
|
|
1425
|
+
restoreNpmToken();
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (subcommand === 'status') {
|
|
1429
|
+
launchPanel();
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (subcommand === 'auth') { cmdAuth(); return; }
|
|
1433
|
+
if (subcommand === 'mode') { cmdMode(); return; }
|
|
1434
|
+
if (subcommand === 'budget') { cmdBudget(); return; }
|
|
1435
|
+
if (subcommand === 'explain') { cmdExplain(); return; }
|
|
1436
|
+
|
|
1437
|
+
let env = detectEnvironment();
|
|
1438
|
+
const startupUpdateInfo = (subcommand === 'update' || dryRun || jsonOut)
|
|
1439
|
+
? null
|
|
1440
|
+
: checkForUpdate(env.workspace);
|
|
1441
|
+
|
|
1442
|
+
if (
|
|
1443
|
+
startupUpdateInfo?.updateAvailable &&
|
|
1444
|
+
process.stdin.isTTY &&
|
|
1445
|
+
process.stdout.isTTY &&
|
|
1446
|
+
!process.env.CI
|
|
1447
|
+
) {
|
|
1448
|
+
console.log('');
|
|
1449
|
+
const shouldUpdate = await promptForUpdate(startupUpdateInfo);
|
|
1450
|
+
console.log('');
|
|
1451
|
+
if (shouldUpdate) {
|
|
1452
|
+
env = healClaudeAuth(env);
|
|
1453
|
+
env = healCodexAuth(env);
|
|
1454
|
+
const updateMode = resolveMode(env);
|
|
1455
|
+
const updateActions = performUpdate(env.workspace, env, updateMode);
|
|
1456
|
+
printReport(env, updateMode, updateActions);
|
|
1457
|
+
if (process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) {
|
|
1458
|
+
launchPanel();
|
|
1459
|
+
}
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Auth self-healing: try to fix expired/missing auth silently
|
|
1465
|
+
env = healClaudeAuth(env);
|
|
1466
|
+
env = healCodexAuth(env);
|
|
1467
|
+
|
|
1468
|
+
// Interactive auth guidance if still not authed
|
|
1469
|
+
if (!env.claude.authed || !env.codex.authed) {
|
|
1470
|
+
env = await authGuidance(env);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Re-resolve mode after healing
|
|
1474
|
+
const mode = resolveMode(env);
|
|
1475
|
+
|
|
1476
|
+
if (dryRun || jsonOut) {
|
|
1477
|
+
if (jsonOut) {
|
|
1478
|
+
console.log(JSON.stringify({ version: VERSION, env, mode }, null, 2));
|
|
1479
|
+
} else {
|
|
1480
|
+
printReport(env, mode, null, true);
|
|
1481
|
+
}
|
|
1482
|
+
process.exit(0);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (subcommand === 'update') {
|
|
1486
|
+
const actions = performUpdate(env.workspace, env, mode);
|
|
1487
|
+
printReport(env, mode, actions);
|
|
1488
|
+
process.exit(0);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// Check for replit-tools on Replit
|
|
1492
|
+
if (env.isReplit && !env.hasReplitTools) {
|
|
1493
|
+
console.log('');
|
|
1494
|
+
console.log(' ⚠️ replit-tools not found — recommended for Replit environments.');
|
|
1495
|
+
console.log(' Dual-brain works best alongside replit-tools for persistent auth,');
|
|
1496
|
+
console.log(' session management, and shell integration.');
|
|
1497
|
+
console.log('');
|
|
1498
|
+
console.log(` Install: ${cmd('npx -y data-tools')}`);
|
|
1499
|
+
console.log('');
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const actions = install(env.workspace, env, mode);
|
|
1503
|
+
|
|
1504
|
+
// Write a standalone shell-hook.sh so users can source it from .bashrc.
|
|
1505
|
+
// Non-interactive installs (npm postinstall) just print the hint; interactive
|
|
1506
|
+
// installs also write the file so it's ready to source.
|
|
1507
|
+
const shellHookSrc = join(__dirname, 'shell-hook.sh');
|
|
1508
|
+
const shellHookDst = join(env.workspace, '.dualbrain', 'shell-hook.sh');
|
|
1509
|
+
try {
|
|
1510
|
+
mkdirSync(join(env.workspace, '.dualbrain'), { recursive: true });
|
|
1511
|
+
if (existsSync(shellHookSrc)) {
|
|
1512
|
+
cpSync(shellHookSrc, shellHookDst);
|
|
1513
|
+
actions.push('✓ .dualbrain/shell-hook.sh (source from .bashrc to auto-launch)');
|
|
1514
|
+
}
|
|
1515
|
+
} catch { /* non-fatal — shell hook is optional */ }
|
|
1516
|
+
|
|
1517
|
+
// On Replit, print a one-liner hint for the shell hook if .bashrc doesn't have it yet.
|
|
1518
|
+
if (env.isReplit) {
|
|
1519
|
+
let bashrcHasDualBrain = false;
|
|
1520
|
+
const bashrcPath = join(process.env.HOME || '', '.bashrc');
|
|
1521
|
+
try {
|
|
1522
|
+
bashrcHasDualBrain = readFileSync(bashrcPath, 'utf8').includes('dual-brain');
|
|
1523
|
+
} catch { /* .bashrc may not exist */ }
|
|
1524
|
+
|
|
1525
|
+
if (!bashrcHasDualBrain) {
|
|
1526
|
+
actions.push('');
|
|
1527
|
+
actions.push('Shell hook (optional — shows dual-brain on new terminal):');
|
|
1528
|
+
actions.push(' dual-brain shell-hook >> ~/.bashrc');
|
|
1529
|
+
actions.push(' # or: source .dualbrain/shell-hook.sh');
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
printReport(env, mode, actions);
|
|
1534
|
+
|
|
1535
|
+
// After install, launch the session manager (interactive TTY only)
|
|
1536
|
+
if (process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) {
|
|
1537
|
+
launchPanel();
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
main();
|