ccus-cli 0.2.6 → 0.2.8
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 +15 -1
- package/dist/cli.js +15 -7
- package/dist/lib/api-mode.js +124 -10
- package/dist/lib/codex-fetcher.js +185 -8
- package/dist/lib/codex-sessions.js +40 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- `ccus statusline emit`:读取 Claude Code statusline 通过 `stdin` 传入的 JSON,输出 statusline 文本,并写入本地日志(加 `--no-store` / `--no-log` 则只输出、不落盘)。
|
|
7
7
|
- `ccus dashboard serve`:直接启动本地 Web 页面,不用先手动生成 HTML 文件。
|
|
8
8
|
- `ccus export`:默认导出当前周数据包(gzip 压缩的 `.json.gz`),里面同时包含原始事件和按天维度的周汇总。
|
|
9
|
+
- `ccus sessions`:把指定周内活跃的 Claude 与 Codex 原始 session 完整打包成 zip。
|
|
9
10
|
- `ccus aggregate`:读取一个目录里的多人 export bundle(`.json.gz` 或 `.json`),输出明细、按天、按周三个 CSV。
|
|
10
11
|
- `ccus aggregate serve`:同样以 bundle 目录为输入,启动本地多人 dashboard 页面,不落地任何文件。
|
|
11
12
|
- `ccus sync`:定时把当前周数据包导出并复制到一个目标目录(如团队共享盘),目标目录下按周自动建子目录;日常由 statusline 兜底触发,也可挂系统计划任务做到严格每天定时。
|
|
@@ -81,6 +82,7 @@ ccus install
|
|
|
81
82
|
ccus export # 导出当前周数据包(this-week)
|
|
82
83
|
ccus export lw # 导出上一整周(last-week,周一到周日)
|
|
83
84
|
ccus export tw # 导出本周(等价于默认 ccus export)
|
|
85
|
+
ccus sessions # 打包本周活跃的 Claude 与 Codex 原始 session
|
|
84
86
|
|
|
85
87
|
ccus dashboard serve # 启动本地页面,默认看本周(this-week)的 5 小时使用率曲线与每日用户消息数
|
|
86
88
|
|
|
@@ -138,6 +140,9 @@ ccus export --range last-week
|
|
|
138
140
|
ccus export lw # 位置参数简写,等价于 --range last-week
|
|
139
141
|
ccus export tw # 等价于 --range this-week
|
|
140
142
|
ccus export --out ./alice_export_2026-05-26_to_2026-06-01.json # --out 指定非 .gz 路径时写明文 JSON
|
|
143
|
+
ccus sessions # 默认导出本周
|
|
144
|
+
ccus sessions lw # 导出上一整周
|
|
145
|
+
ccus sessions --out ./sessions.zip
|
|
141
146
|
ccus aggregate --input-dir ./team-exports --out-dir ./team-report
|
|
142
147
|
ccus aggregate serve --input-dir ./team-exports
|
|
143
148
|
```
|
|
@@ -169,6 +174,13 @@ ccus aggregate serve --input-dir ./team-exports
|
|
|
169
174
|
- `dailySummaries` 会按每天输出消息数、请求数、token 和当天 statusline usage 摘要
|
|
170
175
|
- 不再支持其它导出格式
|
|
171
176
|
|
|
177
|
+
### 原始 session 打包
|
|
178
|
+
|
|
179
|
+
- `ccus sessions` 默认打包本周;支持 `tw`、`lw`、`today`、`5h` 等与 `export` 相同的范围写法。
|
|
180
|
+
- 只选择文件中至少一条记录落入时间窗的 session,但 zip 内保存完整原始文件,不裁剪内容。
|
|
181
|
+
- Claude 文件保持 `<projectDir>/<sessionId>.jsonl` 路径;Codex rollout 保持 `codex/<CODEX_HOME 下 sessions 的相对路径>`。
|
|
182
|
+
- 默认写入 `<data-dir>/sessions/projects_<start>_<end>_<gitUserName>.zip`;`--out` 可指定路径。
|
|
183
|
+
|
|
172
184
|
## 多人汇总
|
|
173
185
|
|
|
174
186
|
- 输入目录放很多通过 `ccus export` 导出的 bundle 文件,`.json.gz`(gzip 压缩)与明文 `.json` 都能识别,gzip 文件读取时自动解压
|
|
@@ -252,6 +264,7 @@ ccus api config # 不带参数:打印当前配置
|
|
|
252
264
|
- **缓存**:statusline 高频调用,额度按 TTL 缓存(默认 5 分钟,`--ttl 5m` 改),拉取失败自动回退上次缓存;缓存文件 `api-quota-cache.json`。
|
|
253
265
|
- **智谱请求头**:除 `Authorization` 外,`--project` / `--organization` 会作为 `bigmodel-project` / `bigmodel-organization` 头发出(团队版需要)。接口对请求来源敏感,若返回空响应疑似被拦,可用 `ccus api config --user-agent "<浏览器UA>"` 改 UA。
|
|
254
266
|
- **自定义 provider**:`--provider custom` 配其它厂商。简单响应用 `--url` / `--method` / `--header "K: V; K2: V2"`(header 值支持 `{{token}}` 占位)/ `--five-hour-path` / `--seven-day-path`(点分路径,如 `data.limits.0.percentage`)映射字段;响应结构复杂(数组筛选/排序)时用 `--extractor-file script.js` 写一段 JS 函数,接收响应对象、返回 `{ fiveHour, sevenDay }` 或 cc-switch 风格的 `[{used}, {used}]`,配了 extractor 就优先于点分路径。脚本在 ccus 进程内执行,只放信任的来源。
|
|
267
|
+
- **统一 env 代理**:智谱 / custom 的额度请求经 ccus 统一代理通道(读 `https_proxy` / `http_proxy` / `all_proxy` / `NO_PROXY`,小写优先、对齐 curl),代理环境也能拉到额度;想给 ccus 单独指定代理(覆盖系统代理)用专属的 `CCUS_PROXY`(同时管 https / http 目标,仍受 `NO_PROXY` 约束),无代理变量维持直连。
|
|
255
268
|
|
|
256
269
|
> API 模式填充的 5h/7d 会和官方 `rate_limits` 走同一条聚合管线(含 7d 累计去毛刺算法,该算法针对 Claude 官方锯齿波设计);不同厂商的额度曲线语义可能不完全一致,混用聚合时注意。
|
|
257
270
|
|
|
@@ -293,7 +306,8 @@ ccus install --codex --uninstall # 移除 ccus 的 Stop hook
|
|
|
293
306
|
要点:
|
|
294
307
|
|
|
295
308
|
- **只支持 Codex CLI**:hooks 与 `codex app-server` 都是 CLI 能力。Codex 桌面版 app(已并入 ChatGPT 桌面 app)不一定触发 CLI 的 hooks、也不一定随附可 spawn 的 `codex` 二进制,不在本期范围。
|
|
296
|
-
- **代理继承**:ccus 由 Codex 直接 spawn,已继承 Codex 的代理 / `CODEX_HOME`;它再 spawn `codex app-server` 时透传 `process.env
|
|
309
|
+
- **代理继承**:ccus 由 Codex 直接 spawn,已继承 Codex 的代理 / `CODEX_HOME`;它再 spawn `codex app-server` 时透传 `process.env`,整条链同环境。主路径必须让 codex 子进程发请求(它读 `HTTP_PROXY` / `HTTPS_PROXY`);wham 回退走 ccus 自己的 Node HTTP,但经统一 env 代理通道(读 `CCUS_PROXY` / `https_proxy` / `http_proxy` / `all_proxy` / `NO_PROXY`,`CCUS_PROXY` 优先、小写优先、对齐 curl),代理环境同样可达。
|
|
310
|
+
- **无 codex 命令时回退 wham**:本机没装 `codex` CLI(spawn 返回 `unavailable`)且无新鲜缓存时,ccus 改走 ChatGPT 后端 `wham/usage` HTTP 直连——读 `~/.codex/auth.json` 的 OAuth token(仅 chatgpt 登录模式)、按各窗 `limit_window_seconds` 认 5h / 7d 桶,照常落盘 `source="codex"` 事件。让「装过 codex 留下 hook + auth.json、但 codex 未入 PATH / 已卸载」的用户也能采到额度。仅 `unavailable` 触发,超时 / RPC 错(`error`)不回退。
|
|
297
311
|
- **定时同步**:Stop hook 还兜底触发 `ccus sync`(与 Claude statusline 对称)——配过 `ccus sync config --target` 后,Codex 每 turn 结束都会检查 3h 周期,到期自动 export + 复制 bundle(含 Codex token/消息)到目标目录。只用 Codex、不开 Claude Code 时也能自动同步。
|
|
298
312
|
- **缓存节流**:Stop 每 turn 触发,额度按 5 分钟 TTL 缓存(`codex-quota-cache.json`),命中秒回不 spawn;过期才拉一次(带 ~10s 超时),失败回退旧缓存。
|
|
299
313
|
- **字段映射**:app-server 返回 `primary`(5h)/ `secondary`(weekly)两窗口,取各自的 `usedPercent`(驼峰,clamp 0–100);ccus 填进 `rate_limits.five_hour` / `seven_day` 的 `used_percentage`,`computeStatuslineEvent` 读时自动算出 usage。
|
package/dist/cli.js
CHANGED
|
@@ -67,7 +67,7 @@ const update_check_1 = require("./lib/update-check");
|
|
|
67
67
|
const version_1 = require("./lib/version");
|
|
68
68
|
/** CLI 帮助信息保持简洁,方便直接挂到 README 或终端里查看。 */
|
|
69
69
|
function printHelp() {
|
|
70
|
-
process.stdout.write(`ccus\n\nCommands:\n ccus install [--settings PATH] [--command CMD] [--data-dir PATH] (默认装 Claude statusLine;--codex 改写 Codex 的 ~/.codex/config.toml notify,配 --uninstall 移除)\n ccus statusline emit [--data-dir PATH] [--input FILE] [--no-store]\n ccus dashboard build [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard open [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard serve [--range today|this-week|last-week|5h] [--port 0] [--host 127.0.0.1] [--open] [--data-dir PATH]\n ccus export [RANGE] [--out FILE] [--data-dir PATH] (RANGE: this-week|tw, last-week|lw, today, 5h; e.g. ccus export lw)\n ccus sessions [RANGE] [--out FILE] [--data-dir PATH] (把
|
|
70
|
+
process.stdout.write(`ccus\n\nCommands:\n ccus install [--settings PATH] [--command CMD] [--data-dir PATH] (默认装 Claude statusLine;--codex 改写 Codex 的 ~/.codex/config.toml notify,配 --uninstall 移除)\n ccus statusline emit [--data-dir PATH] [--input FILE] [--no-store]\n ccus dashboard build [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard open [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard serve [--range today|this-week|last-week|5h] [--port 0] [--host 127.0.0.1] [--open] [--data-dir PATH]\n ccus export [RANGE] [--out FILE] [--data-dir PATH] (RANGE: this-week|tw, last-week|lw, today, 5h; e.g. ccus export lw)\n ccus sessions [RANGE] [--out FILE] [--data-dir PATH] (把 Claude 与 Codex 的活跃 session 打包成 zip,默认本周)\n ccus aggregate --input-dir DIR [--out-dir DIR]\n ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]\n ccus sync [--data-dir PATH]\n ccus sync config [--target DIR] [--interval 3h|daily|<N>h|<N>m] [--range this-week] [--suffix NAME | --no-suffix] [--data-dir PATH]\n ccus sync install [--print] [--data-dir PATH] (注册每周五 18:00 的系统调度器)\n ccus sync uninstall [--print] (卸载系统调度器)\n ccus sync status [--data-dir PATH]\n ccus api config [--enable|--disable] [--provider zhipu|custom] [--token-env NAME] [--token VAL] [--url URL] [--project P] [--organization O] [--ttl 5m] [--extractor-file FILE] [--data-dir PATH]\n ccus api test [--data-dir PATH] (立即拉取第三方额度并打印,验证配置是否生效)\n ccus api status [--data-dir PATH]\n ccus open [--data-dir PATH] [--print]\n ccus update [--data-dir PATH]\n ccus --version\n\nGlobal flags:\n --verbose | --debug | -v 输出详细调试日志到 stderr(等价于设置 CCUS_DEBUG=1),方便排查问题\n`);
|
|
71
71
|
}
|
|
72
72
|
/** 一个轻量的参数解析器,当前命令面不复杂,没必要引入额外依赖。 */
|
|
73
73
|
function parseOptions(args) {
|
|
@@ -656,9 +656,9 @@ function resolveExportOptions(action, args, rest) {
|
|
|
656
656
|
return options;
|
|
657
657
|
}
|
|
658
658
|
/**
|
|
659
|
-
* `ccus sessions`:把
|
|
659
|
+
* `ccus sessions`:把 Claude 与 Codex 中在指定时间范围内有活动的 session 文件打包成 zip。
|
|
660
660
|
*
|
|
661
|
-
*
|
|
661
|
+
* Claude 内部结构保持 <projectDir>/<sessionId>.jsonl,Codex 保持 codex/<sessions 相对路径>。
|
|
662
662
|
* 文件名格式:projects_<start>_<end>_<gitUserName>.zip。
|
|
663
663
|
* 默认输出到 <data-dir>/sessions/,加 --out 可指定完整路径。
|
|
664
664
|
* 位置参数作为 range 简写,例如 `ccus sessions lw` 等价于 `--range last-week`。
|
|
@@ -670,14 +670,22 @@ async function handleSessions(options) {
|
|
|
670
670
|
const now = new Date();
|
|
671
671
|
const window = (0, time_1.expandToFullWeekWindow)((0, time_1.resolveRange)(range, now));
|
|
672
672
|
(0, debug_1.debugLog)("sessions", "range resolved", { range, label: window.label, start: window.start.toISOString(), end: window.end.toISOString() });
|
|
673
|
-
const
|
|
674
|
-
|
|
673
|
+
const [claudeSessions, codexSessions] = await Promise.all([
|
|
674
|
+
(0, claude_1.findActiveSessionFiles)(window.start, window.end),
|
|
675
|
+
(0, codex_sessions_1.findActiveCodexSessionFiles)(window.start, window.end),
|
|
676
|
+
]);
|
|
677
|
+
(0, debug_1.debugLog)("sessions", "active sessions found", { claude: claudeSessions.length, codex: codexSessions.length });
|
|
675
678
|
const fsRead = (await Promise.resolve().then(() => __importStar(require("node:fs/promises")))).readFile;
|
|
676
679
|
const { buildZipBuffer } = await Promise.resolve().then(() => __importStar(require("./lib/zip")));
|
|
677
|
-
const
|
|
680
|
+
const claudeEntries = claudeSessions.map(async (session) => ({
|
|
678
681
|
name: `${session.projectDir.replaceAll("\\", "/")}/${session.sessionId}.jsonl`,
|
|
679
682
|
data: await fsRead(session.filePath),
|
|
680
|
-
}))
|
|
683
|
+
}));
|
|
684
|
+
const codexEntries = codexSessions.map(async (session) => ({
|
|
685
|
+
name: `codex/${session.relativePath.replaceAll("\\", "/")}`,
|
|
686
|
+
data: await fsRead(session.filePath),
|
|
687
|
+
}));
|
|
688
|
+
const entries = await Promise.all([...claudeEntries, ...codexEntries]);
|
|
681
689
|
const zipBuffer = await buildZipBuffer(entries);
|
|
682
690
|
const gitIdentity = await (0, git_1.readGitIdentity)();
|
|
683
691
|
const userName = (0, time_1.formatGitEmailFilePrefix)(gitIdentity.userEmail) ?? "unknown";
|
package/dist/lib/api-mode.js
CHANGED
|
@@ -13,6 +13,9 @@ exports.resolveApiTokenWithSettings = resolveApiTokenWithSettings;
|
|
|
13
13
|
exports.extractCustomQuota = extractCustomQuota;
|
|
14
14
|
exports.runExtractor = runExtractor;
|
|
15
15
|
exports.normalizeExtractorResult = normalizeExtractorResult;
|
|
16
|
+
exports.isNoProxyHost = isNoProxyHost;
|
|
17
|
+
exports.resolveProxyUrl = resolveProxyUrl;
|
|
18
|
+
exports.httpRequest = httpRequest;
|
|
16
19
|
exports.fetchQuota = fetchQuota;
|
|
17
20
|
exports.readApiQuotaCacheSync = readApiQuotaCacheSync;
|
|
18
21
|
exports.resolveApiQuota = resolveApiQuota;
|
|
@@ -308,19 +311,130 @@ function extractArrayNumber(item) {
|
|
|
308
311
|
}
|
|
309
312
|
return null;
|
|
310
313
|
}
|
|
311
|
-
/**
|
|
312
|
-
function
|
|
313
|
-
|
|
314
|
-
|
|
314
|
+
/** 按顺序取第一个非空(去空白后非空)的环境变量值。 */
|
|
315
|
+
function pickEnv(env, ...names) {
|
|
316
|
+
for (const name of names) {
|
|
317
|
+
const v = env[name];
|
|
318
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
319
|
+
return v.trim();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* NO_PROXY 匹配:逗号 / 空白分隔的域名后缀,支持精确匹配、后缀匹配、`*` 全匹配(对齐 curl / proxy-from-env)。
|
|
326
|
+
* 每项可带前导 `.`(`.example.com` 等价 `example.com`);hostname 命中任一项即视为应直连。
|
|
327
|
+
*/
|
|
328
|
+
function isNoProxyHost(hostname, noProxy) {
|
|
329
|
+
const items = noProxy
|
|
330
|
+
.split(/[\s,]+/)
|
|
331
|
+
.map((s) => s.trim())
|
|
332
|
+
.filter((s) => s.length > 0);
|
|
333
|
+
for (const raw of items) {
|
|
334
|
+
if (raw === "*") {
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
const item = raw.startsWith(".") ? raw.slice(1) : raw;
|
|
338
|
+
if (item === hostname || hostname.endsWith(`.${item}`)) {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* 按环境变量解析目标 URL 应走的代理(对齐 curl / proxy-from-env),纯函数、可单测。
|
|
346
|
+
*
|
|
347
|
+
* https 目标读 `https_proxy` → `HTTPS_PROXY` → `all_proxy` → `ALL_PROXY`;
|
|
348
|
+
* http 目标读 `http_proxy` → `HTTP_PROXY` → `all_proxy` → `ALL_PROXY`。**小写优先**。
|
|
349
|
+
* `NO_PROXY` / `no_proxy`(小写优先)命中的主机、或无任何代理变量时返回 null(直连)。
|
|
350
|
+
*/
|
|
351
|
+
function resolveProxyUrl(targetUrl, env) {
|
|
352
|
+
let parsed;
|
|
353
|
+
try {
|
|
354
|
+
parsed = new URL(targetUrl);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const noProxy = pickEnv(env, "no_proxy", "NO_PROXY");
|
|
360
|
+
if (noProxy && isNoProxyHost(parsed.hostname, noProxy)) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
// CCUS_PROXY:ccus 专属代理,单一值同时管 https / http 目标,优先于标准 https_proxy / http_proxy / all_proxy。
|
|
364
|
+
const ccusProxy = pickEnv(env, "CCUS_PROXY");
|
|
365
|
+
if (ccusProxy) {
|
|
366
|
+
return ccusProxy;
|
|
367
|
+
}
|
|
368
|
+
const isHttps = parsed.protocol === "https:" || parsed.protocol === "wss:";
|
|
369
|
+
const proxy = isHttps
|
|
370
|
+
? pickEnv(env, "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY")
|
|
371
|
+
: pickEnv(env, "http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY");
|
|
372
|
+
return proxy ?? null;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* 懒加载两个代理 agent 构造器,模块级缓存。
|
|
376
|
+
*
|
|
377
|
+
* 这两个包是 ESM-only:ccus 是 CommonJS,Node 20/21 的顶层 `require(esm)` 会 `ERR_REQUIRE_ESM`。
|
|
378
|
+
* 必须用**原生**动态 `import()`(CJS 里对全 Node 版本可异步加载 ESM);但 tsc 在 `module: commonjs` 下会把
|
|
379
|
+
* `import()` 降级成 `require`(Node 20 仍崩),故用 `new Function` 构造一个运行时 `import()`,绕过 tsc 降级。
|
|
380
|
+
* 加载失败返回 null(回退直连)。
|
|
381
|
+
*/
|
|
382
|
+
const nativeImport = new Function("specifier", "return import(specifier)");
|
|
383
|
+
let proxyAgentsPromise = null;
|
|
384
|
+
function loadProxyAgents() {
|
|
385
|
+
if (proxyAgentsPromise) {
|
|
386
|
+
return proxyAgentsPromise;
|
|
387
|
+
}
|
|
388
|
+
proxyAgentsPromise = (async () => {
|
|
315
389
|
try {
|
|
316
|
-
|
|
390
|
+
const [httpsMod, httpMod] = (await Promise.all([
|
|
391
|
+
nativeImport("https-proxy-agent"),
|
|
392
|
+
nativeImport("http-proxy-agent"),
|
|
393
|
+
]));
|
|
394
|
+
return {
|
|
395
|
+
HttpsProxyAgent: httpsMod.HttpsProxyAgent,
|
|
396
|
+
HttpProxyAgent: httpMod.HttpProxyAgent,
|
|
397
|
+
};
|
|
317
398
|
}
|
|
318
|
-
catch {
|
|
319
|
-
|
|
320
|
-
return;
|
|
399
|
+
catch (error) {
|
|
400
|
+
(0, debug_1.debugLog)("api-mode", "failed to load proxy agent modules", error instanceof Error ? error.message : String(error));
|
|
401
|
+
return null;
|
|
321
402
|
}
|
|
322
|
-
|
|
323
|
-
|
|
403
|
+
})();
|
|
404
|
+
return proxyAgentsPromise;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* 按 url 协议发起 HTTP(S) 请求,返回 {status, body};超时或错误 reject。
|
|
408
|
+
*
|
|
409
|
+
* 自带 env 代理支持(wham 回退 / 智谱 / custom 共用一条通道):有代理则给请求挂 `https-proxy-agent` /
|
|
410
|
+
* `http-proxy-agent`,无代理 / `NO_PROXY` 命中 / 代理模块加载失败时维持默认 globalAgent。`env` 默认
|
|
411
|
+
* `process.env`,对无代理环境行为零变化。
|
|
412
|
+
*/
|
|
413
|
+
async function httpRequest(url, opts, env = process.env) {
|
|
414
|
+
let parsed;
|
|
415
|
+
try {
|
|
416
|
+
parsed = new URL(url);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
throw new Error(`invalid url: ${url}`);
|
|
420
|
+
}
|
|
421
|
+
const transport = parsed.protocol === "http:" ? node_http_1.default : node_https_1.default;
|
|
422
|
+
const requestOptions = { method: opts.method, headers: opts.headers };
|
|
423
|
+
const proxyUrl = resolveProxyUrl(url, env);
|
|
424
|
+
if (proxyUrl) {
|
|
425
|
+
const agents = await loadProxyAgents();
|
|
426
|
+
if (agents) {
|
|
427
|
+
try {
|
|
428
|
+
requestOptions.agent =
|
|
429
|
+
parsed.protocol === "http:" ? new agents.HttpProxyAgent(proxyUrl) : new agents.HttpsProxyAgent(proxyUrl);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
(0, debug_1.debugLog)("api-mode", "failed to create proxy agent, falling back to direct", error instanceof Error ? error.message : String(error));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return new Promise((resolve, reject) => {
|
|
437
|
+
const request = transport.request(url, requestOptions, (response) => {
|
|
324
438
|
const status = response.statusCode ?? 0;
|
|
325
439
|
let body = "";
|
|
326
440
|
response.setEncoding("utf8");
|
|
@@ -3,7 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.readCodexAuth = readCodexAuth;
|
|
6
7
|
exports.fetchCodexQuota = fetchCodexQuota;
|
|
8
|
+
exports.parseWhamUsage = parseWhamUsage;
|
|
9
|
+
exports.fetchCodexQuotaViaWham = fetchCodexQuotaViaWham;
|
|
7
10
|
exports.readCodexQuotaCacheSync = readCodexQuotaCacheSync;
|
|
8
11
|
exports.resolveCodexQuota = resolveCodexQuota;
|
|
9
12
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -12,12 +15,19 @@ const promises_1 = __importDefault(require("node:fs/promises"));
|
|
|
12
15
|
const node_os_1 = require("node:os");
|
|
13
16
|
const node_path_1 = require("node:path");
|
|
14
17
|
const debug_1 = require("./debug");
|
|
18
|
+
const api_mode_1 = require("./api-mode");
|
|
15
19
|
const paths_1 = require("./paths");
|
|
16
20
|
// app-server 子进程参数:read-only + untrusted,Codex 官方推荐的最低权限拉额度姿态。
|
|
17
21
|
const CODEX_ARGS = ["-s", "read-only", "-a", "untrusted", "app-server"];
|
|
18
22
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
19
23
|
// notify 每 turn 触发,额度缓存 5 分钟,命中秒回避免阻塞 Codex 主流程。
|
|
20
24
|
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
25
|
+
// ChatGPT 后端 wham/usage 回退:仅本机无 codex CLI(spawn unavailable)时走,直连读 auth.json 的 OAuth token。
|
|
26
|
+
const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
27
|
+
const WHAM_TIMEOUT_MS = 15_000;
|
|
28
|
+
// wham 各窗口自带的时长(秒):5h 窗 18000、周窗 604800,按它认桶、不假设 primary/secondary 顺序。
|
|
29
|
+
const WHAM_SESSION_WINDOW_SECONDS = 18000;
|
|
30
|
+
const WHAM_WEEKLY_WINDOW_SECONDS = 604800;
|
|
21
31
|
function isRecord(value) {
|
|
22
32
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
33
|
}
|
|
@@ -136,6 +146,36 @@ function parseRateLimitsResult(result) {
|
|
|
136
146
|
function getCodexHome(codexHomePath) {
|
|
137
147
|
return codexHomePath ?? process.env.CODEX_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), ".codex");
|
|
138
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* 读 `$CODEX_HOME/auth.json`(默认 `~/.codex/auth.json`)的 OAuth token,仅 `auth_mode === "chatgpt"` 返回。
|
|
151
|
+
*
|
|
152
|
+
* 对齐 cc-switch 文件源结构 `{ auth_mode, tokens: { access_token, account_id }, last_refresh }`。
|
|
153
|
+
* 缺文件 / API key 模式(auth_mode !== "chatgpt")/ 结构异常均返回 null,绝不抛错。不实现 refresh、不读 Keychain。
|
|
154
|
+
* auth.json 结构随 Codex 升级变、易碎,解析宽松、缺字段放弃。
|
|
155
|
+
*/
|
|
156
|
+
function readCodexAuth(codexHomePath) {
|
|
157
|
+
try {
|
|
158
|
+
const authPath = (0, node_path_1.join)(getCodexHome(codexHomePath), "auth.json");
|
|
159
|
+
const raw = node_fs_1.default.readFileSync(authPath, "utf8");
|
|
160
|
+
const parsed = JSON.parse(raw);
|
|
161
|
+
if (!isRecord(parsed) || parsed.auth_mode !== "chatgpt") {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const tokens = parsed.tokens;
|
|
165
|
+
if (!isRecord(tokens)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const accessToken = typeof tokens.access_token === "string" && tokens.access_token.trim() !== "" ? tokens.access_token : null;
|
|
169
|
+
if (!accessToken) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const accountId = typeof tokens.account_id === "string" && tokens.account_id.trim() !== "" ? tokens.account_id : null;
|
|
173
|
+
return { accessToken, accountId };
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
139
179
|
function buildRpcMessage(id, method, params) {
|
|
140
180
|
return `${JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} })}\n`;
|
|
141
181
|
}
|
|
@@ -266,6 +306,125 @@ async function fetchCodexQuota(options = {}) {
|
|
|
266
306
|
});
|
|
267
307
|
});
|
|
268
308
|
}
|
|
309
|
+
// --- wham/usage HTTP 直连回退(仅主路径 unavailable 时由 resolveCodexQuota 编排) ---
|
|
310
|
+
/** 读 wham 窗口使用率:主字段 `used_percent`(下划线,wham 实际返回),驼峰 / `used_percentage` 防御性 fallback;窗口为 null 返回 null。 */
|
|
311
|
+
function readWhamUsedPercent(window) {
|
|
312
|
+
if (!window) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
return clampPercent(toFiniteNumber(window.used_percent ?? window.usedPercent ?? window.used_percentage));
|
|
316
|
+
}
|
|
317
|
+
/** 读 wham 窗口重置时间:`reset_at` Unix 秒;< 10^10 视为秒转毫秒,否则按毫秒;窗口为 null 返回 null。 */
|
|
318
|
+
function readWhamResetsAtMs(window) {
|
|
319
|
+
if (!window) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
const n = toFiniteNumber(window.reset_at ?? window.resetsAt);
|
|
323
|
+
if (n === null) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return n < 10_000_000_000 ? n * 1000 : n;
|
|
327
|
+
}
|
|
328
|
+
/** 按窗口时长(秒)认桶:18000→5h、604800→7d,未知返回 null。 */
|
|
329
|
+
function classifyWhamWindow(seconds) {
|
|
330
|
+
if (seconds === null) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
if (seconds === WHAM_SESSION_WINDOW_SECONDS) {
|
|
334
|
+
return "session";
|
|
335
|
+
}
|
|
336
|
+
if (seconds === WHAM_WEEKLY_WINDOW_SECONDS) {
|
|
337
|
+
return "weekly";
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* 解析 wham/usage 响应。
|
|
343
|
+
*
|
|
344
|
+
* 结构 `rate_limit.{primary_window, secondary_window}.{used_percent, limit_window_seconds, reset_at}`,
|
|
345
|
+
* **按 `limit_window_seconds` 认桶**(18000→5h、604800→7d),不假设 primary/secondary 顺序(与主路径按
|
|
346
|
+
* `windowDurationMins` 认桶同构)。某窗缺 `used_percent` 跳过、不阻断另一窗;两窗都缺返回全 null。
|
|
347
|
+
*/
|
|
348
|
+
function parseWhamUsage(json) {
|
|
349
|
+
const rateLimit = isRecord(json) && isRecord(json.rate_limit) ? json.rate_limit : null;
|
|
350
|
+
if (!rateLimit) {
|
|
351
|
+
return { fiveHour: null, sevenDay: null, resetsAt: null };
|
|
352
|
+
}
|
|
353
|
+
let session = null;
|
|
354
|
+
let weekly = null;
|
|
355
|
+
for (const window of [rateLimit.primary_window, rateLimit.secondary_window]) {
|
|
356
|
+
if (!isRecord(window)) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (readWhamUsedPercent(window) === null) {
|
|
360
|
+
continue; // 某窗缺 used_percent 跳过。
|
|
361
|
+
}
|
|
362
|
+
const kind = classifyWhamWindow(toFiniteNumber(window.limit_window_seconds));
|
|
363
|
+
if (kind === "session" && !session) {
|
|
364
|
+
session = window;
|
|
365
|
+
}
|
|
366
|
+
else if (kind === "weekly" && !weekly) {
|
|
367
|
+
weekly = window;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
fiveHour: readWhamUsedPercent(session),
|
|
372
|
+
sevenDay: readWhamUsedPercent(weekly),
|
|
373
|
+
resetsAt: readWhamResetsAtMs(session) ?? readWhamResetsAtMs(weekly),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/** 构造一个全 null 的 outcome 占位(wham 内部失败统一用 error)。 */
|
|
377
|
+
function emptyWhamOutcome(status) {
|
|
378
|
+
return { fiveHour: null, sevenDay: null, resetsAt: null, status };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* wham/usage HTTP 直连回退:读 auth.json 的 chatgpt OAuth token → `GET wham/usage` → parseWhamUsage 认桶。
|
|
382
|
+
*
|
|
383
|
+
* 仅在主路径 `unavailable`(本机无 codex CLI)时由 resolveCodexQuota 编排调用。全程不抛错:
|
|
384
|
+
* 无 token / HTTP 失败 / 非 2xx / JSON 异常 / 解析空 → `{ status: "error", ... }`,成功 → `{ status: "ok", ... }`。
|
|
385
|
+
* 请求经 api-mode 的 httpRequest(自带统一 env 代理通道,代理环境也能回退采到额度)。
|
|
386
|
+
*/
|
|
387
|
+
async function fetchCodexQuotaViaWham(options = {}) {
|
|
388
|
+
const env = options.env ?? process.env;
|
|
389
|
+
const timeoutMs = options.timeoutMs ?? WHAM_TIMEOUT_MS;
|
|
390
|
+
const authReader = options.authReader ?? readCodexAuth;
|
|
391
|
+
const httpGet = options.httpGet ?? api_mode_1.httpRequest;
|
|
392
|
+
const auth = authReader(options.codexHomePath);
|
|
393
|
+
if (!auth) {
|
|
394
|
+
(0, debug_1.debugLog)("codex", "wham fallback: no chatgpt auth token");
|
|
395
|
+
return emptyWhamOutcome("error");
|
|
396
|
+
}
|
|
397
|
+
const headers = {
|
|
398
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
399
|
+
"User-Agent": "codex-cli",
|
|
400
|
+
Accept: "application/json",
|
|
401
|
+
};
|
|
402
|
+
if (auth.accountId) {
|
|
403
|
+
headers["ChatGPT-Account-Id"] = auth.accountId;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const { status, body } = await httpGet(WHAM_USAGE_URL, { method: "GET", headers, timeoutMs }, env);
|
|
407
|
+
if (status < 200 || status >= 300) {
|
|
408
|
+
(0, debug_1.debugLog)("codex", "wham fallback non-2xx", { status });
|
|
409
|
+
return emptyWhamOutcome("error");
|
|
410
|
+
}
|
|
411
|
+
let json;
|
|
412
|
+
try {
|
|
413
|
+
json = JSON.parse(body);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
(0, debug_1.debugLog)("codex", "wham fallback json parse failed", error instanceof Error ? error.message : String(error));
|
|
417
|
+
return emptyWhamOutcome("error");
|
|
418
|
+
}
|
|
419
|
+
const quota = parseWhamUsage(json);
|
|
420
|
+
(0, debug_1.debugLog)("codex", "wham fallback parsed", quota);
|
|
421
|
+
return { ...quota, status: "ok" };
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
(0, debug_1.debugLog)("codex", "wham fallback failed", error instanceof Error ? error.message : String(error));
|
|
425
|
+
return emptyWhamOutcome("error");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
269
428
|
/** 同步读取额度缓存;损坏 / 缺失返回 null。 */
|
|
270
429
|
function readCodexQuotaCacheSync(dataDir) {
|
|
271
430
|
try {
|
|
@@ -314,22 +473,40 @@ async function resolveCodexQuota(dataDir, options = {}) {
|
|
|
314
473
|
const now = options.now ?? new Date();
|
|
315
474
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
316
475
|
const fetcher = options.fetcher ?? fetchCodexQuota;
|
|
476
|
+
const whamFetcher = options.whamFetcher ?? fetchCodexQuotaViaWham;
|
|
317
477
|
const cached = readCodexQuotaCacheSync(dataDir);
|
|
318
478
|
if (cached && isCodexCacheFresh(cached, ttlMs, now)) {
|
|
319
479
|
return quotaFromCache(cached);
|
|
320
480
|
}
|
|
481
|
+
const persist = async (quota) => {
|
|
482
|
+
await writeCodexQuotaCache(dataDir, { ...quota, fetchedAt: now.toISOString() });
|
|
483
|
+
return quota;
|
|
484
|
+
};
|
|
321
485
|
try {
|
|
322
486
|
const outcome = await fetcher(options.fetchOptions);
|
|
323
487
|
if (outcome.status === "ok" && hasAnyQuota(outcome)) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
488
|
+
return await persist({ fiveHour: outcome.fiveHour, sevenDay: outcome.sevenDay, resetsAt: outcome.resetsAt });
|
|
489
|
+
}
|
|
490
|
+
// 主路径 unavailable(本机无 codex CLI)→ wham/usage HTTP 直连回退;
|
|
491
|
+
// error(超时 / RPC 错 / 进程崩)不触发,避免瞬时故障多扛一次 15s HTTP。
|
|
492
|
+
if (outcome.status === "unavailable") {
|
|
493
|
+
try {
|
|
494
|
+
const whamOutcome = await whamFetcher({
|
|
495
|
+
codexHomePath: options.fetchOptions?.codexHomePath,
|
|
496
|
+
env: options.fetchOptions?.env,
|
|
497
|
+
});
|
|
498
|
+
if (whamOutcome.status === "ok" && hasAnyQuota(whamOutcome)) {
|
|
499
|
+
return await persist({ fiveHour: whamOutcome.fiveHour, sevenDay: whamOutcome.sevenDay, resetsAt: whamOutcome.resetsAt });
|
|
500
|
+
}
|
|
501
|
+
(0, debug_1.debugLog)("codex", "wham fallback did not yield usable quota", { status: whamOutcome.status });
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
(0, debug_1.debugLog)("codex", "wham fallback threw", error instanceof Error ? error.message : String(error));
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
(0, debug_1.debugLog)("codex", "fetch did not yield usable quota", { status: outcome.status });
|
|
331
509
|
}
|
|
332
|
-
(0, debug_1.debugLog)("codex", "fetch did not yield usable quota", { status: outcome.status });
|
|
333
510
|
}
|
|
334
511
|
catch (error) {
|
|
335
512
|
(0, debug_1.debugLog)("codex", "fetch threw, fallback to cache", error instanceof Error ? error.message : String(error));
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.findActiveCodexSessionFiles = findActiveCodexSessionFiles;
|
|
6
7
|
exports.summarizeCodexSessionUsage = summarizeCodexSessionUsage;
|
|
7
8
|
exports.summarizeCodexSessionUsageByDay = summarizeCodexSessionUsageByDay;
|
|
8
9
|
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
@@ -44,6 +45,45 @@ function timestampInRange(timestamp, start, end) {
|
|
|
44
45
|
const value = new Date(timestamp).getTime();
|
|
45
46
|
return Number.isFinite(value) && value >= start.getTime() && value <= end.getTime();
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* 找出 `<CODEX_HOME>/sessions` 中在指定时间范围内有活动的 rollout 文件。
|
|
50
|
+
*
|
|
51
|
+
* 只判断文件里是否存在范围内的记录,不过滤内容,导出时完整复制原始文件。
|
|
52
|
+
*/
|
|
53
|
+
async function findActiveCodexSessionFiles(start, end) {
|
|
54
|
+
const sessionsDir = node_path_1.default.join((0, paths_1.getCodexHome)(), "sessions");
|
|
55
|
+
const files = await collectRolloutFiles(sessionsDir);
|
|
56
|
+
const result = [];
|
|
57
|
+
for (const filePath of files) {
|
|
58
|
+
try {
|
|
59
|
+
const content = await promises_1.default.readFile(filePath, "utf8");
|
|
60
|
+
const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
61
|
+
let hasInRange = false;
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
try {
|
|
64
|
+
const record = JSON.parse(line);
|
|
65
|
+
if (isRecord(record) && timestampInRange(getString(record.timestamp), start, end)) {
|
|
66
|
+
hasInRange = true;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (hasInRange) {
|
|
75
|
+
result.push({
|
|
76
|
+
filePath,
|
|
77
|
+
relativePath: node_path_1.default.relative(sessionsDir, filePath),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
47
87
|
/**
|
|
48
88
|
* 从 `token_count` 事件的 `info.last_token_usage` 取 token 用量对象(缺失返回 null)。
|
|
49
89
|
* 必须用 last_token_usage(本次增量),不能用 total_token_usage(会话累计,会重复计)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccus-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Claude Code statusline usage logger and dashboard CLI",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"build": "tsc -p tsconfig.json && node scripts/copy-vendor.js",
|
|
17
17
|
"prepublishOnly": "npm run build",
|
|
18
18
|
"test": "node --test --test-concurrency 1 \"dist/test/**/*.test.js\"",
|
|
19
|
-
"test:src": "node --import tsx --test --test-concurrency 1 src/test/payload.test.ts src/test/dashboard.test.ts src/test/export.test.ts src/test/storage.test.ts src/test/claude.test.ts src/test/aggregate.test.ts src/test/aggregate-dashboard.test.ts src/test/install.test.ts src/test/debug.test.ts src/test/time.test.ts src/test/update-check.test.ts src/test/sync.test.ts src/test/scheduler.test.ts src/test/api-mode.test.ts src/test/codex-fetcher.test.ts src/test/codex-sessions.test.ts src/test/codex-install.test.ts src/test/cli-codex-notify.test.ts src/test/cli-codex-hook.test.ts src/test/cli-install-codex.test.ts",
|
|
19
|
+
"test:src": "node --import tsx --test --test-concurrency 1 src/test/payload.test.ts src/test/dashboard.test.ts src/test/export.test.ts src/test/storage.test.ts src/test/claude.test.ts src/test/aggregate.test.ts src/test/aggregate-dashboard.test.ts src/test/install.test.ts src/test/debug.test.ts src/test/time.test.ts src/test/update-check.test.ts src/test/sync.test.ts src/test/scheduler.test.ts src/test/api-mode.test.ts src/test/codex-fetcher.test.ts src/test/codex-sessions.test.ts src/test/sessions.test.ts src/test/codex-install.test.ts src/test/cli-codex-notify.test.ts src/test/cli-codex-hook.test.ts src/test/cli-install-codex.test.ts",
|
|
20
20
|
"smoke:api": "node scripts/smoke-api.mjs"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
@@ -37,5 +37,9 @@
|
|
|
37
37
|
"@types/node": "^24.0.0",
|
|
38
38
|
"tsx": "^4.20.0",
|
|
39
39
|
"typescript": "^5.8.3"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"http-proxy-agent": "^9.1.0",
|
|
43
|
+
"https-proxy-agent": "^9.1.0"
|
|
40
44
|
}
|
|
41
45
|
}
|