cc-api-statusline 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,15 @@
1
1
  # cc-api-statusline
2
2
 
3
- A high-performance TUI statusline tool that polls API usage data from Claude API proxy services (sub2api, claude-relay-service, or custom providers) and renders a configurable one-line status display.
3
+ English | [简体中文](README.zh-CN.md)
4
+
5
+ <img src="docs/images/banner-screenshot.png" width="800" alt="cc-api-statusline banner">
6
+
7
+ A high-performance TUI statusline tool that polls API usage data from Claude API services (sub2api, claude-relay-service, or custom providers) and renders a configurable one-line status display.
4
8
 
5
9
  ## Features
6
10
 
7
- - ⚡ **Fast piped mode** — <25ms warm cache, <100ms p95
8
11
  - 🎨 **Highly configurable** — Layouts, colors, bar styles, display modes
9
12
  - 🔌 **Provider autodetection** — Works with sub2api, claude-relay-service, custom providers
10
- - 💾 **Smart caching** — Disk cache with atomic writes, TTL validation, automatic garbage collection
11
- - 🎯 **Claude Code integration** — Auto-setup with `--install` command
12
13
  - 📊 **Multiple components** — Daily/weekly/monthly quotas, balance, tokens, rate limits
13
14
  - 🔁 **Hot switching** — Auto-detects API endpoint and credential changes at runtime
14
15
  - 🔒 **Reliability** — No stale data display, race-condition-free writes, auto cache cleanup
@@ -43,7 +44,7 @@ export ANTHROPIC_AUTH_TOKEN="your-api-token"
43
44
  bunx cc-api-statusline@latest --once
44
45
  ```
45
46
 
46
- ### 3. Install as Claude Code widget (optional)
47
+ ### 3.a Install as Claude Code widget
47
48
 
48
49
  ```bash
49
50
  bunx cc-api-statusline@latest --install
@@ -54,6 +55,7 @@ This adds to `~/.claude/settings.json`:
54
55
  ```json
55
56
  {
56
57
  "statusLine": {
58
+ "id": "e62435aa-bdf5-4ded-a2e3-ae13582439db",
57
59
  "type": "command",
58
60
  "command": "bunx -y cc-api-statusline@latest",
59
61
  "padding": 0
@@ -61,6 +63,28 @@ This adds to `~/.claude/settings.json`:
61
63
  }
62
64
  ```
63
65
 
66
+ ### 3.b Install as [ccstatusline](https://github.com/anthropics/claude-code) Custom Command
67
+ <img src="docs/images/ccstatusline-command.png" width="800" alt="ccstatusline-command mode">
68
+ Add to `~/.claude/ccstatusline/config.json`:
69
+
70
+ ```json
71
+ {
72
+ "lines": [
73
+ [
74
+ {
75
+ "id": "e62435aa-bdf5-4ded-a2e3-ae13582439db",
76
+ "type": "custom-command",
77
+ "commandPath": "bunx -y cc-api-statusline@latest --embedded",
78
+ "preserveColors": true,
79
+ "timeout": 10000
80
+ }
81
+ ]
82
+ ]
83
+ }
84
+ ```
85
+
86
+ > **`--embedded` is required here.** Without it, cc-api-statusline prepends an ANSI reset (`\x1b[0m`) that breaks cc-statusline's powerline background colors. The flag tells cc-api-statusline it's running inside a host renderer that handles its own formatting.
87
+
64
88
  Using `bunx` ensures you always run the latest version without a global install. To uninstall:
65
89
 
66
90
  ```bash
@@ -142,31 +166,6 @@ cc-api-statusline --apply-config
142
166
 
143
167
  See [docs/api-config-reference.md](docs/api-config-reference.md) for the full schema.
144
168
 
145
- ## [ccstatusline](https://github.com/anthropics/claude-code) Custom Command
146
-
147
- Add to `~/.claude/ccstatusline/config.json`:
148
-
149
- ```json
150
- {
151
- "customCommands": {
152
- "usage": {
153
- "command": "cc-api-statusline",
154
- "description": "API usage statusline",
155
- "type": "piped"
156
- }
157
- },
158
- "widgets": [
159
- {
160
- "type": "customCommand",
161
- "command": "usage",
162
- "refreshIntervalMs": 30000,
163
- "maxWidth": 100,
164
- "preserveColors": true
165
- }
166
- ]
167
- }
168
- ```
169
-
170
169
  ## Environment Variables
171
170
 
172
171
  All variables are optional at the shell level — `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` can be set via `settings.json` env overlay instead of shell exports (see [Quick Start](#quick-start)).
@@ -177,7 +176,8 @@ All variables are optional at the shell level — `ANTHROPIC_BASE_URL` and `ANTH
177
176
  | `ANTHROPIC_AUTH_TOKEN` | Yes | API key or token |
178
177
  | `CC_STATUSLINE_PROVIDER` | Yes | Override provider detection (`sub2api`, `claude-relay-service`, or custom) |
179
178
  | `CC_STATUSLINE_POLL` | Yes | Override poll interval (seconds, min 5) |
180
- | `CC_STATUSLINE_TIMEOUT` | Yes | Piped mode timeout (milliseconds, default 1000) |
179
+ | `CC_STATUSLINE_TIMEOUT` | Yes | Piped mode timeout (milliseconds, default 5000) |
180
+ | `CC_API_STATUSLINE_EMBEDDED` | Yes | Skip host formatting when set to `"1"` or `"true"`. Alternative to `--embedded` flag; prefer the flag in `commandPath` configs |
181
181
  | `DEBUG` or `CC_STATUSLINE_DEBUG` | Yes | Enable debug logging to `~/.claude/cc-api-statusline/debug.log` |
182
182
 
183
183
  ## Troubleshooting
@@ -219,7 +219,7 @@ cc-api-statusline --once
219
219
  DEBUG=1 cc-api-statusline --once
