@timo972/cc-router 0.7.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.
- package/CHANGELOG.md +96 -0
- package/Dockerfile +42 -0
- package/LICENSE +21 -0
- package/README.md +716 -0
- package/accounts.example.json +25 -0
- package/dist/cli/cmd-accounts.js +248 -0
- package/dist/cli/cmd-client.js +612 -0
- package/dist/cli/cmd-configure.js +145 -0
- package/dist/cli/cmd-docker.js +140 -0
- package/dist/cli/cmd-logs.js +85 -0
- package/dist/cli/cmd-models.js +125 -0
- package/dist/cli/cmd-service.js +193 -0
- package/dist/cli/cmd-setup.js +501 -0
- package/dist/cli/cmd-start.js +318 -0
- package/dist/cli/cmd-status.js +177 -0
- package/dist/cli/cmd-stop.js +100 -0
- package/dist/cli/cmd-telemetry.js +58 -0
- package/dist/cli/cmd-update.js +37 -0
- package/dist/cli/index.js +59 -0
- package/dist/config/manager.js +262 -0
- package/dist/config/paths.js +21 -0
- package/dist/config/telemetry.js +64 -0
- package/dist/daemon/launcher.js +163 -0
- package/dist/daemon/pid.js +98 -0
- package/dist/daemon/service.js +260 -0
- package/dist/interceptor/mitmproxy-manager.js +616 -0
- package/dist/protocol/anthropic-to-openai.js +51 -0
- package/dist/protocol/anthropic-types.js +1 -0
- package/dist/protocol/model-ref.js +36 -0
- package/dist/protocol/model-routing-config.js +30 -0
- package/dist/protocol/openai-response-to-anthropic.js +20 -0
- package/dist/protocol/openai-responses-types.js +1 -0
- package/dist/protocol/openai-stream-to-anthropic.js +75 -0
- package/dist/protocol/openai-to-anthropic.js +61 -0
- package/dist/protocol/sse.js +17 -0
- package/dist/providers/model-discovery.js +71 -0
- package/dist/providers/openai/account-pool.js +11 -0
- package/dist/providers/openai/account-record.js +33 -0
- package/dist/providers/openai/codex-transport.js +36 -0
- package/dist/providers/openai/device-oauth.js +116 -0
- package/dist/providers/openai/token-refresher.js +56 -0
- package/dist/providers/route-selector.js +8 -0
- package/dist/providers/types.js +1 -0
- package/dist/proxy/account-deletion.js +44 -0
- package/dist/proxy/anthropic-proxy.js +26 -0
- package/dist/proxy/anthropic-routing.js +90 -0
- package/dist/proxy/lease-lifecycle.js +68 -0
- package/dist/proxy/logger.js +39 -0
- package/dist/proxy/messages-cross-route.js +179 -0
- package/dist/proxy/models-server.js +150 -0
- package/dist/proxy/provider-routing.js +14 -0
- package/dist/proxy/responses-server.js +91 -0
- package/dist/proxy/server.js +875 -0
- package/dist/proxy/session-router.js +171 -0
- package/dist/proxy/stats.js +25 -0
- package/dist/proxy/stream-lifecycle.js +83 -0
- package/dist/proxy/token-pool.js +407 -0
- package/dist/proxy/token-refresher.js +209 -0
- package/dist/proxy/types.js +29 -0
- package/dist/ui/Dashboard.js +640 -0
- package/dist/ui/accountsApi.js +48 -0
- package/dist/ui/modelsApi.js +47 -0
- package/dist/utils/claude-config.js +185 -0
- package/dist/utils/codex-config.js +62 -0
- package/dist/utils/network.js +16 -0
- package/dist/utils/platform.js +13 -0
- package/dist/utils/self-update.js +239 -0
- package/dist/utils/telemetry.js +88 -0
- package/dist/utils/token-extractor.js +95 -0
- package/dist/utils/token-validator.js +26 -0
- package/docker-compose.yml +63 -0
- package/litellm-config.yaml +44 -0
- package/package.json +69 -0
- package/src/interceptor/addon.py +78 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { input, confirm } from "@inquirer/prompts";
|
|
4
|
+
import { readConfig, writeConfig } from "../config/manager.js";
|
|
5
|
+
import { writeClaudeSettings, removeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
|
|
6
|
+
import { codexBaseUrlFromRouterUrl, writeCodexRouterConfigFromClient } from "../utils/codex-config.js";
|
|
7
|
+
import { isMacos, isWindows } from "../utils/platform.js";
|
|
8
|
+
import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, startInterceptor, stopInterceptor, isInterceptorRunning, getProcessName, getNetworkExtensionStatus, openNetworkExtensionSettings, installInterceptorService, uninstallInterceptorService, isInterceptorServiceInstalled, removeCaCert, } from "../interceptor/mitmproxy-manager.js";
|
|
9
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
|
+
function isClaudeDesktopInstalled() {
|
|
11
|
+
if (isMacos()) {
|
|
12
|
+
return existsSync("/Applications/Claude.app");
|
|
13
|
+
}
|
|
14
|
+
if (isWindows()) {
|
|
15
|
+
const localAppData = process.env["LOCALAPPDATA"];
|
|
16
|
+
return !!localAppData && existsSync(`${localAppData}\\AnthropicClaude\\Claude.exe`);
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
async function fetchRemoteHealth(url, secret) {
|
|
21
|
+
try {
|
|
22
|
+
const headers = {};
|
|
23
|
+
if (secret)
|
|
24
|
+
headers["authorization"] = `Bearer ${secret}`;
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timeout = setTimeout(() => controller.abort(), 8_000);
|
|
27
|
+
const res = await fetch(`${url}/cc-router/health`, { headers, signal: controller.signal });
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
return { ok: false, error: `HTTP ${res.status}` };
|
|
31
|
+
const data = (await res.json());
|
|
32
|
+
return { ok: data.status === "ok" || data.status === "degraded", data };
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
return { ok: false, error: e.message };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function formatUptime(seconds) {
|
|
39
|
+
if (!seconds || seconds < 0)
|
|
40
|
+
return "0s";
|
|
41
|
+
const h = Math.floor(seconds / 3600);
|
|
42
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
43
|
+
const s = Math.floor(seconds % 60);
|
|
44
|
+
if (h > 0)
|
|
45
|
+
return `${h}h ${m}m`;
|
|
46
|
+
if (m > 0)
|
|
47
|
+
return `${m}m ${s}s`;
|
|
48
|
+
return `${s}s`;
|
|
49
|
+
}
|
|
50
|
+
function formatNumber(n) {
|
|
51
|
+
if (n === undefined || n === null)
|
|
52
|
+
return "0";
|
|
53
|
+
if (n >= 1_000_000)
|
|
54
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
55
|
+
if (n >= 1_000)
|
|
56
|
+
return `${(n / 1_000).toFixed(1)}k`;
|
|
57
|
+
return String(n);
|
|
58
|
+
}
|
|
59
|
+
function formatTime(ts) {
|
|
60
|
+
const d = new Date(ts);
|
|
61
|
+
return d.toLocaleTimeString(undefined, { hour12: false });
|
|
62
|
+
}
|
|
63
|
+
function formatUrl(raw) {
|
|
64
|
+
let url = raw.trim().replace(/\/+$/, "");
|
|
65
|
+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
66
|
+
url = `http://${url}`;
|
|
67
|
+
}
|
|
68
|
+
return url;
|
|
69
|
+
}
|
|
70
|
+
function mergeStoredClient(patch) {
|
|
71
|
+
const config = readConfig();
|
|
72
|
+
if (!config.client)
|
|
73
|
+
return undefined;
|
|
74
|
+
const client = { ...config.client, ...patch };
|
|
75
|
+
writeConfig({ ...config, client });
|
|
76
|
+
return client;
|
|
77
|
+
}
|
|
78
|
+
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
79
|
+
export function registerClient(program) {
|
|
80
|
+
const client = program
|
|
81
|
+
.command("client")
|
|
82
|
+
.description("Connect to an existing CC-Router server (client mode)");
|
|
83
|
+
// ── cc-router client connect [url] ──────────────────────────────────────────
|
|
84
|
+
client
|
|
85
|
+
.command("connect [url]")
|
|
86
|
+
.description("Connect Claude Code to a CC-Router server")
|
|
87
|
+
.option("-s, --secret <secret>", "Proxy authentication secret")
|
|
88
|
+
.option("--model <model>", "Default Claude Code model to send through the router")
|
|
89
|
+
.option("-d, --desktop", "Also configure Claude Desktop interception via mitmproxy")
|
|
90
|
+
.option("--codex", "Also configure Codex CLI to use this remote CC-Router")
|
|
91
|
+
.option("--codex-model <model>", "Default Codex model when using --codex", "openai/default")
|
|
92
|
+
.action(async (rawUrl, opts) => {
|
|
93
|
+
console.log(chalk.bold("\n🔗 CC-Router Client Setup\n"));
|
|
94
|
+
// 1. Get remote URL
|
|
95
|
+
let url = rawUrl
|
|
96
|
+
? formatUrl(rawUrl)
|
|
97
|
+
: formatUrl(await input({ message: "Remote CC-Router URL (e.g. 192.168.1.50:3456):" }));
|
|
98
|
+
// 2. Get secret (optional)
|
|
99
|
+
let secret = opts?.secret;
|
|
100
|
+
if (!secret) {
|
|
101
|
+
secret = await input({
|
|
102
|
+
message: "Proxy secret (leave empty if none):",
|
|
103
|
+
transformer: (v) => v ? "•".repeat(v.length) : "",
|
|
104
|
+
}) || undefined;
|
|
105
|
+
}
|
|
106
|
+
// 3. Test connection
|
|
107
|
+
console.log(chalk.gray(`\nTesting connection to ${url}...`));
|
|
108
|
+
const test = await fetchRemoteHealth(url, secret);
|
|
109
|
+
if (!test.ok) {
|
|
110
|
+
console.error(chalk.red(`\n✗ Cannot reach CC-Router at ${url}`));
|
|
111
|
+
console.error(chalk.yellow(` Error: ${test.error}`));
|
|
112
|
+
console.error(chalk.gray(" Make sure the server is running and accessible.\n"));
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
console.log(chalk.green(`✓ Connected — ${test.data?.accounts?.length ?? "?"} accounts on server\n`));
|
|
116
|
+
// 4. Save client config
|
|
117
|
+
const clientCfg = { remoteUrl: url };
|
|
118
|
+
if (secret)
|
|
119
|
+
clientCfg.remoteSecret = secret;
|
|
120
|
+
writeConfig({ ...readConfig(), client: clientCfg });
|
|
121
|
+
// 5. Configure Claude Code
|
|
122
|
+
writeClaudeSettings(0, url, secret ?? "proxy-managed", opts?.model);
|
|
123
|
+
console.log(chalk.green("✓ Claude Code configured to route through CC-Router"));
|
|
124
|
+
console.log(chalk.gray(` ANTHROPIC_BASE_URL → ${url}`));
|
|
125
|
+
if (opts?.model)
|
|
126
|
+
console.log(chalk.gray(` model → ${opts.model}`));
|
|
127
|
+
if (opts?.codex) {
|
|
128
|
+
const result = writeCodexRouterConfigFromClient(readConfig(), {
|
|
129
|
+
defaultModel: opts.codexModel,
|
|
130
|
+
});
|
|
131
|
+
console.log(chalk.green("✓ Codex CLI configured to route through CC-Router"));
|
|
132
|
+
console.log(chalk.gray(` config → ${result.path}`));
|
|
133
|
+
printCodexTokenReminder(result.hasSecret);
|
|
134
|
+
}
|
|
135
|
+
// 6. Optionally configure Claude Desktop (Cowork / Agent mode only)
|
|
136
|
+
let wantsDesktop = opts?.desktop ?? false;
|
|
137
|
+
if (!opts?.desktop && isClaudeDesktopInstalled()) {
|
|
138
|
+
printDesktopSupportExplainer();
|
|
139
|
+
wantsDesktop = await confirm({
|
|
140
|
+
message: "Route Claude Desktop's Cowork / Agent-mode traffic through CC-Router?",
|
|
141
|
+
default: false,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (wantsDesktop) {
|
|
145
|
+
await setupDesktopInterception(url, secret);
|
|
146
|
+
const current = readConfig();
|
|
147
|
+
if (current.client) {
|
|
148
|
+
current.client = { ...current.client, desktopEnabled: true };
|
|
149
|
+
writeConfig(current);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
console.log(chalk.bold.green("\n✓ Client mode active\n"));
|
|
153
|
+
console.log(" Next steps:");
|
|
154
|
+
console.log(" • Restart Claude Code for the new settings to take effect");
|
|
155
|
+
if (!opts?.codex) {
|
|
156
|
+
console.log(" • Run " + chalk.cyan("cc-router client configure-codex") + " to route Codex through this server");
|
|
157
|
+
}
|
|
158
|
+
if (wantsDesktop) {
|
|
159
|
+
console.log(" • Run " + chalk.cyan("cc-router client start-desktop") + " to begin intercepting Claude Desktop");
|
|
160
|
+
}
|
|
161
|
+
console.log(" • Run " + chalk.cyan("cc-router client status") + " to check connection\n");
|
|
162
|
+
});
|
|
163
|
+
// ── cc-router client configure-codex ───────────────────────────────────────
|
|
164
|
+
client
|
|
165
|
+
.command("configure-claude")
|
|
166
|
+
.description("Configure Claude Code from the stored CC-Router client connection")
|
|
167
|
+
.option("--model <model>", "Default Claude Code model to send through the router")
|
|
168
|
+
.action((opts) => {
|
|
169
|
+
const cfg = readConfig();
|
|
170
|
+
if (!cfg.client?.remoteUrl) {
|
|
171
|
+
console.error(chalk.red("✗ Client mode is not configured. Run: cc-router client connect <url>"));
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
writeClaudeSettings(0, cfg.client.remoteUrl, cfg.client.remoteSecret ?? "proxy-managed", opts.model);
|
|
175
|
+
console.log(chalk.green("✓ Claude Code configured to route through CC-Router"));
|
|
176
|
+
console.log(chalk.gray(` ANTHROPIC_BASE_URL → ${cfg.client.remoteUrl}`));
|
|
177
|
+
if (opts.model)
|
|
178
|
+
console.log(chalk.gray(` model → ${opts.model}`));
|
|
179
|
+
});
|
|
180
|
+
client
|
|
181
|
+
.command("configure-codex")
|
|
182
|
+
.description("Configure Codex CLI from the stored CC-Router client connection")
|
|
183
|
+
.option("--model <model>", "Default Codex model", "openai/default")
|
|
184
|
+
.action((opts) => {
|
|
185
|
+
const cfg = readConfig();
|
|
186
|
+
try {
|
|
187
|
+
const result = writeCodexRouterConfigFromClient(cfg, {
|
|
188
|
+
defaultModel: opts.model,
|
|
189
|
+
});
|
|
190
|
+
console.log(chalk.green("✓ Codex CLI configured to route through CC-Router"));
|
|
191
|
+
console.log(chalk.gray(` config → ${result.path}`));
|
|
192
|
+
console.log(chalk.gray(` base_url → ${codexBaseUrlFromRouterUrl(cfg.client.remoteUrl)}`));
|
|
193
|
+
console.log(chalk.gray(` model → ${opts.model}`));
|
|
194
|
+
printCodexTokenReminder(result.hasSecret);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
console.error(chalk.red(`✗ ${err.message}`));
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
// ── cc-router client disconnect ─────────────────────────────────────────────
|
|
202
|
+
client
|
|
203
|
+
.command("disconnect")
|
|
204
|
+
.description("Disconnect from CC-Router and restore Claude Code defaults")
|
|
205
|
+
.action(async () => {
|
|
206
|
+
const cfg = readConfig();
|
|
207
|
+
if (cfg.client?.desktopEnabled) {
|
|
208
|
+
if (isInterceptorServiceInstalled()) {
|
|
209
|
+
console.log(chalk.yellow("Removing Claude Desktop interceptor service..."));
|
|
210
|
+
await uninstallInterceptorService();
|
|
211
|
+
}
|
|
212
|
+
console.log(chalk.yellow("Stopping Claude Desktop interceptor..."));
|
|
213
|
+
await stopInterceptor();
|
|
214
|
+
// Full teardown is the right moment to offer removing the system-wide
|
|
215
|
+
// root CA. It's left in place by default because reconnecting reuses it
|
|
216
|
+
// and reinstalling needs sudo again — but a permanently trusted CA whose
|
|
217
|
+
// private key lives in ~/.mitmproxy is a real leftover if you're done.
|
|
218
|
+
const removeCa = await confirm({
|
|
219
|
+
message: "Also remove the mitmproxy root CA from your system trust store? (needs sudo/admin — recommended if you won't reconnect)",
|
|
220
|
+
default: false,
|
|
221
|
+
});
|
|
222
|
+
if (removeCa) {
|
|
223
|
+
const ok = await removeCaCert();
|
|
224
|
+
console.log(ok
|
|
225
|
+
? chalk.green("✓ mitmproxy root CA removed from the trust store")
|
|
226
|
+
: chalk.yellow("⚠ Could not remove the CA automatically — remove 'mitmproxy' from your OS trust store manually"));
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
console.log(chalk.gray(" Left the mitmproxy CA in your trust store (reused on reconnect)."));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
removeClaudeSettings();
|
|
233
|
+
const current = readConfig();
|
|
234
|
+
delete current.client;
|
|
235
|
+
writeConfig(current);
|
|
236
|
+
console.log(chalk.green("\n✓ Disconnected from CC-Router"));
|
|
237
|
+
console.log(chalk.gray(" Claude Code will use direct Anthropic connection on next restart.\n"));
|
|
238
|
+
});
|
|
239
|
+
// ── cc-router client status ─────────────────────────────────────────────────
|
|
240
|
+
client
|
|
241
|
+
.command("status")
|
|
242
|
+
.description("Show client connection status with live stats from the remote")
|
|
243
|
+
.option("--json", "Output raw remote health JSON")
|
|
244
|
+
.action(async (opts) => {
|
|
245
|
+
const cfg = readConfig();
|
|
246
|
+
const claude = readClaudeProxySettings();
|
|
247
|
+
if (!cfg.client) {
|
|
248
|
+
console.log(chalk.yellow("\n Not connected to any CC-Router server."));
|
|
249
|
+
console.log(chalk.gray(" Run: cc-router client connect <url>\n"));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// Fetch live health from remote
|
|
253
|
+
const test = await fetchRemoteHealth(cfg.client.remoteUrl, cfg.client.remoteSecret);
|
|
254
|
+
if (opts.json) {
|
|
255
|
+
console.log(JSON.stringify(test.data ?? { error: test.error }, null, 2));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
console.log(chalk.bold("\n📡 CC-Router Client Status\n"));
|
|
259
|
+
console.log(` Remote: ${chalk.cyan(cfg.client.remoteUrl)}`);
|
|
260
|
+
console.log(` Auth: ${cfg.client.remoteSecret ? chalk.green("secret configured") : chalk.gray("no auth")}`);
|
|
261
|
+
console.log(` Claude: ${claude.baseUrl ? chalk.green(claude.baseUrl) : chalk.red("not configured")}`);
|
|
262
|
+
if (!test.ok) {
|
|
263
|
+
console.log(` Server: ${chalk.red("unreachable")} — ${test.error}`);
|
|
264
|
+
console.log(chalk.gray("\n The remote proxy isn't responding. Your requests may be failing."));
|
|
265
|
+
console.log();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const d = test.data;
|
|
269
|
+
console.log(` Server: ${chalk.green("online")} · up ${chalk.gray(formatUptime(d.uptime ?? 0))}`);
|
|
270
|
+
// ── Totals ─────────────────────────────────────────────────────────
|
|
271
|
+
console.log(chalk.bold("\n TOTALS"));
|
|
272
|
+
console.log(` Requests: ${chalk.cyan(formatNumber(d.totalRequests))}` +
|
|
273
|
+
` Errors: ${((d.totalErrors ?? 0) > 0 ? chalk.red : chalk.gray)(formatNumber(d.totalErrors))}`);
|
|
274
|
+
console.log(` Input: ${chalk.gray(formatNumber(d.totalInputTokens))} tok` +
|
|
275
|
+
` Output: ${chalk.gray(formatNumber(d.totalOutputTokens))} tok` +
|
|
276
|
+
` Cache read: ${chalk.gray(formatNumber(d.totalCacheReadTokens))} tok`);
|
|
277
|
+
// ── Accounts ───────────────────────────────────────────────────────
|
|
278
|
+
if (d.accounts && d.accounts.length > 0) {
|
|
279
|
+
console.log(chalk.bold("\n ACCOUNTS"));
|
|
280
|
+
for (const a of d.accounts) {
|
|
281
|
+
const dot = a.healthy ? chalk.green("●") : chalk.red("●");
|
|
282
|
+
console.log(` ${dot} ${a.id.padEnd(20)} req ${String(a.requestCount ?? 0).padStart(5)} ` +
|
|
283
|
+
`err ${String(a.errorCount ?? 0).padStart(3)}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// ── Recent activity ────────────────────────────────────────────────
|
|
287
|
+
if (d.recentLogs && d.recentLogs.length > 0) {
|
|
288
|
+
console.log(chalk.bold("\n RECENT ACTIVITY (last 5)"));
|
|
289
|
+
for (const log of d.recentLogs.slice(0, 5)) {
|
|
290
|
+
const status = log.statusCode ?? 0;
|
|
291
|
+
const statusColor = status >= 500 || status === 0 ? chalk.red : status >= 400 ? chalk.yellow : chalk.green;
|
|
292
|
+
const duration = log.durationMs ? ` ${chalk.gray(log.durationMs + "ms")}` : "";
|
|
293
|
+
const src = log.source === "cli" ? chalk.blue("cli")
|
|
294
|
+
: log.source === "desktop" ? chalk.magenta("dsk")
|
|
295
|
+
: log.source === "api" ? chalk.gray("api")
|
|
296
|
+
: chalk.gray(" ");
|
|
297
|
+
console.log(` ${chalk.gray(formatTime(log.ts))} ${src} ${log.accountId.padEnd(18)} ` +
|
|
298
|
+
`${(log.method ?? "?").padEnd(5)} ${(log.path ?? "?").padEnd(22)} ` +
|
|
299
|
+
`${statusColor(String(status))}${duration}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
console.log(chalk.gray("\n No recent activity on the remote proxy."));
|
|
304
|
+
}
|
|
305
|
+
// ── Desktop status ─────────────────────────────────────────────────
|
|
306
|
+
console.log(chalk.bold("\n DESKTOP INTERCEPTOR (Cowork / Agent mode)"));
|
|
307
|
+
if (cfg.client.desktopEnabled) {
|
|
308
|
+
const running = await isInterceptorRunning();
|
|
309
|
+
const serviceInstalled = isInterceptorServiceInstalled();
|
|
310
|
+
if (running) {
|
|
311
|
+
console.log(` ${chalk.green("● running")}`);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
console.log(` ${chalk.yellow("○ configured but stopped")}`);
|
|
315
|
+
console.log(chalk.gray(" Start with: cc-router client start-desktop"));
|
|
316
|
+
}
|
|
317
|
+
if (serviceInstalled) {
|
|
318
|
+
console.log(` ${chalk.green("✓")} ${chalk.gray("Auto-start on boot: enabled")}`);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
console.log(` ${chalk.gray("○ Auto-start on boot: disabled")}`);
|
|
322
|
+
}
|
|
323
|
+
// Check Network Extension on macOS
|
|
324
|
+
if (isMacos()) {
|
|
325
|
+
const extStatus = await getNetworkExtensionStatus();
|
|
326
|
+
if (extStatus === "waiting") {
|
|
327
|
+
console.log(chalk.red(" ⚠ Network Extension NOT approved — interceptor won't capture traffic!"));
|
|
328
|
+
console.log(chalk.gray(" Fix: System Settings → General → Login Items & Extensions → Network Extensions"));
|
|
329
|
+
}
|
|
330
|
+
else if (extStatus === "not_installed") {
|
|
331
|
+
console.log(chalk.yellow(" ⚠ Network Extension not installed — will be triggered on first start"));
|
|
332
|
+
}
|
|
333
|
+
else if (extStatus === "enabled") {
|
|
334
|
+
console.log(` ${chalk.green("✓")} ${chalk.gray("Network Extension: enabled")}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
console.log(chalk.gray(" Scope: /v1/messages + /v1/models (normal chat NOT routed)"));
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
console.log(` ${chalk.gray("not configured — enable with: cc-router client connect --desktop")}`);
|
|
341
|
+
}
|
|
342
|
+
console.log();
|
|
343
|
+
console.log(chalk.gray(" Live dashboard: cc-router status\n"));
|
|
344
|
+
});
|
|
345
|
+
// ── cc-router client start-desktop ──────────────────────────────────────────
|
|
346
|
+
client
|
|
347
|
+
.command("start-desktop")
|
|
348
|
+
.description("Start mitmproxy interceptor for Claude Desktop (Cowork / Agent mode)")
|
|
349
|
+
.action(async () => {
|
|
350
|
+
const cfg = readConfig();
|
|
351
|
+
if (!cfg.client) {
|
|
352
|
+
console.error(chalk.red("Not connected. Run: cc-router client connect <url>"));
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
if (!(await checkMitmproxyInstalled())) {
|
|
356
|
+
console.error(chalk.red("\n✗ mitmproxy not found. Install it first:"));
|
|
357
|
+
console.error(chalk.cyan(isMacos() ? " brew install mitmproxy" : " pip install mitmproxy"));
|
|
358
|
+
console.error();
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
if (!cfg.client.desktopEnabled) {
|
|
362
|
+
await setupDesktopInterception(cfg.client.remoteUrl, cfg.client.remoteSecret);
|
|
363
|
+
mergeStoredClient({ desktopEnabled: true });
|
|
364
|
+
}
|
|
365
|
+
// Pre-flight check: verify Network Extension is ready on macOS.
|
|
366
|
+
// startInterceptor does the same check and throws; we catch and show
|
|
367
|
+
// a friendlier block here with the open-settings shortcut.
|
|
368
|
+
if (isMacos()) {
|
|
369
|
+
const status = await getNetworkExtensionStatus();
|
|
370
|
+
if (status === "waiting") {
|
|
371
|
+
console.error(chalk.red("\n✗ Mitmproxy Network Extension is NOT yet approved.\n"));
|
|
372
|
+
printNetworkExtensionInstructions();
|
|
373
|
+
const openNow = await confirm({
|
|
374
|
+
message: "Open System Settings now?",
|
|
375
|
+
default: true,
|
|
376
|
+
});
|
|
377
|
+
if (openNow)
|
|
378
|
+
await openNetworkExtensionSettings();
|
|
379
|
+
console.error(chalk.yellow("\n Re-run `cc-router client start-desktop` after approving.\n"));
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
if (status === "not_installed") {
|
|
383
|
+
console.error(chalk.yellow("\n⚠ Mitmproxy Network Extension is not installed yet."));
|
|
384
|
+
console.error(chalk.gray(" The first mitmdump run will trigger installation."));
|
|
385
|
+
console.error(chalk.gray(" Approve it in System Settings when macOS prompts you, then re-run this command.\n"));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const target = cfg.client.remoteUrl;
|
|
389
|
+
const secret = cfg.client.remoteSecret;
|
|
390
|
+
const processName = getProcessName();
|
|
391
|
+
console.log(chalk.cyan(`\nStarting mitmproxy interceptor for "${processName}"...`));
|
|
392
|
+
console.log(chalk.gray(` Redirecting api.anthropic.com/v1/messages → ${target}`));
|
|
393
|
+
try {
|
|
394
|
+
await startInterceptor(target, secret);
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
console.error(chalk.red(`\n✗ Failed to start interceptor:\n`));
|
|
398
|
+
console.error(chalk.yellow(" " + e.message.split("\n").join("\n ")));
|
|
399
|
+
console.error();
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
console.log(chalk.green("\n✓ Claude Desktop interceptor running"));
|
|
403
|
+
// ── Auto-start on boot ─────────────────────────────────────────────
|
|
404
|
+
const currentClient = readConfig().client;
|
|
405
|
+
if (!currentClient?.desktopAutoStart && !isInterceptorServiceInstalled()) {
|
|
406
|
+
const autoStart = await confirm({
|
|
407
|
+
message: "Start interceptor automatically when your computer boots? (recommended)",
|
|
408
|
+
default: true,
|
|
409
|
+
});
|
|
410
|
+
if (autoStart) {
|
|
411
|
+
const ok = await installInterceptorService(target, secret);
|
|
412
|
+
if (ok) {
|
|
413
|
+
mergeStoredClient({ desktopAutoStart: true });
|
|
414
|
+
console.log(chalk.green("✓ Auto-start on boot configured"));
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
console.log(chalk.yellow("⚠ Could not configure auto-start. You can retry later."));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
else if (currentClient?.desktopAutoStart) {
|
|
422
|
+
console.log(chalk.gray(" Auto-start on boot: enabled"));
|
|
423
|
+
}
|
|
424
|
+
console.log();
|
|
425
|
+
console.log(chalk.bold.yellow(" Next steps:"));
|
|
426
|
+
console.log(" " + chalk.cyan("1.") + " Quit Claude Desktop completely (⌘Q)");
|
|
427
|
+
console.log(" " + chalk.cyan("2.") + " Reopen Claude Desktop");
|
|
428
|
+
console.log(" " + chalk.cyan("3.") + " Use Cowork / Agent mode (Claude Code in Desktop)");
|
|
429
|
+
console.log();
|
|
430
|
+
console.log(chalk.gray(" Check routing with: ") + chalk.cyan("cc-router client status"));
|
|
431
|
+
console.log(chalk.gray(" Stop interceptor: ") + chalk.cyan("cc-router client stop-desktop"));
|
|
432
|
+
console.log();
|
|
433
|
+
});
|
|
434
|
+
// ── cc-router client stop-desktop ───────────────────────────────────────────
|
|
435
|
+
client
|
|
436
|
+
.command("stop-desktop")
|
|
437
|
+
.description("Stop the Claude Desktop mitmproxy interceptor")
|
|
438
|
+
.option("--keep-autostart", "Stop the interceptor but keep auto-start on boot")
|
|
439
|
+
.action(async (opts) => {
|
|
440
|
+
if (isInterceptorServiceInstalled() && !opts.keepAutostart) {
|
|
441
|
+
await uninstallInterceptorService();
|
|
442
|
+
console.log(chalk.green("✓ Auto-start on boot removed"));
|
|
443
|
+
const cfg = readConfig();
|
|
444
|
+
if (cfg.client) {
|
|
445
|
+
cfg.client.desktopAutoStart = false;
|
|
446
|
+
writeConfig(cfg);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
await stopInterceptor();
|
|
450
|
+
console.log(chalk.green("\n✓ Claude Desktop interceptor stopped\n"));
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
// ─── Desktop setup flow ───────────────────────────────────────────────────────
|
|
454
|
+
function printCodexTokenReminder(hasSecret) {
|
|
455
|
+
if (hasSecret) {
|
|
456
|
+
console.log(chalk.yellow(" Codex auth: set CC_ROUTER_TOKEN to your client proxy secret before running codex."));
|
|
457
|
+
console.log(chalk.gray(" Example: export CC_ROUTER_TOKEN=\"<your-cc-router-secret>\""));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
console.log(chalk.gray(" Codex auth: no proxy secret configured on this client."));
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Printed before asking the user whether to enable Desktop interception.
|
|
464
|
+
* The copy is deliberately explicit about WHAT works and WHAT doesn't — users
|
|
465
|
+
* who expect the normal chat to go through CC-Router will hit confusion fast,
|
|
466
|
+
* and we can head it off here by framing this as a "Cowork / Agent mode" feature.
|
|
467
|
+
*/
|
|
468
|
+
export function printDesktopSupportExplainer() {
|
|
469
|
+
console.log(chalk.bold.cyan("\n 🖥 Claude Desktop — what CC-Router can route\n"));
|
|
470
|
+
console.log(" Claude Desktop does NOT expose ANTHROPIC_BASE_URL, so CC-Router uses\n" +
|
|
471
|
+
" mitmproxy to selectively intercept only the traffic it can handle:\n");
|
|
472
|
+
console.log(chalk.green(" ✓ Cowork / Agent mode ") + chalk.gray("— /v1/messages (this is what gets routed)"));
|
|
473
|
+
console.log(chalk.green(" ✓ Claude Code inside Desktop") + chalk.gray("— /v1/messages (same as CLI)"));
|
|
474
|
+
console.log(chalk.red(" ✗ Normal chat ") + chalk.gray("— goes to claude.ai webview, NOT redirectable"));
|
|
475
|
+
console.log();
|
|
476
|
+
console.log(chalk.gray(" TL;DR: Your LLM-heavy workflows (Cowork, agent tasks, in-Desktop\n" +
|
|
477
|
+
" Claude Code) will rotate across your Max accounts via CC-Router.\n" +
|
|
478
|
+
" The regular chat sidebar keeps going directly through claude.ai."));
|
|
479
|
+
console.log();
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Prints the macOS Network Extension approval walkthrough.
|
|
483
|
+
* This is the #1 gotcha — mitmdump starts silently but captures nothing
|
|
484
|
+
* until the user flips the toggle in System Settings.
|
|
485
|
+
*/
|
|
486
|
+
export function printNetworkExtensionInstructions() {
|
|
487
|
+
if (!isMacos())
|
|
488
|
+
return;
|
|
489
|
+
console.log(chalk.bold.yellow("\n ⚠ IMPORTANT — macOS Network Extension approval\n"));
|
|
490
|
+
console.log(" The first time mitmproxy runs in local mode, macOS installs a");
|
|
491
|
+
console.log(" Network Extension (" + chalk.cyan("Mitmproxy Redirector") + ") that must be approved");
|
|
492
|
+
console.log(" manually. " + chalk.red("Without this step, mitmproxy captures ZERO traffic.") + "\n");
|
|
493
|
+
console.log(chalk.bold(" Steps:"));
|
|
494
|
+
console.log(" " + chalk.cyan("1.") + " Open " + chalk.bold("System Settings"));
|
|
495
|
+
console.log(" " + chalk.cyan("2.") + " Go to " + chalk.bold("General → Login Items & Extensions"));
|
|
496
|
+
console.log(" " + chalk.cyan("3.") + " Scroll to " + chalk.bold("Network Extensions") + " and click the " + chalk.bold("ⓘ") + " button");
|
|
497
|
+
console.log(" " + chalk.cyan("4.") + " Toggle " + chalk.bold("Mitmproxy Redirector") + " ON");
|
|
498
|
+
console.log(" " + chalk.cyan("5.") + " Enter your Mac admin password when prompted\n");
|
|
499
|
+
console.log(chalk.gray(" You only need to do this ONCE per machine.\n"));
|
|
500
|
+
}
|
|
501
|
+
async function setupDesktopInterception(target, secret) {
|
|
502
|
+
console.log(chalk.bold("\n🖥 Claude Desktop Setup\n"));
|
|
503
|
+
// 0. Explain what actually works before anything else
|
|
504
|
+
printDesktopSupportExplainer();
|
|
505
|
+
const proceedWithSetup = await confirm({
|
|
506
|
+
message: "Continue with Cowork / Agent-mode interception setup?",
|
|
507
|
+
default: true,
|
|
508
|
+
});
|
|
509
|
+
if (!proceedWithSetup) {
|
|
510
|
+
console.log(chalk.gray("Skipping Desktop setup. You can run it later with: cc-router client start-desktop\n"));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// 1. Check mitmproxy
|
|
514
|
+
if (!(await checkMitmproxyInstalled())) {
|
|
515
|
+
console.log(chalk.yellow("\nmitmproxy is required but not installed."));
|
|
516
|
+
if (isMacos()) {
|
|
517
|
+
console.log(chalk.cyan(" Install: brew install mitmproxy"));
|
|
518
|
+
}
|
|
519
|
+
else if (isWindows()) {
|
|
520
|
+
console.log(chalk.cyan(" Install: pip install mitmproxy (or download the installer from mitmproxy.org)"));
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
console.log(chalk.cyan(" Install: pip install mitmproxy (Linux local mode requires kernel ≥ 6.8)"));
|
|
524
|
+
}
|
|
525
|
+
console.log();
|
|
526
|
+
const proceed = await confirm({ message: "Have you installed mitmproxy now?", default: false });
|
|
527
|
+
if (!proceed || !(await checkMitmproxyInstalled())) {
|
|
528
|
+
console.log(chalk.red("\nmitmproxy still not found. Skipping Desktop setup.\n"));
|
|
529
|
+
console.log(chalk.gray("Re-run later with: cc-router client start-desktop\n"));
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
console.log(chalk.green("✓ mitmproxy found"));
|
|
534
|
+
// 2. Generate CA cert if missing
|
|
535
|
+
if (!isCaCertInstalled()) {
|
|
536
|
+
console.log(chalk.gray("Generating mitmproxy CA certificate (one-time)..."));
|
|
537
|
+
try {
|
|
538
|
+
await generateCaCert();
|
|
539
|
+
console.log(chalk.green("✓ CA certificate generated"));
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
console.log(chalk.red(`✗ CA generation failed: ${e.message}`));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
console.log(chalk.green("✓ CA certificate already present"));
|
|
548
|
+
}
|
|
549
|
+
// 3. Install CA cert (requires sudo)
|
|
550
|
+
console.log();
|
|
551
|
+
console.log(chalk.yellow("The mitmproxy CA certificate must be trusted by your OS so that"));
|
|
552
|
+
console.log(chalk.yellow("Claude Desktop accepts the local interceptor. This requires sudo."));
|
|
553
|
+
const installCa = await confirm({ message: "Install CA certificate now? (asks for admin password)", default: true });
|
|
554
|
+
if (installCa) {
|
|
555
|
+
const ok = await installCaCert();
|
|
556
|
+
if (ok) {
|
|
557
|
+
console.log(chalk.green("✓ CA certificate installed in system trust store"));
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
console.log(chalk.red("✗ CA certificate install failed."));
|
|
561
|
+
console.log(chalk.gray(" Install manually later with:"));
|
|
562
|
+
console.log(chalk.gray(" sudo security add-trusted-cert -d -r trustRoot \\"));
|
|
563
|
+
console.log(chalk.gray(" -k /Library/Keychains/System.keychain \\"));
|
|
564
|
+
console.log(chalk.gray(" ~/.mitmproxy/mitmproxy-ca-cert.pem"));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// 4. Write addon script (with secret so intercepted requests authenticate)
|
|
568
|
+
writeAddonScript(target, secret);
|
|
569
|
+
console.log(chalk.green("✓ Redirect addon configured"));
|
|
570
|
+
// 5. macOS Network Extension — THIS is the step people miss
|
|
571
|
+
if (isMacos()) {
|
|
572
|
+
printNetworkExtensionInstructions();
|
|
573
|
+
// Check current status and guide the user if it's not enabled
|
|
574
|
+
const status = await getNetworkExtensionStatus();
|
|
575
|
+
if (status === "not_installed") {
|
|
576
|
+
console.log(chalk.gray(" The Network Extension hasn't been installed yet — it'll be triggered\n" +
|
|
577
|
+
" automatically the first time you run `cc-router client start-desktop`.\n" +
|
|
578
|
+
" macOS will show a popup — approve it and follow the steps above.\n"));
|
|
579
|
+
}
|
|
580
|
+
else if (status === "waiting") {
|
|
581
|
+
console.log(chalk.red(" ⚠ Network Extension is installed but NOT yet approved.\n"));
|
|
582
|
+
const openNow = await confirm({
|
|
583
|
+
message: "Open System Settings now so you can approve it?",
|
|
584
|
+
default: true,
|
|
585
|
+
});
|
|
586
|
+
if (openNow) {
|
|
587
|
+
await openNetworkExtensionSettings();
|
|
588
|
+
console.log(chalk.gray("\n System Settings should now be open."));
|
|
589
|
+
console.log(chalk.gray(" Toggle 'Mitmproxy Redirector' ON, then come back here.\n"));
|
|
590
|
+
await confirm({ message: "Done? Press Enter when the toggle is ON", default: true });
|
|
591
|
+
const newStatus = await getNetworkExtensionStatus();
|
|
592
|
+
if (newStatus === "enabled") {
|
|
593
|
+
console.log(chalk.green("✓ Network Extension is enabled"));
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
console.log(chalk.yellow(` Still not enabled (status: ${newStatus})`));
|
|
597
|
+
console.log(chalk.gray(" You can re-check later with: cc-router client status"));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
else if (status === "enabled") {
|
|
602
|
+
console.log(chalk.green(" ✓ Network Extension is already enabled — you're all set"));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// 6. Remind that Claude Desktop must be restarted for mitmproxy to hook into it
|
|
606
|
+
console.log();
|
|
607
|
+
console.log(chalk.bold.yellow(" One more thing:"));
|
|
608
|
+
console.log(chalk.gray(" After starting the interceptor, you must " + chalk.bold("quit and relaunch Claude Desktop")));
|
|
609
|
+
console.log(chalk.gray(" (⌘Q in Claude Desktop, then reopen it). mitmproxy only captures"));
|
|
610
|
+
console.log(chalk.gray(" traffic from processes started AFTER it begins listening."));
|
|
611
|
+
console.log();
|
|
612
|
+
}
|