stepfun-status 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jochen Yang
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,174 @@
1
+ # stepfun-status
2
+
3
+ > StepFun Token-Plan 使用状态监控工具,支持 Claude Code 状态栏常驻显示。
4
+
5
+ [![npm version](https://img.shields.io/npm/v/stepfun-status)](https://www.npmjs.com/package/stepfun-status)
6
+ [![Node >=16](https://img.shields.io/node/v/stepfun-status)](https://nodejs.org)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+
9
+ ## 效果预览
10
+
11
+ Claude Code 底部状态栏:
12
+
13
+ ```
14
+ my-app │ main │ step-3.5-flash │ ██████░░░░ 60% (40/100) │ ⏱ 1h30m │ W ███░░ 58% │ 到期 12天
15
+ ```
16
+
17
+ 终端详细视图:
18
+
19
+ ```
20
+ ┌──────────────────────────────────────────────┐
21
+ │ StepFun Claude Code 使用状态 │
22
+ │ │
23
+ │ 当前模型: step-3.5-flash │
24
+ │ 套餐: Mini │
25
+ │ │
26
+ │ 5小时用量: ██████████████████░░░░░░░░░░ 60% │
27
+ │ 剩余: 40% │
28
+ │ 重置: 1 小时 30 分钟后重置 │
29
+ │ │
30
+ │ 每周用量: ████████░░░░░░░ 58% │
31
+ │ 剩余: 42% │
32
+ │ 重置: 3 小时 12 分钟后重置 │
33
+ │ │
34
+ │ 套餐到期: 2025/6/1 00:00:00 (还剩 12 天) │
35
+ │ │
36
+ │ 状态: ⚡ 注意使用 │
37
+ └──────────────────────────────────────────────┘
38
+ ```
39
+
40
+ ## 安装
41
+
42
+ ```bash
43
+ npm install -g stepfun-status
44
+ ```
45
+
46
+ ## 快速开始
47
+
48
+ ### 1. 获取 Cookie 并认证
49
+
50
+ 运行下方命令,会自动打印获取步骤:
51
+
52
+ ```bash
53
+ stepfun auth
54
+ ```
55
+
56
+ 按提示操作:
57
+ 1. 登录 [https://platform.stepfun.com](https://platform.stepfun.com)
58
+ 2. F12 → Network 标签 → 刷新页面
59
+ 3. 点击任意 API 请求(如 `GetStepPlanStatus`)
60
+ 4. 滚动到 Request Headers → 找到 `Cookie` 行
61
+ 5. **右键 → Copy value**(必须用此方式,直接框选会截断)
62
+ 6. 粘贴到终端并回车
63
+
64
+ > **提示**:Cookie 较长,建议使用交互式 `stepfun auth`(无参数),避免命令行参数截断问题。
65
+
66
+ ### 2. 验证连接
67
+
68
+ ```bash
69
+ stepfun health
70
+ ```
71
+
72
+ ### 3. 查看状态
73
+
74
+ ```bash
75
+ stepfun status
76
+ ```
77
+
78
+ ## Claude Code 状态栏集成
79
+
80
+ ### 配置
81
+
82
+ 编辑 `~/.claude/settings.json`,添加:
83
+
84
+ ```json
85
+ {
86
+ "statusLine": {
87
+ "type": "command",
88
+ "command": "stepfun statusline"
89
+ }
90
+ }
91
+ ```
92
+
93
+ 重启 Claude Code 后生效。
94
+
95
+
96
+ ### 状态栏格式
97
+
98
+ ```
99
+ 目录 │ 分支 │ 模型 │ 5小时用量进度条(剩余) │ ⏱ 倒计时 │ W 周用量 │ 到期天数
100
+ ```
101
+
102
+ **颜色说明**:
103
+
104
+ | 指标 | 绿色 | 黄色 | 红色 |
105
+ |------|------|------|------|
106
+ | 用量 | < 60% | 60–85% | ≥ 85% |
107
+ | 到期 | > 7 天 | ≤ 7 天 | ≤ 3 天 |
108
+ | 分支 | main/master | — | 有未提交改动(`*`) |
109
+
110
+ ## 命令参考
111
+
112
+ | 命令 | 选项 | 说明 |
113
+ |------|------|------|
114
+ | `stepfun auth [cookie]` | — | 设置认证 Cookie(推荐无参数交互式输入) |
115
+ | `stepfun health` | — | 检查配置文件、Cookie 和 API 连通性 |
116
+ | `stepfun status` | — | 详细模式显示当前使用状态 |
117
+ | `stepfun status` | `-c, --compact` | 紧凑单行模式 |
118
+ | `stepfun status` | `-w, --watch` | 实时监控,每 10 秒刷新 |
119
+ | `stepfun status` | `-c -w` | 紧凑模式 + 实时监控 |
120
+ | `stepfun statusline` | — | 输出状态行(供 Claude Code 调用) |
121
+ | `stepfun statusline` | `-w, --watch` | 持续刷新状态行输出 |
122
+
123
+ ## 配置文件
124
+
125
+ | 项目 | 说明 |
126
+ |------|------|
127
+ | 路径 | `~/.stepfun-config.json` |
128
+ | 内容 | `{ "cookie": "..." }` |
129
+ | 权限 | Unix/Mac 下自动设置为 `0600`(仅所有者可读写) |
130
+
131
+ > **Windows 注意**:不支持 Unix 文件权限位,Cookie 以明文存储,请妥善保管配置文件,避免他人访问。
132
+
133
+ > **macOS / Linux**:配置文件权限已自动设为 `0600`,只有当前用户可读写,无需额外操作。
134
+
135
+ ## 常见问题
136
+
137
+ **Q: 状态栏显示 ❌**
138
+ A: Cookie 未配置或已过期,运行 `stepfun health` 诊断,再用 `stepfun auth` 重新设置。
139
+
140
+ **Q: Cookie 粘贴后提示"不能为空"**
141
+ A: 可能复制到了空内容,请确保在 Network 面板使用"右键 → Copy value"而非框选复制。
142
+
143
+ **Q: 状态栏不显示**
144
+ A: 确认 `~/.claude/settings.json` 中 `statusLine.command` 为 `stepfun statusline`,且 `stepfun-status` 已全局安装(`npm install -g stepfun-status`)。
145
+
146
+ **Q: macOS / Linux 上 `stepfun` 命令找不到**
147
+ A: 确认 npm 全局 bin 目录已加入 `PATH`。可运行 `npm bin -g` 查看路径,并将其添加到 `~/.zshrc` 或 `~/.bashrc`:
148
+ ```bash
149
+ export PATH="$(npm bin -g):$PATH"
150
+ ```
151
+
152
+ ## 开发
153
+
154
+ ```bash
155
+ git clone https://github.com/Daiyimo/stepfun-status.git
156
+ cd stepfun-status
157
+ npm install
158
+ npm link # 本地全局链接,方便调试
159
+
160
+ npm start # 查看当前状态
161
+ npm test # 检查连通性
162
+ npm run lint # 语法检查
163
+ ```
164
+
165
+ ## 许可证
166
+
167
+ [MIT](LICENSE)
168
+
169
+ ## 相关链接
170
+
171
+ - [StepFun 开放平台](https://platform.stepfun.com)
172
+ - [StepFun 接口密钥](https://platform.stepfun.com/interface-key)
173
+ - [StepFun 订阅情况](https://platform.stepfun.com/plan-subscribe)
174
+ - [StepFun 接入指南](https://platform.stepfun.com/docs/zh/step-plan/integrations)
package/cli/api.js ADDED
@@ -0,0 +1,345 @@
1
+ const https = require("https");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const os = require("os");
5
+
6
+ // ─── 常量 ─────────────────────────────────────────────────────────────────────
7
+
8
+ const STEPFUN_HOST = "platform.stepfun.com";
9
+ const STEPFUN_ORIGIN = `https://${STEPFUN_HOST}`;
10
+
11
+ const CONFIG = {
12
+ CACHE_TIMEOUT: 30000, // 缓存超时:30 秒
13
+ REQUEST_TIMEOUT: 8000, // 网络请求超时:8 秒
14
+ CONFIG_FILENAME: ".stepfun-config.json",
15
+ };
16
+
17
+ // ─── 跨平台 User-Agent ────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * 根据当前操作系统返回对应的浏览器 User-Agent 字符串,
21
+ * 避免 macOS/Linux 下使用 Windows UA 被服务端检测。
22
+ */
23
+ function getPlatformUserAgent() {
24
+ const platform = os.platform();
25
+ if (platform === "darwin") {
26
+ return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
27
+ }
28
+ if (platform === "linux") {
29
+ return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
30
+ }
31
+ // Windows(默认)
32
+ return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
33
+ }
34
+
35
+ // 配置文件路径(模块级常量,供外部共享,避免各处重复计算)
36
+ const CONFIG_PATH = path.join(
37
+ process.env.HOME || process.env.USERPROFILE,
38
+ CONFIG.CONFIG_FILENAME
39
+ );
40
+
41
+ // ─── 模块级纯函数(提升出 parseUsageData,避免每次调用重新创建) ──────────────
42
+
43
+ /**
44
+ * 将 Unix 时间戳转换为剩余时间对象
45
+ * @param {string|number|null} timestamp Unix 秒级时间戳
46
+ * @returns {{ ts: number, date: string, hoursUntil: number, minutesUntil: number } | null}
47
+ */
48
+ function formatResetTime(timestamp) {
49
+ if (!timestamp) return null;
50
+ const ts = parseInt(timestamp, 10);
51
+ const date = new Date(ts * 1000);
52
+ const diffMs = date.getTime() - Date.now();
53
+ return {
54
+ ts,
55
+ date: date.toLocaleString(),
56
+ hoursUntil: Math.max(0, Math.floor(diffMs / (1000 * 60 * 60))),
57
+ minutesUntil: Math.max(0, Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * 将剩余时间对象格式化为可读文本
63
+ * @param {{ hoursUntil: number, minutesUntil: number } | null} resetInfo
64
+ * @returns {string}
65
+ */
66
+ function calcRemainingText(resetInfo) {
67
+ if (!resetInfo) return "未知";
68
+ const { hoursUntil, minutesUntil } = resetInfo;
69
+ return hoursUntil > 0
70
+ ? `${hoursUntil} 小时 ${minutesUntil} 分钟后重置`
71
+ : `${minutesUntil} 分钟后重置`;
72
+ }
73
+
74
+ // ─── StepFunAPI 类 ────────────────────────────────────────────────────────────
75
+
76
+ class StepFunAPI {
77
+ constructor() {
78
+ this.cookie = null;
79
+ this.cache = {
80
+ statusData: null,
81
+ quotaData: null,
82
+ statusTimestamp: 0,
83
+ quotaTimestamp: 0,
84
+ };
85
+ this.loadConfig();
86
+ }
87
+
88
+ /** 是否已配置凭据 */
89
+ isAuthenticated() {
90
+ return Boolean(this.cookie);
91
+ }
92
+
93
+ loadConfig() {
94
+ try {
95
+ if (fs.existsSync(CONFIG_PATH)) {
96
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
97
+ this.cookie = config.cookie || null;
98
+ }
99
+ } catch (error) {
100
+ console.error("Failed to load config:", error.message);
101
+ }
102
+ }
103
+
104
+ saveConfig() {
105
+ try {
106
+ const config = { cookie: this.cookie };
107
+ // 0o600:仅所有者可读写,在 Unix/Mac 上防止其他用户读取 Cookie
108
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
109
+ } catch (error) {
110
+ console.error("Failed to save config:", error.message);
111
+ }
112
+ }
113
+
114
+ setCredentials(cookie) {
115
+ if (!cookie || typeof cookie !== "string" || !cookie.trim()) {
116
+ throw new Error("Cookie 不能为空");
117
+ }
118
+ this.cookie = cookie.trim();
119
+ this.saveConfig();
120
+ }
121
+
122
+ /** 从 Cookie 字符串中提取指定字段 */
123
+ extractFromCookie() {
124
+ if (!this.cookie) return { oasisWebid: null };
125
+
126
+ const cookies = {};
127
+ this.cookie.split(/;\s*/).forEach((c) => {
128
+ const idx = c.indexOf("=");
129
+ if (idx === -1) return;
130
+ const key = c.slice(0, idx).trim();
131
+ const val = c.slice(idx + 1);
132
+ if (!key) return;
133
+ try {
134
+ cookies[key] = decodeURIComponent(val);
135
+ } catch {
136
+ cookies[key] = val;
137
+ }
138
+ });
139
+
140
+ return { oasisWebid: cookies["Oasis-Webid"] || null };
141
+ }
142
+
143
+ async makePlatformRequest(endpoint, data = {}) {
144
+ if (!this.cookie) {
145
+ throw new Error(
146
+ 'Missing cookie. Please run "stepfun auth <cookie>" first.\n' +
147
+ "获取方式:\n" +
148
+ "1. 登录 https://platform.stepfun.com\n" +
149
+ "2. F12 -> Network -> 刷新页面\n" +
150
+ "3. 点击任意 API 请求 -> Copy -> Copy request headers -> 复制 Cookie 行"
151
+ );
152
+ }
153
+
154
+ const { oasisWebid } = this.extractFromCookie();
155
+ const postData = JSON.stringify(data);
156
+
157
+ const headers = {
158
+ accept: "*/*",
159
+ "content-type": "application/json",
160
+ "oasis-appid": "10300",
161
+ "oasis-platform": "web",
162
+ "oasis-webid": oasisWebid || "",
163
+ origin: STEPFUN_ORIGIN,
164
+ referer: `${STEPFUN_ORIGIN}/plan-subscribe`,
165
+ "user-agent": getPlatformUserAgent(),
166
+ "content-length": Buffer.byteLength(postData),
167
+ Cookie: this.cookie,
168
+ };
169
+
170
+ return new Promise((resolve, reject) => {
171
+ const req = https.request(
172
+ {
173
+ hostname: STEPFUN_HOST, // 与 STEPFUN_ORIGIN 保持同一数据源
174
+ port: 443,
175
+ path: endpoint,
176
+ method: "POST",
177
+ headers,
178
+ rejectUnauthorized: true, // 显式启用 SSL 证书验证
179
+ },
180
+ (res) => {
181
+ let body = "";
182
+ res.on("data", (chunk) => { body += chunk; });
183
+ res.on("end", () => {
184
+ if (res.statusCode === 200) {
185
+ try {
186
+ resolve(JSON.parse(body));
187
+ } catch {
188
+ reject(new Error("Failed to parse response: invalid JSON"));
189
+ }
190
+ } else if (res.statusCode === 401) {
191
+ reject(new Error("Cookie 已失效或过期,请重新运行 auth 命令"));
192
+ } else {
193
+ reject(new Error(`API error: HTTP ${res.statusCode}`));
194
+ }
195
+ });
196
+ }
197
+ );
198
+
199
+ // 超时保护
200
+ req.setTimeout(CONFIG.REQUEST_TIMEOUT, () => {
201
+ req.destroy();
202
+ reject(new Error(`Request timeout after ${CONFIG.REQUEST_TIMEOUT / 1000}s`));
203
+ });
204
+
205
+ req.on("error", (e) => {
206
+ reject(new Error(`Network error: ${e.message}`));
207
+ });
208
+
209
+ req.write(postData);
210
+ req.end();
211
+ });
212
+ }
213
+
214
+ /**
215
+ * 带缓存的请求封装,消除 getSubscriptionStatus / getQuotaInfo 的重复逻辑
216
+ * @param {'statusData'|'quotaData'} dataKey 缓存数据字段名
217
+ * @param {'statusTimestamp'|'quotaTimestamp'} tsKey 缓存时间戳字段名
218
+ * @param {string} endpoint API 路径
219
+ * @param {boolean} forceRefresh 是否强制跳过缓存
220
+ */
221
+ async _cachedRequest(dataKey, tsKey, endpoint, forceRefresh) {
222
+ const now = Date.now();
223
+ if (
224
+ !forceRefresh &&
225
+ this.cache[dataKey] &&
226
+ now - this.cache[tsKey] < CONFIG.CACHE_TIMEOUT
227
+ ) {
228
+ return this.cache[dataKey];
229
+ }
230
+ const data = await this.makePlatformRequest(endpoint);
231
+ this.cache[dataKey] = data;
232
+ this.cache[tsKey] = now;
233
+ return data;
234
+ }
235
+
236
+ async getSubscriptionStatus(forceRefresh = false) {
237
+ return this._cachedRequest(
238
+ "statusData",
239
+ "statusTimestamp",
240
+ "/api/step.openapi.devcenter.Dashboard/GetStepPlanStatus",
241
+ forceRefresh
242
+ );
243
+ }
244
+
245
+ async getQuotaInfo(forceRefresh = false) {
246
+ return this._cachedRequest(
247
+ "quotaData",
248
+ "quotaTimestamp",
249
+ "/api/step.openapi.devcenter.Dashboard/QueryStepPlanRateLimit",
250
+ forceRefresh
251
+ );
252
+ }
253
+
254
+ // ── 预留扩展区 ──────────────────────────────────────────────────────────────
255
+ // 后续新增接口时,只需:
256
+ // 1. 在 this.cache 里加对应的 data/timestamp 字段
257
+ // 2. 仿照下方模板写一个 get 方法
258
+ // 3. 在 getUsageStatus / parseUsageData 里消费新数据
259
+ //
260
+ // 模板(等 Token 用量明细接口开放后接入):
261
+ //
262
+ // async getTokenUsage(forceRefresh = false) {
263
+ // return this._cachedRequest(
264
+ // "tokenData",
265
+ // "tokenTimestamp",
266
+ // "/api/step.openapi.devcenter.Dashboard/QueryTokenUsage", // 待确认
267
+ // forceRefresh
268
+ // );
269
+ // }
270
+ // ────────────────────────────────────────────────────────────────────────────
271
+
272
+ async getUsageStatus(forceRefresh = false) {
273
+ const [statusData, quotaData] = await Promise.all([
274
+ this.getSubscriptionStatus(forceRefresh),
275
+ this.getQuotaInfo(forceRefresh),
276
+ ]);
277
+ return this.parseUsageData(statusData, quotaData);
278
+ }
279
+
280
+ parseUsageData(statusData, quotaData) {
281
+ const subscription = statusData.subscription || {};
282
+ const planDefinition = statusData.plan_definition || {};
283
+
284
+ const fiveHourLeftRate = quotaData.five_hour_usage_left_rate || 0;
285
+ const weeklyLeftRate = quotaData.weekly_usage_left_rate || 0;
286
+
287
+ const fiveHourUsedPercent = Math.round((1 - fiveHourLeftRate) * 100);
288
+ const weeklyUsedPercent = Math.round((1 - weeklyLeftRate) * 100);
289
+
290
+ const fiveHourReset = formatResetTime(quotaData.five_hour_usage_reset_time);
291
+ const weeklyReset = formatResetTime(quotaData.weekly_usage_reset_time);
292
+
293
+ let expiry = null;
294
+ if (subscription.expired_at) {
295
+ const expiryDate = new Date(parseInt(subscription.expired_at, 10) * 1000);
296
+ const daysRemaining = Math.ceil(
297
+ (expiryDate.getTime() - Date.now()) / (1000 * 3600 * 24)
298
+ );
299
+ expiry = {
300
+ date: expiryDate.toLocaleString(),
301
+ daysRemaining,
302
+ text:
303
+ daysRemaining > 0
304
+ ? `还剩 ${daysRemaining} 天`
305
+ : daysRemaining === 0
306
+ ? "今天到期"
307
+ : `已过期 ${Math.abs(daysRemaining)} 天`,
308
+ };
309
+ }
310
+
311
+ return {
312
+ modelName: planDefinition.support_models?.[0] || "step-3.5-flash",
313
+ planName: subscription.name || "Mini",
314
+ remaining: {
315
+ hours: fiveHourReset?.hoursUntil || 0,
316
+ minutes: fiveHourReset?.minutesUntil || 0,
317
+ text: calcRemainingText(fiveHourReset),
318
+ },
319
+ usage: {
320
+ percentage: fiveHourUsedPercent,
321
+ remaining: Math.round(fiveHourLeftRate * 100),
322
+ },
323
+ weekly: {
324
+ percentage: weeklyUsedPercent,
325
+ remaining: Math.round(weeklyLeftRate * 100),
326
+ text: calcRemainingText(weeklyReset),
327
+ daysUntilReset: weeklyReset?.hoursUntil
328
+ ? Math.floor(weeklyReset.hoursUntil / 24)
329
+ : 0,
330
+ },
331
+ expiry,
332
+ };
333
+ }
334
+
335
+ clearCache() {
336
+ this.cache = {
337
+ statusData: null,
338
+ quotaData: null,
339
+ statusTimestamp: 0,
340
+ quotaTimestamp: 0,
341
+ };
342
+ }
343
+ }
344
+
345
+ module.exports = { StepFunAPI, CONFIG_PATH };
package/cli/index.js ADDED
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Force color output even in non-TTY environments (e.g., Claude Code statusline)
4
+ process.env.FORCE_COLOR = "1";
5
+
6
+ const { execSync } = require("child_process");
7
+ const { Command } = require("commander");
8
+ const chalk = require("chalk").default;
9
+ const ora = require("ora").default;
10
+ const { default: boxen } = require("boxen");
11
+ const { StepFunAPI, CONFIG_PATH } = require("./api");
12
+ const packageJson = require("../package.json");
13
+ const fs = require("fs");
14
+
15
+ // ─── 全局错误兜底 ─────────────────────────────────────────────────────────────
16
+ process.on("uncaughtException", (error) => {
17
+ console.error(chalk.red("💥 未捕获的错误:"), error.message);
18
+ process.exit(1);
19
+ });
20
+
21
+ process.on("unhandledRejection", (reason) => {
22
+ console.error(
23
+ chalk.red("💥 未处理的 Promise 错误:"),
24
+ reason instanceof Error ? reason.message : reason
25
+ );
26
+ process.exit(1);
27
+ });
28
+
29
+ // ─── 常量 ────────────────────────────────────────────────────────────────────
30
+ const WATCH_INTERVAL = 10000; // watch 模式刷新间隔:10 秒
31
+
32
+ // ─── 阈值常量(避免魔法数字) ─────────────────────────────────────────────────
33
+ const THRESHOLD = {
34
+ USAGE_CRITICAL: 85, // 用量 >= 85% 时显示红色告警
35
+ USAGE_WARNING: 60, // 用量 >= 60% 时显示黄色警告
36
+ EXPIRY_CRITICAL: 3, // 订阅剩余 <= 3 天时显示红色
37
+ EXPIRY_WARNING: 7, // 订阅剩余 <= 7 天时显示黄色
38
+ };
39
+
40
+ const program = new Command();
41
+ const api = new StepFunAPI();
42
+
43
+ program
44
+ .name("stepfun-status")
45
+ .description("StepFun Claude Code 使用状态监控工具")
46
+ .version(packageJson.version);
47
+
48
+ // ─── 公共工具函数 ─────────────────────────────────────────────────────────────
49
+
50
+ /** 根据已用百分比返回对应的 chalk 颜色函数 */
51
+ function getUsageColor(percentage) {
52
+ if (percentage >= THRESHOLD.USAGE_CRITICAL) return chalk.red;
53
+ if (percentage >= THRESHOLD.USAGE_WARNING) return chalk.yellow;
54
+ return chalk.green;
55
+ }
56
+
57
+ /** 根据订阅剩余天数返回对应的 chalk 颜色函数 */
58
+ function getExpiryColor(daysRemaining) {
59
+ if (daysRemaining <= THRESHOLD.EXPIRY_CRITICAL) return chalk.red;
60
+ if (daysRemaining <= THRESHOLD.EXPIRY_WARNING) return chalk.yellow;
61
+ return chalk.green;
62
+ }
63
+
64
+ /**
65
+ * 生成带颜色的进度条字符串
66
+ * @param {number} percentage 0–100
67
+ * @param {number} width 进度条总格数
68
+ * @param {Function} colorFn chalk 颜色函数
69
+ */
70
+ function renderProgressBar(percentage, width, colorFn) {
71
+ const filled = Math.round((Math.min(100, Math.max(0, percentage)) / 100) * width);
72
+ const empty = width - filled;
73
+ return colorFn("█".repeat(filled)) + chalk.dim("░".repeat(empty));
74
+ }
75
+
76
+ /** 清屏(仅在 TTY 环境,兼容各终端) */
77
+ function clearScreen() {
78
+ if (process.stdout.isTTY) {
79
+ process.stdout.write("\x1B[2J\x1B[0f");
80
+ }
81
+ }
82
+
83
+ /** 获取当前 Git 分支信息,非 Git 目录返回 null */
84
+ function getGitBranch(cwd) {
85
+ try {
86
+ const branch = execSync("git symbolic-ref --short HEAD", {
87
+ encoding: "utf8",
88
+ timeout: 3000,
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ cwd,
91
+ }).trim();
92
+
93
+ // 基本格式校验,防止异常值
94
+ if (!branch || !/^[\w\-\./]+$/.test(branch)) return null;
95
+
96
+ const status = execSync("git status --porcelain", {
97
+ encoding: "utf8",
98
+ timeout: 3000,
99
+ stdio: ["pipe", "pipe", "pipe"],
100
+ cwd,
101
+ }).trim();
102
+
103
+ return { name: branch, hasChanges: Boolean(status) };
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ // ─── Auth 命令 ────────────────────────────────────────────────────────────────
110
+ program
111
+ .command("auth")
112
+ .description("设置认证凭据 (从浏览器 Network 面板复制)")
113
+ .argument("[cookie]", "完整 Cookie 字符串(可选,可直接粘贴不带引号)")
114
+ .action(async (cookie) => {
115
+ // 无论是否已有参数,都先打印获取方式
116
+ console.log(chalk.yellow("\n获取 Cookie 方式:"));
117
+ console.log(chalk.gray("1. 登录 https://platform.stepfun.com"));
118
+ console.log(chalk.gray("2. F12 -> Network 标签"));
119
+ console.log(chalk.gray("3. 刷新页面"));
120
+ console.log(chalk.gray("4. 点击任意 API 请求(如 GetStepPlanStatus)"));
121
+ console.log(chalk.gray("5. 滚动到 Request Headers -> 找到 Cookie 行"));
122
+ console.log(chalk.gray("6. 右键 -> Copy value"));
123
+
124
+ if (!cookie) {
125
+ const readline = require("readline");
126
+ const rl = readline.createInterface({
127
+ input: process.stdin,
128
+ output: process.stdout,
129
+ });
130
+
131
+ console.log(chalk.yellow("\n请粘贴完整的 Cookie 字符串:"));
132
+ console.log(chalk.gray("(粘贴后按回车)"));
133
+
134
+ cookie = await new Promise((resolve) => {
135
+ rl.on("line", (input) => {
136
+ rl.close();
137
+ resolve(input.trim());
138
+ });
139
+ });
140
+ }
141
+
142
+ try {
143
+ api.setCredentials(cookie);
144
+ } catch (err) {
145
+ console.error(chalk.red(`✗ ${err.message}`));
146
+ process.exit(1);
147
+ }
148
+
149
+ console.log(chalk.green("\n✓ 认证信息已保存"));
150
+ console.log(chalk.gray(`配置文件: ${CONFIG_PATH}`));
151
+ });
152
+
153
+ // ─── Health 命令 ──────────────────────────────────────────────────────────────
154
+ program
155
+ .command("health")
156
+ .description("检查配置和连接状态")
157
+ .action(async () => {
158
+ const spinner = ora("正在检查...").start();
159
+
160
+ if (!fs.existsSync(CONFIG_PATH)) {
161
+ spinner.fail("配置文件检查");
162
+ console.log(chalk.red("✗ 配置文件: ") + chalk.gray("未找到"));
163
+ console.log(chalk.yellow("请先运行: stepfun-status auth <cookie>"));
164
+ return;
165
+ }
166
+
167
+ spinner.succeed("配置文件检查");
168
+ console.log(chalk.green("✓ 配置文件: ") + chalk.gray("已找到"));
169
+
170
+ if (api.isAuthenticated()) {
171
+ console.log(chalk.green("✓ Cookie: ") + chalk.gray("已配置"));
172
+ try {
173
+ await api.getUsageStatus();
174
+ console.log(chalk.green("✓ API连接: ") + chalk.gray("正常"));
175
+ } catch (error) {
176
+ console.log(chalk.red("✗ API连接: ") + chalk.gray(error.message));
177
+ }
178
+ } else {
179
+ console.log(chalk.red("✗ Cookie: ") + chalk.gray("未配置"));
180
+ }
181
+ });
182
+
183
+ // ─── Status 命令 ──────────────────────────────────────────────────────────────
184
+ program
185
+ .command("status")
186
+ .description("显示当前使用状态")
187
+ .option("-c, --compact", "紧凑模式显示")
188
+ .option("-w, --watch", "实时监控模式")
189
+ .action(async (options) => {
190
+ const spinner = ora("获取使用状态中...").start();
191
+
192
+ try {
193
+ const usageData = await api.getUsageStatus();
194
+ spinner.succeed("状态获取成功");
195
+
196
+ if (options.compact) {
197
+ console.log(renderCompact(usageData));
198
+ } else {
199
+ console.log("\n" + renderDetailed(usageData) + "\n");
200
+ }
201
+
202
+ if (options.watch) {
203
+ console.log(chalk.gray("监控中... 按 Ctrl+C 退出"));
204
+ // 将已获取的数据和当前渲染函数传入,避免启动时重复请求,并保持显示模式一致
205
+ const renderFn = options.compact ? renderCompact : renderDetailed;
206
+ startWatching(usageData, renderFn);
207
+ }
208
+ } catch (error) {
209
+ spinner.fail(chalk.red("获取状态失败"));
210
+ console.error(chalk.red(`错误: ${error.message}`));
211
+ process.exit(1);
212
+ }
213
+ });
214
+
215
+ // ─── Statusline 命令 ──────────────────────────────────────────────────────────
216
+ program
217
+ .command("statusline")
218
+ .description("Claude Code状态栏集成")
219
+ .option("-w, --watch", "持续监控模式,用于Claude Code状态栏")
220
+ .action(async (options) => {
221
+ // 缓存一次,避免 renderStatusline 与 execSync 重复调用 process.cwd()
222
+ const cwd = process.cwd();
223
+ const cliCurrentDir = cwd.split(/[/\\]/).pop();
224
+
225
+ const renderStatusline = async () => {
226
+ try {
227
+ const usageData = await api.getUsageStatus();
228
+ const { usage, modelName, remaining, expiry, weekly } = usageData;
229
+ const percentage = usage.percentage;
230
+ const color = getUsageColor(percentage);
231
+
232
+ const gitBranch = getGitBranch(cwd);
233
+
234
+ const parts = [];
235
+
236
+ if (cliCurrentDir) {
237
+ parts.push(chalk.cyan(cliCurrentDir));
238
+ }
239
+
240
+ if (gitBranch) {
241
+ const isMainBranch = gitBranch.name === "main" || gitBranch.name === "master";
242
+ const branchColor = isMainBranch ? chalk.green : chalk.white;
243
+ let branchStr = branchColor(gitBranch.name);
244
+ if (gitBranch.hasChanges) branchStr += chalk.red(" *");
245
+ parts.push(branchStr);
246
+ }
247
+
248
+ parts.push(chalk.magenta(modelName));
249
+
250
+ const bar = renderProgressBar(percentage, 10, color);
251
+ parts.push(`${bar} ${color(percentage + "%")} (${usage.remaining}/100)`);
252
+
253
+ const remainingText =
254
+ remaining.hours > 0
255
+ ? `${remaining.hours}h${remaining.minutes}m`
256
+ : `${remaining.minutes}m`;
257
+ parts.push(`${chalk.yellow("⏱")} ${remainingText}`);
258
+
259
+ if (weekly) {
260
+ const weeklyColor = getUsageColor(weekly.percentage);
261
+ const weeklyBar = renderProgressBar(weekly.percentage, 5, weeklyColor);
262
+ parts.push(`${chalk.blue("W")} ${weeklyBar} ${weeklyColor(weekly.percentage + "%")}`);
263
+ }
264
+
265
+ if (expiry) {
266
+ parts.push(getExpiryColor(expiry.daysRemaining)("到期 " + expiry.daysRemaining + "天"));
267
+ }
268
+
269
+ console.log(parts.join(" │ "));
270
+ } catch {
271
+ console.log(
272
+ `${chalk.cyan(cliCurrentDir || "")} │ ${chalk.magenta("step-3.5-flash")} │ ${chalk.red("❌")}`
273
+ );
274
+ }
275
+ };
276
+
277
+ if (options.watch) {
278
+ console.log(chalk.gray("Claude Code 状态栏已启动..."));
279
+
280
+ let stopped = false;
281
+ const shutdown = () => {
282
+ stopped = true;
283
+ process.exit(0);
284
+ };
285
+ process.on("SIGINT", shutdown);
286
+ process.on("SIGTERM", shutdown);
287
+
288
+ // 递归 setTimeout:等本次渲染完成后再安排下一次,避免并发请求
289
+ const loop = async () => {
290
+ if (stopped) return;
291
+ clearScreen();
292
+ await renderStatusline();
293
+ if (!stopped) setTimeout(loop, WATCH_INTERVAL);
294
+ };
295
+
296
+ loop();
297
+ } else {
298
+ await renderStatusline();
299
+ }
300
+ });
301
+
302
+ // ─── 渲染函数 ─────────────────────────────────────────────────────────────────
303
+
304
+ function renderCompact(data) {
305
+ const { usage, remaining, modelName, expiry, weekly } = data;
306
+ const { percentage } = usage;
307
+ const color = getUsageColor(percentage);
308
+
309
+ const statusIcon =
310
+ percentage >= THRESHOLD.USAGE_CRITICAL ? "⚠" :
311
+ percentage >= THRESHOLD.USAGE_WARNING ? "⚡" : "✓";
312
+ const weeklyInfo = weekly ? ` ${chalk.blue("W")} ${weekly.percentage}%` : "";
313
+ const expiryInfo = expiry ? ` ${chalk.gray("•")} 剩余: ${expiry.daysRemaining}天` : "";
314
+
315
+ return (
316
+ `${color("●")} ${modelName} ${percentage}% ` +
317
+ `${chalk.dim(`(${usage.remaining}/100)`)} ${chalk.gray("•")} ` +
318
+ `${remaining.text}${weeklyInfo} ${chalk.gray("•")} ${statusIcon}${expiryInfo}`
319
+ );
320
+ }
321
+
322
+ function renderDetailed(data) {
323
+ const { modelName, remaining, usage, weekly, expiry } = data;
324
+
325
+ const barColor = getUsageColor(usage.percentage);
326
+ const progressBar = renderProgressBar(usage.percentage, 30, barColor);
327
+
328
+ const lines = [];
329
+ lines.push(chalk.bold("StepFun Claude Code 使用状态"));
330
+ lines.push("");
331
+ lines.push(`${chalk.cyan("当前模型:")} ${modelName}`);
332
+ lines.push(`${chalk.cyan("套餐:")} ${data.planName || "Mini"}`);
333
+ lines.push("");
334
+ lines.push(`${chalk.cyan("5小时用量:")} ${progressBar} ${usage.percentage}%`);
335
+ lines.push(`${chalk.dim(" 剩余:")} ${usage.remaining}%`);
336
+ lines.push(`${chalk.dim(" 重置:")} ${remaining.text}`);
337
+
338
+ if (weekly) {
339
+ const weeklyBar = renderProgressBar(weekly.percentage, 15, getUsageColor(weekly.percentage));
340
+ lines.push("");
341
+ lines.push(`${chalk.cyan("每周用量:")} ${weeklyBar} ${weekly.percentage}%`);
342
+ lines.push(`${chalk.dim(" 剩余:")} ${weekly.remaining}%`);
343
+ lines.push(`${chalk.dim(" 重置:")} ${weekly.text}`);
344
+ }
345
+
346
+ if (expiry) {
347
+ lines.push("");
348
+ lines.push(`${chalk.cyan("套餐到期:")} ${expiry.date} (${expiry.text})`);
349
+ }
350
+
351
+ lines.push("");
352
+
353
+ const status =
354
+ usage.percentage >= THRESHOLD.USAGE_CRITICAL ? "⚠ 即将用完" :
355
+ usage.percentage >= THRESHOLD.USAGE_WARNING ? "⚡ 注意使用" : "✓ 正常使用";
356
+ lines.push(`${chalk.cyan("状态:")} ${getUsageColor(usage.percentage)(status)}`);
357
+
358
+ return boxen(lines.join("\n"), {
359
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
360
+ borderColor: "blue",
361
+ borderStyle: "single",
362
+ dimBorder: true,
363
+ });
364
+ }
365
+
366
+ /**
367
+ * 启动实时监控模式
368
+ * @param {object|null} initialData 已获取的 usageData,传入可避免启动时重复请求
369
+ * @param {Function} renderFn 渲染函数,默认 renderDetailed,可传 renderCompact 保持模式一致
370
+ */
371
+ function startWatching(initialData = null, renderFn = renderDetailed) {
372
+ const render = (data) => {
373
+ clearScreen();
374
+ console.log("\n" + renderFn(data) + "\n");
375
+ console.log(chalk.gray(`最后更新: ${new Date().toLocaleTimeString()}`));
376
+ };
377
+
378
+ let stopped = false;
379
+ const shutdown = () => {
380
+ stopped = true;
381
+ console.log(chalk.yellow("\n监控已停止"));
382
+ process.exit(0);
383
+ };
384
+ process.on("SIGINT", shutdown);
385
+ process.on("SIGTERM", shutdown);
386
+
387
+ // 递归 setTimeout:等本次请求完成后再安排下一次,避免慢网络下并发请求堆积
388
+ const loop = async () => {
389
+ if (stopped) return;
390
+ try {
391
+ const usageData = await api.getUsageStatus();
392
+ render(usageData);
393
+ } catch (error) {
394
+ console.error(chalk.red(`更新失败: ${error.message}`));
395
+ }
396
+ if (!stopped) setTimeout(loop, WATCH_INTERVAL);
397
+ };
398
+
399
+ // 立即用已有数据渲染首帧,之后进入定时循环
400
+ if (initialData) {
401
+ render(initialData);
402
+ setTimeout(loop, WATCH_INTERVAL);
403
+ } else {
404
+ loop();
405
+ }
406
+ }
407
+
408
+ // ─── 入口 ─────────────────────────────────────────────────────────────────────
409
+
410
+ if (!process.argv.slice(2).length) {
411
+ program.outputHelp();
412
+ process.exit(1);
413
+ }
414
+
415
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "stepfun-status",
3
+ "version": "1.0.0",
4
+ "description": "StepFun Token-Plan 使用状态监控工具,支持 Claude Code 状态栏集成",
5
+ "main": "cli/index.js",
6
+ "bin": {
7
+ "stepfun-status": "cli/index.js",
8
+ "stepfun": "cli/index.js"
9
+ },
10
+ "files": [
11
+ "cli/",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "test": "node cli/index.js health",
16
+ "start": "node cli/index.js status",
17
+ "lint": "node --check cli/api.js cli/index.js"
18
+ },
19
+ "keywords": [
20
+ "stepfun",
21
+ "step",
22
+ "claude",
23
+ "claude-code",
24
+ "status",
25
+ "monitor",
26
+ "token-plan"
27
+ ],
28
+ "author": "Daiyimo",
29
+ "license": "MIT",
30
+ "homepage": "https://github.com/Daiyimo/stepfun-status",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/Daiyimo/stepfun-status.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/Daiyimo/stepfun-status/issues"
37
+ },
38
+ "dependencies": {
39
+ "boxen": "^8.0.1",
40
+ "chalk": "^5.6.2",
41
+ "commander": "^14.0.2",
42
+ "ora": "^9.0.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=16.0.0"
46
+ }
47
+ }