ai-cc-router 0.1.3 → 0.1.5

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,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { writeClaudeSettings, removeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
3
+ import { readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
3
4
  import { PROXY_PORT, CLAUDE_SETTINGS_PATH } from "../config/paths.js";
4
5
  export function registerConfigure(program) {
5
6
  program
@@ -8,6 +9,9 @@ export function registerConfigure(program) {
8
9
  .option("--remove", "Remove cc-router settings from ~/.claude/settings.json")
9
10
  .option("--port <port>", "Proxy port to configure", String(PROXY_PORT))
10
11
  .option("--show", "Show current Claude Code proxy settings")
12
+ .option("--generate-password", "Generate a new proxy secret and sync Claude Code settings")
13
+ .option("--set-password <secret>", "Set a specific proxy secret and sync Claude Code settings")
14
+ .option("--remove-password", "Remove proxy password protection (open access)")
11
15
  .action((opts) => {
12
16
  if (opts.show) {
13
17
  const current = readClaudeProxySettings();
@@ -20,6 +24,9 @@ export function registerConfigure(program) {
20
24
  console.log(chalk.yellow(" Claude Code is NOT configured to use cc-router."));
21
25
  console.log(chalk.gray(` Run: cc-router configure`));
22
26
  }
27
+ const { proxySecret } = readConfig();
28
+ const pwStatus = proxySecret ? chalk.green("yes") : chalk.gray("no");
29
+ console.log(` Password protected: ${pwStatus}`);
23
30
  return;
24
31
  }
25
32
  if (opts.remove) {
@@ -28,10 +35,45 @@ export function registerConfigure(program) {
28
35
  console.log(chalk.gray(" Claude Code will use its default authentication on next launch."));
29
36
  return;
30
37
  }
38
+ if (opts.generatePassword) {
39
+ const secret = generateProxySecret();
40
+ writeConfig({ ...readConfig(), proxySecret: secret });
41
+ const { baseUrl } = readClaudeProxySettings();
42
+ writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
43
+ console.log(chalk.green("✓ Proxy password set."));
44
+ console.log(" " + chalk.bold.yellow("Save this — it will not be shown again:"));
45
+ console.log(" " + chalk.bold(secret));
46
+ console.log(chalk.gray(" Restart cc-router for the change to take effect."));
47
+ return;
48
+ }
49
+ if (opts.setPassword !== undefined) {
50
+ const secret = opts.setPassword.trim();
51
+ if (!secret) {
52
+ console.error(chalk.red("Secret cannot be empty."));
53
+ process.exit(1);
54
+ }
55
+ writeConfig({ ...readConfig(), proxySecret: secret });
56
+ const { baseUrl } = readClaudeProxySettings();
57
+ writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
58
+ console.log(chalk.green("✓ Proxy password updated."));
59
+ console.log(chalk.gray(" Restart cc-router for the change to take effect."));
60
+ return;
61
+ }
62
+ if (opts.removePassword) {
63
+ const cfg = readConfig();
64
+ delete cfg.proxySecret;
65
+ writeConfig(cfg);
66
+ const { baseUrl } = readClaudeProxySettings();
67
+ writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
68
+ console.log(chalk.green("✓ Proxy password removed. Access is now open."));
69
+ console.log(chalk.gray(" Restart cc-router for the change to take effect."));
70
+ return;
71
+ }
31
72
  const port = parseInt(opts.port, 10);
32
73
  writeClaudeSettings(port);
74
+ const { proxySecret } = readConfig();
33
75
  console.log(chalk.green(`✓ Updated ${CLAUDE_SETTINGS_PATH}`));
34
76
  console.log(chalk.gray(` ANTHROPIC_BASE_URL = http://localhost:${port}`));
35
- console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
77
+ console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = ${proxySecret ? chalk.green("(secret configured)") : "proxy-managed"}`));
36
78
  });
37
79
  }
@@ -1,12 +1,15 @@
1
1
  import { select, input, confirm, password } from "@inquirer/prompts";
2
+ import { execFile, spawn } from "child_process";
3
+ import { promisify } from "util";
2
4
  import chalk from "chalk";
3
5
  import { detectPlatform, isMacos } from "../utils/platform.js";
4
6
  import { extractFromKeychain, extractFromCredentialsFile, formatExpiry, redactToken, } from "../utils/token-extractor.js";
5
7
  import { validateToken } from "../utils/token-validator.js";
6
- import { writeClaudeSettings } from "../utils/claude-config.js";
7
- import { saveAccounts, } from "../proxy/token-refresher.js";
8
- import { loadAccounts, accountsFileExists } from "../config/manager.js";
8
+ import { writeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
9
+ import { saveAccounts } from "../proxy/token-refresher.js";
10
+ import { loadAccounts, accountsFileExists, readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
9
11
  import { PROXY_PORT } from "../config/paths.js";
12
+ const execFileAsync = promisify(execFile);
10
13
  // ─── Public registration ──────────────────────────────────────────────────────
11
14
  export function registerSetup(program) {
12
15
  program
@@ -19,7 +22,6 @@ export function registerSetup(program) {
19
22
  }
20
23
  // ─── Shared single-account setup (also used by `accounts add`) ───────────────
21
24
  export async function setupSingleAccount(index) {
22
- const platform = detectPlatform();
23
25
  const choices = [];
24
26
  if (isMacos()) {
25
27
  choices.push({ name: "Extract automatically from macOS Keychain (recommended)", value: "keychain" });
@@ -70,14 +72,12 @@ export async function setupSingleAccount(index) {
70
72
  }
71
73
  if (!tokens)
72
74
  return null;
73
- // Ask for account ID
74
75
  const defaultId = `max-account-${index}`;
75
76
  const accountId = await input({
76
77
  message: "Account ID (press Enter to accept default):",
77
78
  default: defaultId,
78
79
  validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
79
80
  });
80
- // Validate tokens against Anthropic API
81
81
  process.stdout.write(chalk.gray(" Validating tokens against Anthropic... "));
82
82
  const validation = await validateToken(tokens.accessToken);
83
83
  if (validation.valid) {
@@ -87,10 +87,7 @@ export async function setupSingleAccount(index) {
87
87
  console.log(chalk.red("✗ Invalid"));
88
88
  console.log(chalk.yellow(` Reason: ${validation.reason}`));
89
89
  console.log(chalk.gray(" The token will be saved but may not work until refreshed."));
90
- const keepAnyway = await confirm({
91
- message: "Save this account anyway?",
92
- default: false,
93
- });
90
+ const keepAnyway = await confirm({ message: "Save this account anyway?", default: false });
94
91
  if (!keepAnyway)
95
92
  return null;
96
93
  }
@@ -112,7 +109,6 @@ async function runSetupWizard({ addMode }) {
112
109
  const hasExisting = accountsFileExists();
113
110
  printBanner();
114
111
  console.log(chalk.gray(`Platform: ${platform}\n`));
115
- // If accounts already exist and we're not in add-mode, ask what to do
116
112
  if (hasExisting && !addMode) {
117
113
  const existing = loadAccounts();
118
114
  console.log(chalk.yellow(` Found ${existing.length} existing account(s).\n`));
@@ -138,9 +134,7 @@ async function runSetupWizard({ addMode }) {
138
134
  return;
139
135
  }
140
136
  }
141
- // If 'add', we'll merge below
142
137
  }
143
- // Guide for multi-account setup
144
138
  if (!addMode && isMacos()) {
145
139
  console.log(chalk.cyan(" Tip: to add multiple accounts, you need to:"));
146
140
  console.log(chalk.gray(" 1. Log in to Claude Code with account 1 (already done if you use CC normally)"));
@@ -160,14 +154,14 @@ async function runSetupWizard({ addMode }) {
160
154
  for (let i = 0; i < numAccounts; i++) {
161
155
  const label = numAccounts > 1 ? `${i + 1}/${numAccounts}` : "";
162
156
  console.log(chalk.bold(`\n${"━".repeat(40)}\n Account ${label}\n${"━".repeat(40)}\n`));
163
- // If on macOS and this isn't the first account, remind user to switch accounts
164
157
  if (i > 0 && isMacos()) {
165
158
  console.log(chalk.yellow(` Before extracting account ${i + 1}:\n` +
166
159
  ` 1. Run: ${chalk.white("claude logout")}\n` +
167
160
  ` 2. Run: ${chalk.white("claude login")} (log in with your next Max account)\n`));
168
161
  await confirm({ message: "Ready?", default: true });
169
162
  }
170
- const account = await setupSingleAccount(i + 1 + (hasExisting ? loadAccounts().length : 0));
163
+ const existingCount = hasExisting ? loadAccounts().length : 0;
164
+ const account = await setupSingleAccount(i + 1 + existingCount);
171
165
  if (account) {
172
166
  newAccounts.push(account);
173
167
  console.log(chalk.green(`\n ✓ Account "${account.id}" ready.\n`));
@@ -180,25 +174,281 @@ async function runSetupWizard({ addMode }) {
180
174
  console.log(chalk.red("\n✗ No accounts configured. Run cc-router setup again.\n"));
181
175
  return;
182
176
  }
183
- // Merge with existing accounts (by ID new entries win on conflict)
184
- const existing = hasExisting && !addMode ? [] : (hasExisting ? loadAccounts() : []);
185
- const existingIds = new Set(existing.map(a => a.id));
177
+ // Merge: existing accounts minus any overwritten by ID, plus new ones
178
+ const existingAccounts = (hasExisting && !addMode) ? [] : (hasExisting ? loadAccounts() : []);
186
179
  const merged = [
187
- ...existing.filter(a => !newAccounts.some(n => n.id === a.id)),
180
+ ...existingAccounts.filter(a => !newAccounts.some(n => n.id === a.id)),
188
181
  ...newAccounts,
189
182
  ];
190
- console.log(chalk.bold(`\n${"━".repeat(40)}\n Saving configuration\n${"━".repeat(40)}\n`));
191
- // Save accounts.json (atomic write)
183
+ console.log(chalk.bold(`\n${"━".repeat(40)}\n Saving\n${"━".repeat(40)}\n`));
192
184
  saveAccounts(merged);
193
- console.log(chalk.green(` ✓ Saved ${merged.length} account(s) to ~/.cc-router/accounts.json`));
194
- // Write ~/.claude/settings.json
195
- writeClaudeSettings(PROXY_PORT);
196
- console.log(chalk.green(` ✓ Updated ~/.claude/settings.json`));
197
- console.log(chalk.gray(` ANTHROPIC_BASE_URL = http://localhost:${PROXY_PORT}`));
198
- console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
199
- printNextSteps(merged.length);
185
+ console.log(chalk.green(` ✓ ${merged.length} account(s) saved to ~/.cc-router/accounts.json`));
186
+ // ─── Post-setup interactive flow ─────────────────────────────────────────
187
+ await runPostSetupFlow(merged.length);
200
188
  }
201
- // ─── Helpers ──────────────────────────────────────────────────────────────────
189
+ // ─── Post-setup interactive flow ─────────────────────────────────────────────
190
+ async function runPostSetupFlow(accountCount) {
191
+ console.log(chalk.bold(`\n${"━".repeat(40)}\n Configure this machine\n${"━".repeat(40)}\n`));
192
+ // 1. Configure Claude Code on this machine
193
+ const currentSettings = readClaudeProxySettings();
194
+ const alreadyConfigured = currentSettings.baseUrl?.includes("localhost");
195
+ const configureLocal = await confirm({
196
+ message: alreadyConfigured
197
+ ? `Claude Code is already pointing to ${currentSettings.baseUrl}. Reconfigure?`
198
+ : "Configure Claude Code on this machine to use the proxy?",
199
+ default: true,
200
+ });
201
+ if (configureLocal) {
202
+ // Ask if this is a local proxy or a remote one
203
+ const proxyLocation = await select({
204
+ message: "Where will cc-router run?",
205
+ choices: [
206
+ { name: `On this machine (localhost:${PROXY_PORT})`, value: "local" },
207
+ { name: "On another machine / VPS (I'll enter the address)", value: "remote" },
208
+ ],
209
+ });
210
+ let proxyHost = `http://localhost:${PROXY_PORT}`;
211
+ if (proxyLocation === "remote") {
212
+ const remoteHost = await input({
213
+ message: "Proxy URL (e.g. http://192.168.1.50:3456 or https://cc-router.example.com):",
214
+ validate: (v) => {
215
+ try {
216
+ new URL(v);
217
+ return true;
218
+ }
219
+ catch {
220
+ return "Enter a valid URL (http:// or https://)";
221
+ }
222
+ },
223
+ });
224
+ proxyHost = remoteHost.replace(/\/$/, ""); // strip trailing slash
225
+ }
226
+ const port = proxyLocation === "local"
227
+ ? PROXY_PORT
228
+ : parseInt(new URL(proxyHost).port || "80", 10);
229
+ // ── Password setup for remote proxy ───────────────────────────────────────
230
+ if (proxyLocation === "remote") {
231
+ const pwChoice = await select({
232
+ message: "Set a proxy password? (strongly recommended for internet-exposed proxies)",
233
+ choices: [
234
+ { name: "Generate automatically (recommended)", value: "generate" },
235
+ { name: "Enter my own password", value: "manual" },
236
+ { name: "Skip — no password protection", value: "skip" },
237
+ ],
238
+ });
239
+ let chosenSecret;
240
+ if (pwChoice === "generate") {
241
+ chosenSecret = generateProxySecret();
242
+ writeConfig({ ...readConfig(), proxySecret: chosenSecret });
243
+ }
244
+ else if (pwChoice === "manual") {
245
+ const raw = await password({
246
+ message: "Enter proxy password:",
247
+ validate: (v) => v.trim().length >= 8 || "Minimum 8 characters",
248
+ });
249
+ chosenSecret = raw.trim();
250
+ writeConfig({ ...readConfig(), proxySecret: chosenSecret });
251
+ }
252
+ writeClaudeSettings(port, proxyHost);
253
+ if (chosenSecret) {
254
+ console.log(chalk.yellow("\n *** Save this password — you cannot recover it later ***"));
255
+ console.log(" " + chalk.bold(chosenSecret));
256
+ console.log(chalk.gray(" Claude Code has been configured to use it automatically."));
257
+ console.log(chalk.gray(" Other machines: cc-router configure --set-password <value>"));
258
+ }
259
+ else {
260
+ console.log(chalk.green(`\n ✓ ~/.claude/settings.json updated`));
261
+ console.log(chalk.gray(` ANTHROPIC_BASE_URL = ${proxyHost}`));
262
+ console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
263
+ }
264
+ console.log(chalk.cyan(`\n On the remote machine, start cc-router with:`));
265
+ console.log(chalk.white(` HOST=0.0.0.0 cc-router start`));
266
+ console.log(chalk.cyan(` Or as a service:`));
267
+ console.log(chalk.white(` cc-router service install\n`));
268
+ // Nothing more to do on this machine
269
+ printDone(accountCount);
270
+ return;
271
+ }
272
+ writeClaudeSettings(port, proxyHost);
273
+ console.log(chalk.green(`\n ✓ ~/.claude/settings.json updated`));
274
+ console.log(chalk.gray(` ANTHROPIC_BASE_URL = ${proxyHost}`));
275
+ console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
276
+ }
277
+ // 2. Only ask about starting the proxy if it's local
278
+ console.log(chalk.bold(`\n${"━".repeat(40)}\n Start the proxy\n${"━".repeat(40)}\n`));
279
+ // Check if it's already running
280
+ const alreadyRunning = await isProxyRunning();
281
+ if (alreadyRunning) {
282
+ console.log(chalk.green(` ✓ Proxy is already running on http://localhost:${PROXY_PORT}`));
283
+ printDone(accountCount);
284
+ return;
285
+ }
286
+ const startChoice = await select({
287
+ message: "How do you want to run the proxy?",
288
+ choices: [
289
+ { name: "Install as system service (auto-start on boot — recommended)", value: "service" },
290
+ { name: "Start in background now (current session only, via PM2)", value: "daemon" },
291
+ { name: "Start in foreground now (this terminal, Ctrl+C to stop)", value: "foreground" },
292
+ { name: "I'll start it manually later", value: "skip" },
293
+ ],
294
+ });
295
+ if (startChoice === "service") {
296
+ await installService();
297
+ }
298
+ else if (startChoice === "daemon") {
299
+ await startDaemon();
300
+ }
301
+ else if (startChoice === "foreground") {
302
+ printDone(accountCount);
303
+ console.log(chalk.cyan("\nStarting proxy in foreground...\n"));
304
+ // Launch start as child — it blocks until Ctrl+C
305
+ await startForeground();
306
+ return; // startForeground never returns normally
307
+ }
308
+ printDone(accountCount);
309
+ }
310
+ // ─── Proxy launch helpers ─────────────────────────────────────────────────────
311
+ async function isProxyRunning() {
312
+ try {
313
+ const res = await fetch(`http://localhost:${PROXY_PORT}/cc-router/health`, {
314
+ signal: AbortSignal.timeout(800),
315
+ });
316
+ return res.ok;
317
+ }
318
+ catch {
319
+ return false;
320
+ }
321
+ }
322
+ async function installService() {
323
+ console.log(chalk.cyan("\n Installing as system service via PM2..."));
324
+ try {
325
+ // Ensure PM2 is installed
326
+ await execFileAsync("pm2", ["--version"]).catch(async () => {
327
+ console.log(chalk.gray(" Installing PM2..."));
328
+ await execFileAsync("npm", ["install", "-g", "pm2"]);
329
+ });
330
+ const { fileURLToPath } = await import("url");
331
+ const { dirname, join } = await import("path");
332
+ const __dirname = dirname(fileURLToPath(import.meta.url));
333
+ const cliEntry = join(__dirname, "index.js");
334
+ // Start in PM2
335
+ await execFileAsync("pm2", [
336
+ "start", cliEntry,
337
+ "--name", "cc-router",
338
+ "--interpreter", process.execPath,
339
+ "--max-memory-restart", "500M",
340
+ "--", "start",
341
+ ]).catch(async (err) => {
342
+ // Already registered — restart instead
343
+ if (err.message?.includes("already")) {
344
+ await execFileAsync("pm2", ["restart", "cc-router"]);
345
+ }
346
+ else {
347
+ throw err;
348
+ }
349
+ });
350
+ await execFileAsync("pm2", ["save"]);
351
+ console.log(chalk.green(" ✓ cc-router registered in PM2 and saved"));
352
+ // Generate startup hook
353
+ try {
354
+ const { stdout, stderr } = await execFileAsync("pm2", ["startup"]);
355
+ const combined = stdout + stderr;
356
+ const sudoMatch = combined.match(/sudo\s+\S.+/);
357
+ if (sudoMatch) {
358
+ console.log(chalk.yellow("\n Run this command to complete auto-start setup:"));
359
+ console.log(chalk.white(` ${sudoMatch[0]}`));
360
+ console.log(chalk.gray(" Then run: pm2 save"));
361
+ }
362
+ else {
363
+ console.log(chalk.green(" ✓ Auto-start on boot configured"));
364
+ }
365
+ }
366
+ catch (err) {
367
+ const combined = (err.stdout ?? "") +
368
+ (err.stderr ?? "");
369
+ const sudoMatch = combined.match(/sudo\s+\S.+/);
370
+ if (sudoMatch) {
371
+ console.log(chalk.yellow("\n Run this command to complete auto-start setup:"));
372
+ console.log(chalk.white(` ${sudoMatch[0]}`));
373
+ console.log(chalk.gray(" Then run: pm2 save"));
374
+ }
375
+ }
376
+ // Wait a moment and confirm it started
377
+ await new Promise(r => setTimeout(r, 1500));
378
+ const running = await isProxyRunning();
379
+ if (running) {
380
+ console.log(chalk.green(` ✓ Proxy is running on http://localhost:${PROXY_PORT}`));
381
+ }
382
+ else {
383
+ console.log(chalk.yellow(" ⚠ Service registered but proxy not yet responding — it may still be starting."));
384
+ console.log(chalk.gray(" Check: cc-router service status"));
385
+ }
386
+ }
387
+ catch (err) {
388
+ console.log(chalk.red(` ✗ Service install failed: ${err.message}`));
389
+ console.log(chalk.gray(" Try manually: cc-router service install"));
390
+ }
391
+ }
392
+ async function startDaemon() {
393
+ console.log(chalk.cyan("\n Starting in background via PM2..."));
394
+ try {
395
+ const { fileURLToPath } = await import("url");
396
+ const { dirname, join } = await import("path");
397
+ const __dirname = dirname(fileURLToPath(import.meta.url));
398
+ const cliEntry = join(__dirname, "index.js");
399
+ await execFileAsync("pm2", [
400
+ "start", cliEntry,
401
+ "--name", "cc-router",
402
+ "--interpreter", process.execPath,
403
+ "--max-memory-restart", "500M",
404
+ "--", "start",
405
+ ]).catch(async (err) => {
406
+ if (err.message?.includes("already")) {
407
+ await execFileAsync("pm2", ["restart", "cc-router"]);
408
+ }
409
+ else {
410
+ throw err;
411
+ }
412
+ });
413
+ await new Promise(r => setTimeout(r, 1500));
414
+ const running = await isProxyRunning();
415
+ if (running) {
416
+ console.log(chalk.green(` ✓ Proxy running in background on http://localhost:${PROXY_PORT}`));
417
+ console.log(chalk.gray(" Logs: pm2 logs cc-router | Stop: cc-router stop"));
418
+ }
419
+ else {
420
+ console.log(chalk.yellow(" ⚠ PM2 registered but proxy not yet responding."));
421
+ console.log(chalk.gray(" Check: pm2 logs cc-router"));
422
+ }
423
+ }
424
+ catch (err) {
425
+ console.log(chalk.red(` ✗ Failed to start via PM2: ${err.message}`));
426
+ console.log(chalk.gray(" PM2 not installed? Run: npm install -g pm2"));
427
+ console.log(chalk.gray(" Or start manually: cc-router start"));
428
+ }
429
+ }
430
+ async function startForeground() {
431
+ const { fileURLToPath } = await import("url");
432
+ const { dirname, join } = await import("path");
433
+ const __dirname = dirname(fileURLToPath(import.meta.url));
434
+ const cliEntry = join(__dirname, "index.js");
435
+ const child = spawn(process.execPath, [cliEntry, "start"], { stdio: "inherit" });
436
+ await new Promise((resolve) => {
437
+ child.on("close", resolve);
438
+ child.on("error", (err) => {
439
+ console.error(chalk.red(` ✗ ${err.message}`));
440
+ resolve();
441
+ });
442
+ });
443
+ }
444
+ // ─── Done banner ──────────────────────────────────────────────────────────────
445
+ function printDone(accountCount) {
446
+ console.log(chalk.bold(`\n${"━".repeat(40)}\n All done — ${accountCount} account(s) ready\n${"━".repeat(40)}\n`));
447
+ console.log(` Dashboard: ${chalk.cyan("cc-router status")}`);
448
+ console.log(` Add more accounts: ${chalk.cyan("cc-router setup --add")}`);
449
+ console.log(` Stop & revert: ${chalk.cyan("cc-router revert")}\n`);
450
+ }
451
+ // ─── Manual token input ───────────────────────────────────────────────────────
202
452
  async function promptManualTokens() {
203
453
  console.log(chalk.gray("\n You can find your tokens by running:\n" +
204
454
  " macOS: security find-generic-password -s 'Claude Code-credentials' -w\n" +
@@ -217,16 +467,13 @@ async function promptManualTokens() {
217
467
  ? true
218
468
  : "Must start with sk-ant-ort01-",
219
469
  });
220
- // expiresAt is optional — default to 8h from now
221
470
  const useDefaultExpiry = await confirm({
222
471
  message: "Use default expiry (8 hours from now)?",
223
472
  default: true,
224
473
  });
225
474
  const expiresAt = useDefaultExpiry
226
475
  ? Date.now() + 8 * 60 * 60 * 1000
227
- : new Date(await input({
228
- message: "Paste expiresAt (ISO date or ms timestamp):",
229
- })).getTime();
476
+ : new Date(await input({ message: "Paste expiresAt (ISO date or ms timestamp):" })).getTime();
230
477
  return {
231
478
  accessToken,
232
479
  refreshToken,
@@ -239,10 +486,3 @@ function printBanner() {
239
486
  "║ CC-Router — Setup ║\n" +
240
487
  "╚══════════════════════════════════════════╝\n"));
241
488
  }
242
- function printNextSteps(accountCount) {
243
- console.log(chalk.bold(`\n${"━".repeat(40)}\n Done — ${accountCount} account(s) configured\n${"━".repeat(40)}\n`));
244
- console.log(` Start proxy: ${chalk.cyan("cc-router start")}`);
245
- console.log(` Auto-start: ${chalk.cyan("cc-router service install")}`);
246
- console.log(` Live dashboard: ${chalk.cyan("cc-router status")}`);
247
- console.log(` Add more accounts: ${chalk.cyan("cc-router setup --add")}\n`);
248
- }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs";
2
- import { CONFIG_DIR, ACCOUNTS_PATH } from "./paths.js";
2
+ import { randomBytes } from "crypto";
3
+ import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
3
4
  export function ensureConfigDir() {
4
5
  if (!existsSync(CONFIG_DIR)) {
5
6
  mkdirSync(CONFIG_DIR, { recursive: true });
@@ -36,6 +37,26 @@ export function writeAccountsAtomic(data) {
36
37
  export function loadAccounts() {
37
38
  return deserialize(readAccountsRaw());
38
39
  }
40
+ export function readConfig() {
41
+ if (!existsSync(CONFIG_PATH))
42
+ return {};
43
+ try {
44
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
45
+ }
46
+ catch {
47
+ return {};
48
+ }
49
+ }
50
+ export function writeConfig(cfg) {
51
+ ensureConfigDir();
52
+ const tmp = CONFIG_PATH + ".tmp";
53
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2), "utf-8");
54
+ renameSync(tmp, CONFIG_PATH);
55
+ }
56
+ export function generateProxySecret() {
57
+ return "cc-rtr-" + randomBytes(16).toString("hex");
58
+ }
59
+ // ─── Accounts ─────────────────────────────────────────────────────────────────
39
60
  function deserialize(records) {
40
61
  return records.map(a => ({
41
62
  id: a.id,
@@ -9,3 +9,6 @@ export const PROXY_PORT = parseInt(process.env["PORT"] ?? "3456", 10);
9
9
  export const LITELLM_PORT = 4000;
10
10
  // When set, the server forwards to LiteLLM instead of Anthropic directly
11
11
  export const LITELLM_URL = process.env["LITELLM_URL"];
12
+ // Proxy-level config (password, future settings) — separate from accounts.json
13
+ export const CONFIG_PATH = process.env["CONFIG_PATH"] ??
14
+ path.join(CONFIG_DIR, "config.json");
@@ -1,9 +1,10 @@
1
1
  import express from "express";
2
2
  import { createProxyMiddleware } from "http-proxy-middleware";
3
3
  import { ServerResponse } from "http";
4
+ import { timingSafeEqual } from "crypto";
4
5
  import { TokenPool } from "./token-pool.js";
5
6
  import { needsRefresh, refreshAccountToken, saveAccounts, startRefreshLoop } from "./token-refresher.js";
6
- import { loadAccounts, accountsFileExists, readAccountsFromPath } from "../config/manager.js";
7
+ import { loadAccounts, accountsFileExists, readAccountsFromPath, readConfig } from "../config/manager.js";
7
8
  import { logRoute, logError, logStartup } from "./logger.js";
8
9
  import { stats } from "./stats.js";
9
10
  import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
@@ -30,6 +31,30 @@ export async function startServer(opts = {}) {
30
31
  const pool = new TokenPool(accounts);
31
32
  startRefreshLoop(accounts);
32
33
  const app = express();
34
+ // ─── Proxy auth middleware ─────────────────────────────────────────────────
35
+ // If a proxySecret is configured, all requests must present it as
36
+ // "Authorization: Bearer <secret>". The /cc-router/health endpoint is
37
+ // always exempt so monitoring and PM2 healthchecks keep working.
38
+ const { proxySecret } = readConfig();
39
+ if (proxySecret) {
40
+ const secretBuf = Buffer.from(proxySecret, "utf-8");
41
+ app.use((req, res, next) => {
42
+ if (req.path === "/cc-router/health")
43
+ return next();
44
+ const auth = req.headers["authorization"] ?? "";
45
+ const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
46
+ const tokenBuf = Buffer.from(token, "utf-8");
47
+ if (tokenBuf.length !== secretBuf.length ||
48
+ !timingSafeEqual(tokenBuf, secretBuf)) {
49
+ res.status(401).json({
50
+ type: "error",
51
+ error: { type: "authentication_error", message: "Invalid or missing proxy authentication token" },
52
+ });
53
+ return;
54
+ }
55
+ next();
56
+ });
57
+ }
33
58
  // ─── Health endpoint (cc-router internal, NOT proxied) ────────────────────
34
59
  app.get("/cc-router/health", (_req, res) => {
35
60
  res.json({
@@ -41,7 +66,7 @@ export async function startServer(opts = {}) {
41
66
  totalErrors: stats.totalErrors,
42
67
  totalRefreshes: stats.totalRefreshes,
43
68
  accounts: pool.getStats(),
44
- recentLogs: stats.getRecentLogs(),
69
+ recentLogs: stats.getRecentLogs(50),
45
70
  });
46
71
  });
47
72
  // ─── Proxy middleware ──────────────────────────────────────────────────────
@@ -90,42 +115,70 @@ export async function startServer(opts = {}) {
90
115
  if (!account)
91
116
  return;
92
117
  const status = proxyRes.statusCode ?? 0;
118
+ const durationMs = req._startTime
119
+ ? Date.now() - req._startTime
120
+ : undefined;
121
+ // Complete the pending log entry with response info
122
+ const pendingLog = req._pendingLog ?? {
123
+ ts: Date.now(),
124
+ accountId: account.id,
125
+ model: "-",
126
+ type: "route",
127
+ };
128
+ pendingLog.statusCode = status;
129
+ if (durationMs !== undefined)
130
+ pendingLog.durationMs = durationMs;
93
131
  if (status === 401) {
94
132
  // Token invalid or expired mid-request.
95
133
  // Forward the 401 to the client (Claude Code will retry on 401).
96
134
  // Schedule a background refresh so the next request succeeds.
97
135
  stats.totalErrors++;
98
136
  account.errorCount++;
137
+ pendingLog.type = "error";
138
+ pendingLog.details = "token invalid";
99
139
  logError(account.id, 401, "Token invalid — scheduling background refresh");
100
- stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "401" });
101
140
  refreshAccountToken(account).then(ok => {
102
141
  if (ok)
103
142
  saveAccounts(pool.getAll());
104
143
  }).catch(console.error);
105
144
  }
106
- if (status === 429) {
145
+ else if (status === 429) {
107
146
  // Rate limited — put account on cooldown for Retry-After seconds.
108
147
  stats.totalErrors++;
109
148
  account.errorCount++;
110
149
  const retryAfter = Number(proxyRes.headers["retry-after"] ?? 60);
150
+ pendingLog.type = "error";
151
+ pendingLog.details = `rate limited — cooldown ${retryAfter}s`;
111
152
  logError(account.id, 429, `Rate limited — cooldown ${retryAfter}s`);
112
- stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "429" });
113
153
  account.busy = true;
114
154
  setTimeout(() => { account.busy = false; }, retryAfter * 1_000);
115
155
  }
116
- if (status === 529) {
156
+ else if (status === 529) {
117
157
  // Anthropic service overloaded — short cooldown on this account.
118
158
  stats.totalErrors++;
119
159
  account.errorCount++;
160
+ pendingLog.type = "error";
161
+ pendingLog.details = "service overloaded — cooldown 30s";
120
162
  logError(account.id, 529, "Service overloaded — cooldown 30s");
121
- stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "529" });
122
163
  account.busy = true;
123
164
  setTimeout(() => { account.busy = false; }, 30_000);
124
165
  }
166
+ stats.addLog(pendingLog);
125
167
  },
126
168
  error: (err, _req, res) => {
127
169
  stats.totalErrors++;
128
170
  logError("proxy", 0, err.message);
171
+ // Complete the pending log entry for connection-level errors
172
+ const pendingLog = _req._pendingLog;
173
+ if (pendingLog) {
174
+ pendingLog.type = "error";
175
+ pendingLog.statusCode = 0;
176
+ pendingLog.details = err.message;
177
+ if (_req._startTime) {
178
+ pendingLog.durationMs = Date.now() - _req._startTime;
179
+ }
180
+ stats.addLog(pendingLog);
181
+ }
129
182
  // res may be a Socket (WebSocket upgrade) — only respond on HTTP ServerResponse
130
183
  if (res instanceof ServerResponse && !res.headersSent) {
131
184
  // Match Anthropic's error response format so Claude Code handles it gracefully
@@ -150,8 +203,16 @@ export async function startServer(opts = {}) {
150
203
  saveAccounts(pool.getAll());
151
204
  }
152
205
  req._ccAccount = account;
206
+ req._startTime = Date.now();
207
+ req._pendingLog = {
208
+ ts: Date.now(),
209
+ accountId: account.id,
210
+ model: "-",
211
+ type: "route",
212
+ method: req.method,
213
+ path: req.path,
214
+ };
153
215
  stats.totalRequests++;
154
- stats.addLog({ ts: Date.now(), accountId: account.id, model: "?", type: "route" });
155
216
  logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - Date.now()) / 60_000));
156
217
  next();
157
218
  }, proxy);
@@ -1,4 +1,4 @@
1
- const MAX_LOG_ENTRIES = 50;
1
+ const MAX_LOG_ENTRIES = 100;
2
2
  class ProxyStats {
3
3
  totalRequests = 0;
4
4
  totalErrors = 0;
@@ -1,7 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from "react";
3
3
  import { Box, Text, useInput, useApp } from "ink";
4
4
  const POLL_INTERVAL_MS = 2_000;
5
+ const LOG_VISIBLE = 20;
5
6
  // ─── Dashboard component ──────────────────────────────────────────────────────
6
7
  export function Dashboard({ port }) {
7
8
  const { exit } = useApp();
@@ -59,9 +60,28 @@ function ErrorScreen({ error, port, retries }) {
59
60
  function LiveDashboard({ data, port, lastUpdate }) {
60
61
  const healthyCount = data.accounts.filter(a => a.healthy).length;
61
62
  const updatedAgo = Math.round((Date.now() - lastUpdate) / 1000);
62
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map(a => (_jsx(AccountRow, { account: a }, a.id))) })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.recentLogs.length === 0
63
+ const logs = data.recentLogs;
64
+ // Stable selection: track by timestamp so it survives log rotations
65
+ const [selectedTs, setSelectedTs] = useState(null);
66
+ // Derive index from timestamp; default to 0 (newest)
67
+ const selectedIndex = selectedTs !== null
68
+ ? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
69
+ : 0;
70
+ useInput((_input, key) => {
71
+ if (key.upArrow) {
72
+ const next = Math.max(0, selectedIndex - 1);
73
+ setSelectedTs(logs[next]?.ts ?? null);
74
+ }
75
+ if (key.downArrow) {
76
+ const next = Math.min(logs.length - 1, selectedIndex + 1);
77
+ setSelectedTs(logs[next]?.ts ?? null);
78
+ }
79
+ });
80
+ const selectedLog = logs[selectedIndex] ?? null;
81
+ const visibleLogs = logs.slice(0, LOG_VISIBLE);
82
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [\u2191\u2193] navigate \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map(a => (_jsx(AccountRow, { account: a }, a.id))) })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
63
83
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
64
- : data.recentLogs.slice(0, 10).map((log, i) => (_jsx(LogRow, { log: log }, i))) })] })] }));
84
+ : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: i === selectedIndex }, log.ts))) })] }), selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
65
85
  }
