ccus-cli 0.1.4 → 0.1.6

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
@@ -5,9 +5,10 @@
5
5
  - `ccus install`:自动把 statusLine 命令写进 Claude Code 的 `settings.json`,省去手动改配置。
6
6
  - `ccus statusline emit`:读取 Claude Code statusline 通过 `stdin` 传入的 JSON,输出 statusline 文本,并写入本地日志(加 `--no-store` / `--no-log` 则只输出、不落盘)。
7
7
  - `ccus dashboard serve`:直接启动本地 Web 页面,不用先手动生成 HTML 文件。
8
- - `ccus export`:默认导出当前周数据包,里面同时包含原始事件和按天维度的周汇总。
9
- - `ccus aggregate`:读取一个目录里的多人 export bundle json,输出明细、按天、按周三个 CSV。
8
+ - `ccus export`:默认导出当前周数据包(gzip 压缩的 `.json.gz`),里面同时包含原始事件和按天维度的周汇总。
9
+ - `ccus aggregate`:读取一个目录里的多人 export bundle(`.json.gz` 或 `.json`),输出明细、按天、按周三个 CSV。
10
10
  - `ccus aggregate serve`:同样以 bundle 目录为输入,启动本地多人 dashboard 页面,不落地任何文件。
11
+ - `ccus update`:主动检查 npm 上是否有新版本,有则提示手动升级命令;`ccus --version` 查看当前版本。
11
12
 
12
13
  > **支持范围**:`ccus statusline emit` 依赖 Claude Code 的 statusLine 机制(从 `stdin` 读 JSON、向 `stdout` 回一行文本),**只在命令行版 Claude Code(CLI / 终端)里生效**。
13
14
  >
@@ -23,6 +24,27 @@ npm install -g ccus-cli
23
24
 
24
25
  要求 Node.js >= 20。
25
26
 
27
+ ## 更新检查
28
+
29
+ ccus 通过 npm 全局安装,自带一个轻量的更新检查:
30
+
31
+ - statusline 渲染时会**异步、节流(每天最多一次)**地向 npm registry 查询最新版本,结果缓存到数据目录下的 `update-check.json`。检查在 detached 后台进程里完成,**不阻塞 statusline、不污染单行输出**。
32
+ - 一旦发现有更新,statusline 行尾会追加一个小标记,例如 `… | ⏱ 11:44 | ⬆ v0.1.5`。
33
+ - 看到标记后手动升级即可:
34
+
35
+ ```bash
36
+ npm i -g ccus-cli@latest
37
+ ```
38
+
39
+ 也可以随时主动检查:
40
+
41
+ ```bash
42
+ ccus update # 立即查 registry,有新版本则打印升级命令
43
+ ccus --version # 查看当前安装的版本
44
+ ```
45
+
46
+ > 出于稳妥考虑,ccus **只提示、不自动替你执行全局安装**。如需走私服或镜像(如 npmmirror),设置环境变量 `CCUS_REGISTRY` 指向对应 registry 即可。
47
+
26
48
  ## 快速开始
27
49
 
28
50
  全局安装后,一条命令把 statusline 接进 Claude Code:
@@ -91,7 +113,7 @@ ccus export --range today
91
113
  ccus export --range last-week
92
114
  ccus export lw # 位置参数简写,等价于 --range last-week
93
115
  ccus export tw # 等价于 --range this-week
94
- ccus export --out ./alice_export_2026-05-26_to_2026-06-01.json
116
+ ccus export --out ./alice_export_2026-05-26_to_2026-06-01.json # --out 指定非 .gz 路径时写明文 JSON
95
117
  ccus aggregate --input-dir ./team-exports --out-dir ./team-report
96
118
  ccus aggregate serve --input-dir ./team-exports
