ai-cc-router 0.1.4 → 0.1.5
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/dist/cli/cmd-configure.js +43 -1
- package/dist/cli/cmd-setup.js +39 -5
- package/dist/config/manager.js +22 -1
- package/dist/config/paths.js +3 -0
- package/dist/proxy/server.js +69 -8
- package/dist/proxy/stats.js +1 -1
- package/dist/ui/Dashboard.js +73 -7
- package/dist/utils/claude-config.js +3 -2
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { writeClaudeSettings, removeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
|
|
3
|
+
import { readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
|
|
3
4
|
import { PROXY_PORT, CLAUDE_SETTINGS_PATH } from "../config/paths.js";
|
|
4
5
|
export function registerConfigure(program) {
|
|
5
6
|
program
|
|
@@ -8,6 +9,9 @@ export function registerConfigure(program) {
|
|
|
8
9
|
.option("--remove", "Remove cc-router settings from ~/.claude/settings.json")
|
|
9
10
|
.option("--port <port>", "Proxy port to configure", String(PROXY_PORT))
|
|
10
11
|
.option("--show", "Show current Claude Code proxy settings")
|
|
12
|
+
.option("--generate-password", "Generate a new proxy secret and sync Claude Code settings")
|
|
13
|
+
.option("--set-password <secret>", "Set a specific proxy secret and sync Claude Code settings")
|
|
14
|
+
.option("--remove-password", "Remove proxy password protection (open access)")
|
|
11
15
|
.action((opts) => {
|
|
12
16
|
if (opts.show) {
|
|
13
17
|
const current = readClaudeProxySettings();
|
|
@@ -20,6 +24,9 @@ export function registerConfigure(program) {
|
|
|
20
24
|
console.log(chalk.yellow(" Claude Code is NOT configured to use cc-router."));
|
|
21
25
|
console.log(chalk.gray(` Run: cc-router configure`));
|
|
22
26
|
}
|
|
27
|
+
const { proxySecret } = readConfig();
|
|
28
|
+
const pwStatus = proxySecret ? chalk.green("yes") : chalk.gray("no");
|
|
29
|
+
console.log(` Password protected: ${pwStatus}`);
|
|
23
30
|
return;
|
|
24
31
|
}
|
|
25
32
|
if (opts.remove) {
|
|
@@ -28,10 +35,45 @@ export function registerConfigure(program) {
|
|
|
28
35
|
console.log(chalk.gray(" Claude Code will use its default authentication on next launch."));
|
|
29
36
|
return;
|
|
30
37
|
}
|
|
38
|
+
if (opts.generatePassword) {
|
|
39
|
+
const secret = generateProxySecret();
|
|
40
|
+
writeConfig({ ...readConfig(), proxySecret: secret });
|
|
41
|
+
const { baseUrl } = readClaudeProxySettings();
|
|
42
|
+
writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
|
|
43
|
+
console.log(chalk.green("✓ Proxy password set."));
|
|
44
|
+
console.log(" " + chalk.bold.yellow("Save this — it will not be shown again:"));
|
|
45
|
+
console.log(" " + chalk.bold(secret));
|
|
46
|
+
console.log(chalk.gray(" Restart cc-router for the change to take effect."));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (opts.setPassword !== undefined) {
|
|
50
|
+
const secret = opts.setPassword.trim();
|
|
51
|
+
if (!secret) {
|
|
52
|
+
console.error(chalk.red("Secret cannot be empty."));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
writeConfig({ ...readConfig(), proxySecret: secret });
|
|
56
|
+
const { baseUrl } = readClaudeProxySettings();
|
|
57
|
+
writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
|
|
58
|
+
console.log(chalk.green("✓ Proxy password updated."));
|
|
59
|
+
console.log(chalk.gray(" Restart cc-router for the change to take effect."));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (opts.removePassword) {
|
|
63
|
+
const cfg = readConfig();
|
|
64
|
+
delete cfg.proxySecret;
|
|
65
|
+
writeConfig(cfg);
|
|
66
|
+
const { baseUrl } = readClaudeProxySettings();
|
|
67
|
+
writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
|
|
68
|
+
console.log(chalk.green("✓ Proxy password removed. Access is now open."));
|
|
69
|
+
console.log(chalk.gray(" Restart cc-router for the change to take effect."));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
31
72
|
const port = parseInt(opts.port, 10);
|
|
32
73
|
writeClaudeSettings(port);
|
|
74
|
+
const { proxySecret } = readConfig();
|
|
33
75
|
console.log(chalk.green(`✓ Updated ${CLAUDE_SETTINGS_PATH}`));
|
|
34
76
|
console.log(chalk.gray(` ANTHROPIC_BASE_URL = http://localhost:${port}`));
|
|
35
|
-
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
77
|
+
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = ${proxySecret ? chalk.green("(secret configured)") : "proxy-managed"}`));
|
|
36
78
|
});
|
|
37
79
|
}
|
package/dist/cli/cmd-setup.js
CHANGED
|
@@ -7,7 +7,7 @@ import { extractFromKeychain, extractFromCredentialsFile, formatExpiry, redactTo
|
|
|
7
7
|
import { validateToken } from "../utils/token-validator.js";
|
|
8
8
|
import { writeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
|
|
9
9
|
import { saveAccounts } from "../proxy/token-refresher.js";
|
|
10
|
-
import { loadAccounts, accountsFileExists } from "../config/manager.js";
|
|
10
|
+
import { loadAccounts, accountsFileExists, readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
|
|
11
11
|
import { PROXY_PORT } from "../config/paths.js";
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
13
|
// ─── Public registration ──────────────────────────────────────────────────────
|
|
@@ -226,11 +226,41 @@ async function runPostSetupFlow(accountCount) {
|
|
|
226
226
|
const port = proxyLocation === "local"
|
|
227
227
|
? PROXY_PORT
|
|
228
228
|
: parseInt(new URL(proxyHost).port || "80", 10);
|
|
229
|
-
|
|
230
|
-
console.log(chalk.green(`\n ✓ ~/.claude/settings.json updated`));
|
|
231
|
-
console.log(chalk.gray(` ANTHROPIC_BASE_URL = ${proxyHost}`));
|
|
232
|
-
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
229
|
+
// ── Password setup for remote proxy ───────────────────────────────────────
|
|
233
230
|
if (proxyLocation === "remote") {
|
|
231
|
+
const pwChoice = await select({
|
|
232
|
+
message: "Set a proxy password? (strongly recommended for internet-exposed proxies)",
|
|
233
|
+
choices: [
|
|
234
|
+
{ name: "Generate automatically (recommended)", value: "generate" },
|
|
235
|
+
{ name: "Enter my own password", value: "manual" },
|
|
236
|
+
{ name: "Skip — no password protection", value: "skip" },
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
let chosenSecret;
|
|
240
|
+
if (pwChoice === "generate") {
|
|
241
|
+
chosenSecret = generateProxySecret();
|
|
242
|
+
writeConfig({ ...readConfig(), proxySecret: chosenSecret });
|
|
243
|
+
}
|
|
244
|
+
else if (pwChoice === "manual") {
|
|
245
|
+
const raw = await password({
|
|
246
|
+
message: "Enter proxy password:",
|
|
247
|
+
validate: (v) => v.trim().length >= 8 || "Minimum 8 characters",
|
|
248
|
+
});
|
|
249
|
+
chosenSecret = raw.trim();
|
|
250
|
+
writeConfig({ ...readConfig(), proxySecret: chosenSecret });
|
|
251
|
+
}
|
|
252
|
+
writeClaudeSettings(port, proxyHost);
|
|
253
|
+
if (chosenSecret) {
|
|
254
|
+
console.log(chalk.yellow("\n *** Save this password — you cannot recover it later ***"));
|
|
255
|
+
console.log(" " + chalk.bold(chosenSecret));
|
|
256
|
+
console.log(chalk.gray(" Claude Code has been configured to use it automatically."));
|
|
257
|
+
console.log(chalk.gray(" Other machines: cc-router configure --set-password <value>"));
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
console.log(chalk.green(`\n ✓ ~/.claude/settings.json updated`));
|
|
261
|
+
console.log(chalk.gray(` ANTHROPIC_BASE_URL = ${proxyHost}`));
|
|
262
|
+
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
263
|
+
}
|
|
234
264
|
console.log(chalk.cyan(`\n On the remote machine, start cc-router with:`));
|
|
235
265
|
console.log(chalk.white(` HOST=0.0.0.0 cc-router start`));
|
|
236
266
|
console.log(chalk.cyan(` Or as a service:`));
|
|
@@ -239,6 +269,10 @@ async function runPostSetupFlow(accountCount) {
|
|
|
239
269
|
printDone(accountCount);
|
|
240
270
|
return;
|
|
241
271
|
}
|
|
272
|
+
writeClaudeSettings(port, proxyHost);
|
|
273
|
+
console.log(chalk.green(`\n ✓ ~/.claude/settings.json updated`));
|
|
274
|
+
console.log(chalk.gray(` ANTHROPIC_BASE_URL = ${proxyHost}`));
|
|
275
|
+
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
242
276
|
}
|
|
243
277
|
// 2. Only ask about starting the proxy if it's local
|
|
244
278
|
console.log(chalk.bold(`\n${"━".repeat(40)}\n Start the proxy\n${"━".repeat(40)}\n`));
|
package/dist/config/manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
|
|
3
4
|
export function ensureConfigDir() {
|
|
4
5
|
if (!existsSync(CONFIG_DIR)) {
|
|
5
6
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -36,6 +37,26 @@ export function writeAccountsAtomic(data) {
|
|
|
36
37
|
export function loadAccounts() {
|
|
37
38
|
return deserialize(readAccountsRaw());
|
|
38
39
|
}
|
|
40
|
+
export function readConfig() {
|
|
41
|
+
if (!existsSync(CONFIG_PATH))
|
|
42
|
+
return {};
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function writeConfig(cfg) {
|
|
51
|
+
ensureConfigDir();
|
|
52
|
+
const tmp = CONFIG_PATH + ".tmp";
|
|
53
|
+
writeFileSync(tmp, JSON.stringify(cfg, null, 2), "utf-8");
|
|
54
|
+
renameSync(tmp, CONFIG_PATH);
|
|
55
|
+
}
|
|
56
|
+
export function generateProxySecret() {
|
|
57
|
+
return "cc-rtr-" + randomBytes(16).toString("hex");
|
|
58
|
+
}
|
|
59
|
+
// ─── Accounts ─────────────────────────────────────────────────────────────────
|
|
39
60
|
function deserialize(records) {
|
|
40
61
|
return records.map(a => ({
|
|
41
62
|
id: a.id,
|
package/dist/config/paths.js
CHANGED
|
@@ -9,3 +9,6 @@ export const PROXY_PORT = parseInt(process.env["PORT"] ?? "3456", 10);
|
|
|
9
9
|
export const LITELLM_PORT = 4000;
|
|
10
10
|
// When set, the server forwards to LiteLLM instead of Anthropic directly
|
|
11
11
|
export const LITELLM_URL = process.env["LITELLM_URL"];
|
|
12
|
+
// Proxy-level config (password, future settings) — separate from accounts.json
|
|
13
|
+
export const CONFIG_PATH = process.env["CONFIG_PATH"] ??
|
|
14
|
+
path.join(CONFIG_DIR, "config.json");
|
package/dist/proxy/server.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
3
3
|
import { ServerResponse } from "http";
|
|
4
|
+
import { timingSafeEqual } from "crypto";
|
|
4
5
|
import { TokenPool } from "./token-pool.js";
|
|
5
6
|
import { needsRefresh, refreshAccountToken, saveAccounts, startRefreshLoop } from "./token-refresher.js";
|
|
6
|
-
import { loadAccounts, accountsFileExists, readAccountsFromPath } from "../config/manager.js";
|
|
7
|
+
import { loadAccounts, accountsFileExists, readAccountsFromPath, readConfig } from "../config/manager.js";
|
|
7
8
|
import { logRoute, logError, logStartup } from "./logger.js";
|
|
8
9
|
import { stats } from "./stats.js";
|
|
9
10
|
import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
|
|
@@ -30,6 +31,30 @@ export async function startServer(opts = {}) {
|
|
|
30
31
|
const pool = new TokenPool(accounts);
|
|
31
32
|
startRefreshLoop(accounts);
|
|
32
33
|
const app = express();
|
|
34
|
+
// ─── Proxy auth middleware ─────────────────────────────────────────────────
|
|
35
|
+
// If a proxySecret is configured, all requests must present it as
|
|
36
|
+
// "Authorization: Bearer <secret>". The /cc-router/health endpoint is
|
|
37
|
+
// always exempt so monitoring and PM2 healthchecks keep working.
|
|
38
|
+
const { proxySecret } = readConfig();
|
|
39
|
+
if (proxySecret) {
|
|
40
|
+
const secretBuf = Buffer.from(proxySecret, "utf-8");
|
|
41
|
+
app.use((req, res, next) => {
|
|
42
|
+
if (req.path === "/cc-router/health")
|
|
43
|
+
return next();
|
|
44
|
+
const auth = req.headers["authorization"] ?? "";
|
|
45
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
46
|
+
const tokenBuf = Buffer.from(token, "utf-8");
|
|
47
|
+
if (tokenBuf.length !== secretBuf.length ||
|
|
48
|
+
!timingSafeEqual(tokenBuf, secretBuf)) {
|
|
49
|
+
res.status(401).json({
|
|
50
|
+
type: "error",
|
|
51
|
+
error: { type: "authentication_error", message: "Invalid or missing proxy authentication token" },
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
33
58
|
// ─── Health endpoint (cc-router internal, NOT proxied) ────────────────────
|
|
34
59
|
app.get("/cc-router/health", (_req, res) => {
|
|
35
60
|
res.json({
|
|
@@ -41,7 +66,7 @@ export async function startServer(opts = {}) {
|
|
|
41
66
|
totalErrors: stats.totalErrors,
|
|
42
67
|
totalRefreshes: stats.totalRefreshes,
|
|
43
68
|
accounts: pool.getStats(),
|
|
44
|
-
recentLogs: stats.getRecentLogs(),
|
|
69
|
+
recentLogs: stats.getRecentLogs(50),
|
|
45
70
|
});
|
|
46
71
|
});
|
|
47
72
|
// ─── Proxy middleware ──────────────────────────────────────────────────────
|
|
@@ -90,42 +115,70 @@ export async function startServer(opts = {}) {
|
|
|
90
115
|
if (!account)
|
|
91
116
|
return;
|
|
92
117
|
const status = proxyRes.statusCode ?? 0;
|
|
118
|
+
const durationMs = req._startTime
|
|
119
|
+
? Date.now() - req._startTime
|
|
120
|
+
: undefined;
|
|
121
|
+
// Complete the pending log entry with response info
|
|
122
|
+
const pendingLog = req._pendingLog ?? {
|
|
123
|
+
ts: Date.now(),
|
|
124
|
+
accountId: account.id,
|
|
125
|
+
model: "-",
|
|
126
|
+
type: "route",
|
|
127
|
+
};
|
|
128
|
+
pendingLog.statusCode = status;
|
|
129
|
+
if (durationMs !== undefined)
|
|
130
|
+
pendingLog.durationMs = durationMs;
|
|
93
131
|
if (status === 401) {
|
|
94
132
|
// Token invalid or expired mid-request.
|
|
95
133
|
// Forward the 401 to the client (Claude Code will retry on 401).
|
|
96
134
|
// Schedule a background refresh so the next request succeeds.
|
|
97
135
|
stats.totalErrors++;
|
|
98
136
|
account.errorCount++;
|
|
137
|
+
pendingLog.type = "error";
|
|
138
|
+
pendingLog.details = "token invalid";
|
|
99
139
|
logError(account.id, 401, "Token invalid — scheduling background refresh");
|
|
100
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "401" });
|
|
101
140
|
refreshAccountToken(account).then(ok => {
|
|
102
141
|
if (ok)
|
|
103
142
|
saveAccounts(pool.getAll());
|
|
104
143
|
}).catch(console.error);
|
|
105
144
|
}
|
|
106
|
-
if (status === 429) {
|
|
145
|
+
else if (status === 429) {
|
|
107
146
|
// Rate limited — put account on cooldown for Retry-After seconds.
|
|
108
147
|
stats.totalErrors++;
|
|
109
148
|
account.errorCount++;
|
|
110
149
|
const retryAfter = Number(proxyRes.headers["retry-after"] ?? 60);
|
|
150
|
+
pendingLog.type = "error";
|
|
151
|
+
pendingLog.details = `rate limited — cooldown ${retryAfter}s`;
|
|
111
152
|
logError(account.id, 429, `Rate limited — cooldown ${retryAfter}s`);
|
|
112
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "429" });
|
|
113
153
|
account.busy = true;
|
|
114
154
|
setTimeout(() => { account.busy = false; }, retryAfter * 1_000);
|
|
115
155
|
}
|
|
116
|
-
if (status === 529) {
|
|
156
|
+
else if (status === 529) {
|
|
117
157
|
// Anthropic service overloaded — short cooldown on this account.
|
|
118
158
|
stats.totalErrors++;
|
|
119
159
|
account.errorCount++;
|
|
160
|
+
pendingLog.type = "error";
|
|
161
|
+
pendingLog.details = "service overloaded — cooldown 30s";
|
|
120
162
|
logError(account.id, 529, "Service overloaded — cooldown 30s");
|
|
121
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "529" });
|
|
122
163
|
account.busy = true;
|
|
123
164
|
setTimeout(() => { account.busy = false; }, 30_000);
|
|
124
165
|
}
|
|
166
|
+
stats.addLog(pendingLog);
|
|
125
167
|
},
|
|
126
168
|
error: (err, _req, res) => {
|
|
127
169
|
stats.totalErrors++;
|
|
128
170
|
logError("proxy", 0, err.message);
|
|
171
|
+
// Complete the pending log entry for connection-level errors
|
|
172
|
+
const pendingLog = _req._pendingLog;
|
|
173
|
+
if (pendingLog) {
|
|
174
|
+
pendingLog.type = "error";
|
|
175
|
+
pendingLog.statusCode = 0;
|
|
176
|
+
pendingLog.details = err.message;
|
|
177
|
+
if (_req._startTime) {
|
|
178
|
+
pendingLog.durationMs = Date.now() - _req._startTime;
|
|
179
|
+
}
|
|
180
|
+
stats.addLog(pendingLog);
|
|
181
|
+
}
|
|
129
182
|
// res may be a Socket (WebSocket upgrade) — only respond on HTTP ServerResponse
|
|
130
183
|
if (res instanceof ServerResponse && !res.headersSent) {
|
|
131
184
|
// Match Anthropic's error response format so Claude Code handles it gracefully
|
|
@@ -150,8 +203,16 @@ export async function startServer(opts = {}) {
|
|
|
150
203
|
saveAccounts(pool.getAll());
|
|
151
204
|
}
|
|
152
205
|
req._ccAccount = account;
|
|
206
|
+
req._startTime = Date.now();
|
|
207
|
+
req._pendingLog = {
|
|
208
|
+
ts: Date.now(),
|
|
209
|
+
accountId: account.id,
|
|
210
|
+
model: "-",
|
|
211
|
+
type: "route",
|
|
212
|
+
method: req.method,
|
|
213
|
+
path: req.path,
|
|
214
|
+
};
|
|
153
215
|
stats.totalRequests++;
|
|
154
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "?", type: "route" });
|
|
155
216
|
logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - Date.now()) / 60_000));
|
|
156
217
|
next();
|
|
157
218
|
}, proxy);
|
package/dist/proxy/stats.js
CHANGED
package/dist/ui/Dashboard.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from "react";
|
|
3
3
|
import { Box, Text, useInput, useApp } from "ink";
|
|
4
4
|
const POLL_INTERVAL_MS = 2_000;
|
|
5
|
+
const LOG_VISIBLE = 20;
|
|
5
6
|
// ─── Dashboard component ──────────────────────────────────────────────────────
|
|
6
7
|
export function Dashboard({ port }) {
|
|
7
8
|
const { exit } = useApp();
|
|
@@ -59,9 +60,28 @@ function ErrorScreen({ error, port, retries }) {
|
|
|
59
60
|
function LiveDashboard({ data, port, lastUpdate }) {
|
|
60
61
|
const healthyCount = data.accounts.filter(a => a.healthy).length;
|
|
61
62
|
const updatedAgo = Math.round((Date.now() - lastUpdate) / 1000);
|
|
62
|
-
|
|
63
|
+
const logs = data.recentLogs;
|
|
64
|
+
// Stable selection: track by timestamp so it survives log rotations
|
|
65
|
+
const [selectedTs, setSelectedTs] = useState(null);
|
|
66
|
+
// Derive index from timestamp; default to 0 (newest)
|
|
67
|
+
const selectedIndex = selectedTs !== null
|
|
68
|
+
? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
|
|
69
|
+
: 0;
|
|
70
|
+
useInput((_input, key) => {
|
|
71
|
+
if (key.upArrow) {
|
|
72
|
+
const next = Math.max(0, selectedIndex - 1);
|
|
73
|
+
setSelectedTs(logs[next]?.ts ?? null);
|
|
74
|
+
}
|
|
75
|
+
if (key.downArrow) {
|
|
76
|
+
const next = Math.min(logs.length - 1, selectedIndex + 1);
|
|
77
|
+
setSelectedTs(logs[next]?.ts ?? null);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
const selectedLog = logs[selectedIndex] ?? null;
|
|
81
|
+
const visibleLogs = logs.slice(0, LOG_VISIBLE);
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [\u2191\u2193] navigate \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map(a => (_jsx(AccountRow, { account: a }, a.id))) })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
|
|
63
83
|
? _jsx(Text, { color: "gray", children: " No activity yet" })
|
|
64
|
-
:
|
|
84
|
+
: visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: i === selectedIndex }, log.ts))) })] }), selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
|
|
65
85
|
}
|
|
66
86
|
// ─── Account row ──────────────────────────────────────────────────────────────
|
|
67
87
|
function AccountRow({ account: a }) {
|
|
@@ -76,11 +96,57 @@ function AccountRow({ account: a }) {
|
|
|
76
96
|
return (_jsxs(Box, { children: [_jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { children: a.id.slice(0, 22).padEnd(22) }), _jsx(Text, { color: statusColor, children: statusLabel }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: "white", children: String(a.requestCount).padStart(5) }), _jsx(Text, { color: "gray", children: " err " }), _jsx(Text, { color: a.errorCount > 0 ? "red" : "gray", children: String(a.errorCount).padStart(3) }), _jsx(Text, { color: "gray", children: " expires " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(10) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) })] }));
|
|
77
97
|
}
|
|
78
98
|
// ─── Log row ──────────────────────────────────────────────────────────────────
|
|
79
|
-
function LogRow({ log }) {
|
|
99
|
+
function LogRow({ log, selected }) {
|
|
80
100
|
const time = new Date(log.ts).toLocaleTimeString("en-GB", { hour12: false });
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
101
|
+
const isError = log.type === "error";
|
|
102
|
+
const isRefresh = log.type === "refresh";
|
|
103
|
+
const typeColor = isError ? "red" : isRefresh ? "yellow" : "gray";
|
|
104
|
+
const typeIcon = isError ? "✗" : isRefresh ? "↻" : "→";
|
|
105
|
+
const statusColor = log.statusCode === undefined ? undefined
|
|
106
|
+
: log.statusCode >= 500 ? "red"
|
|
107
|
+
: log.statusCode >= 400 ? "yellow"
|
|
108
|
+
: log.statusCode >= 200 ? "green"
|
|
109
|
+
: "gray";
|
|
110
|
+
const bg = selected ? "white" : undefined;
|
|
111
|
+
const fg = (c) => selected ? "black" : c;
|
|
112
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
|
|
113
|
+
? _jsxs(Text, { backgroundColor: bg, color: fg("white"), children: [" ", log.method, " ", log.path.padEnd(14)] })
|
|
114
|
+
: _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [" ", log.type.padEnd(9)] }), log.statusCode !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg(statusColor), children: [" ", log.statusCode] })), log.durationMs !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.durationMs, "ms"] })), log.details && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.details] }))] }));
|
|
115
|
+
}
|
|
116
|
+
// ─── Detail panel ─────────────────────────────────────────────────────────────
|
|
117
|
+
function DetailPanel({ log }) {
|
|
118
|
+
const time = new Date(log.ts).toLocaleString("en-GB", {
|
|
119
|
+
hour12: false,
|
|
120
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
121
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
122
|
+
});
|
|
123
|
+
const isError = log.type === "error";
|
|
124
|
+
const statusLabel = log.statusCode === undefined ? "—"
|
|
125
|
+
: log.statusCode === 0 ? "connection error"
|
|
126
|
+
: `${log.statusCode} ${httpStatusText(log.statusCode)}`;
|
|
127
|
+
const statusColor = log.statusCode === undefined ? "gray"
|
|
128
|
+
: log.statusCode === 0 ? "red"
|
|
129
|
+
: log.statusCode >= 500 ? "red"
|
|
130
|
+
: log.statusCode >= 400 ? "yellow"
|
|
131
|
+
: "green";
|
|
132
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) }))] })] }));
|
|
133
|
+
}
|
|
134
|
+
function Field({ label, value }) {
|
|
135
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
|
|
136
|
+
}
|
|
137
|
+
function FieldColored({ label, value, color }) {
|
|
138
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: color, children: value })] }));
|
|
139
|
+
}
|
|
140
|
+
// ─── HTTP status text ─────────────────────────────────────────────────────────
|
|
141
|
+
function httpStatusText(code) {
|
|
142
|
+
const map = {
|
|
143
|
+
200: "OK", 201: "Created", 204: "No Content",
|
|
144
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
145
|
+
404: "Not Found", 429: "Too Many Requests",
|
|
146
|
+
500: "Internal Server Error", 502: "Bad Gateway",
|
|
147
|
+
503: "Service Unavailable", 529: "Overloaded",
|
|
148
|
+
};
|
|
149
|
+
return map[code] ?? "";
|
|
84
150
|
}
|
|
85
151
|
// ─── Formatters ───────────────────────────────────────────────────────────────
|
|
86
152
|
function formatUptime(seconds) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
2
|
import { dirname } from "path";
|
|
3
3
|
import { CLAUDE_SETTINGS_PATH } from "../config/paths.js";
|
|
4
|
+
import { readConfig } from "../config/manager.js";
|
|
4
5
|
/**
|
|
5
6
|
* Write ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN into ~/.claude/settings.json.
|
|
6
7
|
*
|
|
@@ -36,8 +37,8 @@ export function writeClaudeSettings(port, baseUrl) {
|
|
|
36
37
|
...existingEnv,
|
|
37
38
|
ANTHROPIC_BASE_URL: resolvedUrl,
|
|
38
39
|
// ANTHROPIC_AUTH_TOKEN has higher precedence than ANTHROPIC_API_KEY in Claude Code.
|
|
39
|
-
//
|
|
40
|
-
ANTHROPIC_AUTH_TOKEN: "proxy-managed",
|
|
40
|
+
// Uses the configured proxy secret if set, otherwise falls back to the open placeholder.
|
|
41
|
+
ANTHROPIC_AUTH_TOKEN: readConfig().proxySecret ?? "proxy-managed",
|
|
41
42
|
},
|
|
42
43
|
};
|
|
43
44
|
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(updated, null, 2), "utf-8");
|