66
86
  // ─── Account row ──────────────────────────────────────────────────────────────
67
87
  function AccountRow({ account: a }) {
@@ -76,11 +96,57 @@ function AccountRow({ account: a }) {
76
96
  return (_jsxs(Box, { children: [_jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { children: a.id.slice(0, 22).padEnd(22) }), _jsx(Text, { color: statusColor, children: statusLabel }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: "white", children: String(a.requestCount).padStart(5) }), _jsx(Text, { color: "gray", children: " err " }), _jsx(Text, { color: a.errorCount > 0 ? "red" : "gray", children: String(a.errorCount).padStart(3) }), _jsx(Text, { color: "gray", children: " expires " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(10) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) })] }));
77
97
  }
78
98
  // ─── Log row ──────────────────────────────────────────────────────────────────
79
- function LogRow({ log }) {
99
+ function LogRow({ log, selected }) {
80
100
  const time = new Date(log.ts).toLocaleTimeString("en-GB", { hour12: false });
81
- const typeColor = log.type === "error" ? "red" : log.type === "refresh" ? "yellow" : "gray";
82
- const typeIcon = log.type === "error" ? "✗" : log.type === "refresh" ? "↻" : "→";
83
- return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [" ", time, " "] }), _jsxs(Text, { color: typeColor, children: [typeIcon, " "] }), _jsx(Text, { color: "cyan", children: log.accountId.slice(0, 22).padEnd(22) }), _jsxs(Text, { color: typeColor, children: [" ", log.type] }), log.details && _jsxs(Text, { color: "gray", children: [" ", log.details] })] }));
101
+ const isError = log.type === "error";
102
+ const isRefresh = log.type === "refresh";
103
+ const typeColor = isError ? "red" : isRefresh ? "yellow" : "gray";
104
+ const typeIcon = isError ? "✗" : isRefresh ? "↻" : "→";
105
+ const statusColor = log.statusCode === undefined ? undefined
106
+ : log.statusCode >= 500 ? "red"
107
+ : log.statusCode >= 400 ? "yellow"
108
+ : log.statusCode >= 200 ? "green"
109
+ : "gray";
110
+ const bg = selected ? "white" : undefined;
111
+ const fg = (c) => selected ? "black" : c;
112
+ return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
113
+ ? _jsxs(Text, { backgroundColor: bg, color: fg("white"), children: [" ", log.method, " ", log.path.padEnd(14)] })
114
+ : _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [" ", log.type.padEnd(9)] }), log.statusCode !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg(statusColor), children: [" ", log.statusCode] })), log.durationMs !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.durationMs, "ms"] })), log.details && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.details] }))] }));
115
+ }
116
+ // ─── Detail panel ─────────────────────────────────────────────────────────────
117
+ function DetailPanel({ log }) {
118
+ const time = new Date(log.ts).toLocaleString("en-GB", {
119
+ hour12: false,
120
+ year: "numeric", month: "2-digit", day: "2-digit",
121
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
122
+ });
123
+ const isError = log.type === "error";
124
+ const statusLabel = log.statusCode === undefined ? "—"
125
+ : log.statusCode === 0 ? "connection error"
126
+ : `${log.statusCode} ${httpStatusText(log.statusCode)}`;
127
+ const statusColor = log.statusCode === undefined ? "gray"
128
+ : log.statusCode === 0 ? "red"
129
+ : log.statusCode >= 500 ? "red"
130
+ : log.statusCode >= 400 ? "yellow"
131
+ : "green";
132
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) }))] })] }));
133
+ }
134
+ function Field({ label, value }) {
135
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
136
+ }
137
+ function FieldColored({ label, value, color }) {
138
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: color, children: value })] }));
139
+ }
140
+ // ─── HTTP status text ─────────────────────────────────────────────────────────
141
+ function httpStatusText(code) {
142
+ const map = {
143
+ 200: "OK", 201: "Created", 204: "No Content",
144
+ 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
145
+ 404: "Not Found", 429: "Too Many Requests",
146
+ 500: "Internal Server Error", 502: "Bad Gateway",
147
+ 503: "Service Unavailable", 529: "Overloaded",
148
+ };
149
+ return map[code] ?? "";
84
150
  }