97
119
  ```
@@ -113,8 +135,10 @@ ccus aggregate serve --input-dir ./team-exports
113
135
  - 默认导出 `this-week`;如需导出上一个完整周(周一到周日),用 `--range last-week`,或位置参数简写 `ccus export lw`(`tw` = 本周)
114
136
  - 周度导出固定覆盖**完整一周(周一到周日)**:`this-week` 即使本周还没过完、后面几天还没有任何数据,文件名的起止日期也会补齐到本周日,`dailySummaries` 同样按整周 7 天逐日输出
115
137
  - 默认输出一个 `json` 数据包,里面同时包含 `rawEvents`、`weeklySummary`、`dailySummaries`
138
+ - 导出文件内容为**紧凑 JSON**(无缩进),并默认 **gzip 压缩**后写成 `.json.gz`;gzip 与紧凑化都只是存储/展示层变化,解压后的字段集合与 `schemaVersion` 不变
139
+ - 默认写 `.json.gz`;若用 `--out` 指定一个非 `.gz` 结尾的路径,则按明文 JSON 写出(不压缩)
116
140
  - 当前导出 bundle / weeklySummary 的 `schemaVersion` 为 `6`,用于标识已使用 `fiveHourLatestUsagePct`、`fiveHourPeakUsagePct`、`sevenDayLatestUsagePct`、`sevenDayPeakUsagePct` 字段的新导出契约
117
- - 默认文件名会带 git email 的帐号名前缀和起止日期,例如:`alice_export_2026-05-26_to_2026-06-01.json`
141
+ - 默认文件名会带 git email 的帐号名前缀和起止日期,例如:`alice_export_2026-05-26_to_2026-06-01.json.gz`
118
142
  - `userMessageCount` 来自 `~/.claude/projects/**/*.jsonl` 的非 meta `type:user` 事件
119
143
  - `apiRequestCount` 与 token 指标来自 `~/.claude/projects/**/*.jsonl` 中带 `message.usage` 的 `type:assistant` 事件
120
144
  - `dailySummaries` 会按每天输出消息数、请求数、token 和当天 statusline usage 摘要
@@ -122,7 +146,7 @@ ccus aggregate serve --input-dir ./team-exports
122
146
 
123
147
  ## 多人汇总
124
148
 
125
- - 输入目录放很多通过 `ccus export` 导出的 bundle `.json` 文件
149
+ - 输入目录放很多通过 `ccus export` 导出的 bundle 文件,`.json.gz`(gzip 压缩)与明文 `.json` 都能识别,gzip 文件读取时自动解压
126
150
  - `aggregate` 目前只接受 `schemaVersion: 6` 的 bundle;旧导出请先用当前版本重新 `ccus export`
127
151
  - `ccus aggregate --input-dir DIR --out-dir DIR`
128
152
  - 输出三个文件:
package/dist/cli.js CHANGED
@@ -55,9 +55,11 @@ const payload_1 = require("./lib/payload");
55
55
  const paths_1 = require("./lib/paths");
56
56
  const storage_1 = require("./lib/storage");
57
57
  const time_1 = require("./lib/time");
58
+ const update_check_1 = require("./lib/update-check");
59
+ const version_1 = require("./lib/version");
58
60
  /** CLI 帮助信息保持简洁,方便直接挂到 README 或终端里查看。 */
59
61
  function printHelp() {
60
- process.stdout.write(`ccus\n\nCommands:\n ccus install [--settings PATH] [--command CMD] [--data-dir PATH]\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 aggregate --input-dir DIR [--out-dir DIR]\n ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]\n\nGlobal flags:\n --verbose | --debug | -v 输出详细调试日志到 stderr(等价于设置 CCUS_DEBUG=1),方便排查问题\n`);
62
+ process.stdout.write(`ccus\n\nCommands:\n ccus install [--settings PATH] [--command CMD] [--data-dir PATH]\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 aggregate --input-dir DIR [--out-dir DIR]\n ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]\n ccus update [--data-dir PATH]\n ccus --version\n\nGlobal flags:\n --verbose | --debug | -v 输出详细调试日志到 stderr(等价于设置 CCUS_DEBUG=1),方便排查问题\n`);
61
63
  }
62
64
  /** 一个轻量的参数解析器,当前命令面不复杂,没必要引入额外依赖。 */
63
65
  function parseOptions(args) {
@@ -174,7 +176,12 @@ async function handleStatuslineEmit(options) {
174
176
  gitUserAccount: event.gitUserAccount,
175
177
  gitBranch,
176
178
  });
177
- process.stdout.write(`${event.statusLine}\n`);
179
+ // 更新检查必须对 statusline 完全无侵入:同步读旧缓存决定是否在行尾追加小标记,
180
+ // 把网络请求甩给 detached 后台进程,主进程不等待、失败静默。
181
+ (0, update_check_1.maybeSpawnBackgroundCheck)(dataDir);
182
+ const notice = (0, update_check_1.computeUpdateNotice)(dataDir);
183
+ const statusLine = notice ? `${event.statusLine} | ${notice}` : event.statusLine;
184
+ process.stdout.write(`${statusLine}\n`);
178
185
  }
179
186
  catch (error) {
180
187
  // 正常路径不能因为采样失败把 statusline 弄挂,所以这里仍然降级输出兜底文本;
@@ -399,10 +406,17 @@ async function handleExport(options) {
399
406
  const content = (0, export_1.buildWeeklyExportBundleJson)(bundle);
400
407
  const fileLabel = (0, time_1.formatRangeFileLabel)(window.start, window.end);
401
408
  const gitEmailPrefix = (0, time_1.formatGitEmailFilePrefix)(exportUserEmail);
402
- const defaultFileName = gitEmailPrefix ? `${gitEmailPrefix}_export_${fileLabel}.json` : `export_${fileLabel}.json`;
409
+ // 默认导出 gzip 压缩的 bundle(.json.gz)以缩减体积;用户用 --out 指定非 .gz 路径时仍写明文 JSON。
410
+ const defaultFileName = gitEmailPrefix ? `${gitEmailPrefix}_export_${fileLabel}.json.gz` : `export_${fileLabel}.json.gz`;
403
411
  const outputPath = node_path_1.default.resolve(output ?? node_path_1.default.join(dataDir, "exports", defaultFileName));
404
- await (0, export_1.writeTextFile)(outputPath, content);
405
- (0, debug_1.debugLog)("export", "bundle written", { outputPath, bytes: content.length });
412
+ const compressed = outputPath.endsWith(".gz");
413
+ if (compressed) {
414
+ await (0, export_1.writeGzipFile)(outputPath, content);
415
+ }
416
+ else {
417
+ await (0, export_1.writeTextFile)(outputPath, content);
418
+ }
419
+ (0, debug_1.debugLog)("export", "bundle written", { outputPath, rawBytes: content.length, compressed });
406
420
  process.stdout.write(`${outputPath}\n`);
407
421
  }
408
422
  /**
@@ -518,6 +532,43 @@ function resolveExportOptions(action, args, rest) {
518
532
  }
519
533
  return options;
520
534
  }
535
+ /**
536
+ * `ccus update`:用户主动检查更新(绕过 24h 节流)。
537
+ *
538
+ * 根据当前的产品选择,这里只做「检查 + 提示」,不替用户执行全局安装。
539
+ * 有新版本时打印应当手动运行的 `npm i -g` 命令。
540
+ */
541
+ async function handleUpdate(options) {
542
+ const dataDir = getDataDir(options);
543
+ const current = (0, version_1.getCurrentVersion)();
544
+ let latest;
545
+ try {
546
+ latest = await (0, update_check_1.fetchLatestVersion)();
547
+ }
548
+ catch (error) {
549
+ const message = error instanceof Error ? error.message : String(error);
550
+ process.stdout.write(`无法检查更新:${message}\n当前版本 v${current}\n`);
551
+ return;
552
+ }
553
+ // 顺手刷新缓存,让 statusline 标记和这次检查结果保持一致。
554
+ await (0, update_check_1.performUpdateCheck)(dataDir);
555
+ if ((0, version_1.isNewerVersion)(latest, current)) {
556
+ process.stdout.write(`发现新版本:v${current} -> v${latest}\n运行以下命令升级:\n npm i -g ccus-cli@latest\n`);
557
+ }
558
+ else {
559
+ process.stdout.write(`已是最新版本 v${current}\n`);
560
+ }
561
+ }
562
+ /**
563
+ * 隐藏命令 `__check-update`:由 statusline 路径以 detached 后台进程触发。
564
+ *
565
+ * 只做一件事:查 registry 并写缓存。不输出任何东西到 stdout(它不是被人看的),
566
+ * 失败也静默,绝不影响触发它的 statusline 主进程。
567
+ */
568
+ async function handleBackgroundCheckUpdate(options) {
569
+ const dataDir = getDataDir(options);
570
+ await (0, update_check_1.performUpdateCheck)(dataDir);
571
+ }
521
572
  /** 顶层命令分发入口。 */
522
573
  async function main(args = process.argv.slice(2)) {
523
574
  (0, debug_1.setDebugEnabled)((0, debug_1.resolveDebugEnabled)(args));
@@ -528,6 +579,18 @@ async function main(args = process.argv.slice(2)) {
528
579
  printHelp();
529
580
  return;
530
581
  }
582
+ if (group === "--version" || group === "-V" || group === "version") {
583
+ process.stdout.write(`${(0, version_1.getCurrentVersion)()}\n`);
584
+ return;
585
+ }
586
+ if (group === "update") {
587
+ await handleUpdate(parseOptions(args.slice(1)));
588
+ return;
589
+ }
590
+ if (group === "__check-update") {
591
+ await handleBackgroundCheckUpdate(parseOptions(args.slice(1)));
592
+ return;
593
+ }
531
594
  if (group === "install") {
532
595
  await handleInstall(parseOptions(args.slice(1)));
533
596
  return;
@@ -9,8 +9,11 @@ exports.buildAggregatedDailyRows = buildAggregatedDailyRows;
9
9
  exports.buildAggregatedWeeklyRows = buildAggregatedWeeklyRows;
10
10
  const promises_1 = __importDefault(require("node:fs/promises"));
11
11
  const node_path_1 = __importDefault(require("node:path"));
12
+ const node_util_1 = require("node:util");
13
+ const node_zlib_1 = require("node:zlib");
12
14
  const payload_1 = require("./payload");
13
15
  const time_1 = require("./time");
16
+ const gunzipAsync = (0, node_util_1.promisify)(node_zlib_1.gunzip);
14
17
  function isRecord(value) {
15
18
  return typeof value === "object" && value !== null && !Array.isArray(value);
16
19
  }
@@ -59,6 +62,10 @@ function weekKey(date) {
59
62
  function toPersonKey(gitUserEmail, gitUserName) {
60
63
  return (0, time_1.extractGitEmailAccount)(gitUserEmail) ?? gitUserName ?? "unknown";
61
64
  }
65
+ /** bundle 文件可以是明文 `.json`,也可以是 `ccus export` 默认输出的 gzip 压缩 `.json.gz`。 */
66
+ function isBundleFileName(name) {
67
+ return name.endsWith(".json") || name.endsWith(".json.gz");
68
+ }
62
69
  async function collectBundleJsonFiles(directoryPath) {
63
70
  const entries = await promises_1.default.readdir(directoryPath, { withFileTypes: true });
64
71
  const nested = await Promise.all(entries.map(async (entry) => {
@@ -66,10 +73,19 @@ async function collectBundleJsonFiles(directoryPath) {
66
73
  if (entry.isDirectory()) {
67
74
  return collectBundleJsonFiles(fullPath);
68
75
  }
69
- return entry.isFile() && entry.name.endsWith(".json") ? [fullPath] : [];
76
+ return entry.isFile() && isBundleFileName(entry.name) ? [fullPath] : [];
70
77
  }));
71
78
  return nested.flat();
72
79
  }
80
+ /** 读取 bundle 文件内容:`.gz` 结尾的先 gunzip 再按 UTF-8 解码,其它按明文读取。 */
81
+ async function readBundleFileContent(filePath) {
82
+ if (filePath.endsWith(".gz")) {
83
+ const compressed = await promises_1.default.readFile(filePath);
84
+ const decompressed = await gunzipAsync(compressed);
85
+ return decompressed.toString("utf8");
86
+ }
87
+ return promises_1.default.readFile(filePath, "utf8");
88
+ }
73
89
  /** 读取目录里的 export bundle json 文件。 */
74
90
  async function loadWeeklyExportBundles(inputDir) {
75
91
  const files = await collectBundleJsonFiles(inputDir);
@@ -77,7 +93,7 @@ async function loadWeeklyExportBundles(inputDir) {
77
93
  const invalidFiles = [];
78
94
  for (const filePath of files) {
79
95
  try {
80
- const content = await promises_1.default.readFile(filePath, "utf8");
96
+ const content = await readBundleFileContent(filePath);
81
97
  const parsed = JSON.parse(content);
82
98
  if (isWeeklyExportBundle(parsed)) {
83
99
  bundles.push({ filePath, bundle: parsed });
@@ -13,9 +13,13 @@ exports.buildSummaryCsv = buildSummaryCsv;
13
13
  exports.buildAggregatedDailyCsv = buildAggregatedDailyCsv;
14
14
  exports.buildAggregatedWeeklyCsv = buildAggregatedWeeklyCsv;
15
15
  exports.writeTextFile = writeTextFile;
16
+ exports.writeGzipFile = writeGzipFile;
16
17
  const promises_1 = __importDefault(require("node:fs/promises"));
17
18
  const node_path_1 = __importDefault(require("node:path"));
19
+ const node_util_1 = require("node:util");
20
+ const node_zlib_1 = require("node:zlib");
18
21
  const time_1 = require("./time");
22
+ const gzipAsync = (0, node_util_1.promisify)(node_zlib_1.gzip);
19
23
  /** CSV 字符串字段统一做转义,避免逗号和引号破坏列结构。 */
20
24
  function quoteCsv(value) {
21
25
  return `"${value.replaceAll("\"", "\"\"")}"`;
