coding-usage-bar 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ Claude Code and Codex data stays local and is read from files those tools alread
17
17
 
18
18
  ## Live SwiftBar Menu
19
19
 
20
- The screenshot is captured from the live SwiftBar plugin. The animation reproduces opening and closing that menu without altering the captured usage data.
20
+ The menu bar title and the dropdown panel are captured from the live SwiftBar plugin and placed on a plain backdrop. The animation reproduces opening and closing that menu without altering the captured usage data.
21
21
 
22
22
  <p align="center">
23
23
  <img src="docs/assets/demo.gif" alt="Opening the live Coding Usage Bar SwiftBar menu with Codex, Claude, GLM, DeepSeek, MiniMax, and Kimi usage" width="800">
@@ -33,6 +33,16 @@ coding-usage-bar status
33
33
 
34
34
  `install` creates a local runtime at `~/.coding-usage-bar/app`, a user-level CLI shim at `~/.local/bin/coding-usage-bar`, a launchd agent, a default config file, and a SwiftBar menu bar plugin. Make sure `~/.local/bin` is in your `PATH`.
35
35
 
36
+ ## Troubleshooting
37
+
38
+ **Menu bar icon not showing after install?**
39
+
40
+ 1. Run `coding-usage-bar doctor --fix`. It relaunches SwiftBar, reinstalls the menu bar plugin if missing, re-adds SwiftBar to login items, and collects status data.
41
+ 2. macOS may fold menu bar icons away: check System Settings → Control Center → Menu Bar.
42
+ 3. If macOS showed an Automation permission prompt (System Events) and it was denied, SwiftBar cannot auto-start at login: allow it under System Settings → Privacy & Security → Automation, then run `coding-usage-bar install` again.
43
+
44
+ `coding-usage-bar install` verifies at the end that the plugin actually renders and prints `Menu bar ready`; if that line is missing, the numbered steps it prints tell you exactly what to do.
45
+
36
46
  ## Commands
37
47
 
38
48
  | Command | Purpose |
@@ -40,6 +50,7 @@ coding-usage-bar status
40
50
  | `coding-usage-bar install` | Install runtime, launchd checker, SwiftBar host/plugin, and Claude ingest when safe |
41
51
  | `coding-usage-bar uninstall` | Remove Coding Usage Bar managed launchd/status line/plugin config |
42
52
  | `coding-usage-bar doctor` | Check local Codex/Claude/GLM usage sources and notification backend |
53
+ | `coding-usage-bar doctor --fix` | Repair common issues: missing SwiftBar/plugin, SwiftBar not running, login item, status data |
43
54
  | `coding-usage-bar status` | Print the rolling windows reported by each account and their pacing state from `~/.coding-usage-bar/status.json` |
44
55
  | `coding-usage-bar status --json` | Print the same status snapshot written to `~/.coding-usage-bar/status.json` |
45
56
  | `coding-usage-bar status --refresh` | Re-collect local usage before printing status |
package/README.zh-CN.md CHANGED
@@ -12,7 +12,7 @@ Claude Code 与 Codex 的数据直接读取它们在本机产生的文件,不
12
12
 
13
13
  ## 真实 SwiftBar 菜单
14
14
 
15
- 以下截图来自正在运行的 SwiftBar 插件;动画复现菜单的展开与收起,不修改截图中的用量数据。
15
+ 菜单栏标题与下拉面板均截自正在运行的 SwiftBar 插件,仅叠加纯色桌面背景;动画复现菜单的展开与收起,不修改截图中的用量数据。
16
16
 
17
17
  <p align="center">
18
18
  <img src="docs/assets/demo.gif" alt="打开 Coding Usage Bar 的真实 SwiftBar 菜单,查看 Codex、Claude、GLM、DeepSeek、MiniMax 和 Kimi 用量" width="800">
@@ -33,6 +33,16 @@ coding-usage-bar status
33
33
  - 每 5 分钟采集一次用量的 launchd 任务
34
34
  - 写入 SwiftBar 当前插件目录的菜单栏插件
35
35
 
36
+ 安装结束时会自动验收菜单栏是否就绪,最后一行出现 `Menu bar ready` 即表示图标已出现在菜单栏右上角。
37
+
38
+ ## 常见问题
39
+
40
+ **安装后菜单栏没有图标?**
41
+
42
+ 1. 执行 `coding-usage-bar doctor --fix`:自动重启 SwiftBar、补装菜单栏插件、补加 SwiftBar 登录项、补采 status 数据。
43
+ 2. macOS 可能把菜单栏图标折叠隐藏了:系统设置 → 控制中心 → 菜单栏,检查隐藏区。
44
+ 3. 如果 macOS 弹过 System Events 的"自动化"权限框且被拒绝,SwiftBar 将无法开机自启:在 系统设置 → 隐私与安全性 → 自动化 中放行,再执行一次 `coding-usage-bar install`。
45
+
36
46
  ## 核心特点
37
47
 
