coding-usage-bar 0.1.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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +220 -0
  3. package/README.zh-CN.md +53 -0
  4. package/THIRD_PARTY_NOTICES.md +16 -0
  5. package/assets/coding-usage-bar-limit.png +0 -0
  6. package/assets/coding-usage-bar-ok.png +0 -0
  7. package/assets/coding-usage-bar-over.png +0 -0
  8. package/assets/coding-usage-bar-under.png +0 -0
  9. package/assets/provider-claude.png +0 -0
  10. package/assets/provider-codex.png +0 -0
  11. package/assets/provider-deepseek.png +0 -0
  12. package/assets/provider-glm-dark.png +0 -0
  13. package/assets/provider-glm.png +0 -0
  14. package/assets/provider-marks.png +0 -0
  15. package/assets/provider-minimax.png +0 -0
  16. package/dist/burn.d.ts +3 -0
  17. package/dist/burn.js +164 -0
  18. package/dist/card.d.ts +4 -0
  19. package/dist/card.js +79 -0
  20. package/dist/claude.d.ts +9 -0
  21. package/dist/claude.js +141 -0
  22. package/dist/cli.d.ts +2 -0
  23. package/dist/cli.js +129 -0
  24. package/dist/codex.d.ts +6 -0
  25. package/dist/codex.js +128 -0
  26. package/dist/config.d.ts +4 -0
  27. package/dist/config.js +64 -0
  28. package/dist/daemon.d.ts +3 -0
  29. package/dist/daemon.js +16 -0
  30. package/dist/deepseek.d.ts +18 -0
  31. package/dist/deepseek.js +47 -0
  32. package/dist/doctor.d.ts +6 -0
  33. package/dist/doctor.js +225 -0
  34. package/dist/format.d.ts +9 -0
  35. package/dist/format.js +92 -0
  36. package/dist/fs-util.d.ts +6 -0
  37. package/dist/fs-util.js +39 -0
  38. package/dist/glm.d.ts +22 -0
  39. package/dist/glm.js +62 -0
  40. package/dist/install.d.ts +25 -0
  41. package/dist/install.js +432 -0
  42. package/dist/menubar.d.ts +23 -0
  43. package/dist/menubar.js +880 -0
  44. package/dist/minimax.d.ts +47 -0
  45. package/dist/minimax.js +100 -0
  46. package/dist/node-runtime.d.ts +1 -0
  47. package/dist/node-runtime.js +29 -0
  48. package/dist/notifier.d.ts +7 -0
  49. package/dist/notifier.js +121 -0
  50. package/dist/paths.d.ts +5 -0
  51. package/dist/paths.js +45 -0
  52. package/dist/runtime.d.ts +23 -0
  53. package/dist/runtime.js +244 -0
  54. package/dist/star.d.ts +15 -0
  55. package/dist/star.js +117 -0
  56. package/dist/status.d.ts +14 -0
  57. package/dist/status.js +65 -0
  58. package/dist/store.d.ts +4 -0
  59. package/dist/store.js +29 -0
  60. package/dist/types.d.ts +101 -0
  61. package/dist/types.js +1 -0
  62. package/dist/usage.d.ts +12 -0
  63. package/dist/usage.js +56 -0
  64. package/docs/assets/hero.png +0 -0
  65. package/fixtures/claude/latest.json +20 -0
  66. package/fixtures/claude/samples.jsonl +3 -0
  67. package/fixtures/codex/latest.json +20 -0
  68. package/fixtures/codex/samples.jsonl +3 -0
  69. package/fixtures/deepseek/latest.json +12 -0
  70. package/package.json +59 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DuYing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # Coding Usage Bar
