ai-cc-router 0.3.1 → 0.4.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.
@@ -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, getNetworkExtensionStatus, openNetworkExtensionSettings, } from "../interceptor/mitmproxy-manager.js";
7
+ import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, startInterceptor, stopInterceptor, isInterceptorRunning, getProcessName, getNetworkExtensionStatus, openNetworkExtensionSettings, installInterceptorService, uninstallInterceptorService, isInterceptorServiceInstalled, } from "../interceptor/mitmproxy-manager.js";
8
8
  // ─── Helpers ──────────────────────────────────────────────────────────────────
9
9
  function isClaudeDesktopInstalled() {
10
10
  if (isMacos()) {
@@ -141,6 +141,10 @@ export function registerClient(program) {
141
141
  .action(async () => {
142
142
  const cfg = readConfig();
143
143
  if (cfg.client?.desktopEnabled) {
144
+ if (isInterceptorServiceInstalled()) {
145
+ console.log(chalk.yellow("Removing Claude Desktop interceptor service..."));
146
+ await uninstallInterceptorService();
147
+ }
144
148
  console.log(chalk.yellow("Stopping Claude Desktop interceptor..."));
145
149
  await stopInterceptor();
146
150
  }
@@ -220,6 +224,7 @@ export function registerClient(program) {
220
224
  console.log(chalk.bold("\n DESKTOP INTERCEPTOR (Cowork / Agent mode)"));
221
225
  if (cfg.client.desktopEnabled) {
222
226
  const running = await isInterceptorRunning();
227
+ const serviceInstalled = isInterceptorServiceInstalled();
223
228
  if (running) {
224
229
  console.log(` ${chalk.green("● running")}`);
225
230
  }
@@ -227,6 +232,12 @@ export function registerClient(program) {
227
232
  console.log(` ${chalk.yellow("○ configured but stopped")}`);
228
233
  console.log(chalk.gray(" Start with: cc-router client start-desktop"));
229
234
  }
235
+ if (serviceInstalled) {
236
+ console.log(` ${chalk.green("✓")} ${chalk.gray("Auto-start on boot: enabled")}`);
237
+ }
238
+ else {
239
+ console.log(` ${chalk.gray("○ Auto-start on boot: disabled")}`);
240
+ }
230
241
  // Check Network Extension on macOS
231
242
  if (isMacos()) {
232
243
  const extStatus = await getNetworkExtensionStatus();
@@ -307,6 +318,27 @@ export function registerClient(program) {
307
318
  process.exit(1);
308
319
  }
309
320
  console.log(chalk.green("\n✓ Claude Desktop interceptor running"));
321
+ // ── Auto-start on boot ─────────────────────────────────────────────
322
+ if (!cfg.client.desktopAutoStart && !isInterceptorServiceInstalled()) {
323
+ const autoStart = await confirm({
324
+ message: "Start interceptor automatically when your computer boots? (recommended)",
325
+ default: true,
326
+ });
327
+ if (autoStart) {
328
+ const ok = await installInterceptorService(target);
329
+ if (ok) {
330
+ cfg.client.desktopAutoStart = true;
331
+ writeConfig(cfg);
332
+ console.log(chalk.green("✓ Auto-start on boot configured"));
333
+ }
334
+ else {
335
+ console.log(chalk.yellow("⚠ Could not configure auto-start. You can retry later."));
336
+ }
337
+ }
338
+ }
339
+ else if (cfg.client.desktopAutoStart) {
340
+ console.log(chalk.gray(" Auto-start on boot: enabled"));
341
+ }
310
342
  console.log();
311
343
  console.log(chalk.bold.yellow(" Next steps:"));
312
344
  console.log(" " + chalk.cyan("1.") + " Quit Claude Desktop completely (⌘Q)");
@@ -321,7 +353,17 @@ export function registerClient(program) {
321
353
  client
322
354
  .command("stop-desktop")
323
355
  .description("Stop the Claude Desktop mitmproxy interceptor")
324
- .action(async () => {
356
+ .option("--keep-autostart", "Stop the interceptor but keep auto-start on boot")
357
+ .action(async (opts) => {
358
+ if (isInterceptorServiceInstalled() && !opts.keepAutostart) {
359
+ await uninstallInterceptorService();
360
+ console.log(chalk.green("✓ Auto-start on boot removed"));
361
+ const cfg = readConfig();
362
+ if (cfg.client) {
363
+ cfg.client.desktopAutoStart = false;
364
+ writeConfig(cfg);
365
+ }
366
+ }
325
367
  await stopInterceptor();
326
368
  console.log(chalk.green("\n✓ Claude Desktop interceptor stopped\n"));
327
369
  });
@@ -183,7 +183,7 @@ async function ensureClaudeCodeConfigured(prefs, cfg) {
183
183
  async function maybeUpdate() {
184
184
  let check;
185
185
  try {
186
- check = await checkForUpdate();
186
+ check = await checkForUpdate(true); // force fresh check, skip disk cache
187
187
  if (!check.updateAvailable)
188
188
  return;
189
189
  }
@@ -10,19 +10,27 @@
10
10
  * Windows → WinDivert (WFP kernel driver)
11
11
  * Linux → eBPF (requires kernel ≥ 6.8)
12
12
  */
13
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
14
- import { join } from "path";
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
14
+ import { dirname, join } from "path";
15
15
  import os from "os";
16
- import { execFile, spawn } from "child_process";
16
+ import { execFile, execFileSync, spawn } from "child_process";
17
17
  import { promisify } from "util";
18
- import { isMacos, isWindows } from "../utils/platform.js";
18
+ import { isMacos, isWindows, detectPlatform } from "../utils/platform.js";
19
19
  import { CONFIG_DIR } from "../config/paths.js";
20
20
  const execFileP = promisify(execFile);
21
21
  // ─── Paths ────────────────────────────────────────────────────────────────────
22
22
  const ADDON_DIR = join(CONFIG_DIR, "interceptor");
23
23
  const ADDON_PATH = join(ADDON_DIR, "addon.py");
24
24
  const PID_PATH = join(ADDON_DIR, "mitmdump.pid");
25
+ const LOG_PATH = join(ADDON_DIR, "mitmdump.log");
25
26
  const CA_PATH = join(os.homedir(), ".mitmproxy", "mitmproxy-ca-cert.pem");
27
+ // ─── Service paths ────────────────────────────────────────────────────────────
28
+ const LAUNCHD_LABEL = "com.cc-router.interceptor";
29
+ const LAUNCHD_PLIST = join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
30
+ const SYSTEMD_DIR = join(os.homedir(), ".config", "systemd", "user");
31
+ const SYSTEMD_SERVICE = join(SYSTEMD_DIR, "cc-router-interceptor.service");
32
+ const WINDOWS_REG_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
33
+ const WINDOWS_REG_NAME = "CC-Router-Interceptor";
26
34
  // Bundled addon template lives next to this module in src/interceptor/addon.py;
27
35
  // at runtime (dist/) it's NOT guaranteed to exist because the .py file is only
28
36
  // included if package.json "files" lists it. We write a copy to ~/.cc-router/
@@ -187,21 +195,28 @@ export async function installCaCert() {
187
195
  export function writeAddonScript(target) {
188
196
  if (!existsSync(ADDON_DIR))
189
197
  mkdirSync(ADDON_DIR, { recursive: true });
190
- // Try to copy from the bundled addon; if not found, generate inline
198
+ // Try to use the bundled addon as template; fall back to a minimal inline version.
199
+ // In BOTH cases we inject the actual target URL so the addon is self-contained
200
+ // and doesn't depend on the CC_ROUTER_TARGET env var being present at runtime.
191
201
  const bundled = addonSourcePath();
202
+ let src;
192
203
  if (existsSync(bundled)) {
193
- const src = readFileSync(bundled, "utf-8");
194
- writeFileSync(ADDON_PATH, src, "utf-8");
204
+ src = readFileSync(bundled, "utf-8");
195
205
  }
196
206
  else {
197
207
  // Inline fallback — minimal addon (only redirects /v1/messages and /v1/models)
198
- const script = `
208
+ src = `
199
209
  import os
200
210
  from mitmproxy import http
201
211
  from urllib.parse import urlparse
202
212
 
203
- _target = os.environ.get("CC_ROUTER_TARGET", ${JSON.stringify(target)}).rstrip("/")
204
- _p = urlparse(_target)
213
+ _target_raw = os.environ.get("CC_ROUTER_TARGET", "http://localhost:3456")
214
+ _target = _target_raw.rstrip("/")
215
+ _target_parsed = urlparse(_target)
216
+
217
+ if not _target_parsed.scheme or not _target_parsed.netloc:
218
+ raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
219
+
205
220
  _REDIRECT_PREFIXES = ("/v1/messages", "/v1/models")
206
221
 
207
222
  def request(flow: http.HTTPFlow) -> None:
@@ -209,13 +224,16 @@ def request(flow: http.HTTPFlow) -> None:
209
224
  return
210
225
  if not flow.request.path.startswith(_REDIRECT_PREFIXES):
211
226
  return
212
- flow.request.scheme = _p.scheme
213
- flow.request.host = _p.hostname or "localhost"
214
- flow.request.port = _p.port or (443 if _p.scheme == "https" else 80)
227
+ flow.request.scheme = _target_parsed.scheme
228
+ flow.request.host = _target_parsed.hostname or "localhost"
229
+ flow.request.port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
215
230
  flow.request.headers["host"] = flow.request.host + (f":{flow.request.port}" if flow.request.port not in (80, 443) else "")
216
231
  `.trimStart();
217
- writeFileSync(ADDON_PATH, script, "utf-8");
218
232
  }
233
+ // Inject the actual target URL into the default so the addon works even
234
+ // without the CC_ROUTER_TARGET env var (e.g. manual mitmdump restarts).
235
+ src = src.replace('"http://localhost:3456"', JSON.stringify(target));
236
+ writeFileSync(ADDON_PATH, src, "utf-8");
219
237
  }
220
238
  // ─── Interceptor lifecycle ────────────────────────────────────────────────────
221
239
  /**
@@ -307,3 +325,232 @@ function readPid() {
307
325
  const pid = parseInt(raw, 10);
308
326
  return Number.isNaN(pid) ? null : pid;
309
327
  }
328
+ // ─── Interceptor OS service (auto-start on boot) ────────────────────────────
329
+ /** Resolve the absolute path to mitmdump so launchd/systemd can find it. */
330
+ async function resolveMitmdumpPath() {
331
+ try {
332
+ const cmd = isWindows() ? "where" : "which";
333
+ const { stdout } = await execFileP(cmd, ["mitmdump"]);
334
+ return stdout.trim().split("\n")[0];
335
+ }
336
+ catch {
337
+ return "mitmdump"; // fallback — hope it's on PATH at boot time
338
+ }
339
+ }
340
+ function buildInterceptorPlist(mitmdumpPath, target) {
341
+ const processName = getProcessName();
342
+ return `<?xml version="1.0" encoding="UTF-8"?>
343
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
344
+ <plist version="1.0">
345
+ <dict>
346
+ <key>Label</key>
347
+ <string>${LAUNCHD_LABEL}</string>
348
+ <key>ProgramArguments</key>
349
+ <array>
350
+ <string>${mitmdumpPath}</string>
351
+ <string>--mode</string>
352
+ <string>local:${processName}</string>
353
+ <string>-s</string>
354
+ <string>${ADDON_PATH}</string>
355
+ <string>--set</string>
356
+ <string>connection_strategy=lazy</string>
357
+ <string>--quiet</string>
358
+ </array>
359
+ <key>RunAtLoad</key>
360
+ <true/>
361
+ <key>KeepAlive</key>
362
+ <dict>
363
+ <key>SuccessfulExit</key>
364
+ <false/>
365
+ </dict>
366
+ <key>StandardOutPath</key>
367
+ <string>${LOG_PATH}</string>
368
+ <key>StandardErrorPath</key>
369
+ <string>${LOG_PATH}</string>
370
+ <key>WorkingDirectory</key>
371
+ <string>${os.homedir()}</string>
372
+ <key>EnvironmentVariables</key>
373
+ <dict>
374
+ <key>PATH</key>
375
+ <string>${process.env["PATH"] ?? "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"}</string>
376
+ <key>CC_ROUTER_TARGET</key>
377
+ <string>${target}</string>
378
+ </dict>
379
+ </dict>
380
+ </plist>
381
+ `;
382
+ }
383
+ function buildInterceptorSystemdUnit(mitmdumpPath, target) {
384
+ const processName = getProcessName();
385
+ return `[Unit]
386
+ Description=CC-Router Interceptor — mitmproxy for Claude Desktop
387
+ After=network-online.target
388
+ Wants=network-online.target
389
+
390
+ [Service]
391
+ Type=simple
392
+ ExecStart=${mitmdumpPath} --mode local:${processName} -s ${ADDON_PATH} --set connection_strategy=lazy --quiet
393
+ Restart=on-failure
394
+ RestartSec=5
395
+ StartLimitIntervalSec=60
396
+ StartLimitBurst=5
397
+ Environment=PATH=${process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin"}
398
+ Environment=CC_ROUTER_TARGET=${target}
399
+
400
+ [Install]
401
+ WantedBy=default.target
402
+ `;
403
+ }
404
+ /**
405
+ * Install the mitmproxy interceptor as an OS service so it starts on boot.
406
+ * Stops any existing detached mitmdump process first — the OS service takes over.
407
+ */
408
+ export async function installInterceptorService(target) {
409
+ // Ensure the addon script is up-to-date with the target URL
410
+ writeAddonScript(target);
411
+ // Stop any manually-spawned mitmdump — OS service will manage it now
412
+ await stopInterceptor();
413
+ const mitmdumpPath = await resolveMitmdumpPath();
414
+ const platform = detectPlatform();
415
+ switch (platform) {
416
+ case "macos": return installInterceptorMacOS(mitmdumpPath, target);
417
+ case "linux": return installInterceptorLinux(mitmdumpPath, target);
418
+ case "windows": return installInterceptorWindows(mitmdumpPath, target);
419
+ }
420
+ }
421
+ export async function uninstallInterceptorService() {
422
+ const platform = detectPlatform();
423
+ switch (platform) {
424
+ case "macos": return uninstallInterceptorMacOS();
425
+ case "linux": return uninstallInterceptorLinux();
426
+ case "windows": return uninstallInterceptorWindows();
427
+ }
428
+ }
429
+ export function isInterceptorServiceInstalled() {
430
+ const platform = detectPlatform();
431
+ switch (platform) {
432
+ case "macos": return existsSync(LAUNCHD_PLIST);
433
+ case "linux": return existsSync(SYSTEMD_SERVICE);
434
+ case "windows": return isInterceptorWindowsServiceInstalled();
435
+ }
436
+ }
437
+ // ─── macOS LaunchAgent ──────────────────────────────────────────────────────
438
+ async function installInterceptorMacOS(mitmdumpPath, target) {
439
+ const launchAgentsDir = dirname(LAUNCHD_PLIST);
440
+ if (!existsSync(launchAgentsDir))
441
+ mkdirSync(launchAgentsDir, { recursive: true });
442
+ // Unload existing if present
443
+ if (existsSync(LAUNCHD_PLIST)) {
444
+ await interceptorLaunchctlUnload();
445
+ }
446
+ writeFileSync(LAUNCHD_PLIST, buildInterceptorPlist(mitmdumpPath, target), "utf-8");
447
+ // Load — try modern `bootstrap` first, fallback to legacy `load`
448
+ const uid = String(process.getuid?.() ?? 501);
449
+ try {
450
+ await execFileP("launchctl", ["bootstrap", `gui/${uid}`, LAUNCHD_PLIST]);
451
+ }
452
+ catch {
453
+ try {
454
+ await execFileP("launchctl", ["load", LAUNCHD_PLIST]);
455
+ }
456
+ catch (err) {
457
+ console.log(`⚠ Could not auto-load the interceptor LaunchAgent: ${err.message}`);
458
+ console.log(` Load manually: launchctl load ${LAUNCHD_PLIST}`);
459
+ return false;
460
+ }
461
+ }
462
+ return true;
463
+ }
464
+ async function uninstallInterceptorMacOS() {
465
+ if (!existsSync(LAUNCHD_PLIST))
466
+ return;
467
+ await interceptorLaunchctlUnload();
468
+ try {
469
+ unlinkSync(LAUNCHD_PLIST);
470
+ }
471
+ catch { /* ok */ }
472
+ }
473
+ async function interceptorLaunchctlUnload() {
474
+ const uid = String(process.getuid?.() ?? 501);
475
+ try {
476
+ await execFileP("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`]);
477
+ }
478
+ catch {
479
+ try {
480
+ await execFileP("launchctl", ["unload", LAUNCHD_PLIST]);
481
+ }
482
+ catch { /* already unloaded */ }
483
+ }
484
+ }
485
+ // ─── Linux systemd user service ─────────────────────────────────────────────
486
+ async function installInterceptorLinux(mitmdumpPath, target) {
487
+ if (!existsSync(SYSTEMD_DIR))
488
+ mkdirSync(SYSTEMD_DIR, { recursive: true });
489
+ writeFileSync(SYSTEMD_SERVICE, buildInterceptorSystemdUnit(mitmdumpPath, target), "utf-8");
490
+ try {
491
+ await execFileP("systemctl", ["--user", "daemon-reload"]);
492
+ await execFileP("systemctl", ["--user", "enable", "cc-router-interceptor"]);
493
+ await execFileP("systemctl", ["--user", "start", "cc-router-interceptor"]);
494
+ return true;
495
+ }
496
+ catch (err) {
497
+ console.log(`⚠ systemd setup issue: ${err.message}`);
498
+ console.log(" Enable manually: systemctl --user enable --now cc-router-interceptor");
499
+ return false;
500
+ }
501
+ }
502
+ async function uninstallInterceptorLinux() {
503
+ if (!existsSync(SYSTEMD_SERVICE))
504
+ return;
505
+ try {
506
+ await execFileP("systemctl", ["--user", "stop", "cc-router-interceptor"]);
507
+ await execFileP("systemctl", ["--user", "disable", "cc-router-interceptor"]);
508
+ }
509
+ catch { /* may already be stopped */ }
510
+ try {
511
+ unlinkSync(SYSTEMD_SERVICE);
512
+ }
513
+ catch { /* ok */ }
514
+ try {
515
+ await execFileP("systemctl", ["--user", "daemon-reload"]);
516
+ }
517
+ catch { /* ok */ }
518
+ }
519
+ // ─── Windows Registry ───────────────────────────────────────────────────────
520
+ async function installInterceptorWindows(mitmdumpPath, target) {
521
+ const processName = getProcessName();
522
+ const cmd = `cmd /c "set CC_ROUTER_TARGET=${target} && "${mitmdumpPath}" --mode local:${processName} -s "${ADDON_PATH}" --set connection_strategy=lazy --quiet"`;
523
+ try {
524
+ await execFileP("reg", [
525
+ "add", WINDOWS_REG_KEY,
526
+ "/v", WINDOWS_REG_NAME,
527
+ "/t", "REG_SZ",
528
+ "/d", cmd,
529
+ "/f",
530
+ ]);
531
+ return true;
532
+ }
533
+ catch (err) {
534
+ console.log(`⚠ Registry write failed: ${err.message}`);
535
+ return false;
536
+ }
537
+ }
538
+ async function uninstallInterceptorWindows() {
539
+ try {
540
+ await execFileP("reg", [
541
+ "delete", WINDOWS_REG_KEY,
542
+ "/v", WINDOWS_REG_NAME,
543
+ "/f",
544
+ ]);
545
+ }
546
+ catch { /* not installed */ }
547
+ }
548
+ function isInterceptorWindowsServiceInstalled() {
549
+ try {
550
+ execFileSync("reg", ["query", WINDOWS_REG_KEY, "/v", WINDOWS_REG_NAME]);
551
+ return true;
552
+ }
553
+ catch {
554
+ return false;
555
+ }
556
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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": {