38
48
  - 一个菜单栏同时查看多个 AI 编程 Provider
package/dist/burn.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import { BurnAnalysis, BurnProfile, ProviderUsage } from "./types.js";
2
2
  export declare function estimateConversionRate(samples: ProviderUsage[]): number | null;
3
+ export declare function analyzeUsageWithConversionRate(usage: ProviderUsage, conversionRate: number | null, profile?: BurnProfile, now?: Date): BurnAnalysis;
3
4
  export declare function analyzeUsage(usage: ProviderUsage, samples: ProviderUsage[], profile?: BurnProfile, now?: Date): BurnAnalysis;
package/dist/burn.js CHANGED
@@ -74,7 +74,7 @@ function messageForState(provider, state, fiveUsed, target) {
74
74
  }
75
75
  return `${label} burn pace is on track (${round(fiveUsed)}%, target ${round(target.min)}%-${round(target.max)}%).`;
76
76
  }
77
- export function analyzeUsage(usage, samples, profile = "low", now = new Date()) {
77
+ function analyzeUsageWithRateResolver(usage, resolveConversionRate, profile = "low", now = new Date()) {
78
78
  const fiveHour = findWindow(usage, "five_hour");
79
79
  const sevenDay = findWindow(usage, "seven_day");
80
80
  if (usage.balance && usage.windows.length === 0) {
@@ -118,7 +118,7 @@ export function analyzeUsage(usage, samples, profile = "low", now = new Date())
118
118
  message,
119
119
  };
120
120
  }
121
- const conversionRate = estimateConversionRate(samples);
121
+ const conversionRate = resolveConversionRate();
122
122
  if (conversionRate === null) {
123
123
  return {
124
124
  provider: usage.provider,
@@ -162,3 +162,9 @@ export function analyzeUsage(usage, samples, profile = "low", now = new Date())
162
162
  }),
163
163
  };
164
164
  }
165
+ export function analyzeUsageWithConversionRate(usage, conversionRate, profile = "low", now = new Date()) {
166
+ return analyzeUsageWithRateResolver(usage, () => conversionRate, profile, now);
167
+ }
168
+ export function analyzeUsage(usage, samples, profile = "low", now = new Date()) {
169
+ return analyzeUsageWithRateResolver(usage, () => estimateConversionRate(samples), profile, now);
170
+ }
package/dist/cli.js CHANGED
@@ -5,11 +5,12 @@ import { saveUsage } from "./store.js";
5
5
  import { formatStatusRows, formatAnalysisDetail, formatIssues, formatProviderMeta } from "./format.js";
6
6
  import { loadDisplayStatusSnapshot, collectStatusSnapshot } from "./runtime.js";
7
7
  import { runDaemonOnce } from "./daemon.js";
8
- import { doctorHasFailures, formatDoctor, runDoctor } from "./doctor.js";
8
+ import { doctorHasFailures, formatDoctor, runDoctor, runDoctorFix } from "./doctor.js";
9
9
  import { installMenuBar, renderMenuBar, uninstallMenuBar, toggleCompactMode } from "./menubar.js";
10
10
  import { buildPaths } from "./paths.js";
11
11
  import { bakeGlyphAtlases } from "./glyphs.js";
12
12
  import { maybePromptForStar } from "./star.js";
13
+ import { updateStatusSnapshotUsage } from "./status.js";
13
14
  function hasFlag(args, flag) {
14
15
  return args.includes(flag);
15
16
  }
