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 +30 -19
- package/bin/ai-battery-hud.js +233 -2
- package/bin/ai-battery-macos-status.applescript +78 -0
- package/bin/ai-battery.js +428 -64
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Codex와 Claude Code의 남은 사용량을 배터리처럼 확인하는 터미
|
|
|
9
9
|

|
|
10
10
|

|
|
11
11
|
|
|
12
|
-
[Install](#install) · [Features](#features) · [Quick Start](#quick-start) · [Claude StatusLine](#claude-statusline) · [
|
|
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
|
-
|
|
|
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` | 지원 | 지원 | 미지원 |
|
|
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.
|
|
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
|
-
##
|
|
210
|
+
## Desktop HUD
|
|
200
211
|
|
|
201
|
-
일반 터미널 위에 외부 프로세스가 안전하게 status line을 덧그리는 방식은 안정적이지 않습니다. 그래서
|
|
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
|
|
218
|
-
| `ai-battery hud status` | HUD 실행 여부와 autostart 등록 상태를 보여줍니다. |
|
|
219
|
-
| `ai-battery hud autostart on` | Windows 로그인 시
|
|
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`에
|
|
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[
|
|
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
|
|
262
|
-
| HUD UI | PowerShell WinForms | Windows floating overlay
|
|
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
|
|
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
|
|
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가 잠시 흔들릴 수 있습니다.
|
package/bin/ai-battery-hud.js
CHANGED
|
@@ -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, "&")
|
|
220
|
+
.replace(/</g, "<")
|
|
221
|
+
.replace(/>/g, ">")
|
|
222
|
+
.replace(/"/g, """);
|
|
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:
|
|
120
|
-
console.error("On
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
697
|
-
resetsInSeconds:
|
|
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 =
|
|
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
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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(
|
|
1176
|
+
const hasReset = Number.isFinite(resetsAt);
|
|
943
1177
|
if (!hasUsedPercentage && !hasRemainingPercentage && !hasReset) return null;
|
|
944
1178
|
return {
|
|
945
1179
|
...limit,
|
|
946
|
-
used_percentage: hasUsedPercentage ?
|
|
1180
|
+
used_percentage: hasUsedPercentage ? usedPercentage : null,
|
|
947
1181
|
remaining_percentage: hasRemainingPercentage ? remainingPercentage : null,
|
|
948
|
-
resets_at: hasReset ?
|
|
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
|
|
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.
|
|
1130
|
-
sevenDay: claudeLimitFromStatusline(rateLimits.
|
|
1439
|
+
fiveHour: claudeLimitFromStatusline(rateLimits.fiveHour, 300),
|
|
1440
|
+
sevenDay: claudeLimitFromStatusline(rateLimits.sevenDay, 10080)
|
|
1131
1441
|
},
|
|
1132
|
-
rawRateLimits: rateLimits,
|
|
1133
|
-
contextWindow
|
|
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 (
|
|
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 (
|
|
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
|
|
1679
|
-
const remaining = context?.
|
|
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?.
|
|
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.
|
|
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 =
|
|
1751
|
-
|
|
1752
|
-
|
|
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
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
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.
|
|
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"
|