@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/Dockerfile +42 -0
  3. package/LICENSE +21 -0
  4. package/README.md +716 -0
  5. package/accounts.example.json +25 -0
  6. package/dist/cli/cmd-accounts.js +248 -0
  7. package/dist/cli/cmd-client.js +612 -0
  8. package/dist/cli/cmd-configure.js +145 -0
  9. package/dist/cli/cmd-docker.js +140 -0
  10. package/dist/cli/cmd-logs.js +85 -0
  11. package/dist/cli/cmd-models.js +125 -0
  12. package/dist/cli/cmd-service.js +193 -0
  13. package/dist/cli/cmd-setup.js +501 -0
  14. package/dist/cli/cmd-start.js +318 -0
  15. package/dist/cli/cmd-status.js +177 -0
  16. package/dist/cli/cmd-stop.js +100 -0
  17. package/dist/cli/cmd-telemetry.js +58 -0
  18. package/dist/cli/cmd-update.js +37 -0
  19. package/dist/cli/index.js +59 -0
  20. package/dist/config/manager.js +262 -0
  21. package/dist/config/paths.js +21 -0
  22. package/dist/config/telemetry.js +64 -0
  23. package/dist/daemon/launcher.js +163 -0
  24. package/dist/daemon/pid.js +98 -0
  25. package/dist/daemon/service.js +260 -0
  26. package/dist/interceptor/mitmproxy-manager.js +616 -0
  27. package/dist/protocol/anthropic-to-openai.js +51 -0
  28. package/dist/protocol/anthropic-types.js +1 -0
  29. package/dist/protocol/model-ref.js +36 -0
  30. package/dist/protocol/model-routing-config.js +30 -0
  31. package/dist/protocol/openai-response-to-anthropic.js +20 -0
  32. package/dist/protocol/openai-responses-types.js +1 -0
  33. package/dist/protocol/openai-stream-to-anthropic.js +75 -0
  34. package/dist/protocol/openai-to-anthropic.js +61 -0
  35. package/dist/protocol/sse.js +17 -0
  36. package/dist/providers/model-discovery.js +71 -0
  37. package/dist/providers/openai/account-pool.js +11 -0
  38. package/dist/providers/openai/account-record.js +33 -0
  39. package/dist/providers/openai/codex-transport.js +36 -0
  40. package/dist/providers/openai/device-oauth.js +116 -0
  41. package/dist/providers/openai/token-refresher.js +56 -0
  42. package/dist/providers/route-selector.js +8 -0
  43. package/dist/providers/types.js +1 -0
  44. package/dist/proxy/account-deletion.js +44 -0
  45. package/dist/proxy/anthropic-proxy.js +26 -0
  46. package/dist/proxy/anthropic-routing.js +90 -0
  47. package/dist/proxy/lease-lifecycle.js +68 -0
  48. package/dist/proxy/logger.js +39 -0
  49. package/dist/proxy/messages-cross-route.js +179 -0
  50. package/dist/proxy/models-server.js +150 -0
  51. package/dist/proxy/provider-routing.js +14 -0
  52. package/dist/proxy/responses-server.js +91 -0
  53. package/dist/proxy/server.js +875 -0
  54. package/dist/proxy/session-router.js +171 -0
  55. package/dist/proxy/stats.js +25 -0
  56. package/dist/proxy/stream-lifecycle.js +83 -0
  57. package/dist/proxy/token-pool.js +407 -0
  58. package/dist/proxy/token-refresher.js +209 -0
  59. package/dist/proxy/types.js +29 -0
  60. package/dist/ui/Dashboard.js +640 -0
  61. package/dist/ui/accountsApi.js +48 -0
  62. package/dist/ui/modelsApi.js +47 -0
  63. package/dist/utils/claude-config.js +185 -0
  64. package/dist/utils/codex-config.js +62 -0
  65. package/dist/utils/network.js +16 -0
  66. package/dist/utils/platform.js +13 -0
  67. package/dist/utils/self-update.js +239 -0
  68. package/dist/utils/telemetry.js +88 -0
  69. package/dist/utils/token-extractor.js +95 -0
  70. package/dist/utils/token-validator.js +26 -0
  71. package/docker-compose.yml +63 -0
  72. package/litellm-config.yaml +44 -0
  73. package/package.json +69 -0
  74. package/src/interceptor/addon.py +78 -0