@@ -19,7 +20,7 @@ function printHelp() {
19
20
  Usage:
20
21
  coding-usage-bar install [--dry-run]
21
22
  coding-usage-bar uninstall [--dry-run]
22
- coding-usage-bar doctor [--dry-run]
23
+ coding-usage-bar doctor [--dry-run] [--fix]
23
24
  coding-usage-bar status [--fixtures] [--json] [--refresh]
24
25
  coding-usage-bar menubar render
25
26
  coding-usage-bar menubar install [--dry-run]
@@ -56,9 +57,21 @@ async function main() {
56
57
  return;
57
58
  }
58
59
  if (command === "doctor") {
60
+ const fix = hasFlag(args, "--fix");
61
+ if (fix) {
62
+ const fixes = await runDoctorFix({ dryRun });
63
+ if (fixes.length > 0) {
64
+ console.log(fixes.join("\n"));
65
+ console.log("");
66
+ }
67
+ }
59
68
  const checks = runDoctor({ dryRun });
60
69
  console.log(formatDoctor(checks));
61
70
  if (doctorHasFailures(checks)) {
71
+ if (!fix) {
72
+ console.log("");
73
+ console.log("Run coding-usage-bar doctor --fix to repair common issues automatically.");
74
+ }
62
75
  process.exitCode = 1;
63
76
  }
64
77
  return;
@@ -119,6 +132,13 @@ async function main() {
119
132
  const parsed = JSON.parse(input);
120
133
  const usage = usageFromClaudeStatusLine(parsed);
121
134
  saveUsage(usage);
135
+ try {
136
+ updateStatusSnapshotUsage(usage);
137
+ }
138
+ catch {
139
+ // The provider cache is authoritative. A later daemon run can rebuild
140
+ // status.json if this best-effort display update loses lock contention.
141
+ }
122
142
  return;
123
143
  }
124
144
  if (command === "daemon") {
package/dist/doctor.d.ts CHANGED
@@ -2,5 +2,25 @@ import { DoctorCheck } from "./types.js";
2
2
  export declare function runDoctor(options?: {
3
3
  dryRun?: boolean;
4
4
  }): DoctorCheck[];
5
+ export interface DoctorFixProbes {
6
+ swiftBarInstalled?: () => boolean;
7
+ pluginPresent?: () => boolean;
8
+ swiftBarRunning?: () => boolean;
9
+ swiftBarInLoginItems?: () => boolean | null;
10
+ statusFilePresent?: () => boolean;
11
+ }
12
+ export interface DoctorFixEffects {
13
+ installSwiftBar?: () => string[];
14
+ installPlugin?: () => string[];
15
+ launchSwiftBar?: () => string[];
16
+ addLoginItem?: () => string[];
17
+ collectStatus?: () => Promise<string[]>;
18
+ }
19
+ export interface DoctorFixOptions {
20
+ dryRun?: boolean;
21
+ probes?: DoctorFixProbes;
22
+ effects?: DoctorFixEffects;
23
+ }
24
+ export declare function runDoctorFix(options?: DoctorFixOptions): Promise<string[]>;
5
25
  export declare function formatDoctor(checks: DoctorCheck[]): string;
6
26
  export declare function doctorHasFailures(checks: DoctorCheck[]): boolean;
package/dist/doctor.js CHANGED
@@ -7,7 +7,8 @@ import { isDir, isFile } from "./fs-util.js";
7
7
  import { resolveKimiConfig } from "./kimi.js";
8
8
  import { buildPaths, providerLatestPath } from "./paths.js";
9
9
  import { notificationBackend } from "./notifier.js";
10
- import { isSwiftBarInstalled, isSwiftBarRunning, swiftBarPluginPath } from "./menubar.js";
10
+ import { addSwiftBarToLoginItems, ensureSwiftBarInstalled, installMenuBar, isSwiftBarInstalled, isSwiftBarInLoginItems, isSwiftBarRunning, openSwiftBar, swiftBarPluginPath, } from "./menubar.js";
11
+ import { runDaemonOnce } from "./daemon.js";
11
12
  function stableClaudeIngestHint() {
12
13
  const paths = buildPaths();
13
14
  return `printf "%s" "$input" | node "${paths.stateDir}/app/dist/cli.js" ingest claude-statusline >/dev/null`;
@@ -240,17 +241,96 @@ export function runDoctor(options = {}) {
240
241
  const swiftBarRunning = pluginPresent && isSwiftBarRunning();
241
242
  checks.push({
242
243
  name: "Menu bar",
243
- ok: pluginPresent,
244
+ ok: pluginPresent && swiftBarRunning,
244
245
  message: swiftBarInstalled
245
246
  ? isFile(swiftBarPlugin.file)
246
247
  ? swiftBarRunning
247
248
  ? `SwiftBar plugin installed and SwiftBar running: ${swiftBarPlugin.file}`
248
- : `SwiftBar plugin installed but SwiftBar is not running (menu bar item hidden); run: open -a SwiftBar`
249
- : `SwiftBar found; run coding-usage-bar menubar install`
250
- : "SwiftBar not found; install SwiftBar, then run coding-usage-bar menubar install",
249
+ : "SwiftBar plugin installed but SwiftBar is not running (menu bar item hidden); run: coding-usage-bar doctor --fix"
250
+ : "SwiftBar found; run: coding-usage-bar doctor --fix"
251
+ : "SwiftBar not found; run: coding-usage-bar doctor --fix (installs SwiftBar via Homebrew)",
252
+ });
253
+ if (process.platform === "darwin") {
254
+ if (options.dryRun) {
255
+ checks.push({
256
+ name: "SwiftBar login item",
257
+ ok: true,
258
+ message: "[dry-run] would check whether SwiftBar auto-starts at login",
259
+ });
260
+ }
261
+ else if (!swiftBarInstalled) {
262
+ checks.push({
263
+ name: "SwiftBar login item",
264
+ ok: true,
265
+ message: "skipped (SwiftBar not installed)",
266
+ });
267
+ }
268
+ else {
269
+ const inLoginItems = isSwiftBarInLoginItems();
270
+ checks.push({
271
+ name: "SwiftBar login item",
272
+ ok: inLoginItems !== false,
273
+ message: inLoginItems === null
274
+ ? "could not verify (Automation permission denied?); if the icon disappears after reboot, check System Settings → General → Login Items"
275
+ : inLoginItems
276
+ ? "SwiftBar auto-starts at login"
277
+ : "not in login items; the menu bar icon disappears after reboot. Run: coding-usage-bar doctor --fix",
278
+ });
279
+ }
280
+ }
281
+ checks.push({
282
+ name: "Status data",
283
+ ok: options.dryRun ? true : isFile(paths.statusFile),
284
+ message: options.dryRun
285
+ ? `[dry-run] would read ${paths.statusFile}`
286
+ : isFile(paths.statusFile)
287
+ ? `found ${paths.statusFile}`
288
+ : `missing ${paths.statusFile}; run coding-usage-bar doctor --fix or coding-usage-bar daemon --once`,
251
289
  });
252
290
  return checks;
253
291
  }
292
+ // One-command self-healing for the common "installed but the menu bar icon is
293
+ // gone" failures: missing SwiftBar, missing plugin, SwiftBar not running,
294
+ // SwiftBar missing from login items, and missing status data.
295
+ export async function runDoctorFix(options = {}) {
296
+ if (options.dryRun) {
297
+ return ["[dry-run] would repair SwiftBar, the menu bar plugin, login items, and status data automatically"];
298
+ }
299
+ const paths = buildPaths();
300
+ const probes = {
301
+ swiftBarInstalled: options.probes?.swiftBarInstalled ?? isSwiftBarInstalled,
302
+ pluginPresent: options.probes?.pluginPresent ?? (() => isFile(swiftBarPluginPath(paths).file)),
303
+ swiftBarRunning: options.probes?.swiftBarRunning ?? isSwiftBarRunning,
304
+ swiftBarInLoginItems: options.probes?.swiftBarInLoginItems ?? isSwiftBarInLoginItems,
305
+ statusFilePresent: options.probes?.statusFilePresent ?? (() => isFile(paths.statusFile)),
306
+ };
307
+ const effects = {
308
+ installSwiftBar: options.effects?.installSwiftBar ?? (() => ensureSwiftBarInstalled()),
309
+ installPlugin: options.effects?.installPlugin ?? (() => installMenuBar()),
310
+ launchSwiftBar: options.effects?.launchSwiftBar ?? (() => openSwiftBar()),
311
+ addLoginItem: options.effects?.addLoginItem ?? (() => addSwiftBarToLoginItems()),
312
+ collectStatus: options.effects?.collectStatus ?? (async () => runDaemonOnce()),
313
+ };
314
+ const messages = [];
315
+ let swiftBarInstalled = probes.swiftBarInstalled();
316
+ if (!swiftBarInstalled) {
317
+ messages.push(...effects.installSwiftBar());
318
+ swiftBarInstalled = probes.swiftBarInstalled();
319
+ }
320
+ if (!probes.pluginPresent()) {
321
+ messages.push(...effects.installPlugin());
322
+ }
323
+ if (swiftBarInstalled && !probes.swiftBarRunning()) {
324
+ messages.push(...effects.launchSwiftBar());
325
+ }
326
+ if (probes.swiftBarInLoginItems() === false) {
327
+ messages.push(...effects.addLoginItem());
328
+ }
329
+ if (!probes.statusFilePresent()) {
330
+ messages.push(...(await effects.collectStatus()));
331
+ }
332
+ return messages;
333
+ }
254
334
  export function formatDoctor(checks) {
255
335
  return checks
256
336
  .map((check) => `${check.ok ? "OK" : "MISSING"} ${check.name}: ${check.message}`)
package/dist/install.js CHANGED
@@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process";
5
5
  import { claudeStatusLineHasIngest, getClaudeStatusLineCommand, readClaudeSettings } from "./claude.js";
6
6
  import { ensureConfig, readConfig } from "./config.js";
7
7
  import { ensureDir, isFile, readJsonFile, writeJsonAtomic } from "./fs-util.js";
8
- import { addSwiftBarToLoginItems, ensureSwiftBarInstalled, installMenuBar, openSwiftBar, uninstallMenuBar } from "./menubar.js";
8
+ import { addSwiftBarToLoginItems, ensureSwiftBarInstalled, installMenuBar, openSwiftBar, uninstallMenuBar, verifyMenuBarSetup } from "./menubar.js";
9
9
  import { bakeGlyphAtlases } from "./glyphs.js";
10
10
  import { buildPaths } from "./paths.js";
11
11
  import { stableNodeExecutable } from "./node-runtime.js";
@@ -376,11 +376,13 @@ export function install(options = {}) {
376
376
  if (dryRun) {
377
377
  messages.push(`[dry-run] would write launchd plist: ${paths.launchAgentFile}`);
378
378
  messages.push(`[dry-run] ProgramArguments: ${[...args, "daemon", "--once"].join(" ")}`);
379
+ messages.push(...verifyMenuBarSetup({ dryRun }));
379
380
  return messages;
380
381
  }
381
382
  fs.writeFileSync(paths.launchAgentFile, xml, "utf8");
382
383
  restartLaunchAgent(paths.launchAgentFile);
383
384
  messages.push(`Installed and restarted launchd agent: ${paths.launchAgentFile}`);
385
+ messages.push(...verifyMenuBarSetup());
384
386
  return messages;
385
387
  }
386
388
  export function uninstall(options = {}) {
package/dist/menubar.d.ts CHANGED
@@ -12,9 +12,24 @@ export declare function ensureSwiftBarInstalled(options?: {
12
12
  }): string[];
13
13
  export declare function isSwiftBarRunning(): boolean;
14
14
  export declare function openSwiftBar(): string[];
15
+ export declare function isSwiftBarInLoginItems(): boolean | null;
15
16
  export declare function addSwiftBarToLoginItems(options?: {
16
17
  dryRun?: boolean;
17
18
  }): string[];
19
+ export interface PluginRunResult {
20
+ ok: boolean;
21
+ output: string;
22
+ }
23
+ export interface VerifyMenuBarOptions {
24
+ dryRun?: boolean;
25
+ runPlugin?: (file: string) => PluginRunResult;
26
+ loginItems?: () => boolean | null;
27
+ swiftBarInstalled?: () => boolean;
28
+ swiftBarRunning?: () => boolean;
29
+ repairPlugin?: () => string[];
30
+ launchSwiftBar?: () => string[];
31
+ }
32
+ export declare function verifyMenuBarSetup(options?: VerifyMenuBarOptions): string[];
18
33
  export declare function renderMenuBar(snapshot?: StatusSnapshot, paths?: RuntimePaths, now?: Date): string;
19
34
  export declare function swiftBarStatusItemVisibilityKeys(defaultsOutput: string): string[];
20
35
  export declare function installMenuBar(options?: {
package/dist/menubar.js CHANGED
@@ -268,22 +268,27 @@ export function openSwiftBar() {
268
268
  }
269
269
  return messages;
270
270
  }
271
- export function addSwiftBarToLoginItems(options = {}) {
272
- const dryRun = options.dryRun ?? false;
273
- const appPath = swiftBarAppPath();
274
- if (!appPath) {
275
- return ["SwiftBar is not installed; skipping login item."];
276
- }
271
+ // Whether SwiftBar auto-starts at login. Returns null when the login item list
272
+ // cannot be queried (Automation permission denied or non-interactive context).
273
+ export function isSwiftBarInLoginItems() {
277
274
  try {
278
275
  const result = execFileSync("osascript", [
279
276
  "-e", 'tell application "System Events" to get the name of every login item',
280
277
  ], { encoding: "utf8" });
281
- if (result.includes("SwiftBar")) {
282
- return ["SwiftBar already in login items."];
283
- }
278
+ return result.split(",").map((name) => name.trim()).includes("SwiftBar");
284
279
  }
285
280
  catch {
286
- // Permission or access issue; try to add anyway.
281
+ return null;
282
+ }
283
+ }
284
+ export function addSwiftBarToLoginItems(options = {}) {
285
+ const dryRun = options.dryRun ?? false;
286
+ const appPath = swiftBarAppPath();
287
+ if (!appPath) {
288
+ return ["SwiftBar is not installed; skipping login item."];
289
+ }
290
+ if (isSwiftBarInLoginItems()) {
291
+ return ["SwiftBar already in login items."];
287
292
  }
288
293
  if (dryRun) {
289
294
  return [`[dry-run] would add SwiftBar to login items: ${appPath}`];
@@ -298,6 +303,69 @@ export function addSwiftBarToLoginItems(options = {}) {
298
303
  return [`Failed to add SwiftBar to login items: ${error instanceof Error ? error.message : String(error)}. Add manually in System Settings → General → Login Items.`];
299
304
  }
300
305
  }
306
+ // Execute the installed plugin exactly the way SwiftBar would and check it
307
+ // produces a real menu. The plugin prints "Usage ERR" with exit code 0 on
308
+ // failure, so a non-error status alone proves nothing.
309
+ function runPluginScript(file) {
310
+ try {
311
+ const stdout = execFileSync(file, { encoding: "utf8", timeout: 30000 });
312
+ const output = stdout.trim();
313
+ return { ok: output.length > 0 && !output.startsWith("Usage ERR"), output };
314
+ }
315
+ catch (error) {
316
+ return { ok: false, output: error instanceof Error ? error.message : String(error) };
317
+ }
318
+ }
319
+ const MENU_BAR_MANUAL_STEPS = [
320
+ "Menu bar is NOT ready yet. Fix it with these steps:",
321
+ " 1. If macOS is showing an Automation/Accessibility permission prompt, click Allow.",
322
+ " 2. Open SwiftBar: open -a SwiftBar",
323
+ " 3. If the icon is still missing, macOS may have folded it away: System Settings → Control Center → Menu Bar.",
324
+ " 4. Re-run: coding-usage-bar doctor --fix",
325
+ ];
326
+ const LOGIN_ITEM_WARNING = [
327
+ "WARN SwiftBar is not in login items: the menu bar icon will disappear after a reboot.",
328
+ " If macOS showed a System Events Automation prompt, allow it and re-run coding-usage-bar install,",
329
+ " or add SwiftBar manually: System Settings → General → Login Items.",
330
+ ];
331
+ // End-to-end acceptance at the end of install: the plugin exists, SwiftBar is
332
+ // running, and the plugin actually renders a menu. Auto-repairs once, then
333
+ // falls back to numbered manual steps instead of failing silently.
334
+ export function verifyMenuBarSetup(options = {}) {
335
+ if (options.dryRun) {
336
+ return ["[dry-run] would verify the SwiftBar plugin renders and the menu bar item appears"];
337
+ }
338
+ const paths = buildPaths();
339
+ const plugin = swiftBarPluginPath(paths);
340
+ const isInstalled = options.swiftBarInstalled ?? isSwiftBarInstalled;
341
+ const isRunning = options.swiftBarRunning ?? isSwiftBarRunning;
342
+ const messages = [];
343
+ if (!isInstalled()) {
344
+ return [
345
+ ...MENU_BAR_MANUAL_STEPS,
346
+ ` (SwiftBar is not installed. Install it with: brew install --cask swiftbar, then re-run coding-usage-bar install.)`,
347
+ ];
348
+ }
349
+ if (!isFile(plugin.file)) {
350
+ messages.push(...(options.repairPlugin ?? installMenuBar)());
351
+ }
352
+ if (!isRunning()) {
353
+ messages.push(...(options.launchSwiftBar ?? openSwiftBar)());
354
+ }
355
+ if (!isFile(plugin.file)) {
356
+ return [...messages, ...MENU_BAR_MANUAL_STEPS, ` (Plugin file missing: ${plugin.file})`];
357
+ }
358
+ const run = (options.runPlugin ?? runPluginScript)(plugin.file);
359
+ if (!run.ok || !isRunning()) {
360
+ return [...messages, ...MENU_BAR_MANUAL_STEPS];
361
+ }
362
+ messages.push("");
363
+ messages.push("Menu bar ready — Coding Usage Bar should now appear at the top-right of your menu bar.");
364
+ if ((options.loginItems ?? isSwiftBarInLoginItems)() === false) {
365
+ messages.push(...LOGIN_ITEM_WARNING);
366
+ }
367
+ return messages;
368
+ }
301
369
  function swiftBarEscape(value) {
302
370
  return value.replaceAll("|", "\\|");
303
371
  }
package/dist/runtime.d.ts CHANGED
@@ -13,6 +13,12 @@ export declare function analyzeUsages(usages: ProviderUsage[], profile: BurnProf
13
13
  export declare function readProfile(): BurnProfile;
14
14
  export declare function loadFixtureSamples(provider: string): ProviderUsage[];
15
15
  export declare function analyzeFixtureUsages(usages: ProviderUsage[], profile: BurnProfile): BurnAnalysis[];
16
+ export declare function commitCollectedStatusSnapshot(usages: ProviderUsage[], issues: StatusIssue[], options?: {
17
+ paths?: RuntimePaths;
18
+ profile?: BurnProfile;
19
+ generatedAt?: Date;
20
+ fixtureSamples?: Map<string, ProviderUsage[]>;
21
+ }): StatusSnapshot;
16
22
  export declare function collectStatusSnapshot(options?: {
17
23
  fixtures?: boolean;
18
24
  }): Promise<StatusSnapshot>;
package/dist/runtime.js CHANGED
@@ -10,7 +10,7 @@ import { collectKimiUsage, resolveKimiConfig } from "./kimi.js";
10
10
  import { collectMinimaxUsage } from "./minimax.js";
11
11
  import { buildPaths } from "./paths.js";
12
12
  import { loadLatestUsage, saveUsage } from "./store.js";
13
- import { createStatusSnapshot, loadStatusSnapshot, refreshStatusSnapshotFreshness, saveStatusSnapshot, } from "./status.js";
13
+ import { createStatusSnapshot, loadStatusSnapshot, reconcileLatestUsages, replaceStatusSnapshotUsage, refreshStatusSnapshotFreshness, saveStatusSnapshot, withStatusSnapshotLock, } from "./status.js";
14
14
  const FIXTURES_DIR = fileURLToPath(new URL("../fixtures", import.meta.url));
15
15
  export function loadFixtureUsages(fixturesDir) {
16
16
  const providers = ["claude", "codex", "glm", "deepseek", "minimax", "kimi"];
@@ -248,17 +248,42 @@ export function analyzeFixtureUsages(usages, profile) {
248
248
  const fixtureSamples = new Map(usages.map((usage) => [usage.provider, loadFixtureSamples(usage.provider)]));
249
249
  return createStatusSnapshot(usages, profile, { fixtureSamples }).providers.map((provider) => provider.analysis);
250
250
  }
251
+ export function commitCollectedStatusSnapshot(usages, issues, options = {}) {
252
+ const paths = options.paths ?? buildPaths();
253
+ const profile = options.profile ?? readProfile();
254
+ const now = options.generatedAt ?? new Date();
255
+ const candidate = createStatusSnapshot(usages, profile, {
256
+ paths,
257
+ generatedAt: now,
258
+ fixtureSamples: options.fixtureSamples,
259
+ issues,
260
+ });
261
+ return withStatusSnapshotLock(paths, () => {
262
+ const existing = loadStatusSnapshot(paths);
263
+ const cached = reconcileLatestUsages(usages, paths);
264
+ let snapshot = candidate;
265
+ for (const usage of cached) {
266
+ snapshot = replaceStatusSnapshotUsage(snapshot, usage, now);
267
+ }
268
+ if (existing) {
269
+ for (const provider of existing.providers) {
270
+ snapshot = replaceStatusSnapshotUsage(snapshot, provider.usage, now);
271
+ }
272
+ }
273
+ saveStatusSnapshot(snapshot, paths);
274
+ return snapshot;
275
+ });
276
+ }
251
277
  export async function collectStatusSnapshot(options = {}) {
252
278
  const { usages, issues } = await collectLocalState(options);
253
279
  const profile = readProfile();
254
280
  const fixtureSamples = options.fixtures
255
281
  ? new Map(usages.map((usage) => [usage.provider, loadFixtureSamples(usage.provider)]))
256
282
  : undefined;
257
- const snapshot = createStatusSnapshot(usages, profile, { fixtureSamples, issues });
258
283
  if (!options.fixtures) {
259
- saveStatusSnapshot(snapshot);
284
+ return commitCollectedStatusSnapshot(usages, issues, { profile });
260
285
  }
261
- return snapshot;
286
+ return createStatusSnapshot(usages, profile, { fixtureSamples, issues });
262
287
  }
263
288
  export function loadDisplayStatusSnapshot(options = {}) {
264
289
  if (options.fixtures || options.refresh) {
package/dist/status.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { BurnProfile, ProviderUsage, RuntimePaths, StatusIssue, StatusSnapshot } from "./types.js";
2
+ export declare function withStatusSnapshotLock<T>(paths: RuntimePaths, action: () => T): T;
3
+ export declare function reconcileLatestUsages(usages: ProviderUsage[], paths: RuntimePaths): ProviderUsage[];
2
4
  export declare function createStatusSnapshot(usages: ProviderUsage[], profile: BurnProfile, options?: {
3
5
  paths?: RuntimePaths;
4
6
  generatedAt?: Date;
@@ -12,3 +14,5 @@ export declare function refreshStatusSnapshotFreshness(snapshot: StatusSnapshot,
12
14
  }): StatusSnapshot;
13
15
  export declare function saveStatusSnapshot(snapshot: StatusSnapshot, paths?: RuntimePaths): void;
14
16
  export declare function loadStatusSnapshot(paths?: RuntimePaths): StatusSnapshot | null;
17
+ export declare function replaceStatusSnapshotUsage(existing: StatusSnapshot | null, usage: ProviderUsage, now?: Date): StatusSnapshot;
18
+ export declare function updateStatusSnapshotUsage(usage: ProviderUsage, paths?: RuntimePaths): StatusSnapshot;
package/dist/status.js CHANGED
@@ -1,8 +1,91 @@
1
- import { analyzeUsage } from "./burn.js";
2
- import { readJsonFile, writeJsonAtomic } from "./fs-util.js";
3
- import { buildPaths } from "./paths.js";
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { analyzeUsage, analyzeUsageWithConversionRate } from "./burn.js";
4
+ import { ensureDir, readJsonFile, writeJsonAtomic } from "./fs-util.js";
5
+ import { buildPaths, providerLatestPath } from "./paths.js";
4
6
  import { loadSamples } from "./store.js";
5
7
  const DEFAULT_STALE_AFTER_SECONDS = 10 * 60;
8
+ const STATUS_LOCK_RETRY_MS = 5;
9
+ const STATUS_LOCK_TIMEOUT_MS = 2_000;
10
+ const STATUS_LOCK_STALE_MS = 10_000;
11
+ const lockWaitArray = new Int32Array(new SharedArrayBuffer(4));
12
+ function waitForStatusLock() {
13
+ Atomics.wait(lockWaitArray, 0, 0, STATUS_LOCK_RETRY_MS);
14
+ }
15
+ export function withStatusSnapshotLock(paths, action) {
16
+ const lockFile = `${paths.statusFile}.lock`;
17
+ const deadline = Date.now() + STATUS_LOCK_TIMEOUT_MS;
18
+ ensureDir(path.dirname(lockFile));
19
+ let descriptor = null;
20
+ while (descriptor === null) {
21
+ try {
22
+ descriptor = fs.openSync(lockFile, "wx", 0o600);
23
+ fs.writeFileSync(descriptor, `${process.pid}\n`, "utf8");
24
+ }
25
+ catch (error) {
26
+ if (error.code !== "EEXIST") {
27
+ throw error;
28
+ }
29
+ try {
30
+ const ageMs = Date.now() - fs.statSync(lockFile).mtimeMs;
31
+ if (ageMs > STATUS_LOCK_STALE_MS) {
32
+ fs.unlinkSync(lockFile);
33
+ continue;
34
+ }
35
+ }
36
+ catch (statError) {
37
+ if (statError.code === "ENOENT") {
38
+ continue;
39
+ }
40
+ throw statError;
41
+ }
42
+ if (Date.now() >= deadline) {
43
+ throw new Error(`Timed out waiting for status snapshot lock at ${lockFile}`);
44
+ }
45
+ waitForStatusLock();
46
+ }
47
+ }
48
+ try {
49
+ return action();
50
+ }
51
+ finally {
52
+ fs.closeSync(descriptor);
53
+ try {
54
+ fs.unlinkSync(lockFile);
55
+ }
56
+ catch (error) {
57
+ if (error.code !== "ENOENT") {
58
+ throw error;
59
+ }
60
+ }
61
+ }
62
+ }
63
+ function observedAtMs(usage) {
64
+ const value = Date.parse(usage.observedAt);
65
+ return Number.isFinite(value) ? value : null;
66
+ }
67
+ function newestUsage(usages) {
68
+ return usages.reduce((latest, candidate) => {
69
+ const latestTime = observedAtMs(latest);
70
+ const candidateTime = observedAtMs(candidate);
71
+ return candidateTime !== null && (latestTime === null || candidateTime > latestTime)
72
+ ? candidate
73
+ : latest;
74
+ });
75
+ }
76
+ export function reconcileLatestUsages(usages, paths) {
77
+ return usages.map((usage) => {
78
+ const cached = readJsonFile(providerLatestPath(paths, usage.provider));
79
+ if (!cached || cached.provider !== usage.provider) {
80
+ return usage;
81
+ }
82
+ const cachedTime = observedAtMs(cached);
83
+ const usageTime = observedAtMs(usage);
84
+ return cachedTime !== null && (usageTime === null || cachedTime > usageTime)
85
+ ? cached
86
+ : usage;
87
+ });
88
+ }
6
89
  function buildProviderMeta(usage, generatedAt, staleAfterSeconds) {
7
90
  const ageSeconds = Math.max(0, Math.round((generatedAt.getTime() - Date.parse(usage.observedAt)) / 1000));
8
91
  return {
@@ -79,3 +162,39 @@ export function saveStatusSnapshot(snapshot, paths = buildPaths()) {
79
162
  export function loadStatusSnapshot(paths = buildPaths()) {
80
163
  return readJsonFile(paths.statusFile);
81
164
  }
165
+ export function replaceStatusSnapshotUsage(existing, usage, now = new Date()) {
166
+ if (!existing) {
167
+ return createStatusSnapshot([usage], "low", {
168
+ generatedAt: now,
169
+ fixtureSamples: new Map([[usage.provider, []]]),
170
+ });
171
+ }
172
+ const previous = existing.providers.find((provider) => provider.usage.provider === usage.provider);
173
+ const selectedUsage = previous ? newestUsage([previous.usage, usage]) : usage;
174
+ const conversionRate = previous?.analysis.target?.conversionRate ?? null;
175
+ const provider = {
176
+ usage: selectedUsage,
177
+ analysis: analyzeUsageWithConversionRate(selectedUsage, conversionRate, existing.profile, now),
178
+ meta: buildProviderMeta(selectedUsage, now, DEFAULT_STALE_AFTER_SECONDS),
179
+ };
180
+ const providers = previous
181
+ ? existing.providers.map((item) => item.usage.provider === usage.provider ? provider : item)
182
+ : [...existing.providers, provider];
183
+ return refreshStatusSnapshotFreshness({
184
+ ...existing,
185
+ generatedAt: now.toISOString(),
186
+ providers,
187
+ issues: existing.issues.filter((issue) => (issue.code !== "USAGE_STALE"
188
+ && !(issue.provider === usage.provider && issue.code === "CLAUDE_INGEST_MISSING"))),
189
+ }, { now });
190
+ }
191
+ export function updateStatusSnapshotUsage(usage, paths = buildPaths()) {
192
+ return withStatusSnapshotLock(paths, () => {
193
+ const now = new Date();
194
+ const existing = loadStatusSnapshot(paths);
195
+ const cached = reconcileLatestUsages([usage], paths)[0];
196
+ const snapshot = replaceStatusSnapshotUsage(existing, newestUsage([usage, cached]), now);
197
+ saveStatusSnapshot(snapshot, paths);
198
+ return snapshot;
199
+ });
200
+ }
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-usage-bar",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "macOS menu bar usage monitor for Claude Code, Codex, GLM, DeepSeek, and MiniMax.",
5
5
  "author": "hanzhangzzz",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "type": "module",
31
31
  "bin": {
32
- "coding-usage-bar": "./dist/cli.js"
32
+ "coding-usage-bar": "dist/cli.js"
33
33
  },
34
34
  "files": [
35
35
  "assets",