ai-cc-router 0.2.2 → 0.2.4

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/README.md CHANGED
@@ -7,6 +7,21 @@ Distribute Claude Code requests across N subscriptions to multiply your throughp
7
7
  [![npm](https://img.shields.io/npm/v/ai-cc-router)](https://www.npmjs.com/package/ai-cc-router)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
9
 
10
+ ### Features
11
+
12
+ - **Round-robin token rotation** — distribute requests across 2-20 Claude Max accounts automatically
13
+ - **Transparent proxy** — Claude Code works normally; streaming, thinking, tool use, prompt caching all pass through
14
+ - **Automatic token refresh** — OAuth tokens are refreshed before they expire, saved atomically to disk
15
+ - **Rate limit awareness** — detects 429/529 responses and coolsdown accounts; picks the least-loaded one
16
+ - **Client mode** — connect to a remote CC-Router from any machine with one command (`cc-router client connect <url>`)
17
+ - **Claude Desktop support** — route Cowork / Agent-mode traffic through CC-Router via mitmproxy interception (macOS, Windows, Linux)
18
+ - **Guided setup wizard** — interactive `cc-router setup` extracts tokens from Keychain or credentials file, configures everything
19
+ - **Live dashboard** — real-time terminal UI showing account health, request counts, token usage, recent activity
20
+ - **Proxy authentication** — optional Bearer / x-api-key secret for internet-exposed deployments
21
+ - **Auto-update** — patch/minor releases install automatically (opt-out available)
22
+ - **Multiple deployment modes** — foreground, PM2 daemon, system service, Docker Compose (with LiteLLM)
23
+ - **Cross-platform** — macOS, Linux, Windows; Node.js 20+
24
+
10
25
  ---
11
26
 
12
27
  > **Warning**
@@ -4,7 +4,7 @@ import { input, confirm } from "@inquirer/prompts";
4
4
  import { readConfig, writeConfig } from "../config/manager.js";
5
5
  import { writeClaudeSettings, removeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
6
6
  import { isMacos, isWindows } from "../utils/platform.js";
7
- import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, startInterceptor, stopInterceptor, isInterceptorRunning, getProcessName, } from "../interceptor/mitmproxy-manager.js";
7
+ import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, startInterceptor, stopInterceptor, isInterceptorRunning, getProcessName, getNetworkExtensionStatus, openNetworkExtensionSettings, } from "../interceptor/mitmproxy-manager.js";
8
8
  // ─── Helpers ──────────────────────────────────────────────────────────────────
9
9
  function isClaudeDesktopInstalled() {
10
10
  if (isMacos()) {
@@ -112,9 +112,15 @@ export function registerClient(program) {
112
112
  writeClaudeSettings(0, url, secret ?? "proxy-managed");
113
113
  console.log(chalk.green("✓ Claude Code configured to route through CC-Router"));
114
114
  console.log(chalk.gray(` ANTHROPIC_BASE_URL → ${url}`));
115
- // 6. Optionally configure Claude Desktop
116
- const wantsDesktop = opts?.desktop ?? (isClaudeDesktopInstalled() &&
117
- await confirm({ message: "Also route Claude Desktop (chat + Cowork) through the proxy?", default: false }));
115
+ // 6. Optionally configure Claude Desktop (Cowork / Agent mode only)
116
+ let wantsDesktop = opts?.desktop ?? false;
117
+ if (!opts?.desktop && isClaudeDesktopInstalled()) {
118
+ printDesktopSupportExplainer();
119
+ wantsDesktop = await confirm({
120
+ message: "Route Claude Desktop's Cowork / Agent-mode traffic through CC-Router?",
121
+ default: false,
122
+ });
123
+ }
118
124
  if (wantsDesktop) {
119
125
  await setupDesktopInterception(url);
120
126
  cfg.client.desktopEnabled = true;
@@ -198,7 +204,11 @@ export function registerClient(program) {
198
204
  const status = log.statusCode ?? 0;
199
205
  const statusColor = status >= 500 || status === 0 ? chalk.red : status >= 400 ? chalk.yellow : chalk.green;
200
206
  const duration = log.durationMs ? ` ${chalk.gray(log.durationMs + "ms")}` : "";
201
- console.log(` ${chalk.gray(formatTime(log.ts))} ${log.accountId.padEnd(18)} ` +
207
+ const src = log.source === "cli" ? chalk.blue("cli")
208
+ : log.source === "desktop" ? chalk.magenta("dsk")
209
+ : log.source === "api" ? chalk.gray("api")
210
+ : chalk.gray(" ");
211
+ console.log(` ${chalk.gray(formatTime(log.ts))} ${src} ${log.accountId.padEnd(18)} ` +
202
212
  `${(log.method ?? "?").padEnd(5)} ${(log.path ?? "?").padEnd(22)} ` +
203
213
  `${statusColor(String(status))}${duration}`);
204
214
  }
@@ -207,16 +217,34 @@ export function registerClient(program) {
207
217
  console.log(chalk.gray("\n No recent activity on the remote proxy."));
208
218
  }
209
219
  // ── Desktop status ─────────────────────────────────────────────────
210
- console.log(chalk.bold("\n DESKTOP INTERCEPTOR"));
220
+ console.log(chalk.bold("\n DESKTOP INTERCEPTOR (Cowork / Agent mode)"));
211
221
  if (cfg.client.desktopEnabled) {
212
222
  const running = await isInterceptorRunning();
213
- console.log(` ${running ? chalk.green("● running") : chalk.yellow("○ configured but stopped")}`);
214
- if (!running) {
223
+ if (running) {
224
+ console.log(` ${chalk.green("● running")}`);
225
+ }
226
+ else {
227
+ console.log(` ${chalk.yellow("○ configured but stopped")}`);
215
228
  console.log(chalk.gray(" Start with: cc-router client start-desktop"));
216
229
  }
230
+ // Check Network Extension on macOS
231
+ if (isMacos()) {
232
+ const extStatus = await getNetworkExtensionStatus();
233
+ if (extStatus === "waiting") {
234
+ console.log(chalk.red(" ⚠ Network Extension NOT approved — interceptor won't capture traffic!"));
235
+ console.log(chalk.gray(" Fix: System Settings → General → Login Items & Extensions → Network Extensions"));
236
+ }
237
+ else if (extStatus === "not_installed") {
238
+ console.log(chalk.yellow(" ⚠ Network Extension not installed — will be triggered on first start"));
239
+ }
240
+ else if (extStatus === "enabled") {
241
+ console.log(` ${chalk.green("✓")} ${chalk.gray("Network Extension: enabled")}`);
242
+ }
243
+ }
244
+ console.log(chalk.gray(" Scope: /v1/messages + /v1/models (normal chat NOT routed)"));
217
245
  }
218
246
  else {
219
- console.log(` ${chalk.gray("not configured")}`);
247
+ console.log(` ${chalk.gray("not configured — enable with: cc-router client connect --desktop")}`);
220
248
  }
221
249
  console.log();
222
250
  console.log(chalk.gray(" Live dashboard: cc-router status\n"));
@@ -224,16 +252,17 @@ export function registerClient(program) {
224
252
  // ── cc-router client start-desktop ──────────────────────────────────────────
225
253
  client
226
254
  .command("start-desktop")
227
- .description("Start mitmproxy interceptor for Claude Desktop")
255
+ .description("Start mitmproxy interceptor for Claude Desktop (Cowork / Agent mode)")
228
256
  .action(async () => {
229
257
  const cfg = readConfig();
230
258
  if (!cfg.client) {
231
259
  console.error(chalk.red("Not connected. Run: cc-router client connect <url>"));
232
260
  process.exit(1);
233
261
  }
234
- if (!await checkMitmproxyInstalled()) {
235
- console.error(chalk.red("mitmproxy not found. Install it first:"));
236
- console.error(chalk.yellow(isMacos() ? " brew install mitmproxy" : " pip install mitmproxy"));
262
+ if (!(await checkMitmproxyInstalled())) {
263
+ console.error(chalk.red("\n✗ mitmproxy not found. Install it first:"));
264
+ console.error(chalk.cyan(isMacos() ? " brew install mitmproxy" : " pip install mitmproxy"));
265
+ console.error();
237
266
  process.exit(1);
238
267
  }
239
268
  if (!cfg.client.desktopEnabled) {
@@ -241,13 +270,52 @@ export function registerClient(program) {
241
270
  cfg.client.desktopEnabled = true;
242
271
  writeConfig(cfg);
243
272
  }
273
+ // Pre-flight check: verify Network Extension is ready on macOS.
274
+ // startInterceptor does the same check and throws; we catch and show
275
+ // a friendlier block here with the open-settings shortcut.
276
+ if (isMacos()) {
277
+ const status = await getNetworkExtensionStatus();
278
+ if (status === "waiting") {
279
+ console.error(chalk.red("\n✗ Mitmproxy Network Extension is NOT yet approved.\n"));
280
+ printNetworkExtensionInstructions();
281
+ const openNow = await confirm({
282
+ message: "Open System Settings now?",
283
+ default: true,
284
+ });
285
+ if (openNow)
286
+ await openNetworkExtensionSettings();
287
+ console.error(chalk.yellow("\n Re-run `cc-router client start-desktop` after approving.\n"));
288
+ process.exit(1);
289
+ }
290
+ if (status === "not_installed") {
291
+ console.error(chalk.yellow("\n⚠ Mitmproxy Network Extension is not installed yet."));
292
+ console.error(chalk.gray(" The first mitmdump run will trigger installation."));
293
+ console.error(chalk.gray(" Approve it in System Settings when macOS prompts you, then re-run this command.\n"));
294
+ }
295
+ }
244
296
  const target = cfg.client.remoteUrl;
245
297
  const processName = getProcessName();
246
298
  console.log(chalk.cyan(`\nStarting mitmproxy interceptor for "${processName}"...`));
247
- console.log(chalk.gray(` Redirecting api.anthropic.com → ${target}\n`));
248
- await startInterceptor(target);
249
- console.log(chalk.green("✓ Claude Desktop interceptor running"));
250
- console.log(chalk.gray(" Open Claude Desktop and send a message to test.\n"));
299
+ console.log(chalk.gray(` Redirecting api.anthropic.com/v1/messages → ${target}`));
300
+ try {
301
+ await startInterceptor(target);
302
+ }
303
+ catch (e) {
304
+ console.error(chalk.red(`\n✗ Failed to start interceptor:\n`));
305
+ console.error(chalk.yellow(" " + e.message.split("\n").join("\n ")));
306
+ console.error();
307
+ process.exit(1);
308
+ }
309
+ console.log(chalk.green("\n✓ Claude Desktop interceptor running"));
310
+ console.log();
311
+ console.log(chalk.bold.yellow(" Next steps:"));
312
+ console.log(" " + chalk.cyan("1.") + " Quit Claude Desktop completely (⌘Q)");
313
+ console.log(" " + chalk.cyan("2.") + " Reopen Claude Desktop");
314
+ console.log(" " + chalk.cyan("3.") + " Use Cowork / Agent mode (Claude Code in Desktop)");
315
+ console.log();
316
+ console.log(chalk.gray(" Check routing with: ") + chalk.cyan("cc-router client status"));
317
+ console.log(chalk.gray(" Stop interceptor: ") + chalk.cyan("cc-router client stop-desktop"));
318
+ console.log();
251
319
  });
252
320
  // ── cc-router client stop-desktop ───────────────────────────────────────────
253
321
  client
@@ -259,53 +327,154 @@ export function registerClient(program) {
259
327
  });
260
328
  }
261
329
  // ─── Desktop setup flow ───────────────────────────────────────────────────────
330
+ /**
331
+ * Printed before asking the user whether to enable Desktop interception.
332
+ * The copy is deliberately explicit about WHAT works and WHAT doesn't — users
333
+ * who expect the normal chat to go through CC-Router will hit confusion fast,
334
+ * and we can head it off here by framing this as a "Cowork / Agent mode" feature.
335
+ */
336
+ export function printDesktopSupportExplainer() {
337
+ console.log(chalk.bold.cyan("\n 🖥 Claude Desktop — what CC-Router can route\n"));
338
+ console.log(" Claude Desktop does NOT expose ANTHROPIC_BASE_URL, so CC-Router uses\n" +
339
+ " mitmproxy to selectively intercept only the traffic it can handle:\n");
340
+ console.log(chalk.green(" ✓ Cowork / Agent mode ") + chalk.gray("— /v1/messages (this is what gets routed)"));
341
+ console.log(chalk.green(" ✓ Claude Code inside Desktop") + chalk.gray("— /v1/messages (same as CLI)"));
342
+ console.log(chalk.red(" ✗ Normal chat ") + chalk.gray("— goes to claude.ai webview, NOT redirectable"));
343
+ console.log();
344
+ console.log(chalk.gray(" TL;DR: Your LLM-heavy workflows (Cowork, agent tasks, in-Desktop\n" +
345
+ " Claude Code) will rotate across your Max accounts via CC-Router.\n" +
346
+ " The regular chat sidebar keeps going directly through claude.ai."));
347
+ console.log();
348
+ }
349
+ /**
350
+ * Prints the macOS Network Extension approval walkthrough.
351
+ * This is the #1 gotcha — mitmdump starts silently but captures nothing
352
+ * until the user flips the toggle in System Settings.
353
+ */
354
+ export function printNetworkExtensionInstructions() {
355
+ if (!isMacos())
356
+ return;
357
+ console.log(chalk.bold.yellow("\n ⚠ IMPORTANT — macOS Network Extension approval\n"));
358
+ console.log(" The first time mitmproxy runs in local mode, macOS installs a");
359
+ console.log(" Network Extension (" + chalk.cyan("Mitmproxy Redirector") + ") that must be approved");
360
+ console.log(" manually. " + chalk.red("Without this step, mitmproxy captures ZERO traffic.") + "\n");
361
+ console.log(chalk.bold(" Steps:"));
362
+ console.log(" " + chalk.cyan("1.") + " Open " + chalk.bold("System Settings"));
363
+ console.log(" " + chalk.cyan("2.") + " Go to " + chalk.bold("General → Login Items & Extensions"));
364
+ console.log(" " + chalk.cyan("3.") + " Scroll to " + chalk.bold("Network Extensions") + " and click the " + chalk.bold("ⓘ") + " button");
365
+ console.log(" " + chalk.cyan("4.") + " Toggle " + chalk.bold("Mitmproxy Redirector") + " ON");
366
+ console.log(" " + chalk.cyan("5.") + " Enter your Mac admin password when prompted\n");
367
+ console.log(chalk.gray(" You only need to do this ONCE per machine.\n"));
368
+ }
262
369
  async function setupDesktopInterception(target) {
263
370
  console.log(chalk.bold("\n🖥 Claude Desktop Setup\n"));
371
+ // 0. Explain what actually works before anything else
372
+ printDesktopSupportExplainer();
373
+ const proceedWithSetup = await confirm({
374
+ message: "Continue with Cowork / Agent-mode interception setup?",
375
+ default: true,
376
+ });
377
+ if (!proceedWithSetup) {
378
+ console.log(chalk.gray("Skipping Desktop setup. You can run it later with: cc-router client start-desktop\n"));
379
+ return;
380
+ }
264
381
  // 1. Check mitmproxy
265
- if (!await checkMitmproxyInstalled()) {
266
- console.log(chalk.yellow("mitmproxy is required but not installed."));
382
+ if (!(await checkMitmproxyInstalled())) {
383
+ console.log(chalk.yellow("\nmitmproxy is required but not installed."));
267
384
  if (isMacos()) {
268
- console.log(chalk.cyan(" Install: brew install mitmproxy"));
385
+ console.log(chalk.cyan(" Install: brew install mitmproxy"));
269
386
  }
270
387
  else if (isWindows()) {
271
- console.log(chalk.cyan(" Install: pip install mitmproxy"));
388
+ console.log(chalk.cyan(" Install: pip install mitmproxy (or download the installer from mitmproxy.org)"));
272
389
  }
273
390
  else {
274
- console.log(chalk.cyan(" Install: pip install mitmproxy (requires kernel ≥ 6.8)"));
391
+ console.log(chalk.cyan(" Install: pip install mitmproxy (Linux local mode requires kernel ≥ 6.8)"));
275
392
  }
276
393
  console.log();
277
- const proceed = await confirm({ message: "Have you installed mitmproxy?", default: false });
278
- if (!proceed || !await checkMitmproxyInstalled()) {
279
- console.log(chalk.red("mitmproxy not found. Skipping Desktop setup.\n"));
394
+ const proceed = await confirm({ message: "Have you installed mitmproxy now?", default: false });
395
+ if (!proceed || !(await checkMitmproxyInstalled())) {
396
+ console.log(chalk.red("\nmitmproxy still not found. Skipping Desktop setup.\n"));
397
+ console.log(chalk.gray("Re-run later with: cc-router client start-desktop\n"));
280
398
  return;
281
399
  }
282
400
  }
283
401
  console.log(chalk.green("✓ mitmproxy found"));
284
- // 2. Generate CA cert if needed
402
+ // 2. Generate CA cert if missing
285
403
  if (!isCaCertInstalled()) {
286
- console.log(chalk.gray("Generating mitmproxy CA certificate..."));
287
- await generateCaCert();
404
+ console.log(chalk.gray("Generating mitmproxy CA certificate (one-time)..."));
405
+ try {
406
+ await generateCaCert();
407
+ console.log(chalk.green("✓ CA certificate generated"));
408
+ }
409
+ catch (e) {
410
+ console.log(chalk.red(`✗ CA generation failed: ${e.message}`));
411
+ return;
412
+ }
413
+ }
414
+ else {
415
+ console.log(chalk.green("✓ CA certificate already present"));
288
416
  }
289
417
  // 3. Install CA cert (requires sudo)
290
- console.log(chalk.yellow("\nInstalling the mitmproxy CA certificate requires admin access."));
291
- console.log(chalk.gray("This is needed so Claude Desktop trusts the local interceptor."));
292
- const installCa = await confirm({ message: "Install CA certificate now? (requires password)", default: true });
418
+ console.log();
419
+ console.log(chalk.yellow("The mitmproxy CA certificate must be trusted by your OS so that"));
420
+ console.log(chalk.yellow("Claude Desktop accepts the local interceptor. This requires sudo."));
421
+ const installCa = await confirm({ message: "Install CA certificate now? (asks for admin password)", default: true });
293
422
  if (installCa) {
294
423
  const ok = await installCaCert();
295
424
  if (ok) {
296
- console.log(chalk.green("✓ CA certificate installed"));
425
+ console.log(chalk.green("✓ CA certificate installed in system trust store"));
297
426
  }
298
427
  else {
299
- console.log(chalk.red("✗ CA certificate install failed. You may need to install it manually."));
428
+ console.log(chalk.red("✗ CA certificate install failed."));
429
+ console.log(chalk.gray(" Install manually later with:"));
430
+ console.log(chalk.gray(" sudo security add-trusted-cert -d -r trustRoot \\"));
431
+ console.log(chalk.gray(" -k /Library/Keychains/System.keychain \\"));
432
+ console.log(chalk.gray(" ~/.mitmproxy/mitmproxy-ca-cert.pem"));
300
433
  }
301
434
  }
302
435
  // 4. Write addon script
303
436
  writeAddonScript(target);
304
437
  console.log(chalk.green("✓ Redirect addon configured"));
305
- // 5. macOS Network Extension note
438
+ // 5. macOS Network Extension — THIS is the step people miss
306
439
  if (isMacos()) {
307
- console.log(chalk.yellow("\n⚠ On first run, macOS will ask to approve mitmproxy's Network Extension."));
308
- console.log(chalk.gray(" Go to System Settings General Login Items & Extensions → Network Extensions"));
309
- console.log(chalk.gray(" and toggle 'Mitmproxy Redirector' on.\n"));
440
+ printNetworkExtensionInstructions();
441
+ // Check current status and guide the user if it's not enabled
442
+ const status = await getNetworkExtensionStatus();
443
+ if (status === "not_installed") {
444
+ console.log(chalk.gray(" The Network Extension hasn't been installed yet — it'll be triggered\n" +
445
+ " automatically the first time you run `cc-router client start-desktop`.\n" +
446
+ " macOS will show a popup — approve it and follow the steps above.\n"));
447
+ }
448
+ else if (status === "waiting") {
449
+ console.log(chalk.red(" ⚠ Network Extension is installed but NOT yet approved.\n"));
450
+ const openNow = await confirm({
451
+ message: "Open System Settings now so you can approve it?",
452
+ default: true,
453
+ });
454
+ if (openNow) {
455
+ await openNetworkExtensionSettings();
456
+ console.log(chalk.gray("\n System Settings should now be open."));
457
+ console.log(chalk.gray(" Toggle 'Mitmproxy Redirector' ON, then come back here.\n"));
458
+ await confirm({ message: "Done? Press Enter when the toggle is ON", default: true });
459
+ const newStatus = await getNetworkExtensionStatus();
460
+ if (newStatus === "enabled") {
461
+ console.log(chalk.green("✓ Network Extension is enabled"));
462
+ }
463
+ else {
464
+ console.log(chalk.yellow(` Still not enabled (status: ${newStatus})`));
465
+ console.log(chalk.gray(" You can re-check later with: cc-router client status"));
466
+ }
467
+ }
468
+ }
469
+ else if (status === "enabled") {
470
+ console.log(chalk.green(" ✓ Network Extension is already enabled — you're all set"));
471
+ }
310
472
  }
473
+ // 6. Remind that Claude Desktop must be restarted for mitmproxy to hook into it
474
+ console.log();
475
+ console.log(chalk.bold.yellow(" One more thing:"));
476
+ console.log(chalk.gray(" After starting the interceptor, you must " + chalk.bold("quit and relaunch Claude Desktop")));
477
+ console.log(chalk.gray(" (⌘Q in Claude Desktop, then reopen it). mitmproxy only captures"));
478
+ console.log(chalk.gray(" traffic from processes started AFTER it begins listening."));
479
+ console.log();
311
480
  }
@@ -11,7 +11,8 @@ import { loadAccounts, accountsFileExists, readConfig, writeConfig, generateProx
11
11
  import { PROXY_PORT } from "../config/paths.js";
12
12
  import { DEFAULT_RATE_LIMITS } from "../proxy/types.js";
13
13
  import { existsSync } from "fs";
14
- import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, } from "../interceptor/mitmproxy-manager.js";
14
+ import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, getNetworkExtensionStatus, openNetworkExtensionSettings, } from "../interceptor/mitmproxy-manager.js";
15
+ import { printDesktopSupportExplainer, printNetworkExtensionInstructions } from "./cmd-client.js";
15
16
  const execFileAsync = promisify(execFile);
16
17
  // ─── Public registration ──────────────────────────────────────────────────────
17
18
  export function registerSetup(program) {
@@ -567,11 +568,12 @@ async function runClientSetupFromWizard() {
567
568
  writeClaudeSettings(0, url, secret ?? "proxy-managed");
568
569
  console.log(chalk.green("✓ Claude Code configured"));
569
570
  console.log(chalk.gray(` ANTHROPIC_BASE_URL → ${url}\n`));
570
- // ── Claude Desktop question ─────────────────────────────────────────────
571
+ // ── Claude Desktop (Cowork / Agent mode) ─────────────────────────────────
571
572
  const desktopInstalled = isMacos() && existsSync("/Applications/Claude.app");
572
573
  if (desktopInstalled) {
574
+ printDesktopSupportExplainer();
573
575
  const wantsDesktop = await confirm({
574
- message: "Also route Claude Desktop (chat + Cowork) through the proxy?",
576
+ message: "Route Claude Desktop's Cowork / Agent-mode traffic through CC-Router?",
575
577
  default: false,
576
578
  });
577
579
  if (wantsDesktop) {
@@ -589,33 +591,82 @@ async function runClientSetupFromWizard() {
589
591
  console.log();
590
592
  }
591
593
  async function setupDesktopFromWizard(target) {
592
- console.log(chalk.bold("\n🖥 Claude Desktop Setup\n"));
594
+ console.log(chalk.bold("\n🖥 Claude Desktop — Cowork / Agent Setup\n"));
595
+ // 1. Check mitmproxy
593
596
  if (!(await checkMitmproxyInstalled())) {
594
597
  console.log(chalk.yellow("mitmproxy is required but not installed."));
595
- console.log(chalk.cyan(" Install: brew install mitmproxy\n"));
596
- const proceed = await confirm({ message: "Have you installed mitmproxy?", default: false });
598
+ if (isMacos()) {
599
+ console.log(chalk.cyan(" Install: brew install mitmproxy\n"));
600
+ }
601
+ else {
602
+ console.log(chalk.cyan(" Install: pip install mitmproxy\n"));
603
+ }
604
+ const proceed = await confirm({ message: "Have you installed mitmproxy now?", default: false });
597
605
  if (!proceed || !(await checkMitmproxyInstalled())) {
598
- console.log(chalk.red("Skipping Desktop setup.\n"));
606
+ console.log(chalk.red("Skipping Desktop setup. Re-run with: cc-router client start-desktop\n"));
599
607
  return;
600
608
  }
601
609
  }
602
610
  console.log(chalk.green("✓ mitmproxy found"));
611
+ // 2. CA cert
603
612
  if (!isCaCertInstalled()) {
604
- console.log(chalk.gray("Generating mitmproxy CA certificate..."));
605
- await generateCaCert();
613
+ console.log(chalk.gray("Generating mitmproxy CA certificate (one-time)..."));
614
+ try {
615
+ await generateCaCert();
616
+ console.log(chalk.green("✓ CA certificate generated"));
617
+ }
618
+ catch (e) {
619
+ console.log(chalk.red(`✗ CA generation failed: ${e.message}`));
620
+ return;
621
+ }
622
+ }
623
+ else {
624
+ console.log(chalk.green("✓ CA certificate already present"));
606
625
  }
607
- console.log(chalk.yellow("\nInstalling the CA certificate requires your admin password."));
626
+ console.log(chalk.yellow("\nThe CA certificate must be installed in your OS trust store (requires admin)."));
608
627
  const doInstall = await confirm({ message: "Install CA certificate now?", default: true });
609
628
  if (doInstall) {
610
629
  const ok = await installCaCert();
611
- console.log(ok ? chalk.green("✓ CA certificate installed") : chalk.red("✗ CA install failed — install manually later"));
630
+ if (ok) {
631
+ console.log(chalk.green("✓ CA certificate installed in system trust store"));
632
+ }
633
+ else {
634
+ console.log(chalk.red("✗ CA install failed."));
635
+ console.log(chalk.gray(" Install manually: sudo security add-trusted-cert -d -r trustRoot \\"));
636
+ console.log(chalk.gray(" -k /Library/Keychains/System.keychain ~/.mitmproxy/mitmproxy-ca-cert.pem"));
637
+ }
612
638
  }
639
+ // 3. Addon
613
640
  writeAddonScript(target);
614
641
  console.log(chalk.green("✓ Redirect addon configured"));
642
+ // 4. Network Extension walkthrough (macOS)
615
643
  if (isMacos()) {
616
- console.log(chalk.yellow("\n⚠ On first run macOS will ask to approve mitmproxy's Network Extension."));
617
- console.log(chalk.gray(" System Settings General → Login Items & Extensions → Network Extensions"));
618
- console.log(chalk.gray(" Toggle 'Mitmproxy Redirector' on.\n"));
644
+ printNetworkExtensionInstructions();
645
+ const status = await getNetworkExtensionStatus();
646
+ if (status === "not_installed") {
647
+ console.log(chalk.gray(" The extension will be installed on first `cc-router client start-desktop`.\n" +
648
+ " macOS will show a popup — follow the steps above to approve it.\n"));
649
+ }
650
+ else if (status === "waiting") {
651
+ console.log(chalk.red(" ⚠ Extension is installed but NOT approved.\n"));
652
+ const openNow = await confirm({ message: "Open System Settings to approve it now?", default: true });
653
+ if (openNow) {
654
+ await openNetworkExtensionSettings();
655
+ console.log(chalk.gray(" System Settings should be open. Toggle 'Mitmproxy Redirector' ON.\n"));
656
+ await confirm({ message: "Done? Press Enter when the toggle is ON", default: true });
657
+ const newStatus = await getNetworkExtensionStatus();
658
+ console.log(newStatus === "enabled"
659
+ ? chalk.green(" ✓ Network Extension enabled")
660
+ : chalk.yellow(` Still not enabled (status: ${newStatus}) — you can fix later`));
661
+ }
662
+ }
663
+ else if (status === "enabled") {
664
+ console.log(chalk.green(" ✓ Network Extension already enabled — you're all set\n"));
665
+ }
666
+ // Remind to restart Claude Desktop
667
+ console.log(chalk.bold.yellow(" Remember:"));
668
+ console.log(chalk.gray(" After starting the interceptor, " + chalk.bold("quit and relaunch Claude Desktop") + " (⌘Q)"));
669
+ console.log(chalk.gray(" so mitmproxy can hook into the new process.\n"));
619
670
  }
620
671
  }
621
672
  function printBanner() {
@@ -60,6 +60,64 @@ export async function checkMitmproxyInstalled() {
60
60
  return false;
61
61
  }
62
62
  }
63
+ /**
64
+ * Check the approval status of the mitmproxy macOS Network Extension.
65
+ * No-op on Windows/Linux (returns "enabled").
66
+ *
67
+ * Parses `systemextensionsctl list` output, looking for the mitmproxy entry.
68
+ * Status comes from the flags column:
69
+ * "* *" → enabled + active
70
+ * " *" → active but waiting for user approval
71
+ */
72
+ export async function getNetworkExtensionStatus() {
73
+ if (!isMacos())
74
+ return "enabled"; // Only macOS needs this check
75
+ try {
76
+ const { stdout } = await execFileP("systemextensionsctl", ["list"]);
77
+ const mitmLine = stdout
78
+ .split("\n")
79
+ .find((l) => l.toLowerCase().includes("mitmproxy"));
80
+ if (!mitmLine)
81
+ return "not_installed";
82
+ // systemextensionsctl flags are the first two columns; "*" means set.
83
+ // Order is "enabled active" — both must be "*" for the extension to work.
84
+ // Example strings seen in the wild:
85
+ // "*\t*\tS8XHQB96PW\torg.mitmproxy.macos-redirector..." → enabled
86
+ // "\t*\tS8XHQB96PW\torg.mitmproxy.macos-redirector..." → waiting
87
+ //
88
+ // We also accept the human-readable "[activated enabled]" / "[activated waiting for user]"
89
+ // suffix that newer macOS versions append.
90
+ if (mitmLine.includes("[activated enabled]"))
91
+ return "enabled";
92
+ if (mitmLine.includes("waiting for user"))
93
+ return "waiting";
94
+ const cols = mitmLine.split("\t").map((s) => s.trim());
95
+ const enabled = cols[0] === "*";
96
+ const active = cols[1] === "*";
97
+ if (enabled && active)
98
+ return "enabled";
99
+ if (!enabled && active)
100
+ return "waiting";
101
+ return "not_installed";
102
+ }
103
+ catch {
104
+ return "unknown";
105
+ }
106
+ }
107
+ /** Open the macOS "Login Items & Extensions" settings pane. Best-effort. */
108
+ export async function openNetworkExtensionSettings() {
109
+ if (!isMacos())
110
+ return;
111
+ try {
112
+ // The x-apple.systempreferences URL opens the right pane in System Settings.
113
+ // Extensions pane is not directly deep-linkable, so we open the closest one.
114
+ await execFileP("open", ["x-apple.systempreferences:com.apple.LoginItems-Settings.extension"]);
115
+ }
116
+ catch {
117
+ // If that fails, fall back to opening plain System Settings
118
+ await execFileP("open", ["/System/Applications/System Settings.app"]).catch(() => { });
119
+ }
120
+ }
63
121
  // ─── CA certificate ───────────────────────────────────────────────────────────
64
122
  export function isCaCertInstalled() {
65
123
  return existsSync(CA_PATH);
@@ -165,6 +223,24 @@ def request(flow: http.HTTPFlow) -> None:
165
223
  * api.anthropic.com traffic to CC-Router via the addon script.
166
224
  */
167
225
  export async function startInterceptor(target) {
226
+ // On macOS, verify the Network Extension is enabled before attempting to start.
227
+ // If it's "waiting", mitmdump starts silently but captures zero traffic.
228
+ if (isMacos()) {
229
+ const status = await getNetworkExtensionStatus();
230
+ if (status === "waiting") {
231
+ throw new Error("Mitmproxy Network Extension is installed but not yet approved.\n" +
232
+ " Open: System Settings → General → Login Items & Extensions → Network Extensions\n" +
233
+ ' Toggle "Mitmproxy Redirector" ON and enter your admin password.\n' +
234
+ " Then re-run this command.");
235
+ }
236
+ if (status === "not_installed") {
237
+ throw new Error("Mitmproxy Network Extension is not installed.\n" +
238
+ " Run mitmdump once manually to trigger the installation:\n" +
239
+ ' mitmdump --mode "local:Claude" --set connection_strategy=lazy\n' +
240
+ " macOS will prompt you to approve it in System Settings.\n" +
241
+ " Then re-run this command.");
242
+ }
243
+ }
168
244
  // Ensure addon exists
169
245
  if (!existsSync(ADDON_PATH))
170
246
  writeAddonScript(target);
@@ -317,6 +317,11 @@ export async function startServer(opts = {}) {
317
317
  }
318
318
  req._ccAccount = account;
319
319
  req._startTime = Date.now();
320
+ const source = req.headers["x-claude-code-session-id"]
321
+ ? "cli"
322
+ : req.headers["x-api-key"]
323
+ ? "desktop"
324
+ : "api";
320
325
  req._pendingLog = {
321
326
  ts: Date.now(),
322
327
  accountId: account.id,
@@ -324,6 +329,7 @@ export async function startServer(opts = {}) {
324
329
  type: "route",
325
330
  method: req.method,
326
331
  path: req.path,
332
+ source,
327
333
  };
328
334
  stats.totalRequests++;
329
335
  logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - Date.now()) / 60_000));
@@ -146,6 +146,13 @@ function LogRow({ log, selected }) {
146
146
  : "gray";
147
147
  const bg = selected ? "white" : undefined;
148
148
  const fg = (c) => selected ? "black" : c;
149
+ const sourceLabel = log.source === "cli" ? "cli"
150
+ : log.source === "desktop" ? "dsk"
151
+ : log.source === "api" ? "api"
152
+ : " ";
153
+ const sourceColor = log.source === "cli" ? "blue"
154
+ : log.source === "desktop" ? "magenta"
155
+ : "gray";
149
156
  // Per-request token stats
150
157
  const inputTok = (log.cacheReadTokens ?? 0) + (log.cacheCreationTokens ?? 0) + (log.inputTokens ?? 0);
151
158
  const outputTok = log.outputTokens ?? 0;
@@ -154,7 +161,7 @@ function LogRow({ log, selected }) {
154
161
  : cacheHitPct >= 70 ? "green"
155
162
  : cacheHitPct >= 30 ? "yellow"
156
163
  : "red";
157
- 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
164
+ return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(sourceColor), children: [sourceLabel, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
158
165
  ? _jsxs(Text, { backgroundColor: bg, color: fg("white"), children: [" ", log.method, " ", log.path.padEnd(14)] })
159
166
  : _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"] })), cacheHitPct !== null && (_jsxs(Text, { backgroundColor: bg, color: fg(cacheColor), children: [" \u2191", cacheHitPct, "%"] })), (inputTok > 0 || outputTok > 0) && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", fmtTok(inputTok), "\u2191 ", fmtTok(outputTok), "\u2193"] })), log.details && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.details] }))] }));
160
167
  }
@@ -174,7 +181,7 @@ function DetailPanel({ log }) {
174
181
  : log.statusCode >= 500 ? "red"
175
182
  : log.statusCode >= 400 ? "yellow"
176
183
  : "green";
177
- 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 }) })), log.cacheReadTokens !== undefined && (_jsx(Box, { gap: 2, children: _jsx(CacheBreakdown, { read: log.cacheReadTokens, created: log.cacheCreationTokens ?? 0, input: log.inputTokens ?? 0, output: log.outputTokens ?? 0 }) }))] })] }));
184
+ 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 }), _jsx(Field, { label: "Source", value: sourceFullLabel(log.source) })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) })), log.cacheReadTokens !== undefined && (_jsx(Box, { gap: 2, children: _jsx(CacheBreakdown, { read: log.cacheReadTokens, created: log.cacheCreationTokens ?? 0, input: log.inputTokens ?? 0, output: log.outputTokens ?? 0 }) }))] })] }));
178
185
  }
179
186
  function Field({ label, value }) {
180
187
  return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
@@ -215,6 +222,16 @@ function fmtTok(n) {
215
222
  return `${(n / 1_000).toFixed(1)}k`;
216
223
  return String(n);
217
224
  }
225
+ // ─── Source label ─────────────────────────────────────────────────────────────
226
+ function sourceFullLabel(source) {
227
+ if (source === "cli")
228
+ return "Claude Code";
229
+ if (source === "desktop")
230
+ return "Claude Desktop";
231
+ if (source === "api")
232
+ return "API";
233
+ return "—";
234
+ }
218
235
  // ─── HTTP status text ─────────────────────────────────────────────────────────
219
236
  function httpStatusText(code) {
220
237
  const map = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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": {