220
220
  ```
221
221
 
222
- Verify `pipedRequestTimeoutMs` in config (default 800ms) and check `~/.claude/cc-api-statusline/cache-*.json` exists.
222
+ Verify `pipedRequestTimeoutMs` in config (default 3000ms) and check `~/.claude/cc-api-statusline/cache-*.json` exists.
223
223
 
224
224
  ### Widget shows `[Exit: 1]` in Claude Code
225
225
 
@@ -0,0 +1,295 @@
1
+ # cc-api-statusline
2
+
3
+ [English](README.md) | 简体中文
4
+
5
+ 在ClaudeCode状态栏显示API用量,通过轮询 Claude API 服务(sub2api、claude-relay-service 或自定义提供商)获取用量数据,并以可配置显示样式。
6
+
7
+ ## 特性
8
+
9
+ - 🎨 **高度可配置** — 布局、颜色、进度条样式、显示模式任意调整
10
+ - 🔌 **提供商自动识别** — 开箱支持 sub2api、claude-relay-service 及自定义提供商
11
+ - 🎯 **Claude Code 集成** — 一键 `--install` 完成安装
12
+ - 📊 **多维度用量展示** — 每日/每周/每月配额、余额、Token数、速率限制
13
+ - 🔁 **热切换** — 自动感知 API 端点和凭证变更,无需重启
14
+ - 🔒 **高可靠性** — 无过期数据展示、无竞争条件写入、缓存自动清理
15
+
16
+ ## 快速上手
17
+
18
+ ### 1. 配置 API 端点
19
+
20
+ 需要准备 `ANTHROPIC_BASE_URL`(代理地址)和 `ANTHROPIC_AUTH_TOKEN`(API 密钥)两个变量。
21
+
22
+ **推荐方式:写入 `~/.claude/settings.json` 的 env 字段**(会自动传递给组件):
23
+
24
+ ```json
25
+ {
26
+ "env": {
27
+ "ANTHROPIC_BASE_URL": "https://your-proxy.example.com",
28
+ "ANTHROPIC_AUTH_TOKEN": "your-api-token"
29
+ }
30
+ }
31
+ ```
32
+
33
+ 也可以直接在 Shell 中导出:
34
+
35
+ ```bash
36
+ export ANTHROPIC_BASE_URL="https://your-proxy.example.com"
37
+ export ANTHROPIC_AUTH_TOKEN="your-api-token"
38
+ ```
39
+
40
+ ### 2. 预览效果
41
+
42
+ ```bash
43
+ bunx cc-api-statusline@latest --once
44
+ ```
45
+
46
+ ### 3.a 安装为 Claude Code 状态栏组件
47
+
48
+ ```bash
49
+ bunx cc-api-statusline@latest --install
50
+ ```
51
+
52
+ 此命令会自动向 `~/.claude/settings.json` 写入以下配置:
53
+
54
+ ```json
55
+ {
56
+ "statusLine": {
57
+ "type": "command",
58
+ "command": "bunx -y cc-api-statusline@latest",
59
+ "padding": 0
60
+ }
61
+ }
62
+ ```
63
+ ### 3.b 安装为 [ccstatusline](https://github.com/anthropics/claude-code) 自定义命令
64
+ <img src="docs/images/ccstatusline-command.png" width="800" alt="ccstatusline-command mode">
65
+ 在 `~/.claude/ccstatusline/config.json` 中添加如下配置:
66
+
67
+ ```json
68
+ {
69
+ "lines": [
70
+ [
71
+ {
72
+ "id": "e62435aa-bdf5-4ded-a2e3-ae13582439db",
73
+ "type": "custom-command",
74
+ "commandPath": "bunx -y cc-api-statusline@latest --embedded",
75
+ "preserveColors": true,
76
+ "timeout": 10000
77
+ }
78
+ ]
79
+ ]
80
+ }
81
+ ```
82
+
83
+ > **此处必须加 `--embedded`。** 不加的话,cc-api-statusline 会在输出前插入 ANSI 重置码(`\x1b[0m`),破坏 cc-statusline 的 powerline 背景色。该标志告知 cc-api-statusline 当前运行在宿主渲染器内部,由宿主负责格式化。
84
+
85
+ 使用 `bunx` 可每次自动拉取最新版本,无需全局安装。如需卸载:
86
+
87
+ ```bash
88
+ bunx cc-api-statusline --uninstall
89
+ ```
90
+
91
+ 也支持全局安装:
92
+
93
+ ```bash
94
+ bun add -g cc-api-statusline
95
+ # 或
96
+ npm install -g cc-api-statusline
97
+ ```
98
+
99
+ ## 热切换
100
+
101
+ 当 `ANTHROPIC_BASE_URL` 或 `ANTHROPIC_AUTH_TOKEN` 发生变化时(例如切换 Claude Code 配置文件或轮换密钥),cc-api-statusline 会自动检测并触发切换。切换期间会短暂显示过渡提示(`⟳ Switching provider...`),随后从新端点刷新数据,全程无需重启。
102
+
103
+ ## 配置
104
+
105
+ ### 样式配置(`~/.claude/cc-api-statusline/config.json`)
106
+
107
+ ```json
108
+ {
109
+ "display": {
110
+ "layout": "standard",
111
+ "displayMode": "text",
112
+ "progressStyle": "icon",
113
+ "barStyle": "block",
114
+ "divider": { "text": "|", "margin": 1, "color": "#555753" },
115
+ "maxWidth": 100
116
+ },
117
+ "components": {
118
+ "daily": true,
119
+ "weekly": true,
120
+ "monthly": true,
121
+ "balance": true,
122
+ "tokens": false,
123
+ "rateLimit": false
124
+ }
125
+ }
126
+ ```
127
+
128
+ 主要配置项说明:
129
+
130
+ | 配置项 | 可选值 | 默认值 | 说明 |
131
+ |--------|--------|--------|------|
132
+ | `layout` | `standard` / `percent-first` | `standard` | 标签、进度条、数值的排列顺序 |
133
+ | `displayMode` | `text` / `compact` / `emoji` / `nerd` / `hidden` | `text` | 标签样式。`nerd` 需安装 [Nerd Font](https://www.nerdfonts.com/font-downloads) |
134
+ | `progressStyle` | `bar` / `icon` / `hidden` | `icon` | 用量进度的可视化方式。`icon` 需安装 [Nerd Font](https://www.nerdfonts.com/font-downloads) |
135
+ | `barStyle` | `block` / `classic` / `dot` / `shade` / `pipe` / `braille` / `square` / `star` | `block` | 进度条字符样式 |
136
+ | `barSize` | `small` / `small-medium` / `medium` / `medium-large` / `large` | `medium` | 进度条宽度(4–12 个字符) |
137
+ | `divider` | `DividerConfig` 或 `false` | `{ text: "\|", margin: 1, color: "#555753" }` | 组件间分隔符;设为 `false` 可禁用 |
138
+ | `maxWidth` | 20–100 | `100` | 状态栏最大宽度占终端宽度的百分比 |
139
+
140
+ 完整样式参考(包含每组件独立配置、颜色别名、倒计时等高级选项)请查阅 [docs/spec-tui-style.md](docs/spec-tui-style.md)。
141
+
142
+ #### User-Agent 伪装
143
+
144
+ 部分提供商会限制非 Claude Code 客户端的访问,可启用此选项绕过:
145
+
146
+ ```json
147
+ {
148
+ "spoofClaudeCodeUA": true
149
+ }
150
+ ```
151
+
152
+ - `false` / `undefined` — 不发送 User-Agent 请求头(默认)
153
+ - `true` — 自动获取当前 Claude Code 版本,获取失败则回退至 `claude-cli/2.1.56 (external, cli)`
154
+ - `"string"` — 使用指定的自定义 User-Agent 字符串
155
+
156
+ ### API 提供商配置(`~/.claude/cc-api-statusline/api-config/`)
157
+
158
+ 在此目录下以 JSON 文件形式定义自定义提供商。添加或修改后,执行以下命令使其生效:
159
+
160
+ ```bash
161
+ cc-api-statusline --apply-config
162
+ ```
163
+
164
+ 完整 Schema 请参阅 [docs/api-config-reference.md](docs/api-config-reference.md)。
165
+
166
+ ## 环境变量
167
+
168
+ 以下所有变量均为可选——`ANTHROPIC_BASE_URL` 和 `ANTHROPIC_AUTH_TOKEN` 可通过 `settings.json` 的 env 字段配置,无需在 Shell 中手动导出(详见[快速上手](#快速上手))。
169
+
170
+ | 变量 | 是否可选 | 说明 |
171
+ |------|----------|------|
172
+ | `ANTHROPIC_BASE_URL` | 是 | API 端点地址(如 `https://api.sub2api.com`) |
173
+ | `ANTHROPIC_AUTH_TOKEN` | 是 | API 密钥或Token |
174
+ | `CC_STATUSLINE_PROVIDER` | 是 | 手动指定提供商(`sub2api`、`claude-relay-service` 或自定义) |
175
+ | `CC_STATUSLINE_POLL` | 是 | 轮询间隔(秒,最小 5) |
176
+ | `CC_STATUSLINE_TIMEOUT` | 是 | 管道模式超时时间(毫秒,默认 5000) |
177
+ | `DEBUG` 或 `CC_STATUSLINE_DEBUG` | 是 | 开启调试日志,输出至 `~/.claude/cc-api-statusline/debug.log` |
178
+
179
+ ## 常见问题
180
+
181
+ ### 提示 "Missing required environment variable"
182
+
183
+ 请通过 Shell 导出或 `settings.json` env 字段设置 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_AUTH_TOKEN`(详见[快速上手](#快速上手))。
184
+
185
+ ### 提示 "Unknown provider"
186
+
187
+ 提供商自动识别失败,请手动指定:
188
+
189
+ ```bash
190
+ export CC_STATUSLINE_PROVIDER="sub2api"
191
+ ```
192
+
193
+ 或在 `api-config/` 目录下定义自定义提供商。
194
+
195
+ ### 显示 "[offline]" 或 "[stale]"
196
+
197
+ 通常由网络错误或缓存过期导致,可开启调试日志排查:
198
+
199
+ ```bash
200
+ DEBUG=1 cc-api-statusline --once
201
+ tail -f ~/.claude/cc-api-statusline/debug.log
202
+ ```
203
+
204
+ 排查要点:
205
+ - `ANTHROPIC_BASE_URL` 对应的网络是否可达
206
+ - API 端点是否正常响应
207
+ - Token是否有效且未过期
208
+
209
+ ### 管道模式响应较慢
210
+
211
+ ```bash
212
+ # 单独预热缓存
213
+ cc-api-statusline --once
214
+ # 查看详细耗时
215
+ DEBUG=1 cc-api-statusline --once
216
+ ```
217
+
218
+ 检查配置中的 `pipedRequestTimeoutMs`(默认 3000ms),并确认 `~/.claude/cc-api-statusline/cache-*.json` 文件已存在。
219
+
220
+ ### Claude Code 中组件显示 `[Exit: 1]`
221
+
222
+ 在 `~/.claude/settings.json` 中开启调试日志:
223
+
224
+ ```json
225
+ {
226
+ "statusLine": {
227
+ "type": "command",
228
+ "command": "DEBUG=1 bunx -y cc-api-statusline@latest",
229
+ "padding": 0
230
+ }
231
+ }
232
+ ```
233
+
234
+ 然后查看日志:`tail -f ~/.claude/cc-api-statusline/debug.log`
235
+
236
+ ## 开发
237
+
238
+ | 命令 | 说明 |
239
+ |------|------|
240
+ | `bun install` | 安装依赖 |
241
+ | `bun run start` | 单次获取(--once 模式),用于快速调试 |
242
+ | `bun run example` | 模拟管道模式 |
243
+ | `bun run test` | 运行测试 |
244
+ | `bun run lint` | 代码检查 |
245
+ | `bun run build` | 构建 |
246
+ | `bun run check` | 运行全部检查 |
247
+
248
+ ### 调试日志
249
+
250
+ 启用详细执行日志:
251
+
252
+ ```bash
253
+ # 开启调试
254
+ DEBUG=1 cc-api-statusline --once
255
+
256
+ # 用于 Claude Code 组件时,在 settings.json 中设置:
257
+ # "command": "DEBUG=1 bunx -y cc-api-statusline@latest"
258
+
259
+ # 实时追踪日志
260
+ tail -f ~/.claude/cc-api-statusline/debug.log
261
+
262
+ # 搜索错误记录
263
+ grep "ERROR" ~/.claude/cc-api-statusline/debug.log
264
+ ```
265
+
266
+ 调试日志涵盖:执行时间戳、模式检测、配置与缓存状态、执行路径(A/B/C/D)、请求耗时及错误详情。
267
+
268
+ 日志文件自动轮转(约每 20 次调用触发一次):
269
+ - `debug.log` ≥ 500 KB → 归档为 `debug.YYYY-MM-DDTHH-MM.log`
270
+ - 归档超过 24 小时 → gzip 压缩
271
+ - 压缩归档超过 3 天 → 自动删除
272
+
273
+ ## 测试
274
+
275
+ - **691 个测试**,覆盖 **39 个测试文件**
276
+ - 所有服务、渲染器及工具函数的单元测试
277
+ - 核心执行路径测试(A/B/C/D)
278
+ - 隔离环境端到端冒烟测试
279
+ - 性能测试(验证 p95 < 600ms)
280
+ - 缓存垃圾回收测试
281
+ - GitHub Actions CI/CD 流水线
282
+
283
+ 运行:`bun run check`
284
+
285
+ ## 许可证
286
+
287
+ MIT
288
+
289
+ ## 相关文档
290
+
291
+ - [实现手册](docs/implementation-handbook.md)
292
+ - [当前实现说明](docs/current-implementation.md)
293
+ - [TUI 样式规范](docs/spec-tui-style.md)
294
+ - [API 轮询规范](docs/spec-api-polling.md)
295
+ - [自定义提供商规范](docs/spec-custom-providers.md)
@@ -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: "1.0.1",
7
+ version: "1.1.1",
8
8
  description: "Claude Code statusline tool that polls API usage from third-party proxy backends",
9
9
  type: "module",
10
10
  bin: {
@@ -70,6 +70,7 @@ function parseArgs() {
70
70
  let uninstall = false;
71
71
  let applyConfig = false;
72
72
  let force = false;
73
+ let embedded = false;
73
74
  let configPath;
74
75
  let runner;
75
76
  for (let i = 0;i < args.length; i++) {
@@ -88,6 +89,8 @@ function parseArgs() {
88
89
  applyConfig = true;
89
90
  } else if (arg === "--force") {
90
91
  force = true;
92
+ } else if (arg === "--embedded") {
93
+ embedded = true;
91
94
  } else if (arg === "--config" && i + 1 < args.length) {
92
95
  configPath = args[i + 1];
93
96
  i++;
@@ -99,7 +102,9 @@ function parseArgs() {
99
102
  i++;
100
103
  }
101
104
  }
102
- return { help, version, once, install, uninstall, applyConfig, force, configPath, runner };
105
+ const envVal = process.env["CC_API_STATUSLINE_EMBEDDED"];
106
+ embedded = embedded || envVal === "1" || envVal === "true";
107
+ return { help, version, once, install, uninstall, applyConfig, force, embedded, configPath, runner };
103
108
  }
104
109
  function showHelp() {
105
110
  console.log(`
@@ -118,14 +123,16 @@ Options:
118
123
  --apply-config Apply endpoint config changes (updates lock file, clears caches)
119
124
  --runner <runner> Package runner: npx or bunx (default: auto-detect)
120
125
  --force Force overwrite existing statusline configuration
126
+ --embedded Skip host formatting (for use inside cc-statusline)
121
127
 
122
128
  Environment Variables:
123
- ANTHROPIC_BASE_URL API endpoint (required)
124
- ANTHROPIC_AUTH_TOKEN API key (required)
125
- CC_STATUSLINE_PROVIDER Override provider detection
126
- CC_STATUSLINE_POLL Override poll interval (seconds)
127
- CC_STATUSLINE_TIMEOUT Piped mode timeout (milliseconds, default 1000)
128
- DEBUG or CC_STATUSLINE_DEBUG Enable debug logging to ~/.claude/cc-api-statusline/debug.log
129
+ ANTHROPIC_BASE_URL API endpoint (required)
130
+ ANTHROPIC_AUTH_TOKEN API key (required)
131
+ CC_STATUSLINE_PROVIDER Override provider detection
132
+ CC_STATUSLINE_POLL Override poll interval (seconds)
133
+ CC_STATUSLINE_TIMEOUT Piped mode timeout (milliseconds, default 5000)
134
+ DEBUG or CC_STATUSLINE_DEBUG Enable debug logging to ~/.claude/cc-api-statusline/debug.log
135
+ CC_API_STATUSLINE_EMBEDDED Skip host formatting when set to "1" or "true" (for use inside cc-statusline)
129
136
 
130
137
  Config File:
131
138
  ~/.claude/cc-api-statusline/config.json
@@ -177,8 +184,10 @@ import { spawn } from "child_process";
177
184
  import { dirname, join } from "path";
178
185
 
179
186
  // src/core/constants.ts
180
- var DEFAULT_FETCH_TIMEOUT_MS = 5000;
187
+ var DEFAULT_TIMEOUT_BUDGET_MS = 5000;
188
+ var TTY_TIMEOUT_BUDGET_MS = DEFAULT_TIMEOUT_BUDGET_MS * 2;
181
189
  var EXIT_BUFFER_MS = 50;
190
+ var TIMEOUT_HEADROOM_MS = 100;
182
191
  var STALENESS_THRESHOLD_MINUTES = 5;
183
192
  var VERY_STALE_THRESHOLD_MINUTES = 30;
184
193
  var GC_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -189,6 +198,12 @@ var LOG_ROTATION_PROBABILITY = 0.05;
189
198
  var LOG_MAX_SIZE_BYTES = 512 * 1024;
190
199
  var LOG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
191
200
  var LOG_RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
201
+ var HEALTH_MATCH_WILDCARD = "*";
202
+ var DETECTION_TTL_BASE_S = 86400;
203
+ var DETECTION_TTL_MAX_S = 604800;
204
+ var DETECTION_TTL_CHANGED_S = 3600;
205
+ var DETECTION_TTL_FAILED_S = 300;
206
+ var MAINTENANCE_GC_PROBABILITY = 0.1;
192
207
 
193
208
  // src/services/log-rotator.ts
194
209
  var ARCHIVE_LOG_RE = /^debug\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}\.log$/;
