ccus-cli 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -252,6 +252,7 @@ ccus api config # 不带参数:打印当前配置
252
252
  - **缓存**:statusline 高频调用,额度按 TTL 缓存(默认 5 分钟,`--ttl 5m` 改),拉取失败自动回退上次缓存;缓存文件 `api-quota-cache.json`。
253
253
  - **智谱请求头**:除 `Authorization` 外,`--project` / `--organization` 会作为 `bigmodel-project` / `bigmodel-organization` 头发出(团队版需要)。接口对请求来源敏感,若返回空响应疑似被拦,可用 `ccus api config --user-agent "<浏览器UA>"` 改 UA。
254
254
  - **自定义 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 进程内执行,只放信任的来源。
255
+ - **统一 env 代理**:智谱 / custom 的额度请求经 ccus 统一代理通道(读 `https_proxy` / `http_proxy` / `all_proxy` / `NO_PROXY`,小写优先、对齐 curl),代理环境也能拉到额度;想给 ccus 单独指定代理(覆盖系统代理)用专属的 `CCUS_PROXY`(同时管 https / http 目标,仍受 `NO_PROXY` 约束),无代理变量维持直连。
255
256
 
256
257
  > API 模式填充的 5h/7d 会和官方 `rate_limits` 走同一条聚合管线(含 7d 累计去毛刺算法,该算法针对 Claude 官方锯齿波设计);不同厂商的额度曲线语义可能不完全一致,混用聚合时注意。
257
258
 
@@ -293,7 +294,8 @@ ccus install --codex --uninstall # 移除 ccus 的 Stop hook
293
294
  要点:
294
295
 
295
296
  - **只支持 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`,整条链同环境。必须让 codex 子进程发请求(它读 `HTTP_PROXY`/`HTTPS_PROXY`),不能用 ccus 自己的 Node `fetch`(裸 fetch 不读代理、代理环境下直连失败)。
297
+ - **代理继承**: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),代理环境同样可达。
298
+ - **无 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
299
  - **定时同步**:Stop hook 还兜底触发 `ccus sync`(与 Claude statusline 对称)——配过 `ccus sync config --target` 后,Codex 每 turn 结束都会检查 3h 周期,到期自动 export + 复制 bundle(含 Codex token/消息)到目标目录。只用 Codex、不开 Claude Code 时也能自动同步。
298
300
  - **缓存节流**:Stop 每 turn 触发,额度按 5 分钟 TTL 缓存(`codex-quota-cache.json`),命中秒回不 spawn;过期才拉一次(带 ~10s 超时),失败回退旧缓存。
299
301
  - **字段映射**:app-server 返回 `primary`(5h)/ `secondary`(weekly)两窗口,取各自的 `usedPercent`(驼峰,clamp 0–100);ccus 填进 `rate_limits.five_hour` / `seven_day` 的 `used_percentage`,`computeStatuslineEvent` 读时自动算出 usage。
@@ -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
- /** url 协议发起 HTTP(S) 请求,返回 {status, body};超时或错误 reject(仿 fetchLatestVersion)。 */
312
- function httpRequest(url, opts) {
313
- return new Promise((resolve, reject) => {
314
- let parsed;
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
- parsed = new URL(url);
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
- reject(new Error(`invalid url: ${url}`));
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
- const transport = parsed.protocol === "http:" ? node_http_1.default : node_https_1.default;
323
- const request = transport.request(url, { method: opts.method, headers: opts.headers }, (response) => {
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
- const quota = {
325
- fiveHour: outcome.fiveHour,
326
- sevenDay: outcome.sevenDay,
327
- resetsAt: outcome.resetsAt,
328
- };
329
- await writeCodexQuotaCache(dataDir, { ...quota, fetchedAt: now.toISOString() });
330
- return quota;
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -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
  }