@@ -128,9 +132,9 @@ function buildRawJsonl(events) {
128
132
  function buildWeeklySummaryJson(summary) {
129
133
  return `${JSON.stringify(summary, null, 2)}\n`;
130
134
  }
131
- /** 默认导出把原始事件与周汇总一起打包成一个 JSON 文件。 */
135
+ /** 默认导出把原始事件与周汇总一起打包成一个 JSON 文件,用紧凑序列化避免缩进空白撑大体积。 */
132
136
  function buildWeeklyExportBundleJson(bundle) {
133
- return `${JSON.stringify(bundle, null, 2)}\n`;
137
+ return `${JSON.stringify(bundle)}\n`;
134
138
  }
135
139
  /** 按天汇总 usage 数据,生成 summary 模式的中间结果。 */
136
140
  function buildSummaryRows(events) {
@@ -272,4 +276,15 @@ async function writeTextFile(outputPath, content) {
272
276
  await promises_1.default.mkdir(node_path_1.default.dirname(outputPath), { recursive: true });
273
277
  await promises_1.default.writeFile(outputPath, content, "utf8");
274
278
  }
279
+ /**
280
+ * 把内容 gzip 压缩后写文件,父目录不存在时自动创建。
281
+ *
282
+ * 仅压缩存储/传输层,写入的字节解压后与 `writeTextFile` 完全一致,
283
+ * 不改变 bundle 字段集合,也不影响 schemaVersion。
284
+ */
285
+ async function writeGzipFile(outputPath, content) {
286
+ await promises_1.default.mkdir(node_path_1.default.dirname(outputPath), { recursive: true });
287
+ const compressed = await gzipAsync(content);
288
+ await promises_1.default.writeFile(outputPath, compressed);
289
+ }
275
290
  //# sourceMappingURL=export.js.map
package/dist/lib/paths.js CHANGED
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getDefaultDataDir = getDefaultDataDir;
7
7
  exports.getEventsDir = getEventsDir;
8
8
  exports.getDashboardDir = getDashboardDir;
9
+ exports.getUpdateCachePath = getUpdateCachePath;
9
10
  exports.getClaudeDataDir = getClaudeDataDir;
10
11
  exports.getClaudeSettingsPath = getClaudeSettingsPath;
11
12
  const node_os_1 = __importDefault(require("node:os"));
@@ -41,6 +42,10 @@ function getEventsDir(dataDir) {
41
42
  function getDashboardDir(dataDir) {
42
43
  return node_path_1.default.join(dataDir, "dashboard");
43
44
  }
45
+ /** 版本更新检查的本地缓存文件,记录上次检查时间与拿到的最新版本。 */
46
+ function getUpdateCachePath(dataDir) {
47
+ return node_path_1.default.join(dataDir, "update-check.json");
48
+ }
44
49
  /** Claude Code 默认本地数据目录。 */
45
50
  function getClaudeDataDir() {
46
51
  const configured = process.env.CCUS_CLAUDE_DATA_DIR;
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.readUpdateCacheSync = readUpdateCacheSync;
7
+ exports.fetchLatestVersion = fetchLatestVersion;
8
+ exports.performUpdateCheck = performUpdateCheck;
9
+ exports.maybeSpawnBackgroundCheck = maybeSpawnBackgroundCheck;
10
+ exports.computeUpdateNotice = computeUpdateNotice;
11
+ const node_child_process_1 = require("node:child_process");
12
+ const node_fs_1 = __importDefault(require("node:fs"));
13
+ const promises_1 = __importDefault(require("node:fs/promises"));
14
+ const node_https_1 = __importDefault(require("node:https"));
15
+ const debug_1 = require("./debug");
16
+ const paths_1 = require("./paths");
17
+ const version_1 = require("./version");
18
+ /** npm 包名,更新检查向 registry 查询它的最新版本。 */
19
+ const PACKAGE_NAME = "ccus-cli";
20
+ /** 后台检查节流窗口:同一数据目录每天最多向 registry 查一次。 */
21
+ const CHECK_TTL_MS = 24 * 60 * 60 * 1000;
22
+ /** 网络请求超时,避免后台进程长时间挂着;statusline 主进程本身不等它。 */
23
+ const FETCH_TIMEOUT_MS = 4000;
24
+ /**
25
+ * 同步读取更新缓存。
26
+ *
27
+ * statusline 是高频短命进程,这里只读一个很小的 JSON,用同步 IO 换取最简单、最快的路径;
28
+ * 任何异常都静默返回 null,绝不影响状态行输出。
29
+ */
30
+ function readUpdateCacheSync(dataDir) {
31
+ try {
32
+ const raw = node_fs_1.default.readFileSync((0, paths_1.getUpdateCachePath)(dataDir), "utf8");
33
+ const parsed = JSON.parse(raw);
34
+ if (typeof parsed.lastCheckedAt !== "string") {
35
+ return null;
36
+ }
37
+ return {
38
+ lastCheckedAt: parsed.lastCheckedAt,
39
+ latestVersion: typeof parsed.latestVersion === "string" ? parsed.latestVersion : null,
40
+ };
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** 写入更新缓存;目录可能尚未创建,所以先 mkdir。失败静默。 */
47
+ async function writeUpdateCache(dataDir, cache) {
48
+ try {
49
+ await promises_1.default.mkdir(dataDir, { recursive: true });
50
+ await promises_1.default.writeFile((0, paths_1.getUpdateCachePath)(dataDir), `${JSON.stringify(cache, null, 2)}\n`, "utf8");
51
+ }
52
+ catch (error) {
53
+ (0, debug_1.debugLog)("update", "failed to write cache", error instanceof Error ? error.message : String(error));
54
+ }
55
+ }
56
+ /** registry 基地址,允许用 CCUS_REGISTRY 指向私服或镜像(如 npmmirror)。 */
57
+ function getRegistryBase(env = process.env) {
58
+ const configured = (env.CCUS_REGISTRY ?? "").trim();
59
+ const base = configured || "https://registry.npmjs.org";
60
+ return base.replace(/\/+$/, "");
61
+ }
62
+ /**
63
+ * 向 npm registry 查询最新发布版本。
64
+ *
65
+ * 走 `/<pkg>/latest` 这个轻量端点,只取 `version` 字段;超时或任何错误都 reject,
66
+ * 由调用方决定是否静默。
67
+ */
68
+ function fetchLatestVersion(env = process.env) {
69
+ const url = `${getRegistryBase(env)}/${PACKAGE_NAME}/latest`;
70
+ return new Promise((resolve, reject) => {
71
+ const request = node_https_1.default.get(url, { headers: { accept: "application/json" } }, (response) => {
72
+ const status = response.statusCode ?? 0;
73
+ if (status < 200 || status >= 300) {
74
+ response.resume();
75
+ reject(new Error(`registry responded ${status}`));
76
+ return;
77
+ }
78
+ let body = "";
79
+ response.setEncoding("utf8");
80
+ response.on("data", (chunk) => {
81
+ body += chunk;
82
+ });
83
+ response.on("end", () => {
84
+ try {
85
+ const parsed = JSON.parse(body);
86
+ if (typeof parsed.version === "string") {
87
+ resolve(parsed.version);
88
+ }
89
+ else {
90
+ reject(new Error("registry payload missing version"));
91
+ }
92
+ }
93
+ catch (error) {
94
+ reject(error instanceof Error ? error : new Error(String(error)));
95
+ }
96
+ });
97
+ });
98
+ request.setTimeout(FETCH_TIMEOUT_MS, () => {
99
+ request.destroy(new Error("registry request timed out"));
100
+ });
101
+ request.on("error", reject);
102
+ });
103
+ }
104
+ /**
105
+ * 真正执行一次检查并把结果写进缓存。
106
+ *
107
+ * 供隐藏命令 `__check-update`(statusline 后台触发)和 `ccus update`(用户主动)复用。
108
+ * 即使 registry 失败也会更新 lastCheckedAt,避免反复重试打爆 registry。
109
+ */
110
+ async function performUpdateCheck(dataDir) {
111
+ let latestVersion = null;
112
+ try {
113
+ latestVersion = await fetchLatestVersion();
114
+ (0, debug_1.debugLog)("update", "fetched latest version", latestVersion);
115
+ }
116
+ catch (error) {
117
+ (0, debug_1.debugLog)("update", "fetch failed", error instanceof Error ? error.message : String(error));
118
+ }
119
+ const cache = {
120
+ lastCheckedAt: new Date().toISOString(),
121
+ latestVersion,
122
+ };
123
+ await writeUpdateCache(dataDir, cache);
124
+ return cache;
125
+ }
126
+ /** 缓存是否已过 TTL(或根本不存在),需要重新向 registry 查询。 */
127
+ function isCacheStale(cache, now) {
128
+ if (!cache) {
129
+ return true;
130
+ }
131
+ const last = Date.parse(cache.lastCheckedAt);
132
+ if (!Number.isFinite(last)) {
133
+ return true;
134
+ }
135
+ return now.getTime() - last >= CHECK_TTL_MS;
136
+ }
137
+ /**
138
+ * 如缓存过期,spawn 一个 detached 后台进程刷新它,主进程不等待。
139
+ *
140
+ * 这是 statusline 路径能用的唯一安全方式:主进程同步读旧缓存决定是否提示,
141
+ * 把网络请求甩给后台子进程,自己立刻输出单行状态文本并退出,绝不阻塞。
142
+ */
143
+ function maybeSpawnBackgroundCheck(dataDir, now = new Date()) {
144
+ try {
145
+ if (!isCacheStale(readUpdateCacheSync(dataDir), now)) {
146
+ return;
147
+ }
148
+ const scriptPath = process.argv[1];
149
+ if (!scriptPath) {
150
+ return;
151
+ }
152
+ const child = (0, node_child_process_1.spawn)(process.execPath, [scriptPath, "__check-update", "--data-dir", dataDir], {
153
+ detached: true,
154
+ stdio: "ignore",
155
+ windowsHide: true,
156
+ });
157
+ child.on("error", () => { });
158
+ child.unref();
159
+ (0, debug_1.debugLog)("update", "spawned background check");
160
+ }
161
+ catch (error) {
162
+ (0, debug_1.debugLog)("update", "failed to spawn background check", error instanceof Error ? error.message : String(error));
163
+ }
164
+ }
165
+ /**
166
+ * 根据缓存计算 statusline 末尾要追加的更新标记。
167
+ *
168
+ * 有严格新于当前版本的发布时返回形如 `⬆ v0.1.5` 的短标记,否则返回 null。
169
+ */
170
+ function computeUpdateNotice(dataDir, currentVersion = (0, version_1.getCurrentVersion)()) {
171
+ const cache = readUpdateCacheSync(dataDir);
172
+ if (!cache || !cache.latestVersion) {
173
+ return null;
174
+ }
175
+ if (!(0, version_1.isNewerVersion)(cache.latestVersion, currentVersion)) {
176
+ return null;
177
+ }
178
+ return `⬆ v${cache.latestVersion}`;
179
+ }
180
+ //# sourceMappingURL=update-check.js.map
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getCurrentVersion = getCurrentVersion;
7
+ exports.isNewerVersion = isNewerVersion;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ /**
11
+ * 读取 ccus 自身的版本号(package.json 的 version)。
12
+ *
13
+ * dist/lib/version.js 与 src/lib/version.ts 距离包根都是 `../..`,
14
+ * 所以编译产物和 tsx 直跑源码时都能定位到同一个 package.json。
15
+ * 读不到时回退 "0.0.0",让上层逻辑(更新检查)安全降级而不是抛错。
16
+ */
17
+ let cachedVersion = null;
18
+ function getCurrentVersion() {
19
+ if (cachedVersion !== null) {
20
+ return cachedVersion;
21
+ }
22
+ try {
23
+ const packageJsonPath = node_path_1.default.join(__dirname, "..", "..", "package.json");
24
+ const raw = node_fs_1.default.readFileSync(packageJsonPath, "utf8");
25
+ const parsed = JSON.parse(raw);
26
+ cachedVersion = typeof parsed.version === "string" ? parsed.version : "0.0.0";
27
+ }
28
+ catch {
29
+ cachedVersion = "0.0.0";
30
+ }
31
+ return cachedVersion;
32
+ }
33
+ /**
34
+ * 语义化版本比较:判断 latest 是否严格新于 current。
35
+ *
36
+ * 只比较 `major.minor.patch` 三段数字,忽略预发布/构建元数据,
37
+ * 对当前发布节奏足够;任意一段解析失败按 0 处理,避免误报更新。
38
+ */
39
+ function isNewerVersion(latest, current) {
40
+ const parse = (value) => value
41
+ .trim()
42
+ .replace(/^v/, "")
43
+ .split("-", 1)[0]
44
+ .split(".")
45
+ .map((part) => Number.parseInt(part, 10))
46
+ .map((part) => (Number.isFinite(part) ? part : 0));
47
+ const a = parse(latest);
48
+ const b = parse(current);
49
+ const length = Math.max(a.length, b.length);
50
+ for (let index = 0; index < length; index += 1) {
51
+ const left = a[index] ?? 0;
52
+ const right = b[index] ?? 0;
53
+ if (left > right) {
54
+ return true;
55
+ }
56
+ if (left < right) {
57
+ return false;
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+ //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "build": "tsc -p tsconfig.json",
16
16
  "prepublishOnly": "npm run build",
17
17
  "test": "node --test dist/test",
18
- "test:src": "node --import tsx --test 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"
18
+ "test:src": "node --import tsx --test 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"
19
19
  },
20
20
  "keywords": [
21
21
  "claude-code",