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.
@@ -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
  }
@@ -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
- writeClaudeSettings(port, proxyHost);
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`));
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs";
2
- import { CONFIG_DIR, ACCOUNTS_PATH } from "./paths.js";
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,
@@ -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");
@@ -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);
@@ -1,4 +1,4 @@
1
- const MAX_LOG_ENTRIES = 50;
1
+ const MAX_LOG_ENTRIES = 100;
2
2
  class ProxyStats {
3
3
  totalRequests = 0;
4
4
  totalErrors = 0;
@@ -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
- 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 [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: data.recentLogs.length === 0
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
- : data.recentLogs.slice(0, 10).map((log, i) => (_jsx(LogRow, { log: log }, i))) })] })] }));
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 typeColor = log.type === "error" ? "red" : log.type === "refresh" ? "yellow" : "gray";
82
- const typeIcon = log.type === "error" ? "✗" : log.type === "refresh" ? "↻" : "→";
83
- return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [" ", time, " "] }), _jsxs(Text, { color: typeColor, children: [typeIcon, " "] }), _jsx(Text, { color: "cyan", children: log.accountId.slice(0, 22).padEnd(22) }), _jsxs(Text, { color: typeColor, children: [" ", log.type] }), log.details && _jsxs(Text, { color: "gray", children: [" ", log.details] })] }));
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
- // The proxy replaces this placeholder with the real OAuth token per request.
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Round-robin proxy for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {