cc-api-statusline 0.2.1 → 1.0.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.
@@ -4,7 +4,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
  // package.json
5
5
  var package_default = {
6
6
  name: "cc-api-statusline",
7
- version: "0.2.1",
7
+ version: "1.0.0",
8
8
  description: "Claude Code statusline tool that polls API usage from third-party proxy backends",
9
9
  type: "module",
10
10
  bin: {
@@ -68,6 +68,7 @@ function parseArgs() {
68
68
  let once = false;
69
69
  let install = false;
70
70
  let uninstall = false;
71
+ let applyConfig = false;
71
72
  let force = false;
72
73
  let configPath;
73
74
  let runner;
@@ -83,6 +84,8 @@ function parseArgs() {
83
84
  install = true;
84
85
  } else if (arg === "--uninstall") {
85
86
  uninstall = true;
87
+ } else if (arg === "--apply-config") {
88
+ applyConfig = true;
86
89
  } else if (arg === "--force") {
87
90
  force = true;
88
91
  } else if (arg === "--config" && i + 1 < args.length) {
@@ -96,7 +99,7 @@ function parseArgs() {
96
99
  i++;
97
100
  }
98
101
  }
99
- return { help, version, once, install, uninstall, force, configPath, runner };
102
+ return { help, version, once, install, uninstall, applyConfig, force, configPath, runner };
100
103
  }
101
104
  function showHelp() {
102
105
  console.log(`
@@ -110,8 +113,9 @@ Options:
110
113
  --version, -v Show version
111
114
  --once Fetch once and exit (no polling)
112
115
  --config <path> Use custom config file
113
- --install Register as Claude Code statusline widget
116
+ --install Register as Claude Code statusline widget and create default configs
114
117
  --uninstall Remove statusline widget registration
118
+ --apply-config Apply endpoint config changes (updates lock file, clears caches)
115
119
  --runner <runner> Package runner: npx or bunx (default: auto-detect)
116
120
  --force Force overwrite existing statusline configuration
117
121
 
@@ -134,13 +138,13 @@ function showVersion() {
134
138
  console.log(`cc-api-statusline v${package_default.version}`);
135
139
  }
136
140
  // src/services/settings.ts
137
- import { readFileSync as readFileSync2, existsSync as existsSync4 } from "fs";
141
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
138
142
  import { execSync } from "child_process";
139
143
 
140
144
  // src/services/env.ts
141
- import { readFileSync, existsSync } from "fs";
142
- import { join } from "path";
143
- import { homedir } from "os";
145
+ import { readFileSync } from "fs";
146
+ import { join as join4 } from "path";
147
+ import { homedir as homedir2 } from "os";
144
148
 
145
149
  // src/services/hash.ts
146
150
  function sha256(input) {
@@ -157,21 +161,193 @@ function shortHash(input, length = 12) {
157
161
  return fullHash.slice(0, length);
158
162
  }
159
163
 
164
+ // src/services/logger.ts
165
+ import { appendFileSync } from "fs";
166
+ import { join as join3, dirname as dirname2 } from "path";
167
+
168
+ // src/services/ensure-dir.ts
169
+ import { mkdirSync } from "fs";
170
+ function ensureDir(dirPath) {
171
+ mkdirSync(dirPath, { recursive: true, mode: 448 });
172
+ }
173
+
174
+ // src/services/log-rotator.ts
175
+ import { statSync, renameSync, readdirSync, unlinkSync } from "fs";
176
+ import { spawn } from "child_process";
177
+ import { dirname, join } from "path";
178
+
179
+ // src/core/constants.ts
180
+ var DEFAULT_FETCH_TIMEOUT_MS = 5000;
181
+ var EXIT_BUFFER_MS = 50;
182
+ var STALENESS_THRESHOLD_MINUTES = 5;
183
+ var VERY_STALE_THRESHOLD_MINUTES = 30;
184
+ var GC_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
185
+ var GC_MAX_CACHE_FILES = 20;
186
+ var GC_ORPHAN_TMP_AGE_MS = 60 * 60 * 1000;
187
+ var LOG_ROTATION_PROBABILITY = 0.05;
188
+ var LOG_MAX_SIZE_BYTES = 512 * 1024;
189
+ var LOG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
190
+ var LOG_RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
191
+
192
+ // src/services/log-rotator.ts
193
+ var ARCHIVE_LOG_RE = /^debug\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}\.log$/;
194
+ var ARCHIVE_GZ_RE = /^debug\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}\.log\.gz$/;
195
+ function archiveName(logPath, now = new Date) {
196
+ const pad = (n) => n.toString().padStart(2, "0");
197
+ const y = now.getFullYear();
198
+ const mo = pad(now.getMonth() + 1);
199
+ const d = pad(now.getDate());
200
+ const h = pad(now.getHours());
201
+ const min = pad(now.getMinutes());
202
+ return join(dirname(logPath), `debug.${y}-${mo}-${d}T${h}-${min}.log`);
203
+ }
204
+ function spawnGzip(filePath) {
205
+ try {
206
+ const child = spawn("gzip", ["-f", filePath], {
207
+ detached: true,
208
+ stdio: "ignore"
209
+ });
210
+ child.unref();
211
+ } catch {}
212
+ }
213
+ function runCleanup(logDir, excludePath) {
214
+ try {
215
+ const files = readdirSync(logDir);
216
+ const now = Date.now();
217
+ for (const name of files) {
218
+ const filePath = join(logDir, name);
219
+ if (filePath === excludePath)
220
+ continue;
221
+ if (ARCHIVE_LOG_RE.test(name)) {
222
+ const s = statSync(filePath, { throwIfNoEntry: false });
223
+ if (s && now - s.mtimeMs >= LOG_MAX_AGE_MS) {
224
+ spawnGzip(filePath);
225
+ }
226
+ continue;
227
+ }
228
+ if (ARCHIVE_GZ_RE.test(name)) {
229
+ const s = statSync(filePath, { throwIfNoEntry: false });
230
+ if (s && now - s.mtimeMs >= LOG_RETENTION_MS) {
231
+ try {
232
+ unlinkSync(filePath);
233
+ } catch {}
234
+ }
235
+ }
236
+ }
237
+ } catch {}
238
+ }
239
+ function maybeRotateLogs(logPath) {
240
+ if (Math.random() > LOG_ROTATION_PROBABILITY)
241
+ return;
242
+ const logDir = dirname(logPath);
243
+ const stat = statSync(logPath, { throwIfNoEntry: false });
244
+ let rotatedArchive = null;
245
+ if (stat) {
246
+ const age = Date.now() - stat.mtimeMs;
247
+ const archive = archiveName(logPath);
248
+ try {
249
+ if (age >= LOG_MAX_AGE_MS) {
250
+ renameSync(logPath, archive);
251
+ spawnGzip(archive);
252
+ rotatedArchive = archive;
253
+ } else if (stat.size >= LOG_MAX_SIZE_BYTES) {
254
+ renameSync(logPath, archive);
255
+ rotatedArchive = archive;
256
+ }
257
+ } catch {}
258
+ }
259
+ runCleanup(logDir, rotatedArchive);
260
+ }
261
+
262
+ // src/services/paths.ts
263
+ import { homedir } from "node:os";
264
+ import { join as join2 } from "node:path";
265
+ function getConfigDir() {
266
+ return join2(homedir(), ".claude", "cc-api-statusline");
267
+ }
268
+
269
+ // src/services/logger.ts
270
+ class Logger {
271
+ enabled;
272
+ logPath;
273
+ constructor() {
274
+ this.enabled = !!(process.env["DEBUG"] || process.env["CC_STATUSLINE_DEBUG"]);
275
+ const logDir = process.env["CC_API_STATUSLINE_LOG_DIR"] || getConfigDir();
276
+ this.logPath = join3(logDir, "debug.log");
277
+ if (this.enabled) {
278
+ this.ensureLogDir();
279
+ maybeRotateLogs(this.logPath);
280
+ }
281
+ }
282
+ ensureLogDir() {
283
+ try {
284
+ const dir = dirname2(this.logPath);
285
+ ensureDir(dir);
286
+ } catch {
287
+ this.enabled = false;
288
+ }
289
+ }
290
+ formatLocalTimestamp() {
291
+ const d = new Date;
292
+ const pad = (n, len = 2) => n.toString().padStart(len, "0");
293
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
294
+ }
295
+ format(level, message, data) {
296
+ const timestamp = this.formatLocalTimestamp();
297
+ const dataStr = data ? ` ${JSON.stringify(data)}` : "";
298
+ return `[${timestamp}] [${level.toUpperCase()}] ${message}${dataStr}
299
+ `;
300
+ }
301
+ write(level, message, data) {
302
+ if (!this.enabled) {
303
+ return;
304
+ }
305
+ try {
306
+ const entry = this.format(level, message, data);
307
+ appendFileSync(this.logPath, entry, { encoding: "utf-8" });
308
+ } catch {}
309
+ }
310
+ debug(message, data) {
311
+ this.write("debug", message, data);
312
+ }
313
+ info(message, data) {
314
+ this.write("info", message, data);
315
+ }
316
+ warn(message, data) {
317
+ this.write("warn", message, data);
318
+ }
319
+ error(message, data) {
320
+ this.write("error", message, data);
321
+ }
322
+ isEnabled() {
323
+ return this.enabled;
324
+ }
325
+ getLogPath() {
326
+ return this.logPath;
327
+ }
328
+ }
329
+ var logger = new Logger;
330
+
160
331
  // src/services/env.ts
161
332
  function getSettingsJsonPath() {
162
333
  const configDir = process.env["CLAUDE_CONFIG_DIR"];
163
334
  if (configDir) {
164
- return join(configDir, "settings.json");
335
+ return join4(configDir, "settings.json");
165
336
  }
166
- return join(homedir(), ".claude", "settings.json");
337
+ return join4(homedir2(), ".claude", "settings.json");
167
338
  }
168
339
  function readSettingsJsonEnv() {
169
340
  const settingsPath = getSettingsJsonPath();
170
- if (!existsSync(settingsPath)) {
171
- return {};
341
+ let content;
342
+ try {
343
+ content = readFileSync(settingsPath, "utf-8");
344
+ } catch (err) {
345
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
346
+ return {};
347
+ }
348
+ throw err;
172
349
  }
173
350
  try {
174
- const content = readFileSync(settingsPath, "utf-8");
175
351
  const settings = JSON.parse(content);
176
352
  if (settings["env"] && typeof settings["env"] === "object") {
177
353
  const env = settings["env"];
@@ -186,8 +362,8 @@ function readSettingsJsonEnv() {
186
362
  return result;
187
363
  }
188
364
  return {};
189
- } catch (error) {
190
- console.warn(`Warning: Could not read settings.json: ${error}`);
365
+ } catch (err) {
366
+ logger.warn(`Could not read settings.json: ${err}`);
191
367
  return {};
192
368
  }
193
369
  }
@@ -227,25 +403,15 @@ function validateRequiredEnv(env) {
227
403
  }
228
404
 
229
405
  // src/services/atomic-write.ts
230
- import { writeFileSync, renameSync, unlinkSync, existsSync as existsSync3, chmodSync } from "fs";
231
- import { dirname } from "path";
232
-
233
- // src/services/ensure-dir.ts
234
- import { mkdirSync, existsSync as existsSync2 } from "fs";
235
- function ensureDir(dirPath) {
236
- if (!existsSync2(dirPath)) {
237
- mkdirSync(dirPath, { recursive: true, mode: 448 });
238
- }
239
- }
240
-
241
- // src/services/atomic-write.ts
406
+ import { writeFileSync, renameSync as renameSync2, unlinkSync as unlinkSync2, existsSync, chmodSync } from "fs";
407
+ import { dirname as dirname3 } from "path";
242
408
  function atomicWriteFile(filePath, content, opts = {}) {
243
409
  const { mode = 384, ensureParentDir: ensureParent = false, appendNewline = false } = opts;
244
410
  const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
245
411
  const tmpPath = `${filePath}.${nonce}.tmp`;
246
412
  try {
247
413
  if (ensureParent) {
248
- const dir = dirname(filePath);
414
+ const dir = dirname3(filePath);
249
415
  ensureDir(dir);
250
416
  }
251
417
  const finalContent = appendNewline ? `${content}
@@ -254,11 +420,11 @@ function atomicWriteFile(filePath, content, opts = {}) {
254
420
  try {
255
421
  chmodSync(tmpPath, mode);
256
422
  } catch {}
257
- renameSync(tmpPath, filePath);
423
+ renameSync2(tmpPath, filePath);
258
424
  } catch (error) {
259
425
  try {
260
- if (existsSync3(tmpPath)) {
261
- unlinkSync(tmpPath);
426
+ if (existsSync(tmpPath)) {
427
+ unlinkSync2(tmpPath);
262
428
  }
263
429
  } catch {}
264
430
  throw new Error(`Failed to write file atomically: ${error}`);
@@ -268,14 +434,14 @@ function atomicWriteFile(filePath, content, opts = {}) {
268
434
  // src/services/settings.ts
269
435
  function loadClaudeSettings() {
270
436
  const path = getSettingsJsonPath();
271
- if (!existsSync4(path)) {
437
+ if (!existsSync2(path)) {
272
438
  return {};
273
439
  }
274
440
  try {
275
441
  const content = readFileSync2(path, "utf-8");
276
442
  return JSON.parse(content);
277
443
  } catch (error) {
278
- console.warn(`Failed to read settings from ${path}: ${error}`);
444
+ logger.warn(`Failed to read settings from ${path}: ${error}`);
279
445
  return {};
280
446
  }
281
447
  }
@@ -321,38 +487,9 @@ function uninstallStatusLine() {
321
487
  }
322
488
  }
323
489
 
324
- // src/cli/commands.ts
325
- function handleInstall(args) {
326
- const existing = getExistingStatusLine();
327
- if (existing && !args.force) {
328
- console.error("Error: statusLine is already configured in settings.json");
329
- console.error(`Current command: ${existing}`);
330
- console.error("Use --force to overwrite, or --uninstall to remove first.");
331
- process.exit(1);
332
- }
333
- const runner = args.runner ?? (isBunxAvailable() ? "bunx" : "npx");
334
- installStatusLine(runner);
335
- console.log("✓ Statusline installed successfully!");
336
- console.log(` Runner: ${runner}`);
337
- console.log(` Command: ${runner} -y cc-api-statusline@latest`);
338
- console.log(` Config: ~/.claude/settings.json`);
339
- process.exit(0);
340
- }
341
- function handleUninstall() {
342
- const existing = getExistingStatusLine();
343
- if (!existing) {
344
- console.log("No statusLine configuration found in settings.json");
345
- process.exit(0);
346
- }
347
- uninstallStatusLine();
348
- console.log("✓ Statusline uninstalled successfully");
349
- console.log(" Removed statusLine from ~/.claude/settings.json");
350
- process.exit(0);
351
- }
352
- // src/services/cache.ts
353
- import { readFileSync as readFileSync3, existsSync as existsSync5, unlinkSync as unlinkSync2 } from "fs";
354
- import { join as join2 } from "path";
355
- import { homedir as homedir2 } from "os";
490
+ // src/services/config-defaults.ts
491
+ import { join as join8 } from "path";
492
+ import { existsSync as existsSync4 } from "fs";
356
493
 
357
494
  // src/types/normalized-usage.ts
358
495
  function createEmptyNormalizedUsage(provider, billingMode, planName) {
@@ -385,6 +522,14 @@ function computeSoonestReset(usage) {
385
522
  return sorted[0] ?? null;
386
523
  }
387
524
  // src/types/config.ts
525
+ var DEFAULT_DIVIDER_CONFIG = { text: "|", margin: 1, color: "#555753" };
526
+ var DEFAULT_TIER_THRESHOLDS = [37.5, 62.5, 75, 87.5, 100];
527
+ function buildTiers(colors, thresholds = DEFAULT_TIER_THRESHOLDS) {
528
+ if (colors.length !== thresholds.length) {
529
+ throw new Error(`buildTiers: colors.length (${colors.length}) must equal thresholds.length (${thresholds.length})`);
530
+ }
531
+ return colors.map((color, i) => ({ color, maxPercent: thresholds[i] }));
532
+ }
388
533
  var DEFAULT_CONFIG = {
389
534
  display: {
390
535
  layout: "standard",
@@ -392,7 +537,7 @@ var DEFAULT_CONFIG = {
392
537
  progressStyle: "icon",
393
538
  barSize: "medium",
394
539
  barStyle: "block",
395
- separator: " | ",
540
+ divider: DEFAULT_DIVIDER_CONFIG,
396
541
  maxWidth: 100,
397
542
  clockFormat: "24h",
398
543
  colorMode: "auto",
@@ -405,28 +550,17 @@ var DEFAULT_CONFIG = {
405
550
  balance: true,
406
551
  tokens: false,
407
552
  rateLimit: false,
408
- plan: false,
409
- divider: true
553
+ plan: false
410
554
  },
411
555
  colors: {
412
- auto: {
413
- tiers: [
414
- { color: "cool", maxPercent: 30 },
415
- { color: "comfortable", maxPercent: 65 },
416
- { color: "warm", maxPercent: 80 },
417
- { color: "hot", maxPercent: 90 },
418
- { color: "critical", maxPercent: 100 }
419
- ]
420
- },
421
- chill: {
422
- low: "cyan",
423
- medium: "blue",
424
- high: "magenta",
425
- lowThreshold: 50,
426
- highThreshold: 80
427
- }
556
+ auto: { tiers: buildTiers(["cool", "comfortable", "warm", "hot", "critical"]) },
557
+ vibrant: { tiers: buildTiers(["#00D9FF", "#4ADE80", "#FDE047", "#FB923C", "#F87171"]) },
558
+ pastel: { tiers: buildTiers(["pastel-cool", "pastel-comfortable", "pastel-medium", "pastel-warm", "pastel-hot"]) },
559
+ bright: { tiers: buildTiers(["bright-cool", "bright-comfortable", "bright-medium", "bright-warm", "bright-hot"]) },
560
+ ocean: { tiers: buildTiers(["ocean-cool", "ocean-comfortable", "ocean-medium", "ocean-warm", "ocean-hot"]) },
561
+ neutral: { tiers: buildTiers(["neutral-cool", "neutral-comfortable", "neutral-warm", "neutral-hot", "neutral-critical"]) },
562
+ chill: { tiers: buildTiers(["cyan", "cyan", "blue", "blue", "magenta"]) }
428
563
  },
429
- customProviders: {},
430
564
  pollIntervalSeconds: 30,
431
565
  pipedRequestTimeoutMs: 800
432
566
  };
@@ -493,12 +627,12 @@ var DEFAULT_COMPONENT_ORDER = [
493
627
  "plan"
494
628
  ];
495
629
  // src/types/cache.ts
496
- var CACHE_VERSION = 1;
630
+ var CACHE_VERSION = 2;
497
631
  function isCacheEntry(value) {
498
632
  if (typeof value !== "object" || value === null)
499
633
  return false;
500
634
  const c = value;
501
- return typeof c["version"] === "number" && typeof c["provider"] === "string" && typeof c["baseUrl"] === "string" && typeof c["tokenHash"] === "string" && typeof c["configHash"] === "string" && typeof c["data"] === "object" && c["data"] !== null && typeof c["renderedLine"] === "string" && typeof c["fetchedAt"] === "string" && typeof c["ttlSeconds"] === "number" && (c["errorState"] === null || typeof c["errorState"] === "object");
635
+ return typeof c["version"] === "number" && typeof c["provider"] === "string" && typeof c["baseUrl"] === "string" && typeof c["tokenHash"] === "string" && typeof c["configHash"] === "string" && typeof c["endpointConfigHash"] === "string" && typeof c["data"] === "object" && c["data"] !== null && typeof c["renderedLine"] === "string" && typeof c["fetchedAt"] === "string" && typeof c["ttlSeconds"] === "number" && (c["errorState"] === null || typeof c["errorState"] === "object");
502
636
  }
503
637
  var PROVIDER_DETECTION_TTL_SECONDS = 86400;
504
638
  function isProviderDetectionCacheEntry(value) {
@@ -507,150 +641,260 @@ function isProviderDetectionCacheEntry(value) {
507
641
  const c = value;
508
642
  return typeof c["baseUrl"] === "string" && typeof c["provider"] === "string" && (c["detectedVia"] === "health-probe" || c["detectedVia"] === "url-pattern" || c["detectedVia"] === "override") && typeof c["detectedAt"] === "string" && typeof c["ttlSeconds"] === "number";
509
643
  }
510
- // src/services/cache.ts
511
- function getCacheDir() {
512
- const override = process.env["CC_API_STATUSLINE_CACHE_DIR"];
513
- if (override) {
514
- return override;
644
+ // src/services/endpoint-config.ts
645
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
646
+ import { join as join5 } from "path";
647
+ function getEndpointConfigDir(customRoot) {
648
+ const envRoot = process.env["CC_API_STATUSLINE_CONFIG_DIR"];
649
+ const root = customRoot || envRoot || getConfigDir();
650
+ return join5(root, "api-config");
651
+ }
652
+ function loadEndpointConfigs(customDir) {
653
+ const configDir = getEndpointConfigDir(customDir);
654
+ if (!existsSync3(configDir)) {
655
+ return getBuiltInEndpointConfigs();
656
+ }
657
+ const registry = {};
658
+ const files = readdirSync2(configDir).filter((f) => f.endsWith(".json"));
659
+ if (files.length === 0) {
660
+ return getBuiltInEndpointConfigs();
661
+ }
662
+ for (const file of files) {
663
+ const filePath = join5(configDir, file);
664
+ try {
665
+ const config = loadEndpointConfigFile(filePath);
666
+ registry[config.provider] = config;
667
+ } catch (error) {
668
+ logger.error(`Failed to load endpoint config ${file}`, { error: String(error) });
669
+ }
515
670
  }
516
- return join2(homedir2(), ".claude", "cc-api-statusline");
517
- }
518
- function ensureCacheDir() {
519
- const dir = getCacheDir();
520
- ensureDir(dir);
671
+ if (Object.keys(registry).length === 0) {
672
+ return getBuiltInEndpointConfigs();
673
+ }
674
+ return registry;
521
675
  }
522
- function getCachePath(baseUrl) {
523
- const hash = shortHash(baseUrl, 12);
524
- return join2(getCacheDir(), `cache-${hash}.json`);
676
+ function loadEndpointConfigFile(filePath) {
677
+ const content = readFileSync3(filePath, "utf-8");
678
+ const data = JSON.parse(content);
679
+ validateEndpointConfig(data, filePath);
680
+ return data;
525
681
  }
526
- function readCache(baseUrl) {
527
- const path = getCachePath(baseUrl);
528
- if (!existsSync5(path)) {
529
- return null;
682
+ function validateEndpointConfig(data, filename) {
683
+ if (typeof data !== "object" || data === null) {
684
+ throw new Error(`${filename}: Config must be an object`);
530
685
  }
531
- try {
532
- const content = readFileSync3(path, "utf-8");
533
- const data = JSON.parse(content);
534
- if (!isCacheEntry(data)) {
535
- console.warn(`Invalid cache structure at ${path}`);
536
- return null;
537
- }
538
- return data;
539
- } catch (error) {
540
- console.warn(`Failed to read cache from ${path}: ${error}`);
541
- return null;
686
+ const config = data;
687
+ if (typeof config.provider !== "string" || !config.provider) {
688
+ throw new Error(`${filename}: Missing or invalid 'provider' field`);
542
689
  }
543
- }
544
- function writeCache(baseUrl, entry) {
545
- const path = getCachePath(baseUrl);
546
- try {
547
- ensureCacheDir();
548
- const content = JSON.stringify(entry, null, 2);
549
- atomicWriteFile(path, content);
550
- } catch (error) {
551
- console.warn(`Failed to write cache to ${path}: ${error}`);
690
+ if (typeof config.endpoint !== "object" || config.endpoint === null) {
691
+ throw new Error(`${filename}: Missing or invalid 'endpoint' field`);
552
692
  }
553
- }
554
- function isCacheValid(entry, currentEnv) {
555
- const fetchedAt = new Date(entry.fetchedAt).getTime();
556
- const now = Date.now();
557
- const age = now - fetchedAt;
558
- const ttlMs = entry.ttlSeconds * 1000;
559
- if (age >= ttlMs) {
560
- return false;
693
+ const endpoint = config.endpoint;
694
+ if (typeof endpoint.path !== "string" || !endpoint.path) {
695
+ throw new Error(`${filename}: Missing or invalid 'endpoint.path' field`);
561
696
  }
562
- if (entry.baseUrl !== currentEnv.baseUrl) {
563
- return false;
697
+ if (endpoint.method !== "GET" && endpoint.method !== "POST") {
698
+ throw new Error(`${filename}: Invalid 'endpoint.method' (must be GET or POST)`);
564
699
  }
565
- if (entry.version !== CACHE_VERSION) {
566
- return false;
700
+ if (typeof config.auth !== "object" || config.auth === null) {
701
+ throw new Error(`${filename}: Missing or invalid 'auth' field`);
567
702
  }
568
- if (entry.tokenHash !== currentEnv.tokenHash) {
569
- return false;
703
+ const auth = config.auth;
704
+ if (!["bearer-header", "body-key", "custom-header"].includes(auth.type)) {
705
+ throw new Error(`${filename}: Invalid 'auth.type' (must be bearer-header, body-key, or custom-header)`);
570
706
  }
571
- return true;
572
- }
573
- function isCacheProviderValid(entry, currentProvider) {
574
- return entry.provider === currentProvider;
575
- }
576
- function isCacheRenderedLineUsable(entry, currentConfigHash) {
577
- return entry.configHash === currentConfigHash;
578
- }
579
- function computeConfigHash(configPath) {
580
- if (!existsSync5(configPath)) {
581
- return sha256("").slice(0, 12);
707
+ if (typeof config.responseMapping !== "object" || config.responseMapping === null) {
708
+ throw new Error(`${filename}: Missing or invalid 'responseMapping' field`);
582
709
  }
583
- try {
584
- const bytes = readFileSync3(configPath);
585
- return shortHash(bytes.toString("utf-8"), 12);
586
- } catch (error) {
587
- console.warn(`Failed to read config for hash: ${error}`);
588
- return sha256("").slice(0, 12);
710
+ const mapping = config.responseMapping;
711
+ for (const [key, val] of Object.entries(mapping)) {
712
+ if (val !== undefined && typeof val !== "string") {
713
+ throw new Error(`${filename}: responseMapping.${key} must be a string`);
714
+ }
715
+ }
716
+ if (config.displayName !== undefined && typeof config.displayName !== "string") {
717
+ throw new Error(`${filename}: Invalid 'displayName' field (must be string)`);
718
+ }
719
+ if (config.defaults !== undefined && typeof config.defaults !== "object") {
720
+ throw new Error(`${filename}: Invalid 'defaults' field (must be object)`);
721
+ }
722
+ if (config.detection !== undefined && typeof config.detection !== "object") {
723
+ throw new Error(`${filename}: Invalid 'detection' field (must be object)`);
589
724
  }
590
725
  }
591
- var DEFAULT_POLL_INTERVAL_SECONDS = 30;
592
- function getEffectivePollInterval(config, envOverride) {
593
- if (envOverride !== null) {
594
- return Math.max(5, envOverride);
726
+ function computeEndpointConfigHash(customDir) {
727
+ const configDir = getEndpointConfigDir(customDir);
728
+ if (!existsSync3(configDir)) {
729
+ const builtIn = getBuiltInEndpointConfigs();
730
+ const serialized = JSON.stringify(builtIn, Object.keys(builtIn).sort());
731
+ return shortHash(serialized, 12);
595
732
  }
596
- const fromConfig = config.pollIntervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS;
597
- return Math.max(5, fromConfig);
733
+ const files = readdirSync2(configDir).filter((f) => f.endsWith(".json")).sort();
734
+ if (files.length === 0) {
735
+ const builtIn = getBuiltInEndpointConfigs();
736
+ const serialized = JSON.stringify(builtIn, Object.keys(builtIn).sort());
737
+ return shortHash(serialized, 12);
738
+ }
739
+ let combined = "";
740
+ for (const file of files) {
741
+ const filePath = join5(configDir, file);
742
+ try {
743
+ const content = readFileSync3(filePath, "utf-8");
744
+ combined += `\x00${file}\x00${content}`;
745
+ } catch {
746
+ continue;
747
+ }
748
+ }
749
+ return shortHash(combined, 12);
598
750
  }
599
- function getProviderDetectionCachePath(baseUrl) {
600
- const hash = shortHash(baseUrl, 12);
601
- return join2(getCacheDir(), `provider-detect-${hash}.json`);
751
+ function getBuiltInEndpointConfigs() {
752
+ return {
753
+ sub2api: {
754
+ provider: "sub2api",
755
+ displayName: "sub2api",
756
+ endpoint: {
757
+ path: "/v1/usage",
758
+ method: "GET"
759
+ },
760
+ auth: {
761
+ type: "bearer-header"
762
+ },
763
+ defaults: {
764
+ unit: "USD",
765
+ planName: "Unknown"
766
+ },
767
+ detection: {
768
+ healthMatch: { status: "ok" }
769
+ },
770
+ responseMapping: {
771
+ billingMode: "subscription",
772
+ planName: "$.planName",
773
+ "balance.remaining": "$.remaining",
774
+ "balance.unit": "$.unit",
775
+ "daily.used": "$.subscription.daily_usage_usd",
776
+ "daily.limit": "$.subscription.daily_limit_usd",
777
+ "weekly.used": "$.subscription.weekly_usage_usd",
778
+ "weekly.limit": "$.subscription.weekly_limit_usd",
779
+ "monthly.used": "$.subscription.monthly_usage_usd",
780
+ "monthly.limit": "$.subscription.monthly_limit_usd",
781
+ "tokenStats.today.requests": "$.usage.today.requests",
782
+ "tokenStats.today.inputTokens": "$.usage.today.input_tokens",
783
+ "tokenStats.today.outputTokens": "$.usage.today.output_tokens",
784
+ "tokenStats.today.cacheCreationTokens": "$.usage.today.cache_creation_tokens",
785
+ "tokenStats.today.cacheReadTokens": "$.usage.today.cache_read_tokens",
786
+ "tokenStats.today.totalTokens": "$.usage.today.total_tokens",
787
+ "tokenStats.today.cost": "$.usage.today.cost",
788
+ "tokenStats.total.requests": "$.usage.total.requests",
789
+ "tokenStats.total.inputTokens": "$.usage.total.input_tokens",
790
+ "tokenStats.total.outputTokens": "$.usage.total.output_tokens",
791
+ "tokenStats.total.totalTokens": "$.usage.total.total_tokens",
792
+ "tokenStats.total.cost": "$.usage.total.cost",
793
+ "tokenStats.rpm": "$.usage.rpm",
794
+ "tokenStats.tpm": "$.usage.tpm"
795
+ }
796
+ },
797
+ "claude-relay-service": {
798
+ provider: "claude-relay-service",
799
+ displayName: "CRS",
800
+ endpoint: {
801
+ path: "/apiStats/api/user-stats",
802
+ method: "POST",
803
+ contentType: "application/json"
804
+ },
805
+ auth: {
806
+ type: "body-key",
807
+ bodyField: "apiKey"
808
+ },
809
+ defaults: {
810
+ billingMode: "subscription",
811
+ planName: "API Key",
812
+ resetSemantics: "rolling-window"
813
+ },
814
+ detection: {
815
+ urlPatterns: ["/apistats", "/api/user-stats"],
816
+ healthMatch: { service: "*" }
817
+ },
818
+ responseMapping: {
819
+ billingMode: "subscription",
820
+ planName: "$.data.name",
821
+ "daily.used": "$.data.limits.currentDailyCost",
822
+ "daily.limit": "$.data.limits.dailyCostLimit",
823
+ "weekly.used": "$.data.limits.weeklyOpusCost",
824
+ "weekly.limit": "$.data.limits.weeklyOpusCostLimit",
825
+ "monthly.used": "$.data.limits.currentTotalCost",
826
+ "monthly.limit": "$.data.limits.totalCostLimit",
827
+ "tokenStats.total.requests": "$.data.usage.total.requests",
828
+ "tokenStats.total.inputTokens": "$.data.usage.total.inputTokens",
829
+ "tokenStats.total.outputTokens": "$.data.usage.total.outputTokens",
830
+ "tokenStats.total.cacheCreationTokens": "$.data.usage.total.cacheCreateTokens",
831
+ "tokenStats.total.cacheReadTokens": "$.data.usage.total.cacheReadTokens",
832
+ "tokenStats.total.totalTokens": "$.data.usage.total.tokens",
833
+ "tokenStats.total.cost": "$.data.usage.total.cost",
834
+ "rateLimit.windowSeconds": "$.data.limits.rateLimitWindow",
835
+ "rateLimit.requestsUsed": "$.data.limits.currentWindowRequests",
836
+ "rateLimit.requestsLimit": "$.data.limits.rateLimitRequests",
837
+ "rateLimit.costUsed": "$.data.limits.currentWindowCost",
838
+ "rateLimit.costLimit": "$.data.limits.rateLimitCost",
839
+ "rateLimit.remainingSeconds": "$.data.limits.windowRemainingSeconds"
840
+ }
841
+ }
842
+ };
602
843
  }
603
- function readProviderDetectionCache(baseUrl) {
604
- const path = getProviderDetectionCachePath(baseUrl);
605
- if (!existsSync5(path)) {
606
- return null;
844
+
845
+ // src/services/endpoint-lock.ts
846
+ import { readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "fs";
847
+ import { join as join6 } from "path";
848
+ function getLockFilePath(customDir) {
849
+ if (customDir) {
850
+ return join6(customDir, ".endpoint-config.lock");
607
851
  }
852
+ return join6(getConfigDir(), ".endpoint-config.lock");
853
+ }
854
+ function readEndpointLock(customDir) {
855
+ const lockPath = getLockFilePath(customDir);
856
+ let content;
608
857
  try {
609
- const content = readFileSync3(path, "utf-8");
610
- const data = JSON.parse(content);
611
- if (!isProviderDetectionCacheEntry(data)) {
612
- console.warn(`Invalid provider detection cache structure at ${path}`);
858
+ content = readFileSync4(lockPath, "utf-8");
859
+ } catch (err) {
860
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
613
861
  return null;
614
862
  }
615
- const detectedAt = new Date(data.detectedAt).getTime();
616
- const now = Date.now();
617
- const age = now - detectedAt;
618
- const ttlMs = data.ttlSeconds * 1000;
619
- if (age >= ttlMs) {
620
- try {
621
- unlinkSync2(path);
622
- } catch {}
623
- return null;
863
+ throw err;
864
+ }
865
+ try {
866
+ const data = JSON.parse(content);
867
+ if (typeof data === "object" && data !== null && "hash" in data && "lockedAt" in data && typeof data.hash === "string" && typeof data.lockedAt === "string") {
868
+ return {
869
+ hash: data.hash,
870
+ lockedAt: data.lockedAt
871
+ };
624
872
  }
625
- return data;
626
- } catch (error) {
627
- console.warn(`Failed to read provider detection cache from ${path}: ${error}`);
873
+ return null;
874
+ } catch {
628
875
  return null;
629
876
  }
630
877
  }
631
- function writeProviderDetectionCache(baseUrl, entry) {
632
- const path = getProviderDetectionCachePath(baseUrl);
633
- try {
634
- ensureCacheDir();
635
- const content = JSON.stringify(entry, null, 2);
636
- atomicWriteFile(path, content);
637
- } catch (error) {
638
- console.warn(`Failed to write provider detection cache to ${path}: ${error}`);
639
- }
878
+ function writeEndpointLock(hash, customDir) {
879
+ const lockPath = getLockFilePath(customDir);
880
+ const entry = {
881
+ hash,
882
+ lockedAt: new Date().toISOString()
883
+ };
884
+ atomicWriteFile(lockPath, JSON.stringify(entry, null, 2), {
885
+ ensureParentDir: true,
886
+ appendNewline: true
887
+ });
640
888
  }
641
889
 
642
890
  // src/services/config.ts
643
- import { readFileSync as readFileSync4, existsSync as existsSync6 } from "fs";
644
- import { join as join3 } from "path";
645
- import { homedir as homedir3 } from "os";
646
- function getConfigDir() {
647
- return join3(homedir3(), ".claude", "cc-api-statusline");
648
- }
891
+ import { readFileSync as readFileSync5 } from "fs";
892
+ import { join as join7 } from "path";
649
893
  function getConfigPath(customPath) {
650
894
  if (customPath) {
651
895
  return customPath;
652
896
  }
653
- return join3(getConfigDir(), "config.json");
897
+ return join7(getConfigDir(), "config.json");
654
898
  }
655
899
  function deepMerge(target, source) {
656
900
  const result = { ...target };
@@ -670,19 +914,19 @@ function validateConfig(config) {
670
914
  let pollIntervalSeconds = config.pollIntervalSeconds;
671
915
  let pipedRequestTimeoutMs = config.pipedRequestTimeoutMs;
672
916
  if (maxWidth < 20) {
673
- console.warn("Warning: display.maxWidth < 20, clamping to 20");
917
+ logger.warn("display.maxWidth < 20, clamping to 20");
674
918
  maxWidth = 20;
675
919
  }
676
920
  if (maxWidth > 100) {
677
- console.warn("Warning: display.maxWidth > 100, clamping to 100");
921
+ logger.warn("display.maxWidth > 100, clamping to 100");
678
922
  maxWidth = 100;
679
923
  }
680
924
  if (pollIntervalSeconds !== undefined && pollIntervalSeconds < 5) {
681
- console.warn("Warning: pollIntervalSeconds < 5, clamping to 5");
925
+ logger.warn("pollIntervalSeconds < 5, clamping to 5");
682
926
  pollIntervalSeconds = 5;
683
927
  }
684
928
  if (pipedRequestTimeoutMs !== undefined && pipedRequestTimeoutMs < 100) {
685
- console.warn("Warning: pipedRequestTimeoutMs < 100, clamping to 100");
929
+ logger.warn("pipedRequestTimeoutMs < 100, clamping to 100");
686
930
  pipedRequestTimeoutMs = 100;
687
931
  }
688
932
  return {
@@ -695,22 +939,91 @@ function validateConfig(config) {
695
939
  pipedRequestTimeoutMs
696
940
  };
697
941
  }
698
- function loadConfig(configPath) {
699
- const path = getConfigPath(configPath);
700
- if (!existsSync6(path)) {
701
- return DEFAULT_CONFIG;
702
- }
942
+ function parseConfigContent(content, path) {
703
943
  try {
704
- const content = readFileSync4(path, "utf-8");
705
944
  const userConfig = JSON.parse(content);
706
945
  const merged = deepMerge(DEFAULT_CONFIG, userConfig);
707
946
  return validateConfig(merged);
708
- } catch (error) {
709
- console.warn(`Warning: Could not load config from ${path}: ${error}`);
710
- console.warn("Using default configuration");
947
+ } catch (err) {
948
+ logger.warn(`Could not load config from ${path}: ${err}`);
949
+ logger.warn("Using default configuration");
711
950
  return DEFAULT_CONFIG;
712
951
  }
713
952
  }
953
+ function loadConfigWithHash(configPath) {
954
+ const path = getConfigPath(configPath);
955
+ let content;
956
+ try {
957
+ content = readFileSync5(path, "utf-8");
958
+ } catch (err) {
959
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
960
+ return { config: DEFAULT_CONFIG, configHash: shortHash("", 12) };
961
+ }
962
+ throw err;
963
+ }
964
+ return {
965
+ config: parseConfigContent(content, path),
966
+ configHash: shortHash(content, 12)
967
+ };
968
+ }
969
+ function serializableConfig(config) {
970
+ const { colors: _colors, ...rest } = config;
971
+ return rest;
972
+ }
973
+
974
+ // src/services/config-defaults.ts
975
+ function getDefaultStyleConfig() {
976
+ return DEFAULT_CONFIG;
977
+ }
978
+ function getDefaultSub2apiConfig() {
979
+ const configs = getBuiltInEndpointConfigs();
980
+ const config = configs["sub2api"];
981
+ if (!config)
982
+ throw new Error("Built-in sub2api config not found");
983
+ return config;
984
+ }
985
+ function getDefaultCrsConfig() {
986
+ const configs = getBuiltInEndpointConfigs();
987
+ const config = configs["claude-relay-service"];
988
+ if (!config)
989
+ throw new Error("Built-in claude-relay-service config not found");
990
+ return config;
991
+ }
992
+ function writeDefaultConfigs(customDir) {
993
+ const configDir = customDir || getConfigDir();
994
+ const configPath = join8(configDir, "config.json");
995
+ const apiConfigDir = getEndpointConfigDir(customDir);
996
+ ensureDir(configDir);
997
+ ensureDir(apiConfigDir);
998
+ if (!existsSync4(configPath)) {
999
+ const styleConfigWithoutColors = serializableConfig(getDefaultStyleConfig());
1000
+ atomicWriteFile(configPath, JSON.stringify(styleConfigWithoutColors, null, 2), {
1001
+ appendNewline: true
1002
+ });
1003
+ }
1004
+ const sub2apiPath = join8(apiConfigDir, "sub2api.json");
1005
+ if (!existsSync4(sub2apiPath)) {
1006
+ const sub2apiConfig = getDefaultSub2apiConfig();
1007
+ atomicWriteFile(sub2apiPath, JSON.stringify(sub2apiConfig, null, 2), {
1008
+ appendNewline: true
1009
+ });
1010
+ }
1011
+ const crsPath = join8(apiConfigDir, "crs.json");
1012
+ if (!existsSync4(crsPath)) {
1013
+ const crsConfig = getDefaultCrsConfig();
1014
+ atomicWriteFile(crsPath, JSON.stringify(crsConfig, null, 2), {
1015
+ appendNewline: true
1016
+ });
1017
+ }
1018
+ const currentHash = computeEndpointConfigHash(customDir);
1019
+ writeEndpointLock(currentHash, customDir);
1020
+ }
1021
+ function needsConfigInit(customDir) {
1022
+ const configDir = customDir || getConfigDir();
1023
+ const configPath = join8(configDir, "config.json");
1024
+ const apiConfigDir = getEndpointConfigDir(customDir);
1025
+ return !existsSync4(configPath) || !existsSync4(apiConfigDir);
1026
+ }
714
1027
 
715
1028
  // src/providers/http.ts
716
1029
  class HttpError extends Error {
@@ -816,76 +1129,345 @@ async function secureFetch(url, options = {}, timeoutMs = 5000, userAgent) {
816
1129
  }
817
1130
  }
818
1131
 
819
- // src/services/user-agent.ts
820
- import { execSync as execSync2 } from "child_process";
821
- import { join as join5 } from "path";
822
- import { homedir as homedir5 } from "os";
1132
+ // src/providers/health-probe.ts
1133
+ function extractOrigin(baseUrl) {
1134
+ try {
1135
+ const url = new URL(baseUrl);
1136
+ return url.origin;
1137
+ } catch {
1138
+ return baseUrl;
1139
+ }
1140
+ }
1141
+ async function probeHealth(baseUrl, timeoutMs = 1500) {
1142
+ const origin = extractOrigin(baseUrl);
1143
+ const healthUrl = `${origin}/health`;
1144
+ logger.debug("Probing health endpoint", { healthUrl, timeoutMs });
1145
+ try {
1146
+ const responseText = await secureFetch(healthUrl, {
1147
+ method: "GET",
1148
+ headers: {
1149
+ Accept: "application/json"
1150
+ }
1151
+ }, timeoutMs);
1152
+ const data = JSON.parse(responseText);
1153
+ logger.debug("Health probe response", { data });
1154
+ if (typeof data["service"] === "string") {
1155
+ logger.debug("Detected provider from service field", { provider: data["service"] });
1156
+ return data["service"];
1157
+ }
1158
+ if (data["status"] === "ok") {
1159
+ logger.debug("Detected sub2api from status: ok pattern");
1160
+ return "sub2api";
1161
+ }
1162
+ logger.debug("Health probe returned unrecognized pattern", { data });
1163
+ return null;
1164
+ } catch (error) {
1165
+ logger.debug("Health probe failed", { error: String(error) });
1166
+ return null;
1167
+ }
1168
+ }
823
1169
 
824
- // src/services/logger.ts
825
- import { appendFileSync } from "fs";
826
- import { join as join4, dirname as dirname2 } from "path";
827
- import { homedir as homedir4 } from "os";
828
- class Logger {
829
- enabled;
830
- logPath;
831
- constructor() {
832
- this.enabled = !!(process.env["DEBUG"] || process.env["CC_STATUSLINE_DEBUG"]);
833
- const logDir = process.env["CC_API_STATUSLINE_LOG_DIR"] || join4(homedir4(), ".claude", "cc-api-statusline");
834
- this.logPath = join4(logDir, "debug.log");
835
- if (this.enabled) {
836
- this.ensureLogDir();
1170
+ // src/services/cache.ts
1171
+ import { readFileSync as readFileSync6, unlinkSync as unlinkSync4 } from "fs";
1172
+ import { join as join9 } from "path";
1173
+ function getCacheDir() {
1174
+ const override = process.env["CC_API_STATUSLINE_CACHE_DIR"];
1175
+ if (override) {
1176
+ return override;
1177
+ }
1178
+ return getConfigDir();
1179
+ }
1180
+ function ensureCacheDir() {
1181
+ const dir = getCacheDir();
1182
+ ensureDir(dir);
1183
+ }
1184
+ function getCachePath(baseUrl) {
1185
+ const hash = shortHash(baseUrl, 12);
1186
+ return join9(getCacheDir(), `cache-${hash}.json`);
1187
+ }
1188
+ function readCache(baseUrl) {
1189
+ const path = getCachePath(baseUrl);
1190
+ let content;
1191
+ try {
1192
+ content = readFileSync6(path, "utf-8");
1193
+ } catch (err) {
1194
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
1195
+ return null;
837
1196
  }
1197
+ throw err;
838
1198
  }
839
- ensureLogDir() {
840
- try {
841
- const dir = dirname2(this.logPath);
842
- ensureDir(dir);
843
- } catch {
844
- this.enabled = false;
1199
+ try {
1200
+ const data = JSON.parse(content);
1201
+ if (!isCacheEntry(data)) {
1202
+ logger.warn(`Invalid cache structure at ${path}`);
1203
+ return null;
845
1204
  }
1205
+ return data;
1206
+ } catch (err) {
1207
+ logger.warn(`Failed to parse cache from ${path}: ${err}`);
1208
+ return null;
846
1209
  }
847
- formatLocalTimestamp() {
848
- const d = new Date;
849
- const pad = (n, len = 2) => n.toString().padStart(len, "0");
850
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
1210
+ }
1211
+ function writeCache(baseUrl, entry) {
1212
+ const path = getCachePath(baseUrl);
1213
+ try {
1214
+ ensureCacheDir();
1215
+ const content = JSON.stringify(entry, null, 2);
1216
+ atomicWriteFile(path, content);
1217
+ } catch (error) {
1218
+ logger.warn(`Failed to write cache to ${path}: ${error}`);
851
1219
  }
852
- format(level, message, data) {
853
- const timestamp = this.formatLocalTimestamp();
854
- const dataStr = data ? ` ${JSON.stringify(data)}` : "";
855
- return `[${timestamp}] [${level.toUpperCase()}] ${message}${dataStr}
856
- `;
1220
+ }
1221
+ function isCacheValid(entry, currentEnv) {
1222
+ const fetchedAt = new Date(entry.fetchedAt).getTime();
1223
+ const now = Date.now();
1224
+ const age = now - fetchedAt;
1225
+ const ttlMs = entry.ttlSeconds * 1000;
1226
+ if (age >= ttlMs) {
1227
+ return false;
857
1228
  }
858
- write(level, message, data) {
859
- if (!this.enabled) {
860
- return;
1229
+ if (entry.baseUrl !== currentEnv.baseUrl) {
1230
+ return false;
1231
+ }
1232
+ if (entry.version !== CACHE_VERSION) {
1233
+ return false;
1234
+ }
1235
+ if (entry.tokenHash !== currentEnv.tokenHash) {
1236
+ return false;
1237
+ }
1238
+ return true;
1239
+ }
1240
+ function isCacheProviderValid(entry, currentProvider) {
1241
+ return entry.provider === currentProvider;
1242
+ }
1243
+ function isCacheRenderedLineUsable(entry, currentConfigHash) {
1244
+ return entry.configHash === currentConfigHash;
1245
+ }
1246
+ var DEFAULT_POLL_INTERVAL_SECONDS = 30;
1247
+ function getEffectivePollInterval(config, envOverride) {
1248
+ if (envOverride !== null) {
1249
+ return Math.max(5, envOverride);
1250
+ }
1251
+ const fromConfig = config.pollIntervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS;
1252
+ return Math.max(5, fromConfig);
1253
+ }
1254
+ function getProviderDetectionCachePath(baseUrl) {
1255
+ const hash = shortHash(baseUrl, 12);
1256
+ return join9(getCacheDir(), `provider-detect-${hash}.json`);
1257
+ }
1258
+ function readProviderDetectionCache(baseUrl) {
1259
+ const path = getProviderDetectionCachePath(baseUrl);
1260
+ let content;
1261
+ try {
1262
+ content = readFileSync6(path, "utf-8");
1263
+ } catch (err) {
1264
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
1265
+ return null;
861
1266
  }
862
- try {
863
- const entry = this.format(level, message, data);
864
- appendFileSync(this.logPath, entry, { encoding: "utf-8" });
865
- } catch {}
1267
+ throw err;
866
1268
  }
867
- debug(message, data) {
868
- this.write("debug", message, data);
1269
+ try {
1270
+ const data = JSON.parse(content);
1271
+ if (!isProviderDetectionCacheEntry(data)) {
1272
+ logger.warn(`Invalid provider detection cache structure at ${path}`);
1273
+ return null;
1274
+ }
1275
+ const detectedAt = new Date(data.detectedAt).getTime();
1276
+ const now = Date.now();
1277
+ const age = now - detectedAt;
1278
+ const ttlMs = data.ttlSeconds * 1000;
1279
+ if (age >= ttlMs) {
1280
+ try {
1281
+ unlinkSync4(path);
1282
+ } catch {}
1283
+ return null;
1284
+ }
1285
+ return data;
1286
+ } catch (err) {
1287
+ logger.warn(`Failed to parse provider detection cache from ${path}: ${err}`);
1288
+ return null;
869
1289
  }
870
- info(message, data) {
871
- this.write("info", message, data);
1290
+ }
1291
+ function writeProviderDetectionCache(baseUrl, entry) {
1292
+ const path = getProviderDetectionCachePath(baseUrl);
1293
+ try {
1294
+ ensureCacheDir();
1295
+ const content = JSON.stringify(entry, null, 2);
1296
+ atomicWriteFile(path, content);
1297
+ } catch (error) {
1298
+ logger.warn(`Failed to write provider detection cache to ${path}: ${error}`);
872
1299
  }
873
- warn(message, data) {
874
- this.write("warn", message, data);
1300
+ }
1301
+
1302
+ // src/providers/autodetect.ts
1303
+ var detectionCache = new Map;
1304
+ function detectProviderFromUrlPattern(baseUrl, endpointConfigs = {}, options = {}) {
1305
+ const includeBuiltInPatterns = options.includeBuiltInPatterns ?? true;
1306
+ const fallbackProvider = Object.prototype.hasOwnProperty.call(options, "fallbackProvider") ? options.fallbackProvider ?? null : "sub2api";
1307
+ const normalizedUrl = baseUrl.toLowerCase().replace(/\/$/, "");
1308
+ for (const [providerId, config] of Object.entries(endpointConfigs)) {
1309
+ const urlPatterns = config.detection?.urlPatterns;
1310
+ if (urlPatterns && urlPatterns.length > 0) {
1311
+ for (const pattern of urlPatterns) {
1312
+ const normalizedPattern = pattern.toLowerCase();
1313
+ if (normalizedUrl.includes(normalizedPattern)) {
1314
+ return providerId;
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+ if (includeBuiltInPatterns && (normalizedUrl.includes("/apistats") || normalizedUrl.includes("/api/user-stats"))) {
1320
+ return "claude-relay-service";
1321
+ }
1322
+ return fallbackProvider;
1323
+ }
1324
+ async function resolveProvider(baseUrl, providerOverride, endpointConfigs = {}, probeTimeoutMs = 1500) {
1325
+ if (providerOverride) {
1326
+ logger.debug("Provider override detected", { provider: providerOverride });
1327
+ return providerOverride;
1328
+ }
1329
+ const cached = detectionCache.get(baseUrl);
1330
+ if (cached) {
1331
+ logger.debug("Provider detection cache hit (memory)", { provider: cached.provider });
1332
+ return cached.provider;
1333
+ }
1334
+ const diskCached = readProviderDetectionCache(baseUrl);
1335
+ if (diskCached) {
1336
+ logger.debug("Provider detection cache hit (disk)", {
1337
+ provider: diskCached.provider,
1338
+ detectedVia: diskCached.detectedVia
1339
+ });
1340
+ detectionCache.set(baseUrl, {
1341
+ provider: diskCached.provider,
1342
+ detectedAt: diskCached.detectedAt
1343
+ });
1344
+ return diskCached.provider;
1345
+ }
1346
+ const endpointPatternProvider = detectProviderFromUrlPattern(baseUrl, endpointConfigs, {
1347
+ includeBuiltInPatterns: false,
1348
+ fallbackProvider: null
1349
+ });
1350
+ if (endpointPatternProvider) {
1351
+ logger.debug("Provider detected via endpoint URL pattern", { provider: endpointPatternProvider });
1352
+ cacheProviderDetection(baseUrl, endpointPatternProvider, "url-pattern");
1353
+ return endpointPatternProvider;
1354
+ }
1355
+ logger.debug("Attempting health probe", { baseUrl, timeoutMs: probeTimeoutMs });
1356
+ const probedProvider = await probeHealth(baseUrl, probeTimeoutMs);
1357
+ if (probedProvider) {
1358
+ logger.debug("Provider detected via health probe", { provider: probedProvider });
1359
+ cacheProviderDetection(baseUrl, probedProvider, "health-probe");
1360
+ return probedProvider;
1361
+ }
1362
+ const patternProvider = detectProviderFromUrlPattern(baseUrl, {});
1363
+ if (!patternProvider) {
1364
+ logger.debug("Provider URL pattern detection had no match, defaulting to sub2api");
1365
+ cacheProviderDetection(baseUrl, "sub2api", "url-pattern");
1366
+ return "sub2api";
1367
+ }
1368
+ logger.debug("Provider detected via built-in URL pattern", { provider: patternProvider });
1369
+ cacheProviderDetection(baseUrl, patternProvider, "url-pattern");
1370
+ return patternProvider;
1371
+ }
1372
+ function cacheProviderDetection(baseUrl, provider, detectedVia) {
1373
+ const now = new Date().toISOString();
1374
+ detectionCache.set(baseUrl, {
1375
+ provider,
1376
+ detectedAt: now
1377
+ });
1378
+ writeProviderDetectionCache(baseUrl, {
1379
+ baseUrl,
1380
+ provider,
1381
+ detectedVia,
1382
+ detectedAt: now,
1383
+ ttlSeconds: PROVIDER_DETECTION_TTL_SECONDS
1384
+ });
1385
+ }
1386
+ function clearDetectionCache() {
1387
+ detectionCache.clear();
1388
+ }
1389
+
1390
+ // src/cli/commands.ts
1391
+ import { existsSync as existsSync5, readdirSync as readdirSync3, unlinkSync as unlinkSync5 } from "fs";
1392
+ import { join as join10 } from "path";
1393
+ function handleInstall(args) {
1394
+ const existing = getExistingStatusLine();
1395
+ if (existing && !args.force) {
1396
+ console.error("Error: statusLine is already configured in settings.json");
1397
+ console.error(`Current command: ${existing}`);
1398
+ console.error("Use --force to overwrite, or --uninstall to remove first.");
1399
+ process.exit(1);
875
1400
  }
876
- error(message, data) {
877
- this.write("error", message, data);
1401
+ const runner = args.runner ?? (isBunxAvailable() ? "bunx" : "npx");
1402
+ console.log("Creating default configuration files...");
1403
+ writeDefaultConfigs();
1404
+ console.log("✓ Config files created:");
1405
+ console.log(" - ~/.claude/cc-api-statusline/config.json");
1406
+ console.log(" - ~/.claude/cc-api-statusline/api-config/sub2api.json");
1407
+ console.log(" - ~/.claude/cc-api-statusline/api-config/crs.json");
1408
+ console.log(" - ~/.claude/cc-api-statusline/.endpoint-config.lock");
1409
+ installStatusLine(runner);
1410
+ console.log("✓ Statusline installed successfully!");
1411
+ console.log(` Runner: ${runner}`);
1412
+ console.log(` Command: ${runner} -y cc-api-statusline@latest`);
1413
+ console.log(` Config: ~/.claude/settings.json`);
1414
+ process.exit(0);
1415
+ }
1416
+ function handleUninstall() {
1417
+ const existing = getExistingStatusLine();
1418
+ if (!existing) {
1419
+ console.log("No statusLine configuration found in settings.json");
1420
+ process.exit(0);
878
1421
  }
879
- isEnabled() {
880
- return this.enabled;
1422
+ uninstallStatusLine();
1423
+ console.log("✓ Statusline uninstalled successfully");
1424
+ console.log(" Removed statusLine from ~/.claude/settings.json");
1425
+ process.exit(0);
1426
+ }
1427
+ function handleApplyConfig() {
1428
+ console.log("Applying endpoint configuration changes...");
1429
+ const currentHash = computeEndpointConfigHash();
1430
+ console.log(`Current endpoint config hash: ${currentHash}`);
1431
+ clearDetectionCache();
1432
+ console.log("✓ Provider detection cache cleared");
1433
+ const cacheDir = getCacheDir();
1434
+ let failCount = 0;
1435
+ if (existsSync5(cacheDir)) {
1436
+ const files = readdirSync3(cacheDir).filter((f) => f.startsWith("cache-") && f.endsWith(".json"));
1437
+ for (const file of files) {
1438
+ try {
1439
+ const filePath = join10(cacheDir, file);
1440
+ unlinkSync5(filePath);
1441
+ } catch {
1442
+ failCount++;
1443
+ }
1444
+ }
1445
+ if (failCount > 0) {
1446
+ console.log(`⚠ Cleared ${files.length - failCount}/${files.length} cache files (${failCount} failed)`);
1447
+ } else {
1448
+ console.log(`✓ Cleared ${files.length} data cache file(s)`);
1449
+ }
881
1450
  }
882
- getLogPath() {
883
- return this.logPath;
1451
+ writeEndpointLock(currentHash);
1452
+ console.log("✓ Lock file updated");
1453
+ console.log("");
1454
+ console.log("✓ Endpoint config changes applied successfully!");
1455
+ console.log(" Changes will take effect on next statusline refresh.");
1456
+ console.log("");
1457
+ console.log("Config files:");
1458
+ const apiConfigDir = getEndpointConfigDir();
1459
+ if (existsSync5(apiConfigDir)) {
1460
+ const configFiles = readdirSync3(apiConfigDir).filter((f) => f.endsWith(".json"));
1461
+ for (const file of configFiles) {
1462
+ console.log(` - ${apiConfigDir}/${file}`);
1463
+ }
884
1464
  }
1465
+ process.exit(0);
885
1466
  }
886
- var logger = new Logger;
887
-
888
1467
  // src/services/user-agent.ts
1468
+ import { execSync as execSync2 } from "child_process";
1469
+ import { join as join11 } from "path";
1470
+ import { homedir as homedir3 } from "os";
889
1471
  var FALLBACK_UA = "claude-cli/2.1.56 (external, cli)";
890
1472
  function resolveUserAgent(config) {
891
1473
  if (!config) {
@@ -912,10 +1494,10 @@ function detectClaudeVersion() {
912
1494
  if (!process.env["CLAUDECODE"]) {
913
1495
  return null;
914
1496
  }
915
- const claudePath = join5(homedir5(), ".claude", "bin", "claude");
1497
+ const claudePath = join11(homedir3(), ".claude", "bin", "claude");
916
1498
  const result = execSync2(`"${claudePath}" --version`, {
917
1499
  encoding: "utf-8",
918
- timeout: 1000,
1500
+ timeout: 100,
919
1501
  stdio: ["ignore", "pipe", "ignore"]
920
1502
  });
921
1503
  const match = result.match(/(\d+\.\d+\.\d+)/);
@@ -942,15 +1524,6 @@ function createQuotaWindow(used, limit, resetsAt) {
942
1524
  };
943
1525
  }
944
1526
 
945
- // src/core/constants.ts
946
- var DEFAULT_FETCH_TIMEOUT_MS = 5000;
947
- var EXIT_BUFFER_MS = 50;
948
- var STALENESS_THRESHOLD_MINUTES = 5;
949
- var VERY_STALE_THRESHOLD_MINUTES = 30;
950
- var GC_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
951
- var GC_MAX_CACHE_FILES = 20;
952
- var GC_ORPHAN_TMP_AGE_MS = 60 * 60 * 1000;
953
-
954
1527
  // src/providers/sub2api.ts
955
1528
  function mapPeriodTokens(data) {
956
1529
  if (!data)
@@ -1027,44 +1600,6 @@ async function fetchSub2api(baseUrl, token, config, timeoutMs = DEFAULT_FETCH_TI
1027
1600
  };
1028
1601
  }
1029
1602
 
1030
- // src/providers/health-probe.ts
1031
- function extractOrigin(baseUrl) {
1032
- try {
1033
- const url = new URL(baseUrl);
1034
- return url.origin;
1035
- } catch {
1036
- return baseUrl;
1037
- }
1038
- }
1039
- async function probeHealth(baseUrl, timeoutMs = 1500) {
1040
- const origin = extractOrigin(baseUrl);
1041
- const healthUrl = `${origin}/health`;
1042
- logger.debug("Probing health endpoint", { healthUrl, timeoutMs });
1043
- try {
1044
- const responseText = await secureFetch(healthUrl, {
1045
- method: "GET",
1046
- headers: {
1047
- Accept: "application/json"
1048
- }
1049
- }, timeoutMs);
1050
- const data = JSON.parse(responseText);
1051
- logger.debug("Health probe response", { data });
1052
- if (typeof data["service"] === "string") {
1053
- logger.debug("Detected provider from service field", { provider: data["service"] });
1054
- return data["service"];
1055
- }
1056
- if (data["status"] === "ok") {
1057
- logger.debug("Detected sub2api from status: ok pattern");
1058
- return "sub2api";
1059
- }
1060
- logger.debug("Health probe returned unrecognized pattern", { data });
1061
- return null;
1062
- } catch (error) {
1063
- logger.debug("Health probe failed", { error: String(error) });
1064
- return null;
1065
- }
1066
- }
1067
-
1068
1603
  // src/services/time.ts
1069
1604
  function computeNextMidnightLocal() {
1070
1605
  const now = new Date;
@@ -1158,7 +1693,7 @@ async function fetchClaudeRelayService(baseUrl, token, config, timeoutMs = DEFAU
1158
1693
  };
1159
1694
  }
1160
1695
 
1161
- // src/providers/custom-mapping.ts
1696
+ // src/providers/response-mapping.ts
1162
1697
  function resolveJsonPath(data, path) {
1163
1698
  if (!path.startsWith("$.")) {
1164
1699
  return path;
@@ -1236,11 +1771,11 @@ function extractTokenStatsPeriod(data, mapping, prefix) {
1236
1771
  cost: extractNumber(data, mapping[`${prefix}.cost`]) ?? 0
1237
1772
  };
1238
1773
  }
1239
- function mapResponseToUsage(responseData, mapping, providerConfig) {
1774
+ function mapResponseToUsage(responseData, mapping, endpointConfig) {
1240
1775
  const billingModeStr = extractString(responseData, mapping.billingMode, "subscription");
1241
1776
  const billingMode = billingModeStr === "balance" ? "balance" : "subscription";
1242
- const planName = extractString(responseData, mapping.planName, providerConfig.displayName ?? providerConfig.id);
1243
- const base = createEmptyNormalizedUsage(providerConfig.id, billingMode, planName);
1777
+ const planName = extractString(responseData, mapping.planName, endpointConfig.displayName ?? endpointConfig.provider);
1778
+ const base = createEmptyNormalizedUsage(endpointConfig.provider, billingMode, planName);
1244
1779
  const balance = mapping["balance.remaining"] ? (() => {
1245
1780
  const remaining = extractNumber(responseData, mapping["balance.remaining"]);
1246
1781
  if (remaining === null)
@@ -1277,19 +1812,17 @@ function mapResponseToUsage(responseData, mapping, providerConfig) {
1277
1812
  remainingSeconds: extractNumber(responseData, mapping["rateLimit.remainingSeconds"]) ?? 0
1278
1813
  };
1279
1814
  })();
1280
- const resetsAt = (() => {
1281
- const times = [];
1282
- if (daily?.resetsAt)
1283
- times.push(daily.resetsAt);
1284
- if (weekly?.resetsAt)
1285
- times.push(weekly.resetsAt);
1286
- if (monthly?.resetsAt)
1287
- times.push(monthly.resetsAt);
1288
- if (times.length === 0)
1289
- return null;
1290
- const sorted = [...times].sort();
1291
- return sorted[0] ?? null;
1292
- })();
1815
+ const resetsAt = computeSoonestReset({
1816
+ ...base,
1817
+ resetSemantics: billingMode === "balance" ? "expiry" : "end-of-day",
1818
+ balance,
1819
+ daily,
1820
+ weekly,
1821
+ monthly,
1822
+ tokenStats,
1823
+ rateLimit,
1824
+ resetsAt: null
1825
+ });
1293
1826
  return {
1294
1827
  ...base,
1295
1828
  resetSemantics: billingMode === "balance" ? "expiry" : "end-of-day",
@@ -1303,159 +1836,75 @@ function mapResponseToUsage(responseData, mapping, providerConfig) {
1303
1836
  };
1304
1837
  }
1305
1838
 
1306
- // src/providers/custom.ts
1307
- function validateCustomProvider(providerConfig) {
1308
- if (!providerConfig.id)
1309
- return "Custom provider missing required field: id";
1310
- if (!providerConfig.endpoint)
1311
- return "Custom provider missing required field: endpoint";
1312
- if (!providerConfig.method)
1313
- return "Custom provider missing required field: method";
1314
- if (!providerConfig.auth)
1315
- return "Custom provider missing required field: auth";
1316
- if (!providerConfig.responseMapping)
1317
- return "Custom provider missing required field: responseMapping";
1318
- if (!providerConfig.endpoint.startsWith("/")) {
1319
- return "Custom provider endpoint must start with /";
1320
- }
1321
- if (!providerConfig.responseMapping.billingMode) {
1322
- return "Custom provider responseMapping must include billingMode";
1323
- }
1324
- if (providerConfig.auth.type === "header" && !providerConfig.auth.header) {
1325
- return 'Custom provider auth.type="header" requires auth.header';
1326
- }
1327
- if (providerConfig.auth.type === "body" && !providerConfig.auth.bodyField) {
1328
- return 'Custom provider auth.type="body" requires auth.bodyField';
1329
- }
1330
- if (providerConfig.urlPatterns && !Array.isArray(providerConfig.urlPatterns)) {
1331
- return "Custom provider urlPatterns must be an array";
1839
+ // src/providers/endpoint-fetch.ts
1840
+ function validateEndpointConfigSemantics(config) {
1841
+ if (!config.provider)
1842
+ return "Endpoint config missing required field: provider";
1843
+ if (!config.endpoint?.path)
1844
+ return "Endpoint config missing required field: endpoint.path";
1845
+ if (!config.endpoint?.method)
1846
+ return "Endpoint config missing required field: endpoint.method";
1847
+ if (!config.auth)
1848
+ return "Endpoint config missing required field: auth";
1849
+ if (!config.responseMapping)
1850
+ return "Endpoint config missing required field: responseMapping";
1851
+ if (!config.endpoint.path.startsWith("/")) {
1852
+ return "Endpoint path must start with /";
1853
+ }
1854
+ if (config.auth.type === "custom-header" && !config.auth.header) {
1855
+ return 'Auth type="custom-header" requires auth.header';
1856
+ }
1857
+ if (config.auth.type === "body-key" && !config.auth.bodyField) {
1858
+ return 'Auth type="body-key" requires auth.bodyField';
1332
1859
  }
1333
1860
  return null;
1334
1861
  }
1335
- async function fetchCustom(baseUrl, token, appConfig, providerConfig, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
1336
- const validationError = validateCustomProvider(providerConfig);
1862
+ async function fetchEndpoint(baseUrl, token, appConfig, endpointConfig, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
1863
+ const validationError = validateEndpointConfigSemantics(endpointConfig);
1337
1864
  if (validationError) {
1338
- throw new Error(`Invalid custom provider providerConfig: ${validationError}`);
1865
+ throw new Error(`Invalid endpoint config: ${validationError}`);
1339
1866
  }
1340
- const url = `${baseUrl}${providerConfig.endpoint}`;
1867
+ const url = `${baseUrl}${endpointConfig.endpoint.path}`;
1341
1868
  const headers = {
1342
1869
  Accept: "application/json"
1343
1870
  };
1344
- if (providerConfig.contentType) {
1345
- headers["Content-Type"] = providerConfig.contentType;
1871
+ if (endpointConfig.endpoint.contentType) {
1872
+ headers["Content-Type"] = endpointConfig.endpoint.contentType;
1346
1873
  }
1347
- if (providerConfig.auth.type === "header" && providerConfig.auth.header) {
1348
- const prefix = providerConfig.auth.prefix ?? "";
1349
- headers[providerConfig.auth.header] = `${prefix}${token}`;
1874
+ if (endpointConfig.auth.type === "bearer-header") {
1875
+ const prefix = endpointConfig.auth.prefix ?? "Bearer ";
1876
+ headers["Authorization"] = `${prefix}${token}`;
1877
+ } else if (endpointConfig.auth.type === "custom-header" && endpointConfig.auth.header) {
1878
+ const prefix = endpointConfig.auth.prefix ?? "";
1879
+ headers[endpointConfig.auth.header] = `${prefix}${token}`;
1350
1880
  }
1351
1881
  let body;
1352
- if (providerConfig.method === "POST") {
1353
- if (providerConfig.auth.type === "body" && providerConfig.auth.bodyField) {
1354
- const bodyObj = { ...providerConfig.requestBody };
1355
- bodyObj[providerConfig.auth.bodyField] = token;
1882
+ if (endpointConfig.endpoint.method === "POST") {
1883
+ if (endpointConfig.auth.type === "body-key" && endpointConfig.auth.bodyField) {
1884
+ const bodyObj = { ...endpointConfig.requestBody ?? {} };
1885
+ bodyObj[endpointConfig.auth.bodyField] = token;
1356
1886
  body = JSON.stringify(bodyObj);
1357
- } else if (providerConfig.requestBody) {
1358
- body = JSON.stringify(providerConfig.requestBody);
1887
+ } else if (endpointConfig.requestBody) {
1888
+ body = JSON.stringify(endpointConfig.requestBody);
1359
1889
  }
1360
1890
  }
1361
- const providerUA = providerConfig.spoofClaudeCodeUA;
1891
+ const endpointUA = endpointConfig.spoofClaudeCodeUA;
1362
1892
  const globalUA = appConfig.spoofClaudeCodeUA;
1363
- const effectiveUA = providerUA !== undefined ? providerUA : globalUA;
1893
+ const effectiveUA = endpointUA !== undefined ? endpointUA : globalUA;
1364
1894
  const resolvedUA = resolveUserAgent(effectiveUA);
1365
1895
  if (resolvedUA) {
1366
- logger.debug(`Using User-Agent for ${providerConfig.id}: ${resolvedUA}`);
1896
+ logger.debug(`Using User-Agent for ${endpointConfig.provider}: ${resolvedUA}`);
1367
1897
  }
1368
1898
  const responseText = await secureFetch(url, {
1369
- method: providerConfig.method,
1899
+ method: endpointConfig.endpoint.method,
1370
1900
  headers,
1371
1901
  body
1372
1902
  }, timeoutMs, resolvedUA);
1373
1903
  const responseData = JSON.parse(responseText);
1374
- const result = mapResponseToUsage(responseData, providerConfig.responseMapping, providerConfig);
1904
+ const result = mapResponseToUsage(responseData, endpointConfig.responseMapping, endpointConfig);
1375
1905
  return result;
1376
1906
  }
1377
1907
 
1378
- // src/providers/autodetect.ts
1379
- var detectionCache = new Map;
1380
- function detectProviderFromUrlPattern(baseUrl, customProviders = {}) {
1381
- const normalizedUrl = baseUrl.toLowerCase().replace(/\/$/, "");
1382
- for (const [providerId, config] of Object.entries(customProviders)) {
1383
- if (config.urlPatterns && config.urlPatterns.length > 0) {
1384
- for (const pattern of config.urlPatterns) {
1385
- const normalizedPattern = pattern.toLowerCase();
1386
- if (normalizedUrl.includes(normalizedPattern)) {
1387
- return providerId;
1388
- }
1389
- }
1390
- }
1391
- }
1392
- if (normalizedUrl.includes("/apistats") || normalizedUrl.includes("/api/user-stats")) {
1393
- return "claude-relay-service";
1394
- }
1395
- return "sub2api";
1396
- }
1397
- async function resolveProvider(baseUrl, providerOverride, customProviders = {}, probeTimeoutMs = 1500) {
1398
- if (providerOverride) {
1399
- logger.debug("Provider override detected", { provider: providerOverride });
1400
- return providerOverride;
1401
- }
1402
- const cached = detectionCache.get(baseUrl);
1403
- if (cached) {
1404
- logger.debug("Provider detection cache hit (memory)", { provider: cached.provider });
1405
- return cached.provider;
1406
- }
1407
- const diskCached = readProviderDetectionCache(baseUrl);
1408
- if (diskCached) {
1409
- logger.debug("Provider detection cache hit (disk)", {
1410
- provider: diskCached.provider,
1411
- detectedVia: diskCached.detectedVia
1412
- });
1413
- detectionCache.set(baseUrl, {
1414
- provider: diskCached.provider,
1415
- detectedAt: diskCached.detectedAt
1416
- });
1417
- return diskCached.provider;
1418
- }
1419
- for (const [providerId, config] of Object.entries(customProviders)) {
1420
- if (config.urlPatterns && config.urlPatterns.length > 0) {
1421
- const normalizedUrl = baseUrl.toLowerCase().replace(/\/$/, "");
1422
- for (const pattern of config.urlPatterns) {
1423
- const normalizedPattern = pattern.toLowerCase();
1424
- if (normalizedUrl.includes(normalizedPattern)) {
1425
- logger.debug("Provider detected via custom URL pattern", { provider: providerId, pattern });
1426
- cacheProviderDetection(baseUrl, providerId, "url-pattern");
1427
- return providerId;
1428
- }
1429
- }
1430
- }
1431
- }
1432
- logger.debug("Attempting health probe", { baseUrl, timeoutMs: probeTimeoutMs });
1433
- const probedProvider = await probeHealth(baseUrl, probeTimeoutMs);
1434
- if (probedProvider) {
1435
- logger.debug("Provider detected via health probe", { provider: probedProvider });
1436
- cacheProviderDetection(baseUrl, probedProvider, "health-probe");
1437
- return probedProvider;
1438
- }
1439
- const patternProvider = detectProviderFromUrlPattern(baseUrl, {});
1440
- logger.debug("Provider detected via built-in URL pattern", { provider: patternProvider });
1441
- cacheProviderDetection(baseUrl, patternProvider, "url-pattern");
1442
- return patternProvider;
1443
- }
1444
- function cacheProviderDetection(baseUrl, provider, detectedVia) {
1445
- const now = new Date().toISOString();
1446
- detectionCache.set(baseUrl, {
1447
- provider,
1448
- detectedAt: now
1449
- });
1450
- writeProviderDetectionCache(baseUrl, {
1451
- baseUrl,
1452
- provider,
1453
- detectedVia,
1454
- detectedAt: now,
1455
- ttlSeconds: PROVIDER_DETECTION_TTL_SECONDS
1456
- });
1457
- }
1458
-
1459
1908
  // src/providers/index.ts
1460
1909
  var BUILT_IN_ADAPTERS = {
1461
1910
  sub2api: {
@@ -1465,14 +1914,14 @@ var BUILT_IN_ADAPTERS = {
1465
1914
  fetch: fetchClaudeRelayService
1466
1915
  }
1467
1916
  };
1468
- function getProvider(providerId, customProviders = {}) {
1917
+ function getProvider(providerId, endpointConfigs = {}) {
1469
1918
  if (BUILT_IN_ADAPTERS[providerId]) {
1470
1919
  return BUILT_IN_ADAPTERS[providerId];
1471
1920
  }
1472
- const customConfig = customProviders[providerId];
1473
- if (customConfig) {
1921
+ const endpointConfig = endpointConfigs[providerId];
1922
+ if (endpointConfig) {
1474
1923
  return {
1475
- fetch: (baseUrl, token, config, timeoutMs) => fetchCustom(baseUrl, token, config, customConfig, timeoutMs)
1924
+ fetch: (baseUrl, token, config, timeoutMs) => fetchEndpoint(baseUrl, token, config, endpointConfig, timeoutMs)
1476
1925
  };
1477
1926
  }
1478
1927
  return null;
@@ -1501,10 +1950,30 @@ var ANSI_COLORS = {
1501
1950
  };
1502
1951
  var THEME_COLORS = {
1503
1952
  cool: "#56B6C2",
1504
- comfortable: "#6BAF8D",
1953
+ comfortable: "#5EBE8A",
1505
1954
  warm: "#C9A84C",
1506
- hot: "#CB7E55",
1507
- critical: "#C96B6B"
1955
+ hot: "#D68B45",
1956
+ critical: "#D45A5A",
1957
+ "pastel-cool": "#BAD7F2",
1958
+ "pastel-comfortable": "#BAF2D8",
1959
+ "pastel-medium": "#BAF2BB",
1960
+ "pastel-warm": "#F2E2BA",
1961
+ "pastel-hot": "#F2BAC9",
1962
+ "bright-cool": "#90F1EF",
1963
+ "bright-comfortable": "#7BF1A8",
1964
+ "bright-medium": "#C1FBA4",
1965
+ "bright-warm": "#FFEF9F",
1966
+ "bright-hot": "#FFD6E0",
1967
+ "ocean-cool": "#0081A7",
1968
+ "ocean-comfortable": "#00AFB9",
1969
+ "ocean-medium": "#FDFCDC",
1970
+ "ocean-warm": "#FED9B7",
1971
+ "ocean-hot": "#F07167",
1972
+ "neutral-cool": "#D8E2DC",
1973
+ "neutral-comfortable": "#FFE5D9",
1974
+ "neutral-warm": "#FFCAD4",
1975
+ "neutral-hot": "#F4ACB7",
1976
+ "neutral-critical": "#9D8189"
1508
1977
  };
1509
1978
  var ANSI_RESET = "\x1B[0m";
1510
1979
  var ANSI_DIM = "\x1B[2m";
@@ -1596,6 +2065,9 @@ function hexToRgb(hex) {
1596
2065
  function dimText(text) {
1597
2066
  return `${ANSI_DIM}${text}${ANSI_RESET}`;
1598
2067
  }
2068
+ function stripAnsi(text) {
2069
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
2070
+ }
1599
2071
  function resolveColor(colorName, usagePercent, config) {
1600
2072
  const effectiveColor = colorName ?? "auto";
1601
2073
  if (effectiveColor.startsWith("#") || ANSI_COLORS[effectiveColor.toLowerCase()]) {
@@ -1614,9 +2086,6 @@ function resolveColor(colorName, usagePercent, config) {
1614
2086
  }
1615
2087
  return resolveColorAlias(alias, usagePercent);
1616
2088
  }
1617
- function isTieredEntry(alias) {
1618
- return "tiers" in alias;
1619
- }
1620
2089
  function resolveTieredColor(entry, usagePercent) {
1621
2090
  if (entry.tiers.length === 0)
1622
2091
  return null;
@@ -1633,19 +2102,7 @@ function resolveTieredColor(entry, usagePercent) {
1633
2102
  function resolveColorAlias(alias, usagePercent) {
1634
2103
  if (!alias)
1635
2104
  return null;
1636
- if (isTieredEntry(alias)) {
1637
- return resolveTieredColor(alias, usagePercent);
1638
- }
1639
- if (usagePercent === null) {
1640
- return alias.low;
1641
- }
1642
- if (usagePercent < alias.lowThreshold) {
1643
- return alias.low;
1644
- } else if (usagePercent < alias.highThreshold) {
1645
- return alias.medium;
1646
- } else {
1647
- return alias.high;
1648
- }
2105
+ return resolveTieredColor(alias, usagePercent);
1649
2106
  }
1650
2107
 
1651
2108
  // src/renderer/transition.ts
@@ -1696,6 +2153,8 @@ function renderStandaloneError(errorState, provider, message) {
1696
2153
  return `${warningIcon} Set ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`;
1697
2154
  case "timeout":
1698
2155
  return `${warningIcon} Fetching...`;
2156
+ case "endpoint-config-changed":
2157
+ return `${warningIcon} Endpoint config changed — run: cc-api-statusline --apply-config`;
1699
2158
  case "network-error":
1700
2159
  case "server-error":
1701
2160
  case "parse-error":
@@ -2003,46 +2462,49 @@ function formatCompactNumber(n) {
2003
2462
 
2004
2463
  // src/renderer/component.ts
2005
2464
  function renderComponent(componentId, data, componentConfig, globalConfig, renderContext) {
2006
- const effectiveLayout = componentConfig.layout ?? globalConfig.display.layout;
2007
- const effectiveDisplayMode = resolveEffectiveDisplayMode(componentConfig.displayMode ?? globalConfig.display.displayMode, renderContext);
2008
- const effectiveProgressStyle = resolveEffectiveProgressStyle(componentConfig.progressStyle ?? globalConfig.display.progressStyle, renderContext);
2009
- const effectiveBarSize = componentConfig.barSize ?? globalConfig.display.barSize;
2010
- const effectiveBarStyle = componentConfig.barStyle ?? globalConfig.display.barStyle;
2011
- const clockFormat = globalConfig.display.clockFormat;
2465
+ const options = {
2466
+ layout: componentConfig.layout ?? globalConfig.display.layout,
2467
+ displayMode: resolveEffectiveDisplayMode(componentConfig.displayMode ?? globalConfig.display.displayMode, renderContext),
2468
+ progressStyle: resolveEffectiveProgressStyle(componentConfig.progressStyle ?? globalConfig.display.progressStyle, renderContext),
2469
+ barSize: componentConfig.barSize ?? globalConfig.display.barSize,
2470
+ barStyle: componentConfig.barStyle ?? globalConfig.display.barStyle,
2471
+ clockFormat: globalConfig.display.clockFormat
2472
+ };
2012
2473
  switch (componentId) {
2013
2474
  case "daily":
2014
- return renderQuotaComponent("daily", data.daily, effectiveLayout, effectiveDisplayMode, effectiveProgressStyle, effectiveBarSize, effectiveBarStyle, componentConfig, globalConfig, clockFormat, renderContext);
2015
2475
  case "weekly":
2016
- return renderQuotaComponent("weekly", data.weekly, effectiveLayout, effectiveDisplayMode, effectiveProgressStyle, effectiveBarSize, effectiveBarStyle, componentConfig, globalConfig, clockFormat, renderContext);
2017
2476
  case "monthly":
2018
- return renderQuotaComponent("monthly", data.monthly, effectiveLayout, effectiveDisplayMode, effectiveProgressStyle, effectiveBarSize, effectiveBarStyle, componentConfig, globalConfig, clockFormat, renderContext);
2477
+ return renderQuotaComponent(componentId, data[componentId], options, componentConfig, globalConfig, renderContext);
2019
2478
  case "balance":
2020
- return renderBalanceComponent(data.balance, effectiveLayout, effectiveDisplayMode, effectiveProgressStyle, effectiveBarSize, effectiveBarStyle, componentConfig, globalConfig, renderContext);
2479
+ return renderBalanceComponent(data.balance, options, componentConfig, globalConfig, renderContext);
2021
2480
  case "tokens":
2022
- return renderTokensComponent(data.tokenStats, effectiveLayout, effectiveDisplayMode, componentConfig, globalConfig, renderContext);
2481
+ return renderTokensComponent(data.tokenStats, options, componentConfig, globalConfig, renderContext);
2023
2482
  case "rateLimit":
2024
- return renderRateLimitComponent(data.rateLimit, effectiveLayout, effectiveDisplayMode, effectiveProgressStyle, effectiveBarSize, effectiveBarStyle, componentConfig, globalConfig, renderContext);
2483
+ return renderRateLimitComponent(data.rateLimit, options, componentConfig, globalConfig, renderContext);
2025
2484
  case "plan":
2026
- return renderPlanComponent(data.planName, effectiveLayout, effectiveDisplayMode, componentConfig, globalConfig, renderContext);
2485
+ return renderPlanComponent(data.planName, options, componentConfig, globalConfig, renderContext);
2027
2486
  default:
2028
2487
  return null;
2029
2488
  }
2030
2489
  }
2031
- function renderQuotaComponent(componentId, quota, layout, displayMode, progressStyle, barSize, barStyle, componentConfig, globalConfig, clockFormat, renderContext) {
2490
+ function renderQuotaComponent(componentId, quota, options, componentConfig, globalConfig, renderContext) {
2491
+ const { layout, displayMode, progressStyle, barSize, barStyle, clockFormat } = options;
2032
2492
  if (!quota)
2033
2493
  return null;
2034
2494
  const usagePercent = calculateUsagePercent(quota.used, quota.limit);
2035
2495
  const label = renderLabel(componentId, displayMode, componentConfig, quota.qualifier);
2496
+ const showPercentage = componentConfig.percentage !== false;
2036
2497
  const barColor = resolvePartColor("bar", usagePercent, componentConfig, globalConfig);
2037
- const valueColor = resolvePartColor("value", usagePercent, componentConfig, globalConfig);
2498
+ const valueColor = showPercentage ? resolvePartColor("value", usagePercent, componentConfig, globalConfig) : null;
2038
2499
  const labelColor = resolvePartColor("label", usagePercent, componentConfig, globalConfig);
2039
2500
  const countdownColor = resolvePartColor("countdown", usagePercent, componentConfig, globalConfig);
2040
2501
  const progress = renderProgress(progressStyle, usagePercent, barSize, barStyle, barColor, null, renderContext);
2041
- const value = ansiColor(`${Math.round(usagePercent)}%`, valueColor, renderContext);
2502
+ const value = showPercentage ? ansiColor(`${Math.round(usagePercent)}%`, valueColor, renderContext) : "";
2042
2503
  const countdown = renderSecondaryDisplay(quota.resetsAt, quota, componentConfig.countdown, countdownColor, clockFormat, renderContext);
2043
2504
  return assembleComponent(layout, label, labelColor, progress, value, countdown, renderContext);
2044
2505
  }
2045
- function renderBalanceComponent(balance, layout, displayMode, progressStyle, barSize, barStyle, componentConfig, globalConfig, renderContext) {
2506
+ function renderBalanceComponent(balance, options, componentConfig, globalConfig, renderContext) {
2507
+ const { layout, displayMode, progressStyle, barSize, barStyle, clockFormat } = options;
2046
2508
  if (!balance)
2047
2509
  return null;
2048
2510
  const isUnlimited = balance.remaining === -1;
@@ -2061,7 +2523,8 @@ function renderBalanceComponent(balance, layout, displayMode, progressStyle, bar
2061
2523
  const countdown = "";
2062
2524
  return assembleComponent(layout, label, labelColor, progress, value, countdown, renderContext);
2063
2525
  }
2064
- function renderTokensComponent(tokenStats, layout, displayMode, componentConfig, globalConfig, renderContext) {
2526
+ function renderTokensComponent(tokenStats, options, componentConfig, globalConfig, renderContext) {
2527
+ const { layout, displayMode, progressStyle, barSize, barStyle, clockFormat } = options;
2065
2528
  if (!tokenStats)
2066
2529
  return null;
2067
2530
  const stats = tokenStats.total ?? tokenStats.today;
@@ -2077,7 +2540,8 @@ function renderTokensComponent(tokenStats, layout, displayMode, componentConfig,
2077
2540
  const countdown = "";
2078
2541
  return assembleComponent(layout, label, labelColor, progress, value, countdown, renderContext);
2079
2542
  }
2080
- function renderRateLimitComponent(rateLimit, layout, displayMode, progressStyle, barSize, barStyle, componentConfig, globalConfig, renderContext) {
2543
+ function renderRateLimitComponent(rateLimit, options, componentConfig, globalConfig, renderContext) {
2544
+ const { layout, displayMode, progressStyle, barSize, barStyle, clockFormat } = options;
2081
2545
  if (!rateLimit)
2082
2546
  return null;
2083
2547
  let usagePercent = null;
@@ -2094,7 +2558,8 @@ function renderRateLimitComponent(rateLimit, layout, displayMode, progressStyle,
2094
2558
  const countdown = "";
2095
2559
  return assembleComponent(layout, label, labelColor, progress, value, countdown, renderContext);
2096
2560
  }
2097
- function renderPlanComponent(planName, layout, displayMode, componentConfig, globalConfig, renderContext) {
2561
+ function renderPlanComponent(planName, options, componentConfig, globalConfig, renderContext) {
2562
+ const { layout, displayMode, progressStyle, barSize, barStyle, clockFormat } = options;
2098
2563
  if (displayMode === "hidden")
2099
2564
  return null;
2100
2565
  const labelColor = resolvePartColor("label", null, componentConfig, globalConfig);
@@ -2208,21 +2673,25 @@ function assembleComponent(layout, label, labelColor, progress, value, countdown
2208
2673
  if (layout === "percent-first") {
2209
2674
  if (coloredLabel)
2210
2675
  parts.push(coloredLabel);
2211
- parts.push(value);
2676
+ if (value)
2677
+ parts.push(value);
2212
2678
  if (progress)
2213
2679
  parts.push(progress);
2214
- if (countdown)
2215
- parts.push(countdown);
2216
2680
  } else {
2217
2681
  if (coloredLabel)
2218
2682
  parts.push(coloredLabel);
2219
2683
  if (progress)
2220
2684
  parts.push(progress);
2221
- parts.push(value);
2222
- if (countdown)
2223
- parts.push(countdown);
2685
+ if (value)
2686
+ parts.push(value);
2687
+ }
2688
+ if (countdown && parts.length > 0) {
2689
+ const idx = parts.length - 1;
2690
+ parts[idx] = (parts[idx] ?? "") + countdown;
2691
+ } else if (countdown) {
2692
+ parts.push(countdown);
2224
2693
  }
2225
- return parts.filter((p) => p).join(" ").replace(/ (\x1b\[[0-9;]*m)? ([·•])/g, "$1 $2");
2694
+ return parts.join(" ");
2226
2695
  }
2227
2696
  function calculateUsagePercent(used, limit) {
2228
2697
  if (limit === null)
@@ -2259,8 +2728,7 @@ function computeMaxWidth(termWidth, maxWidthPct) {
2259
2728
  return Math.floor(termWidth * pct / 100);
2260
2729
  }
2261
2730
  function visibleLength(text) {
2262
- const stripped = text.replace(/\x1b\[[0-9;]*m/g, "");
2263
- return stripped.length;
2731
+ return stripAnsi(text).length;
2264
2732
  }
2265
2733
  function ansiAwareTruncate(text, maxWidth) {
2266
2734
  const visible = visibleLength(text);
@@ -2307,8 +2775,8 @@ var COMPONENT_DROP_PRIORITY = [
2307
2775
  // src/renderer/divider.ts
2308
2776
  function renderDivider(divider) {
2309
2777
  const text = divider.text ?? "|";
2310
- const padding = divider.padding ?? 1;
2311
- const pad = " ".repeat(padding);
2778
+ const margin = divider.margin ?? 1;
2779
+ const pad = " ".repeat(margin);
2312
2780
  const padded = `${pad}${text}${pad}`;
2313
2781
  return ansiColor(padded, divider.color ?? "#555753");
2314
2782
  }
@@ -2400,63 +2868,46 @@ function renderStatusline(data, config, errorState, cacheAge, isPiped = false) {
2400
2868
  currentWidth = calculateStatuslineWidth(componentMap, activeComponents, componentOrder, separator, errorState, data, cacheAge);
2401
2869
  }
2402
2870
  }
2403
- const renderedComponents = [];
2404
- for (const componentId of componentOrder) {
2405
- if (activeComponents.has(componentId)) {
2406
- const rendered = componentMap.get(componentId);
2407
- if (rendered)
2408
- renderedComponents.push(rendered);
2409
- }
2410
- }
2411
- let statusline = renderedComponents.join(separator);
2412
- if (errorState) {
2413
- if (isTransitionState(errorState)) {
2414
- statusline = renderError(errorState, "with-cache", data.provider, undefined, cacheAge);
2415
- } else {
2416
- const hasCache = renderedComponents.length > 0;
2417
- const errorMode = hasCache ? "with-cache" : "without-cache";
2418
- const errorIndicator = renderError(errorState, errorMode, data.provider, undefined, cacheAge);
2419
- statusline = hasCache ? `${statusline} ${errorIndicator}` : errorIndicator;
2420
- }
2421
- }
2871
+ let statusline = assembleStatuslineString(componentOrder, componentMap, activeComponents, separator, errorState, data, cacheAge);
2422
2872
  const termWidth = getTerminalWidth();
2423
2873
  const maxW = computeMaxWidth(termWidth, config.display.maxWidth ?? 100);
2424
2874
  statusline = ansiAwareTruncate(statusline, maxW);
2425
2875
  return statusline;
2426
2876
  }
2427
2877
  function computeSeparator(config) {
2428
- const dividerConfig = config.components.divider;
2878
+ const dividerConfig = config.display.divider;
2429
2879
  if (dividerConfig === false)
2430
2880
  return "";
2431
- if (typeof dividerConfig === "object")
2432
- return renderDivider(dividerConfig);
2433
- return config.display.separator ?? " | ";
2881
+ return renderDivider(dividerConfig ?? DEFAULT_DIVIDER_CONFIG);
2434
2882
  }
2435
2883
  function maxWidth(config) {
2436
2884
  const termWidth = getTerminalWidth();
2437
2885
  return computeMaxWidth(termWidth, config.display.maxWidth ?? 100);
2438
2886
  }
2439
- function calculateStatuslineWidth(componentMap, activeComponents, componentOrder, separator, errorState, data, cacheAge) {
2440
- const components = [];
2887
+ function assembleStatuslineString(componentOrder, componentMap, activeComponents, separator, errorState, data, cacheAge) {
2888
+ const rendered = [];
2441
2889
  for (const id of componentOrder) {
2442
2890
  if (activeComponents.has(id)) {
2443
- const rendered = componentMap.get(id);
2444
- if (rendered)
2445
- components.push(rendered);
2891
+ const r = componentMap.get(id);
2892
+ if (r)
2893
+ rendered.push(r);
2446
2894
  }
2447
2895
  }
2448
- let statusline = components.join(separator);
2896
+ let statusline = rendered.join(separator);
2449
2897
  if (errorState) {
2450
2898
  if (isTransitionState(errorState)) {
2451
2899
  statusline = renderError(errorState, "with-cache", data.provider, undefined, cacheAge);
2452
2900
  } else {
2453
- const hasCache = components.length > 0;
2901
+ const hasCache = rendered.length > 0;
2454
2902
  const errorMode = hasCache ? "with-cache" : "without-cache";
2455
2903
  const errorIndicator = renderError(errorState, errorMode, data.provider, undefined, cacheAge);
2456
2904
  statusline = hasCache ? `${statusline} ${errorIndicator}` : errorIndicator;
2457
2905
  }
2458
2906
  }
2459
- return visibleLength(statusline);
2907
+ return statusline;
2908
+ }
2909
+ function calculateStatuslineWidth(componentMap, activeComponents, componentOrder, separator, errorState, data, cacheAge) {
2910
+ return visibleLength(assembleStatuslineString(componentOrder, componentMap, activeComponents, separator, errorState, data, cacheAge));
2460
2911
  }
2461
2912
  function getComponentOrder(config) {
2462
2913
  const explicitOrder = [];
@@ -2481,10 +2932,10 @@ function isComponentId(key) {
2481
2932
 
2482
2933
  // src/core/execute-cycle.ts
2483
2934
  async function executeCycle(ctx) {
2484
- const { env, config, configHash, cachedEntry, providerId, provider, timeoutBudgetMs, startTime, fetchTimeoutMs } = ctx;
2935
+ const { env, config, configHash, endpointConfigHash, endpointLock, cachedEntry, providerId, provider, timeoutBudgetMs, startTime, fetchTimeoutMs } = ctx;
2485
2936
  if (cachedEntry) {
2486
2937
  if (isCacheValid(cachedEntry, env) && isCacheProviderValid(cachedEntry, providerId)) {
2487
- if (cachedEntry.renderedLine && isCacheRenderedLineUsable(cachedEntry, configHash)) {
2938
+ if (cachedEntry.renderedLine && isCacheRenderedLineUsable(cachedEntry, configHash) && cachedEntry.endpointConfigHash === endpointConfigHash) {
2488
2939
  logger.debug("Path A: Fast path (cached renderedLine)", {
2489
2940
  cacheAge: `${Math.floor((Date.now() - new Date(cachedEntry.fetchedAt).getTime()) / 1000)}s`
2490
2941
  });
@@ -2496,13 +2947,34 @@ async function executeCycle(ctx) {
2496
2947
  }
2497
2948
  }
2498
2949
  }
2950
+ if (endpointLock && endpointLock.hash !== endpointConfigHash) {
2951
+ logger.debug("Path B2: Endpoint config changed (locked out)", {
2952
+ lockedHash: endpointLock.hash,
2953
+ currentHash: endpointConfigHash
2954
+ });
2955
+ if (cachedEntry && isCacheValid(cachedEntry, env) && isCacheProviderValid(cachedEntry, providerId)) {
2956
+ const statusline = renderStatusline(cachedEntry.data, config);
2957
+ return {
2958
+ output: statusline,
2959
+ exitCode: 0,
2960
+ cacheUpdate: null
2961
+ };
2962
+ }
2963
+ const errorOutput = renderError("endpoint-config-changed", "without-cache");
2964
+ return {
2965
+ output: errorOutput,
2966
+ exitCode: 0,
2967
+ cacheUpdate: null
2968
+ };
2969
+ }
2499
2970
  if (cachedEntry && isCacheValid(cachedEntry, env) && isCacheProviderValid(cachedEntry, providerId)) {
2500
2971
  logger.debug("Path B: Re-render (config changed, cache data valid)");
2501
2972
  const statusline = renderStatusline(cachedEntry.data, config);
2502
2973
  const updatedEntry = {
2503
2974
  ...cachedEntry,
2504
2975
  renderedLine: statusline,
2505
- configHash
2976
+ configHash,
2977
+ endpointConfigHash
2506
2978
  };
2507
2979
  return {
2508
2980
  output: statusline,
@@ -2548,6 +3020,7 @@ async function executeCycle(ctx) {
2548
3020
  data,
2549
3021
  renderedLine: statusline,
2550
3022
  configHash,
3023
+ endpointConfigHash,
2551
3024
  errorState: null
2552
3025
  };
2553
3026
  return {
@@ -2582,23 +3055,23 @@ async function executeCycle(ctx) {
2582
3055
  }
2583
3056
  }
2584
3057
  // src/services/cache-gc.ts
2585
- import { readdirSync, statSync, unlinkSync as unlinkSync3, existsSync as existsSync7 } from "fs";
2586
- import { join as join6 } from "path";
3058
+ import { readdirSync as readdirSync4, statSync as statSync2, unlinkSync as unlinkSync6, existsSync as existsSync6 } from "fs";
3059
+ import { join as join12 } from "path";
2587
3060
  function runCacheGC(cacheDir) {
2588
3061
  try {
2589
- if (!existsSync7(cacheDir)) {
3062
+ if (!existsSync6(cacheDir)) {
2590
3063
  logger.debug("GC: Cache directory does not exist, skipping", { cacheDir });
2591
3064
  return;
2592
3065
  }
2593
3066
  logger.debug("GC: Starting garbage collection", { cacheDir });
2594
- const files = readdirSync(cacheDir);
3067
+ const files = readdirSync4(cacheDir);
2595
3068
  const cacheFiles = [];
2596
3069
  const providerDetectFiles = [];
2597
3070
  const tmpFiles = [];
2598
3071
  for (const file of files) {
2599
3072
  try {
2600
- const filePath = join6(cacheDir, file);
2601
- const stats = statSync(filePath);
3073
+ const filePath = join12(cacheDir, file);
3074
+ const stats = statSync2(filePath);
2602
3075
  const mtime = stats.mtimeMs;
2603
3076
  if (file.startsWith("cache-") && file.endsWith(".json")) {
2604
3077
  cacheFiles.push({ name: file, mtime });
@@ -2617,7 +3090,7 @@ function runCacheGC(cacheDir) {
2617
3090
  const age = now - file.mtime;
2618
3091
  if (age > GC_MAX_AGE_MS) {
2619
3092
  try {
2620
- unlinkSync3(join6(cacheDir, file.name));
3093
+ unlinkSync6(join12(cacheDir, file.name));
2621
3094
  deletedCount++;
2622
3095
  logger.debug("GC: Deleted old cache file", { file: file.name, ageDays: Math.floor(age / (24 * 60 * 60 * 1000)) });
2623
3096
  } catch (error) {
@@ -2629,7 +3102,7 @@ function runCacheGC(cacheDir) {
2629
3102
  const age = now - file.mtime;
2630
3103
  if (age > GC_MAX_AGE_MS) {
2631
3104
  try {
2632
- unlinkSync3(join6(cacheDir, file.name));
3105
+ unlinkSync6(join12(cacheDir, file.name));
2633
3106
  deletedCount++;
2634
3107
  logger.debug("GC: Deleted old provider-detect file", { file: file.name, ageDays: Math.floor(age / (24 * 60 * 60 * 1000)) });
2635
3108
  } catch (error) {
@@ -2641,7 +3114,7 @@ function runCacheGC(cacheDir) {
2641
3114
  const age = now - file.mtime;
2642
3115
  if (age > GC_ORPHAN_TMP_AGE_MS) {
2643
3116
  try {
2644
- unlinkSync3(join6(cacheDir, file.name));
3117
+ unlinkSync6(join12(cacheDir, file.name));
2645
3118
  deletedCount++;
2646
3119
  logger.debug("GC: Deleted orphaned tmp file", { file: file.name, ageMinutes: Math.floor(age / (60 * 1000)) });
2647
3120
  } catch (error) {
@@ -2658,7 +3131,7 @@ function runCacheGC(cacheDir) {
2658
3131
  const toDelete = remainingCacheFiles.slice(0, remainingCacheFiles.length - GC_MAX_CACHE_FILES);
2659
3132
  for (const file of toDelete) {
2660
3133
  try {
2661
- unlinkSync3(join6(cacheDir, file.name));
3134
+ unlinkSync6(join12(cacheDir, file.name));
2662
3135
  deletedCount++;
2663
3136
  logger.debug("GC: Deleted cache file (count limit)", { file: file.name });
2664
3137
  } catch (error) {
@@ -2673,60 +3146,121 @@ function runCacheGC(cacheDir) {
2673
3146
  }
2674
3147
 
2675
3148
  // src/cli/piped-mode.ts
2676
- function buildExecutionContext(args, isPiped, startTime) {
2677
- return (async () => {
2678
- const env = readCurrentEnv();
2679
- logger.debug("Environment loaded", {
2680
- baseUrl: env.baseUrl ? `${env.baseUrl.substring(0, 30)}...` : undefined,
2681
- hasToken: !!env.authToken,
2682
- providerOverride: env.providerOverride,
2683
- pollIntervalOverride: env.pollIntervalOverride
3149
+ class StatuslineError extends Error {
3150
+ errorType;
3151
+ constructor(errorType) {
3152
+ super(errorType);
3153
+ this.errorType = errorType;
3154
+ }
3155
+ }
3156
+ function safeStdoutWrite(data) {
3157
+ try {
3158
+ process.stdout["write"](data);
3159
+ } catch {}
3160
+ }
3161
+ function readAndValidateEnv() {
3162
+ const env = readCurrentEnv();
3163
+ logger.debug("Environment loaded", {
3164
+ baseUrl: env.baseUrl ? `${env.baseUrl.substring(0, 30)}...` : undefined,
3165
+ hasToken: !!env.authToken,
3166
+ providerOverride: env.providerOverride,
3167
+ pollIntervalOverride: env.pollIntervalOverride
3168
+ });
3169
+ const envError = validateRequiredEnv(env);
3170
+ if (envError) {
3171
+ throw new StatuslineError("missing-env");
3172
+ }
3173
+ const { baseUrl } = env;
3174
+ if (!baseUrl) {
3175
+ process.exit(0);
3176
+ }
3177
+ return { env, baseUrl };
3178
+ }
3179
+ function ensureDefaultConfigs() {
3180
+ if (needsConfigInit()) {
3181
+ logger.debug("First run detected - initializing default configs");
3182
+ writeDefaultConfigs();
3183
+ }
3184
+ }
3185
+ function loadEndpointConfigsWithHash() {
3186
+ const endpointConfigs = loadEndpointConfigs();
3187
+ const endpointConfigHash = computeEndpointConfigHash();
3188
+ logger.debug("Endpoint configs loaded", {
3189
+ configCount: Object.keys(endpointConfigs).length,
3190
+ endpointConfigHash
3191
+ });
3192
+ return { endpointConfigs, endpointConfigHash };
3193
+ }
3194
+ function resolveEndpointLock(hash) {
3195
+ const existing = readEndpointLock();
3196
+ if (existing) {
3197
+ logger.debug("Endpoint lock file loaded", {
3198
+ lockedHash: existing.hash,
3199
+ currentHash: hash,
3200
+ locked: existing.hash === hash
2684
3201
  });
2685
- const envError = validateRequiredEnv(env);
2686
- if (envError) {
2687
- const errorOutput = renderError("missing-env", "without-cache");
2688
- process.stdout.write(errorOutput);
2689
- process.exit(0);
2690
- }
2691
- const baseUrl = env.baseUrl;
2692
- const authToken = env.authToken;
2693
- if (!baseUrl || !authToken) {
2694
- process.exit(1);
2695
- }
2696
- const config = loadConfig(args.configPath);
2697
- const configPath = getConfigPath(args.configPath);
2698
- const configHash = computeConfigHash(configPath);
2699
- logger.debug("Config loaded", { configPath, configHash });
2700
- const probeTimeout = isPiped ? Math.min(1500, Math.max(200, Number(process.env["CC_STATUSLINE_TIMEOUT"] ?? 1000) - 200)) : 3000;
2701
- const providerId = await resolveProvider(baseUrl, env.providerOverride, config.customProviders ?? {}, probeTimeout);
2702
- const provider = getProvider(providerId, config.customProviders ?? {});
2703
- logger.debug("Provider resolved", { providerId, probeTimeout });
2704
- if (!provider) {
2705
- logger.error("Provider not found", { providerId });
2706
- const errorOutput = renderError("provider-unknown", "without-cache");
2707
- process.stdout.write(errorOutput);
2708
- process.exit(0);
3202
+ return existing;
3203
+ }
3204
+ logger.debug("Endpoint lock file missing - creating with current hash");
3205
+ writeEndpointLock(hash);
3206
+ return { hash, lockedAt: new Date().toISOString() };
3207
+ }
3208
+ async function resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, timeoutMs) {
3209
+ const probeTimeout = isPiped ? Math.min(1500, Math.max(200, timeoutMs - 200)) : 3000;
3210
+ const providerId = await resolveProvider(baseUrl, env.providerOverride, endpointConfigs, probeTimeout);
3211
+ const provider = getProvider(providerId, endpointConfigs);
3212
+ logger.debug("Provider resolved", { providerId, probeTimeout });
3213
+ if (!provider) {
3214
+ logger.error("Provider not found", { providerId });
3215
+ throw new StatuslineError("provider-unknown");
3216
+ }
3217
+ return { providerId, provider };
3218
+ }
3219
+ function computeTimeoutBudgets(isPiped, config, timeoutMs) {
3220
+ const timeoutBudgetMs = isPiped ? timeoutMs : 1e4;
3221
+ const fetchTimeoutMs = isPiped ? Math.min(config.pipedRequestTimeoutMs ?? 800, timeoutBudgetMs - 100) : 1e4;
3222
+ return { timeoutBudgetMs, fetchTimeoutMs };
3223
+ }
3224
+ async function buildExecutionContext(args, isPiped, startTime, rawTimeoutMs) {
3225
+ const { env, baseUrl } = readAndValidateEnv();
3226
+ ensureDefaultConfigs();
3227
+ const { config, configHash } = loadConfigWithHash(args.configPath);
3228
+ const { endpointConfigs, endpointConfigHash } = loadEndpointConfigsWithHash();
3229
+ const endpointLock = resolveEndpointLock(endpointConfigHash);
3230
+ const cachedEntry = readCache(baseUrl);
3231
+ logger.debug("Cache read", {
3232
+ cacheHit: !!cachedEntry,
3233
+ cacheAge: cachedEntry ? `${Math.floor((Date.now() - new Date(cachedEntry.fetchedAt).getTime()) / 1000)}s` : "N/A"
3234
+ });
3235
+ let providerId;
3236
+ let provider;
3237
+ if (cachedEntry && isCacheValid(cachedEntry, env)) {
3238
+ const cachedProvider = getProvider(cachedEntry.provider, endpointConfigs);
3239
+ if (cachedProvider) {
3240
+ providerId = cachedEntry.provider;
3241
+ provider = cachedProvider;
3242
+ logger.debug("Cache-first: skipping provider probe", { providerId });
3243
+ } else {
3244
+ ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs));
2709
3245
  }
2710
- const cachedEntry = readCache(baseUrl);
2711
- logger.debug("Cache read", {
2712
- cacheHit: !!cachedEntry,
2713
- cacheAge: cachedEntry ? `${Math.floor((Date.now() - new Date(cachedEntry.fetchedAt).getTime()) / 1000)}s` : "N/A"
2714
- });
2715
- const timeoutBudgetMs = isPiped ? Number(process.env["CC_STATUSLINE_TIMEOUT"] ?? 1000) : 1e4;
2716
- const fetchTimeoutMs = isPiped ? Math.min(config.pipedRequestTimeoutMs ?? 800, timeoutBudgetMs - 100) : 1e4;
2717
- const ctx = {
2718
- env,
2719
- config,
2720
- configHash,
2721
- cachedEntry,
2722
- providerId,
2723
- provider,
2724
- timeoutBudgetMs,
2725
- startTime,
2726
- fetchTimeoutMs
2727
- };
2728
- return { ctx, baseUrl };
2729
- })();
3246
+ } else {
3247
+ ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs));
3248
+ }
3249
+ const { timeoutBudgetMs, fetchTimeoutMs } = computeTimeoutBudgets(isPiped, config, rawTimeoutMs);
3250
+ const ctx = {
3251
+ env,
3252
+ config,
3253
+ configHash,
3254
+ endpointConfigHash,
3255
+ endpointLock,
3256
+ cachedEntry,
3257
+ providerId,
3258
+ provider,
3259
+ timeoutBudgetMs,
3260
+ startTime,
3261
+ fetchTimeoutMs
3262
+ };
3263
+ return { ctx, baseUrl };
2730
3264
  }
2731
3265
  function formatOutput(output, isPiped) {
2732
3266
  let normalizedOutput = output;
@@ -2748,12 +3282,45 @@ async function executePipedMode(args) {
2748
3282
  logger.debug("Start time", { startTime });
2749
3283
  const isPiped = !process.stdin.isTTY;
2750
3284
  logger.debug("Mode detection", { isPiped, once: args.once });
2751
- const { ctx, baseUrl } = await buildExecutionContext(args, isPiped, startTime);
3285
+ const rawTimeoutMs = Number(process.env["CC_STATUSLINE_TIMEOUT"] ?? 1000);
3286
+ if (isPiped) {
3287
+ const watchdogMs = rawTimeoutMs - 100;
3288
+ setTimeout(() => {
3289
+ logger.error("Watchdog timeout - forcing clean exit", { watchdogMs });
3290
+ const fallback = dimText("⟳ Refreshing...");
3291
+ const formatted = formatOutput(fallback, isPiped);
3292
+ safeStdoutWrite(formatted);
3293
+ process.exit(0);
3294
+ }, watchdogMs).unref();
3295
+ }
3296
+ let ctx;
3297
+ let baseUrl;
3298
+ try {
3299
+ ({ ctx, baseUrl } = await buildExecutionContext(args, isPiped, startTime, rawTimeoutMs));
3300
+ } catch (error) {
3301
+ logger.error("Failed to build execution context", { error: String(error) });
3302
+ const errorType = error instanceof StatuslineError ? error.errorType : "network-error";
3303
+ const errorOutput = renderError(errorType, "without-cache");
3304
+ const formattedOutput2 = formatOutput(errorOutput, isPiped);
3305
+ safeStdoutWrite(formattedOutput2);
3306
+ logger.debug("=== cc-api-statusline execution completed ===");
3307
+ process.exit(0);
3308
+ }
2752
3309
  logger.debug("Execution context prepared", {
2753
3310
  timeoutBudgetMs: ctx.timeoutBudgetMs,
2754
3311
  fetchTimeoutMs: ctx.fetchTimeoutMs
2755
3312
  });
2756
- const result = await executeCycle(ctx);
3313
+ let result;
3314
+ try {
3315
+ result = await executeCycle(ctx);
3316
+ } catch (error) {
3317
+ logger.error("Execution cycle failed", { error: String(error) });
3318
+ const errorOutput = renderError("network-error", "without-cache");
3319
+ const formattedOutput2 = formatOutput(errorOutput, isPiped);
3320
+ safeStdoutWrite(formattedOutput2);
3321
+ logger.debug("=== cc-api-statusline execution completed ===");
3322
+ process.exit(0);
3323
+ }
2757
3324
  const executionTime = Date.now() - startTime;
2758
3325
  logger.debug("Execution completed", {
2759
3326
  exitCode: result.exitCode,
@@ -2762,7 +3329,7 @@ async function executePipedMode(args) {
2762
3329
  cacheUpdate: !!result.cacheUpdate
2763
3330
  });
2764
3331
  const formattedOutput = formatOutput(result.output, isPiped);
2765
- process.stdout.write(formattedOutput);
3332
+ safeStdoutWrite(formattedOutput);
2766
3333
  if (result.cacheUpdate) {
2767
3334
  writeCache(baseUrl, result.cacheUpdate);
2768
3335
  logger.debug("Cache written", { baseUrl });
@@ -2776,6 +3343,7 @@ function discardStdin() {
2776
3343
  if (!process.stdin.isTTY) {
2777
3344
  process.stdin.resume();
2778
3345
  process.stdin.on("data", () => {});
3346
+ process.stdin.on("error", () => {});
2779
3347
  }
2780
3348
  }
2781
3349
  async function main() {
@@ -2799,6 +3367,10 @@ async function main() {
2799
3367
  handleUninstall();
2800
3368
  return;
2801
3369
  }
3370
+ if (args.applyConfig) {
3371
+ handleApplyConfig();
3372
+ return;
3373
+ }
2802
3374
  if (process.stdin.isTTY && !args.once) {
2803
3375
  console.log("Interactive configuration mode coming soon.");
2804
3376
  console.log("Use --once for a single fetch, or configure as a Claude Code statusline command.");
@@ -2806,8 +3378,14 @@ async function main() {
2806
3378
  }
2807
3379
  await executePipedMode(args);
2808
3380
  }
3381
+ process.on("SIGTERM", () => {
3382
+ process.exit(0);
3383
+ });
3384
+ process.on("uncaughtException", (error) => {
3385
+ logger.error("Uncaught exception", { error: String(error) });
3386
+ process.exit(0);
3387
+ });
2809
3388
  main().catch((error) => {
2810
3389
  logger.error("Unhandled error in main", { error: String(error) });
2811
- console.error(`Fatal error: ${error}`);
2812
- process.exit(1);
3390
+ process.exit(0);
2813
3391
  });