@@ -563,7 +578,7 @@ var DEFAULT_CONFIG = {
563
578
  chill: { tiers: buildTiers(["cyan", "cyan", "blue", "blue", "magenta"]) }
564
579
  },
565
580
  pollIntervalSeconds: 30,
566
- pipedRequestTimeoutMs: 800
581
+ pipedRequestTimeoutMs: DEFAULT_TIMEOUT_BUDGET_MS
567
582
  };
568
583
  var BAR_SIZE_MAP = {
569
584
  small: 4,
@@ -635,12 +650,11 @@ function isCacheEntry(value) {
635
650
  const c = value;
636
651
  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");
637
652
  }
638
- var PROVIDER_DETECTION_TTL_SECONDS = 86400;
639
653
  function isProviderDetectionCacheEntry(value) {
640
654
  if (typeof value !== "object" || value === null)
641
655
  return false;
642
656
  const c = value;
643
- 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";
657
+ return typeof c["baseUrl"] === "string" && typeof c["provider"] === "string" && (c["detectedVia"] === "health-probe" || c["detectedVia"] === "override") && typeof c["detectedAt"] === "string" && typeof c["ttlSeconds"] === "number";
644
658
  }
645
659
  // src/services/endpoint-config.ts
646
660
  import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
@@ -813,7 +827,6 @@ function getBuiltInEndpointConfigs() {
813
827
  resetSemantics: "rolling-window"
814
828
  },
815
829
  detection: {
816
- urlPatterns: ["/apistats", "/api/user-stats"],
817
830
  healthMatch: { service: "*" }
818
831
  },
819
832
  responseMapping: {
@@ -998,7 +1011,15 @@ function writeDefaultConfigs(customDir) {
998
1011
  ensureDir(apiConfigDir);
999
1012
  if (!existsSync4(configPath)) {
1000
1013
  const styleConfigWithoutColors = serializableConfig(getDefaultStyleConfig());
1001
- atomicWriteFile(configPath, JSON.stringify(styleConfigWithoutColors, null, 2), {
1014
+ const autoColorEntry = DEFAULT_CONFIG.colors?.auto;
1015
+ if (!autoColorEntry || typeof autoColorEntry === "string") {
1016
+ throw new Error("DEFAULT_CONFIG is missing the built-in auto color alias");
1017
+ }
1018
+ const configWithAutoColor = {
1019
+ ...styleConfigWithoutColors,
1020
+ colors: { auto: { tiers: autoColorEntry.tiers } }
1021
+ };
1022
+ atomicWriteFile(configPath, JSON.stringify(configWithAutoColor, null, 2), {
1002
1023
  appendNewline: true
1003
1024
  });
1004
1025
  }
@@ -1090,7 +1111,7 @@ async function readBodyWithLimit(response) {
1090
1111
  throw new HttpError(`Failed to read response body: ${error}`);
1091
1112
  }
1092
1113
  }
1093
- async function secureFetch(url, options = {}, timeoutMs = 5000, userAgent) {
1114
+ async function secureFetch(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_BUDGET_MS, userAgent) {
1094
1115
  const signal = AbortSignal.timeout(timeoutMs);
1095
1116
  const fetchOptions = {
1096
1117
  ...options,
@@ -1139,7 +1160,33 @@ function extractOrigin(baseUrl) {
1139
1160
  return baseUrl;
1140
1161
  }
1141
1162
  }
1142
- async function probeHealth(baseUrl, timeoutMs = 1500) {
1163
+ function matchHealthResponse(data, endpointConfigs) {
1164
+ const candidates = Object.entries(endpointConfigs).reduce((acc, [providerId, config]) => {
1165
+ const healthMatch = config.detection?.healthMatch;
1166
+ if (healthMatch != null && Object.keys(healthMatch).length > 0) {
1167
+ acc.push({ providerId, healthMatch });
1168
+ }
1169
+ return acc;
1170
+ }, []);
1171
+ candidates.sort((a, b) => {
1172
+ const diff = Object.keys(b.healthMatch).length - Object.keys(a.healthMatch).length;
1173
+ return diff !== 0 ? diff : a.providerId.localeCompare(b.providerId);
1174
+ });
1175
+ for (const { providerId, healthMatch } of candidates) {
1176
+ const matches = Object.entries(healthMatch).every(([field, expected]) => {
1177
+ const actual = data[field];
1178
+ if (expected === HEALTH_MATCH_WILDCARD) {
1179
+ return typeof actual === "string";
1180
+ }
1181
+ return actual === expected;
1182
+ });
1183
+ if (matches) {
1184
+ return providerId;
1185
+ }
1186
+ }
1187
+ return null;
1188
+ }
1189
+ async function probeHealth(baseUrl, timeoutMs = DEFAULT_TIMEOUT_BUDGET_MS, endpointConfigs = {}) {
1143
1190
  const origin = extractOrigin(baseUrl);
1144
1191
  const healthUrl = `${origin}/health`;
1145
1192
  logger.debug("Probing health endpoint", { healthUrl, timeoutMs });
@@ -1152,13 +1199,10 @@ async function probeHealth(baseUrl, timeoutMs = 1500) {
1152
1199
  }, timeoutMs);
1153
1200
  const data = JSON.parse(responseText);
1154
1201
  logger.debug("Health probe response", { data });
1155
- if (typeof data["service"] === "string") {
1156
- logger.debug("Detected provider from service field", { provider: data["service"] });
1157
- return data["service"];
1158
- }
1159
- if (data["status"] === "ok") {
1160
- logger.debug("Detected sub2api from status: ok pattern");
1161
- return "sub2api";
1202
+ const matched = matchHealthResponse(data, endpointConfigs);
1203
+ if (matched) {
1204
+ logger.debug("Detected provider from health response", { provider: matched });
1205
+ return matched;
1162
1206
  }
1163
1207
  logger.debug("Health probe returned unrecognized pattern", { data });
1164
1208
  return null;
@@ -1167,6 +1211,15 @@ async function probeHealth(baseUrl, timeoutMs = 1500) {
1167
1211
  return null;
1168
1212
  }
1169
1213
  }
1214
+ async function probeHealthWithMetrics(baseUrl, timeoutMs, endpointConfigs) {
1215
+ const start = Date.now();
1216
+ const matchedProvider = await probeHealth(baseUrl, timeoutMs, endpointConfigs);
1217
+ return {
1218
+ success: matchedProvider !== null,
1219
+ matchedProvider,
1220
+ responseTimeMs: Date.now() - start
1221
+ };
1222
+ }
1170
1223
 
1171
1224
  // src/services/cache.ts
1172
1225
  import { readFileSync as readFileSync6, unlinkSync as unlinkSync4 } from "fs";
@@ -1289,6 +1342,37 @@ function readProviderDetectionCache(baseUrl) {
1289
1342
  return null;
1290
1343
  }
1291
1344
  }
1345
+ function deleteProviderDetectionCache(baseUrl) {
1346
+ const path = getProviderDetectionCachePath(baseUrl);
1347
+ try {
1348
+ unlinkSync4(path);
1349
+ } catch (err) {
1350
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
1351
+ return;
1352
+ }
1353
+ logger.warn(`Failed to delete provider detection cache at ${path}: ${err}`);
1354
+ }
1355
+ }
1356
+ function readDetectionCacheMeta(baseUrl) {
1357
+ const defaultTtlMs = DETECTION_TTL_BASE_S * 1000;
1358
+ const path = getProviderDetectionCachePath(baseUrl);
1359
+ let content;
1360
+ try {
1361
+ content = readFileSync6(path, "utf-8");
1362
+ } catch {
1363
+ return { ageMs: null, ttlMs: defaultTtlMs };
1364
+ }
1365
+ try {
1366
+ const data = JSON.parse(content);
1367
+ if (!isProviderDetectionCacheEntry(data))
1368
+ return { ageMs: null, ttlMs: defaultTtlMs };
1369
+ const detectedAt = new Date(data.detectedAt).getTime();
1370
+ const ageMs = isNaN(detectedAt) ? null : Date.now() - detectedAt;
1371
+ return { ageMs, ttlMs: data.ttlSeconds * 1000 };
1372
+ } catch {
1373
+ return { ageMs: null, ttlMs: defaultTtlMs };
1374
+ }
1375
+ }
1292
1376
  function writeProviderDetectionCache(baseUrl, entry) {
1293
1377
  const path = getProviderDetectionCachePath(baseUrl);
1294
1378
  try {
@@ -1302,27 +1386,7 @@ function writeProviderDetectionCache(baseUrl, entry) {
1302
1386
 
1303
1387
  // src/providers/autodetect.ts
1304
1388
  var detectionCache = new Map;
1305
- function detectProviderFromUrlPattern(baseUrl, endpointConfigs = {}, options = {}) {
1306
- const includeBuiltInPatterns = options.includeBuiltInPatterns ?? true;
1307
- const fallbackProvider = Object.prototype.hasOwnProperty.call(options, "fallbackProvider") ? options.fallbackProvider ?? null : "sub2api";
1308
- const normalizedUrl = baseUrl.toLowerCase().replace(/\/$/, "");
1309
- for (const [providerId, config] of Object.entries(endpointConfigs)) {
1310
- const urlPatterns = config.detection?.urlPatterns;
1311
- if (urlPatterns && urlPatterns.length > 0) {
1312
- for (const pattern of urlPatterns) {
1313
- const normalizedPattern = pattern.toLowerCase();
1314
- if (normalizedUrl.includes(normalizedPattern)) {
1315
- return providerId;
1316
- }
1317
- }
1318
- }
1319
- }
1320
- if (includeBuiltInPatterns && (normalizedUrl.includes("/apistats") || normalizedUrl.includes("/api/user-stats"))) {
1321
- return "claude-relay-service";
1322
- }
1323
- return fallbackProvider;
1324
- }
1325
- async function resolveProvider(baseUrl, providerOverride, endpointConfigs = {}, probeTimeoutMs = 1500) {
1389
+ async function resolveProvider(baseUrl, providerOverride, endpointConfigs = {}, probeTimeoutMs = DEFAULT_TIMEOUT_BUDGET_MS) {
1326
1390
  if (providerOverride) {
1327
1391
  logger.debug("Provider override detected", { provider: providerOverride });
1328
1392
  return providerOverride;
@@ -1344,33 +1408,18 @@ async function resolveProvider(baseUrl, providerOverride, endpointConfigs = {},
1344
1408
  });
1345
1409
  return diskCached.provider;
1346
1410
  }
1347
- const endpointPatternProvider = detectProviderFromUrlPattern(baseUrl, endpointConfigs, {
1348
- includeBuiltInPatterns: false,
1349
- fallbackProvider: null
1350
- });
1351
- if (endpointPatternProvider) {
1352
- logger.debug("Provider detected via endpoint URL pattern", { provider: endpointPatternProvider });
1353
- cacheProviderDetection(baseUrl, endpointPatternProvider, "url-pattern");
1354
- return endpointPatternProvider;
1355
- }
1356
1411
  logger.debug("Attempting health probe", { baseUrl, timeoutMs: probeTimeoutMs });
1357
- const probedProvider = await probeHealth(baseUrl, probeTimeoutMs);
1412
+ const probedProvider = await probeHealth(baseUrl, probeTimeoutMs, endpointConfigs);
1358
1413
  if (probedProvider) {
1359
1414
  logger.debug("Provider detected via health probe", { provider: probedProvider });
1360
1415
  cacheProviderDetection(baseUrl, probedProvider, "health-probe");
1361
1416
  return probedProvider;
1362
1417
  }
1363
- const patternProvider = detectProviderFromUrlPattern(baseUrl, {});
1364
- if (!patternProvider) {
1365
- logger.debug("Provider URL pattern detection had no match, defaulting to sub2api");
1366
- cacheProviderDetection(baseUrl, "sub2api", "url-pattern");
1367
- return "sub2api";
1368
- }
1369
- logger.debug("Provider detected via built-in URL pattern", { provider: patternProvider });
1370
- cacheProviderDetection(baseUrl, patternProvider, "url-pattern");
1371
- return patternProvider;
1418
+ logger.debug("Health probe failed, defaulting to sub2api");
1419
+ cacheProviderDetection(baseUrl, "sub2api", "health-probe");
1420
+ return "sub2api";
1372
1421
  }
1373
- function cacheProviderDetection(baseUrl, provider, detectedVia) {
1422
+ function cacheProviderDetection(baseUrl, provider, detectedVia, ttlSeconds = DETECTION_TTL_BASE_S) {
1374
1423
  const now = new Date().toISOString();
1375
1424
  detectionCache.set(baseUrl, {
1376
1425
  provider,
@@ -1381,9 +1430,15 @@ function cacheProviderDetection(baseUrl, provider, detectedVia) {
1381
1430
  provider,
1382
1431
  detectedVia,
1383
1432
  detectedAt: now,
1384
- ttlSeconds: PROVIDER_DETECTION_TTL_SECONDS
1433
+ ttlSeconds
1385
1434
  });
1386
1435
  }
1436
+ function cacheProviderDetectionWithTtl(baseUrl, provider, ttlSeconds) {
1437
+ cacheProviderDetection(baseUrl, provider, "health-probe", ttlSeconds);
1438
+ }
1439
+ function invalidateDetectionCache(baseUrl) {
1440
+ detectionCache.delete(baseUrl);
1441
+ }
1387
1442
  function clearDetectionCache() {
1388
1443
  detectionCache.clear();
1389
1444
  }
@@ -1539,7 +1594,7 @@ function mapPeriodTokens(data) {
1539
1594
  cost: data.cost ?? 0
1540
1595
  };
1541
1596
  }
1542
- async function fetchSub2api(baseUrl, token, config, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
1597
+ async function fetchSub2api(baseUrl, token, config, timeoutMs = DEFAULT_TIMEOUT_BUDGET_MS) {
1543
1598
  const url = `${baseUrl}/v1/usage`;
1544
1599
  const resolvedUA = resolveUserAgent(config.spoofClaudeCodeUA);
1545
1600
  if (resolvedUA) {
@@ -1620,7 +1675,7 @@ function computeWeeklyResetTime(resetDay, resetHour) {
1620
1675
  const resetDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilReset, resetHour, 0, 0, 0));
1621
1676
  return resetDate.toISOString();
1622
1677
  }
1623
- async function fetchClaudeRelayService(baseUrl, token, config, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
1678
+ async function fetchClaudeRelayService(baseUrl, token, config, timeoutMs = DEFAULT_TIMEOUT_BUDGET_MS) {
1624
1679
  const origin = extractOrigin(baseUrl);
1625
1680
  const url = `${origin}/apiStats/api/user-stats`;
1626
1681
  const resolvedUA = resolveUserAgent(config.spoofClaudeCodeUA);
@@ -1860,7 +1915,7 @@ function validateEndpointConfigSemantics(config) {
1860
1915
  }
1861
1916
  return null;
1862
1917
  }
1863
- async function fetchEndpoint(baseUrl, token, appConfig, endpointConfig, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
1918
+ async function fetchEndpoint(baseUrl, token, appConfig, endpointConfig, timeoutMs = DEFAULT_TIMEOUT_BUDGET_MS) {
1864
1919
  const validationError = validateEndpointConfigSemantics(endpointConfig);
1865
1920
  if (validationError) {
1866
1921
  throw new Error(`Invalid endpoint config: ${validationError}`);
@@ -2399,11 +2454,14 @@ var TEXT_PROGRESS_ICONS = [
2399
2454
  "◕",
2400
2455
  "●"
2401
2456
  ];
2457
+ function calcIconIndex(percent, bucketSize, maxIndex) {
2458
+ return Math.min(maxIndex, Math.ceil(Math.round(percent) / bucketSize));
2459
+ }
2402
2460
  function calcNerdIconIndex(percent) {
2403
- return Math.min(8, Math.ceil(percent / 12.5));
2461
+ return calcIconIndex(percent, 12.5, 8);
2404
2462
  }
2405
2463
  function calcTextIconIndex(percent) {
2406
- return Math.min(4, Math.ceil(percent / 25));
2464
+ return calcIconIndex(percent, 25, 4);
2407
2465
  }
2408
2466
  function getProgressIcon(percent, nerdFontAvailable = true) {
2409
2467
  if (!nerdFontAvailable) {
@@ -2511,7 +2569,7 @@ function renderBalanceComponent(balance, options, componentConfig, globalConfig,
2511
2569
  const isUnlimited = balance.remaining === -1;
2512
2570
  let usagePercent = null;
2513
2571
  if (!isUnlimited && balance.initial !== null && balance.initial > 0) {
2514
- usagePercent = (balance.initial - balance.remaining) / balance.initial * 100;
2572
+ usagePercent = calculateUsagePercent(balance.initial - balance.remaining, balance.initial);
2515
2573
  }
2516
2574
  const effectivePercent = isUnlimited ? 0 : usagePercent;
2517
2575
  const label = renderLabel("balance", displayMode, componentConfig);
@@ -2931,6 +2989,35 @@ function isComponentId(key) {
2931
2989
  return DEFAULT_COMPONENT_ORDER.includes(key);
2932
2990
  }
2933
2991
 
2992
+ // src/core/error-classifier.ts
2993
+ function classifyFetchError(error) {
2994
+ if (error && typeof error === "object") {
2995
+ if ("statusCode" in error) {
2996
+ const statusCode = error.statusCode;
2997
+ if (statusCode === 404 || statusCode === 410) {
2998
+ return "site-closed";
2999
+ }
3000
+ return "transient";
3001
+ }
3002
+ if (error instanceof Error) {
3003
+ if (error.name === "TimeoutError") {
3004
+ return "transient";
3005
+ }
3006
+ if (error.name === "ResponseTooLargeError") {
3007
+ return "provider-mismatch";
3008
+ }
3009
+ if (error instanceof SyntaxError) {
3010
+ return "provider-mismatch";
3011
+ }
3012
+ const msg = error.message.toLowerCase();
3013
+ if (msg.includes("invalid response") || msg.includes("expected object") || msg.includes("missing data") || msg.includes("missing limits")) {
3014
+ return "provider-mismatch";
3015
+ }
3016
+ }
3017
+ }
3018
+ return "transient";
3019
+ }
3020
+
2934
3021
  // src/core/execute-cycle.ts
2935
3022
  async function executeCycle(ctx) {
2936
3023
  const { env, config, configHash, endpointConfigHash, endpointLock, cachedEntry, providerId, provider, timeoutBudgetMs, startTime, fetchTimeoutMs } = ctx;
@@ -2943,7 +3030,9 @@ async function executeCycle(ctx) {
2943
3030
  return {
2944
3031
  output: cachedEntry.renderedLine,
2945
3032
  exitCode: 0,
2946
- cacheUpdate: null
3033
+ cacheUpdate: null,
3034
+ invalidateProvider: false,
3035
+ path: "A"
2947
3036
  };
2948
3037
  }
2949
3038
  }
@@ -2958,14 +3047,18 @@ async function executeCycle(ctx) {
2958
3047
  return {
2959
3048
  output: statusline,
2960
3049
  exitCode: 0,
2961
- cacheUpdate: null
3050
+ cacheUpdate: null,
3051
+ invalidateProvider: false,
3052
+ path: "B2"
2962
3053
  };
2963
3054
  }
2964
3055
  const errorOutput = renderError("endpoint-config-changed", "without-cache");
2965
3056
  return {
2966
3057
  output: errorOutput,
2967
3058
  exitCode: 0,
2968
- cacheUpdate: null
3059
+ cacheUpdate: null,
3060
+ invalidateProvider: false,
3061
+ path: "B2"
2969
3062
  };
2970
3063
  }
2971
3064
  if (cachedEntry && isCacheValid(cachedEntry, env) && isCacheProviderValid(cachedEntry, providerId)) {
@@ -2980,7 +3073,9 @@ async function executeCycle(ctx) {
2980
3073
  return {
2981
3074
  output: statusline,
2982
3075
  exitCode: 0,
2983
- cacheUpdate: updatedEntry
3076
+ cacheUpdate: updatedEntry,
3077
+ invalidateProvider: false,
3078
+ path: "B"
2984
3079
  };
2985
3080
  }
2986
3081
  const deadline = startTime + timeoutBudgetMs - EXIT_BUFFER_MS;
@@ -2991,7 +3086,9 @@ async function executeCycle(ctx) {
2991
3086
  return {
2992
3087
  output: errorOutput,
2993
3088
  exitCode: 0,
2994
- cacheUpdate: null
3089
+ cacheUpdate: null,
3090
+ invalidateProvider: false,
3091
+ path: "D"
2995
3092
  };
2996
3093
  }
2997
3094
  try {
@@ -3001,7 +3098,9 @@ async function executeCycle(ctx) {
3001
3098
  return {
3002
3099
  output: renderError("missing-env", "without-cache"),
3003
3100
  exitCode: 0,
3004
- cacheUpdate: null
3101
+ cacheUpdate: null,
3102
+ invalidateProvider: false,
3103
+ path: "D"
3005
3104
  };
3006
3105
  }
3007
3106
  logger.debug("Path C: Fetching from provider", { providerId, fetchTimeoutMs });
@@ -3027,23 +3126,30 @@ async function executeCycle(ctx) {
3027
3126
  return {
3028
3127
  output: statusline,
3029
3128
  exitCode: 0,
3030
- cacheUpdate: newEntry
3129
+ cacheUpdate: newEntry,
3130
+ invalidateProvider: false,
3131
+ path: "C"
3031
3132
  };
3032
3133
  } catch (error) {
3033
3134
  logger.error("Path D: Fetch error", { error: String(error), hasCachedEntry: !!cachedEntry });
3135
+ const errorCategory = classifyFetchError(error);
3034
3136
  let errorState = "network-error";
3035
3137
  if (error && typeof error === "object" && "statusCode" in error) {
3036
3138
  const statusCode = error.statusCode;
3037
3139
  if (statusCode === 429) {
3038
3140
  errorState = "rate-limited";
3141
+ } else if (errorCategory === "site-closed") {
3142
+ errorState = "network-error";
3039
3143
  } else if (statusCode && statusCode >= 500) {
3040
3144
  errorState = "server-error";
3041
3145
  } else if (statusCode === 401 || statusCode === 403) {
3042
3146
  errorState = "auth-error";
3043
3147
  }
3148
+ } else if (errorCategory === "provider-mismatch") {
3149
+ errorState = "parse-error";
3044
3150
  }
3045
3151
  if (cachedEntry) {
3046
- logger.debug("Discarding stale cache, showing error", { errorState });
3152
+ logger.debug("Discarding stale cache, showing error", { errorState, errorCategory });
3047
3153
  } else {
3048
3154
  logger.warn("No cache available for error fallback");
3049
3155
  }
@@ -3051,10 +3157,32 @@ async function executeCycle(ctx) {
3051
3157
  return {
3052
3158
  output: errorOutput,
3053
3159
  exitCode: 0,
3054
- cacheUpdate: null
3160
+ cacheUpdate: null,
3161
+ invalidateProvider: errorCategory === "provider-mismatch",
3162
+ path: "D"
3055
3163
  };
3056
3164
  }
3057
3165
  }
3166
+ // src/core/maintenance-scheduler.ts
3167
+ function selectMaintenanceTask(ctx) {
3168
+ if (ctx.path !== "A" && ctx.path !== "B")
3169
+ return "none";
3170
+ if (ctx.detectionCacheAgeMs === null)
3171
+ return "health-probe";
3172
+ if (ctx.detectionCacheAgeMs >= ctx.detectionCacheTtlMs * 0.5)
3173
+ return "health-probe";
3174
+ if (Math.random() < MAINTENANCE_GC_PROBABILITY)
3175
+ return "cache-gc";
3176
+ return "none";
3177
+ }
3178
+ function computeDynamicDetectionTtl(outcome, currentProvider, currentTtlSeconds) {
3179
+ if (!outcome.success)
3180
+ return DETECTION_TTL_FAILED_S;
3181
+ if (outcome.matchedProvider !== currentProvider)
3182
+ return DETECTION_TTL_CHANGED_S;
3183
+ return Math.min(currentTtlSeconds * 2, DETECTION_TTL_MAX_S);
3184
+ }
3185
+
3058
3186
  // src/services/cache-gc.ts
3059
3187
  import { readdirSync as readdirSync4, statSync as statSync2, unlinkSync as unlinkSync6, existsSync as existsSync6 } from "fs";
3060
3188
  import { join as join12 } from "path";
@@ -3164,6 +3292,34 @@ function runCacheGC(cacheDir) {
3164
3292
  }
3165
3293
 
3166
3294
  // src/cli/piped-mode.ts
3295
+ var DEFAULT_PIPED_MODE_DEPS = {
3296
+ readCurrentEnv,
3297
+ validateRequiredEnv,
3298
+ readCache,
3299
+ writeCache,
3300
+ getCacheDir,
3301
+ isCacheValid,
3302
+ loadConfigWithHash,
3303
+ loadEndpointConfigs,
3304
+ computeEndpointConfigHash,
3305
+ readEndpointLock,
3306
+ writeEndpointLock,
3307
+ needsConfigInit,
3308
+ writeDefaultConfigs,
3309
+ resolveProvider,
3310
+ getProvider,
3311
+ invalidateDetectionCache,
3312
+ deleteProviderDetectionCache,
3313
+ renderError,
3314
+ dimText,
3315
+ executeCycle,
3316
+ logger,
3317
+ runCacheGC,
3318
+ probeHealthWithMetrics,
3319
+ readDetectionCacheMeta,
3320
+ cacheProviderDetectionWithTtl
3321
+ };
3322
+
3167
3323
  class StatuslineError extends Error {
3168
3324
  errorType;
3169
3325
  constructor(errorType) {
@@ -3176,15 +3332,15 @@ function safeStdoutWrite(data) {
3176
3332
  process.stdout["write"](data);
3177
3333
  } catch {}
3178
3334
  }
3179
- function readAndValidateEnv() {
3180
- const env = readCurrentEnv();
3181
- logger.debug("Environment loaded", {
3335
+ function readAndValidateEnv(deps) {
3336
+ const env = deps.readCurrentEnv();
3337
+ deps.logger.debug("Environment loaded", {
3182
3338
  baseUrl: env.baseUrl ? `${env.baseUrl.substring(0, 30)}...` : undefined,
3183
3339
  hasToken: !!env.authToken,
3184
3340
  providerOverride: env.providerOverride,
3185
3341
  pollIntervalOverride: env.pollIntervalOverride
3186
3342
  });
3187
- const envError = validateRequiredEnv(env);
3343
+ const envError = deps.validateRequiredEnv(env);
3188
3344
  if (envError) {
3189
3345
  throw new StatuslineError("missing-env");
3190
3346
  }
@@ -3194,75 +3350,75 @@ function readAndValidateEnv() {
3194
3350
  }
3195
3351
  return { env, baseUrl };
3196
3352
  }
3197
- function ensureDefaultConfigs() {
3198
- if (needsConfigInit()) {
3199
- logger.debug("First run detected - initializing default configs");
3200
- writeDefaultConfigs();
3353
+ function ensureDefaultConfigs(deps) {
3354
+ if (deps.needsConfigInit()) {
3355
+ deps.logger.debug("First run detected - initializing default configs");
3356
+ deps.writeDefaultConfigs();
3201
3357
  }
3202
3358
  }
3203
- function loadEndpointConfigsWithHash() {
3204
- const endpointConfigs = loadEndpointConfigs();
3205
- const endpointConfigHash = computeEndpointConfigHash();
3206
- logger.debug("Endpoint configs loaded", {
3359
+ function loadEndpointConfigsWithHash(deps) {
3360
+ const endpointConfigs = deps.loadEndpointConfigs();
3361
+ const endpointConfigHash = deps.computeEndpointConfigHash();
3362
+ deps.logger.debug("Endpoint configs loaded", {
3207
3363
  configCount: Object.keys(endpointConfigs).length,
3208
3364
  endpointConfigHash
3209
3365
  });
3210
3366
  return { endpointConfigs, endpointConfigHash };
3211
3367
  }
3212
- function resolveEndpointLock(hash) {
3213
- const existing = readEndpointLock();
3368
+ function resolveEndpointLock(hash, deps) {
3369
+ const existing = deps.readEndpointLock();
3214
3370
  if (existing) {
3215
- logger.debug("Endpoint lock file loaded", {
3371
+ deps.logger.debug("Endpoint lock file loaded", {
3216
3372
  lockedHash: existing.hash,
3217
3373
  currentHash: hash,
3218
3374
  locked: existing.hash === hash
3219
3375
  });
3220
3376
  return existing;
3221
3377
  }
3222
- logger.debug("Endpoint lock file missing - creating with current hash");
3223
- writeEndpointLock(hash);
3378
+ deps.logger.debug("Endpoint lock file missing - creating with current hash");
3379
+ deps.writeEndpointLock(hash);
3224
3380
  return { hash, lockedAt: new Date().toISOString() };
3225
3381
  }
3226
- async function resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, timeoutMs) {
3227
- const probeTimeout = isPiped ? Math.min(1500, Math.max(200, timeoutMs - 200)) : 3000;
3228
- const providerId = await resolveProvider(baseUrl, env.providerOverride, endpointConfigs, probeTimeout);
3229
- const provider = getProvider(providerId, endpointConfigs);
3230
- logger.debug("Provider resolved", { providerId, probeTimeout });
3382
+ async function resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, timeoutMs, deps) {
3383
+ const probeTimeout = isPiped ? Math.floor(timeoutMs / 2) : timeoutMs;
3384
+ const providerId = await deps.resolveProvider(baseUrl, env.providerOverride, endpointConfigs, probeTimeout);
3385
+ const provider = deps.getProvider(providerId, endpointConfigs);
3386
+ deps.logger.debug("Provider resolved", { providerId, probeTimeout });
3231
3387
  if (!provider) {
3232
- logger.error("Provider not found", { providerId });
3388
+ deps.logger.error("Provider not found", { providerId });
3233
3389
  throw new StatuslineError("provider-unknown");
3234
3390
  }
3235
3391
  return { providerId, provider };
3236
3392
  }
3237
3393
  function computeTimeoutBudgets(isPiped, config, timeoutMs) {
3238
- const timeoutBudgetMs = isPiped ? timeoutMs : 1e4;
3239
- const fetchTimeoutMs = isPiped ? Math.min(config.pipedRequestTimeoutMs ?? 800, timeoutBudgetMs - 100) : 1e4;
3394
+ const timeoutBudgetMs = isPiped ? timeoutMs : TTY_TIMEOUT_BUDGET_MS;
3395
+ const fetchTimeoutMs = isPiped ? Math.min(config.pipedRequestTimeoutMs ?? DEFAULT_TIMEOUT_BUDGET_MS, timeoutBudgetMs - TIMEOUT_HEADROOM_MS) : TTY_TIMEOUT_BUDGET_MS;
3240
3396
  return { timeoutBudgetMs, fetchTimeoutMs };
3241
3397
  }
3242
- async function buildExecutionContext(args, isPiped, startTime, rawTimeoutMs) {
3243
- const { env, baseUrl } = readAndValidateEnv();
3244
- ensureDefaultConfigs();
3245
- const { config, configHash } = loadConfigWithHash(args.configPath);
3246
- const { endpointConfigs, endpointConfigHash } = loadEndpointConfigsWithHash();
3247
- const endpointLock = resolveEndpointLock(endpointConfigHash);
3248
- const cachedEntry = readCache(baseUrl);
3249
- logger.debug("Cache read", {
3398
+ async function buildExecutionContext(args, isPiped, startTime, rawTimeoutMs, deps) {
3399
+ const { env, baseUrl } = readAndValidateEnv(deps);
3400
+ ensureDefaultConfigs(deps);
3401
+ const { config, configHash } = deps.loadConfigWithHash(args.configPath);
3402
+ const { endpointConfigs, endpointConfigHash } = loadEndpointConfigsWithHash(deps);
3403
+ const endpointLock = resolveEndpointLock(endpointConfigHash, deps);
3404
+ const cachedEntry = deps.readCache(baseUrl);
3405
+ deps.logger.debug("Cache read", {
3250
3406
  cacheHit: !!cachedEntry,
3251
3407
  cacheAge: cachedEntry ? `${Math.floor((Date.now() - new Date(cachedEntry.fetchedAt).getTime()) / 1000)}s` : "N/A"
3252
3408
  });
3253
3409
  let providerId;
3254
3410
  let provider;
3255
- if (cachedEntry && isCacheValid(cachedEntry, env)) {
3256
- const cachedProvider = getProvider(cachedEntry.provider, endpointConfigs);
3411
+ if (cachedEntry && deps.isCacheValid(cachedEntry, env)) {
3412
+ const cachedProvider = deps.getProvider(cachedEntry.provider, endpointConfigs);
3257
3413
  if (cachedProvider) {
3258
3414
  providerId = cachedEntry.provider;
3259
3415
  provider = cachedProvider;
3260
- logger.debug("Cache-first: skipping provider probe", { providerId });
3416
+ deps.logger.debug("Cache-first: skipping provider probe", { providerId });
3261
3417
  } else {
3262
- ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs));
3418
+ ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs, deps));
3263
3419
  }
3264
3420
  } else {
3265
- ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs));
3421
+ ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs, deps));
3266
3422
  }
3267
3423
  const { timeoutBudgetMs, fetchTimeoutMs } = computeTimeoutBudgets(isPiped, config, rawTimeoutMs);
3268
3424
  const ctx = {
@@ -3278,82 +3434,124 @@ async function buildExecutionContext(args, isPiped, startTime, rawTimeoutMs) {
3278
3434
  startTime,
3279
3435
  fetchTimeoutMs
3280
3436
  };
3281
- return { ctx, baseUrl };
3437
+ return { ctx, baseUrl, endpointConfigs };
3282
3438
  }
3283
- function formatOutput(output, isPiped) {
3439
+ function formatOutput(output, mode, log) {
3284
3440
  let normalizedOutput = output;
3285
3441
  if (!normalizedOutput || normalizedOutput.trim().length === 0) {
3286
- logger.debug("Empty output detected, using fallback");
3442
+ log.debug("Empty output detected, using fallback");
3287
3443
  normalizedOutput = "[loading...]";
3288
3444
  }
3289
- if (isPiped) {
3290
- logger.debug("Output formatted for piped mode (ANSI reset + NBSP)");
3291
- return "\x1B[0m" + normalizedOutput.replace(/ /g, " ");
3292
- } else {
3293
- logger.debug("Output written (TTY mode)");
3294
- return normalizedOutput;
3445
+ switch (mode) {
3446
+ case "piped-embedded":
3447
+ log.debug("Output written (embedded piped mode - no host formatting)");
3448
+ return normalizedOutput;
3449
+ case "piped":
3450
+ log.debug("Output formatted for piped mode (ANSI reset + NBSP)");
3451
+ return "\x1B[0m" + normalizedOutput.replace(/ /g, " ");
3452
+ case "tty":
3453
+ log.debug("Output written (TTY mode)");
3454
+ return normalizedOutput + `
3455
+ `;
3295
3456
  }
3296
3457
  }
3297
- async function executePipedMode(args) {
3458
+ async function runMaintenance(result, baseUrl, startTime, budgetMs, endpointConfigs, currentProviderId, deps) {
3459
+ const { ageMs, ttlMs } = deps.readDetectionCacheMeta(baseUrl);
3460
+ const task = selectMaintenanceTask({
3461
+ path: result.path,
3462
+ detectionCacheAgeMs: ageMs,
3463
+ detectionCacheTtlMs: ttlMs
3464
+ });
3465
+ if (task === "none")
3466
+ return;
3467
+ deps.logger.debug("Maintenance task selected", { task, path: result.path });
3468
+ if (task === "health-probe") {
3469
+ const elapsed = Date.now() - startTime;
3470
+ const remainingMs = Math.max(50, budgetMs - elapsed - TIMEOUT_HEADROOM_MS);
3471
+ const currentTtlS = Math.floor(ttlMs / 1000) || DETECTION_TTL_BASE_S;
3472
+ const outcome = await deps.probeHealthWithMetrics(baseUrl, remainingMs, endpointConfigs);
3473
+ deps.logger.debug("Maintenance probe completed", {
3474
+ success: outcome.success,
3475
+ matchedProvider: outcome.matchedProvider,
3476
+ responseTimeMs: outcome.responseTimeMs
3477
+ });
3478
+ if (outcome.success && outcome.matchedProvider) {
3479
+ const newTtlS = computeDynamicDetectionTtl(outcome, currentProviderId, currentTtlS);
3480
+ deps.cacheProviderDetectionWithTtl(baseUrl, outcome.matchedProvider, newTtlS);
3481
+ deps.logger.debug("Detection cache refreshed", { ttlSeconds: newTtlS, provider: outcome.matchedProvider });
3482
+ }
3483
+ } else if (task === "cache-gc") {
3484
+ deps.runCacheGC(deps.getCacheDir());
3485
+ deps.logger.debug("Cache GC completed");
3486
+ }
3487
+ }
3488
+ async function executePipedMode(args, deps = DEFAULT_PIPED_MODE_DEPS) {
3298
3489
  const startTime = Date.now();
3299
- logger.debug("=== cc-api-statusline execution started ===");
3300
- logger.debug("Start time", { startTime });
3490
+ deps.logger.debug("=== cc-api-statusline execution started ===");
3491
+ deps.logger.debug("Start time", { startTime });
3301
3492
  const isPiped = !process.stdin.isTTY;
3302
- logger.debug("Mode detection", { isPiped, once: args.once });
3303
- const rawTimeoutMs = Number(process.env["CC_STATUSLINE_TIMEOUT"] ?? 1000);
3493
+ const outputMode = !isPiped ? "tty" : args.embedded ? "piped-embedded" : "piped";
3494
+ deps.logger.debug("Mode detection", { isPiped, once: args.once, outputMode });
3495
+ const rawTimeoutMs = Number(process.env["CC_STATUSLINE_TIMEOUT"] ?? DEFAULT_TIMEOUT_BUDGET_MS);
3304
3496
  if (isPiped) {
3305
- const watchdogMs = rawTimeoutMs - 100;
3497
+ const watchdogMs = rawTimeoutMs - TIMEOUT_HEADROOM_MS;
3306
3498
  setTimeout(() => {
3307
- logger.error("Watchdog timeout - forcing clean exit", { watchdogMs });
3308
- const fallback = dimText("⟳ Refreshing...");
3309
- const formatted = formatOutput(fallback, isPiped);
3499
+ deps.logger.error("Watchdog timeout - forcing clean exit", { watchdogMs });
3500
+ const fallback = deps.dimText("⟳ Refreshing...");
3501
+ const formatted = formatOutput(fallback, outputMode, deps.logger);
3310
3502
  safeStdoutWrite(formatted);
3311
3503
  process.exit(0);
3312
3504
  }, watchdogMs).unref();
3313
3505
  }
3314
3506
  let ctx;
3315
3507
  let baseUrl;
3508
+ let endpointConfigs;
3316
3509
  try {
3317
- ({ ctx, baseUrl } = await buildExecutionContext(args, isPiped, startTime, rawTimeoutMs));
3510
+ ({ ctx, baseUrl, endpointConfigs } = await buildExecutionContext(args, isPiped, startTime, rawTimeoutMs, deps));
3318
3511
  } catch (error) {
3319
- logger.error("Failed to build execution context", { error: String(error) });
3512
+ deps.logger.error("Failed to build execution context", { error: String(error) });
3320
3513
  const errorType = error instanceof StatuslineError ? error.errorType : "network-error";
3321
- const errorOutput = renderError(errorType, "without-cache");
3322
- const formattedOutput2 = formatOutput(errorOutput, isPiped);
3514
+ const errorOutput = deps.renderError(errorType, "without-cache");
3515
+ const formattedOutput2 = formatOutput(errorOutput, outputMode, deps.logger);
3323
3516
  safeStdoutWrite(formattedOutput2);
3324
- logger.debug("=== cc-api-statusline execution completed ===");
3517
+ deps.logger.debug("=== cc-api-statusline execution completed ===");
3325
3518
  process.exit(0);
3326
3519
  }
3327
- logger.debug("Execution context prepared", {
3520
+ deps.logger.debug("Execution context prepared", {
3328
3521
  timeoutBudgetMs: ctx.timeoutBudgetMs,
3329
3522
  fetchTimeoutMs: ctx.fetchTimeoutMs
3330
3523
  });
3331
3524
  let result;
3332
3525
  try {
3333
- result = await executeCycle(ctx);
3526
+ result = await deps.executeCycle(ctx);
3334
3527
  } catch (error) {
3335
- logger.error("Execution cycle failed", { error: String(error) });
3336
- const errorOutput = renderError("network-error", "without-cache");
3337
- const formattedOutput2 = formatOutput(errorOutput, isPiped);
3528
+ deps.logger.error("Execution cycle failed", { error: String(error) });
3529
+ const errorOutput = deps.renderError("network-error", "without-cache");
3530
+ const formattedOutput2 = formatOutput(errorOutput, outputMode, deps.logger);
3338
3531
  safeStdoutWrite(formattedOutput2);
3339
- logger.debug("=== cc-api-statusline execution completed ===");
3532
+ deps.logger.debug("=== cc-api-statusline execution completed ===");
3340
3533
  process.exit(0);
3341
3534
  }
3342
3535
  const executionTime = Date.now() - startTime;
3343
- logger.debug("Execution completed", {
3536
+ deps.logger.debug("Execution completed", {
3344
3537
  exitCode: result.exitCode,
3345
3538
  executionTime: `${executionTime}ms`,
3346
3539
  outputLength: result.output.length,
3347
3540
  cacheUpdate: !!result.cacheUpdate
3348
3541
  });
3349
- const formattedOutput = formatOutput(result.output, isPiped);
3542
+ const formattedOutput = formatOutput(result.output, outputMode, deps.logger);
3350
3543
  safeStdoutWrite(formattedOutput);
3544
+ if (result.invalidateProvider) {
3545
+ deps.invalidateDetectionCache(baseUrl);
3546
+ deps.deleteProviderDetectionCache(baseUrl);
3547
+ deps.logger.debug("Provider detection cache invalidated", { baseUrl });
3548
+ }
3351
3549
  if (result.cacheUpdate) {
3352
- writeCache(baseUrl, result.cacheUpdate);
3353
- logger.debug("Cache written", { baseUrl });
3354
- runCacheGC(getCacheDir());
3550
+ deps.writeCache(baseUrl, result.cacheUpdate);
3551
+ deps.logger.debug("Cache written", { baseUrl });
3355
3552
  }
3356
- logger.debug("=== cc-api-statusline execution completed ===");
3553
+ await runMaintenance(result, baseUrl, startTime, rawTimeoutMs, endpointConfigs, ctx.providerId, deps);
3554
+ deps.logger.debug("=== cc-api-statusline execution completed ===");
3357
3555
  process.exit(result.exitCode);
3358
3556
  }
3359
3557
  // src/main.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-api-statusline",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Claude Code statusline tool that polls API usage from third-party proxy backends",
5
5
  "type": "module",
6
6
  "bin": {