@@ -0,0 +1,318 @@
1
+ import { select, confirm, password as passwordPrompt } from "@inquirer/prompts";
2
+ import chalk from "chalk";
3
+ import { PROXY_PORT, LITELLM_PORT, ACCOUNTS_PATH } from "../config/paths.js";
4
+ import { accountsFileExists, readConfig, writeConfig, generateProxySecret, } from "../config/manager.js";
5
+ import { writeClaudeSettings } from "../utils/claude-config.js";
6
+ import { checkForUpdate, performUpdate, PKG_NAME } from "../utils/self-update.js";
7
+ import { launchDaemon } from "../daemon/launcher.js";
8
+ import { installService } from "../daemon/service.js";
9
+ import { getLocalIPs } from "../utils/network.js";
10
+ export function registerStart(program) {
11
+ program
12
+ .command("start")
13
+ .description("Start the proxy server")
14
+ .option("--foreground", "Run in the foreground (stay in this terminal)")
15
+ .option("--port <port>", "Port to listen on", String(PROXY_PORT))
16
+ .option("--litellm [url]", "Forward to LiteLLM instead of Anthropic directly")
17
+ .option("--accounts <path>", "Path to accounts.json", ACCOUNTS_PATH)
18
+ .option("--reconfigure", "Re-ask run preferences (forget saved preferences)")
19
+ .action(async (opts) => {
20
+ // ── Step 0: Check for updates ──────────────────────────────────────────
21
+ await maybeUpdate();
22
+ // ── Step 1: Ensure accounts exist ──────────────────────────────────────
23
+ if (!accountsFileExists(opts.accounts !== ACCOUNTS_PATH ? opts.accounts : undefined)) {
24
+ console.log(chalk.yellow("\n No accounts configured yet.\n"));
25
+ const runSetup = await confirm({
26
+ message: "Run the setup wizard now?",
27
+ default: true,
28
+ });
29
+ if (runSetup) {
30
+ const { runSetupWizard } = await import("./cmd-setup.js");
31
+ await runSetupWizard({ addMode: false });
32
+ // After setup, re-check
33
+ if (!accountsFileExists()) {
34
+ console.log(chalk.red("\n✗ Setup did not produce accounts. Cannot start.\n"));
35
+ process.exit(1);
36
+ }
37
+ }
38
+ else {
39
+ console.log(chalk.gray(" Run 'cc-router setup' when you're ready.\n"));
40
+ return;
41
+ }
42
+ }
43
+ // ── Step 2: Resolve run preferences ────────────────────────────────────
44
+ const cfg = readConfig();
45
+ // --foreground flag overrides everything
46
+ if (opts.foreground) {
47
+ await startForeground(opts);
48
+ return;
49
+ }
50
+ // --reconfigure: forget saved preferences
51
+ if (opts.reconfigure) {
52
+ delete cfg.runPreferences;
53
+ writeConfig(cfg);
54
+ }
55
+ let prefs = cfg.runPreferences;
56
+ if (!prefs) {
57
+ // First time — ask the user
58
+ prefs = await askRunPreferences();
59
+ cfg.runPreferences = prefs;
60
+ writeConfig(cfg);
61
+ }
62
+ // ── Step 3: Handle server mode setup ───────────────────────────────────
63
+ if (prefs.serverMode && !cfg.proxySecret) {
64
+ await maybeSetupPassword(cfg);
65
+ }
66
+ // ── Step 4: Configure Claude Code if not already done ──────────────────
67
+ await ensureClaudeCodeConfigured(prefs, cfg);
68
+ // ── Step 5: Start according to preferences ─────────────────────────────
69
+ const port = parseInt(opts.port, 10) || prefs.port;
70
+ const litellmUrl = opts.litellm
71
+ ? (typeof opts.litellm === "string" ? opts.litellm : `http://localhost:${LITELLM_PORT}`)
72
+ : undefined;
73
+ if (opts.litellm && typeof opts.litellm !== "string") {
74
+ await ensureLiteLLMRunning();
75
+ }
76
+ if (prefs.mode === "foreground") {
77
+ await startForeground(opts);
78
+ return;
79
+ }
80
+ if (prefs.mode === "service") {
81
+ await installService(prefs.serverMode);
82
+ }
83
+ else {
84
+ // background mode
85
+ await launchDaemon({
86
+ port,
87
+ litellmUrl,
88
+ accountsPath: opts.accounts !== ACCOUNTS_PATH ? opts.accounts : undefined,
89
+ serverMode: prefs.serverMode,
90
+ });
91
+ }
92
+ // ── Step 6: Print server mode instructions ─────────────────────────────
93
+ if (prefs.serverMode) {
94
+ printServerModeInstructions(port, cfg.proxySecret);
95
+ }
96
+ });
97
+ }
98
+ // ─── Interactive preferences ─────────────────────────────────────────────────
99
+ async function askRunPreferences() {
100
+ console.log(chalk.bold(`\n${"━".repeat(40)}\n First-time setup\n${"━".repeat(40)}\n`));
101
+ const mode = await select({
102
+ message: "How do you want to run CC-Router?",
103
+ choices: [
104
+ { name: "In the background (recommended — runs silently, auto-restarts)", value: "background" },
105
+ { name: "In the foreground (stays in this terminal, Ctrl+C to stop)", value: "foreground" },
106
+ ],
107
+ });
108
+ let autoStart = false;
109
+ if (mode === "background") {
110
+ autoStart = await confirm({
111
+ message: "Start automatically when your computer boots?",
112
+ default: true,
113
+ });
114
+ }
115
+ const serverMode = await confirm({
116
+ message: "Will this machine serve other devices on the network? (server mode)",
117
+ default: false,
118
+ });
119
+ // For local (non-server) mode, ask about auto-configuring Claude Code
120
+ let configureClaudeCode = true;
121
+ if (!serverMode) {
122
+ configureClaudeCode = await confirm({
123
+ message: "Configure Claude Code to use the proxy automatically? (recommended)",
124
+ default: true,
125
+ });
126
+ }
127
+ const prefs = {
128
+ mode: autoStart ? "service" : mode,
129
+ serverMode,
130
+ port: PROXY_PORT,
131
+ configureClaudeCode,
132
+ };
133
+ console.log(chalk.green(`\n ✓ Preferences saved. Next time 'cc-router start' will use these automatically.`));
134
+ console.log(chalk.gray(` Change anytime with: cc-router start --reconfigure\n`));
135
+ return prefs;
136
+ }
137
+ async function maybeSetupPassword(cfg) {
138
+ console.log(chalk.yellow("\n Server mode binds the proxy to the network. A password is REQUIRED —" +
139
+ "\n without it, anyone who can reach the port could use your accounts.\n"));
140
+ const pwChoice = await select({
141
+ message: "Set a proxy password?",
142
+ choices: [
143
+ { name: "Generate automatically (recommended)", value: "generate" },
144
+ { name: "Enter my own password", value: "manual" },
145
+ ],
146
+ });
147
+ if (pwChoice === "manual") {
148
+ const raw = await passwordPrompt({
149
+ message: "Enter proxy password:",
150
+ validate: (v) => v.trim().length >= 8 || "Minimum 8 characters",
151
+ });
152
+ cfg.proxySecret = raw.trim();
153
+ writeConfig(cfg);
154
+ console.log(chalk.green(" ✓ Password saved.\n"));
155
+ return;
156
+ }
157
+ const secret = generateProxySecret();
158
+ cfg.proxySecret = secret;
159
+ writeConfig(cfg);
160
+ console.log(chalk.yellow("\n *** Save this password — you cannot recover it later ***"));
161
+ console.log(" " + chalk.bold(secret));
162
+ console.log(chalk.gray(" Clients will need this to connect.\n"));
163
+ }
164
+ async function ensureClaudeCodeConfigured(prefs, cfg) {
165
+ // Skip if user opted out (server mode always skips — clients configure themselves)
166
+ if (prefs.configureClaudeCode === false || prefs.serverMode)
167
+ return;
168
+ try {
169
+ const { readClaudeProxySettings } = await import("../utils/claude-config.js");
170
+ const current = readClaudeProxySettings();
171
+ if (current.baseUrl)
172
+ return; // already configured
173
+ writeClaudeSettings(prefs.port, `http://localhost:${prefs.port}`);
174
+ console.log(chalk.green(" ✓ Claude Code configured to use the proxy"));
175
+ }
176
+ catch (err) {
177
+ console.warn(chalk.yellow(` ⚠ Could not configure Claude Code: ${err.message}`));
178
+ console.warn(chalk.gray(` Configure manually: cc-router configure`));
179
+ }
180
+ }
181
+ // ─── Update check ────────────────────────────────────────────────────────────
182
+ async function maybeUpdate() {
183
+ let check;
184
+ try {
185
+ check = await checkForUpdate(true); // force fresh check, skip disk cache
186
+ if (!check.updateAvailable)
187
+ return;
188
+ }
189
+ catch {
190
+ return; // network check is non-critical
191
+ }
192
+ // From here, errors should be visible
193
+ if (check.diff === "major") {
194
+ console.log(chalk.yellow(`\n New major version available: v${check.current} → v${check.latest}`));
195
+ console.log(chalk.gray(` Update manually: npm i -g ${PKG_NAME}@${check.latest}\n`));
196
+ return;
197
+ }
198
+ console.log(chalk.cyan(`\n Update available: v${check.current} → v${check.latest} (${check.diff})`));
199
+ const doUpdate = await confirm({
200
+ message: "Update now?",
201
+ default: true,
202
+ });
203
+ if (!doUpdate)
204
+ return;
205
+ try {
206
+ const ok = await performUpdate(check.latest);
207
+ if (ok) {
208
+ console.log(chalk.green(" ✓ Updated. Restarting with new version...\n"));
209
+ const { spawn } = await import("child_process");
210
+ const child = spawn(process.execPath, process.argv.slice(1), {
211
+ stdio: "inherit",
212
+ env: process.env,
213
+ });
214
+ child.on("exit", (code) => process.exit(code ?? 0));
215
+ child.on("error", (err) => {
216
+ console.error(chalk.red(` Failed to restart after update: ${err.message}`));
217
+ process.exit(1);
218
+ });
219
+ await new Promise(() => { });
220
+ }
221
+ }
222
+ catch (err) {
223
+ console.error(chalk.yellow(` ⚠ Update failed: ${err.message}`));
224
+ console.log(chalk.gray(" Continuing with current version.\n"));
225
+ }
226
+ }
227
+ // ─── Server mode instructions ────────────────────────────────────────────────
228
+ function printServerModeInstructions(port, secret) {
229
+ const ips = getLocalIPs();
230
+ const ip = ips[0] ?? "<your-ip>";
231
+ console.log(chalk.bold.cyan(`\n ┌${"─".repeat(56)}┐`));
232
+ console.log(chalk.bold.cyan(` │ Server mode active — clients can connect with: │`));
233
+ console.log(chalk.bold.cyan(` ├${"─".repeat(56)}┤`));
234
+ console.log(chalk.cyan(` │ │`));
235
+ console.log(chalk.cyan(` │ ${chalk.white(`cc-router client connect http://${ip}:${port}`)}${" ".repeat(Math.max(0, 38 - ip.length - String(port).length))}│`));
236
+ if (secret) {
237
+ console.log(chalk.cyan(` │ ${chalk.gray(`--secret ${secret}`)}${" ".repeat(Math.max(0, 43 - secret.length))}│`));
238
+ }
239
+ console.log(chalk.cyan(` │ │`));
240
+ console.log(chalk.cyan(` │ Or manually in ~/.claude/settings.json: │`));
241
+ console.log(chalk.cyan(` │ ${chalk.gray(`{`)} │`));
242
+ console.log(chalk.cyan(` │ ${chalk.gray(`"env": {`)} │`));
243
+ console.log(chalk.cyan(` │ ${chalk.gray(`"ANTHROPIC_BASE_URL": "http://${ip}:${port}"`)}${" ".repeat(Math.max(0, 30 - ip.length - String(port).length))}│`));
244
+ if (secret) {
245
+ console.log(chalk.cyan(` │ ${chalk.gray(`"ANTHROPIC_AUTH_TOKEN": "${secret}"`)}${" ".repeat(Math.max(0, 30 - secret.length))}│`));
246
+ }
247
+ else {
248
+ console.log(chalk.cyan(` │ ${chalk.gray(`"ANTHROPIC_AUTH_TOKEN": "proxy-managed"`)} │`));
249
+ }
250
+ console.log(chalk.cyan(` │ ${chalk.gray(`}`)} │`));
251
+ console.log(chalk.cyan(` │ ${chalk.gray(`}`)} │`));
252
+ console.log(chalk.bold.cyan(` └${"─".repeat(56)}┘\n`));
253
+ // Plaintext-HTTP warning: the proxy speaks HTTP only. Over a non-loopback
254
+ // link the proxy secret and every prompt/response travel in the clear.
255
+ console.log(chalk.yellow.bold(" ⚠ This link is plain HTTP — not encrypted."));
256
+ console.log(chalk.yellow(` The password above and all prompts/responses are sent in cleartext and\n` +
257
+ ` can be read by anyone on the network path. For anything beyond a trusted\n` +
258
+ ` LAN, put CC-Router behind a TLS-terminating reverse proxy (Caddy, nginx,\n` +
259
+ ` Cloudflare Tunnel) and hand clients the https:// URL.\n` +
260
+ ` See: docs/security.md → "Transport security (TLS)".\n`));
261
+ }
262
+ // ─── Foreground start (direct server import) ────────────────────────────────
263
+ async function startForeground(opts) {
264
+ const litellmUrl = opts.litellm
265
+ ? (typeof opts.litellm === "string" ? opts.litellm : `http://localhost:${LITELLM_PORT}`)
266
+ : undefined;
267
+ if (opts.litellm && typeof opts.litellm !== "string") {
268
+ await ensureLiteLLMRunning();
269
+ }
270
+ // Apply server mode env if configured
271
+ const cfg = readConfig();
272
+ if (cfg.runPreferences?.serverMode && !process.env["HOST"]) {
273
+ process.env["HOST"] = "0.0.0.0";
274
+ }
275
+ const { startServer } = await import("../proxy/server.js");
276
+ await startServer({
277
+ port: parseInt(opts.port, 10),
278
+ litellmUrl,
279
+ accountsPath: opts.accounts !== ACCOUNTS_PATH ? opts.accounts : undefined,
280
+ });
281
+ }
282
+ // ─── LiteLLM Docker helper ──────────────────────────────────────────────────
283
+ async function ensureLiteLLMRunning() {
284
+ const { execFile } = await import("child_process");
285
+ const { promisify } = await import("util");
286
+ const execFileAsync = promisify(execFile);
287
+ const litellmUrl = `http://localhost:${LITELLM_PORT}`;
288
+ try {
289
+ const res = await fetch(`${litellmUrl}/health`, { signal: AbortSignal.timeout(1_000) });
290
+ if (res.ok) {
291
+ console.log(chalk.green(`✓ LiteLLM already running at ${litellmUrl}`));
292
+ return;
293
+ }
294
+ }
295
+ catch { /* not running */ }
296
+ console.log(chalk.cyan("Starting LiteLLM via Docker..."));
297
+ try {
298
+ await execFileAsync("docker", ["info"]);
299
+ }
300
+ catch {
301
+ console.error(chalk.red("✗ Docker is not running. Start Docker Desktop first."));
302
+ console.error(chalk.gray(" Or pass a custom LiteLLM URL: cc-router start --litellm http://your-host:4000"));
303
+ process.exit(1);
304
+ }
305
+ try {
306
+ const { spawn } = await import("child_process");
307
+ await new Promise((resolve, reject) => {
308
+ const child = spawn("docker", ["compose", "up", "-d", "litellm"], { stdio: "inherit" });
309
+ child.on("error", reject);
310
+ child.on("close", code => code === 0 ? resolve() : reject(new Error(`exit ${code}`)));
311
+ });
312
+ console.log(chalk.green(`✓ LiteLLM starting at ${litellmUrl}/ui`));
313
+ }
314
+ catch (err) {
315
+ console.error(chalk.red("✗ Failed to start LiteLLM:"), err.message);
316
+ process.exit(1);
317
+ }
318
+ }
@@ -0,0 +1,177 @@
1
+ import chalk from "chalk";
2
+ import { PROXY_PORT } from "../config/paths.js";
3
+ import { readConfig } from "../config/manager.js";
4
+ export function resolveStatusTarget(port) {
5
+ const cfg = readConfig();
6
+ if (cfg.client) {
7
+ const base = cfg.client.remoteUrl.replace(/\/+$/, "");
8
+ const headers = {};
9
+ if (cfg.client.remoteSecret)
10
+ headers["authorization"] = `Bearer ${cfg.client.remoteSecret}`;
11
+ return {
12
+ baseUrl: base,
13
+ healthUrl: `${base}/cc-router/health`,
14
+ headers,
15
+ authToken: cfg.client.remoteSecret,
16
+ };
17
+ }
18
+ const base = `http://localhost:${port}`;
19
+ const headers = {};
20
+ if (cfg.proxySecret)
21
+ headers["authorization"] = `Bearer ${cfg.proxySecret}`;
22
+ return {
23
+ baseUrl: base,
24
+ healthUrl: `${base}/cc-router/health`,
25
+ headers,
26
+ authToken: cfg.proxySecret,
27
+ };
28
+ }
29
+ export function registerStatus(program) {
30
+ program
31
+ .command("status")
32
+ .description("Live dashboard: account health, request counts, recent routing log")
33
+ .option("--port <port>", "Proxy port to connect to", String(PROXY_PORT))
34
+ .option("--json", "Output current stats as JSON and exit (non-interactive)")
35
+ .action(async (opts) => {
36
+ const port = parseInt(opts.port, 10);
37
+ if (opts.json) {
38
+ await jsonOutput(port);
39
+ return;
40
+ }
41
+ await dashboardLoop(port);
42
+ });
43
+ }
44
+ async function jsonOutput(port) {
45
+ const { healthUrl, headers } = resolveStatusTarget(port);
46
+ try {
47
+ const res = await fetch(healthUrl, {
48
+ headers,
49
+ signal: AbortSignal.timeout(2_000),
50
+ });
51
+ if (!res.ok) {
52
+ console.error(chalk.red(`Proxy returned HTTP ${res.status}`));
53
+ process.exit(1);
54
+ }
55
+ console.log(JSON.stringify(await res.json(), null, 2));
56
+ }
57
+ catch {
58
+ console.error(chalk.red(`Cannot connect to proxy at ${healthUrl}`));
59
+ const cfg = readConfig();
60
+ if (cfg.client) {
61
+ console.error(chalk.gray("Is the remote CC-Router running?"));
62
+ }
63
+ else {
64
+ console.error(chalk.gray("Is it running? Start with: cc-router start"));
65
+ }
66
+ process.exit(1);
67
+ }
68
+ }
69
+ /**
70
+ * Launches the Ink dashboard and handles "re-launch" intents.
71
+ *
72
+ * The dashboard cannot run inquirer prompts while Ink owns stdin, so when
73
+ * the user presses `n` to add an account, Ink unmounts **completely** first.
74
+ * Only after `waitUntilExit()` resolves — and stdin is restored from raw mode
75
+ * — does the OAuth flow run. Once tokens are obtained and POSTed to the
76
+ * server, the dashboard is re-rendered and polling resumes.
77
+ *
78
+ * IMPORTANT: The previous design resolved the outer promise from inside
79
+ * `onIntent` (before Ink unmounted), then raced with `waitUntilExit`. That
80
+ * caused inquirer to see a half-released stdin and force-close itself.
81
+ * The fix: `onIntent` writes to a mutable variable; `waitUntilExit()`
82
+ * is the ONLY thing that resolves the await; stdin is explicitly restored
83
+ * before inquirer runs.
84
+ */
85
+ async function dashboardLoop(port) {
86
+ // Dynamic imports keep these heavy deps out of the cold-start path
87
+ const [{ render }, { createElement }, { Dashboard }] = await Promise.all([
88
+ import("ink"),
89
+ import("react"),
90
+ import("../ui/Dashboard.js"),
91
+ ]);
92
+ while (true) {
93
+ const target = resolveStatusTarget(port);
94
+ // `pendingIntent` is set by the Dashboard component via `onIntent`;
95
+ // it defaults to "quit" so Ctrl+C (exitOnCtrlC) does the right thing
96
+ // without the Dashboard ever firing onIntent.
97
+ let pendingIntent = "quit";
98
+ const instance = render(createElement(Dashboard, {
99
+ port,
100
+ baseUrl: target.baseUrl,
101
+ authToken: target.authToken,
102
+ onIntent: (i) => { pendingIntent = i; },
103
+ }), { exitOnCtrlC: true });
104
+ // Block until Ink has FULLY unmounted and released stdin.
105
+ // The Dashboard's keyboard handler calls exit() for both `q` and `n`;
106
+ // Ctrl+C also triggers exit via exitOnCtrlC.
107
+ await instance.waitUntilExit();
108
+ // Yield the event loop so any of Ink's pending stdin cleanup tasks
109
+ // (listeners detach, raw-mode restore) run before inquirer grabs stdin.
110
+ // Without this, inquirer can see a half-released stdin and throw
111
+ // "User force closed the prompt".
112
+ await new Promise(resolve => setImmediate(resolve));
113
+ // Ink leaves stdin in raw mode. Restore it before running inquirer or
114
+ // exiting, otherwise the terminal may remain in a broken state.
115
+ if (process.stdin.isTTY) {
116
+ process.stdin.setRawMode(false);
117
+ }
118
+ // Ink may have paused stdin — resume it so inquirer can read input.
119
+ process.stdin.resume();
120
+ if (pendingIntent === "quit")
121
+ return;
122
+ // Intent: addAccount — run the OAuth flow, then POST the resulting
123
+ // tokens to the server we're connected to (local or remote).
124
+ console.log();
125
+ console.log(chalk.cyan("→ Adding a new account..."));
126
+ console.log();
127
+ const added = await runAddAccountFlow(target);
128
+ if (added) {
129
+ console.log(chalk.green(`\n✓ Account "${added}" added. Returning to dashboard...\n`));
130
+ }
131
+ else {
132
+ console.log(chalk.yellow("\n No account added. Returning to dashboard...\n"));
133
+ }
134
+ // Fall through → loop re-renders the dashboard
135
+ }
136
+ }
137
+ /**
138
+ * Runs the existing setupSingleAccount() OAuth flow, then POSTs the resulting
139
+ * tokens to /cc-router/accounts on the active target. Returns the new id on
140
+ * success, or null if the user aborted / an error occurred.
141
+ */
142
+ async function runAddAccountFlow(target) {
143
+ try {
144
+ const { setupSingleAccount } = await import("./cmd-setup.js");
145
+ // The index shown in the flow is just for display, pick something neutral.
146
+ const account = await setupSingleAccount(1);
147
+ if (!account)
148
+ return null;
149
+ const res = await fetch(`${target.baseUrl}/cc-router/accounts`, {
150
+ method: "POST",
151
+ headers: {
152
+ "content-type": "application/json",
153
+ ...target.headers,
154
+ },
155
+ body: JSON.stringify({
156
+ id: account.id,
157
+ accessToken: account.tokens.accessToken,
158
+ refreshToken: account.tokens.refreshToken,
159
+ expiresAt: account.tokens.expiresAt,
160
+ scopes: account.tokens.scopes,
161
+ }),
162
+ signal: AbortSignal.timeout(5_000),
163
+ });
164
+ if (!res.ok) {
165
+ const text = await res.text().catch(() => "");
166
+ console.error(chalk.red(`\n✗ Server rejected account: HTTP ${res.status}`));
167
+ if (text)
168
+ console.error(chalk.gray(` ${text}`));
169
+ return null;
170
+ }
171
+ return account.id;
172
+ }
173
+ catch (err) {
174
+ console.error(chalk.red(`\n✗ Failed to add account: ${err.message}`));
175
+ return null;
176
+ }
177
+ }
@@ -0,0 +1,100 @@
1
+ import { confirm } from "@inquirer/prompts";
2
+ import chalk from "chalk";
3
+ import { removeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
4
+ import { PROXY_PORT } from "../config/paths.js";
5
+ import { stopDaemon } from "../daemon/launcher.js";
6
+ import { isProxyRunning } from "../daemon/pid.js";
7
+ import { isServiceInstalled, uninstallService } from "../daemon/service.js";
8
+ import { readConfig, writeConfig } from "../config/manager.js";
9
+ export function registerStop(program) {
10
+ program
11
+ .command("stop")
12
+ .description("Stop the proxy and optionally clean up service / Claude Code config")
13
+ .option("--keep-config", "Stop the proxy but keep all configuration")
14
+ .option("--full", "Stop, remove auto-start, and revert Claude Code config (no prompts)")
15
+ .action(async (opts) => {
16
+ await stopProxy(opts);
17
+ });
18
+ }
19
+ export function registerRevert(program) {
20
+ program
21
+ .command("revert")
22
+ .description("Restore Claude Code to its normal authentication (removes proxy config)")
23
+ .action(async () => {
24
+ await stopProxy({ full: true });
25
+ });
26
+ }
27
+ // ─── Core logic ──────────────────────────────────────────────────────────────
28
+ async function stopProxy(opts) {
29
+ let anythingDone = false;
30
+ // 1. Stop the proxy process
31
+ const wasRunning = await isProxyRunning();
32
+ if (wasRunning) {
33
+ const stopped = await stopDaemon(PROXY_PORT);
34
+ if (stopped) {
35
+ console.log(chalk.green("✓ Proxy process stopped"));
36
+ anythingDone = true;
37
+ }
38
+ else {
39
+ console.log(chalk.yellow("⚠ Could not stop proxy automatically."));
40
+ console.log(chalk.gray(" If it's running in a terminal, press Ctrl+C there."));
41
+ console.log(chalk.gray(` Or kill manually: kill $(lsof -ti:${PROXY_PORT})`));
42
+ }
43
+ }
44
+ else {
45
+ console.log(chalk.gray(" Proxy is not running."));
46
+ }
47
+ if (opts.keepConfig) {
48
+ printDone(anythingDone);
49
+ return;
50
+ }
51
+ // 2. Service cleanup
52
+ const hasService = isServiceInstalled();
53
+ if (hasService) {
54
+ let removeService = opts.full ?? false;
55
+ if (!opts.full) {
56
+ removeService = await confirm({
57
+ message: "CC-Router is configured to start on boot. Remove auto-start?",
58
+ default: false,
59
+ });
60
+ }
61
+ if (removeService) {
62
+ await uninstallService();
63
+ // Also clear the service preference so next `start` re-asks
64
+ const cfg = readConfig();
65
+ if (cfg.runPreferences?.mode === "service") {
66
+ cfg.runPreferences.mode = "background";
67
+ writeConfig(cfg);
68
+ }
69
+ anythingDone = true;
70
+ }
71
+ }
72
+ // 3. Claude Code config cleanup
73
+ const current = readClaudeProxySettings();
74
+ if (current.baseUrl) {
75
+ let removeSettings = opts.full ?? false;
76
+ if (!opts.full) {
77
+ removeSettings = await confirm({
78
+ message: "Remove proxy settings from Claude Code? (Claude will use normal auth)",
79
+ default: false,
80
+ });
81
+ }
82
+ if (removeSettings) {
83
+ removeClaudeSettings();
84
+ console.log(chalk.green("✓ Removed proxy settings from ~/.claude/settings.json"));
85
+ console.log(chalk.gray(" Claude Code will use its normal authentication on next launch."));
86
+ anythingDone = true;
87
+ }
88
+ }
89
+ printDone(anythingDone);
90
+ }
91
+ function printDone(anythingDone) {
92
+ if (!anythingDone) {
93
+ console.log(chalk.gray("\nNothing to do — proxy was not running and config was not set."));
94
+ }
95
+ else {
96
+ console.log(chalk.green("\n✓ Done."));
97
+ console.log(chalk.gray(" To re-enable: cc-router start"));
98
+ console.log(chalk.gray(" To reconfigure: cc-router start --reconfigure\n"));
99
+ }
100
+ }
@@ -0,0 +1,58 @@
1
+ import chalk from "chalk";
2
+ import { loadTelemetryState, writeTelemetryState, isTelemetryEnabled } from "../config/telemetry.js";
3
+ export function registerTelemetry(program) {
4
+ program
5
+ .command("telemetry [action]")
6
+ .description("Manage anonymous usage analytics: on, off, status (default: status)")
7
+ .action(async (action) => {
8
+ const resolved = action ?? "status";
9
+ if (resolved === "status") {
10
+ showStatus();
11
+ return;
12
+ }
13
+ if (resolved === "on") {
14
+ const state = loadTelemetryState();
15
+ state.enabled = true;
16
+ writeTelemetryState(state);
17
+ console.log(chalk.green("Telemetry enabled."));
18
+ console.log(chalk.dim(`Install ID: ${state.installId}`));
19
+ return;
20
+ }
21
+ if (resolved === "off") {
22
+ // Do not beacon on opt-out: an explicit "turn it off" must not send data.
23
+ const state = loadTelemetryState();
24
+ state.enabled = false;
25
+ writeTelemetryState(state);
26
+ console.log(chalk.yellow("Telemetry disabled. No data will be sent."));
27
+ console.log(chalk.dim("Re-enable anytime with: cc-router telemetry on"));
28
+ return;
29
+ }
30
+ console.error(chalk.red(`Unknown action "${resolved}". Use: on, off, status`));
31
+ process.exitCode = 1;
32
+ });
33
+ }
34
+ function showStatus() {
35
+ const state = loadTelemetryState();
36
+ const envDisabled = process.env["DO_NOT_TRACK"] === "1" || process.env["CC_ROUTER_TELEMETRY"] === "0";
37
+ console.log(chalk.bold("Telemetry"));
38
+ console.log();
39
+ if (envDisabled) {
40
+ console.log(` Status: ${chalk.yellow("disabled")} (by environment variable)`);
41
+ }
42
+ else if (state.enabled) {
43
+ console.log(` Status: ${chalk.green("enabled")}`);
44
+ }
45
+ else {
46
+ console.log(` Status: ${chalk.yellow("disabled")}`);
47
+ }
48
+ console.log(` Active: ${isTelemetryEnabled() ? chalk.green("yes") : chalk.yellow("no")}`);
49
+ console.log(` Install ID: ${chalk.dim(state.installId)}`);
50
+ console.log(` Since: ${chalk.dim(state.firstRunAt)}`);
51
+ console.log();
52
+ console.log(chalk.dim(" What we send: version, OS, locale, lifecycle events (start, heartbeat)"));
53
+ console.log(chalk.dim(" What we DON'T: IPs, tokens, prompts, request content, account names"));
54
+ console.log(chalk.dim(" Source code: src/utils/telemetry.ts"));
55
+ console.log();
56
+ console.log(chalk.dim(" Disable: cc-router telemetry off"));
57
+ console.log(chalk.dim(" Or set: DO_NOT_TRACK=1 | CC_ROUTER_TELEMETRY=0"));
58
+ }