@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.
@@ -1,16 +1,14 @@
1
1
  import chalk from "chalk";
2
- import { loadAccounts, loadOpenAIAccounts, loadXaiAccounts, accountsFileExists, upsertAccountRecord, removeAccountRecordById, renameAccountRecordById, readConfig, serialize } from "../config/manager.js";
2
+ import { loadAccounts, loadOpenAIAccounts, loadXaiAccounts, accountsFileExists, upsertAccountRecord, removeAccountRecordById, renameAccountRecordById, readConfig } from "../config/manager.js";
3
3
  import { saveAccounts } from "../proxy/token-refresher.js";
4
4
  import { formatExpiry, redactToken } from "../utils/token-extractor.js";
5
5
  import { PROXY_PORT } from "../config/paths.js";
6
- import { createOpenAIAccountRecord } from "../providers/openai/account-record.js";
7
- import { loginOpenAIWithDeviceCode } from "../providers/openai/device-oauth.js";
8
- import { importGrokCliAuth } from "../providers/xai/import-auth.js";
9
- import { loginXaiWithDeviceCode } from "../providers/xai/device-oauth.js";
10
6
  import { isValidAccountId } from "../proxy/account-rename.js";
11
- import { createSetupAttempt, failAttemptFromError, withSetupTelemetryFlush, } from "../telemetry/setup-diagnostics.js";
7
+ import { failAttemptFromError, withSetupTelemetryFlush, } from "../telemetry/setup-diagnostics.js";
8
+ import { isTokenOnly } from "../proxy/types.js";
12
9
  import { sanitizeAccountInfo, formatAccountInfo } from "../providers/account-info.js";
13
10
  import { needsReauthentication } from "../providers/auth-state.js";
11
+ import { CliUsageError } from "./cli-errors.js";
14
12
  export function registerAccounts(program) {
15
13
  const accounts = program
16
14
  .command("accounts")
@@ -45,10 +43,10 @@ export function registerAccounts(program) {
45
43
  ? `\n Accounts (${liveStats.length} in the running proxy)\n`
46
44
  : `\n Accounts (${stored.length + openAIStored.length + xaiStored.length} configured)\n`));
47
45
  /**
48
- * Accounts only re-authentication can restore, tagged with the provider
49
- * whose sign-in command actually recovers them — `accounts add` runs the
50
- * Claude Max flow, so pointing an OpenAI or Grok operator at it would
51
- * re-add the id under the wrong provider.
46
+ * Accounts only re-authentication can restore. Just the ids: `accounts
47
+ * reauth <id>` resolves the provider itself, so the hint no longer has
48
+ * to pick a per-provider sign-in command — getting that wrong re-added
49
+ * the id under the wrong provider entirely.
52
50
  */
53
51
  const reauthNeeded = [];