2
+
3
+ [English](README.md) · [简体中文](README.zh-CN.md)
4
+
5
+ **Claude Code and Codex usage monitoring in your macOS menu bar.** Track rolling plan limits across Claude Code, OpenAI Codex, GLM (Zhipu AI), DeepSeek, and MiniMax before a coding session hits the wall.
6
+
7
+ [![npm](https://img.shields.io/npm/v/coding-usage-bar.svg)](https://www.npmjs.com/package/coding-usage-bar)
8
+ [![CI](https://github.com/hanzhangzzz/coding-usage-bar/actions/workflows/test.yml/badge.svg)](https://github.com/hanzhangzzz/coding-usage-bar/actions/workflows/test.yml)
9
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
+ [![platform](https://img.shields.io/badge/platform-macOS-lightgrey.svg?logo=apple)](#requirements)
11
+
12
+ ![Coding Usage Bar showing Claude Code, Codex, GLM, DeepSeek, and MiniMax plan usage in the macOS menu bar](docs/assets/hero.png)
13
+
14
+ Coding Usage Bar does more than display quota percentages. It compares short rolling windows with the weekly budget and labels your pace as learning, under-burning, on track, over-burning, or close to a limit.
15
+
16
+ Claude Code and Codex data stays local and is read from files those tools already produce. Provider API keys for GLM, DeepSeek, and MiniMax are stored only in `~/.coding-usage-bar/config.json` and sent directly to their respective quota APIs.
17
+
18
+ ## Quick Start
19
+
20
+ ```bash
21
+ npx coding-usage-bar install
22
+ coding-usage-bar doctor
23
+ coding-usage-bar status
24
+ ```
25
+
26
+ `install` creates a local runtime at `~/.coding-usage-bar/app`, a user-level CLI shim at `~/.local/bin/coding-usage-bar`, a launchd agent, a default config file, and a SwiftBar menu bar plugin. Make sure `~/.local/bin` is in your `PATH`.
27
+
28
+ ## Commands
29
+
30
+ | Command | Purpose |
31
+ |---------|---------|
32
+ | `coding-usage-bar install` | Install runtime, launchd checker, SwiftBar host/plugin, and Claude ingest when safe |
33
+ | `coding-usage-bar uninstall` | Remove Coding Usage Bar managed launchd/status line/plugin config |
34
+ | `coding-usage-bar doctor` | Check local Codex/Claude/GLM usage sources and notification backend |
35
+ | `coding-usage-bar status` | Print the rolling windows reported by each account and their pacing state from `~/.coding-usage-bar/status.json` |
36
+ | `coding-usage-bar status --json` | Print the same status snapshot written to `~/.coding-usage-bar/status.json` |
37
+ | `coding-usage-bar status --refresh` | Re-collect local usage before printing status |
38
+ | `coding-usage-bar menubar render` | Print SwiftBar-compatible menu text from `~/.coding-usage-bar/status.json` |
39
+ | `coding-usage-bar menubar install` | Install the SwiftBar plugin wrapper |
40
+ | `coding-usage-bar menubar uninstall` | Remove the Coding Usage Bar managed SwiftBar plugin |
41
+ | `coding-usage-bar ingest claude-statusline` | Read Claude Code status line JSON from stdin and cache usage |
42
+
43
+ ## Install Behavior
44
+
45
+ `coding-usage-bar install` is designed to be repeatable.
46
+
47
+ - From `npx coding-usage-bar install` or `npx --no-install coding-usage-bar install`, it copies the current package into `~/.coding-usage-bar/app` through a temporary directory, then restarts launchd.
48
+ - From a source checkout, `npm run build && npx --no-install coding-usage-bar install` installs the current local build.
49
+ - From the installed shim `coding-usage-bar install`, it detects that it is already running from `~/.coding-usage-bar/app`, skips runtime self-copy, and still refreshes the CLI shim, SwiftBar plugin, and launchd agent.
50
+ - The launchd job runs `~/.coding-usage-bar/app/dist/cli.js daemon --once` every 300 seconds.
51
+ - The installer does not overwrite user-managed Claude Code status line scripts. If one already exists, it asks before installing a Coding Usage Bar wrapper around it.
52
+ - Long-lived launchd, SwiftBar, and Claude wrapper commands prefer the stable `node` found in `PATH` (for example `/opt/homebrew/bin/node`) instead of a versioned Homebrew Cellar path. Set `CODING_USAGE_BAR_NODE=/absolute/path/to/node` before installing to override it, and rerun `coding-usage-bar install` after moving or replacing Node.
53
+
54
+ ## Claude Code Status Line
55
+
56
+ If you do not have a Claude Code status line, `coding-usage-bar install` can create a minimal one.
57
+
58
+ If you already have one, Coding Usage Bar will ask before changing Claude settings. When you answer `y`, it writes a Coding Usage Bar wrapper at `~/.coding-usage-bar/claude/statusline.sh`, saves the original command metadata, and updates `statusLine.command` so the wrapper runs first. The wrapper ingests Claude usage, then forwards the same input to your existing status line command. `coding-usage-bar uninstall` restores the original command.
59
+
60
+ If you answer `n` or run in a non-interactive shell, Coding Usage Bar skips the status line update and prints manual setup instructions. Add this near the top of your own script:
61
+
62
+ ```bash
63
+ input="$(cat)"
64
+ printf "%s" "$input" | node "$HOME/.coding-usage-bar/app/dist/cli.js" ingest claude-statusline >/dev/null
65
+
66
+ # Make the rest of your script read from "$input" instead of stdin.
67
+ ```
68
+
69
+ Without this integration, Claude usage stays unavailable in Coding Usage Bar. Claude burn-rate analysis, Claude notifications, and Claude menu bar data will report `CLAUDE_INGEST_MISSING`; Codex usage is unaffected.
70
+
71
+ ## Codex
72
+
73
+ Coding Usage Bar reads Codex `payload.rate_limits` from local `~/.codex` JSONL session data. It displays the windows the current account actually reports; some accounts expose both 5h and 7d windows, while others currently expose only 7d. If no rate-limit data exists, run Codex CLI or Codex App once and complete a normal interaction.
74
+
75
+ ## GLM (Zhipu AI)
76
+
77
+ Coding Usage Bar calls the Zhipu AI quota API (`GET /api/monitor/usage/quota/limit`) to read 5h and 7d usage windows. You need to set `glm.apiKey` in `~/.coding-usage-bar/config.json`:
78
+
79
+ ```json
80
+ {
81
+ "providers": ["codex", "claude", "glm"],
82
+ "glm": {
83
+ "baseUrl": "https://open.bigmodel.cn",
84
+ "apiKey": "your-api-key"
85
+ }
86
+ }
87
+ ```
88
+
89
+ Get your API key from the [Zhipu AI console](https://open.bigmodel.cn). Without this key, GLM monitoring will report `GLM_API_KEY_MISSING`.
90
+
91
+ ## DeepSeek
92
+
93
+ Coding Usage Bar calls `GET https://api.deepseek.com/user/balance` to read your account balance. DeepSeek exposes balance rather than 5h/7d usage windows, so the menu bar shows `Available` or `Depleted` plus the currency amount. Set `deepseek.apiKey` in `~/.coding-usage-bar/config.json`:
94
+
95
+ ```json
96
+ {
97
+ "providers": ["codex", "claude", "glm", "deepseek"],
98
+ "deepseek": {
99
+ "apiKey": "your-deepseek-api-key"
100
+ }
101
+ }
102
+ ```
103
+
104
+ Without this key, DeepSeek monitoring will report `DEEPSEEK_API_KEY_MISSING`.
105
+
106
+ ## MiniMax (M3)
107
+
108
+ Coding Usage Bar calls `GET https://api.minimaxi.com/v1/token_plan/remains` to read 5h and 7d usage windows. MiniMax returns two signals per model: a count-based ratio (`current_*_usage_count` / `current_*_total_count`) for tiered models like `video`, and a credit-based `current_*_remaining_percent` (0-100) for the `general` model where the count stays at 0. Coding Usage Bar prefers the count ratio when total > 0 and otherwise derives used% as `100 - remaining_percent`. Set `minimax.apiKey` and (optionally) `minimax.region` in `~/.coding-usage-bar/config.json`:
109
+
110
+ ```json
111
+ {
112
+ "providers": ["codex", "claude", "glm", "minimax"],
113
+ "minimax": {
114
+ "region": "cn",
115
+ "apiKey": "your-minimax-api-key"
116
+ }
117
+ }
118
+ ```
119
+
120
+ `region` defaults to `cn` (api.minimaxi.com); set it to `global` to use api.minimax.io. Without this key, MiniMax monitoring will report `MINIMAX_API_KEY_MISSING`.
121
+
122
+ ## Profiles
123
+
124
+ Set `CODING_USAGE_BAR_PROFILE=high` for the more aggressive profile. The default is `low`.
125
+
126
+ ```bash
127
+ CODING_USAGE_BAR_PROFILE=high coding-usage-bar status
128
+ ```
129
+
130
+ Both profiles are constrained by the weekly budget. When a short window is reported, Coding Usage Bar does not treat filling every short window as the goal.
131
+
132
+ ## Provider Config
133
+
134
+ `coding-usage-bar install` creates `~/.coding-usage-bar/config.json`:
135
+
136
+ ```json
137
+ {
138
+ "providers": ["codex", "claude", "glm", "deepseek", "minimax"]
139
+ }
140
+ ```
141
+
142
+ Remove a provider from this list if you do not want Coding Usage Bar to monitor it. For one-off runs, `CODING_USAGE_BAR_PROVIDERS=codex coding-usage-bar status --refresh` also works.
143
+
144
+ ## Menu Bar
145
+
146
+ The first menu bar implementation uses SwiftBar as a thin host. Coding Usage Bar still owns collection and state; the SwiftBar plugin only runs `coding-usage-bar menubar render` and reads `~/.coding-usage-bar/status.json`.
147
+
148
+ `coding-usage-bar install` checks for SwiftBar and installs it with Homebrew cask when it is missing. It installs the Coding Usage Bar plugin into SwiftBar's configured `PluginDirectory`, not blindly into a hardcoded default directory, then opens SwiftBar.
149
+
150
+ ```bash
151
+ coding-usage-bar menubar install
152
+ ```
153
+
154
+ If SwiftBar is not installed, `coding-usage-bar doctor` will report it. If SwiftBar already has a custom plugin folder, Coding Usage Bar uses that folder.
155
+
156
+ The compact menu bar title and dropdown use recognizable Provider marks for Codex, Claude Code, Zhipu AI, DeepSeek, and MiniMax alongside the rolling-window data (DeepSeek shows balance instead of windows):
157
+
158
+ ```text
159
+ {Codex icon} 5H:14%,7D:67% │ {Claude icon} 5H:24%,7D:74% │ {GLM icon} 5H:36%,7D:7%
160
+ ```
161
+
162
+ SwiftBar only supports one bitmap image on a single stable title item, so Coding Usage Bar renders the full title into one transparent PNG at render time. That bitmap keeps each provider marker next to its own usage segment while avoiding SwiftBar's multi-title rotation behavior. The dropdown stays read-only and uses SwiftBar-native symbols, badges, progress meters, reset time, target range, data age, and warnings. SwiftBar is a host dependency; `coding-usage-bar uninstall` removes the Coding Usage Bar plugin but does not uninstall SwiftBar itself.
163
+
164
+ ## Runtime Files
165
+
166
+ | Path | Purpose |
167
+ |------|---------|
168
+ | `~/.coding-usage-bar/app/` | Stable runtime copy used by launchd, Claude ingest hints, and SwiftBar |
169
+ | `~/.coding-usage-bar/config.json` | Provider selection, default `["codex", "claude", "glm", "deepseek", "minimax"]` |
170
+ | `~/.coding-usage-bar/status.json` | Stable display-layer entry point |
171
+ | `~/.coding-usage-bar/codex/latest.json` | Latest normalized Codex usage |
172
+ | `~/.coding-usage-bar/claude/latest.json` | Latest normalized Claude usage after status line ingest |
173
+ | `~/.coding-usage-bar/glm/latest.json` | Latest normalized GLM usage from Zhipu AI quota API |
174
+ | `~/.coding-usage-bar/deepseek/latest.json` | Latest normalized DeepSeek balance from DeepSeek API |
175
+ | `~/.coding-usage-bar/minimax/latest.json` | Latest normalized MiniMax 5h/7d usage from MiniMax API |
176
+ | `~/.local/bin/coding-usage-bar` | CLI shim pointing at the stable runtime |
177
+ | `~/Library/LaunchAgents/com.duying.coding-usage-bar.plist` | macOS launchd agent |
178
+ | SwiftBar `PluginDirectory` / `coding-usage-bar.1m.js` | Menu bar plugin wrapper |
179
+
180
+ ## Notifications
181
+
182
+ On macOS, Coding Usage Bar uses `terminal-notifier` when available and attaches a dynamic data card as the notification content image.
183
+
184
+ The card shows provider, the available rolling-window usage, state label, target range, and reset countdown. This avoids asking users to infer meaning from a red/yellow icon.
185
+
186
+ After an interactive install, Coding Usage Bar may ask whether you want to star the repository. The prompt defaults to **No** (`[y/N]`) and only uses an already authenticated GitHub CLI after explicit `y`/`yes` consent.
187
+
188
+ If `terminal-notifier` is unavailable, Coding Usage Bar falls back to `osascript display notification`, which uses the system default notification appearance.
189
+
190
+ See the [product and technical baseline](https://github.com/hanzhangzzz/coding-usage-bar/blob/main/plan.md) for the current design.
191
+
192
+ ## Why Coding Usage Bar?
193
+
194
+ - **One menu bar for multiple coding providers:** Claude Code, Codex, GLM, DeepSeek, and MiniMax.
195
+ - **Pacing, not just percentages:** short-window usage is evaluated against the weekly budget.
196
+ - **Local-first:** Claude Code and Codex usage is read from local tool output, without a separate account or telemetry service.
197
+ - **Visible but lightweight:** SwiftBar is only the host; collection and display state remain separated and auditable.
198
+ - **Actionable warnings:** limit risk and burn-rate notifications include reset timing and current usage.
199
+
200
+ ## Requirements
201
+
202
+ - macOS
203
+ - Node.js 20 or newer
204
+ - SwiftBar (the installer can install it through Homebrew)
205
+ - Claude Code or Codex for local usage monitoring
206
+
207
+ ## Development
208
+
209
+ ```bash
210
+ npm ci
211
+ npm test
212
+ npm run build
213
+ npm pack
214
+ ```
215
+
216
+ ## License
217
+
218
+ [MIT](LICENSE)
219
+
220
+ Provider names and trademarks belong to their respective owners. Coding Usage Bar is an independent project and is not affiliated with or endorsed by Anthropic, OpenAI, Zhipu AI, DeepSeek, or MiniMax. Provider marks are used only for identification, are excluded from the MIT License, and are documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,53 @@
1
+ # Coding Usage Bar
2
+
3
+ [English](README.md) · [简体中文](README.zh-CN.md)
4
+
5
+ **在 macOS 菜单栏监控 Claude Code、Codex 和其他 AI 编程套餐用量。**
6
+
7
+ ![Coding Usage Bar 在 macOS 菜单栏展示多个 AI 编程 Provider 的套餐用量](docs/assets/hero.png)
8
+
9
+ Coding Usage Bar 同时支持 Claude Code、OpenAI Codex、GLM(智谱 AI)、DeepSeek 和 MiniMax。它不只展示百分比,还会结合短周期和周额度判断当前节奏是偏慢、正常、偏快,还是已经接近限额。
10
+
11
+ Claude Code 与 Codex 的数据直接读取它们在本机产生的文件,不需要额外登录,也不会上传使用记录。GLM、DeepSeek 和 MiniMax 的 API Key 只保存在 `~/.coding-usage-bar/config.json`,并直接请求对应 Provider 的额度接口。
12
+
13
+ ## 快速开始
14
+
15
+ ```bash
16
+ npx coding-usage-bar install
17
+ coding-usage-bar doctor
18
+ coding-usage-bar status
19
+ ```
20
+
21
+ 安装器会创建:
22
+
23
+ - `~/.coding-usage-bar/app` 稳定运行副本
24
+ - `~/.local/bin/coding-usage-bar` 命令入口
25
+ - 每 5 分钟采集一次用量的 launchd 任务
26
+ - 写入 SwiftBar 当前插件目录的菜单栏插件
27
+
28
+ ## 核心特点
29
+
30
+ - 一个菜单栏同时查看多个 AI 编程 Provider
31
+ - 按账户实际下发的滚动窗口分析使用节奏;部分 Codex 账户当前只提供 7d
32
+ - Claude Code、Codex 本地优先,不依赖额外遥测服务
33
+ - 展示层只读稳定的 `status.json`,不会在打开菜单时临时访问数据源
34
+ - 接近限额或燃烧节奏异常时发送系统通知
35
+
36
+ 完整命令、Provider 配置和实现边界请查看[英文 README](README.md)。
37
+
38
+ 交互式安装结束后,工具可能询问是否为 GitHub 仓库加 Star。该提示默认选择 No(`[y/N]`),只有用户明确输入 `y`/`yes` 且本机 GitHub CLI 已登录时才会修改远端账户状态。
39
+
40
+ ## 开发验证
41
+
42
+ ```bash
43
+ npm ci
44
+ npm test
45
+ npm run build
46
+ npm pack
47
+ ```
48
+
49
+ ## License
50
+
51
+ [MIT](LICENSE)
52
+
53
+ 各 Provider 名称与商标归其权利人所有。本项目独立开发,与 Anthropic、OpenAI、智谱 AI、DeepSeek、MiniMax 无隶属或背书关系。Provider 标识仅用于识别,不适用项目 MIT License,详见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
@@ -0,0 +1,16 @@
1
+ # Third-Party Marks
2
+
3
+ Coding Usage Bar includes the following provider marks solely to identify the services whose local usage data is being displayed:
4
+
5
+ | Files | Provider |
6
+ |---|---|
7
+ | `assets/provider-claude.png` | Anthropic / Claude |
8
+ | `assets/provider-codex.png` | OpenAI / Codex |
9
+ | `assets/provider-glm.png`, `assets/provider-glm-dark.png` | Zhipu AI / GLM |
10
+ | `assets/provider-deepseek.png` | DeepSeek |
11
+ | `assets/provider-minimax.png` | MiniMax |
12
+ | `assets/provider-marks.png` | Composite of the marks listed above |
13
+
14
+ These marks and their associated names are owned by their respective holders. They are **not** licensed under this repository's MIT License. Their inclusion identifies the Provider whose usage is displayed and does not imply affiliation, sponsorship, or endorsement. No rights to the marks are granted by this project. Use of a mark remains subject to its owner's applicable brand rules, including the [OpenAI Design Guidelines](https://openai.com/brand/).
15
+
16
+ All other Coding Usage Bar artwork named `coding-usage-bar-*` is project-owned and covered by the repository license.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/dist/burn.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { BurnAnalysis, BurnProfile, ProviderUsage } from "./types.js";
2
+ export declare function estimateConversionRate(samples: ProviderUsage[]): number | null;
3
+ export declare function analyzeUsage(usage: ProviderUsage, samples: ProviderUsage[], profile?: BurnProfile, now?: Date): BurnAnalysis;
package/dist/burn.js ADDED
@@ -0,0 +1,164 @@
1
+ import { findWindow } from "./usage.js";
2
+ const PROFILE_FACTORS = {
3
+ low: { min: 0.8, max: 1.1 },
4
+ high: { min: 0.9, max: 1.35 },
5
+ };
6
+ function round(value) {
7
+ return Math.round(value * 10) / 10;
8
+ }
9
+ function minutesUntil(iso, nowMs) {
10
+ return Math.max(0, (Date.parse(iso) - nowMs) / 60_000);
11
+ }
12
+ const MIN_TOTAL_FIVE_DELTA = 5;
13
+ export function estimateConversionRate(samples) {
14
+ const usable = [...samples]
15
+ .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt))
16
+ .map((sample) => ({
17
+ five: findWindow(sample, "five_hour")?.usedPercent,
18
+ seven: findWindow(sample, "seven_day")?.usedPercent,
19
+ }))
20
+ .filter((item) => {
21
+ return typeof item.five === "number" && typeof item.seven === "number";
22
+ });
23
+ // Trim to the longest tail where the 7d window is monotonically non-decreasing.
24
+ // Cross-session anomalies (Codex picking a different rollout file with stale
25
+ // rate_limits) show up as sharp drops in 7d; using only the most recent
26
+ // stable tail avoids contaminating the rate with the recovery ramp-up.
27
+ let startIdx = usable.length - 1;
28
+ while (startIdx > 0 && usable[startIdx - 1].seven <= usable[startIdx].seven) {
29
+ startIdx -= 1;
30
+ }
31
+ // If we stopped because of a drop, the sample we landed on is the anomalous
32
+ // low reading itself; skip it so its artificially low 7d baseline doesn't
33
+ // inflate the first delta of the tail.
34
+ if (startIdx > 0) {
35
+ startIdx += 1;
36
+ }
37
+ const recent = usable.slice(startIdx);
38
+ // Aggregate cumulative deltas across positive 5h advances. Using totals
39
+ // (not the median of per-pair rates) averages out integer-rounding noise:
40
+ // a single 5h tick is often 1% while 7d may not bump until several 5h ticks
41
+ // accumulate, so per-pair rates are dominated by zeros and over-estimates.
42
+ let totalFive = 0;
43
+ let totalSeven = 0;
44
+ for (let i = 1; i < recent.length; i += 1) {
45
+ const deltaFive = recent[i].five - recent[i - 1].five;
46
+ const deltaSeven = recent[i].seven - recent[i - 1].seven;
47
+ if (deltaFive > 0 && deltaSeven >= 0) {
48
+ totalFive += deltaFive;
49
+ totalSeven += deltaSeven;
50
+ }
51
+ }
52
+ if (totalFive < MIN_TOTAL_FIVE_DELTA) {
53
+ return null;
54
+ }
55
+ const value = totalSeven / totalFive;
56
+ return Number.isFinite(value) && value > 0 ? value : null;
57
+ }
58
+ function providerLabel(provider) {
59
+ return provider === "claude" ? "Claude" : provider === "glm" ? "GLM" : provider === "deepseek" ? "DeepSeek" : provider === "minimax" ? "MiniMax" : "Codex";
60
+ }
61
+ function messageForState(provider, state, fiveUsed, target) {
62
+ const label = providerLabel(provider);
63
+ if (state === "LIMIT_RISK") {
64
+ return `${label} usage is close to a plan limit. Consider switching provider or lowering intensity.`;
65
+ }
66
+ if (!target || fiveUsed === undefined) {
67
+ return `${label} usage is visible, but more samples are needed before dynamic burn advice is available.`;
68
+ }
69
+ if (state === "UNDER_BURN") {
70
+ return `${label} 5h usage is below target (${round(fiveUsed)}%, target ${round(target.min)}%-${round(target.max)}%).`;
71
+ }
72
+ if (state === "OVER_BURN") {
73
+ return `${label} 5h usage is above target (${round(fiveUsed)}%, target ${round(target.min)}%-${round(target.max)}%).`;
74
+ }
75
+ return `${label} burn pace is on track (${round(fiveUsed)}%, target ${round(target.min)}%-${round(target.max)}%).`;
76
+ }
77
+ export function analyzeUsage(usage, samples, profile = "low", now = new Date()) {
78
+ const fiveHour = findWindow(usage, "five_hour");
79
+ const sevenDay = findWindow(usage, "seven_day");
80
+ if (usage.balance && usage.windows.length === 0) {
81
+ const currency = usage.balance.currency === "CNY" ? "¥" : "$";
82
+ const balanceText = `${currency}${usage.balance.total}`;
83
+ return {
84
+ provider: usage.provider,
85
+ state: "RAW",
86
+ profile,
87
+ observedAt: usage.observedAt,
88
+ message: usage.balance.isAvailable
89
+ ? `${messageForState(usage.provider, "RAW")} balance: ${balanceText}`
90
+ : `${messageForState(usage.provider, "LIMIT_RISK")} balance depleted`,
91
+ };
92
+ }
93
+ if ((fiveHour?.usedPercent ?? 0) >= 90 || (sevenDay?.usedPercent ?? 0) >= 90) {
94
+ return {
95
+ provider: usage.provider,
96
+ state: "LIMIT_RISK",
97
+ profile,
98
+ observedAt: usage.observedAt,
99
+ fiveHour,
100
+ sevenDay,
101
+ message: messageForState(usage.provider, "LIMIT_RISK"),
102
+ };
103
+ }
104
+ if (!fiveHour || !sevenDay) {
105
+ const label = providerLabel(usage.provider);
106
+ const message = !fiveHour && sevenDay
107
+ ? `${label} 5h usage unavailable; showing 7d only.`
108
+ : fiveHour && !sevenDay
109
+ ? `${label} 7d usage unavailable; showing 5h only.`
110
+ : `${label} usage windows unavailable.`;
111
+ return {
112
+ provider: usage.provider,
113
+ state: "RAW",
114
+ profile,
115
+ observedAt: usage.observedAt,
116
+ fiveHour,
117
+ sevenDay,
118
+ message,
119
+ };
120
+ }
121
+ const conversionRate = estimateConversionRate(samples);
122
+ if (conversionRate === null) {
123
+ return {
124
+ provider: usage.provider,
125
+ state: "RAW",
126
+ profile,
127
+ observedAt: usage.observedAt,
128
+ fiveHour,
129
+ sevenDay,
130
+ message: messageForState(usage.provider, "RAW"),
131
+ };
132
+ }
133
+ const remainingSeven = Math.max(0, 100 - sevenDay.usedPercent);
134
+ const remainingSlots = Math.max(1, minutesUntil(sevenDay.resetsAt, now.getTime()) / 300);
135
+ const weeklyBudgetPerSlot = remainingSeven / remainingSlots;
136
+ const recommendedPercent = Math.min(100, weeklyBudgetPerSlot / conversionRate);
137
+ const factors = PROFILE_FACTORS[profile];
138
+ const target = {
139
+ minPercent: Math.min(100, recommendedPercent * factors.min),
140
+ maxPercent: Math.min(100, recommendedPercent * factors.max),
141
+ recommendedPercent,
142
+ conversionRate,
143
+ };
144
+ let state = "ON_TRACK";
145
+ if (fiveHour.usedPercent < target.minPercent) {
146
+ state = "UNDER_BURN";
147
+ }
148
+ else if (fiveHour.usedPercent > target.maxPercent) {
149
+ state = "OVER_BURN";
150
+ }
151
+ return {
152
+ provider: usage.provider,
153
+ state,
154
+ profile,
155
+ observedAt: usage.observedAt,
156
+ fiveHour,
157
+ sevenDay,
158
+ target,
159
+ message: messageForState(usage.provider, state, fiveHour.usedPercent, {
160
+ min: target.minPercent,
161
+ max: target.maxPercent,
162
+ }),
163
+ };
164
+ }
package/dist/card.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { BurnAnalysis, RuntimePaths } from "./types.js";
2
+ export declare function notificationCardSvg(analysis: BurnAnalysis): string;
3
+ export declare function cardPathForAnalysis(analysis: BurnAnalysis, paths?: RuntimePaths): string;
4
+ export declare function writeNotificationCard(analysis: BurnAnalysis, paths?: RuntimePaths): string;
package/dist/card.js ADDED
@@ -0,0 +1,79 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { ensureDir } from "./fs-util.js";
5
+ import { formatDurationUntil } from "./format.js";
6
+ import { buildPaths } from "./paths.js";
7
+ function escapeXml(value) {
8
+ return value
9
+ .replaceAll("&", "&amp;")
10
+ .replaceAll("<", "&lt;")
11
+ .replaceAll(">", "&gt;")
12
+ .replaceAll('"', "&quot;");
13
+ }
14
+ function providerLabel(provider) {
15
+ if (provider === "claude")
16
+ return "Claude";
17
+ if (provider === "glm")
18
+ return "GLM";
19
+ if (provider === "deepseek")
20
+ return "DeepSeek";
21
+ return "Codex";
22
+ }
23
+ function cardTheme(state) {
24
+ if (state === "UNDER_BURN") {
25
+ return { accent: "#f59e0b", label: "BURN LOW" };
26
+ }
27
+ if (state === "OVER_BURN") {
28
+ return { accent: "#7c3aed", label: "TOO FAST" };
29
+ }
30
+ if (state === "LIMIT_RISK") {
31
+ return { accent: "#dc2626", label: "LIMIT RISK" };
32
+ }
33
+ return { accent: "#16a34a", label: "ON TRACK" };
34
+ }
35
+ function targetLine(analysis) {
36
+ if (analysis.state === "LIMIT_RISK") {
37
+ return "near plan limit";
38
+ }
39
+ if (!analysis.target) {
40
+ return "learning baseline";
41
+ }
42
+ return `target ${Math.round(analysis.target.minPercent)}-${Math.round(analysis.target.maxPercent)}%`;
43
+ }
44
+ export function notificationCardSvg(analysis) {
45
+ const theme = cardTheme(analysis.state);
46
+ const used = analysis.fiveHour ? `${Math.round(analysis.fiveHour.usedPercent)}%` : "--";
47
+ const reset = analysis.fiveHour ? `${formatDurationUntil(analysis.fiveHour.resetsAt)} left` : "no reset data";
48
+ const title = `${providerLabel(analysis.provider)} 5h`;
49
+ const target = targetLine(analysis);
50
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="420" height="180" viewBox="0 0 420 180">
51
+ <rect width="420" height="180" rx="28" fill="#111827"/>
52
+ <rect x="0" y="0" width="12" height="180" fill="${theme.accent}"/>
53
+ <text x="30" y="45" fill="#f9fafb" font-family="Arial, Helvetica, sans-serif" font-size="28" font-weight="700">${escapeXml(title)}</text>
54
+ <text x="30" y="116" fill="${theme.accent}" font-family="Arial, Helvetica, sans-serif" font-size="62" font-weight="800">${escapeXml(used)}</text>
55
+ <text x="214" y="64" fill="#f9fafb" font-family="Arial, Helvetica, sans-serif" font-size="24" font-weight="700">${escapeXml(theme.label)}</text>
56
+ <text x="214" y="104" fill="#d1d5db" font-family="Arial, Helvetica, sans-serif" font-size="22">${escapeXml(target)}</text>
57
+ <text x="214" y="138" fill="#9ca3af" font-family="Arial, Helvetica, sans-serif" font-size="21">${escapeXml(reset)}</text>
58
+ </svg>
59
+ `;
60
+ }
61
+ export function cardPathForAnalysis(analysis, paths = buildPaths()) {
62
+ const fileName = `${analysis.provider}-${analysis.state.toLowerCase()}.png`;
63
+ return path.join(paths.stateDir, "cards", fileName);
64
+ }
65
+ export function writeNotificationCard(analysis, paths = buildPaths()) {
66
+ const pngPath = cardPathForAnalysis(analysis, paths);
67
+ const svgPath = pngPath.replace(/\.png$/, ".svg");
68
+ ensureDir(path.dirname(pngPath));
69
+ fs.writeFileSync(svgPath, notificationCardSvg(analysis), "utf8");
70
+ try {
71
+ execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], {
72
+ stdio: "ignore",
73
+ });
74
+ return pngPath;
75
+ }
76
+ catch {
77
+ return svgPath;
78
+ }
79
+ }
@@ -0,0 +1,9 @@
1
+ import { ProviderUsage } from "./types.js";
2
+ export declare function usageFromClaudeStatusLine(input: unknown): ProviderUsage;
3
+ export declare function readStdin(): Promise<string>;
4
+ export declare function readClaudeSettings(file: string): Record<string, unknown> | null;
5
+ export declare function getClaudeStatusLineCommand(settings: Record<string, unknown> | null): string | null;
6
+ export declare function claudeStatusLineHasIngest(command: string | null, options: {
7
+ appCliPath: string;
8
+ managedScriptPath?: string;
9
+ }): boolean;