coding-usage-bar 0.1.0 → 0.2.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
@@ -2,18 +2,26 @@
2
2
 
3
3
  [English](README.md) · [简体中文](README.zh-CN.md)
4
4
 
5
- **Claude Code and Codex usage monitoring in your macOS menu bar.** Track rolling plan limits across Claude Code, OpenAI Codex, GLM (Zhipu AI), DeepSeek, and MiniMax before a coding session hits the wall.
5
+ **Claude Code and Codex usage monitoring in your macOS menu bar.** Track rolling plan limits across Claude Code, OpenAI Codex, GLM (Zhipu AI), DeepSeek, MiniMax, and Kimi (Moonshot AI) before a coding session hits the wall.
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/coding-usage-bar.svg)](https://www.npmjs.com/package/coding-usage-bar)
8
8
  [![CI](https://github.com/hanzhangzzz/coding-usage-bar/actions/workflows/test.yml/badge.svg)](https://github.com/hanzhangzzz/coding-usage-bar/actions/workflows/test.yml)
9
9
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
10
  [![platform](https://img.shields.io/badge/platform-macOS-lightgrey.svg?logo=apple)](#requirements)
11
11
 
12
- ![Coding Usage Bar showing Claude Code, Codex, GLM, DeepSeek, and MiniMax plan usage in the macOS menu bar](docs/assets/hero.png)
12
+ ![Coding Usage Bar showing Claude Code, Codex, GLM, DeepSeek, MiniMax, and Kimi plan usage in the macOS menu bar](docs/assets/hero.png)
13
13
 
14
14
  Coding Usage Bar does more than display quota percentages. It compares short rolling windows with the weekly budget and labels your pace as learning, under-burning, on track, over-burning, or close to a limit.
15
15
 
16
- Claude Code and Codex data stays local and is read from files those tools already produce. Provider API keys for GLM, DeepSeek, and MiniMax are stored only in `~/.coding-usage-bar/config.json` and sent directly to their respective quota APIs.
16
+ Claude Code and Codex data stays local and is read from files those tools already produce. Provider API keys for GLM, DeepSeek, and MiniMax are stored only in `~/.coding-usage-bar/config.json` and sent directly to their respective quota APIs. Kimi reads its key from `~/.coding-usage-bar/config.json` or, when unset, from the kimi.com lane in `~/.config/claude-lanes/config.env`.
17
+
18
+ ## Live SwiftBar Menu
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.
21
+
22
+ <p align="center">
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">
24
+ </p>
17
25
 
18
26
  ## Quick Start
19
27
 
@@ -119,6 +127,21 @@ Coding Usage Bar calls `GET https://api.minimaxi.com/v1/token_plan/remains` to r
119
127
 
120
128
  `region` defaults to `cn` (api.minimaxi.com); set it to `global` to use api.minimax.io. Without this key, MiniMax monitoring will report `MINIMAX_API_KEY_MISSING`.
121
129
 
130
+ ## Kimi (Moonshot AI)
131
+
132
+ Coding Usage Bar calls `GET https://api.kimi.com/coding/v1/usages` to read 5h and 7d usage windows. The response carries the 300-minute rolling window in `limits[]` and the weekly window in the top-level `usage`, with `limit`/`used`/`remaining` as strings; Coding Usage Bar derives used% from `used / limit` (or from `used / (used + remaining)` when limit is 0). Set `kimi.apiKey` in `~/.coding-usage-bar/config.json`:
133
+
134
+ ```json
135
+ {
136
+ "providers": ["codex", "claude", "glm", "minimax", "kimi"],
137
+ "kimi": {
138
+ "apiKey": "your-kimi-api-key"
139
+ }
140
+ }
141
+ ```
142
+
143
+ If `kimi.apiKey` is empty, Coding Usage Bar falls back to the lane whose `CONFIG_<n>_BASE_URL` points at kimi.com in `~/.config/claude-lanes/config.env` and uses that lane's `CONFIG_<n>_AUTH_TOKEN` (and base URL). Without either source, Kimi monitoring will report `KIMI_API_KEY_MISSING`.
144
+
122
145
  ## Profiles
123
146
 
124
147
  Set `CODING_USAGE_BAR_PROFILE=high` for the more aggressive profile. The default is `low`.
@@ -135,7 +158,7 @@ Both profiles are constrained by the weekly budget. When a short window is repor
135
158
 
136
159
  ```json
137
160
  {
138
- "providers": ["codex", "claude", "glm", "deepseek", "minimax"]
161
+ "providers": ["codex", "claude", "glm", "deepseek", "minimax", "kimi"]
139
162
  }
140
163
  ```
141
164
 
@@ -153,7 +176,7 @@ coding-usage-bar menubar install
153
176
 
154
177
  If SwiftBar is not installed, `coding-usage-bar doctor` will report it. If SwiftBar already has a custom plugin folder, Coding Usage Bar uses that folder.
155
178
 
156
- The compact menu bar title and dropdown use recognizable Provider marks for Codex, Claude Code, Zhipu AI, DeepSeek, and MiniMax alongside the rolling-window data (DeepSeek shows balance instead of windows):
179
+ The compact menu bar title and dropdown use recognizable Provider marks for Codex, Claude Code, Zhipu AI, DeepSeek, MiniMax, and Kimi alongside the rolling-window data (DeepSeek shows balance instead of windows):
157
180
 
158
181
  ```text
159
182
  {Codex icon} 5H:14%,7D:67% │ {Claude icon} 5H:24%,7D:74% │ {GLM icon} 5H:36%,7D:7%
@@ -166,13 +189,14 @@ SwiftBar only supports one bitmap image on a single stable title item, so Coding
166
189
  | Path | Purpose |
167
190
  |------|---------|
168
191
  | `~/.coding-usage-bar/app/` | Stable runtime copy used by launchd, Claude ingest hints, and SwiftBar |
169
- | `~/.coding-usage-bar/config.json` | Provider selection, default `["codex", "claude", "glm", "deepseek", "minimax"]` |
192
+ | `~/.coding-usage-bar/config.json` | Provider selection, default `["codex", "claude", "glm", "deepseek", "minimax", "kimi"]` |
170
193
  | `~/.coding-usage-bar/status.json` | Stable display-layer entry point |
171
194
  | `~/.coding-usage-bar/codex/latest.json` | Latest normalized Codex usage |
172
195
  | `~/.coding-usage-bar/claude/latest.json` | Latest normalized Claude usage after status line ingest |
173
196
  | `~/.coding-usage-bar/glm/latest.json` | Latest normalized GLM usage from Zhipu AI quota API |
174
197
  | `~/.coding-usage-bar/deepseek/latest.json` | Latest normalized DeepSeek balance from DeepSeek API |
175
198
  | `~/.coding-usage-bar/minimax/latest.json` | Latest normalized MiniMax 5h/7d usage from MiniMax API |
199
+ | `~/.coding-usage-bar/kimi/latest.json` | Latest normalized Kimi 5h/7d usage from Kimi usages API |
176
200
  | `~/.local/bin/coding-usage-bar` | CLI shim pointing at the stable runtime |
177
201
  | `~/Library/LaunchAgents/com.duying.coding-usage-bar.plist` | macOS launchd agent |
178
202
  | SwiftBar `PluginDirectory` / `coding-usage-bar.1m.js` | Menu bar plugin wrapper |
@@ -191,7 +215,7 @@ See the [product and technical baseline](https://github.com/hanzhangzzz/coding-u
191
215
 
192
216
  ## Why Coding Usage Bar?
193
217
 
194
- - **One menu bar for multiple coding providers:** Claude Code, Codex, GLM, DeepSeek, and MiniMax.
218
+ - **One menu bar for multiple coding providers:** Claude Code, Codex, GLM, DeepSeek, MiniMax, and Kimi.
195
219
  - **Pacing, not just percentages:** short-window usage is evaluated against the weekly budget.
196
220
  - **Local-first:** Claude Code and Codex usage is read from local tool output, without a separate account or telemetry service.
197
221
  - **Visible but lightweight:** SwiftBar is only the host; collection and display state remain separated and auditable.
@@ -217,4 +241,4 @@ npm pack
217
241
 
218
242
  [MIT](LICENSE)
219
243
 
220
- Provider names and trademarks belong to their respective owners. Coding Usage Bar is an independent project and is not affiliated with or endorsed by Anthropic, OpenAI, Zhipu AI, DeepSeek, or MiniMax. Provider marks are used only for identification, are excluded from the MIT License, and are documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
244
+ Provider names and trademarks belong to their respective owners. Coding Usage Bar is an independent project and is not affiliated with or endorsed by Anthropic, OpenAI, Zhipu AI, DeepSeek, MiniMax, or Moonshot AI. Provider marks are used only for identification, are excluded from the MIT License, and are documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
package/README.zh-CN.md CHANGED
@@ -6,9 +6,17 @@
6
6
 
7
7
  ![Coding Usage Bar 在 macOS 菜单栏展示多个 AI 编程 Provider 的套餐用量](docs/assets/hero.png)
8
8
 
9
- Coding Usage Bar 同时支持 Claude Code、OpenAI Codex、GLM(智谱 AI)、DeepSeek 和 MiniMax。它不只展示百分比,还会结合短周期和周额度判断当前节奏是偏慢、正常、偏快,还是已经接近限额。
9
+ Coding Usage Bar 同时支持 Claude Code、OpenAI Codex、GLM(智谱 AI)、DeepSeek、MiniMax 和 Kimi(Moonshot AI)。它不只展示百分比,还会结合短周期和周额度判断当前节奏是偏慢、正常、偏快,还是已经接近限额。
10
10
 
11
- Claude Code 与 Codex 的数据直接读取它们在本机产生的文件,不需要额外登录,也不会上传使用记录。GLM、DeepSeek 和 MiniMax 的 API Key 只保存在 `~/.coding-usage-bar/config.json`,并直接请求对应 Provider 的额度接口。
11
+ Claude Code 与 Codex 的数据直接读取它们在本机产生的文件,不需要额外登录,也不会上传使用记录。GLM、DeepSeek 和 MiniMax 的 API Key 只保存在 `~/.coding-usage-bar/config.json`,并直接请求对应 Provider 的额度接口。Kimi 的 Key 优先读 `~/.coding-usage-bar/config.json`,未配置时回退到 `~/.config/claude-lanes/config.env` 中指向 kimi.com 的 lane。
12
+
13
+ ## 真实 SwiftBar 菜单
14
+
15
+ 以下截图来自正在运行的 SwiftBar 插件;动画复现菜单的展开与收起,不修改截图中的用量数据。
16
+
17
+ <p align="center">
18
+ <img src="docs/assets/demo.gif" alt="打开 Coding Usage Bar 的真实 SwiftBar 菜单,查看 Codex、Claude、GLM、DeepSeek、MiniMax 和 Kimi 用量" width="800">
19
+ </p>
12
20
 
13
21
  ## 快速开始
14
22
 
@@ -50,4 +58,4 @@ npm pack
50
58
 
51
59
  [MIT](LICENSE)
52
60
 
53
- 各 Provider 名称与商标归其权利人所有。本项目独立开发,与 Anthropic、OpenAI、智谱 AI、DeepSeek、MiniMax 无隶属或背书关系。Provider 标识仅用于识别,不适用项目 MIT License,详见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
61
+ 各 Provider 名称与商标归其权利人所有。本项目独立开发,与 Anthropic、OpenAI、智谱 AI、DeepSeek、MiniMax、Moonshot AI 无隶属或背书关系。Provider 标识仅用于识别,不适用项目 MIT License,详见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
@@ -9,6 +9,7 @@ Coding Usage Bar includes the following provider marks solely to identify the se
9
9
  | `assets/provider-glm.png`, `assets/provider-glm-dark.png` | Zhipu AI / GLM |
10
10
  | `assets/provider-deepseek.png` | DeepSeek |
11
11
  | `assets/provider-minimax.png` | MiniMax |
12
+ | `assets/provider-kimi.png` | Moonshot AI / Kimi |
12
13
  | `assets/provider-marks.png` | Composite of the marks listed above |
13
14
 
14
15
  These marks and their associated names are owned by their respective holders. They are **not** licensed under this repository's MIT License. Their inclusion identifies the Provider whose usage is displayed and does not imply affiliation, sponsorship, or endorsement. No rights to the marks are granted by this project. Use of a mark remains subject to its owner's applicable brand rules, including the [OpenAI Design Guidelines](https://openai.com/brand/).
Binary file
package/dist/burn.js CHANGED
@@ -56,7 +56,7 @@ export function estimateConversionRate(samples) {
56
56
  return Number.isFinite(value) && value > 0 ? value : null;
57
57
  }
58
58
  function providerLabel(provider) {
59
- return provider === "claude" ? "Claude" : provider === "glm" ? "GLM" : provider === "deepseek" ? "DeepSeek" : provider === "minimax" ? "MiniMax" : "Codex";
59
+ return provider === "claude" ? "Claude" : provider === "glm" ? "GLM" : provider === "deepseek" ? "DeepSeek" : provider === "minimax" ? "MiniMax" : provider === "kimi" ? "Kimi" : "Codex";
60
60
  }
61
61
  function messageForState(provider, state, fiveUsed, target) {
62
62
  const label = providerLabel(provider);
package/dist/claude.js CHANGED
@@ -114,22 +114,122 @@ function statusLineScriptCandidates(command) {
114
114
  }
115
115
  return [...new Set(candidates)];
116
116
  }
117
+ function stripInlineShellComment(line) {
118
+ let quote = null;
119
+ let escaped = false;
120
+ for (let index = 0; index < line.length; index += 1) {
121
+ const char = line[index];
122
+ if (escaped) {
123
+ escaped = false;
124
+ continue;
125
+ }
126
+ if (char === "\\" && quote !== "'") {
127
+ escaped = true;
128
+ continue;
129
+ }
130
+ if ((char === "'" || char === "\"") && !quote) {
131
+ quote = char;
132
+ continue;
133
+ }
134
+ if (char === quote) {
135
+ quote = null;
136
+ continue;
137
+ }
138
+ if (char === "#" && !quote && (index === 0 || /\s/.test(line[index - 1]))) {
139
+ return line.slice(0, index);
140
+ }
141
+ }
142
+ return line;
143
+ }
144
+ function hasBalancedShellQuotes(line) {
145
+ let quote = null;
146
+ let escaped = false;
147
+ for (const char of line) {
148
+ if (escaped) {
149
+ escaped = false;
150
+ continue;
151
+ }
152
+ if (char === "\\" && quote !== "'") {
153
+ escaped = true;
154
+ continue;
155
+ }
156
+ if ((char === "'" || char === "\"") && !quote) {
157
+ quote = char;
158
+ }
159
+ else if (char === quote) {
160
+ quote = null;
161
+ }
162
+ }
163
+ return quote === null;
164
+ }
165
+ function executableShellLines(text) {
166
+ const result = [];
167
+ const heredocDelimiters = [];
168
+ for (const rawLine of text.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
169
+ if (heredocDelimiters.length > 0) {
170
+ if (rawLine.trim() === heredocDelimiters[0]) {
171
+ heredocDelimiters.shift();
172
+ }
173
+ continue;
174
+ }
175
+ const line = stripInlineShellComment(rawLine);
176
+ const heredocPattern = /<<-?\s*(?:'([^']+)'|"([^"]+)"|\\([^\s;|&()<>]+)|([^\s;|&()<>]+))/g;
177
+ const heredocs = [...line.matchAll(heredocPattern)];
178
+ if (heredocs.length > 0) {
179
+ for (const heredoc of heredocs) {
180
+ heredocDelimiters.push(heredoc[1] ?? heredoc[2] ?? heredoc[3] ?? heredoc[4]);
181
+ }
182
+ result.push(line.slice(0, heredocs[0].index));
183
+ }
184
+ else {
185
+ result.push(line);
186
+ }
187
+ }
188
+ return result;
189
+ }
117
190
  export function claudeStatusLineHasIngest(command, options) {
191
+ const isCliPath = (word) => (path.basename(word) === "coding-usage-bar"
192
+ || word === options.appCliPath
193
+ || word.endsWith("/.coding-usage-bar/app/dist/cli.js"));
194
+ const hasIngestCommand = (text) => {
195
+ const lines = executableShellLines(text);
196
+ if (lines.some((line) => !hasBalancedShellQuotes(line))) {
197
+ return false;
198
+ }
199
+ return lines.some((line) => {
200
+ if (!line.trim()) {
201
+ return false;
202
+ }
203
+ const words = splitShellWords(line);
204
+ const commandStarts = [0, ...words.flatMap((word, index) => word === "|" ? [index + 1] : [])];
205
+ return commandStarts.some((start) => {
206
+ const executable = words[start];
207
+ const executableIsNode = ["node", "nodejs"].includes(path.basename(executable ?? ""));
208
+ const cliIndex = executableIsNode ? start + 1 : start;
209
+ const finalArgument = words[cliIndex + 2]?.replace(/[;)]$/, "");
210
+ return isCliPath(words[cliIndex] ?? "")
211
+ && words[cliIndex + 1] === "ingest"
212
+ && finalArgument === "claude-statusline";
213
+ });
214
+ });
215
+ };
118
216
  if (!command) {
119
217
  return false;
120
218
  }
121
- if (command.includes("coding-usage-bar ingest claude-statusline")
122
- || command.includes(options.appCliPath)
219
+ if (hasIngestCommand(command)
123
220
  || command === options.managedScriptPath) {
124
221
  return true;
125
222
  }
126
223
  for (const candidate of statusLineScriptCandidates(command)) {
127
224
  try {
225
+ if (isCliPath(candidate)) {
226
+ continue;
227
+ }
128
228
  if (!fs.statSync(candidate).isFile()) {
129
229
  continue;
130
230
  }
131
231
  const script = fs.readFileSync(candidate, "utf8");
132
- if (script.includes("coding-usage-bar ingest claude-statusline") || script.includes(options.appCliPath)) {
232
+ if (hasIngestCommand(script)) {
133
233
  return true;
134
234
  }
135
235
  }
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readJsonFile, writeJsonAtomic } from "./fs-util.js";
2
2
  import { buildPaths } from "./paths.js";
3
- const DEFAULT_PROVIDERS = ["codex", "claude", "glm", "deepseek", "minimax"];
4
- const PROVIDERS = new Set(["codex", "claude", "glm", "deepseek", "minimax"]);
3
+ const DEFAULT_PROVIDERS = ["codex", "claude", "glm", "deepseek", "minimax", "kimi"];
4
+ const PROVIDERS = new Set(["codex", "claude", "glm", "deepseek", "minimax", "kimi"]);
5
5
  function normalizeProviders(value) {
6
6
  if (!Array.isArray(value)) {
7
7
  return DEFAULT_PROVIDERS;
@@ -24,6 +24,7 @@ export function defaultConfig() {
24
24
  glm: { baseUrl: "https://open.bigmodel.cn", apiKey: "" },
25
25
  deepseek: { apiKey: "" },
26
26
  minimax: { region: "cn", apiKey: "" },
27
+ kimi: { apiKey: "" },
27
28
  };
28
29
  }
29
30
  export function readConfig(paths = buildPaths()) {
@@ -40,6 +41,7 @@ export function readConfig(paths = buildPaths()) {
40
41
  glm: fileConfig.glm ?? defaultConfig().glm,
41
42
  deepseek: fileConfig.deepseek ?? defaultConfig().deepseek,
42
43
  minimax: fileConfig.minimax ?? defaultConfig().minimax,
44
+ kimi: fileConfig.kimi ?? defaultConfig().kimi,
43
45
  };
44
46
  }
45
47
  export function ensureConfig(paths = buildPaths()) {
@@ -60,5 +62,9 @@ export function ensureConfig(paths = buildPaths()) {
60
62
  if (!latest.minimax) {
61
63
  writeJsonAtomic(paths.configFile, { ...latest, minimax: defaultConfig().minimax });
62
64
  }
65
+ const current = readJsonFile(paths.configFile) ?? latest;
66
+ if (!current.kimi) {
67
+ writeJsonAtomic(paths.configFile, { ...current, kimi: defaultConfig().kimi });
68
+ }
63
69
  return false;
64
70
  }
package/dist/doctor.js CHANGED
@@ -4,6 +4,7 @@ import { readConfig } from "./config.js";
4
4
  import { collectCodexUsage } from "./codex.js";
5
5
  import { claudeStatusLineHasIngest, getClaudeStatusLineCommand, readClaudeSettings } from "./claude.js";
6
6
  import { isDir, isFile } from "./fs-util.js";
7
+ import { resolveKimiConfig } from "./kimi.js";
7
8
  import { buildPaths, providerLatestPath } from "./paths.js";
8
9
  import { notificationBackend } from "./notifier.js";
9
10
  import { isSwiftBarInstalled, swiftBarPluginPath } from "./menubar.js";
@@ -184,6 +185,37 @@ export function runDoctor(options = {}) {
184
185
  message: "disabled by config",
185
186
  });
186
187
  }
188
+ if (monitored.has("kimi")) {
189
+ const kimiConfig = resolveKimiConfig(config.kimi, paths.homeDir);
190
+ const hasApiKey = Boolean(kimiConfig.apiKey);
191
+ const keySource = config.kimi?.apiKey
192
+ ? "coding-usage-bar config"
193
+ : "claude-lanes config.env";
194
+ checks.push({
195
+ name: "Kimi API key",
196
+ ok: hasApiKey,
197
+ message: hasApiKey
198
+ ? `configured (${keySource})`
199
+ : "not set; edit ~/.coding-usage-bar/config.json to set kimi.apiKey, or configure a kimi.com lane in ~/.config/claude-lanes/config.env",
200
+ });
201
+ const kimiLatest = providerLatestPath(paths, "kimi");
202
+ checks.push({
203
+ name: "Kimi usage cache",
204
+ ok: !hasApiKey || isFile(kimiLatest),
205
+ message: isFile(kimiLatest)
206
+ ? `found ${kimiLatest}`
207
+ : hasApiKey
208
+ ? `missing ${kimiLatest}; run coding-usage-bar daemon --once to collect`
209
+ : "skipped (API key not set)",
210
+ });
211
+ }
212
+ else {
213
+ checks.push({
214
+ name: "Kimi",
215
+ ok: true,
216
+ message: "disabled by config",
217
+ });
218
+ }
187
219
  const backend = notificationBackend();
188
220
  const notificationOk = backend !== "unsupported" && backend !== "burnt-toast";
189
221
  checks.push({
package/dist/format.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BurnAnalysis, StatusIssue, ProviderUsage, UsageWindow, StatusSnapshot } from "./types.js";
2
2
  export declare function formatDurationUntil(iso: string, now?: Date): string;
3
- export declare function formatProviderLabel(provider: string): "Claude" | "GLM" | "DeepSeek" | "MiniMax" | "Codex";
3
+ export declare function formatProviderLabel(provider: string): "Claude" | "GLM" | "DeepSeek" | "MiniMax" | "Kimi" | "Codex";
4
4
  export declare function formatWindowLabel(window: UsageWindow): "5h" | "7d";
5
5
  export declare function usageBar(usedPercent: number, width?: number): string;
6
6
  export declare function formatStatusRows(usages: ProviderUsage[], analyses: BurnAnalysis[]): string;
package/dist/format.js CHANGED
@@ -23,6 +23,8 @@ function providerLabel(provider) {
23
23
  return "DeepSeek";
24
24
  if (provider === "minimax")
25
25
  return "MiniMax";
26
+ if (provider === "kimi")
27
+ return "Kimi";
26
28
  return "Codex";
27
29
  }
28
30
  export function formatProviderLabel(provider) {
package/dist/install.d.ts CHANGED
@@ -21,5 +21,6 @@ export declare function install(options?: {
21
21
  }): string[];
22
22
  export declare function uninstall(options?: {
23
23
  dryRun?: boolean;
24
+ launchctlRunner?: LaunchctlRunner;
24
25
  }): string[];
25
26
  export {};
package/dist/install.js CHANGED
@@ -382,7 +382,7 @@ export function uninstall(options = {}) {
382
382
  messages.push(`[dry-run] would unload and remove ${paths.launchAgentFile}`);
383
383
  }
384
384
  else {
385
- stopLaunchAgent(paths.launchAgentFile);
385
+ stopLaunchAgent(paths.launchAgentFile, options.launchctlRunner);
386
386
  fs.rmSync(paths.launchAgentFile, { force: true });
387
387
  messages.push(`Removed launchd agent: ${paths.launchAgentFile}`);
388
388
  }
package/dist/kimi.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { KimiConfig, ProviderUsage } from "./types.js";
2
+ interface KimiQuotaDetail {
3
+ limit?: string | number;
4
+ used?: string | number;
5
+ remaining?: string | number;
6
+ resetTime?: string;
7
+ }
8
+ interface KimiWindowLimit {
9
+ window?: {
10
+ duration?: number;
11
+ timeUnit?: string;
12
+ };
13
+ detail?: KimiQuotaDetail;
14
+ }
15
+ interface KimiUsagesResponse {
16
+ user?: {
17
+ membership?: {
18
+ level?: string;
19
+ };
20
+ };
21
+ usage?: KimiQuotaDetail;
22
+ limits?: KimiWindowLimit[];
23
+ }
24
+ export declare function fetchKimiUsages(config: KimiConfig): Promise<KimiUsagesResponse>;
25
+ export declare function usageFromKimiUsages(usages: KimiUsagesResponse, options: {
26
+ observedAt?: string;
27
+ source: string;
28
+ }): ProviderUsage | null;
29
+ export declare function readClaudeLanesKimiConfig(configFile: string): KimiConfig | null;
30
+ export declare function resolveKimiConfig(config: KimiConfig | undefined, homeDir?: string): KimiConfig;
31
+ export declare function collectKimiUsage(config: KimiConfig): Promise<ProviderUsage>;
32
+ export {};
package/dist/kimi.js ADDED
@@ -0,0 +1,148 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { makeProviderUsage, normalizeWindow } from "./usage.js";
5
+ const DEFAULT_BASE_URL = "https://api.kimi.com/coding";
6
+ const KIMI_HOST_PATTERN = /kimi\.com/;
7
+ function asNumber(value) {
8
+ if (typeof value === "number" && Number.isFinite(value)) {
9
+ return value;
10
+ }
11
+ if (typeof value === "string" && value.trim() !== "") {
12
+ const parsed = Number(value);
13
+ return Number.isFinite(parsed) ? parsed : null;
14
+ }
15
+ return null;
16
+ }
17
+ // Kimi reports limit/used/remaining per window. Prefer the used/limit ratio;
18
+ // when limit is 0 or missing, derive the total from used + remaining instead of
19
+ // falling back to 0% while a real signal exists.
20
+ function percentFromDetail(detail) {
21
+ const limit = asNumber(detail.limit);
22
+ const used = asNumber(detail.used);
23
+ const remaining = asNumber(detail.remaining);
24
+ if (limit !== null && limit > 0 && used !== null) {
25
+ return Math.max(0, Math.min(100, (used / limit) * 100));
26
+ }
27
+ if (used !== null && remaining !== null && used + remaining > 0) {
28
+ return Math.max(0, Math.min(100, (used / (used + remaining)) * 100));
29
+ }
30
+ return null;
31
+ }
32
+ function fiveHourLimit(limits) {
33
+ const withWindow = limits.filter((entry) => typeof entry.window?.duration === "number");
34
+ const exact = withWindow.find((entry) => entry.window?.duration === 300 && entry.window?.timeUnit === "TIME_UNIT_MINUTE");
35
+ if (exact) {
36
+ return exact;
37
+ }
38
+ const sorted = [...withWindow].sort((left, right) => (left.window?.duration ?? Infinity) - (right.window?.duration ?? Infinity));
39
+ return sorted[0] ?? null;
40
+ }
41
+ export async function fetchKimiUsages(config) {
42
+ const baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
43
+ const url = `${baseUrl}/v1/usages`;
44
+ const response = await fetch(url, {
45
+ method: "GET",
46
+ headers: {
47
+ "Authorization": `Bearer ${config.apiKey}`,
48
+ "Content-Type": "application/json",
49
+ },
50
+ signal: AbortSignal.timeout(15_000),
51
+ });
52
+ if (!response.ok) {
53
+ throw new Error(`Kimi usages API returned HTTP ${response.status}`);
54
+ }
55
+ return (await response.json());
56
+ }
57
+ export function usageFromKimiUsages(usages, options) {
58
+ const shortWindow = fiveHourLimit(usages.limits ?? []);
59
+ const fiveHourPercent = shortWindow?.detail ? percentFromDetail(shortWindow.detail) : null;
60
+ const fiveHourReset = shortWindow?.detail?.resetTime;
61
+ const sevenDayPercent = usages.usage ? percentFromDetail(usages.usage) : null;
62
+ const sevenDayReset = usages.usage?.resetTime;
63
+ if (fiveHourPercent === null || !fiveHourReset || sevenDayPercent === null || !sevenDayReset) {
64
+ return null;
65
+ }
66
+ const fiveHour = normalizeWindow("five_hour", {
67
+ used_percent: fiveHourPercent,
68
+ resets_at: fiveHourReset,
69
+ }, 300);
70
+ const sevenDay = normalizeWindow("seven_day", {
71
+ used_percent: sevenDayPercent,
72
+ resets_at: sevenDayReset,
73
+ }, 10080);
74
+ if (!fiveHour || !sevenDay) {
75
+ return null;
76
+ }
77
+ return makeProviderUsage({
78
+ provider: "kimi",
79
+ source: options.source,
80
+ observedAt: options.observedAt,
81
+ planType: usages.user?.membership?.level ?? null,
82
+ fiveHour,
83
+ sevenDay,
84
+ });
85
+ }
86
+ // Claude Code lanes keep the Kimi credential in ~/.config/claude-lanes/config.env
87
+ // as CONFIG_<n>_BASE_URL / CONFIG_<n>_AUTH_TOKEN pairs. Scan every numbered entry
88
+ // and pick the one whose base URL points at kimi.com, so lane reordering does not
89
+ // break the lookup.
90
+ export function readClaudeLanesKimiConfig(configFile) {
91
+ let content;
92
+ try {
93
+ content = fs.readFileSync(configFile, "utf8");
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ const entries = new Map();
99
+ for (const line of content.split(/\r?\n/)) {
100
+ const trimmed = line.trim();
101
+ if (!trimmed || trimmed.startsWith("#")) {
102
+ continue;
103
+ }
104
+ const eq = trimmed.indexOf("=");
105
+ if (eq <= 0) {
106
+ continue;
107
+ }
108
+ entries.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1).trim());
109
+ }
110
+ const indices = new Set();
111
+ for (const key of entries.keys()) {
112
+ const match = key.match(/^CONFIG_(\d+)_(?:BASE_URL|AUTH_TOKEN)$/);
113
+ if (match) {
114
+ indices.add(Number(match[1]));
115
+ }
116
+ }
117
+ for (const index of [...indices].sort((left, right) => left - right)) {
118
+ const baseUrl = entries.get(`CONFIG_${index}_BASE_URL`);
119
+ const apiKey = entries.get(`CONFIG_${index}_AUTH_TOKEN`);
120
+ if (baseUrl && apiKey && KIMI_HOST_PATTERN.test(baseUrl)) {
121
+ return { baseUrl, apiKey };
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ // Resolution order: explicit ~/.coding-usage-bar/config.json kimi.* wins;
127
+ // otherwise fall back to the claude-lanes lane that targets kimi.com.
128
+ export function resolveKimiConfig(config, homeDir = os.homedir()) {
129
+ const lanes = readClaudeLanesKimiConfig(path.join(homeDir, ".config", "claude-lanes", "config.env"));
130
+ return {
131
+ baseUrl: config?.baseUrl || lanes?.baseUrl,
132
+ apiKey: config?.apiKey || lanes?.apiKey,
133
+ };
134
+ }
135
+ export async function collectKimiUsage(config) {
136
+ if (!config.apiKey) {
137
+ throw new Error("Kimi API key not configured. Edit ~/.coding-usage-bar/config.json to set kimi.apiKey, or configure a kimi.com lane in ~/.config/claude-lanes/config.env.");
138
+ }
139
+ const baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
140
+ const usages = await fetchKimiUsages(config);
141
+ const usage = usageFromKimiUsages(usages, {
142
+ source: `${baseUrl}/v1/usages`,
143
+ });
144
+ if (!usage) {
145
+ throw new Error("Kimi usages response did not contain expected 5h/7d quota data");
146
+ }
147
+ return usage;
148
+ }
package/dist/menubar.js CHANGED
@@ -23,6 +23,7 @@ const PROVIDER_ICON_ASSET = {
23
23
  glm: "provider-glm.png",
24
24
  deepseek: "provider-deepseek.png",
25
25
  minimax: "provider-minimax.png",
26
+ kimi: "provider-kimi.png",
26
27
  };
27
28
  const PROVIDER_ICON_DARK_ASSET = {
28
29
  glm: "provider-glm-dark.png",
@@ -33,6 +34,7 @@ const PROVIDER_ICON_FALLBACK = {
33
34
  glm: "cpu",
34
35
  deepseek: "bubble.left.and.bubble.right",
35
36
  minimax: "waveform.path.ecg",
37
+ kimi: "k.square.fill",
36
38
  };
37
39
  const TITLE_ICON_ASSET = "provider-marks.png";
38
40
  const ALERT_COLOR = "#D97757,#E8956E";
@@ -56,7 +58,7 @@ const STATE_PRIORITY = {
56
58
  const TEXT_COLOR = "#111827,#F9FAFB";
57
59
  const MUTED_COLOR = "#6B7280,#A1A1AA";
58
60
  const ROW_FONT = "Menlo";
59
- const TITLE_PROVIDER_ORDER = ["codex", "claude", "glm", "deepseek", "minimax"];
61
+ const TITLE_PROVIDER_ORDER = ["codex", "claude", "glm", "deepseek", "minimax", "kimi"];
60
62
  const TITLE_SEPARATOR = "│";
61
63
  const METER_WIDTH = 12;
62
64
  const ASSET_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "assets");
@@ -615,6 +617,9 @@ function issueLabel(code) {
615
617
  if (code === "MINIMAX_API_KEY_MISSING") {
616
618
  return "MiniMax API key not set";
617
619
  }
620
+ if (code === "KIMI_API_KEY_MISSING") {
621
+ return "Kimi API key not set";
622
+ }
618
623
  if (code === "USAGE_STALE") {
619
624
  return "Usage data is stale";
620
625
  }
package/dist/notifier.js CHANGED
@@ -53,7 +53,7 @@ export function markNotified(analysis, paths = buildPaths(), now = new Date()) {
53
53
  writeJsonAtomic(paths.notificationStateFile, state);
54
54
  }
55
55
  function notificationText(analysis) {
56
- const provider = analysis.provider === "claude" ? "Claude" : analysis.provider === "glm" ? "GLM" : "Codex";
56
+ const provider = analysis.provider === "claude" ? "Claude" : analysis.provider === "glm" ? "GLM" : analysis.provider === "minimax" ? "MiniMax" : analysis.provider === "deepseek" ? "DeepSeek" : analysis.provider === "kimi" ? "Kimi" : "Codex";
57
57
  const suffix = analysis.fiveHour ? `剩 ${formatDurationUntil(analysis.fiveHour.resetsAt)} 重置。` : "";
58
58
  if (analysis.state === "UNDER_BURN") {
59
59
  return {
package/dist/paths.js CHANGED
@@ -11,6 +11,7 @@ export function buildPaths(homeDir = os.homedir()) {
11
11
  glmDir: path.join(stateDir, "glm"),
12
12
  deepseekDir: path.join(stateDir, "deepseek"),
13
13
  minimaxDir: path.join(stateDir, "minimax"),
14
+ kimiDir: path.join(stateDir, "kimi"),
14
15
  notificationStateFile: path.join(stateDir, "notifications.json"),
15
16
  statusFile: path.join(stateDir, "status.json"),
16
17
  starPromptFile: path.join(stateDir, "star-prompt.json"),
@@ -33,6 +34,8 @@ function providerDir(paths, provider) {
33
34
  return paths.deepseekDir;
34
35
  if (provider === "minimax")
35
36
  return paths.minimaxDir;
37
+ if (provider === "kimi")
38
+ return paths.kimiDir;
36
39
  if (provider === "claude")
37
40
  return paths.claudeDir;
38
41
  return paths.codexDir;
package/dist/runtime.js CHANGED
@@ -6,13 +6,14 @@ import { collectCodexUsage } from "./codex.js";
6
6
  import { readJsonFile } from "./fs-util.js";
7
7
  import { collectGlmUsage } from "./glm.js";
8
8
  import { collectDeepseekUsage } from "./deepseek.js";
9
+ import { collectKimiUsage, resolveKimiConfig } from "./kimi.js";
9
10
  import { collectMinimaxUsage } from "./minimax.js";
10
11
  import { buildPaths } from "./paths.js";
11
12
  import { loadLatestUsage, saveUsage } from "./store.js";
12
13
  import { createStatusSnapshot, loadStatusSnapshot, refreshStatusSnapshotFreshness, saveStatusSnapshot, } from "./status.js";
13
14
  const FIXTURES_DIR = fileURLToPath(new URL("../fixtures", import.meta.url));
14
15
  export function loadFixtureUsages(fixturesDir) {
15
- const providers = ["claude", "codex", "glm", "deepseek", "minimax"];
16
+ const providers = ["claude", "codex", "glm", "deepseek", "minimax", "kimi"];
16
17
  return providers
17
18
  .map((provider) => readJsonFile(path.join(fixturesDir, provider, "latest.json")))
18
19
  .filter((item) => item !== null);
@@ -181,6 +182,44 @@ export async function collectLocalState(options = {}) {
181
182
  }
182
183
  }
183
184
  }
185
+ if (monitored.has("kimi")) {
186
+ const kimiConfig = resolveKimiConfig(config.kimi, paths.homeDir);
187
+ if (!kimiConfig.apiKey) {
188
+ issues.push({
189
+ provider: "kimi",
190
+ severity: "warning",
191
+ code: "KIMI_API_KEY_MISSING",
192
+ message: "Kimi API key not configured. Edit ~/.coding-usage-bar/config.json to set kimi.apiKey, or configure a kimi.com lane in ~/.config/claude-lanes/config.env.",
193
+ });
194
+ }
195
+ else {
196
+ try {
197
+ const kimi = await collectKimiUsage(kimiConfig);
198
+ saveUsage(kimi, paths);
199
+ usages.push(kimi);
200
+ }
201
+ catch (error) {
202
+ const cached = loadLatestUsage("kimi", paths);
203
+ if (cached) {
204
+ usages.push(cached);
205
+ issues.push({
206
+ provider: "kimi",
207
+ severity: "warning",
208
+ code: "KIMI_USING_CACHE",
209
+ message: `Kimi live usage unavailable; using cached usage from ${cached.observedAt}. ${error instanceof Error ? error.message : String(error)}`,
210
+ });
211
+ }
212
+ else {
213
+ issues.push({
214
+ provider: "kimi",
215
+ severity: "error",
216
+ code: "KIMI_USAGE_MISSING",
217
+ message: error instanceof Error ? error.message : String(error),
218
+ });
219
+ }
220
+ }
221
+ }
222
+ }
184
223
  return { usages, issues };
185
224
  }
186
225
  export async function collectLocalUsages(options = {}) {
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ProviderId = "claude" | "codex" | "glm" | "deepseek" | "minimax";
1
+ export type ProviderId = "claude" | "codex" | "glm" | "deepseek" | "minimax" | "kimi";
2
2
  export type WindowName = "five_hour" | "seven_day";
3
3
  export type BurnProfile = "low" | "high";
4
4
  export type BurnState = "RAW" | "UNDER_BURN" | "ON_TRACK" | "OVER_BURN" | "LIMIT_RISK";
@@ -51,6 +51,10 @@ export interface MinimaxConfig {
51
51
  region?: "cn" | "global";
52
52
  apiKey?: string;
53
53
  }
54
+ export interface KimiConfig {
55
+ baseUrl?: string;
56
+ apiKey?: string;
57
+ }
54
58
  export interface RuntimePaths {
55
59
  homeDir: string;
56
60
  stateDir: string;
@@ -60,6 +64,7 @@ export interface RuntimePaths {
60
64
  glmDir: string;
61
65
  deepseekDir: string;
62
66
  minimaxDir: string;
67
+ kimiDir: string;
63
68
  notificationStateFile: string;
64
69
  statusFile: string;
65
70
  starPromptFile: string;
@@ -98,4 +103,5 @@ export interface BurnConfig {
98
103
  glm?: GlmConfig;
99
104
  deepseek?: DeepseekConfig;
100
105
  minimax?: MinimaxConfig;
106
+ kimi?: KimiConfig;
101
107
  }
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-usage-bar",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "files": [
35
35
  "assets",
36
+ "docs/assets/demo.gif",
36
37
  "docs/assets/hero.png",
37
38
  "dist",
38
39
  "fixtures",