ai-battery 0.1.1 → 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
@@ -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
 
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;
@@ -46,6 +47,7 @@ function parseArgs(argv) {
46
47
  force: false,
47
48
  header: true,
48
49
  help: false,
50
+ version: false,
49
51
  targets: [],
50
52
  rest: []
51
53
  };
@@ -99,6 +101,8 @@ function parseArgs(argv) {
99
101
  } else if (arg === "--menu-bar") {
100
102
  args.menuBar = true;
101
103
  args.style = "plain";
104
+ } else if (arg === "--version" || arg === "-v") {
105
+ args.version = true;
102
106
  } else if (arg === "--help" || arg === "-h") {
103
107
  args.help = true;
104
108
  } else {
@@ -147,6 +151,7 @@ Options:
147
151
  --tmux Emit tmux status-line color markup
148
152
  --force Replace an existing Claude statusLine
149
153
  --no-color Disable ANSI colors
154
+ -v, --version Show ai-battery version
150
155
  -h, --help Show this help
151
156
 
152
157
  Compatibility:
@@ -279,6 +284,132 @@ function scriptDir() {
279
284
  return path.dirname(fileURLToPath(import.meta.url));
280
285
  }
281
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
+
282
413
  function shQuote(value) {
283
414
  return `'${String(value).replace(/'/g, "'\\''")}'`;
284
415
  }
@@ -518,11 +649,21 @@ function diagnoseCodex() {
518
649
  };
519
650
  }
520
651
 
521
- function runDoctor() {
652
+ async function runDoctor() {
653
+ const version = await checkPackageVersion();
522
654
  return {
523
655
  generatedAt: new Date().toISOString(),
524
656
  aiBattery: {
525
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
+ },
526
667
  stateDir: stateDir(),
527
668
  configPath: configPath()
528
669
  },
@@ -687,21 +828,37 @@ function prioritizeCodexSessionFiles(files) {
687
828
  });
688
829
  }
689
830
 
690
- function firstFiniteValue(source, keys) {
831
+ function firstFiniteEntry(source, keys) {
691
832
  for (const key of [keys].flat().filter(Boolean)) {
692
833
  const value = source?.[key];
693
- if (Number.isFinite(value)) return value;
834
+ if (Number.isFinite(value)) return { key, value };
694
835
  if (typeof value === "string" && value.trim()) {
695
836
  const numeric = Number(value);
696
- if (Number.isFinite(numeric)) return numeric;
837
+ if (Number.isFinite(numeric)) return { key, value: numeric };
697
838
  }
698
839
  }
699
840
  return null;
700
841
  }
701
842
 
702
- function percentValue(value) {
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 = {}) {
703
852
  if (!Number.isFinite(value)) return null;
704
- return value >= 0 && value <= 1 ? value * 100 : value;
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
+ });
705
862
  }
706
863
 
