@timo972/cc-router 0.12.4 → 0.13.0-rc.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/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/dist/cli/account-flows.js +422 -0
- package/dist/cli/cli-errors.js +37 -0
- package/dist/cli/cmd-accounts.js +215 -203
- package/dist/cli/cmd-setup.js +5 -186
- package/dist/cli/cmd-status.js +85 -24
- package/dist/cli/index.js +15 -3
- package/dist/config/manager.js +16 -7
- package/dist/providers/anthropic/claude-cli.js +104 -0
- package/dist/providers/anthropic/scopes.js +5 -0
- package/dist/providers/anthropic/usage-refresher.js +38 -31
- package/dist/providers/openai/device-oauth.js +20 -1
- package/dist/providers/xai/device-oauth.js +9 -0
- package/dist/proxy/account-info-cache.js +69 -20
- package/dist/proxy/account-post-validation.js +23 -0
- package/dist/proxy/account-refresh.js +59 -0
- package/dist/proxy/account-replace.js +12 -12
- package/dist/proxy/server.js +66 -14
- package/dist/proxy/token-refresher.js +36 -1
- package/dist/proxy/types.js +19 -0
- package/dist/telemetry/contracts.js +2 -0
- package/dist/ui/Dashboard.js +72 -6
- package/dist/ui/accountsApi.js +21 -0
- package/dist/utils/browser.js +36 -0
- package/dist/utils/expiry-input.js +21 -0
- package/dist/utils/token-extractor.js +9 -5
- package/package.json +1 -1
package/dist/cli/cmd-setup.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { select, input, confirm, password } from "@inquirer/prompts";
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { detectPlatform, isMacos } from "../utils/platform.js";
|
|
4
|
-
import { extractFromKeychainDetailed, extractFromCredentialsFileDetailed, formatExpiry, redactToken, } from "../utils/token-extractor.js";
|
|
5
|
-
import { validateToken } from "../utils/token-validator.js";
|
|
6
4
|
import { writeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
|
|
7
5
|
import { saveAccounts } from "../proxy/token-refresher.js";
|
|
8
6
|
import { loadAccounts, accountsFileExists, readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
|
|
9
7
|
import { PROXY_PORT } from "../config/paths.js";
|
|
10
|
-
import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS } from "../proxy/types.js";
|
|
11
8
|
import { existsSync } from "fs";
|
|
12
9
|
import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, getNetworkExtensionStatus, openNetworkExtensionSettings, } from "../interceptor/mitmproxy-manager.js";
|
|
13
10
|
import { printDesktopSupportExplainer, printNetworkExtensionInstructions } from "./cmd-client.js";
|
|
14
|
-
import {
|
|
11
|
+
import { collectClaudeAccount } from "./account-flows.js";
|
|
12
|
+
import { withSetupTelemetryFlush, } from "../telemetry/setup-diagnostics.js";
|
|
15
13
|
// ─── Public registration ──────────────────────────────────────────────────────
|
|
16
14
|
export function registerSetup(program) {
|
|
17
15
|
program
|
|
@@ -28,142 +26,6 @@ function printDiagnosticId(outcome) {
|
|
|
28
26
|
return;
|
|
29
27
|
console.log(chalk.gray(` Diagnostic ID: ${outcome.diagnosticId}`));
|
|
30
28
|
}
|
|
31
|
-
// ─── Shared single-account setup (also used by `accounts add`) ───────────────
|
|
32
|
-
/**
|
|
33
|
-
* Same step, but also hands back the setup attempt so the caller can mark the
|
|
34
|
-
* `persistence` stage and the final outcome once the account is written.
|
|
35
|
-
*/
|
|
36
|
-
export async function setupSingleAccountWithAttempt(index) {
|
|
37
|
-
const choices = [];
|
|
38
|
-
if (isMacos()) {
|
|
39
|
-
choices.push({ name: "Extract automatically from macOS Keychain (recommended)", value: "keychain" });
|
|
40
|
-
}
|
|
41
|
-
choices.push({ name: "Read from ~/.claude/.credentials.json", value: "credentials" });
|
|
42
|
-
choices.push({ name: "Paste tokens manually", value: "manual" });
|
|
43
|
-
const method = await select({
|
|
44
|
-
message: "How do you want to add the tokens?",
|
|
45
|
-
choices,
|
|
46
|
-
});
|
|
47
|
-
const attempt = createSetupAttempt({
|
|
48
|
-
provider: "anthropic",
|
|
49
|
-
method: method === "keychain"
|
|
50
|
-
? "macos_keychain"
|
|
51
|
-
: method === "credentials" ? "claude_credentials_file" : "manual_token",
|
|
52
|
-
});
|
|
53
|
-
attempt.stageCompleted("credential_source_selection");
|
|
54
|
-
// Shared by reference: the file-extraction fallback swaps in a manual-token attempt.
|
|
55
|
-
const current = { attempt };
|
|
56
|
-
let reached = "credential_read";
|
|
57
|
-
try {
|
|
58
|
-
return await collectAnthropicAccount(index, method, current, stage => { reached = stage; });
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
// A thrown prompt or extraction error must still close the funnel record.
|
|
62
|
-
const outcome = failAttemptFromError(current.attempt, error, reached);
|
|
63
|
-
if (outcome)
|
|
64
|
-
printDiagnosticId(outcome);
|
|
65
|
-
throw error;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
async function collectAnthropicAccount(index, method, current, reached) {
|
|
69
|
-
let attempt = current.attempt;
|
|
70
|
-
let tokens = null;
|
|
71
|
-
if (method === "keychain") {
|
|
72
|
-
process.stdout.write(chalk.gray(" Extracting from Keychain... "));
|
|
73
|
-
const extraction = await extractFromKeychainDetailed();
|
|
74
|
-
if (extraction.ok) {
|
|
75
|
-
tokens = extraction.tokens;
|
|
76
|
-
console.log(chalk.green("✓"));
|
|
77
|
-
console.log(chalk.gray(` Token: ${redactToken(tokens.accessToken)}`));
|
|
78
|
-
console.log(chalk.gray(` Expiry: ${formatExpiry(tokens.expiresAt)}`));
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
console.log(chalk.red("✗"));
|
|
82
|
-
console.log(chalk.yellow(" Could not find credentials in Keychain."));
|
|
83
|
-
console.log(chalk.gray(" Make sure Claude Code is logged in: run `claude login` first."));
|
|
84
|
-
printDiagnosticId(attempt.stageFailed(extraction.error, "credential_read"));
|
|
85
|
-
const retry = await confirm({ message: "Try another extraction method?", default: true });
|
|
86
|
-
attempt.cancelled();
|
|
87
|
-
if (!retry)
|
|
88
|
-
return { account: null, attempt };
|
|
89
|
-
return setupSingleAccountWithAttempt(index);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (method === "credentials") {
|
|
93
|
-
const extraction = extractFromCredentialsFileDetailed();
|
|
94
|
-
if (extraction.ok) {
|
|
95
|
-
tokens = extraction.tokens;
|
|
96
|
-
console.log(chalk.green(` ✓ Found credentials in ~/.claude/.credentials.json`));
|
|
97
|
-
console.log(chalk.gray(` Token: ${redactToken(tokens.accessToken)}`));
|
|
98
|
-
console.log(chalk.gray(` Expiry: ${formatExpiry(tokens.expiresAt)}`));
|
|
99
|
-
}
|
|
100
|
-
else {
|
|
101
|
-
console.log(chalk.red(" ✗ ~/.claude/.credentials.json not found or unreadable."));
|
|
102
|
-
console.log(chalk.gray(" Make sure Claude Code is installed and you've run `claude login`."));
|
|
103
|
-
const retry = await confirm({ message: "Paste tokens manually instead?", default: true });
|
|
104
|
-
if (!retry) {
|
|
105
|
-
printDiagnosticId(attempt.stageFailed(extraction.error, "credential_read"));
|
|
106
|
-
attempt.cancelled();
|
|
107
|
-
return { account: null, attempt };
|
|
108
|
-
}
|
|
109
|
-
// The file-based attempt failed; the pasted tokens are a manual-token setup.
|
|
110
|
-
printDiagnosticId(attempt.failed(extraction.error, "credential_read"));
|
|
111
|
-
attempt = createSetupAttempt({ provider: "anthropic", method: "manual_token" });
|
|
112
|
-
current.attempt = attempt;
|
|
113
|
-
attempt.stageCompleted("credential_source_selection");
|
|
114
|
-
tokens = await promptManualTokens();
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
if (method === "manual") {
|
|
118
|
-
tokens = await promptManualTokens();
|
|
119
|
-
}
|
|
120
|
-
if (!tokens) {
|
|
121
|
-
attempt.cancelled();
|
|
122
|
-
return { account: null, attempt };
|
|
123
|
-
}
|
|
124
|
-
attempt.stageCompleted("credential_read");
|
|
125
|
-
attempt.stageCompleted("credential_parse");
|
|
126
|
-
reached("token_validation");
|
|
127
|
-
const defaultId = `max-account-${index}`;
|
|
128
|
-
const accountId = await input({
|
|
129
|
-
message: "Account ID (press Enter to accept default):",
|
|
130
|
-
default: defaultId,
|
|
131
|
-
validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
|
|
132
|
-
});
|
|
133
|
-
process.stdout.write(chalk.gray(" Validating tokens against Anthropic... "));
|
|
134
|
-
const validation = await validateToken(tokens.accessToken);
|
|
135
|
-
if (validation.valid) {
|
|
136
|
-
console.log(chalk.green("✓ Valid"));
|
|
137
|
-
attempt.stageCompleted("token_validation");
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
140
|
-
console.log(chalk.red("✗ Invalid"));
|
|
141
|
-
console.log(chalk.yellow(` Reason: ${validation.reason}`));
|
|
142
|
-
printDiagnosticId(attempt.stageFailed(validation.diagnostic, "token_validation"));
|
|
143
|
-
console.log(chalk.gray(" The token will be saved but may not work until refreshed."));
|
|
144
|
-
const keepAnyway = await confirm({ message: "Save this account anyway?", default: false });
|
|
145
|
-
if (!keepAnyway) {
|
|
146
|
-
attempt.cancelled();
|
|
147
|
-
return { account: null, attempt };
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return {
|
|
151
|
-
account: {
|
|
152
|
-
id: accountId,
|
|
153
|
-
tokens,
|
|
154
|
-
healthy: validation.valid,
|
|
155
|
-
busy: false,
|
|
156
|
-
requestCount: 0,
|
|
157
|
-
errorCount: 0,
|
|
158
|
-
lastUsed: 0,
|
|
159
|
-
lastRefresh: 0,
|
|
160
|
-
consecutiveErrors: 0,
|
|
161
|
-
rateLimits: { ...DEFAULT_RATE_LIMITS },
|
|
162
|
-
...ACCOUNT_USER_DEFAULTS,
|
|
163
|
-
},
|
|
164
|
-
attempt,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
29
|
// ─── Full wizard ──────────────────────────────────────────────────────────────
|
|
168
30
|
export async function runSetupWizard({ addMode }) {
|
|
169
31
|
const platform = detectPlatform();
|
|
@@ -220,13 +82,9 @@ export async function runSetupWizard({ addMode }) {
|
|
|
220
82
|
}
|
|
221
83
|
}
|
|
222
84
|
}
|
|
223
|
-
if (!addMode && isMacos()) {
|
|
224
|
-
console.log(chalk.cyan(" Tip: to add multiple accounts, you need to:"));
|
|
225
|
-
console.log(chalk.gray(" 1. Log in to Claude Code with account 1 (already done if you use CC normally)"));
|
|
226
|
-
console.log(chalk.gray(" 2. Extract tokens → log out → log in with account 2 → extract → repeat\n"));
|
|
227
|
-
}
|
|
228
85
|
let numAccounts = 1;
|
|
229
86
|
if (!addMode) {
|
|
87
|
+
console.log(chalk.gray(" Tip: each account can be signed in directly with the browser method — no logging Claude Code out between them.\n"));
|
|
230
88
|
const { number } = await import("@inquirer/prompts");
|
|
231
89
|
numAccounts = await number({
|
|
232
90
|
message: "How many accounts do you want to configure now?",
|
|
@@ -240,14 +98,8 @@ export async function runSetupWizard({ addMode }) {
|
|
|
240
98
|
for (let i = 0; i < numAccounts; i++) {
|
|
241
99
|
const label = numAccounts > 1 ? `${i + 1}/${numAccounts}` : "";
|
|
242
100
|
console.log(chalk.bold(`\n${"━".repeat(40)}\n Account ${label}\n${"━".repeat(40)}\n`));
|
|
243
|
-
if (i > 0 && isMacos()) {
|
|
244
|
-
console.log(chalk.yellow(` Before extracting account ${i + 1}:\n` +
|
|
245
|
-
` 1. Run: ${chalk.white("claude logout")}\n` +
|
|
246
|
-
` 2. Run: ${chalk.white("claude login")} (log in with your next Max account)\n`));
|
|
247
|
-
await confirm({ message: "Ready?", default: true });
|
|
248
|
-
}
|
|
249
101
|
const existingCount = hasExisting ? loadAccounts().length : 0;
|
|
250
|
-
const { account, attempt } = await
|
|
102
|
+
const { account, attempt } = await collectClaudeAccount({ index: i + 1 + existingCount });
|
|
251
103
|
if (account) {
|
|
252
104
|
newAccounts.push(account);
|
|
253
105
|
savedAttempts.push(attempt);
|
|
@@ -379,42 +231,9 @@ async function runPostSetupFlow(accountCount) {
|
|
|
379
231
|
function printDone(accountCount) {
|
|
380
232
|
console.log(chalk.bold(`\n${"━".repeat(40)}\n All done — ${accountCount} account(s) ready\n${"━".repeat(40)}\n`));
|
|
381
233
|
console.log(` Start the proxy: ${chalk.cyan("cc-router start")}`);
|
|
382
|
-
console.log(` Add more accounts: ${chalk.cyan("cc-router setup --add")}`);
|
|
234
|
+
console.log(` Add more accounts: ${chalk.cyan("cc-router accounts login")} or ${chalk.cyan("cc-router setup --add")}`);
|
|
383
235
|
console.log(` Dashboard: ${chalk.cyan("cc-router status")}\n`);
|
|
384
236
|
}
|
|
385
|
-
// ─── Manual token input ───────────────────────────────────────────────────────
|
|
386
|
-
async function promptManualTokens() {
|
|
387
|
-
console.log(chalk.gray("\n You can find your tokens by running:\n" +
|
|
388
|
-
" macOS: security find-generic-password -s 'Claude Code-credentials' -w\n" +
|
|
389
|
-
" Linux/Windows: cat ~/.claude/.credentials.json\n"));
|
|
390
|
-
const accessToken = await password({
|
|
391
|
-
message: "Paste accessToken (sk-ant-oat01-...):",
|
|
392
|
-
mask: "•",
|
|
393
|
-
validate: (v) => v.startsWith("sk-ant-oat01-") || v.startsWith("sk-ant-")
|
|
394
|
-
? true
|
|
395
|
-
: "Must start with sk-ant-oat01-",
|
|
396
|
-
});
|
|
397
|
-
const refreshToken = await password({
|
|
398
|
-
message: "Paste refreshToken (sk-ant-ort01-...):",
|
|
399
|
-
mask: "•",
|
|
400
|
-
validate: (v) => v.startsWith("sk-ant-ort01-") || v.startsWith("sk-ant-")
|
|
401
|
-
? true
|
|
402
|
-
: "Must start with sk-ant-ort01-",
|
|
403
|
-
});
|
|
404
|
-
const useDefaultExpiry = await confirm({
|
|
405
|
-
message: "Use default expiry (8 hours from now)?",
|
|
406
|
-
default: true,
|
|
407
|
-
});
|
|
408
|
-
const expiresAt = useDefaultExpiry
|
|
409
|
-
? Date.now() + 8 * 60 * 60 * 1000
|
|
410
|
-
: new Date(await input({ message: "Paste expiresAt (ISO date or ms timestamp):" })).getTime();
|
|
411
|
-
return {
|
|
412
|
-
accessToken,
|
|
413
|
-
refreshToken,
|
|
414
|
-
expiresAt,
|
|
415
|
-
scopes: ["user:inference", "user:profile"],
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
237
|
// ─── Client-mode setup (from wizard) ─────────────────────────────────────────
|
|
419
238
|
async function runClientSetupFromWizard() {
|
|
420
239
|
console.log(chalk.bold("\n🔗 Client Mode — Connect to a CC-Router server\n"));
|
package/dist/cli/cmd-status.js
CHANGED
|
@@ -98,7 +98,10 @@ async function dashboardLoop(port) {
|
|
|
98
98
|
// `pendingIntent` is set by the Dashboard component via `onIntent`;
|
|
99
99
|
// it defaults to "quit" so Ctrl+C (exitOnCtrlC) does the right thing
|
|
100
100
|
// without the Dashboard ever firing onIntent.
|
|
101
|
-
|
|
101
|
+
// Kept at the full union rather than narrowed to its initializer: the
|
|
102
|
+
// real value is written from inside `onIntent`, which control-flow
|
|
103
|
+
// analysis can't see, so without the cast every later branch is `never`.
|
|
104
|
+
let pendingIntent = { kind: "quit" };
|
|
102
105
|
const instance = render(createElement(Dashboard, {
|
|
103
106
|
port,
|
|
104
107
|
baseUrl: target.baseUrl,
|
|
@@ -121,34 +124,95 @@ async function dashboardLoop(port) {
|
|
|
121
124
|
}
|
|
122
125
|
// Ink may have paused stdin — resume it so inquirer can read input.
|
|
123
126
|
process.stdin.resume();
|
|
124
|
-
if (pendingIntent === "quit")
|
|
127
|
+
if (pendingIntent.kind === "quit")
|
|
125
128
|
return;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
if (pendingIntent.kind === "addAccount") {
|
|
130
|
+
// Intent: addAccount — run the OAuth flow, then POST the resulting
|
|
131
|
+
// tokens to the server we're connected to (local or remote).
|
|
132
|
+
console.log();
|
|
133
|
+
console.log(chalk.cyan("→ Adding a new account..."));
|
|
134
|
+
console.log();
|
|
135
|
+
const added = await runAddAccountFlow(target);
|
|
136
|
+
if (added) {
|
|
137
|
+
console.log(chalk.green(`\n✓ Account "${added}" added. Returning to dashboard...\n`));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
console.log(chalk.yellow("\n No account added. Returning to dashboard...\n"));
|
|
141
|
+
}
|
|
142
|
+
// Fall through → loop re-renders the dashboard
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// Intent: reauth — sign in again under the existing id and replace the
|
|
146
|
+
// stored credentials in place, keeping the account's caps and settings.
|
|
130
147
|
console.log();
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
148
|
+
console.log(chalk.cyan(`→ Re-authenticating ${pendingIntent.id}...`));
|
|
149
|
+
const reauthed = await runReauthFlow(target, pendingIntent);
|
|
150
|
+
console.log(reauthed
|
|
151
|
+
? chalk.green(`\n✓ Account "${reauthed}" re-authenticated. Returning to dashboard...\n`)
|
|
152
|
+
: chalk.yellow("\n No account re-authenticated. Returning to dashboard...\n"));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Runs the provider's sign-in with the id fixed, then POSTs the replacement
|
|
157
|
+
* record to /cc-router/accounts. Returns the id on success, or null if the
|
|
158
|
+
* operator aborted / an error occurred.
|
|
159
|
+
*/
|
|
160
|
+
async function runReauthFlow(target, intent) {
|
|
161
|
+
let attempt;
|
|
162
|
+
try {
|
|
163
|
+
const { collectReauthRecord } = await import("./account-flows.js");
|
|
164
|
+
const result = await collectReauthRecord({
|
|
165
|
+
id: intent.id,
|
|
166
|
+
provider: intent.provider,
|
|
167
|
+
...(intent.email ? { email: intent.email } : {}),
|
|
168
|
+
});
|
|
169
|
+
if (!result)
|
|
170
|
+
return null;
|
|
171
|
+
attempt = result.attempt;
|
|
172
|
+
const res = await fetch(`${target.baseUrl}/cc-router/accounts`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: {
|
|
175
|
+
"content-type": "application/json",
|
|
176
|
+
...target.headers,
|
|
177
|
+
},
|
|
178
|
+
// `replace: true` is what makes this an in-place re-auth rather than a
|
|
179
|
+
// duplicate-id rejection.
|
|
180
|
+
body: JSON.stringify({ ...result.record, replace: true }),
|
|
181
|
+
signal: AbortSignal.timeout(5_000),
|
|
182
|
+
});
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
const text = await res.text().catch(() => "");
|
|
185
|
+
console.error(chalk.red(`\n✗ Server rejected the replacement: HTTP ${res.status}`));
|
|
186
|
+
if (text)
|
|
187
|
+
console.error(chalk.gray(` ${text}`));
|
|
188
|
+
attempt.failed(classifyHttpSetupFailure("persistence", res.status, "dashboard re-auth rejected"), "persistence");
|
|
189
|
+
return null;
|
|
134
190
|
}
|
|
135
|
-
|
|
136
|
-
|
|
191
|
+
attempt.stageCompleted("persistence");
|
|
192
|
+
attempt.succeeded();
|
|
193
|
+
return result.record.id;
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
console.error(chalk.red(`\n✗ Re-authentication failed: ${err.message}`));
|
|
197
|
+
if (attempt) {
|
|
198
|
+
const outcome = failAttemptFromError(attempt, err, "persistence");
|
|
199
|
+
if (outcome?.unexpected)
|
|
200
|
+
console.error(chalk.gray(` Diagnostic ID: ${outcome.diagnosticId}`));
|
|
137
201
|
}
|
|
138
|
-
|
|
202
|
+
return null;
|
|
139
203
|
}
|
|
140
204
|
}
|
|
141
205
|
/**
|
|
142
|
-
* Runs the
|
|
143
|
-
*
|
|
144
|
-
*
|
|
206
|
+
* Runs the shared Claude sign-in flow, then POSTs the resulting record to
|
|
207
|
+
* /cc-router/accounts on the active target. Returns the new id on success, or
|
|
208
|
+
* null if the user aborted / an error occurred.
|
|
145
209
|
*/
|
|
146
210
|
async function runAddAccountFlow(target) {
|
|
147
211
|
let attempt;
|
|
148
212
|
try {
|
|
149
|
-
const {
|
|
213
|
+
const { collectClaudeAccount, accountToRecord } = await import("./account-flows.js");
|
|
150
214
|
// The index shown in the flow is just for display, pick something neutral.
|
|
151
|
-
const setup = await
|
|
215
|
+
const setup = await collectClaudeAccount({ index: 1 });
|
|
152
216
|
attempt = setup.attempt;
|
|
153
217
|
const account = setup.account;
|
|
154
218
|
if (!account)
|
|
@@ -159,13 +223,10 @@ async function runAddAccountFlow(target) {
|
|
|
159
223
|
"content-type": "application/json",
|
|
160
224
|
...target.headers,
|
|
161
225
|
},
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
expiresAt: account.tokens.expiresAt,
|
|
167
|
-
scopes: account.tokens.scopes,
|
|
168
|
-
}),
|
|
226
|
+
// The record form is what the endpoint stores, and it is the only shape
|
|
227
|
+
// that survives a `setup-token` credential: a hand-built body would have
|
|
228
|
+
// to invent a refresh token that account does not have.
|
|
229
|
+
body: JSON.stringify({ ...accountToRecord(account) }),
|
|
169
230
|
signal: AbortSignal.timeout(5_000),
|
|
170
231
|
});
|
|
171
232
|
if (!res.ok) {
|
package/dist/cli/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { registerCliTargets } from "./cmd-cli-targets.js";
|
|
|
16
16
|
import { getCurrentVersion, checkForUpdate, printUpdateBanner } from "../utils/self-update.js";
|
|
17
17
|
import { recordApplicationStart, shutdownTelemetryWithin } from "../telemetry/facade.js";
|
|
18
18
|
import { isTelemetryTracingActive } from "../telemetry/runtime.js";
|
|
19
|
+
import { describeExpectedCliError } from "./cli-errors.js";
|
|
19
20
|
const program = new Command();
|
|
20
21
|
program
|
|
21
22
|
.name("cc-router")
|
|
@@ -32,8 +33,8 @@ Examples:
|
|
|
32
33
|
$ cc-router models list # List dynamically discovered provider models
|
|
33
34
|
$ cc-router logs # View proxy logs (background mode)
|
|
34
35
|
$ cc-router accounts list # Show Claude, ChatGPT, and Grok accounts
|
|
35
|
-
$ cc-router accounts
|
|
36
|
-
$ cc-router accounts
|
|
36
|
+
$ cc-router accounts login # Sign in to a Claude, OpenAI or Grok account
|
|
37
|
+
$ cc-router accounts reauth <id> # Sign an existing account in again, same id
|
|
37
38
|
$ cc-router revert # Restore Claude Code to normal (remove all proxy config)
|
|
38
39
|
$ cc-router docker up # Full stack: cc-router + LiteLLM in Docker
|
|
39
40
|
$ cc-router client connect <url> # Route Claude Code through a remote CC-Router
|
|
@@ -73,7 +74,18 @@ program.hook("preAction", () => { recordApplicationStart(); });
|
|
|
73
74
|
// that call process.exit() early simply lose their in-flight telemetry. A
|
|
74
75
|
// `start` that is serving requests keeps its runtime: startServer() resolves
|
|
75
76
|
// once it is listening, and its signal handler owns telemetry shutdown.
|
|
76
|
-
void program.parseAsync()
|
|
77
|
+
void program.parseAsync()
|
|
78
|
+
// A missing `claude` binary, a sign-in the operator closed, a mistyped
|
|
79
|
+
// provider or Ctrl+C at a prompt is news, not a crash: one line, no stack.
|
|
80
|
+
// Anything else is rethrown untouched so a real bug still surfaces in full.
|
|
81
|
+
.catch((error) => {
|
|
82
|
+
const expected = describeExpectedCliError(error);
|
|
83
|
+
if (!expected)
|
|
84
|
+
throw error;
|
|
85
|
+
console.error(expected.message);
|
|
86
|
+
process.exitCode = expected.exitCode;
|
|
87
|
+
})
|
|
88
|
+
.finally(() => {
|
|
77
89
|
if (isTelemetryTracingActive())
|
|
78
90
|
return undefined;
|
|
79
91
|
return shutdownTelemetryWithin(500);
|
package/dist/config/manager.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from "fs";
|
|
2
2
|
import { randomBytes } from "crypto";
|
|
3
3
|
import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
|
|
4
|
-
import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent } from "../proxy/types.js";
|
|
4
|
+
import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent, withInheritedSettings } from "../proxy/types.js";
|
|
5
5
|
export const DEFAULT_PROXY_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
6
6
|
/** Owner-only permissions for files/dirs that hold OAuth tokens or the proxy secret. */
|
|
7
7
|
const SECRET_FILE_MODE = 0o600;
|
|
@@ -87,9 +87,15 @@ export function writeAnthropicAccountsPreservingOtherProviders(data, path = ACCO
|
|
|
87
87
|
export function upsertAccountRecord(record) {
|
|
88
88
|
ensureConfigDir();
|
|
89
89
|
const existing = readAccountsRaw();
|
|
90
|
+
// Compare normalised providers: a Claude record written before provider
|
|
91
|
+
// tags existed has none, and a strict comparison appended a tagged
|
|
92
|
+
// duplicate next to it instead of replacing it.
|
|
93
|
+
const sameAccount = (a) => a.id === record.id && normalizeAccountProvider(a) === normalizeAccountProvider(record);
|
|
94
|
+
const previous = existing.find(sameAccount);
|
|
90
95
|
const next = [
|
|
91
|
-
...existing.filter(a => !(a
|
|
92
|
-
|
|
96
|
+
...existing.filter(a => !sameAccount(a)),
|
|
97
|
+
// A re-authentication replaces credentials, not the operator's settings.
|
|
98
|
+
previous ? withInheritedSettings(record, previous) : record,
|
|
93
99
|
];
|
|
94
100
|
writeAccountsAtomicToPath(ACCOUNTS_PATH, next);
|
|
95
101
|
}
|
|
@@ -167,7 +173,9 @@ export function loadOpenAIAccounts(path) {
|
|
|
167
173
|
id: a.id,
|
|
168
174
|
provider: "openai_subscription",
|
|
169
175
|
accessToken: a.accessToken,
|
|
170
|
-
|
|
176
|
+
// Refresh tokens are optional on the record only for Anthropic
|
|
177
|
+
// `setup-token` credentials; OpenAI records always carry one.
|
|
178
|
+
refreshToken: a.refreshToken ?? "",
|
|
171
179
|
expiresAt: a.expiresAt,
|
|
172
180
|
enabled: a.enabled !== false,
|
|
173
181
|
// Without this the flag is lost on restart and the dead refresh token is
|
|
@@ -210,7 +218,8 @@ export function loadXaiAccounts(path) {
|
|
|
210
218
|
id: a.id,
|
|
211
219
|
provider: "xai_subscription",
|
|
212
220
|
accessToken: a.accessToken,
|
|
213
|
-
|
|
221
|
+
// See loadOpenAIAccounts: xAI records always carry a refresh token.
|
|
222
|
+
refreshToken: a.refreshToken ?? "",
|
|
214
223
|
expiresAt: a.expiresAt,
|
|
215
224
|
enabled: a.enabled !== false,
|
|
216
225
|
...(Array.isArray(a.scopes) ? { scopes: a.scopes } : {}),
|
|
@@ -291,7 +300,7 @@ export function generateProxySecret() {
|
|
|
291
300
|
return "cc-rtr-" + randomBytes(16).toString("hex");
|
|
292
301
|
}
|
|
293
302
|
// ─── Accounts ─────────────────────────────────────────────────────────────────
|
|
294
|
-
function deserialize(records) {
|
|
303
|
+
export function deserialize(records) {
|
|
295
304
|
return records.filter(a => a.provider === undefined || a.provider === "anthropic_subscription").map(a => ({
|
|
296
305
|
id: a.id,
|
|
297
306
|
tokens: {
|
|
@@ -328,7 +337,7 @@ export function serialize(accounts) {
|
|
|
328
337
|
id: a.id,
|
|
329
338
|
provider: "anthropic_subscription",
|
|
330
339
|
accessToken: a.tokens.accessToken,
|
|
331
|
-
refreshToken: a.tokens.refreshToken,
|
|
340
|
+
...(a.tokens.refreshToken ? { refreshToken: a.tokens.refreshToken } : {}),
|
|
332
341
|
expiresAt: a.tokens.expiresAt,
|
|
333
342
|
scopes: a.tokens.scopes,
|
|
334
343
|
enabled: a.enabled,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { execFile as nodeExecFile, spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { SetupDiagnosticError } from "../../telemetry/setup-diagnostics.js";
|
|
3
|
+
import { extractFromCredentialsFileDetailed, extractFromKeychainDetailed, } from "../../utils/token-extractor.js";
|
|
4
|
+
import { isMacos } from "../../utils/platform.js";
|
|
5
|
+
/** `claude setup-token` credentials are long-lived and never refreshable. */
|
|
6
|
+
export const LONG_LIVED_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const CLAUDE = "claude";
|
|
8
|
+
const TOKEN_PATTERN = /sk-ant-oat01-[A-Za-z0-9_-]{20,}/g;
|
|
9
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
10
|
+
function defaultExtract() {
|
|
11
|
+
return isMacos()
|
|
12
|
+
? extractFromKeychainDetailed()
|
|
13
|
+
: Promise.resolve(extractFromCredentialsFileDetailed());
|
|
14
|
+
}
|
|
15
|
+
/** Confirms the Claude Code CLI is runnable; the error message tells the operator the import fallback. */
|
|
16
|
+
export function resolveClaudeCli(deps = {}) {
|
|
17
|
+
const execFile = deps.execFile ?? nodeExecFile;
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
execFile(CLAUDE, ["--version"], error => {
|
|
20
|
+
if (!error) {
|
|
21
|
+
resolve(CLAUDE);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
reject(new SetupDiagnosticError("Claude Code CLI not found on PATH. Install it or use `cc-router accounts add claude` to import an existing login.", { stage: "credential_read", reason: "not_found", expected: true }, { cause: error }));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Last token in the (ANSI-stripped) output: Ink repaints the success frame several times. */
|
|
29
|
+
export function extractLongLivedToken(output) {
|
|
30
|
+
const matches = output.replace(ANSI, "").match(TOKEN_PATTERN);
|
|
31
|
+
return matches ? matches[matches.length - 1] : null;
|
|
32
|
+
}
|
|
33
|
+
function waitForExit(child) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
child.once("error", reject);
|
|
36
|
+
child.once("exit", code => resolve(code));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `claude auth login --claudeai [--email]` with the terminal handed over to
|
|
41
|
+
* Claude Code. The snapshot comparison is what stops a login that exited 0
|
|
42
|
+
* without writing from re-importing the previous account.
|
|
43
|
+
*/
|
|
44
|
+
export async function loginWithClaudeCli(options, deps = {}) {
|
|
45
|
+
const command = await resolveClaudeCli(deps);
|
|
46
|
+
const extract = deps.extract ?? defaultExtract;
|
|
47
|
+
const before = await extract();
|
|
48
|
+
const args = ["auth", "login", "--claudeai", ...(options.email ? ["--email", options.email] : [])];
|
|
49
|
+
const child = (deps.spawn ?? nodeSpawn)(command, args, { stdio: "inherit" });
|
|
50
|
+
const code = await waitForExit(child);
|
|
51
|
+
if (code !== 0) {
|
|
52
|
+
throw new SetupDiagnosticError("claude auth login was cancelled or failed", {
|
|
53
|
+
stage: "credential_read",
|
|
54
|
+
reason: "user_cancelled",
|
|
55
|
+
expected: true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const after = await extract();
|
|
59
|
+
if (!after.ok)
|
|
60
|
+
throw after.error;
|
|
61
|
+
if (before.ok && before.tokens.accessToken === after.tokens.accessToken) {
|
|
62
|
+
throw new SetupDiagnosticError("claude auth login finished but no new credentials were stored.", {
|
|
63
|
+
stage: "credential_read",
|
|
64
|
+
reason: "not_found",
|
|
65
|
+
expected: true,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return after.tokens;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `claude setup-token` with stdout mirrored to the terminal and scanned for
|
|
72
|
+
* the token line. Ink keys raw mode on stdin, which stays inherited, and its
|
|
73
|
+
* cursor escapes pass through untouched, so the UI renders as usual. `null`
|
|
74
|
+
* means the command ended without a recognisable token; the caller falls back
|
|
75
|
+
* to a paste prompt. The captured token is never logged.
|
|
76
|
+
*/
|
|
77
|
+
export async function createLongLivedTokenWithClaudeCli(deps = {}) {
|
|
78
|
+
const command = await resolveClaudeCli(deps);
|
|
79
|
+
const mirror = deps.stdout ?? process.stdout;
|
|
80
|
+
const child = (deps.spawn ?? nodeSpawn)(command, ["setup-token"], {
|
|
81
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
82
|
+
});
|
|
83
|
+
let output = "";
|
|
84
|
+
const stdout = child.stdout;
|
|
85
|
+
stdout?.on("data", (chunk) => {
|
|
86
|
+
output += chunk.toString();
|
|
87
|
+
mirror.write(chunk);
|
|
88
|
+
});
|
|
89
|
+
const drained = stdout
|
|
90
|
+
? new Promise(resolve => {
|
|
91
|
+
stdout.once("end", resolve);
|
|
92
|
+
stdout.once("close", resolve);
|
|
93
|
+
stdout.once("error", () => resolve());
|
|
94
|
+
})
|
|
95
|
+
: Promise.resolve();
|
|
96
|
+
const code = await waitForExit(child);
|
|
97
|
+
// A child can exit before the pipe has been fully read; the token often sits
|
|
98
|
+
// in that last unread chunk.
|
|
99
|
+
await drained;
|
|
100
|
+
if (code !== 0)
|
|
101
|
+
return null;
|
|
102
|
+
const accessToken = extractLongLivedToken(output);
|
|
103
|
+
return accessToken ? { accessToken } : null;
|
|
104
|
+
}
|