54
52
  if (liveStats) {
@@ -63,19 +61,23 @@ export function registerAccounts(program) {
63
61
  // permanently rejected refresh token. Only the latter needs the
64
62
  // operator, so it gets its own label rather than hiding in the crowd.
65
63
  if (needsReauthentication(s))
66
- reauthNeeded.push({ id: s.id, provider: s.provider });
64
+ reauthNeeded.push(s.id);
67
65
  const status = needsReauthentication(s)
68
66
  ? chalk.red("✗ re-auth required")
69
67
  : s.healthy
70
68
  ? chalk.green("✓ healthy")
71
69
  : chalk.red("✗ unhealthy");
72
70
  const busy = s.busy ? chalk.yellow(" [busy]") : "";
71
+ // A `claude setup-token` account has no refresh token, so it never
72
+ // refreshes and carries the inference scope only. Saying so here is
73
+ // what stops "expires" reading as a bug rather than a deadline.
74
+ const tokenOnly = s.tokenOnly ? chalk.gray(" token-only") : "";
73
75
  const exp = s.expiresInMs > 0
74
76
  ? chalk.yellow(formatMs(s.expiresInMs))
75
77
  : chalk.red("EXPIRED");
76
78
  console.log(` ${chalk.bold(s.id.padEnd(24))}` +
77
79
  ` ${provider}` +
78
- ` ${status}${busy}` +
80
+ ` ${status}${busy}${tokenOnly}` +
79
81
  ` requests: ${chalk.cyan(String(s.requestCount).padStart(5))}` +
80
82
  ` errors: ${chalk.red(String(s.errorCount).padStart(3))}` +
81
83
  ` expires: ${exp}`);
@@ -110,11 +112,13 @@ export function registerAccounts(program) {
110
112
  // `authExpired` is persisted, so the dead state is knowable without
111
113
  // the proxy running — and this is exactly when an operator looks.
112
114
  if (needsReauthentication(a))
113
- reauthNeeded.push({ id: a.id, provider: "anthropic_subscription" });
115
+ reauthNeeded.push(a.id);
114
116
  console.log(` ${chalk.bold(a.id.padEnd(24))}` +
115
117
  ` ${redactToken(a.tokens.accessToken).padEnd(26)}` +
116
118
  ` expires: ${expColor}` +
117
- ` scopes: ${chalk.gray(a.tokens.scopes.join(" "))}` +
119
+ (isTokenOnly(a.tokens)
120
+ ? ` ${chalk.gray("token-only")}`
121
+ : ` scopes: ${chalk.gray(a.tokens.scopes.join(" "))}`) +
118
122
  (needsReauthentication(a) ? ` ${chalk.red("✗ re-auth required")}` : ""));
119
123
  }
120
124
  for (const a of openAIStored) {
@@ -122,7 +126,7 @@ export function registerAccounts(program) {
122
126
  ? chalk.yellow(formatExpiry(a.expiresAt))
123
127
  : chalk.red("EXPIRED");
124
128
  if (needsReauthentication(a))
125
- reauthNeeded.push({ id: a.id, provider: "openai_subscription" });
129
+ reauthNeeded.push(a.id);
126
130
  console.log(` ${chalk.bold(a.id.padEnd(24))}` +
127
131
  ` ${chalk.magenta("openai".padEnd(10))}` +
128
132
  ` ${redactToken(a.accessToken).padEnd(26)}` +
@@ -143,204 +147,118 @@ export function registerAccounts(program) {
143
147
  // out of: the refresh loop has deliberately stopped retrying, so nothing
144
148
  // changes until someone re-authenticates. Say so, and say how.
145
149
  if (reauthNeeded.length > 0) {
146
- console.log(chalk.red(`\n ⚠ Needs re-authentication: ${reauthNeeded.map(a => a.id).join(", ")}`));
150
+ console.log(chalk.red(`\n ⚠ Needs re-authentication: ${reauthNeeded.join(", ")}`));
147
151
  console.log(chalk.gray(" The provider rejected these refresh tokens permanently; they cannot\n"
148
152
  + " be recovered and the refresh loop has stopped retrying them. Sign in\n"
149
153
  + " again under the same account id to resume routing — the existing\n"
150
154
  + " account is replaced, so there is nothing to remove first:"));
151
- for (const { id, provider } of reauthNeeded) {
152
- console.log(chalk.gray(` ${reauthCommand(provider)} (for ${id})`));
155
+ for (const id of reauthNeeded) {
156
+ console.log(chalk.gray(` ${reauthCommand(id)}`));
153
157
  }
154
158
  }
155
159
  console.log();
156
160
  });
157
- // ── accounts add ─────────────────────────────────────────────────────────
158
- accounts
159
- .command("add")
160
- .description("Add a new Claude Max account interactively")
161
- .action(async () => {
162
- const { setupSingleAccountWithAttempt } = await import("./cmd-setup.js");
163
- const existing = accountsFileExists() ? loadAccounts() : [];
164
- const { account, attempt } = await setupSingleAccountWithAttempt(existing.length + 1);
165
- if (!account) {
166
- console.log(chalk.yellow("\nNo account added.\n"));
167
- return;
168
- }
169
- // Merge: replace by ID if already exists, otherwise append
170
- const merged = [
171
- ...existing.filter(a => a.id !== account.id),
172
- account,
173
- ];
174
- let mode;
175
- try {
176
- // Only `addStored` is overridden: this flow merges by id across the
177
- // whole Claude pool. `tryAddLive` must stay the default so the live
178
- // pool is asked to replace an existing id rather than reject it.
179
- ({ mode } = await addAccountRuntimeAware(serialize([account])[0], {
180
- addStored: () => saveAccounts(merged),
181
- }));
182
- }
183
- catch (error) {
184
- endFailedAttempt(attempt, error, "persistence");
185
- throw error;
186
- }
187
- attempt.stageCompleted("persistence");
188
- attempt.succeeded();
189
- console.log(chalk.green(`\n✓ Account "${account.id}" added (${merged.length} total).\n`));
190
- printAddOutcome(mode);
191
- });
192
- // ── accounts add-openai ──────────────────────────────────────────────────
161
+ // ── accounts login ───────────────────────────────────────────────────────
193
162
  accounts
194
- .command("add-openai")
195
- .description("Add an OpenAI ChatGPT/Codex subscription account manually")
196
- .action(async () => withSetupTelemetryFlush(async () => {
197
- const { input, password } = await import("@inquirer/prompts");
198
- const attempt = createSetupAttempt({ provider: "openai", method: "manual_token" });
199
- attempt.stageCompleted("credential_source_selection");
200
- let reached = "credential_read";
201
- try {
202
- const id = await input({
203
- message: "OpenAI account ID:",
204
- default: `openai-account-${loadOpenAIAccounts().length + 1}`,
205
- validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
163
+ .command("login [provider]")
164
+ .description("Sign in to a Claude, OpenAI or Grok account in the browser (provider: claude | openai | grok)")
165
+ .option("--id <id>", "Account id to store the credentials under")
166
+ .option("--email <email>", "Email to prefill on the sign-in page")
167
+ .option("--long-lived", "Claude only: create a long-lived token with claude setup-token instead of a full sign-in")
168
+ .action(async (providerArg, opts) => withSetupTelemetryFlush(async () => {
169
+ const provider = await chooseProvider(providerArg);
170
+ const flows = await import("./account-flows.js");
171
+ if (provider === "claude") {
172
+ const { account, attempt } = await flows.collectClaudeAccount({
173
+ index: (accountsFileExists() ? loadAccounts().length : 0) + 1,
174
+ fixedId: opts.id,
175
+ email: opts.email,
176
+ offer: "login",
177
+ ...(opts.longLived ? { method: "setup_token" } : {}),
206
178
  });
207
- const accessToken = await password({
208
- message: "OpenAI access token:",
209
- mask: "*",
210
- validate: (v) => v.trim().length > 0 || "Access token is required",
211
- });
212
- const refreshToken = await password({
213
- message: "OpenAI refresh token:",
214
- mask: "*",
215
- validate: (v) => v.trim().length > 0 || "Refresh token is required",
216
- });
217
- const expiresAt = await input({
218
- message: "Access token expiry (Unix ms):",
219
- default: String(Date.now() + 60 * 60 * 1000),
220
- validate: (v) => Number.isFinite(Number(v)) && Number(v) > 0 || "Enter a positive Unix timestamp in milliseconds",
221
- });
222
- const scopes = await input({
223
- message: "Scopes:",
224
- default: "openid profile email offline_access",
225
- });
226
- attempt.stageCompleted("credential_read");
227
- reached = "credential_parse";
228
- const record = createOpenAIAccountRecord({
229
- id,
230
- accessToken,
231
- refreshToken,
232
- expiresAt,
233
- scopes,
234
- });
235
- attempt.stageCompleted("credential_parse");
236
- reached = "persistence";
237
- const { mode } = await addAccountRuntimeAware(record);
238
- attempt.stageCompleted("persistence");
239
- attempt.succeeded();
240
- console.log(chalk.green(`\n✓ OpenAI account "${record.id}" saved.\n`));
241
- printAddOutcome(mode);
242
- console.log(chalk.yellow(" Treat this as experimental until the OAuth login wizard lands.\n"));
179
+ if (!account) {
180
+ console.log(chalk.yellow("\nNo account added.\n"));
181
+ return;
182
+ }
183
+ await persistClaude(account, attempt);
184
+ return;
243
185
  }
244
- catch (error) {
245
- endFailedAttempt(attempt, error, reached);
246
- throw error;
186
+ if (provider === "openai") {
187
+ // The id prompt (with its `openai-account-N` default) lives in the
188
+ // flow, so an absent --id is a prompt rather than a guessed name.
189
+ const { record, attempt } = await flows.loginOpenAIAccount({ accountId: opts.id, email: opts.email });
190
+ await persistRecord(record, attempt, "OpenAI account");
191
+ return;
247
192
  }
193
+ const record = await flows.loginGrokAccount({ accountId: opts.id });
194
+ upsertAccountRecord(record);
195
+ console.log(chalk.green(`\n✓ Grok account "${record.id}" saved via device login.\n`));
196
+ printAddOutcome("stored");
248
197
  }));
249
- // ── accounts login-openai ────────────────────────────────────────────────
198
+ // ── accounts add ─────────────────────────────────────────────────────────
250
199
  accounts
251
- .command("login-openai")
252
- .description("Sign in to an OpenAI ChatGPT/Codex subscription account with device code")
253
- .action(async () => withSetupTelemetryFlush(async () => {
254
- const { input } = await import("@inquirer/prompts");
255
- const attempt = createSetupAttempt({ provider: "openai", method: "device_oauth" });
256
- let reached = "device_code_request";
257
- try {
258
- const accountId = await input({
259
- message: "OpenAI account ID:",
260
- default: `openai-account-${loadOpenAIAccounts().length + 1}`,
261
- validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
262
- });
263
- console.log(chalk.cyan("\nOpenAI Codex device login"));
264
- console.log(chalk.gray("This will open no local callback server. You will approve the login in your browser.\n"));
265
- const record = await loginOpenAIWithDeviceCode({
266
- accountId,
267
- onDeviceCode: (code) => {
268
- console.log(chalk.bold("1. Open this URL:"));
269
- console.log(` ${chalk.cyan(code.verificationUrl)}`);
270
- console.log(chalk.bold("2. Enter this code:"));
271
- console.log(` ${chalk.cyan(code.userCode)}\n`);
272
- console.log(chalk.gray("Waiting for authorization..."));
273
- },
274
- onStageCompleted: (stage) => {
275
- attempt.stageCompleted(stage);
276
- reached = stage;
277
- },
200
+ .command("add [provider]")
201
+ .description("Import credentials that already exist: Claude Keychain / credentials file / pasted tokens, OpenAI tokens, or ~/.grok")
202
+ .option("--id <id>", "Account id to store the credentials under")
203
+ .action(async (providerArg, opts) => withSetupTelemetryFlush(async () => {
204
+ const provider = await chooseProvider(providerArg);
205
+ const flows = await import("./account-flows.js");
206
+ if (provider === "claude") {
207
+ const { account, attempt } = await flows.collectClaudeAccount({
208
+ index: (accountsFileExists() ? loadAccounts().length : 0) + 1,
209
+ fixedId: opts.id,
210
+ offer: "import",
278
211
  });
279
- reached = "persistence";
280
- const { mode } = await addAccountRuntimeAware(record);
281
- attempt.stageCompleted("persistence");
282
- attempt.succeeded();
283
- console.log(chalk.green(`\n✓ OpenAI account "${record.id}" saved via device login.\n`));
284
- printAddOutcome(mode);
285
- }
286
- catch (error) {
287
- endFailedAttempt(attempt, error, reached);
288
- throw error;
289
- }
290
- }));
291
- // ── accounts add-grok ────────────────────────────────────────────────────
292
- accounts
293
- .command("add-grok")
294
- .description("Import the Grok CLI login from ~/.grok/auth.json")
295
- .action(async () => {
296
- const { input } = await import("@inquirer/prompts");
297
- let imported;
298
- try {
299
- imported = importGrokCliAuth();
212
+ if (!account) {
213
+ console.log(chalk.yellow("\nNo account added.\n"));
214
+ return;
215
+ }
216
+ await persistClaude(account, attempt);
217
+ return;
300
218
  }
301
- catch (err) {
302
- const message = err instanceof Error ? err.message : String(err);
303
- console.log(chalk.red(`\n✗ ${message}\n`));
304
- console.log(chalk.gray(" Or sign in here: cc-router accounts login-grok\n"));
305
- process.exit(1);
219
+ if (provider === "openai") {
220
+ const { record, attempt } = await flows.importOpenAIAccount({ accountId: opts.id });
221
+ await persistRecord(record, attempt, "OpenAI account");
222
+ return;
306
223
  }
307
- const id = await input({
308
- message: "Grok account ID:",
309
- default: imported.id,
310
- validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
311
- });
312
- const record = { ...imported, id };
224
+ const record = await flows.importGrokAccount({ accountId: opts.id });
313
225
  upsertAccountRecord(record);
314
226
  console.log(chalk.green(`\n✓ Grok account "${record.id}" imported from ~/.grok.\n`));
315
227
  printAddOutcome("stored");
316
- });
317
- // ── accounts login-grok ──────────────────────────────────────────────────
228
+ }));
229
+ // ── accounts reauth ──────────────────────────────────────────────────────
318
230
  accounts
319
- .command("login-grok")
320
- .description("Sign in to a Grok / xAI account with device code")
321
- .action(async () => {
322
- const { input } = await import("@inquirer/prompts");
323
- const accountId = await input({
324
- message: "Grok account ID:",
325
- default: loadXaiAccounts().length === 0 ? "grok" : `grok-${loadXaiAccounts().length + 1}`,
326
- validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
231
+ .command("reauth <id>")
232
+ .description("Sign an existing account in again under the same id (its provider and email are looked up for you)")
233
+ .option("--email <email>", "Override the email to prefill on the sign-in page")
234
+ .option("--long-lived", "Claude only: use claude setup-token")
235
+ .action(async (id, opts) => withSetupTelemetryFlush(async () => {
236
+ const live = await fetchLiveStats();
237
+ const target = resolveReauthTarget(id, live, {
238
+ anthropic: accountsFileExists() ? loadAccounts() : [],
239
+ openai: loadOpenAIAccounts(),
240
+ xai: loadXaiAccounts(),
327
241
  });
328
- console.log(chalk.cyan("\nGrok device login"));
329
- console.log(chalk.gray("Approve the login in your browser. No local callback server is used.\n"));
330
- const record = await loginXaiWithDeviceCode({
331
- accountId,
332
- onDeviceCode: (code) => {
333
- console.log(chalk.bold("1. Open this URL:"));
334
- console.log(` ${chalk.cyan(code.verificationUrl)}`);
335
- console.log(chalk.bold("2. Enter this code if the page does not fill it in:"));
336
- console.log(` ${chalk.cyan(code.userCode)}\n`);
337
- console.log(chalk.gray("Waiting for authorization..."));
338
- },
339
- });
340
- upsertAccountRecord(record);
341
- console.log(chalk.green(`\n✓ Grok account "${record.id}" saved via device login.\n`));
342
- printAddOutcome("stored");
343
- });
242
+ if (!target) {
243
+ const { ids } = mergeAccountInventory(loadAccounts().map(a => a.id), loadOpenAIAccounts().map(a => a.id), live, loadXaiAccounts().map(a => a.id));
244
+ console.log(chalk.red(`✗ Account "${id}" not found.`));
245
+ console.log(chalk.gray(` Available: ${ids.join(", ")}`));
246
+ process.exit(1);
247
+ }
248
+ // Grok credentials are minted by the Grok CLI and read back out of
249
+ // ~/.grok; there is no id-preserving sign-in to re-run here.
250
+ if ("grok" in target) {
251
+ console.log(chalk.yellow(`Grok credentials live in ~/.grok. Run ${chalk.white("grok login")}, then ${chalk.white("cc-router accounts add grok")}.`));
252
+ process.exit(1);
253
+ }
254
+ const flows = await import("./account-flows.js");
255
+ const result = await flows.collectReauthRecord({ ...target, ...(opts.email ? { email: opts.email } : {}) }, { longLived: opts.longLived });
256
+ if (!result) {
257
+ console.log(chalk.yellow("\nNo account re-authenticated.\n"));
258
+ return;
259
+ }
260
+ await persistRecord(result.record, result.attempt, target.provider === "openai_subscription" ? "OpenAI account" : "Account");
261
+ }));
344
262
  // ── accounts remove ───────────────────────────────────────────────────────
345
263
  accounts
346
264
  .command("remove <id>")
@@ -426,7 +344,102 @@ export function registerAccounts(program) {
426
344
  : chalk.gray(" Saved to accounts.json — loads on next start: cc-router start"));
427
345
  });
428
346
  }
429
- // ─── Helpers ──────────────────────────────────────────────────────────────────
347
+ /**
348
+ * The provider named on the command line, if one was.
349
+ *
350
+ * Unknown values throw rather than falling back to a picker: silently asking
351
+ * again would let `accounts login clade` sign the operator into whatever they
352
+ * then clicked, under a command they did not mean to run.
353
+ */
354
+ export function parseProviderArg(value) {
355
+ if (value === undefined)
356
+ return undefined;
357
+ if (value === "claude" || value === "openai" || value === "grok")
358
+ return value;
359
+ throw new CliUsageError(`Unknown provider "${value}" — use claude, openai or grok`);
360
+ }
361
+ async function chooseProvider(given) {
362
+ const parsed = parseProviderArg(given);
363
+ if (parsed)
364
+ return parsed;
365
+ const { select } = await import("@inquirer/prompts");
366
+ return select({
367
+ message: "Which provider?",
368
+ choices: [
369
+ { name: "Claude (Claude Max / Pro subscription)", value: "claude" },
370
+ { name: "OpenAI (ChatGPT / Codex subscription)", value: "openai" },
371
+ { name: "Grok (xAI)", value: "grok" },
372
+ ],
373
+ });
374
+ }
375
+ /**
376
+ * Persist a collected Claude account, replacing any account already holding
377
+ * its id. The merge is by id across the whole Claude pool, so only `addStored`
378
+ * is overridden — `tryAddLive` must stay the default, which asks the running
379
+ * proxy to replace rather than reject the id.
380
+ */
381
+ async function persistClaude(account, attempt) {
382
+ const { accountToRecord } = await import("./account-flows.js");
383
+ const existing = accountsFileExists() ? loadAccounts() : [];
384
+ const merged = [...existing.filter(a => a.id !== account.id), account];
385
+ let mode;
386
+ try {
387
+ ({ mode } = await addAccountRuntimeAware(accountToRecord(account), {
388
+ addStored: () => saveAccounts(merged),
389
+ }));
390
+ }
391
+ catch (error) {
392
+ endFailedAttempt(attempt, error, "persistence");
393
+ throw error;
394
+ }
395
+ attempt.stageCompleted("persistence");
396
+ attempt.succeeded();
397
+ console.log(chalk.green(`\n✓ Account "${account.id}" saved (${merged.length} Claude accounts).\n`));
398
+ printAddOutcome(mode);
399
+ }
400
+ /** Persist any already-serialized record (OpenAI, or a re-authenticated account). */
401
+ async function persistRecord(record, attempt, label) {
402
+ let mode;
403
+ try {
404
+ ({ mode } = await addAccountRuntimeAware(record));
405
+ }
406
+ catch (error) {
407
+ if (attempt)
408
+ endFailedAttempt(attempt, error, "persistence");
409
+ throw error;
410
+ }
411
+ attempt?.stageCompleted("persistence");
412
+ attempt?.succeeded();
413
+ console.log(chalk.green(`\n✓ ${label} "${record.id}" saved.\n`));
414
+ printAddOutcome(mode);
415
+ }
416
+ /**
417
+ * Which account `accounts reauth <id>` should sign in again, and as whom.
418
+ *
419
+ * The live pool wins: it is the only source that carries the fetched account
420
+ * metadata, and its email is what makes the sign-in page land on the right
421
+ * account instead of whichever one the browser is already holding. Disk is the
422
+ * fallback for a proxy that is not running, and knows no email.
423
+ */
424
+ export function resolveReauthTarget(id, live, stored) {
425
+ const liveMatch = live?.find(a => a.id === id);
426
+ if (liveMatch) {
427
+ if (liveMatch.provider === "xai_subscription")
428
+ return { grok: true };
429
+ return {
430
+ id,
431
+ provider: liveMatch.provider === "openai_subscription" ? "openai_subscription" : "anthropic_subscription",
432
+ ...(liveMatch.accountInfo?.email ? { email: liveMatch.accountInfo.email } : {}),
433
+ };
434
+ }
435
+ if (stored.xai.some(a => a.id === id))
436
+ return { grok: true };
437
+ if (stored.openai.some(a => a.id === id))
438
+ return { id, provider: "openai_subscription" };
439
+ if (stored.anthropic.some(a => a.id === id))
440
+ return { id, provider: "anthropic_subscription" };
441
+ return null;
442
+ }
430
443
  /**
431
444
  * Close a setup attempt that ended in a thrown error. A cancelled prompt is a
432
445
  * user decision, not a failure, and only an unexpected failure gets a
@@ -494,6 +507,7 @@ export function buildStoredAccountsJson(anthropicAccounts, openAIAccounts, xaiAc
494
507
  expiresAt: a.tokens.expiresAt,
495
508
  scopes: a.tokens.scopes,
496
509
  ...(needsReauthentication(a) ? { authExpired: true } : {}),
510
+ ...(isTokenOnly(a.tokens) ? { tokenOnly: true } : {}),
497
511
  })),
498
512
  ...openAIAccounts.map(a => ({
499
513
  id: a.id,
@@ -669,19 +683,17 @@ export async function addAccountRuntimeAware(record, dependencies = {}) {
669
683
  return { mode: "stored" };
670
684
  }
671
685
  /**
672
- * The sign-in command that recovers an account of this provider.
686
+ * The command that recovers this account, whatever its provider.
673
687
  *
674
- * `accounts add` is the Claude Max flow specifically; the other providers have
675
- * their own. Each re-registers under the id the operator types, and the live
676
- * pool now replaces rather than rejects it, so no deletion step is needed —
677
- * which also avoids the running proxy refusing to delete a lone Claude account.
688
+ * One command for every provider, because it resolves the provider from the id
689
+ * itself — the previous per-provider hints had to guess, and guessing wrong
690
+ * re-added the id under the wrong provider. It re-registers under the id the
691
+ * operator already has, and the live pool replaces rather than rejects it, so
692
+ * no deletion step is needed — which also avoids the running proxy refusing to
693
+ * delete a lone Claude account.
678
694
  */
679
- function reauthCommand(provider) {
680
- if (provider === "openai_subscription")
681
- return "cc-router accounts login-openai";
682
- if (provider === "xai_subscription")
683
- return "cc-router accounts login-grok";
684
- return "cc-router accounts add";
695
+ function reauthCommand(id) {
696
+ return `cc-router accounts reauth ${id}`;
685
697
  }
686
698
  async function fetchLiveStats() {
687
699
  try {