ai-battery 0.1.0 → 0.1.2

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
@@ -9,7 +9,7 @@ Codex와 Claude Code의 남은 사용량을 배터리처럼 확인하는 터미
9
9
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20WSL%20%7C%20Linux%20%7C%20macOS-blue)
10
10
  ![License](https://img.shields.io/badge/license-MIT-green)
11
11
 
12
- [Install](#install) · [Features](#features) · [Quick Start](#quick-start) · [Claude StatusLine](#claude-statusline) · [Floating HUD](#floating-hud) · [Caution](#caution)
12
+ [Install](#install) · [Features](#features) · [Quick Start](#quick-start) · [Claude StatusLine](#claude-statusline) · [Desktop HUD](#desktop-hud) · [Caution](#caution)
13
13
 
14
14
  ## Overview
15
15
 
@@ -40,7 +40,7 @@ Codex 86% │ 5h 18:09 │ 7d 82% ┃ Claude 4% │ 5h 18:10 │ 7d 71%
40
40
  | 색상 기준 | 40% 초과 초록, 21-40% 주황, 20% 이하 빨강으로 배터리만 강조합니다. |
41
41
  | Codex terminal row | Codex 아래에 별도 사용량 행을 고정하는 PTY wrapper를 제공합니다. |
42
42
  | Claude statusLine | Claude Code 내장 statusLine hook과 실제 429 hit 로그로 Claude rate limit 상태를 읽습니다. |
43
- | Windows HUD | Windows nativeWSL에서 실행하는 작은 floating HUD 제공합니다. |
43
+ | HUD / menu bar | Windows native/WSL에서는 floating HUD, macOS에서는 menu bar status item을 제공합니다. |
44
44
  | npm 실행 | `npm install -g` 또는 `npx`로 실행할 수 있습니다. |
45
45
 
46
46
  ## Platform Support
@@ -52,7 +52,7 @@ Codex 86% │ 5h 18:09 │ 7d 82% ┃ Claude 4% │ 5h 18:10 │ 7d 71%
52
52
  | Claude statusLine | 지원 | 지원 | 지원 | 지원 | Claude Code `statusLine`에 `node <script>` 명령을 저장합니다. |
53
53
  | Codex terminal row | 미지원 | 지원 | 지원 | 지원 | `ai-battery-run`이 POSIX PTY와 `python3`를 사용합니다. |
54
54
  | `ai-battery setup codex` | 미지원 | 지원 | 지원 | 지원 | `codex` wrapper가 POSIX shell/PTY 기반입니다. |
55
- | `ai-battery hud` | 지원 | 지원 | 미지원 | 미지원 | Windows PowerShell/WinForms HUD입니다. Linux/macOS 터미널에서는 `ai-battery --watch`를 사용하세요. |
55
+ | `ai-battery hud` | 지원 | 지원 | 미지원 | 지원 | Windows/WSL은 PowerShell/WinForms HUD, macOS menu bar status item입니다. |
56
56
 
57
57
  실행 중 감지(흰색/회색 표시)는 Linux/WSL은 `/proc`, macOS는 `ps`, Windows는 PowerShell 프로세스 목록을 사용합니다.
58
58
 
@@ -91,7 +91,7 @@ npx ai-battery
91
91
  codex
92
92
  ```
93
93
 
94
- 4. Windows HUD 필요하면 실행합니다.
94
+ 4. 데스크톱 HUD macOS menu bar 표시가 필요하면 실행합니다.
95
95
 
96
96
  ```bash
97
97
  ai-battery hud
@@ -103,9 +103,11 @@ npx ai-battery
103
103
  ai-battery
104
104
  ai-battery --watch 10
105
105
  ai-battery --json
106
+ ai-battery --version
106
107
  ai-battery --provider codex
107
108
  ai-battery --provider claude
108
109
  ai-battery setup
110
+ ai-battery doctor
109
111
  ai-battery hud
110
112
  ai-battery off codex
111
113
  ai-battery on codex
@@ -118,6 +120,9 @@ ai-battery on codex
118
120
  | `--json` | HUD나 다른 도구에서 쓰기 좋은 JSON을 출력합니다. |
119
121
  | `--bar-width N` | 터미널 배터리 바 길이를 조정합니다. |
120
122
  | `--show-paths` | 로그 파일 경로와 데이터 관측 시각을 함께 표시합니다. |
123
+ | `-v`, `--version` | 설치된 `ai-battery` 버전을 출력합니다. |
124
+
125
+ `doctor`는 설치 상태와 함께 npm latest 버전을 확인합니다. 네트워크가 막혀 있으면 버전 확인만 건너뛰고 나머지 진단은 계속 표시합니다.
121
126
 
122
127
  ## Setup
123
128
 
@@ -134,7 +139,13 @@ ai-battery setup claude
134
139
  ai-battery setup codex
135
140
  ```
136
141
 
137
- Codex wrapper는 기존 `codex` 명령을 직접 덮어쓰지 않습니다. `~/.local/bin/codex`에 관리형 wrapper를 만들고, 필요한 경우 셸 설정에 `~/.local/bin`을 PATH 앞쪽으로 추가합니다. 새 터미널부터 `codex`가 자동으로 AI Battery 하단 행과 함께 실행됩니다.
142
+ Codex wrapper는 기존 `codex` 명령을 직접 덮어쓰지 않습니다. `~/.local/bin/codex`에 관리형 wrapper를 만들고, 필요한 경우 셸 설정에 `~/.local/bin`을 PATH 앞쪽으로 추가합니다. 새 터미널부터 `codex`가 자동으로 AI Battery 하단 행과 함께 실행됩니다. 현재 터미널에서 바로 쓰려면 `setup` 출력에 표시되는 `source ...` 명령을 실행하세요.
143
+
144
+ Codex 하단 행이 보이지 않으면 진단을 실행합니다.
145
+
146
+ ```bash
147
+ ai-battery doctor
148
+ ```
138
149
 
139
150
  표시할 provider는 짧은 on/off 명령으로 바꿉니다.
140
151
 
@@ -196,15 +207,15 @@ ai-battery uninstall-claude-statusline
196
207
 
197
208
  Claude가 한 번 이상 statusLine payload를 전달해야 Claude의 사용량 캐시가 생성됩니다. 그 전에는 Claude 로컬 로그 기반 fallback이 표시됩니다.
198
209
 
199
- ## Floating HUD
210
+ ## Desktop HUD
200
211
 
201
- 일반 터미널 위에 외부 프로세스가 안전하게 status line을 덧그리는 방식은 안정적이지 않습니다. 그래서 HUD는 Windows floating overlay 제공합니다. Windows native에서는 WSL 없이 PowerShell/WinForms로 바로 실행되고, WSL에서는 `powershell.exe`를 통해 같은 HUD를 띄웁니다.
212
+ 일반 터미널 위에 외부 프로세스가 안전하게 status line을 덧그리는 방식은 안정적이지 않습니다. 그래서 Windows에서는 floating overlay를, macOS에서는 상단 menu bar status item을 제공합니다. Windows native에서는 WSL 없이 PowerShell/WinForms로 바로 실행되고, WSL에서는 `powershell.exe`를 통해 같은 HUD를 띄웁니다. macOS에서는 메뉴바 두께에 맞춰 `Cx 75% | Cl 4%`처럼 한 줄 요약만 표시하고, 클릭하면 자세한 상태를 볼 수 있습니다.
202
213
 
203
214
  ```bash
204
215
  ai-battery hud
205
216
  ```
206
217
 
207
- HUD는 백그라운드에서 실행되고 터미널을 바로 돌려줍니다. 위치는 드래그로 옮길 수 있으며, 다음 실행 때 저장된 위치를 재사용합니다.
218
+ HUD는 백그라운드에서 실행되고 터미널을 바로 돌려줍니다. Windows HUD는 위치를 드래그로 옮길 수 있으며, 다음 실행 때 저장된 위치를 재사용합니다. macOS menu bar item은 시스템 메뉴바 오른쪽 영역에 표시됩니다.
208
219
 
209
220
  ```text
210
221
  Codex [battery:88] │ 5h 00:47 │ 7d 93%
@@ -213,18 +224,18 @@ Claude [battery:76] │ 5h 00:47 │ 7d 59%
213
224
 
214
225
  | Command | Role |
215
226
  | --- | --- |
216
- | `ai-battery hud` / `ai-battery hud start` | floating HUD 시작합니다. 이미 있으면 인스턴스로 교체합니다. |
217
- | `ai-battery hud stop` | 실행 중인 HUD 종료합니다. (`--stop`도 동일) |
218
- | `ai-battery hud status` | HUD 실행 여부와 autostart 등록 상태를 보여줍니다. |
219
- | `ai-battery hud autostart on` | Windows 로그인 시 HUD 자동 실행을 등록합니다. |
227
+ | `ai-battery hud` / `ai-battery hud start` | Windows floating HUD 또는 macOS menu bar item을 시작합니다. |
228
+ | `ai-battery hud stop` | 실행 중인 HUD/menu bar item을 종료합니다. (`--stop`도 동일) |
229
+ | `ai-battery hud status` | HUD/menu bar 실행 여부와 autostart 등록 상태를 보여줍니다. |
230
+ | `ai-battery hud autostart on` | Windows 로그인 또는 macOS 로그인 시 자동 실행을 등록합니다. |
220
231
  | `ai-battery hud autostart off` | 자동 실행 등록을 해제합니다. |
221
232
  | `ai-battery hud autostart status` | 자동 실행 등록 상태만 보여줍니다. |
222
233
  | `ai-battery hud -Foreground` | 디버깅용으로 터미널에 붙여 실행합니다. |
223
234
  | `ai-battery hud -Once` | 콘솔에서 한 번만 출력합니다. |
224
235
  | `ai-battery hud -Interval 2` | 갱신 주기를 바꿉니다. |
225
- | `ai-battery hud -Mode tray` | Windows tray icon 모드로 실행합니다. |
236
+ | `ai-battery hud -Mode tray` | Windows tray icon 모드로 실행합니다. macOS에서는 menu bar item이 기본입니다. |
226
237
 
227
- autostart는 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`에 사용자 단위로 등록됩니다. Windows native에서는 WSL 없이 바로 실행되고, WSL에서 등록한 경우에는 HUD 스크립트 사본을 `%LOCALAPPDATA%\ai-battery`에 두어 로그인 WSL이 아직 시작되지 않아도 HUD가 먼저 뜨도록 합니다(WSL 로그 기반 사용량은 WSL이 올라온 뒤 채워집니다). ai-battery를 업데이트한 뒤에는 `ai-battery hud autostart on`을 다시 실행해 사본을 갱신하세요.
238
+ Windows autostart는 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`에 사용자 단위로 등록됩니다. Windows native에서는 WSL 없이 바로 실행되고, WSL에서 등록한 경우에는 HUD 스크립트 사본을 `%LOCALAPPDATA%\ai-battery`에 둡니다. macOS autostart는 `~/Library/LaunchAgents/com.ai-battery.hud.plist`로 등록됩니다. ai-battery를 업데이트한 뒤에는 `ai-battery hud autostart on`을 다시 실행해 등록 경로를 갱신하세요.
228
239
 
229
240
  ## Shell Prompt
230
241
 
@@ -247,7 +258,7 @@ flowchart LR
247
258
  F --> R
248
259
  R --> T[Terminal output]
249
260
  R --> P[PTY reserved row]
250
- R --> H[Windows HUD]
261
+ R --> H[Desktop HUD / menu bar]
251
262
  ```
252
263
 
253
264
  Codex는 최근 세션 로그에서 `rate_limits` 이벤트를 찾습니다. Claude Code는 statusLine payload로 사용률과 리셋 시각을 제공하고, 실제 429 rate-limit hit 로그가 있으면 reset 전까지 0%로 반영합니다. fallback 모드에서는 최근 토큰 사용량만 확인할 수 있습니다.
@@ -258,13 +269,13 @@ Codex는 최근 세션 로그에서 `rate_limits` 이벤트를 찾습니다. Cla
258
269
  | --- | --- | --- |
259
270
  | CLI | Node.js | 로그 파싱, Claude cache, ANSI/statusLine 출력 |
260
271
  | PTY row | Python 3 | Codex 실행용 reserved terminal row |
261
- | HUD launcher | Node.js / Bash compatibility wrapper | Windows native/WSL에서 PowerShell HUD 실행 |
262
- | HUD UI | PowerShell WinForms | Windows floating overlay tray icon |
272
+ | HUD launcher | Node.js / Bash compatibility wrapper | Windows native/WSL PowerShell HUD macOS menu bar 실행 |
273
+ | HUD UI | PowerShell WinForms / AppleScriptObjC | Windows floating overlay, tray icon, macOS menu bar item |
263
274
  | Data | JSONL logs, statusLine JSON | Codex/Claude 사용량 소스 |
264
275
 
265
276
  ## Source Environment
266
277
 
267
- 기본 CLI는 Node.js 18 이상이 있으면 Windows native, WSL, Linux, macOS에서 실행됩니다. `ai-battery-run`과 Codex terminal row는 Python 3와 POSIX PTY가 필요해서 WSL/Linux/macOS에서 지원됩니다. HUD는 Windows PowerShell/WinForms 사용하므로 Windows native와 WSL에서만 지원됩니다.
278
+ 기본 CLI는 Node.js 18 이상이 있으면 Windows native, WSL, Linux, macOS에서 실행됩니다. `ai-battery-run`과 Codex terminal row는 Python 3와 POSIX PTY가 필요해서 WSL/Linux/macOS에서 지원됩니다. HUD는 Windows/WSL에서는 PowerShell/WinForms, macOS에서는 내장 `osascript`와 AppleScriptObjC를 사용합니다.
268
279
 
269
280
  Codex 데이터는 기본적으로 `~/.codex/sessions`를 읽습니다. 다른 위치를 쓰고 있다면 `CODEX_HOME`을 설정하세요.
270
281
 
@@ -279,5 +290,5 @@ Claude의 사용량 표시는 Claude Code statusLine hook을 설치한 뒤부터
279
290
  - 이 도구는 로컬 로그와 Claude statusLine payload를 읽습니다. 서비스의 공식 과금/한도 화면을 대체하지 않습니다.
280
291
  - Codex rate limit 이벤트가 아직 생성되지 않았거나 오래된 경우 최신 상태와 차이가 있을 수 있습니다.
281
292
  - Claude statusLine은 사용률과 reset 시각만 제공하므로, 실제 hit 상태는 Claude가 남긴 429 rate-limit 로그를 함께 읽어 반영합니다.
282
- - HUD는 Windows PowerShell/WinForms 기반입니다. Windows native에서는 직접 실행하고, WSL에서는 `powershell.exe`와 `wsl.exe`를 함께 사용합니다.
293
+ - HUD는 Windows에서는 PowerShell/WinForms 기반이고, macOS에서는 menu bar status item 기반입니다. WSL에서는 `powershell.exe`와 `wsl.exe`를 함께 사용합니다.
283
294
  - `ai-battery-run`은 PTY wrapper입니다. 일부 전체 화면 TUI는 화면을 지우는 escape sequence 때문에 status row가 잠시 흔들릴 수 있습니다.
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  const scriptPath = fs.realpathSync(fileURLToPath(import.meta.url));
10
10
  const scriptDir = path.dirname(scriptPath);
11
11
  const ps1Path = path.join(scriptDir, "ai-battery-hud.ps1");
12
+ const macStatusPath = path.join(scriptDir, "ai-battery-macos-status.applescript");
12
13
 
13
14
  function isWsl() {
14
15
  if (process.platform !== "linux") return false;
@@ -112,12 +113,242 @@ function prefetchInitialJson(command, useWslCommand) {
112
113
  return null;
113
114
  }
114
115
 
116
+ function relevantEnvPrefix() {
117
+ const names = [
118
+ "AI_BATTERY_STATE_DIR",
119
+ "CLAUDEX_BATTERY_STATE_DIR",
120
+ "AI_BATTERY_COLUMNS",
121
+ "CLAUDEX_BATTERY_COLUMNS",
122
+ "AI_BATTERY_COLUMN_GUARD",
123
+ "CLAUDEX_BATTERY_COLUMN_GUARD",
124
+ "CODEX_HOME"
125
+ ];
126
+ const pairs = [`HOME=${shellQuote(os.homedir())}`];
127
+ for (const name of names) {
128
+ if (process.env[name]) pairs.push(`${name}=${shellQuote(process.env[name])}`);
129
+ }
130
+ return pairs.join(" ");
131
+ }
132
+
133
+ function parseMacHudArgs(cliArgs) {
134
+ const options = {
135
+ foreground: false,
136
+ once: false,
137
+ stop: false,
138
+ subcommand: null,
139
+ autostartAction: "status",
140
+ interval: 10,
141
+ provider: "all"
142
+ };
143
+
144
+ for (let i = 0; i < cliArgs.length; i += 1) {
145
+ const arg = cliArgs[i];
146
+ if (arg === "-Foreground" || arg === "--foreground") {
147
+ options.foreground = true;
148
+ } else if (arg === "-Once" || arg === "--once") {
149
+ options.once = true;
150
+ } else if (arg === "-Stop" || arg === "--stop" || arg === "stop") {
151
+ options.stop = true;
152
+ } else if (arg === "start") {
153
+ // Starting is the default action.
154
+ } else if (arg === "status") {
155
+ options.subcommand = "status";
156
+ } else if (arg === "autostart") {
157
+ options.subcommand = "autostart";
158
+ const next = cliArgs[i + 1];
159
+ if (next === "on" || next === "off" || next === "status") {
160
+ options.autostartAction = next;
161
+ i += 1;
162
+ }
163
+ } else if (arg === "-Interval" || arg === "--interval") {
164
+ const next = cliArgs[i + 1];
165
+ if (next && !next.startsWith("-")) {
166
+ options.interval = Math.max(1, Number(next) || options.interval);
167
+ i += 1;
168
+ }
169
+ } else if (arg === "--provider" || arg === "-p") {
170
+ const next = cliArgs[i + 1];
171
+ if (["all", "codex", "claude"].includes(next)) {
172
+ options.provider = next;
173
+ i += 1;
174
+ }
175
+ }
176
+ }
177
+
178
+ return options;
179
+ }
180
+
181
+ function macProviderArgs(provider) {
182
+ return provider === "all" ? "" : ` --provider ${shellQuote(provider)}`;
183
+ }
184
+
185
+ function macCommands(options) {
186
+ const node = shellQuote(process.execPath);
187
+ const batteryJs = shellQuote(path.join(scriptDir, "ai-battery.js"));
188
+ const providerArgs = macProviderArgs(options.provider);
189
+ const envPrefix = relevantEnvPrefix();
190
+ return {
191
+ title: `${envPrefix} ${node} ${batteryJs} --menu-bar${providerArgs} 2>/dev/null`,
192
+ detail: `${envPrefix} ${node} ${batteryJs} --no-color${providerArgs} 2>/dev/null`
193
+ };
194
+ }
195
+
196
+ function macHudPids() {
197
+ const result = spawnSync("pgrep", ["-f", macStatusPath], {
198
+ encoding: "utf8",
199
+ stdio: ["ignore", "pipe", "ignore"]
200
+ });
201
+ if (result.status !== 0) return [];
202
+ return (result.stdout || "")
203
+ .trim()
204
+ .split(/\s+/)
205
+ .map((pid) => Number(pid))
206
+ .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
207
+ }
208
+
209
+ function macLaunchAgentPath() {
210
+ return path.join(os.homedir(), "Library", "LaunchAgents", "com.ai-battery.hud.plist");
211
+ }
212
+
213
+ function macLaunchctlDomain() {
214
+ return `gui/${typeof process.getuid === "function" ? process.getuid() : ""}`;
215
+ }
216
+
217
+ function xmlEscape(value) {
218
+ return String(value)
219
+ .replace(/&/g, "&amp;")
220
+ .replace(/</g, "&lt;")
221
+ .replace(/>/g, "&gt;")
222
+ .replace(/"/g, "&quot;");
223
+ }
224
+
225
+ function plistString(value) {
226
+ return ` <string>${xmlEscape(value)}</string>`;
227
+ }
228
+
229
+ function macAutostartPlist(options) {
230
+ const args = [process.execPath, scriptPath, "start", "--interval", String(options.interval)];
231
+ if (options.provider !== "all") args.push("--provider", options.provider);
232
+ return `<?xml version="1.0" encoding="UTF-8"?>
233
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
234
+ <plist version="1.0">
235
+ <dict>
236
+ <key>Label</key>
237
+ <string>com.ai-battery.hud</string>
238
+ <key>ProgramArguments</key>
239
+ <array>
240
+ ${args.map(plistString).join("\n")}
241
+ </array>
242
+ <key>RunAtLoad</key>
243
+ <true/>
244
+ </dict>
245
+ </plist>
246
+ `;
247
+ }
248
+
249
+ function macAutostartStatus() {
250
+ const filePath = macLaunchAgentPath();
251
+ return { enabled: fs.existsSync(filePath), filePath };
252
+ }
253
+
254
+ function macAutostartEnable(options) {
255
+ const filePath = macLaunchAgentPath();
256
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
257
+ fs.writeFileSync(filePath, macAutostartPlist(options), "utf8");
258
+ spawnSync("launchctl", ["bootstrap", macLaunchctlDomain(), filePath], { stdio: "ignore" });
259
+ return filePath;
260
+ }
261
+
262
+ function macAutostartDisable() {
263
+ const filePath = macLaunchAgentPath();
264
+ spawnSync("launchctl", ["bootout", macLaunchctlDomain(), filePath], { stdio: "ignore" });
265
+ fs.rmSync(filePath, { force: true });
266
+ return filePath;
267
+ }
268
+
269
+ function runMacHud(cliArgs) {
270
+ const options = parseMacHudArgs(cliArgs);
271
+ const commands = macCommands(options);
272
+
273
+ if (options.once) {
274
+ const result = spawnSync(process.execPath, [
275
+ path.join(scriptDir, "ai-battery.js"),
276
+ "--menu-bar",
277
+ ...(options.provider === "all" ? [] : ["--provider", options.provider])
278
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
279
+ if (result.stdout) process.stdout.write(result.stdout);
280
+ process.exit(result.status ?? 0);
281
+ }
282
+
283
+ if (options.subcommand === "status") {
284
+ const pids = macHudPids();
285
+ const auto = macAutostartStatus();
286
+ console.log(`HUD: ${pids.length ? `running (PID ${pids[0]})` : "stopped"}`);
287
+ console.log(`Autostart: ${auto.enabled ? "on" : "off"}`);
288
+ if (auto.enabled) console.log(` ${auto.filePath}`);
289
+ process.exit(0);
290
+ }
291
+
292
+ if (options.subcommand === "autostart") {
293
+ if (options.autostartAction === "on") {
294
+ console.log(`HUD autostart enabled: ${macAutostartEnable(options)}`);
295
+ } else if (options.autostartAction === "off") {
296
+ console.log(`HUD autostart disabled: ${macAutostartDisable()}`);
297
+ } else {
298
+ const auto = macAutostartStatus();
299
+ console.log(`Autostart: ${auto.enabled ? "on" : "off"}`);
300
+ if (auto.enabled) console.log(` ${auto.filePath}`);
301
+ }
302
+ process.exit(0);
303
+ }
304
+
305
+ if (options.stop) {
306
+ const pids = macHudPids();
307
+ if (!pids.length) {
308
+ console.log("AI Battery menu bar is not running.");
309
+ process.exit(0);
310
+ }
311
+ const result = spawnSync("kill", pids.map(String), { stdio: "inherit" });
312
+ if ((result.status ?? 0) === 0) console.log("AI Battery menu bar stopped.");
313
+ process.exit(result.status ?? 0);
314
+ }
315
+
316
+ if (macHudPids().length) {
317
+ console.log("AI Battery menu bar is already running.");
318
+ process.exit(0);
319
+ }
320
+
321
+ const osascriptArgs = [
322
+ macStatusPath,
323
+ commands.title,
324
+ commands.detail,
325
+ String(options.interval)
326
+ ];
327
+
328
+ if (options.foreground) {
329
+ const result = spawnSync("osascript", osascriptArgs, { stdio: "inherit" });
330
+ process.exit(result.status ?? 0);
331
+ }
332
+
333
+ const child = spawn("osascript", osascriptArgs, {
334
+ detached: true,
335
+ stdio: "ignore"
336
+ });
337
+ child.unref();
338
+ console.log("AI Battery menu bar started. Click the menu bar item for details; use \"ai-battery hud stop\" to close it.");
339
+ process.exit(0);
340
+ }
341
+
342
+ if (process.platform === "darwin") {
343
+ runMacHud(process.argv.slice(2));
344
+ }
345
+
115
346
  const useWsl = isWsl();
116
347
  const powershell = "powershell.exe";
117
348
 
118
349
  if (!useWsl && process.platform !== "win32") {
119
- console.error("ai-battery-hud: the floating HUD needs Windows (native or WSL).");
120
- console.error("On macOS/Linux terminals use: ai-battery --watch");
350
+ console.error("ai-battery-hud: desktop HUD needs Windows (native or WSL) or macOS.");
351
+ console.error("On Linux terminals use: ai-battery --watch");
121
352
  process.exit(1);
122
353
  }
123
354
 
@@ -0,0 +1,78 @@
1
+ use framework "AppKit"
2
+ use framework "Foundation"
3
+ use scripting additions
4
+
5
+ property statusItem : missing value
6
+ property detailItem : missing value
7
+ property refreshTimer : missing value
8
+ property titleCommand : ""
9
+ property detailCommand : ""
10
+ property refreshInterval : 10
11
+
12
+ on run argv
13
+ if (count of argv) < 2 then error "ai-battery macOS status item requires title and detail commands"
14
+
15
+ set titleCommand to item 1 of argv
16
+ set detailCommand to item 2 of argv
17
+ if (count of argv) is greater than or equal to 3 then
18
+ try
19
+ set refreshInterval to (item 3 of argv) as real
20
+ end try
21
+ end if
22
+
23
+ current application's NSApplication's sharedApplication()
24
+ current application's NSApp's setActivationPolicy:(current application's NSApplicationActivationPolicyAccessory)
25
+
26
+ set statusItem to current application's NSStatusBar's systemStatusBar()'s statusItemWithLength:(current application's NSVariableStatusItemLength)
27
+ statusItem's button()'s setTitle:"AI --"
28
+ statusItem's button()'s setToolTip:"AI Battery"
29
+
30
+ set statusMenu to current application's NSMenu's alloc()'s initWithTitle:"AI Battery"
31
+ set detailItem to current application's NSMenuItem's alloc()'s initWithTitle:"Loading..." action:(missing value) keyEquivalent:""
32
+ detailItem's setEnabled:false
33
+ statusMenu's addItem:detailItem
34
+ statusMenu's addItem:(current application's NSMenuItem's separatorItem())
35
+
36
+ set refreshItem to current application's NSMenuItem's alloc()'s initWithTitle:"Refresh" action:"refresh:" keyEquivalent:"r"
37
+ refreshItem's setTarget:me
38
+ statusMenu's addItem:refreshItem
39
+
40
+ set quitItem to current application's NSMenuItem's alloc()'s initWithTitle:"Quit AI Battery" action:"quit:" keyEquivalent:"q"
41
+ quitItem's setTarget:me
42
+ statusMenu's addItem:quitItem
43
+
44
+ statusItem's setMenu:statusMenu
45
+ my refresh_(missing value)
46
+
47
+ set refreshTimer to current application's NSTimer's scheduledTimerWithTimeInterval:refreshInterval target:me selector:"refresh:" userInfo:(missing value) repeats:true
48
+ current application's NSRunLoop's currentRunLoop()'s addTimer:refreshTimer forMode:(current application's NSRunLoopCommonModes)
49
+ current application's NSApp's run()
50
+ end run
51
+
52
+ on refresh_(sender)
53
+ set titleText to "AI --"
54
+ set detailText to "AI Battery unavailable"
55
+
56
+ try
57
+ set titleText to do shell script titleCommand
58
+ end try
59
+
60
+ try
61
+ set detailText to do shell script detailCommand
62
+ end try
63
+
64
+ if titleText is "" then set titleText to "AI --"
65
+ if detailText is "" then set detailText to "AI Battery unavailable"
66
+
67
+ statusItem's button()'s setTitle:titleText
68
+ statusItem's button()'s setToolTip:detailText
69
+ detailItem's setTitle:detailText
70
+ end refresh_
71
+
72
+ on quit_(sender)
73
+ try
74
+ refreshTimer's invalidate()
75
+ end try
76
+ current application's NSStatusBar's systemStatusBar()'s removeStatusItem:statusItem
77
+ current application's NSApp's terminate:me
78
+ end quit_
package/bin/ai-battery.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from "node:fs";
4
+ import https from "node:https";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
6
7
  import { execFileSync, spawnSync } from "node:child_process";
7
- import { fileURLToPath } from "node:url";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
9
 
9
10
  const DEFAULT_TAIL_BYTES = 4 * 1024 * 1024;
10
11
  const DEFAULT_MAX_FILES = 40;
@@ -41,10 +42,12 @@ function parseArgs(argv) {
41
42
  leftPadding: 0,
42
43
  activeProvider: null,
43
44
  showPaths: false,
45
+ menuBar: false,
44
46
  silent: false,
45
47
  force: false,
46
48
  header: true,
47
49
  help: false,
50
+ version: false,
48
51
  targets: [],
49
52
  rest: []
50
53
  };
@@ -95,6 +98,11 @@ function parseArgs(argv) {
95
98
  args.activeProvider = argv[++i] || null;
96
99
  } else if (arg === "--show-paths") {
97
100
  args.showPaths = true;
101
+ } else if (arg === "--menu-bar") {
102
+ args.menuBar = true;
103
+ args.style = "plain";
104
+ } else if (arg === "--version" || arg === "-v") {
105
+ args.version = true;
98
106
  } else if (arg === "--help" || arg === "-h") {
99
107
  args.help = true;
100
108
  } else {
@@ -139,9 +147,11 @@ Options:
139
147
  --left-padding N Prefix status output with N spaces
140
148
  --active-provider codex|claude
141
149
  --show-paths Include source log paths in text output
150
+ --menu-bar Print compact macOS menu bar text
142
151
  --tmux Emit tmux status-line color markup
143
152
  --force Replace an existing Claude statusLine
144
153
  --no-color Disable ANSI colors
154
+ -v, --version Show ai-battery version
145
155
  -h, --help Show this help
146
156
 
147
157
  Compatibility:
@@ -274,6 +284,132 @@ function scriptDir() {
274
284
  return path.dirname(fileURLToPath(import.meta.url));
275
285
  }
276
286
 
287
+ function packageInfo() {
288
+ const pkg = readJson(path.join(scriptDir(), "..", "package.json")) ?? {};
289
+ return {
290
+ name: pkg.name || "ai-battery",
291
+ version: pkg.version || "0.0.0"
292
+ };
293
+ }
294
+
295
+ function npmRegistryPackageUrl(name) {
296
+ const encoded = String(name)
297
+ .split("/")
298
+ .map((part) => encodeURIComponent(part))
299
+ .join("%2F");
300
+ return `https://registry.npmjs.org/${encoded}/latest`;
301
+ }
302
+
303
+ function compareVersions(left, right) {
304
+ const parse = (value) => {
305
+ const [main, prerelease = ""] = String(value || "0.0.0").replace(/^v/, "").split("-", 2);
306
+ return {
307
+ parts: main.split(".").map((part) => Number.parseInt(part, 10) || 0),
308
+ prerelease
309
+ };
310
+ };
311
+ const a = parse(left);
312
+ const b = parse(right);
313
+ for (let i = 0; i < 3; i += 1) {
314
+ const diff = (a.parts[i] || 0) - (b.parts[i] || 0);
315
+ if (diff !== 0) return diff > 0 ? 1 : -1;
316
+ }
317
+ if (a.prerelease && !b.prerelease) return -1;
318
+ if (!a.prerelease && b.prerelease) return 1;
319
+ if (a.prerelease === b.prerelease) return 0;
320
+ return a.prerelease > b.prerelease ? 1 : -1;
321
+ }
322
+
323
+ function fetchJson(url, timeoutMs = 1800, redirectsLeft = 2) {
324
+ return new Promise((resolve) => {
325
+ const request = https.get(url, {
326
+ headers: {
327
+ Accept: "application/vnd.npm.install-v1+json, application/json",
328
+ "User-Agent": "ai-battery"
329
+ }
330
+ }, (response) => {
331
+ const location = response.headers.location;
332
+ if (
333
+ location
334
+ && response.statusCode >= 300
335
+ && response.statusCode < 400
336
+ && redirectsLeft > 0
337
+ ) {
338
+ response.resume();
339
+ const nextUrl = new URL(location, url).toString();
340
+ fetchJson(nextUrl, timeoutMs, redirectsLeft - 1).then(resolve);
341
+ return;
342
+ }
343
+
344
+ let body = "";
345
+ response.setEncoding("utf8");
346
+ response.on("data", (chunk) => {
347
+ body += chunk;
348
+ if (body.length > 1024 * 1024) request.destroy(new Error("response too large"));
349
+ });
350
+ response.on("end", () => {
351
+ if (response.statusCode < 200 || response.statusCode >= 300) {
352
+ resolve({ ok: false, error: `HTTP ${response.statusCode}` });
353
+ return;
354
+ }
355
+ try {
356
+ resolve({ ok: true, value: JSON.parse(body) });
357
+ } catch {
358
+ resolve({ ok: false, error: "invalid JSON from npm registry" });
359
+ }
360
+ });
361
+ });
362
+
363
+ request.setTimeout(timeoutMs, () => {
364
+ request.destroy(new Error("timeout"));
365
+ });
366
+ request.on("error", (error) => {
367
+ resolve({ ok: false, error: error.message || String(error) });
368
+ });
369
+ });
370
+ }
371
+
372
+ async function checkPackageVersion() {
373
+ const current = packageInfo();
374
+ const checkedAt = new Date().toISOString();
375
+ if (process.env.AI_BATTERY_NO_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) {
376
+ return {
377
+ name: current.name,
378
+ current: current.version,
379
+ latest: null,
380
+ updateAvailable: false,
381
+ checked: false,
382
+ checkedAt,
383
+ error: "disabled"
384
+ };
385
+ }
386
+
387
+ const result = await fetchJson(npmRegistryPackageUrl(current.name));
388
+ if (!result.ok) {
389
+ return {
390
+ name: current.name,
391
+ current: current.version,
392
+ latest: null,
393
+ updateAvailable: false,
394
+ checked: false,
395
+ checkedAt,
396
+ error: result.error
397
+ };
398
+ }
399
+
400
+ const latest = result.value?.version ?? null;
401
+ const comparison = latest ? compareVersions(latest, current.version) : 0;
402
+ return {
403
+ name: current.name,
404
+ current: current.version,
405
+ latest,
406
+ updateAvailable: comparison > 0,
407
+ checked: Boolean(latest),
408
+ checkedAt,
409
+ error: latest ? null : "npm registry response did not include a version"
410
+ };
411
+ }
412
+
277
413
  function shQuote(value) {
278
414
  return `'${String(value).replace(/'/g, "'\\''")}'`;
279
415
  }
@@ -350,6 +486,7 @@ function shellRcPath() {
350
486
  if (process.env.AI_BATTERY_RC) return process.env.AI_BATTERY_RC;
351
487
  const shell = path.basename(process.env.SHELL || "");
352
488
  if (shell === "zsh") return homePath(".zshrc");
489
+ if (shell === "bash" && process.platform === "darwin") return homePath(".bash_profile");
353
490
  if (shell === "bash") return homePath(".bashrc");
354
491
  if (shell === "fish") return homePath(".config", "fish", "config.fish");
355
492
  return homePath(".profile");
@@ -448,6 +585,13 @@ function installCodexWrapper(args) {
448
585
  };
449
586
  }
450
587
 
588
+ function sourcePathCommand(rcPath) {
589
+ const shell = path.basename(process.env.SHELL || "");
590
+ if (!rcPath) return null;
591
+ if (["bash", "fish", "zsh"].includes(shell)) return `source ${shellArg(rcPath)}`;
592
+ return `. ${shellArg(rcPath)}`;
593
+ }
594
+
451
595
  function codexRestartNote() {
452
596
  if (!runningInsideCodex() || runningInsideAiBatteryCodexWrapper()) return null;
453
597
  return "Current Codex was not started through AI Battery. Exit this Codex session and run plain \"codex\" again from a normal terminal.";
@@ -461,6 +605,9 @@ function diagnoseCodex() {
461
605
  const wrapperInstalled = configuredWrapper ? managedCodexWrapper(configuredWrapper) : false;
462
606
  const activeIsWrapper = activeCodex ? managedCodexWrapper(activeCodex) : false;
463
607
  const originalExists = configuredOriginal ? fs.existsSync(configuredOriginal) : false;
608
+ const runnerPath = path.join(scriptDir(), "ai-battery-run");
609
+ const runnerExists = isExecutable(runnerPath);
610
+ const python3 = findCommand("python3");
464
611
  const providerEnabled = providerVisible("codex");
465
612
  const notes = [];
466
613
 
@@ -473,6 +620,12 @@ function diagnoseCodex() {
473
620
  if (wrapperInstalled && !activeIsWrapper) {
474
621
  notes.push("Plain \"codex\" does not resolve to the AI Battery wrapper in this shell. Open a new terminal or put ~/.local/bin before the original codex on PATH.");
475
622
  }
623
+ if (!runnerExists) {
624
+ notes.push(`AI Battery runner is missing or not executable: ${runnerPath}`);
625
+ }
626
+ if (wrapperInstalled && !python3) {
627
+ notes.push("Codex wrapper needs python3 for the POSIX PTY row. Install Python 3, then run plain \"codex\" again.");
628
+ }
476
629
  if (configuredOriginal && !originalExists) {
477
630
  notes.push(`Original codex path saved by setup no longer exists: ${configuredOriginal}`);
478
631
  }
@@ -485,6 +638,9 @@ function diagnoseCodex() {
485
638
  activeIsWrapper,
486
639
  wrapperPath: configuredWrapper,
487
640
  wrapperInstalled,
641
+ runnerPath,
642
+ runnerExists,
643
+ python3,
488
644
  originalCommand: configuredOriginal,
489
645
  originalExists: configuredOriginal ? originalExists : null,
490
646
  insideCodex: runningInsideCodex(),
@@ -493,11 +649,21 @@ function diagnoseCodex() {
493
649
  };
494
650
  }
495
651
 
496
- function runDoctor() {
652
+ async function runDoctor() {
653
+ const version = await checkPackageVersion();
497
654
  return {
498
655
  generatedAt: new Date().toISOString(),
499
656
  aiBattery: {
500
657
  script: fileURLToPath(import.meta.url),
658
+ packageName: version.name,
659
+ version: version.current,
660
+ latestVersion: version.latest,
661
+ updateAvailable: version.updateAvailable,
662
+ updateCheck: {
663
+ checked: version.checked,
664
+ checkedAt: version.checkedAt,
665
+ error: version.error
666
+ },
501
667
  stateDir: stateDir(),
502
668
  configPath: configPath()
503
669
  },
@@ -662,18 +828,76 @@ function prioritizeCodexSessionFiles(files) {
662
828
  });
663
829
  }
664
830
 
831
+ function firstFiniteEntry(source, keys) {
832
+ for (const key of [keys].flat().filter(Boolean)) {
833
+ const value = source?.[key];
834
+ if (Number.isFinite(value)) return { key, value };
835
+ if (typeof value === "string" && value.trim()) {
836
+ const numeric = Number(value);
837
+ if (Number.isFinite(numeric)) return { key, value: numeric };
838
+ }
839
+ }
840
+ return null;
841
+ }
842
+
843
+ function firstFiniteValue(source, keys) {
844
+ return firstFiniteEntry(source, keys)?.value ?? null;
845
+ }
846
+
847
+ function keyUsesFractionalPercent(key) {
848
+ return /(?:ratio|fraction|utilization)$/i.test(String(key));
849
+ }
850
+
851
+ function percentValue(value, options = {}) {
852
+ if (!Number.isFinite(value)) return null;
853
+ return options.scaleFraction && value >= 0 && value <= 1 ? value * 100 : value;
854
+ }
855
+
856
+ function firstPercentValue(source, keys) {
857
+ const entry = firstFiniteEntry(source, keys);
858
+ if (!entry) return null;
859
+ return percentValue(entry.value, {
860
+ scaleFraction: keyUsesFractionalPercent(entry.key)
861
+ });
862
+ }
863
+
864
+ function usageInputTokens(usage) {
865
+ if (!usage) return null;
866
+ const inputTokens = firstFiniteValue(usage, ["input_tokens", "inputTokens"]);
867
+ const cacheCreationTokens = firstFiniteValue(usage, ["cache_creation_input_tokens", "cacheCreationInputTokens"]);
868
+ const cacheReadTokens = firstFiniteValue(usage, ["cache_read_input_tokens", "cacheReadInputTokens"]);
869
+ if (
870
+ !Number.isFinite(inputTokens)
871
+ && !Number.isFinite(cacheCreationTokens)
872
+ && !Number.isFinite(cacheReadTokens)
873
+ ) {
874
+ return null;
875
+ }
876
+ return (Number(inputTokens) || 0) + (Number(cacheCreationTokens) || 0) + (Number(cacheReadTokens) || 0);
877
+ }
878
+
879
+ function resetEpochSeconds(value) {
880
+ if (Number.isFinite(value)) return value > 1_000_000_000_000 ? Math.floor(value / 1000) : value;
881
+ if (typeof value === "string" && value.trim()) {
882
+ const numeric = Number(value);
883
+ if (Number.isFinite(numeric)) return resetEpochSeconds(numeric);
884
+ const millis = Date.parse(value);
885
+ if (!Number.isNaN(millis)) return Math.floor(millis / 1000);
886
+ }
887
+ return null;
888
+ }
889
+
665
890
  function normalizeLimit(limit, options = {}) {
666
891
  if (!limit) return null;
667
892
 
668
- const usedKey = options.usedKey || "used_percent";
893
+ const usedKeys = options.usedKey || "used_percent";
669
894
  const remainingKeys = [options.remainingKey].flat().filter(Boolean);
670
- const windowMinutes = options.windowMinutes ?? limit?.window_minutes ?? null;
671
- const usedValue = limit?.[usedKey];
672
- const remainingValue = remainingKeys
673
- .map((key) => limit?.[key])
674
- .find((value) => Number.isFinite(value));
895
+ const windowMinutes = options.windowMinutes ?? limit?.window_minutes ?? limit?.windowMinutes ?? null;
896
+ const usedValue = firstPercentValue(limit, usedKeys);
897
+ const remainingValue = firstPercentValue(limit, remainingKeys);
675
898
  const nowSeconds = Math.floor(Date.now() / 1000);
676
- const resetPassed = Number.isFinite(limit.resets_at) && limit.resets_at <= nowSeconds;
899
+ const resetsAtSeconds = resetEpochSeconds(limit.resets_at ?? limit.resetsAt ?? limit.reset_at ?? limit.resetAt);
900
+ const resetPassed = Number.isFinite(resetsAtSeconds) && resetsAtSeconds <= nowSeconds;
677
901
  const inferResetPassed = options.inferResetPassed !== false;
678
902
 
679
903
  if (!Number.isFinite(usedValue) && !Number.isFinite(remainingValue) && !(resetPassed && inferResetPassed)) return null;
@@ -693,8 +917,8 @@ function normalizeLimit(limit, options = {}) {
693
917
  usedPercent,
694
918
  remainingPercent,
695
919
  windowMinutes,
696
- resetsAt: limit.resets_at ? new Date(limit.resets_at * 1000).toISOString() : null,
697
- resetsInSeconds: limit.resets_at ? Math.max(0, limit.resets_at - nowSeconds) : null,
920
+ resetsAt: resetsAtSeconds ? new Date(resetsAtSeconds * 1000).toISOString() : null,
921
+ resetsInSeconds: resetsAtSeconds ? Math.max(0, resetsAtSeconds - nowSeconds) : null,
698
922
  resetPassed
699
923
  };
700
924
  }
@@ -721,7 +945,7 @@ function readJson(filePath) {
721
945
  }
722
946
  }
723
947
 
724
- const SCAN_CACHE_VERSION = 1;
948
+ const SCAN_CACHE_VERSION = 2;
725
949
 
726
950
  function scanCacheSeconds(defaultSeconds) {
727
951
  const raw = Number(process.env.AI_BATTERY_SCAN_CACHE_SECONDS);
@@ -932,34 +1156,119 @@ function readStdin() {
932
1156
 
933
1157
  function claudeLimitFromStatusline(limit, windowMinutes) {
934
1158
  if (!limit) return null;
935
- const hasUsedPercentage = Number.isFinite(limit.used_percentage);
936
- const remainingPercentage = [
937
- limit.remaining_percentage,
938
- limit.remaining_percent,
939
- limit.percent_remaining
940
- ].find((value) => Number.isFinite(value));
1159
+ const usedPercentage = firstPercentValue(limit, [
1160
+ "used_percentage",
1161
+ "usedPercent",
1162
+ "percent_used",
1163
+ "percentUsed",
1164
+ "utilization"
1165
+ ]);
1166
+ const remainingPercentage = firstPercentValue(limit, [
1167
+ "remaining_percentage",
1168
+ "remainingPercent",
1169
+ "remaining_percent",
1170
+ "percent_remaining",
1171
+ "percentRemaining"
1172
+ ]);
1173
+ const resetsAt = resetEpochSeconds(limit.resets_at ?? limit.resetsAt ?? limit.reset_at ?? limit.resetAt);
1174
+ const hasUsedPercentage = Number.isFinite(usedPercentage);
941
1175
  const hasRemainingPercentage = Number.isFinite(remainingPercentage);
942
- const hasReset = Number.isFinite(limit.resets_at);
1176
+ const hasReset = Number.isFinite(resetsAt);
943
1177
  if (!hasUsedPercentage && !hasRemainingPercentage && !hasReset) return null;
944
1178
  return {
945
1179
  ...limit,
946
- used_percentage: hasUsedPercentage ? limit.used_percentage : null,
1180
+ used_percentage: hasUsedPercentage ? usedPercentage : null,
947
1181
  remaining_percentage: hasRemainingPercentage ? remainingPercentage : null,
948
- resets_at: hasReset ? limit.resets_at : null,
949
- window_minutes: limit.window_minutes ?? windowMinutes
1182
+ resets_at: hasReset ? resetsAt : null,
1183
+ window_minutes: limit.window_minutes ?? limit.windowMinutes ?? windowMinutes
950
1184
  };
951
1185
  }
952
1186
 
953
1187
  function normalizeClaudeCachedLimit(limit, options = {}) {
954
1188
  if (!limit) return null;
955
1189
  return normalizeLimit(limit, {
956
- usedKey: "used_percentage",
957
- remainingKey: ["remaining_percentage", "remaining_percent", "percent_remaining"],
958
- windowMinutes: limit.window_minutes ?? null,
1190
+ usedKey: ["used_percentage", "usedPercent", "percent_used", "percentUsed", "utilization"],
1191
+ remainingKey: ["remaining_percentage", "remainingPercent", "remaining_percent", "percent_remaining", "percentRemaining"],
1192
+ windowMinutes: limit.window_minutes ?? limit.windowMinutes ?? null,
959
1193
  ...options
960
1194
  });
961
1195
  }
962
1196
 
1197
+ function claudeRateLimitsFromStatusline(input) {
1198
+ const rateLimits = input.rate_limits ?? input.rateLimits ?? input.limits ?? {};
1199
+ const fiveHour = rateLimits.five_hour
1200
+ ?? rateLimits.fiveHour
1201
+ ?? rateLimits.five_hour_limit
1202
+ ?? rateLimits.fiveHourLimit
1203
+ ?? rateLimits.session
1204
+ ?? rateLimits.session_limit
1205
+ ?? rateLimits.sessionLimit
1206
+ ?? rateLimits.primary
1207
+ ?? null;
1208
+ const sevenDay = rateLimits.seven_day
1209
+ ?? rateLimits.sevenDay
1210
+ ?? rateLimits.seven_day_limit
1211
+ ?? rateLimits.sevenDayLimit
1212
+ ?? rateLimits.weekly
1213
+ ?? rateLimits.weekly_limit
1214
+ ?? rateLimits.weeklyLimit
1215
+ ?? rateLimits.weekly_scoped
1216
+ ?? rateLimits.weeklyScoped
1217
+ ?? rateLimits.secondary
1218
+ ?? null;
1219
+ return { fiveHour, sevenDay, raw: rateLimits };
1220
+ }
1221
+
1222
+ function normalizeClaudeContextWindow(context) {
1223
+ if (!context) return null;
1224
+
1225
+ const usedPercentage = firstPercentValue(context, ["used_percentage", "usedPercentage", "percent_used", "percentUsed"]);
1226
+ let remainingPercentage = firstPercentValue(context, [
1227
+ "remaining_percentage",
1228
+ "remainingPercentage",
1229
+ "percent_remaining",
1230
+ "percentRemaining"
1231
+ ]);
1232
+ const currentUsage = context.current_usage ?? context.currentUsage ?? null;
1233
+ const contextWindowSize = firstFiniteValue(context, ["context_window_size", "contextWindowSize", "size", "max_tokens", "maxTokens"]);
1234
+ const totalInputTokens = firstFiniteValue(context, ["total_input_tokens", "totalInputTokens"])
1235
+ ?? usageInputTokens(currentUsage)
1236
+ ?? firstFiniteValue(context, ["input_tokens", "inputTokens"]);
1237
+ const totalOutputTokens = firstFiniteValue(context, ["total_output_tokens", "totalOutputTokens", "output_tokens", "outputTokens"])
1238
+ ?? firstFiniteValue(currentUsage, ["output_tokens", "outputTokens"]);
1239
+ const totalTokens = firstFiniteValue(context, ["total_tokens", "totalTokens"])
1240
+ ?? ((Number.isFinite(totalInputTokens) || Number.isFinite(totalOutputTokens))
1241
+ ? (Number(totalInputTokens) || 0) + (Number(totalOutputTokens) || 0)
1242
+ : null);
1243
+
1244
+ if (!Number.isFinite(remainingPercentage) && Number.isFinite(usedPercentage)) {
1245
+ remainingPercentage = 100 - usedPercentage;
1246
+ }
1247
+ if (!Number.isFinite(remainingPercentage) && Number.isFinite(contextWindowSize) && Number.isFinite(totalTokens) && contextWindowSize > 0) {
1248
+ remainingPercentage = 100 - ((totalTokens / contextWindowSize) * 100);
1249
+ }
1250
+
1251
+ return {
1252
+ usedPercentage: Number.isFinite(usedPercentage)
1253
+ ? clamp(Math.round(usedPercentage), 0, 100)
1254
+ : (Number.isFinite(remainingPercentage) ? clamp(100 - Math.round(remainingPercentage), 0, 100) : null),
1255
+ remainingPercentage: Number.isFinite(remainingPercentage) ? clamp(Math.round(remainingPercentage), 0, 100) : null,
1256
+ contextWindowSize: contextWindowSize ?? null,
1257
+ totalInputTokens: totalInputTokens ?? null,
1258
+ totalOutputTokens: totalOutputTokens ?? null
1259
+ };
1260
+ }
1261
+
1262
+ function claudeContextWindowFromStatusline(input) {
1263
+ const context = input.context_window
1264
+ ?? input.contextWindow
1265
+ ?? input.context
1266
+ ?? input.context_window_info
1267
+ ?? (input.context_window_size || input.contextWindowSize ? input : null)
1268
+ ?? null;
1269
+ return normalizeClaudeContextWindow(context);
1270
+ }
1271
+
963
1272
  function claudeTranscriptSessionKind(transcriptPath) {
964
1273
  if (!transcriptPath) return null;
965
1274
  const text = readTail(transcriptPath, 256 * 1024);
@@ -1107,48 +1416,44 @@ function applyClaudeRateLimitHit(limit, hit, window) {
1107
1416
  }
1108
1417
 
1109
1418
  function captureClaudeStatusline(input) {
1110
- const rateLimits = input.rate_limits ?? {};
1111
- const sessionId = input.session_id ?? null;
1419
+ const rateLimits = claudeRateLimitsFromStatusline(input);
1420
+ const sessionId = input.session_id ?? input.sessionId ?? null;
1112
1421
  const previous = (sessionId ? readJson(claudeSessionCachePath(sessionId)) : null) ?? readJson(claudeCachePath());
1113
1422
  const sessionKind = claudeStatuslineSessionKind(input);
1423
+ const contextWindow = claudeContextWindowFromStatusline(input);
1114
1424
  const snapshot = {
1115
1425
  version: 1,
1116
1426
  provider: "claude",
1117
1427
  sourceType: "statusline",
1118
1428
  capturedAt: new Date().toISOString(),
1119
1429
  sessionId,
1120
- promptId: input.prompt_id ?? null,
1121
- transcriptPath: input.transcript_path ?? null,
1430
+ promptId: input.prompt_id ?? input.promptId ?? null,
1431
+ transcriptPath: input.transcript_path ?? input.transcriptPath ?? null,
1122
1432
  sessionKind,
1123
1433
  claudeVersion: input.version ?? null,
1124
1434
  model: {
1125
- id: input.model?.id ?? null,
1126
- displayName: input.model?.display_name ?? null
1435
+ id: typeof input.model === "string" ? input.model : input.model?.id ?? null,
1436
+ displayName: typeof input.model === "string" ? input.model : input.model?.display_name ?? input.model?.displayName ?? null
1127
1437
  },
1128
1438
  rateLimits: {
1129
- fiveHour: claudeLimitFromStatusline(rateLimits.five_hour, 300),
1130
- sevenDay: claudeLimitFromStatusline(rateLimits.seven_day, 10080)
1439
+ fiveHour: claudeLimitFromStatusline(rateLimits.fiveHour, 300),
1440
+ sevenDay: claudeLimitFromStatusline(rateLimits.sevenDay, 10080)
1131
1441
  },
1132
- rawRateLimits: rateLimits,
1133
- contextWindow: input.context_window
1134
- ? {
1135
- usedPercentage: input.context_window.used_percentage ?? null,
1136
- remainingPercentage: input.context_window.remaining_percentage ?? null,
1137
- contextWindowSize: input.context_window.context_window_size ?? null,
1138
- totalInputTokens: input.context_window.total_input_tokens ?? null,
1139
- totalOutputTokens: input.context_window.total_output_tokens ?? null
1140
- }
1141
- : null
1442
+ rawRateLimits: rateLimits.raw,
1443
+ contextWindow
1142
1444
  };
1143
1445
 
1144
1446
  if (previous?.provider === "claude" && previous?.sourceType === "statusline") {
1145
- const sameSession = previous.sessionId && previous.sessionId === snapshot.sessionId;
1146
- if (sameSession && !snapshot.rateLimits.fiveHour) {
1447
+ const sameSession = previous.sessionId && snapshot.sessionId && previous.sessionId === snapshot.sessionId;
1448
+ if (!snapshot.rateLimits.fiveHour) {
1147
1449
  snapshot.rateLimits.fiveHour = previous.rateLimits?.fiveHour ?? null;
1148
1450
  }
1149
- if (sameSession && !snapshot.rateLimits.sevenDay) {
1451
+ if (!snapshot.rateLimits.sevenDay) {
1150
1452
  snapshot.rateLimits.sevenDay = previous.rateLimits?.sevenDay ?? null;
1151
1453
  }
1454
+ if (sameSession && !snapshot.contextWindow) {
1455
+ snapshot.contextWindow = previous.contextWindow ?? null;
1456
+ }
1152
1457
  }
1153
1458
 
1154
1459
  if (snapshot.sessionId) {
@@ -1674,18 +1979,18 @@ function statusLineUsableColumns(input) {
1674
1979
  return Math.max(20, statusLineColumns(input) - guard);
1675
1980
  }
1676
1981
 
1677
- function contextRemainingPercent(input) {
1678
- const context = input.context_window ?? input.contextWindow ?? null;
1679
- const remaining = context?.remaining_percentage ?? context?.remainingPercentage;
1982
+ function contextRemainingPercent(input, fallbackContext = null) {
1983
+ const context = claudeContextWindowFromStatusline(input) ?? fallbackContext;
1984
+ const remaining = context?.remainingPercentage;
1680
1985
  if (typeof remaining === "number") return clamp(Math.round(remaining), 0, 100);
1681
1986
 
1682
- const used = context?.used_percentage ?? context?.usedPercentage;
1987
+ const used = context?.usedPercentage;
1683
1988
  if (typeof used === "number") return clamp(100 - Math.round(used), 0, 100);
1684
1989
  return null;
1685
1990
  }
1686
1991
 
1687
- function contextLeftText(input) {
1688
- const remaining = contextRemainingPercent(input);
1992
+ function contextLeftText(input, fallbackContext = null) {
1993
+ const remaining = contextRemainingPercent(input, fallbackContext);
1689
1994
  if (remaining === null) return null;
1690
1995
  return `${remaining}% context left`;
1691
1996
  }
@@ -1730,13 +2035,20 @@ function statuslineGitBranch(input) {
1730
2035
  || input.git_branch
1731
2036
  || input.workspace?.git_branch
1732
2037
  || input.workspace?.git?.branch
2038
+ || input.workspace?.branch
2039
+ || input.worktree?.branch
2040
+ || input.worktree?.original_branch
1733
2041
  || null;
1734
2042
  if (explicit) return explicit;
1735
2043
 
1736
2044
  const dirs = [
1737
2045
  input.workspace?.current_dir,
2046
+ input.workspace?.currentDir,
1738
2047
  input.cwd,
1739
- input.workspace?.project_dir
2048
+ input.current_dir,
2049
+ input.currentDir,
2050
+ input.workspace?.project_dir,
2051
+ input.workspace?.projectDir
1740
2052
  ].filter(Boolean);
1741
2053
 
1742
2054
  for (const dir of dirs) {
@@ -1746,10 +2058,19 @@ function statuslineGitBranch(input) {
1746
2058
  return null;
1747
2059
  }
1748
2060
 
1749
- function claudeHeader(input, args) {
1750
- const model = input.model?.display_name || input.model?.id || "Claude";
1751
- const effort = input.effort?.level || null;
1752
- const workspaceRoot = input.workspace?.project_dir || input.cwd || input.workspace?.current_dir || "";
2061
+ function claudeHeader(input, args, capturedClaude = null) {
2062
+ const model = typeof input.model === "string"
2063
+ ? input.model
2064
+ : input.model?.display_name || input.model?.displayName || input.model?.id || "Claude";
2065
+ const effort = input.effort?.level || input.effortLevel || null;
2066
+ const workspaceRoot = input.workspace?.project_dir
2067
+ || input.workspace?.projectDir
2068
+ || input.cwd
2069
+ || input.current_dir
2070
+ || input.currentDir
2071
+ || input.workspace?.current_dir
2072
+ || input.workspace?.currentDir
2073
+ || "";
1753
2074
  const gitBranch = statuslineGitBranch(input);
1754
2075
  const parts = [model];
1755
2076
  if (effort) parts.push(effort);
@@ -1760,7 +2081,7 @@ function claudeHeader(input, args) {
1760
2081
  parts.push(gitBranch);
1761
2082
  }
1762
2083
  const left = parts.join(" ");
1763
- const right = contextLeftText(input);
2084
+ const right = contextLeftText(input, capturedClaude?.contextWindow ?? null);
1764
2085
  const columns = Math.max(20, statusLineUsableColumns(input) - (args.leftPadding || 0));
1765
2086
  const header = alignHeader(left, right, columns);
1766
2087
  return statusColorize({ running: false }, header, args);
@@ -1891,6 +2212,17 @@ function renderLine(snapshot, args) {
1891
2212
  .join(providerDivider);
1892
2213
  }
1893
2214
 
2215
+ function renderMenuBar(snapshot) {
2216
+ const parts = snapshot.results
2217
+ .map((result) => {
2218
+ const label = result.provider === "codex" ? "Cx" : "Cl";
2219
+ if (!result.ok || typeof result.percentRemaining !== "number") return `${label} --`;
2220
+ return `${label} ${result.percentRemaining}%`;
2221
+ })
2222
+ .filter(Boolean);
2223
+ return parts.length ? parts.join(" | ") : "AI --";
2224
+ }
2225
+
1894
2226
  function applyLeftPadding(output, args) {
1895
2227
  const padding = Math.max(0, Number(args.leftPadding) || 0);
1896
2228
  if (!padding) return output;
@@ -1903,6 +2235,7 @@ function applyLeftPadding(output, args) {
1903
2235
 
1904
2236
  function render(snapshot, args) {
1905
2237
  if (args.json) return JSON.stringify(snapshot, null, 2);
2238
+ if (args.menuBar) return renderMenuBar(snapshot);
1906
2239
 
1907
2240
  const maxWidth = args.maxWidth
1908
2241
  ? Math.max(20, args.maxWidth - (Number(args.leftPadding) || 0))
@@ -1921,6 +2254,10 @@ function render(snapshot, args) {
1921
2254
 
1922
2255
  async function main() {
1923
2256
  const args = parseArgs(process.argv.slice(2));
2257
+ if (args.version) {
2258
+ console.log(packageInfo().version);
2259
+ return;
2260
+ }
1924
2261
  if (args.help) {
1925
2262
  printHelp();
1926
2263
  return;
@@ -1938,6 +2275,8 @@ async function main() {
1938
2275
  console.log(`Codex wrapper installed: ${result.codex.wrapperPath}`);
1939
2276
  console.log(`Original codex: ${result.codex.originalCommand}`);
1940
2277
  if (result.codex.path?.note) console.log(result.codex.path.note);
2278
+ const reloadCommand = sourcePathCommand(result.codex.path?.rcPath);
2279
+ if (reloadCommand) console.log(`For this terminal now, run: ${reloadCommand}`);
1941
2280
  } else if (result.codex?.skipped) {
1942
2281
  console.log(`Codex wrapper skipped: ${result.codex.reason}`);
1943
2282
  }
@@ -1948,16 +2287,33 @@ async function main() {
1948
2287
  }
1949
2288
 
1950
2289
  if (args.command === "doctor") {
1951
- const result = runDoctor();
2290
+ const result = await runDoctor();
1952
2291
  if (args.json) {
1953
2292
  console.log(JSON.stringify(result, null, 2));
1954
2293
  } else {
1955
2294
  console.log(`AI Battery: ${result.aiBattery.script}`);
2295
+ console.log(`Version: ${result.aiBattery.version}`);
2296
+ if (result.aiBattery.latestVersion) {
2297
+ console.log(`npm latest: ${result.aiBattery.latestVersion}`);
2298
+ if (result.aiBattery.updateAvailable) {
2299
+ console.log(`Update: available (npm install -g ${result.aiBattery.packageName}@latest)`);
2300
+ } else if (compareVersions(result.aiBattery.version, result.aiBattery.latestVersion) > 0) {
2301
+ console.log("Update: local version is newer than npm latest");
2302
+ } else {
2303
+ console.log("Update: up to date");
2304
+ }
2305
+ } else if (result.aiBattery.updateCheck.error === "disabled") {
2306
+ console.log("npm latest: skipped");
2307
+ } else {
2308
+ console.log(`npm latest: unavailable (${result.aiBattery.updateCheck.error || "unknown error"})`);
2309
+ }
1956
2310
  console.log(`State: ${result.aiBattery.stateDir}`);
1957
2311
  console.log("");
1958
2312
  console.log(`Codex provider: ${result.codex.providerEnabled ? "on" : "off"}`);
1959
2313
  console.log(`Codex on PATH: ${result.codex.activeCodex || "not found"}`);
1960
2314
  console.log(`Codex wrapper: ${result.codex.wrapperInstalled ? "installed" : "missing"} (${result.codex.wrapperPath})`);
2315
+ console.log(`AI Battery runner: ${result.codex.runnerExists ? "found" : "missing"} (${result.codex.runnerPath})`);
2316
+ console.log(`python3: ${result.codex.python3 || "not found"}`);
1961
2317
  console.log(`PATH uses wrapper: ${result.codex.activeIsWrapper ? "yes" : "no"}`);
1962
2318
  console.log(`Inside Codex: ${result.codex.insideCodex ? "yes" : "no"}`);
1963
2319
  console.log(`Current Codex wrapped: ${result.codex.currentCodexWrapped ? "yes" : "no"}`);
@@ -2032,7 +2388,7 @@ async function main() {
2032
2388
  const claudeData = claudeStatuslineResultFromCache(capturedClaude, capturedSource, { includeBackground: true }) ?? readClaude();
2033
2389
  results.push({ ...claudeData, running: true });
2034
2390
  }
2035
- const header = args.header ? applyLeftPadding(claudeHeader(input, args), args) : "";
2391
+ const header = args.header ? applyLeftPadding(claudeHeader(input, args, capturedClaude), args) : "";
2036
2392
  const usage = render({
2037
2393
  generatedAt: new Date().toISOString(),
2038
2394
  results
@@ -2076,7 +2432,15 @@ async function main() {
2076
2432
  }
2077
2433
  }
2078
2434
 
2079
- main().catch((error) => {
2080
- console.error(`ai-battery: ${error.message}`);
2081
- process.exit(1);
2082
- });
2435
+ export {
2436
+ firstPercentValue,
2437
+ normalizeLimit,
2438
+ percentValue
2439
+ };
2440
+
2441
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
2442
+ main().catch((error) => {
2443
+ console.error(`ai-battery: ${error.message}`);
2444
+ process.exit(1);
2445
+ });
2446
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-battery",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Tiny terminal battery meter for local Codex and Claude Code usage.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -25,13 +25,15 @@
25
25
  "bin/ai-battery-run",
26
26
  "bin/ai-battery-hud",
27
27
  "bin/ai-battery-hud.js",
28
+ "bin/ai-battery-macos-status.applescript",
28
29
  "bin/ai-battery-hud.ps1",
29
30
  "docs/*.svg",
30
31
  "README.md"
31
32
  ],
32
33
  "scripts": {
33
34
  "start": "node ./bin/ai-battery.js",
34
- "check": "node --check ./bin/ai-battery.js"
35
+ "check": "node --check ./bin/ai-battery.js && node --check ./bin/ai-battery-hud.js && node --test",
36
+ "test": "node --test"
35
37
  },
36
38
  "engines": {
37
39
  "node": ">=18"