@timo972/cc-router 0.12.4 → 0.13.0-rc.1
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 +41 -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 +93 -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/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,47 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
### ⚠️ Breaking
|
|
12
|
+
|
|
13
|
+
- `cc-router accounts add-openai`, `login-openai`, `add-grok` and `login-grok`
|
|
14
|
+
are gone. Use `accounts login openai`, `accounts add openai`,
|
|
15
|
+
`accounts login grok` and `accounts add grok`. `accounts add` now takes the
|
|
16
|
+
provider as its first argument (`accounts add claude` for the old behaviour)
|
|
17
|
+
and only imports existing credentials; browser sign-in is `accounts login`.
|
|
18
|
+
- The dashboard's `onIntent` callback receives an object (`{ kind: "quit" }`,
|
|
19
|
+
`{ kind: "addAccount" }`, `{ kind: "reauth", ... }`) instead of a string.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `cc-router accounts login claude` drives `claude auth login --claudeai`
|
|
24
|
+
(with `--email` prefill) and imports the new credentials; `--long-lived`
|
|
25
|
+
drives `claude setup-token` for a one-year, refresh-less token.
|
|
26
|
+
- `cc-router accounts reauth <id>` re-signs an account in under the same id,
|
|
27
|
+
looking up its provider and cached email. The dashboard does the same on
|
|
28
|
+
`l` with an account selected.
|
|
29
|
+
- Device-code sign-ins (OpenAI, Grok) open the verification page in the
|
|
30
|
+
browser; the OpenAI page receives the code and, on re-auth, the email.
|
|
31
|
+
`CC_ROUTER_NO_BROWSER=1` keeps the browser closed and prints the URL only.
|
|
32
|
+
- Claude accounts without a refresh token (`claude setup-token`) are
|
|
33
|
+
accepted, never refreshed, skipped by the usage and identity fetchers, and
|
|
34
|
+
marked `token-only`; they flip to `re-auth required` when they expire.
|
|
35
|
+
- `POST /cc-router/accounts/:id/refresh` refreshes one account. The
|
|
36
|
+
dashboard's `R` uses it when an account is selected and reloads the whole
|
|
37
|
+
pool otherwise.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Pressing `n` (and now `l`) in the dashboard no longer kills the process
|
|
42
|
+
before the sign-in prompt can take input. Ink unrefs stdin when it
|
|
43
|
+
unmounts and nothing ref'd it again, so with no other work pending the
|
|
44
|
+
process exited while inquirer was still waiting — reported as
|
|
45
|
+
`User force closed the prompt with 0 null`. The dashboard now refs stdin
|
|
46
|
+
for the duration of the interactive flow.
|
|
47
|
+
- Re-authenticating an account replaces its credentials only. Previously the
|
|
48
|
+
replacement was built from the freshly collected record, so a disabled
|
|
49
|
+
account came back enabled and custom session/weekly caps reset to 100 —
|
|
50
|
+
both on the live pool and in `accounts.json`.
|
|
51
|
+
|
|
11
52
|
---
|
|
12
53
|
|
|
13
54
|
## [0.12.4] — 2026-09-18
|
package/README.md
CHANGED
|
@@ -103,8 +103,8 @@ claude
|
|
|
103
103
|
|
|
104
104
|
That's it. On first `start` you're asked how to run the router (background, foreground, or
|
|
105
105
|
auto-start on boot) and the choice is remembered; `cc-router start --reconfigure` changes
|
|
106
|
-
it later. Adding more accounts is `cc-router
|
|
107
|
-
dashboard.
|
|
106
|
+
it later. Adding more accounts is `cc-router accounts login`, re-signing one in is
|
|
107
|
+
`cc-router accounts reauth <id>`, and `cc-router status` opens the dashboard.
|
|
108
108
|
|
|
109
109
|
Per-platform token extraction, Codex CLI, Docker and everything else lives in
|
|
110
110
|
[the docs](docs/).
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every interactive "get me a credential for this provider" flow, in one place.
|
|
3
|
+
*
|
|
4
|
+
* These functions collect an account and hand it back — they never persist.
|
|
5
|
+
* The commands that call them decide where the record goes (disk, a running
|
|
6
|
+
* proxy, or both), which is what lets `setup`, `accounts add`, the dashboard's
|
|
7
|
+
* add-account key and the re-authentication path all share one sign-in.
|
|
8
|
+
*/
|
|
9
|
+
import { select, input, confirm, password } from "@inquirer/prompts";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import { isMacos } from "../utils/platform.js";
|
|
12
|
+
import { extractFromKeychainDetailed, extractFromCredentialsFileDetailed, formatExpiry, redactToken, } from "../utils/token-extractor.js";
|
|
13
|
+
import { validateToken } from "../utils/token-validator.js";
|
|
14
|
+
import { parseExpiryInput } from "../utils/expiry-input.js";
|
|
15
|
+
import { serialize, loadOpenAIAccounts, loadXaiAccounts } from "../config/manager.js";
|
|
16
|
+
import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS } from "../proxy/types.js";
|
|
17
|
+
import { loginWithClaudeCli, createLongLivedTokenWithClaudeCli, LONG_LIVED_TOKEN_TTL_MS, } from "../providers/anthropic/claude-cli.js";
|
|
18
|
+
import { createOpenAIAccountRecord } from "../providers/openai/account-record.js";
|
|
19
|
+
import { loginOpenAIWithDeviceCode } from "../providers/openai/device-oauth.js";
|
|
20
|
+
import { importGrokCliAuth } from "../providers/xai/import-auth.js";
|
|
21
|
+
import { loginXaiWithDeviceCode } from "../providers/xai/device-oauth.js";
|
|
22
|
+
import { createSetupAttempt, failAttemptFromError, SetupDiagnosticError, } from "../telemetry/setup-diagnostics.js";
|
|
23
|
+
/**
|
|
24
|
+
* The only place a Claude sign-in method is translated into a telemetry method
|
|
25
|
+
* name. Keeping the map here means a new method cannot reach the wire under an
|
|
26
|
+
* ad-hoc string.
|
|
27
|
+
*/
|
|
28
|
+
const CLAUDE_METHOD_TELEMETRY = {
|
|
29
|
+
cli_login: "claude_cli_login",
|
|
30
|
+
setup_token: "claude_setup_token",
|
|
31
|
+
keychain: "macos_keychain",
|
|
32
|
+
credentials: "claude_credentials_file",
|
|
33
|
+
manual: "manual_token",
|
|
34
|
+
};
|
|
35
|
+
function claudeMethodChoices(offer) {
|
|
36
|
+
const login = [
|
|
37
|
+
{ name: "Sign in with the browser (claude auth login — recommended)", value: "cli_login" },
|
|
38
|
+
{ name: "Create a long-lived token (claude setup-token — does not change Claude Code's login)", value: "setup_token" },
|
|
39
|
+
];
|
|
40
|
+
const imports = [
|
|
41
|
+
...(isMacos() ? [{ name: "Extract automatically from macOS Keychain", value: "keychain" }] : []),
|
|
42
|
+
{ name: "Read from ~/.claude/.credentials.json", value: "credentials" },
|
|
43
|
+
{ name: "Paste tokens manually", value: "manual" },
|
|
44
|
+
];
|
|
45
|
+
return offer === "login" ? login : offer === "import" ? imports : [...login, ...imports];
|
|
46
|
+
}
|
|
47
|
+
/** Only an unexpected failure gets a diagnostic ID worth quoting in a bug report. */
|
|
48
|
+
function printDiagnosticId(outcome) {
|
|
49
|
+
if (!outcome.unexpected)
|
|
50
|
+
return;
|
|
51
|
+
console.log(chalk.gray(` Diagnostic ID: ${outcome.diagnosticId}`));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Collect one Claude account. Also hands back the setup attempt so the caller
|
|
55
|
+
* can mark the `persistence` stage and the final outcome once the account is
|
|
56
|
+
* written.
|
|
57
|
+
*/
|
|
58
|
+
export async function collectClaudeAccount(options) {
|
|
59
|
+
const method = options.method ?? await select({
|
|
60
|
+
message: "How do you want to add the account?",
|
|
61
|
+
choices: claudeMethodChoices(options.offer ?? "all"),
|
|
62
|
+
});
|
|
63
|
+
const attempt = createSetupAttempt({ provider: "anthropic", method: CLAUDE_METHOD_TELEMETRY[method] });
|
|
64
|
+
attempt.stageCompleted("credential_source_selection");
|
|
65
|
+
// Shared by reference: the file-extraction fallback swaps in a manual-token attempt.
|
|
66
|
+
const current = { attempt };
|
|
67
|
+
let reached = "credential_read";
|
|
68
|
+
try {
|
|
69
|
+
return await collectAnthropicAccount(options, method, current, stage => { reached = stage; });
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// A thrown prompt or extraction error must still close the funnel record.
|
|
73
|
+
// `failAttemptFromError` is terminal-guarded, so the attempt ends exactly
|
|
74
|
+
// once here, and `printDiagnosticId` stays silent for an expected failure.
|
|
75
|
+
const outcome = failAttemptFromError(current.attempt, error, reached);
|
|
76
|
+
if (outcome)
|
|
77
|
+
printDiagnosticId(outcome);
|
|
78
|
+
// An expected failure — `claude` missing, or a browser sign-in the operator
|
|
79
|
+
// closed — is this one account declined, not a dead run. Returning null
|
|
80
|
+
// puts it on the same footing as the import methods, which already do, so
|
|
81
|
+
// the setup wizard prints "Skipped account N" and keeps the accounts it
|
|
82
|
+
// collected before this one instead of losing them to a stack trace.
|
|
83
|
+
if (error instanceof SetupDiagnosticError && error.classification.expected) {
|
|
84
|
+
console.log(chalk.yellow(`\n ✗ ${error.message}\n`));
|
|
85
|
+
return { account: null, attempt: current.attempt };
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function collectAnthropicAccount(options, method, current, reached) {
|
|
91
|
+
let attempt = current.attempt;
|
|
92
|
+
let tokens = null;
|
|
93
|
+
if (method === "cli_login") {
|
|
94
|
+
console.log(chalk.gray("\n Handing the terminal to Claude Code. Sign in with the account you want to add.\n"));
|
|
95
|
+
tokens = await loginWithClaudeCli({ email: options.email }, undefined);
|
|
96
|
+
console.log(chalk.green(` ✓ Signed in — token ${redactToken(tokens.accessToken)}, expires ${formatExpiry(tokens.expiresAt)}`));
|
|
97
|
+
console.log(chalk.gray(" Note: Claude Code on this machine is now logged in as this account."));
|
|
98
|
+
}
|
|
99
|
+
if (method === "setup_token") {
|
|
100
|
+
console.log(chalk.gray("\n Handing the terminal to Claude Code to create a long-lived token.\n"));
|
|
101
|
+
const captured = await createLongLivedTokenWithClaudeCli(undefined);
|
|
102
|
+
let accessToken = captured?.accessToken;
|
|
103
|
+
if (!accessToken) {
|
|
104
|
+
console.log(chalk.yellow("\n Could not read the token from claude setup-token's output."));
|
|
105
|
+
accessToken = await password({
|
|
106
|
+
message: "Paste the token (sk-ant-oat01-...):",
|
|
107
|
+
mask: "•",
|
|
108
|
+
validate: v => v.startsWith("sk-ant-oat01-") || "Must start with sk-ant-oat01-",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const useDefaultExpiry = await confirm({ message: "Token valid for 1 year (default)?", default: true });
|
|
112
|
+
const expiresAt = useDefaultExpiry
|
|
113
|
+
? Date.now() + LONG_LIVED_TOKEN_TTL_MS
|
|
114
|
+
: await promptExpiry();
|
|
115
|
+
tokens = { accessToken, refreshToken: undefined, expiresAt, scopes: ["user:inference"] };
|
|
116
|
+
console.log(chalk.gray(" This token has no refresh token and the inference scope only: usage and identity metadata are unavailable for it."));
|
|
117
|
+
}
|
|
118
|
+
if (method === "keychain") {
|
|
119
|
+
process.stdout.write(chalk.gray(" Extracting from Keychain... "));
|
|
120
|
+
const extraction = await extractFromKeychainDetailed();
|
|
121
|
+
if (extraction.ok) {
|
|
122
|
+
tokens = extraction.tokens;
|
|
123
|
+
console.log(chalk.green("✓"));
|
|
124
|
+
console.log(chalk.gray(` Token: ${redactToken(tokens.accessToken)}`));
|
|
125
|
+
console.log(chalk.gray(` Expiry: ${formatExpiry(tokens.expiresAt)}`));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.log(chalk.red("✗"));
|
|
129
|
+
console.log(chalk.yellow(" Could not find credentials in Keychain."));
|
|
130
|
+
console.log(chalk.gray(" Make sure Claude Code is logged in: run `claude login` first."));
|
|
131
|
+
printDiagnosticId(attempt.stageFailed(extraction.error, "credential_read"));
|
|
132
|
+
const retry = await confirm({ message: "Try another extraction method?", default: true });
|
|
133
|
+
attempt.cancelled();
|
|
134
|
+
if (!retry)
|
|
135
|
+
return { account: null, attempt };
|
|
136
|
+
return collectClaudeAccount({ ...options, method: undefined });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (method === "credentials") {
|
|
140
|
+
const extraction = extractFromCredentialsFileDetailed();
|
|
141
|
+
if (extraction.ok) {
|
|
142
|
+
tokens = extraction.tokens;
|
|
143
|
+
console.log(chalk.green(` ✓ Found credentials in ~/.claude/.credentials.json`));
|
|
144
|
+
console.log(chalk.gray(` Token: ${redactToken(tokens.accessToken)}`));
|
|
145
|
+
console.log(chalk.gray(` Expiry: ${formatExpiry(tokens.expiresAt)}`));
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
console.log(chalk.red(" ✗ ~/.claude/.credentials.json not found or unreadable."));
|
|
149
|
+
console.log(chalk.gray(" Make sure Claude Code is installed and you've run `claude login`."));
|
|
150
|
+
const retry = await confirm({ message: "Paste tokens manually instead?", default: true });
|
|
151
|
+
if (!retry) {
|
|
152
|
+
printDiagnosticId(attempt.stageFailed(extraction.error, "credential_read"));
|
|
153
|
+
attempt.cancelled();
|
|
154
|
+
return { account: null, attempt };
|
|
155
|
+
}
|
|
156
|
+
// The file-based attempt failed; the pasted tokens are a manual-token setup.
|
|
157
|
+
printDiagnosticId(attempt.failed(extraction.error, "credential_read"));
|
|
158
|
+
attempt = createSetupAttempt({ provider: "anthropic", method: "manual_token" });
|
|
159
|
+
current.attempt = attempt;
|
|
160
|
+
attempt.stageCompleted("credential_source_selection");
|
|
161
|
+
tokens = await promptManualTokens();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (method === "manual") {
|
|
165
|
+
tokens = await promptManualTokens();
|
|
166
|
+
}
|
|
167
|
+
if (!tokens) {
|
|
168
|
+
attempt.cancelled();
|
|
169
|
+
return { account: null, attempt };
|
|
170
|
+
}
|
|
171
|
+
attempt.stageCompleted("credential_read");
|
|
172
|
+
attempt.stageCompleted("credential_parse");
|
|
173
|
+
reached("token_validation");
|
|
174
|
+
const accountId = options.fixedId ?? await input({
|
|
175
|
+
message: "Account ID (press Enter to accept default):",
|
|
176
|
+
default: `max-account-${options.index}`,
|
|
177
|
+
validate: v => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
|
|
178
|
+
});
|
|
179
|
+
process.stdout.write(chalk.gray(" Validating tokens against Anthropic... "));
|
|
180
|
+
const validation = await validateToken(tokens.accessToken);
|
|
181
|
+
if (validation.valid) {
|
|
182
|
+
console.log(chalk.green("✓ Valid"));
|
|
183
|
+
attempt.stageCompleted("token_validation");
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
console.log(chalk.red("✗ Invalid"));
|
|
187
|
+
console.log(chalk.yellow(` Reason: ${validation.reason}`));
|
|
188
|
+
printDiagnosticId(attempt.stageFailed(validation.diagnostic, "token_validation"));
|
|
189
|
+
console.log(chalk.gray(" The token will be saved but may not work until refreshed."));
|
|
190
|
+
const keepAnyway = await confirm({ message: "Save this account anyway?", default: false });
|
|
191
|
+
if (!keepAnyway) {
|
|
192
|
+
attempt.cancelled();
|
|
193
|
+
return { account: null, attempt };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
account: {
|
|
198
|
+
id: accountId,
|
|
199
|
+
tokens,
|
|
200
|
+
healthy: validation.valid,
|
|
201
|
+
busy: false,
|
|
202
|
+
requestCount: 0,
|
|
203
|
+
errorCount: 0,
|
|
204
|
+
lastUsed: 0,
|
|
205
|
+
lastRefresh: 0,
|
|
206
|
+
consecutiveErrors: 0,
|
|
207
|
+
rateLimits: { ...DEFAULT_RATE_LIMITS },
|
|
208
|
+
...ACCOUNT_USER_DEFAULTS,
|
|
209
|
+
},
|
|
210
|
+
attempt,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// ─── Manual token input ───────────────────────────────────────────────────────
|
|
214
|
+
/**
|
|
215
|
+
* The refresh token stays required here: a pasted long-lived `setup-token`
|
|
216
|
+
* credential has none, and it has its own method rather than this one.
|
|
217
|
+
*/
|
|
218
|
+
async function promptManualTokens() {
|
|
219
|
+
console.log(chalk.gray("\n You can find your tokens by running:\n" +
|
|
220
|
+
" macOS: security find-generic-password -s 'Claude Code-credentials' -w\n" +
|
|
221
|
+
" Linux/Windows: cat ~/.claude/.credentials.json\n"));
|
|
222
|
+
const accessToken = await password({
|
|
223
|
+
message: "Paste accessToken (sk-ant-oat01-...):",
|
|
224
|
+
mask: "•",
|
|
225
|
+
validate: (v) => v.startsWith("sk-ant-oat01-") || v.startsWith("sk-ant-")
|
|
226
|
+
? true
|
|
227
|
+
: "Must start with sk-ant-oat01-",
|
|
228
|
+
});
|
|
229
|
+
const refreshToken = await password({
|
|
230
|
+
message: "Paste refreshToken (sk-ant-ort01-...):",
|
|
231
|
+
mask: "•",
|
|
232
|
+
validate: (v) => v.startsWith("sk-ant-ort01-") || v.startsWith("sk-ant-")
|
|
233
|
+
? true
|
|
234
|
+
: "Must start with sk-ant-ort01-",
|
|
235
|
+
});
|
|
236
|
+
const useDefaultExpiry = await confirm({
|
|
237
|
+
message: "Use default expiry (8 hours from now)?",
|
|
238
|
+
default: true,
|
|
239
|
+
});
|
|
240
|
+
const expiresAt = useDefaultExpiry
|
|
241
|
+
? Date.now() + 8 * 60 * 60 * 1000
|
|
242
|
+
: await promptExpiry();
|
|
243
|
+
return {
|
|
244
|
+
accessToken,
|
|
245
|
+
refreshToken,
|
|
246
|
+
expiresAt,
|
|
247
|
+
scopes: ["user:inference", "user:profile"],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function promptOpenAIAccountId(fixed) {
|
|
251
|
+
if (fixed)
|
|
252
|
+
return Promise.resolve(fixed);
|
|
253
|
+
return input({
|
|
254
|
+
message: "OpenAI account ID:",
|
|
255
|
+
default: `openai-account-${loadOpenAIAccounts().length + 1}`,
|
|
256
|
+
validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/** Device-code sign-in. The browser is opened by the device flow itself. */
|
|
260
|
+
export async function loginOpenAIAccount(options = {}) {
|
|
261
|
+
const attempt = createSetupAttempt({ provider: "openai", method: "device_oauth" });
|
|
262
|
+
let reached = "device_code_request";
|
|
263
|
+
try {
|
|
264
|
+
const accountId = await promptOpenAIAccountId(options.accountId);
|
|
265
|
+
console.log(chalk.cyan("\nOpenAI Codex device login"));
|
|
266
|
+
console.log(chalk.gray("This will open no local callback server. You will approve the login in your browser.\n"));
|
|
267
|
+
const record = await loginOpenAIWithDeviceCode({
|
|
268
|
+
accountId,
|
|
269
|
+
...(options.email ? { loginHint: options.email } : {}),
|
|
270
|
+
onDeviceCode: (code) => {
|
|
271
|
+
console.log(chalk.bold("1. Open this URL (opening it for you if possible):"));
|
|
272
|
+
console.log(` ${chalk.cyan(code.verificationUrl)}`);
|
|
273
|
+
console.log(chalk.bold("2. Enter this code if the page does not fill it in:"));
|
|
274
|
+
console.log(` ${chalk.cyan(code.userCode)}`);
|
|
275
|
+
if (options.email)
|
|
276
|
+
console.log(chalk.bold(`3. Sign in as ${chalk.cyan(options.email)}`));
|
|
277
|
+
console.log(chalk.gray("\nWaiting for authorization..."));
|
|
278
|
+
},
|
|
279
|
+
onStageCompleted: (stage) => {
|
|
280
|
+
attempt.stageCompleted(stage);
|
|
281
|
+
reached = stage;
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
return { record, attempt };
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
endFailedAttempt(attempt, error, reached);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Hand-pasted OpenAI credentials, for when the device flow is not an option. */
|
|
292
|
+
export async function importOpenAIAccount(options = {}) {
|
|
293
|
+
const attempt = createSetupAttempt({ provider: "openai", method: "manual_token" });
|
|
294
|
+
attempt.stageCompleted("credential_source_selection");
|
|
295
|
+
let reached = "credential_read";
|
|
296
|
+
try {
|
|
297
|
+
const id = await promptOpenAIAccountId(options.accountId);
|
|
298
|
+
const accessToken = await password({
|
|
299
|
+
message: "OpenAI access token:",
|
|
300
|
+
mask: "*",
|
|
301
|
+
validate: (v) => v.trim().length > 0 || "Access token is required",
|
|
302
|
+
});
|
|
303
|
+
const refreshToken = await password({
|
|
304
|
+
message: "OpenAI refresh token:",
|
|
305
|
+
mask: "*",
|
|
306
|
+
validate: (v) => v.trim().length > 0 || "Refresh token is required",
|
|
307
|
+
});
|
|
308
|
+
const expiresAt = await input({
|
|
309
|
+
message: "Access token expiry (Unix ms):",
|
|
310
|
+
default: String(Date.now() + 60 * 60 * 1000),
|
|
311
|
+
validate: (v) => Number.isFinite(Number(v)) && Number(v) > 0 || "Enter a positive Unix timestamp in milliseconds",
|
|
312
|
+
});
|
|
313
|
+
const scopes = await input({
|
|
314
|
+
message: "Scopes:",
|
|
315
|
+
default: "openid profile email offline_access",
|
|
316
|
+
});
|
|
317
|
+
attempt.stageCompleted("credential_read");
|
|
318
|
+
reached = "credential_parse";
|
|
319
|
+
const record = createOpenAIAccountRecord({ id, accessToken, refreshToken, expiresAt, scopes });
|
|
320
|
+
attempt.stageCompleted("credential_parse");
|
|
321
|
+
return { record, attempt };
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
endFailedAttempt(attempt, error, reached);
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// ─── Grok / xAI ───────────────────────────────────────────────────────────────
|
|
329
|
+
function promptGrokAccountId(fixed, fallback) {
|
|
330
|
+
if (fixed)
|
|
331
|
+
return Promise.resolve(fixed);
|
|
332
|
+
return input({
|
|
333
|
+
message: "Grok account ID:",
|
|
334
|
+
default: fallback,
|
|
335
|
+
validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
/** Device-code sign-in for a Grok / xAI account. */
|
|
339
|
+
export async function loginGrokAccount(options = {}) {
|
|
340
|
+
const accountId = await promptGrokAccountId(options.accountId, loadXaiAccounts().length === 0 ? "grok" : `grok-${loadXaiAccounts().length + 1}`);
|
|
341
|
+
console.log(chalk.cyan("\nGrok device login"));
|
|
342
|
+
console.log(chalk.gray("Approve the login in your browser. No local callback server is used.\n"));
|
|
343
|
+
return loginXaiWithDeviceCode({
|
|
344
|
+
accountId,
|
|
345
|
+
onDeviceCode: (code) => {
|
|
346
|
+
console.log(chalk.bold("1. Open this URL:"));
|
|
347
|
+
console.log(` ${chalk.cyan(code.verificationUrl)}`);
|
|
348
|
+
console.log(chalk.bold("2. Enter this code if the page does not fill it in:"));
|
|
349
|
+
console.log(` ${chalk.cyan(code.userCode)}\n`);
|
|
350
|
+
console.log(chalk.gray("Waiting for authorization..."));
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** Copy an existing Grok CLI login out of ~/.grok/auth.json. */
|
|
355
|
+
export async function importGrokAccount(options = {}) {
|
|
356
|
+
let imported;
|
|
357
|
+
try {
|
|
358
|
+
imported = importGrokCliAuth();
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
362
|
+
console.log(chalk.red(`\n✗ ${message}\n`));
|
|
363
|
+
console.log(chalk.gray(" Or sign in here: cc-router accounts login grok\n"));
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
const id = await promptGrokAccountId(options.accountId, imported.id);
|
|
367
|
+
return { ...imported, id };
|
|
368
|
+
}
|
|
369
|
+
/** Runs the provider's login with the id fixed. Returns null when the operator cancels. */
|
|
370
|
+
export async function collectReauthRecord(target, options = {}) {
|
|
371
|
+
console.log(chalk.cyan(`\nRe-authenticating "${target.id}" (${target.provider === "openai_subscription" ? "openai" : "claude"})`
|
|
372
|
+
+ (target.email ? ` — sign in as ${chalk.bold(target.email)}` : "") + "\n"));
|
|
373
|
+
if (target.provider === "openai_subscription") {
|
|
374
|
+
const { record, attempt } = await loginOpenAIAccount({
|
|
375
|
+
accountId: target.id,
|
|
376
|
+
...(target.email ? { email: target.email } : {}),
|
|
377
|
+
});
|
|
378
|
+
return { record: credentialsOnly(record), attempt };
|
|
379
|
+
}
|
|
380
|
+
const { account, attempt } = await collectClaudeAccount({
|
|
381
|
+
index: 1,
|
|
382
|
+
fixedId: target.id,
|
|
383
|
+
...(target.email ? { email: target.email } : {}),
|
|
384
|
+
offer: "login",
|
|
385
|
+
...(options.longLived ? { method: "setup_token" } : {}),
|
|
386
|
+
});
|
|
387
|
+
return account ? { record: credentialsOnly(accountToRecord(account)), attempt } : null;
|
|
388
|
+
}
|
|
389
|
+
/** An explicit expiry: ISO date or Unix milliseconds, validated as typed. */
|
|
390
|
+
async function promptExpiry() {
|
|
391
|
+
const raw = await input({
|
|
392
|
+
message: "Paste expiresAt (ISO date or ms timestamp):",
|
|
393
|
+
validate: v => parseExpiryInput(v) !== null || "Enter an ISO date (2027-01-01) or a Unix timestamp in milliseconds",
|
|
394
|
+
});
|
|
395
|
+
return parseExpiryInput(raw);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* A re-authentication hands over new credentials and nothing else: the
|
|
399
|
+
* replaced account's enabled state and caps are the operator's, and both the
|
|
400
|
+
* live replace and the on-disk upsert carry them over when the record omits
|
|
401
|
+
* them. A freshly collected account would otherwise ship the defaults.
|
|
402
|
+
*/
|
|
403
|
+
function credentialsOnly(record) {
|
|
404
|
+
const { enabled: _enabled, sessionLimitPercent: _session, weeklyLimitPercent: _weekly, ...credentials } = record;
|
|
405
|
+
return credentials;
|
|
406
|
+
}
|
|
407
|
+
/** The on-disk form of a collected Claude account. */
|
|
408
|
+
export function accountToRecord(account) {
|
|
409
|
+
return serialize([account])[0];
|
|
410
|
+
}
|
|
411
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
412
|
+
/**
|
|
413
|
+
* Close a setup attempt that ended in a thrown error. A cancelled prompt is a
|
|
414
|
+
* user decision, not a failure, and only an unexpected failure gets a
|
|
415
|
+
* diagnostic ID worth quoting in a bug report.
|
|
416
|
+
*/
|
|
417
|
+
function endFailedAttempt(attempt, error, fallbackStage) {
|
|
418
|
+
const outcome = failAttemptFromError(attempt, error, fallbackStage);
|
|
419
|
+
if (outcome?.unexpected) {
|
|
420
|
+
console.log(chalk.gray(` Diagnostic ID: ${outcome.diagnosticId}`));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line between "the operator needs to read one sentence" and "this is a
|
|
3
|
+
* bug worth a stack trace".
|
|
4
|
+
*
|
|
5
|
+
* Keeping the decision here — rather than inline in `index.ts` — is what lets
|
|
6
|
+
* it be tested without parsing a command line, and what keeps the entry point
|
|
7
|
+
* to a single `.catch()`.
|
|
8
|
+
*/
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import { SetupDiagnosticError, isPromptCancellation } from "../telemetry/setup-diagnostics.js";
|
|
11
|
+
/**
|
|
12
|
+
* A wrong argument, not a failure: the command never started work, so there is
|
|
13
|
+
* nothing to diagnose beyond the sentence telling the operator what to type.
|
|
14
|
+
*/
|
|
15
|
+
export class CliUsageError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "CliUsageError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Describe an error the CLI expects to hit — a missing `claude` binary, a
|
|
23
|
+
* sign-in the operator closed, a mistyped provider, Ctrl+C at a prompt — or
|
|
24
|
+
* `null` for anything else, which must keep surfacing with its stack so a real
|
|
25
|
+
* bug is never quietly downgraded to a one-liner.
|
|
26
|
+
*/
|
|
27
|
+
export function describeExpectedCliError(error) {
|
|
28
|
+
// 130 = terminated by SIGINT, which is what Ctrl+C at a prompt means.
|
|
29
|
+
if (isPromptCancellation(error))
|
|
30
|
+
return { message: chalk.gray("Cancelled."), exitCode: 130 };
|
|
31
|
+
if (error instanceof CliUsageError)
|
|
32
|
+
return { message: chalk.red(`✗ ${error.message}`), exitCode: 1 };
|
|
33
|
+
if (error instanceof SetupDiagnosticError && error.classification.expected) {
|
|
34
|
+
return { message: chalk.red(`✗ ${error.message}`), exitCode: 1 };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|