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 +12 -1
- package/README.zh-CN.md +11 -1
- package/dist/burn.d.ts +1 -0
- package/dist/burn.js +8 -2
- package/dist/cli.js +22 -2
- package/dist/doctor.d.ts +20 -0
- package/dist/doctor.js +85 -5
- package/dist/install.js +3 -1
- package/dist/menubar.d.ts +15 -0
- package/dist/menubar.js +78 -10
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +29 -4
- package/dist/status.d.ts +4 -0
- package/dist/status.js +122 -3
- package/docs/assets/demo.gif +0 -0
- package/docs/assets/hero.png +0 -0
- package/package.json +2 -2
- package/assets/screenshot-20260714-160548.png +0 -0
- package/assets/screenshot-20260720-153409.png +0 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
:
|
|
249
|
-
:
|
|
250
|
-
: "SwiftBar not found;
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
282
|
-
return ["SwiftBar already in login items."];
|
|
283
|
-
}
|
|
278
|
+
return result.split(",").map((name) => name.trim()).includes("SwiftBar");
|
|
284
279
|
}
|
|
285
280
|
catch {
|
|
286
|
-
|
|
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
|
-
|
|
284
|
+
return commitCollectedStatusSnapshot(usages, issues, { profile });
|
|
260
285
|
}
|
|
261
|
-
return
|
|
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
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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
|
+
}
|
package/docs/assets/demo.gif
CHANGED
|
Binary file
|
package/docs/assets/hero.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coding-usage-bar",
|
|
3
|
-
"version": "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": "
|
|
32
|
+
"coding-usage-bar": "dist/cli.js"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"assets",
|
|
Binary file
|
|
Binary file
|