85
151
  // ─── Formatters ───────────────────────────────────────────────────────────────
86
152
  function formatUptime(seconds) {
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
2
  import { dirname } from "path";
3
3
  import { CLAUDE_SETTINGS_PATH } from "../config/paths.js";
4
+ import { readConfig } from "../config/manager.js";
4
5
  /**
5
6
  * Write ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN into ~/.claude/settings.json.
6
7
  *
@@ -9,7 +10,12 @@ import { CLAUDE_SETTINGS_PATH } from "../config/paths.js";
9
10
  * - Do NOT append /v1 to ANTHROPIC_BASE_URL — Claude Code adds it automatically
10
11
  * - Merges with existing settings, preserving all other keys
11
12
  */
12
- export function writeClaudeSettings(port) {
13
+ /**
14
+ * @param port - proxy port (used only when baseUrl is not provided)
15
+ * @param baseUrl - full proxy URL e.g. "http://192.168.1.50:3456" or "https://cc-router.example.com"
16
+ * If omitted, defaults to http://localhost:<port>
17
+ */
18
+ export function writeClaudeSettings(port, baseUrl) {
13
19
  const dir = dirname(CLAUDE_SETTINGS_PATH);
14
20
  if (!existsSync(dir))
15
21
  mkdirSync(dir, { recursive: true });
@@ -23,15 +29,16 @@ export function writeClaudeSettings(port) {
23
29
  }
24
30
  }
25
31
  const existingEnv = existing["env"] ?? {};
32
+ // ANTHROPIC_BASE_URL: no trailing /v1 — Claude Code appends it automatically
33
+ const resolvedUrl = baseUrl ?? `http://localhost:${port}`;
26
34
  const updated = {
27
35
  ...existing,
28
36
  env: {
29
37
  ...existingEnv,
30
- // ANTHROPIC_BASE_URL: no trailing /v1 — Claude Code appends it automatically
31
- ANTHROPIC_BASE_URL: `http://localhost:${port}`,
38
+ ANTHROPIC_BASE_URL: resolvedUrl,
32
39
  // ANTHROPIC_AUTH_TOKEN has higher precedence than ANTHROPIC_API_KEY in Claude Code.
33
- // The proxy replaces this placeholder with the real OAuth token per request.
34
- ANTHROPIC_AUTH_TOKEN: "proxy-managed",
40
+ // Uses the configured proxy secret if set, otherwise falls back to the open placeholder.
41
+ ANTHROPIC_AUTH_TOKEN: readConfig().proxySecret ?? "proxy-managed",
35
42
  },
36
43
  };
37
44
  writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(updated, null, 2), "utf-8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Round-robin proxy for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {