kimi-quota-line 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial public release of `glm-quota-line`
6
+ - Added Claude Code status line integration with `install` and `uninstall`
7
+ - Added `text`, `compact`, and `bar` styles
8
+ - Added `config set` and `config show`
9
+ - Added official auth variable compatibility for `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_BASE_URL`
10
+ - Added 5 minute lazy refresh with per-session first refresh
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 caojian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # kimi-quota-line
2
+
3
+ English | [中文文档](README.zh-CN.md)
4
+
5
+ A lightweight CLI for Claude Code status bar to display Moonshot Kimi Code usage quota.
6
+
7
+ Inspired by [glm-quota-line](https://github.com/deluo/glm-quota-line), modified to support Kimi.
8
+
9
+ It fetches the quota API, caches successful results, and outputs a single line suitable for display in the status bar.
10
+
11
+ ## Features
12
+
13
+ - Compatible with Claude Code `statusLine.command`
14
+ - Uses API Key for Kimi authentication (via `ANTHROPIC_AUTH_TOKEN`)
15
+ - Force refresh on first session, then cache for 5 minutes
16
+ - Supports three styles: `text`, `compact`, and `bar`
17
+ - Auto-install and uninstall Claude Code status bar configuration
18
+
19
+ ## Display Styles
20
+
21
+ ### `bar` (Default)
22
+
23
+ ```text
24
+ Kimi ■□□□□□□□□□ 91% | 14:47
25
+ ```
26
+
27
+ ### `text`
28
+
29
+ ```text
30
+ Kimi | 91% left | reset 14:47
31
+ ```
32
+
33
+ ### `compact`
34
+
35
+ ```text
36
+ Kimi 91% | 14:47
37
+ ```
38
+
39
+ Legend:
40
+
41
+ - `■` = Used
42
+ - `□` = Remaining
43
+
44
+ ## Installation
45
+
46
+ Install from local repository:
47
+
48
+ ```bash
49
+ npm install -g .
50
+ ```
51
+
52
+ Or install from npm (when published):
53
+
54
+ ```bash
55
+ npm install -g kimi-quota-line
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ Install Claude Code status bar:
61
+
62
+ ```bash
63
+ kimi-quota-line install
64
+ ```
65
+
66
+ Switch styles (default is `bar`):
67
+
68
+ ```bash
69
+ kimi-quota-line config set style text
70
+ kimi-quota-line config set style compact
71
+ kimi-quota-line config set style bar
72
+ ```
73
+
74
+ Switch display modes:
75
+
76
+ ```bash
77
+ kimi-quota-line config set display left # Show remaining percentage
78
+ kimi-quota-line config set display used # Show used percentage
79
+ kimi-quota-line config set display both # Show both
80
+ ```
81
+
82
+ View current configuration:
83
+
84
+ ```bash
85
+ kimi-quota-line config show
86
+ ```
87
+
88
+ Uninstall status bar:
89
+
90
+ ```bash
91
+ kimi-quota-line uninstall
92
+ ```
93
+
94
+ ## Environment Variables
95
+
96
+ Required:
97
+
98
+ - `ANTHROPIC_AUTH_TOKEN` - Claude Code official environment variable. Set this to your Kimi Code API Key. If you already configured Claude Code with this variable, no additional setup is needed:
99
+ ```bash
100
+ export ANTHROPIC_BASE_URL=https://api.kimi.com/coding/
101
+ export ANTHROPIC_AUTH_TOKEN=sk-your-kimi-api-key
102
+ export ANTHROPIC_MODEL="kimi-k2.5"
103
+ export ANTHROPIC_DEFAULT_OPUS_MODEL="kimi-k2.5"
104
+ export ANTHROPIC_DEFAULT_SONNET_MODEL="kimi-k2.5"
105
+ export ANTHROPIC_DEFAULT_HAIKU_MODEL="kimi-k2.5"
106
+ export CLAUDE_CODE_SUBAGENT_MODEL="kimi-k2.5"
107
+ export ENABLE_TOOL_SEARCH="false"
108
+ claude
109
+ ```
110
+
111
+ Optional:
112
+
113
+ - `ANTHROPIC_BASE_URL` - Set to `https://api.kimi.com/coding/v1` for Kimi Code platform
114
+ - `KIMI_API_KEY` - Alternative way, same as `ANTHROPIC_AUTH_TOKEN`
115
+
116
+ ## Getting API Key
117
+
118
+ 1. Visit [Kimi Code](https://kimi.com)
119
+ 2. Go to Settings
120
+ 3. Find API Key management and create a new API Key
121
+ 4. Set it as environment variable: `export ANTHROPIC_AUTH_TOKEN="your-api-key"`
122
+
123
+ ## Notes
124
+
125
+ - Displays a single line by default, suitable for bottom status bar
126
+ - Shows `Kimi | auth expired` when authentication fails
127
+ - Shows `Kimi | quota unavailable` when API is unavailable
128
+ - `install` won't overwrite existing non-tool `statusLine` by default
129
+ - `install --force` overwrites and backs up original config, `uninstall` can restore it
130
+ - Supports Kimi Code platform API (`https://api.kimi.com/coding/v1`)
@@ -0,0 +1,130 @@
1
+ # kimi-quota-line
2
+
3
+ [English](README.md) | 中文文档
4
+
5
+ `kimi-quota-line` 是一个给 Claude Code 底部状态栏使用的轻量 CLI,用来显示 Moonshot Kimi Code 的使用配额状态。
6
+
7
+ 本项目借鉴自 [glm-quota-line](https://github.com/deluo/glm-quota-line) 并修改以支持 Kimi。
8
+
9
+ 它会读取配额接口、缓存成功结果,并输出一行适合状态栏展示的短文本。
10
+
11
+ ## 功能
12
+
13
+ - 适配 Claude Code `statusLine.command`
14
+ - 通过 API Key 进行 Kimi 身份验证(使用 `ANTHROPIC_AUTH_TOKEN`)
15
+ - 新会话首次强制刷新,之后 5 分钟内走缓存
16
+ - 支持 `text`、`compact`、`bar` 三种样式
17
+ - 支持自动安装和卸载 Claude Code 的状态栏配置
18
+
19
+ ## 展示效果
20
+
21
+ ### `bar`(默认)
22
+
23
+ ```text
24
+ Kimi ■□□□□□□□□□ 91% | 14:47
25
+ ```
26
+
27
+ ### `text`
28
+
29
+ ```text
30
+ Kimi | 91% left | reset 14:47
31
+ ```
32
+
33
+ ### `compact`
34
+
35
+ ```text
36
+ Kimi 91% | 14:47
37
+ ```
38
+
39
+ 说明:
40
+
41
+ - `■` 表示已用
42
+ - `□` 表示未用
43
+
44
+ ## 安装
45
+
46
+ 本地仓库安装:
47
+
48
+ ```bash
49
+ npm install -g .
50
+ ```
51
+
52
+ npm 发布后安装:
53
+
54
+ ```bash
55
+ npm install -g kimi-quota-line
56
+ ```
57
+
58
+ ## 使用
59
+
60
+ 安装 Claude Code 状态栏:
61
+
62
+ ```bash
63
+ kimi-quota-line install
64
+ ```
65
+
66
+ 切换样式(默认为 `bar`):
67
+
68
+ ```bash
69
+ kimi-quota-line config set style text
70
+ kimi-quota-line config set style compact
71
+ kimi-quota-line config set style bar
72
+ ```
73
+
74
+ 切换显示模式:
75
+
76
+ ```bash
77
+ kimi-quota-line config set display left # 显示剩余百分比
78
+ kimi-quota-line config set display used # 显示已用百分比
79
+ kimi-quota-line config set display both # 两者都显示
80
+ ```
81
+
82
+ 查看当前配置:
83
+
84
+ ```bash
85
+ kimi-quota-line config show
86
+ ```
87
+
88
+ 卸载状态栏:
89
+
90
+ ```bash
91
+ kimi-quota-line uninstall
92
+ ```
93
+
94
+ ## 环境变量
95
+
96
+ 必需:
97
+
98
+ - `ANTHROPIC_AUTH_TOKEN` - Claude Code 官方环境变量,设置为你的 Kimi Code API Key。如果你配置 Claude Code 时已经使用了此变量,则无需重复设置:
99
+ ```bash
100
+ export ANTHROPIC_BASE_URL=https://api.kimi.com/coding/
101
+ export ANTHROPIC_AUTH_TOKEN=sk-your-kimi-api-key
102
+ export ANTHROPIC_MODEL="kimi-k2.5"
103
+ export ANTHROPIC_DEFAULT_OPUS_MODEL="kimi-k2.5"
104
+ export ANTHROPIC_DEFAULT_SONNET_MODEL="kimi-k2.5"
105
+ export ANTHROPIC_DEFAULT_HAIKU_MODEL="kimi-k2.5"
106
+ export CLAUDE_CODE_SUBAGENT_MODEL="kimi-k2.5"
107
+ export ENABLE_TOOL_SEARCH="false"
108
+ claude
109
+ ```
110
+
111
+ 可选:
112
+
113
+ - `ANTHROPIC_BASE_URL` - 设置为 `https://api.kimi.com/coding/v1` (Kimi Code 平台)
114
+ - `KIMI_API_KEY` - 备选方式,与 `ANTHROPIC_AUTH_TOKEN` 作用相同
115
+
116
+ ## 获取 API Key
117
+
118
+ 1. 访问 [Kimi Code](https://kimi.com)
119
+ 2. 进入设置页面
120
+ 3. 找到 API Key 管理,创建新的 API Key
121
+ 4. 将 API Key 设置为环境变量:`export ANTHROPIC_AUTH_TOKEN="your-api-key"`
122
+
123
+ ## 说明
124
+
125
+ - 默认只显示一行文本,适合底部状态栏
126
+ - 鉴权失效时显示 `Kimi | auth expired`
127
+ - 接口异常时显示 `Kimi | quota unavailable`
128
+ - `install` 默认不会覆盖已有的非本工具 `statusLine`
129
+ - `install --force` 会覆盖并备份原配置,`uninstall` 时可恢复
130
+ - 支持 Kimi Code 平台 API (`https://api.kimi.com/coding/v1`)
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "kimi-quota-line",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code status line helper for Kimi Code usage quota.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "MaBing",
8
+ "preferGlobal": true,
9
+ "keywords": [
10
+ "claude-code",
11
+ "statusline",
12
+ "kimi",
13
+ "moonshot",
14
+ "quota",
15
+ "cli"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/usernameisnull/kimi-quote-line.git"
20
+ },
21
+ "homepage": "https://github.com/usernameisnull/kimi-quote-line#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/usernameisnull/kimi-quote-line/issues"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "scripts",
31
+ "README.md",
32
+ "README.zh-CN.md",
33
+ "LICENSE",
34
+ "CHANGELOG.md"
35
+ ],
36
+ "bin": {
37
+ "kimi-quota-line": "src/cli.js"
38
+ },
39
+ "scripts": {
40
+ "start": "node ./src/cli.js",
41
+ "test": "node --test"
42
+ },
43
+ "engines": {
44
+ "node": ">=18"
45
+ }
46
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "../src/cli.js";
package/src/cache.js ADDED
@@ -0,0 +1,81 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ function isSuccessCacheShape(value) {
5
+ if (!value || value.kind !== "success" || typeof value.level !== "string") {
6
+ return false;
7
+ }
8
+
9
+ if (!Number.isFinite(value.nextResetTime)) {
10
+ return false;
11
+ }
12
+
13
+ if (value.display === "percent") {
14
+ return Number.isFinite(value.leftPercent);
15
+ }
16
+
17
+ if (value.display === "absolute") {
18
+ return Number.isFinite(value.remaining) && Number.isFinite(value.total);
19
+ }
20
+
21
+ return false;
22
+ }
23
+
24
+ function isValidThreadId(value) {
25
+ return value === undefined || typeof value === "string";
26
+ }
27
+
28
+ export async function readCache(cacheFilePath) {
29
+ try {
30
+ const raw = await fs.readFile(cacheFilePath, "utf8");
31
+ const parsed = JSON.parse(raw);
32
+
33
+ if (!parsed || typeof parsed !== "object") {
34
+ return null;
35
+ }
36
+
37
+ if (
38
+ !Number.isFinite(parsed.savedAt) ||
39
+ !isValidThreadId(parsed.threadId) ||
40
+ !isSuccessCacheShape(parsed.result)
41
+ ) {
42
+ return null;
43
+ }
44
+
45
+ return parsed;
46
+ } catch (error) {
47
+ if (error && error.code === "ENOENT") {
48
+ return null;
49
+ }
50
+
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export async function readFreshCache(cacheFilePath, ttlMs, threadId = "", now = Date.now()) {
56
+ const cached = await readCache(cacheFilePath);
57
+ if (!cached) {
58
+ return null;
59
+ }
60
+
61
+ if (threadId && cached.threadId !== threadId) {
62
+ return null;
63
+ }
64
+
65
+ return now - cached.savedAt <= ttlMs ? cached : null;
66
+ }
67
+
68
+ export async function writeSuccessCache(cacheFilePath, result, threadId = "", now = Date.now()) {
69
+ const payload = JSON.stringify(
70
+ {
71
+ savedAt: now,
72
+ threadId,
73
+ result
74
+ },
75
+ null,
76
+ 2
77
+ );
78
+
79
+ await fs.mkdir(path.dirname(cacheFilePath), { recursive: true });
80
+ await fs.writeFile(cacheFilePath, payload, "utf8");
81
+ }
package/src/cli.js ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { loadConfig } from "./config.js";
4
+ import { formatStatus } from "./formatStatus.js";
5
+ import { parseArgs } from "./parseArgs.js";
6
+ import { getSessionId, readStatusLineInput } from "./readStatusLineInput.js";
7
+ import { runQuotaLine } from "./runQuotaLine.js";
8
+ import {
9
+ getToolConfigPath,
10
+ installClaudeStatusLine,
11
+ readToolConfig,
12
+ setToolConfigValue,
13
+ uninstallClaudeStatusLine
14
+ } from "./userConfig.js";
15
+
16
+ function printHelp() {
17
+ process.stdout.write(`kimi-quota-line
18
+
19
+ Usage:
20
+ kimi-quota-line [--style text|compact|bar] [--display left|used|both]
21
+ kimi-quota-line install [--force]
22
+ kimi-quota-line uninstall
23
+ kimi-quota-line config set style <text|compact|bar>
24
+ kimi-quota-line config set display <left|used|both>
25
+ kimi-quota-line config show
26
+
27
+ Environment:
28
+ KIMI_AUTHORIZATION # Kimi 的 Authorization token (必需)
29
+ ANTHROPIC_BASE_URL
30
+ KIMI_DISPLAY_MODE
31
+ KIMI_STYLE
32
+ KIMI_BAR_WIDTH
33
+ KIMI_TIMEOUT_MS
34
+ KIMI_CACHE_TTL_MS
35
+ `);
36
+ }
37
+
38
+ function validateStyle(value) {
39
+ return value === "text" || value === "compact" || value === "bar";
40
+ }
41
+
42
+ function validateDisplay(value) {
43
+ return value === "left" || value === "used" || value === "both";
44
+ }
45
+
46
+ async function handleCommand(args) {
47
+ const [command, subcommand, key, value] = args.positionals;
48
+
49
+ if (command === "install") {
50
+ const result = await installClaudeStatusLine(undefined, undefined, undefined, {
51
+ force: Boolean(args.force)
52
+ });
53
+ if (!result.installed && result.reason === "unmanaged_exists") {
54
+ process.stdout.write(
55
+ `Skipped install because Claude Code already has an unmanaged statusLine.\nsettings: ${result.settingsPath}\nRun 'kimi-quota-line install --force' to replace it and back it up.\n`
56
+ );
57
+ return true;
58
+ }
59
+
60
+ process.stdout.write(
61
+ `Installed Claude Code status line.\nsettings: ${result.settingsPath}\ncommand: ${result.command}\n`
62
+ );
63
+ return true;
64
+ }
65
+
66
+ if (command === "uninstall") {
67
+ const result = await uninstallClaudeStatusLine();
68
+ if (result.removed) {
69
+ process.stdout.write(`Removed Claude Code status line.\nsettings: ${result.settingsPath}\n`);
70
+ return true;
71
+ }
72
+
73
+ if (result.reason === "unmanaged") {
74
+ process.stdout.write(
75
+ `Skipped uninstall because current statusLine is not managed by kimi-quota-line.\nsettings: ${result.settingsPath}\n`
76
+ );
77
+ return true;
78
+ }
79
+
80
+ process.stdout.write(`No Claude Code status line was configured.\nsettings: ${result.settingsPath}\n`);
81
+ return true;
82
+ }
83
+
84
+ if (command === "config" && subcommand === "show") {
85
+ const config = await readToolConfig();
86
+ process.stdout.write(`${JSON.stringify(config, null, 2)}\n`);
87
+ return true;
88
+ }
89
+
90
+ if (command === "config" && subcommand === "set") {
91
+ if (key === "style") {
92
+ if (!validateStyle(value)) {
93
+ process.stdout.write("Invalid style. Use: text, compact, or bar.\n");
94
+ return true;
95
+ }
96
+
97
+ const config = await setToolConfigValue("style", value);
98
+ process.stdout.write(`Saved style=${config.style}\nconfig: ${getToolConfigPath()}\n`);
99
+ return true;
100
+ }
101
+
102
+ if (key === "display") {
103
+ if (!validateDisplay(value)) {
104
+ process.stdout.write("Invalid display. Use: left, used, or both.\n");
105
+ return true;
106
+ }
107
+
108
+ const config = await setToolConfigValue("displayMode", value);
109
+ process.stdout.write(`Saved display=${config.displayMode}\nconfig: ${getToolConfigPath()}\n`);
110
+ return true;
111
+ }
112
+
113
+ process.stdout.write("Supported config keys: style, display\n");
114
+ return true;
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ async function main() {
121
+ try {
122
+ const args = parseArgs();
123
+ if (args.help) {
124
+ printHelp();
125
+ return;
126
+ }
127
+
128
+ if (await handleCommand(args)) {
129
+ return;
130
+ }
131
+
132
+ const statusLineInput = await readStatusLineInput();
133
+ const userConfig = await readToolConfig();
134
+
135
+ // Claude Code passes env vars via statusLineInput.env
136
+ const envVars = statusLineInput?.env || process.env;
137
+ const config = {
138
+ ...loadConfig(envVars),
139
+ ...(validateStyle(userConfig.style) ? { style: userConfig.style } : {}),
140
+ ...(validateDisplay(userConfig.displayMode) ? { displayMode: userConfig.displayMode } : {}),
141
+ ...args,
142
+ threadId: getSessionId(statusLineInput)
143
+ };
144
+ const finalResult = await runQuotaLine(config);
145
+
146
+ process.stdout.write(
147
+ `${formatStatus(finalResult, {
148
+ displayMode: config.displayMode,
149
+ style: config.style,
150
+ barWidth: config.barWidth
151
+ })}\n`
152
+ );
153
+ } catch {
154
+ process.stdout.write("Kimi | quota unavailable\n");
155
+ }
156
+ }
157
+
158
+ await main();
package/src/config.js ADDED
@@ -0,0 +1,71 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ const DEFAULT_QUOTA_URL = "https://api.kimi.com/coding/v1/usages";
5
+ const DEFAULT_TIMEOUT_MS = 5000;
6
+ const DEFAULT_CACHE_TTL_MS = 300_000;
7
+ const DEFAULT_DISPLAY_MODE = "left";
8
+ const DEFAULT_STYLE = "bar";
9
+ const DEFAULT_BAR_WIDTH = 10;
10
+
11
+ function parsePositiveInt(value, fallback) {
12
+ if (value === undefined || value === null || value === "") {
13
+ return fallback;
14
+ }
15
+
16
+ const parsed = Number.parseInt(String(value), 10);
17
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
18
+ }
19
+
20
+ function getCacheRoot() {
21
+ if (process.platform === "darwin") {
22
+ return path.join(os.homedir(), "Library", "Caches");
23
+ }
24
+
25
+ if (process.platform === "win32") {
26
+ return process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
27
+ }
28
+
29
+ return process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
30
+ }
31
+
32
+ function deriveQuotaUrl(baseUrl) {
33
+ if (!baseUrl) {
34
+ return "";
35
+ }
36
+
37
+ try {
38
+ const parsedBaseUrl = new URL(baseUrl);
39
+
40
+ // Kimi Code platform
41
+ if (parsedBaseUrl.host.includes("api.kimi.com")) {
42
+ return `${baseUrl.replace(/\/$/, "")}/usages`;
43
+ }
44
+ } catch {
45
+ return "";
46
+ }
47
+
48
+ return "";
49
+ }
50
+
51
+ export function loadConfig(env = process.env) {
52
+ const cacheRoot = getCacheRoot();
53
+ const anthropicBaseUrl = env.ANTHROPIC_BASE_URL || "";
54
+ const derivedQuotaUrl = deriveQuotaUrl(anthropicBaseUrl);
55
+
56
+ // Use ANTHROPIC_AUTH_TOKEN (Claude Code official) as Kimi API Key
57
+ const apiKey = env.ANTHROPIC_AUTH_TOKEN || env.KIMI_API_KEY || env.KIMI_AUTHORIZATION || "";
58
+
59
+ return {
60
+ quotaUrl: derivedQuotaUrl || DEFAULT_QUOTA_URL,
61
+ apiKey,
62
+ anthropicBaseUrl,
63
+ timeoutMs: parsePositiveInt(env.KIMI_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
64
+ cacheTtlMs: parsePositiveInt(env.KIMI_CACHE_TTL_MS, DEFAULT_CACHE_TTL_MS),
65
+ displayMode: env.KIMI_DISPLAY_MODE || DEFAULT_DISPLAY_MODE,
66
+ style: env.KIMI_STYLE || DEFAULT_STYLE,
67
+ barWidth: parsePositiveInt(env.KIMI_BAR_WIDTH, DEFAULT_BAR_WIDTH),
68
+ cacheFilePath: path.join(cacheRoot, "kimi-quota-line", "cache.json"),
69
+ threadId: env.CODEX_THREAD_ID || ""
70
+ };
71
+ }
@@ -0,0 +1,29 @@
1
+ export async function fetchQuota(config, fetchImpl = globalThis.fetch) {
2
+ if (typeof fetchImpl !== "function") {
3
+ return { kind: "unavailable" };
4
+ }
5
+
6
+ try {
7
+ const response = await fetchImpl(config.quotaUrl, {
8
+ method: "GET",
9
+ headers: {
10
+ Accept: "application/json",
11
+ Authorization: `Bearer ${config.apiKey}`
12
+ },
13
+ signal: AbortSignal.timeout(config.timeoutMs)
14
+ });
15
+
16
+ const text = await response.text();
17
+ let json;
18
+
19
+ try {
20
+ json = JSON.parse(text);
21
+ } catch {
22
+ return { kind: "unavailable" };
23
+ }
24
+
25
+ return { kind: "json", status: response.status, json };
26
+ } catch {
27
+ return { kind: "unavailable" };
28
+ }
29
+ }
@@ -0,0 +1,156 @@
1
+ function padTwoDigits(value) {
2
+ return String(value).padStart(2, "0");
3
+ }
4
+
5
+ function formatLevel(level) {
6
+ if (!level) {
7
+ return "Kimi";
8
+ }
9
+
10
+ return `Kimi ${level.charAt(0).toUpperCase()}${level.slice(1)}`;
11
+ }
12
+
13
+ function formatCompactLabel() {
14
+ return "Kimi";
15
+ }
16
+
17
+ function formatResetTime(timestampMs) {
18
+ const date = new Date(timestampMs);
19
+ if (Number.isNaN(date.getTime())) {
20
+ return null;
21
+ }
22
+
23
+ return `${padTwoDigits(date.getHours())}:${padTwoDigits(date.getMinutes())}`;
24
+ }
25
+
26
+ function normalizeDisplayMode(displayMode) {
27
+ return displayMode === "used" || displayMode === "both" ? displayMode : "left";
28
+ }
29
+
30
+ function normalizeStyle(style) {
31
+ return style === "compact" || style === "bar" ? style : "text";
32
+ }
33
+
34
+ function normalizeBarWidth(barWidth) {
35
+ if (!Number.isFinite(barWidth)) {
36
+ return 10;
37
+ }
38
+
39
+ return Math.min(20, Math.max(5, Math.round(barWidth)));
40
+ }
41
+
42
+ function buildBar(usedPercent, barWidth) {
43
+ const safePercent = Math.min(100, Math.max(0, usedPercent));
44
+ const width = normalizeBarWidth(barWidth);
45
+ let filled;
46
+ if (safePercent <= 0) {
47
+ filled = 0;
48
+ } else if (safePercent >= 100) {
49
+ filled = width;
50
+ } else {
51
+ filled = Math.min(width - 1, Math.max(1, Math.floor((safePercent / 100) * width)));
52
+ }
53
+ return `${"■".repeat(filled)}${"□".repeat(width - filled)}`;
54
+ }
55
+
56
+ function formatPercentStatus(result, resetTime, displayMode) {
57
+ const mode = normalizeDisplayMode(displayMode);
58
+
59
+ if (mode === "used" && Number.isFinite(result.usedPercent)) {
60
+ return `${formatLevel(result.level)} | ${result.usedPercent}% used | reset ${resetTime}`;
61
+ }
62
+
63
+ if (
64
+ mode === "both" &&
65
+ Number.isFinite(result.leftPercent) &&
66
+ Number.isFinite(result.usedPercent)
67
+ ) {
68
+ return `${formatLevel(result.level)} | ${result.leftPercent}% left ${result.usedPercent}% used | reset ${resetTime}`;
69
+ }
70
+
71
+ if (Number.isFinite(result.leftPercent)) {
72
+ return `${formatLevel(result.level)} | ${result.leftPercent}% left | reset ${resetTime}`;
73
+ }
74
+
75
+ return "Kimi | quota unavailable";
76
+ }
77
+
78
+ function formatAbsoluteStatus(result, resetTime, displayMode, style, barWidth) {
79
+ const mode = normalizeDisplayMode(displayMode);
80
+ const usedValue =
81
+ Number.isFinite(result.total) && Number.isFinite(result.remaining)
82
+ ? result.total - result.remaining
83
+ : null;
84
+ const leftPercent =
85
+ Number.isFinite(result.total) && result.total > 0 && Number.isFinite(result.remaining)
86
+ ? Math.round((result.remaining / result.total) * 100)
87
+ : null;
88
+ const usedPercent =
89
+ Number.isFinite(leftPercent) ? Math.max(0, Math.min(100, 100 - leftPercent)) : null;
90
+
91
+ if (style === "compact" && Number.isFinite(leftPercent)) {
92
+ return `${formatCompactLabel()} ${leftPercent}% | ${resetTime}`;
93
+ }
94
+
95
+ if (style === "bar" && Number.isFinite(leftPercent) && Number.isFinite(usedPercent)) {
96
+ return `${formatLevel(result.level)} ${buildBar(usedPercent, barWidth)} ${leftPercent}% | ${resetTime}`;
97
+ }
98
+
99
+ if (mode === "used" && Number.isFinite(usedValue) && Number.isFinite(result.total)) {
100
+ return `${formatLevel(result.level)} | used ${usedValue}/${result.total} | reset ${resetTime}`;
101
+ }
102
+
103
+ if (
104
+ mode === "both" &&
105
+ Number.isFinite(result.remaining) &&
106
+ Number.isFinite(result.total) &&
107
+ Number.isFinite(usedValue)
108
+ ) {
109
+ return `${formatLevel(result.level)} | left ${result.remaining}/${result.total} used ${usedValue}/${result.total} | reset ${resetTime}`;
110
+ }
111
+
112
+ if (Number.isFinite(result.remaining) && Number.isFinite(result.total)) {
113
+ return `${formatLevel(result.level)} | left ${result.remaining}/${result.total} | reset ${resetTime}`;
114
+ }
115
+
116
+ return "Kimi | quota unavailable";
117
+ }
118
+
119
+ export function formatStatus(result, options = {}) {
120
+ if (!result || typeof result !== "object") {
121
+ return "Kimi | quota unavailable";
122
+ }
123
+
124
+ if (result.kind === "auth_error") {
125
+ return "Kimi | auth expired";
126
+ }
127
+
128
+ if (result.kind !== "success") {
129
+ return "Kimi | quota unavailable";
130
+ }
131
+
132
+ const resetTime = formatResetTime(result.nextResetTime);
133
+ if (!resetTime) {
134
+ return "Kimi | quota unavailable";
135
+ }
136
+
137
+ const style = normalizeStyle(options.style);
138
+
139
+ if (result.display === "percent") {
140
+ if (style === "compact" && Number.isFinite(result.leftPercent)) {
141
+ return `${formatCompactLabel()} ${result.leftPercent}% | ${resetTime}`;
142
+ }
143
+
144
+ if (style === "bar" && Number.isFinite(result.leftPercent) && Number.isFinite(result.usedPercent)) {
145
+ return `${formatLevel(result.level)} ${buildBar(result.usedPercent, options.barWidth)} ${result.leftPercent}% | ${resetTime}`;
146
+ }
147
+
148
+ return formatPercentStatus(result, resetTime, options.displayMode);
149
+ }
150
+
151
+ if (result.display === "absolute") {
152
+ return formatAbsoluteStatus(result, resetTime, options.displayMode, style, options.barWidth);
153
+ }
154
+
155
+ return "Kimi | quota unavailable";
156
+ }
@@ -0,0 +1,54 @@
1
+ function takeValue(args, index) {
2
+ const current = args[index];
3
+ const next = args[index + 1];
4
+
5
+ if (current.includes("=")) {
6
+ return current.split(/=(.*)/s)[1] ?? "";
7
+ }
8
+
9
+ return next ?? "";
10
+ }
11
+
12
+ export function parseArgs(argv = process.argv.slice(2)) {
13
+ const options = {};
14
+ const positionals = [];
15
+
16
+ for (let index = 0; index < argv.length; index += 1) {
17
+ const arg = argv[index];
18
+
19
+ if (!arg.startsWith("-")) {
20
+ positionals.push(arg);
21
+ continue;
22
+ }
23
+
24
+ if (arg === "--help" || arg === "-h") {
25
+ options.help = true;
26
+ continue;
27
+ }
28
+
29
+ if (arg === "--force") {
30
+ options.force = true;
31
+ continue;
32
+ }
33
+
34
+ if (arg === "--style" || arg.startsWith("--style=")) {
35
+ options.style = takeValue(argv, index);
36
+ if (!arg.includes("=")) {
37
+ index += 1;
38
+ }
39
+ continue;
40
+ }
41
+
42
+ if (arg === "--display" || arg.startsWith("--display=")) {
43
+ options.displayMode = takeValue(argv, index);
44
+ if (!arg.includes("=")) {
45
+ index += 1;
46
+ }
47
+ continue;
48
+ }
49
+
50
+ }
51
+
52
+ options.positionals = positionals;
53
+ return options;
54
+ }
@@ -0,0 +1,143 @@
1
+ function asFiniteNumber(value) {
2
+ if (typeof value === "string") {
3
+ const parsed = Number.parseFloat(value);
4
+ return Number.isFinite(parsed) ? parsed : null;
5
+ }
6
+ return Number.isFinite(value) ? value : null;
7
+ }
8
+
9
+ function isAuthFailureMessage(value) {
10
+ if (typeof value !== "string") {
11
+ return false;
12
+ }
13
+
14
+ return /authorization|身份验证|鉴权|auth|令牌|token|过期|验证不正确|invalid|unauthorized/i.test(value);
15
+ }
16
+
17
+ function parseResetTime(resetTimeValue) {
18
+ if (typeof resetTimeValue === "string") {
19
+ const date = new Date(resetTimeValue);
20
+ if (!Number.isNaN(date.getTime())) {
21
+ return date.getTime();
22
+ }
23
+ }
24
+ return asFiniteNumber(resetTimeValue);
25
+ }
26
+
27
+ export function parseQuotaResponse(response) {
28
+ if (!response || response.kind !== "json") {
29
+ return { kind: "unavailable" };
30
+ }
31
+
32
+ const payload = response.json;
33
+ if (!payload || typeof payload !== "object") {
34
+ return { kind: "unavailable" };
35
+ }
36
+
37
+ // Handle error response
38
+ if (payload.error) {
39
+ if (payload.error.code === "invalid_auth" ||
40
+ payload.error.code === "unauthorized" ||
41
+ isAuthFailureMessage(payload.error.message)) {
42
+ return { kind: "auth_error" };
43
+ }
44
+ return { kind: "unavailable" };
45
+ }
46
+
47
+ // Handle Kimi error format: { code: "unauthenticated", details: [...] }
48
+ if (payload.code === "unauthenticated" ||
49
+ payload.code === "unauthorized" ||
50
+ isAuthFailureMessage(payload.code)) {
51
+ return { kind: "auth_error" };
52
+ }
53
+
54
+ // Kimi Code platform format: { "usage": {...}, "limits": [...] }
55
+ // Try usage object first (summary)
56
+ const usage = payload.usage;
57
+ if (usage && typeof usage === "object") {
58
+ const result = parseUsageDetail(usage);
59
+ if (result) {
60
+ return result;
61
+ }
62
+ }
63
+
64
+ // Fallback: try limits array
65
+ const limits = Array.isArray(payload.limits) ? payload.limits : [];
66
+ if (limits.length > 0) {
67
+ // Find the first limit with a detail object
68
+ for (const limit of limits) {
69
+ if (limit && limit.detail && typeof limit.detail === "object") {
70
+ const result = parseUsageDetail(limit.detail);
71
+ if (result) {
72
+ return result;
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ // Legacy format: { "usages": [{ "scope": "FEATURE_CODING", "detail": {...} }] }
79
+ const usages = Array.isArray(payload.usages) ? payload.usages : [];
80
+ const codingUsage = usages.find(u => u?.scope === "FEATURE_CODING");
81
+
82
+ if (codingUsage && codingUsage.detail && typeof codingUsage.detail === "object") {
83
+ const result = parseUsageDetail(codingUsage.detail);
84
+ if (result) {
85
+ return result;
86
+ }
87
+ }
88
+
89
+ // Fallback: try limits array in legacy format
90
+ if (codingUsage && Array.isArray(codingUsage.limits)) {
91
+ const windowLimit = codingUsage.limits[0];
92
+ if (windowLimit && windowLimit.detail && typeof windowLimit.detail === "object") {
93
+ const result = parseUsageDetail(windowLimit.detail);
94
+ if (result) {
95
+ return result;
96
+ }
97
+ }
98
+ }
99
+
100
+ return { kind: "unavailable" };
101
+ }
102
+
103
+ function parseUsageDetail(detail) {
104
+ // Support both snake_case and camelCase field names
105
+ const limit = asFiniteNumber(detail.limit);
106
+ const used = asFiniteNumber(detail.used);
107
+ const remaining = asFiniteNumber(detail.remaining);
108
+
109
+ // Support multiple reset time field names
110
+ const nextResetTime = parseResetTime(
111
+ detail.reset_at ||
112
+ detail.resetAt ||
113
+ detail.reset_time ||
114
+ detail.resetTime
115
+ );
116
+
117
+ if (limit !== null && used !== null && remaining !== null && nextResetTime !== null) {
118
+ const leftPercent = limit > 0 ? Math.round((remaining / limit) * 100) : null;
119
+ const usedPercent = limit > 0 ? Math.round((used / limit) * 100) : null;
120
+
121
+ if (leftPercent !== null && usedPercent !== null) {
122
+ return {
123
+ kind: "success",
124
+ level: "",
125
+ display: "percent",
126
+ leftPercent,
127
+ usedPercent,
128
+ nextResetTime
129
+ };
130
+ }
131
+
132
+ return {
133
+ kind: "success",
134
+ level: "",
135
+ display: "absolute",
136
+ remaining,
137
+ total: limit,
138
+ nextResetTime
139
+ };
140
+ }
141
+
142
+ return null;
143
+ }
@@ -0,0 +1,39 @@
1
+ async function readStdinText(stream) {
2
+ let text = "";
3
+
4
+ for await (const chunk of stream) {
5
+ text += chunk;
6
+ }
7
+
8
+ return text;
9
+ }
10
+
11
+ export async function readStatusLineInput(stdin = process.stdin) {
12
+ if (!stdin || stdin.isTTY) {
13
+ return null;
14
+ }
15
+
16
+ try {
17
+ const raw = await readStdinText(stdin);
18
+ const trimmed = raw.trim();
19
+ if (!trimmed) {
20
+ return null;
21
+ }
22
+
23
+ return JSON.parse(trimmed);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function getSessionId(statusLineInput, env = process.env) {
30
+ if (statusLineInput && typeof statusLineInput.session_id === "string" && statusLineInput.session_id) {
31
+ return statusLineInput.session_id;
32
+ }
33
+
34
+ if (typeof env.CODEX_THREAD_ID === "string" && env.CODEX_THREAD_ID) {
35
+ return env.CODEX_THREAD_ID;
36
+ }
37
+
38
+ return "";
39
+ }
@@ -0,0 +1,41 @@
1
+ import { readCache, readFreshCache, writeSuccessCache } from "./cache.js";
2
+ import { fetchQuota } from "./fetchQuota.js";
3
+ import { parseQuotaResponse } from "./parseQuota.js";
4
+
5
+ export async function runQuotaLine(config, options = {}) {
6
+ const now = options.now ?? Date.now();
7
+ const fetchImpl = options.fetchImpl;
8
+
9
+ if (!config.apiKey) {
10
+ return { kind: "auth_error" };
11
+ }
12
+
13
+ const freshCache = await readFreshCache(
14
+ config.cacheFilePath,
15
+ config.cacheTtlMs,
16
+ config.threadId,
17
+ now
18
+ );
19
+ if (freshCache) {
20
+ return freshCache.result;
21
+ }
22
+
23
+ const staleCache = await readCache(config.cacheFilePath);
24
+ const response = await fetchQuota(config, fetchImpl);
25
+ const parsed = parseQuotaResponse(response);
26
+
27
+ if (parsed.kind === "success") {
28
+ await writeSuccessCache(config.cacheFilePath, parsed, config.threadId, now);
29
+ return parsed;
30
+ }
31
+
32
+ if (parsed.kind === "auth_error") {
33
+ return parsed;
34
+ }
35
+
36
+ if (staleCache) {
37
+ return staleCache.result;
38
+ }
39
+
40
+ return parsed;
41
+ }
@@ -0,0 +1,161 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const TOOL_CONFIG_SCHEMA_VERSION = 1;
9
+ const TOOL_CONFIG_MANAGED_BY = "glm-quota-line";
10
+
11
+ function normalizePathForShell(value) {
12
+ return value.replace(/\\/g, "/");
13
+ }
14
+
15
+ async function readJsonFile(filePath, fallback = {}) {
16
+ try {
17
+ const raw = await fs.readFile(filePath, "utf8");
18
+ return JSON.parse(raw);
19
+ } catch (error) {
20
+ if (error && error.code === "ENOENT") {
21
+ return fallback;
22
+ }
23
+
24
+ throw error;
25
+ }
26
+ }
27
+
28
+ async function writeJsonFile(filePath, value) {
29
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
30
+ const tempPath = `${filePath}.tmp-${process.pid}`;
31
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
32
+ await fs.rename(tempPath, filePath);
33
+ }
34
+
35
+ export function getClaudeDir() {
36
+ return path.join(os.homedir(), ".claude");
37
+ }
38
+
39
+ export function getClaudeSettingsPath() {
40
+ return path.join(getClaudeDir(), "settings.json");
41
+ }
42
+
43
+ export function getToolConfigPath() {
44
+ return path.join(getClaudeDir(), "glm-quota-line.json");
45
+ }
46
+
47
+ export function getStatusLineScriptPath() {
48
+ return path.resolve(__dirname, "..", "scripts", "status-line.js");
49
+ }
50
+
51
+ export function buildInstalledStatusLineCommand(nodePath = process.execPath) {
52
+ return `"${normalizePathForShell(nodePath)}" "${normalizePathForShell(getStatusLineScriptPath())}"`;
53
+ }
54
+
55
+ function isManagedStatusLine(statusLine) {
56
+ const command = statusLine?.command;
57
+ if (typeof command !== "string") {
58
+ return false;
59
+ }
60
+
61
+ return command.includes("glm-quota-line") || command.includes("status-line.js");
62
+ }
63
+
64
+ function normalizeToolConfig(config) {
65
+ const base = config && typeof config === "object" ? config : {};
66
+
67
+ return {
68
+ schemaVersion: TOOL_CONFIG_SCHEMA_VERSION,
69
+ managedBy: TOOL_CONFIG_MANAGED_BY,
70
+ style: typeof base.style === "string" ? base.style : undefined,
71
+ displayMode: typeof base.displayMode === "string" ? base.displayMode : undefined,
72
+ install: base.install && typeof base.install === "object" ? base.install : {}
73
+ };
74
+ }
75
+
76
+ export async function readToolConfig(configPath = getToolConfigPath()) {
77
+ const parsed = await readJsonFile(configPath, {});
78
+ return normalizeToolConfig(parsed);
79
+ }
80
+
81
+ export async function writeToolConfig(config, configPath = getToolConfigPath()) {
82
+ await writeJsonFile(configPath, normalizeToolConfig(config));
83
+ }
84
+
85
+ export async function setToolConfigValue(key, value, configPath = getToolConfigPath()) {
86
+ const current = await readToolConfig(configPath);
87
+ current[key] = value;
88
+ await writeToolConfig(current, configPath);
89
+ return current;
90
+ }
91
+
92
+ export async function installClaudeStatusLine(
93
+ command = buildInstalledStatusLineCommand(),
94
+ settingsPath = getClaudeSettingsPath(),
95
+ configPath = getToolConfigPath(),
96
+ options = {}
97
+ ) {
98
+ const settings = await readJsonFile(settingsPath, {});
99
+ const toolConfig = await readToolConfig(configPath);
100
+ const existingStatusLine = settings.statusLine;
101
+
102
+ if (existingStatusLine && !isManagedStatusLine(existingStatusLine) && !options.force) {
103
+ return {
104
+ installed: false,
105
+ reason: "unmanaged_exists",
106
+ settingsPath,
107
+ command
108
+ };
109
+ }
110
+
111
+ if (existingStatusLine && !isManagedStatusLine(existingStatusLine) && options.force) {
112
+ toolConfig.install.previousStatusLine = existingStatusLine;
113
+ }
114
+
115
+ const nextStatusLine = {
116
+ ...(existingStatusLine && typeof existingStatusLine === "object" ? existingStatusLine : {}),
117
+ type: "command",
118
+ command
119
+ };
120
+
121
+ settings.statusLine = nextStatusLine;
122
+ await writeJsonFile(settingsPath, settings);
123
+ toolConfig.install.settingsPath = settingsPath;
124
+ toolConfig.install.command = command;
125
+ toolConfig.install.installed = true;
126
+ await writeToolConfig(toolConfig, configPath);
127
+
128
+ return {
129
+ installed: true,
130
+ command,
131
+ settingsPath
132
+ };
133
+ }
134
+
135
+ export async function uninstallClaudeStatusLine(
136
+ settingsPath = getClaudeSettingsPath(),
137
+ configPath = getToolConfigPath()
138
+ ) {
139
+ const settings = await readJsonFile(settingsPath, {});
140
+ const toolConfig = await readToolConfig(configPath);
141
+ const statusLine = settings.statusLine;
142
+
143
+ if (!statusLine) {
144
+ return { removed: false, reason: "missing", settingsPath };
145
+ }
146
+
147
+ if (!isManagedStatusLine(statusLine)) {
148
+ return { removed: false, reason: "unmanaged", settingsPath };
149
+ }
150
+
151
+ if (toolConfig.install.previousStatusLine) {
152
+ settings.statusLine = toolConfig.install.previousStatusLine;
153
+ } else {
154
+ delete settings.statusLine;
155
+ }
156
+
157
+ await writeJsonFile(settingsPath, settings);
158
+ toolConfig.install = {};
159
+ await writeToolConfig(toolConfig, configPath);
160
+ return { removed: true, reason: "removed", settingsPath };
161
+ }