@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,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
|
+
}
|