707
864
  function usageInputTokens(usage) {
@@ -736,8 +893,8 @@ function normalizeLimit(limit, options = {}) {
736
893
  const usedKeys = options.usedKey || "used_percent";
737
894
  const remainingKeys = [options.remainingKey].flat().filter(Boolean);
738
895
  const windowMinutes = options.windowMinutes ?? limit?.window_minutes ?? limit?.windowMinutes ?? null;
739
- const usedValue = percentValue(firstFiniteValue(limit, usedKeys));
740
- const remainingValue = percentValue(firstFiniteValue(limit, remainingKeys));
896
+ const usedValue = firstPercentValue(limit, usedKeys);
897
+ const remainingValue = firstPercentValue(limit, remainingKeys);
741
898
  const nowSeconds = Math.floor(Date.now() / 1000);
742
899
  const resetsAtSeconds = resetEpochSeconds(limit.resets_at ?? limit.resetsAt ?? limit.reset_at ?? limit.resetAt);
743
900
  const resetPassed = Number.isFinite(resetsAtSeconds) && resetsAtSeconds <= nowSeconds;
@@ -788,7 +945,7 @@ function readJson(filePath) {
788
945
  }
789
946
  }
790
947
 
791
- const SCAN_CACHE_VERSION = 1;
948
+ const SCAN_CACHE_VERSION = 2;
792
949
 
793
950
  function scanCacheSeconds(defaultSeconds) {
794
951
  const raw = Number(process.env.AI_BATTERY_SCAN_CACHE_SECONDS);
@@ -999,20 +1156,20 @@ function readStdin() {
999
1156
 
1000
1157
  function claudeLimitFromStatusline(limit, windowMinutes) {
1001
1158
  if (!limit) return null;
1002
- const usedPercentage = percentValue(firstFiniteValue(limit, [
1159
+ const usedPercentage = firstPercentValue(limit, [
1003
1160
  "used_percentage",
1004
1161
  "usedPercent",
1005
1162
  "percent_used",
1006
1163
  "percentUsed",
1007
1164
  "utilization"
1008
- ]));
1009
- const remainingPercentage = percentValue(firstFiniteValue(limit, [
1165
+ ]);
1166
+ const remainingPercentage = firstPercentValue(limit, [
1010
1167
  "remaining_percentage",
1011
1168
  "remainingPercent",
1012
1169
  "remaining_percent",
1013
1170
  "percent_remaining",
1014
1171
  "percentRemaining"
1015
- ]));
1172
+ ]);
1016
1173
  const resetsAt = resetEpochSeconds(limit.resets_at ?? limit.resetsAt ?? limit.reset_at ?? limit.resetAt);
1017
1174
  const hasUsedPercentage = Number.isFinite(usedPercentage);
1018
1175
  const hasRemainingPercentage = Number.isFinite(remainingPercentage);
@@ -1065,13 +1222,13 @@ function claudeRateLimitsFromStatusline(input) {
1065
1222
  function normalizeClaudeContextWindow(context) {
1066
1223
  if (!context) return null;
1067
1224
 
1068
- const usedPercentage = percentValue(firstFiniteValue(context, ["used_percentage", "usedPercentage", "percent_used", "percentUsed"]));
1069
- let remainingPercentage = percentValue(firstFiniteValue(context, [
1225
+ const usedPercentage = firstPercentValue(context, ["used_percentage", "usedPercentage", "percent_used", "percentUsed"]);
1226
+ let remainingPercentage = firstPercentValue(context, [
1070
1227
  "remaining_percentage",
1071
1228
  "remainingPercentage",
1072
1229
  "percent_remaining",
1073
1230
  "percentRemaining"
1074
- ]));
1231
+ ]);
1075
1232
  const currentUsage = context.current_usage ?? context.currentUsage ?? null;
1076
1233
  const contextWindowSize = firstFiniteValue(context, ["context_window_size", "contextWindowSize", "size", "max_tokens", "maxTokens"]);
1077
1234
  const totalInputTokens = firstFiniteValue(context, ["total_input_tokens", "totalInputTokens"])
@@ -2097,6 +2254,10 @@ function render(snapshot, args) {
2097
2254
 
2098
2255
  async function main() {
2099
2256
  const args = parseArgs(process.argv.slice(2));
2257
+ if (args.version) {
2258
+ console.log(packageInfo().version);
2259
+ return;
2260
+ }
2100
2261
  if (args.help) {
2101
2262
  printHelp();
2102
2263
  return;
@@ -2126,11 +2287,26 @@ async function main() {
2126
2287
  }
2127
2288
 
2128
2289
  if (args.command === "doctor") {
2129
- const result = runDoctor();
2290
+ const result = await runDoctor();
2130
2291
  if (args.json) {
2131
2292
  console.log(JSON.stringify(result, null, 2));
2132
2293
  } else {
2133
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
+ }
2134
2310
  console.log(`State: ${result.aiBattery.stateDir}`);
2135
2311
  console.log("");
2136
2312
  console.log(`Codex provider: ${result.codex.providerEnabled ? "on" : "off"}`);
@@ -2256,7 +2432,15 @@ async function main() {
2256
2432
  }
2257
2433
  }
2258
2434
 
2259
- main().catch((error) => {
2260
- console.error(`ai-battery: ${error.message}`);
2261
- process.exit(1);
2262
- });
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.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": [
@@ -32,7 +32,8 @@
32
32
  ],
33
33
  "scripts": {
34
34
  "start": "node ./bin/ai-battery.js",
35
- "check": "node --check ./bin/ai-battery.js && node --check ./bin/ai-battery-hud.js"
35
+ "check": "node --check ./bin/ai-battery.js && node --check ./bin/ai-battery-hud.js && node --test",
36
+ "test": "node --test"
36
37
  },
37
38
  "engines": {
38
39
  "node": ">=18"