ccus-cli 0.2.17 → 0.2.19

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
@@ -209,6 +209,24 @@ ccus aggregate serve --input-dir ./team-exports
209
209
 
210
210
  ## 定时同步
211
211
 
212
+ `sync`(含后台同步)、`export` 和个人 dashboard 在统计前自动同步 session 文件修改时间,默认仅修复跨天差异,并按实际文件去重硬链接。自动同步保持静默,异常信息可通过 `--verbose` 查看。
213
+
214
+ session 内容已更新但文件修改时间仍旧,导致统计遗漏时,可手动修复:
215
+
216
+ ```bash
217
+ ccus sessions repair --dry-run # 预览全部待修复文件
218
+ ccus sessions repair # 修复全部类型
219
+ ccus sessions repair codex # 仅修复 Codex(含 Orca)
220
+ ccus sessions repair claude # 仅修复 Claude
221
+ ccus sessions repair codex --dry-run # 仅预览 Codex
222
+ ccus sessions repair codex --min-gap 3h # 至少落后 3 小时才修复
223
+ ccus sync # 重新统计并同步
224
+ ```
225
+
226
+ 预览和执行均按实际文件标识去重:Codex 与 Orca 的硬链接只显示、计数一次,修复一个路径会同时更新所有硬链接路径。普通独立副本仍分别处理。
227
+
228
+ 默认扫描 Claude projects、Codex 与 Orca sessions 的 JSONL;指定类型时只扫描对应目录。从尾部分块读取最后一条非空记录。默认仅当文件修改时间落后于记录的 `timestamp`,且两者按本机时区跨自然日时修复(不是必须相差 24 小时)。`--min-gap` 改为按实际时差判断,支持非负整数加 `m`(分钟)、`h`(小时)、`d`(24 小时);达到阈值即修复,`0m` 表示任何正差值。保留更晚的修改时间,不改写会话内容。末条 JSON 不完整、时间戳缺失或无效时跳过并列出原因。此命令不限制本周,处理所选类型的所有历史 session;日常统计的修改时间预筛保持不变。
229
+
212
230
  把每个人的 `ccus export` 攒到一个共享目录(团队网盘 / 共享盘)通常是手动的,容易漏。`ccus sync` 用来自动化这一步:周期到了就 `export` 一次,并把导出文件**复制**到目标目录下**按周命名的子目录**里。
213
231
 
214
232
  ```bash
package/dist/cli.js CHANGED
@@ -70,7 +70,7 @@ const api_equivalent_cost_1 = require("./lib/api-equivalent-cost");
70
70
  const version_1 = require("./lib/version");
71
71
  /** CLI 帮助信息保持简洁,方便直接挂到 README 或终端里查看。 */
72
72
  function printHelp() {
73
- 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`);
73
+ 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 sessions repair [codex|claude] [--min-gap 3h] [--dry-run] (按末条记录时间修复 session 修改时间,默认仅跨天)\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`);
74
74
  }
75
75
  /** 一个轻量的参数解析器,当前命令面不复杂,没必要引入额外依赖。 */
76
76
  function parseOptions(args) {
@@ -247,6 +247,25 @@ async function handleStatuslineEmit(options) {
247
247
  process.stdout.write(`${fallback.statusLine}\n`);
248
248
  }
249
249
  }
250
+ /** 统计前静默同步会话修改时间,修复失败时仍尝试原有统计流程。 */
251
+ async function loadSessionUsage(start, end) {
252
+ try {
253
+ const { repairSessionMtimes } = await Promise.resolve().then(() => __importStar(require("./lib/session-mtime")));
254
+ const results = await repairSessionMtimes(false);
255
+ (0, debug_1.debugLog)("sessions", "统计前同步修改时间", {
256
+ scanned: results.length,
257
+ repaired: results.filter((result) => result.status === "repaired").length,
258
+ issues: results.filter((result) => result.status === "failed" || result.status === "skipped"),
259
+ });
260
+ }
261
+ catch (error) {
262
+ (0, debug_1.debugLog)("sessions", "统计前同步修改时间失败", error);
263
+ }
264
+ return Promise.all([
265
+ (0, claude_1.summarizeClaudeProjectUsageCombined)(start, end),
266
+ (0, codex_sessions_1.summarizeCodexSessionUsageCombined)(start, end),
267
+ ]);
268
+ }
250
269
  /**
251
270
  * 加载 dashboard 渲染数据:statusline 事件、Claude/Codex 每日消息数与标准 API 等效成本。
252
271
  *
@@ -264,10 +283,7 @@ async function loadDashboardData(options, defaultRange) {
264
283
  // codex 消息柱图走 dailyUserMessages,不受影响。
265
284
  const events = (await (0, storage_1.readEventsForRange)(dataDir, range, now))
266
285
  .map((record) => (0, payload_1.computeStatuslineEvent)(record));
267
- const [claudeUsage, codexUsage] = await Promise.all([
268
- (0, claude_1.summarizeClaudeProjectUsageCombined)(window.start, window.end),
269
- (0, codex_sessions_1.summarizeCodexSessionUsageCombined)(window.start, window.end),
270
- ]);
286
+ const [claudeUsage, codexUsage] = await loadSessionUsage(window.start, window.end);
271
287
  const claudeDailyUsage = claudeUsage.daily;
272
288
  const codexDailyUsage = codexUsage.daily;
273
289
  const dailyUserMessages = (0, time_1.enumerateDateKeys)(window.start, window.end).map((date) => ({
@@ -401,10 +417,7 @@ async function runExport(options) {
401
417
  const statuslineDailyRows = (0, export_1.buildSummaryRows)(claudeEvents);
402
418
  const codexStatuslineSummary = (0, dashboard_2.summarizeEvents)(codexEvents);
403
419
  const codexStatuslineDailyRows = (0, export_1.buildSummaryRows)(codexEvents);
404
- const [claudeScan, codexScan] = await Promise.all([
405
- (0, claude_1.summarizeClaudeProjectUsageCombined)(window.start, window.end),
406
- (0, codex_sessions_1.summarizeCodexSessionUsageCombined)(window.start, window.end),
407
- ]);
420
+ const [claudeScan, codexScan] = await loadSessionUsage(window.start, window.end);
408
421
  const claudeUsage = claudeScan.weekly;
409
422
  const claudeDailyUsage = claudeScan.daily;
410
423
  const codexUsage = codexScan.weekly;
@@ -743,9 +756,48 @@ async function handleSessions(options) {
743
756
  await fsNode.writeFile(outputPath, zipBuffer);
744
757
  process.stdout.write(`${outputPath}\n`);
745
758
  }
746
- /**
747
- * 解析 sessions 命令的参数,支持 RANGE 作为位置参数简写。
748
- */
759
+ /** 手动修复 session 修改时间,并报告逐文件结果。 */
760
+ async function handleRepairSessionMtimes(args) {
761
+ const startedAt = performance.now();
762
+ let source;
763
+ let dryRun = false;
764
+ let minGapMs;
765
+ for (let index = 0; index < args.length; index += 1) {
766
+ const arg = args[index];
767
+ if (arg === "--dry-run")
768
+ dryRun = true;
769
+ else if (arg === "--min-gap" && minGapMs === undefined) {
770
+ const match = /^(\d+)(m|h|d)$/.exec(args[++index] ?? "");
771
+ if (!match)
772
+ throw new Error("--min-gap 需要非负整数和单位 m/h/d,例如 30m、3h、1d;0m 表示任何正差值。");
773
+ minGapMs = Number(match[1]) * ({ m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2]]);
774
+ if (!Number.isSafeInteger(minGapMs))
775
+ throw new Error("--min-gap 数值过大。");
776
+ }
777
+ else if (["--verbose", "--debug", "-v"].includes(arg))
778
+ continue;
779
+ else if ((arg === "codex" || arg === "claude") && source === undefined)
780
+ source = arg;
781
+ else
782
+ throw new Error(`不支持的修复参数:${arg}。用法:ccus sessions repair [codex|claude] [--min-gap 3h] [--dry-run]`);
783
+ }
784
+ const { repairSessionMtimes } = await Promise.resolve().then(() => __importStar(require("./lib/session-mtime")));
785
+ const results = await repairSessionMtimes(dryRun, source, minGapMs);
786
+ for (const result of results) {
787
+ if (result.status === "repaired") {
788
+ process.stdout.write(`${dryRun ? "待修复" : "已修复"} ${result.filePath}: ${result.before.toISOString()} → ${result.after.toISOString()}\n`);
789
+ }
790
+ else if (result.status === "skipped" || result.status === "failed") {
791
+ process.stderr.write(`${result.status === "failed" ? "失败" : "跳过"} ${result.filePath}: ${result.reason}\n`);
792
+ }
793
+ }
794
+ const count = (status) => results.filter((result) => result.status === status).length;
795
+ process.stdout.write(`扫描 ${results.length},${dryRun ? "待修复" : "已修复"} ${count("repaired")},无需修复 ${count("unchanged")},跳过 ${count("skipped")},失败 ${count("failed")}\n`);
796
+ process.stdout.write(`耗时:${(0, sync_1.formatSyncElapsed)(performance.now() - startedAt)}\n`);
797
+ if (count("failed") > 0)
798
+ process.exitCode = 1;
799
+ }
800
+ /** 解析 sessions 命令的参数,支持 RANGE 作为位置参数简写。 */
749
801
  function resolveSessionsOptions(action, args, rest) {
750
802
  if (!action || action.startsWith("--")) {
751
803
  return parseOptions(args.slice(1));
@@ -1423,6 +1475,10 @@ async function main(args = process.argv.slice(2)) {
1423
1475
  await handleExport(exportOptions);
1424
1476
  return;
1425
1477
  }
1478
+ if (group === "sessions" && action === "repair") {
1479
+ await handleRepairSessionMtimes(rest);
1480
+ return;
1481
+ }
1426
1482
  if (group === "sessions") {
1427
1483
  const sessionsOptions = resolveSessionsOptions(action, args, rest);
1428
1484
  await handleSessions(sessionsOptions);
@@ -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.collectProjectJsonlFiles = collectProjectJsonlFiles;
6
7
  exports.summarizeClaudeProjectUsage = summarizeClaudeProjectUsage;
7
8
  exports.summarizeClaudeProjectUsageCombined = summarizeClaudeProjectUsageCombined;
8
9
  exports.findActiveSessionFiles = findActiveSessionFiles;
@@ -0,0 +1,98 @@
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.repairSessionMtimes = repairSessionMtimes;
7
+ const promises_1 = __importDefault(require("node:fs/promises"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const claude_1 = require("./claude");
10
+ const paths_1 = require("./paths");
11
+ const time_1 = require("./time");
12
+ /** 从尾部按块取最后一条非空 JSON;异常末行不猜测时间。 */
13
+ async function readLastTimestamp(file, size) {
14
+ let position = size;
15
+ const chunks = [];
16
+ while (position > 0) {
17
+ const length = Math.min(position, 16 * 1024);
18
+ position -= length;
19
+ const buffer = Buffer.allocUnsafe(length);
20
+ const { bytesRead } = await file.read(buffer, 0, length, position);
21
+ if (bytesRead !== length)
22
+ return null;
23
+ let end = length;
24
+ if (chunks.length === 0) {
25
+ while (end > 0 && [9, 10, 13, 32].includes(buffer[end - 1]))
26
+ end -= 1;
27
+ if (end === 0)
28
+ continue;
29
+ }
30
+ const newline = buffer.lastIndexOf(10, end - 1);
31
+ chunks.push(buffer.subarray(newline + 1, end));
32
+ if (newline >= 0 || position === 0) {
33
+ try {
34
+ const record = JSON.parse(Buffer.concat(chunks.reverse()).toString("utf8"));
35
+ if (typeof record !== "object" || record === null || !("timestamp" in record)
36
+ || typeof record.timestamp !== "string")
37
+ return null;
38
+ const timestamp = new Date(record.timestamp).getTime();
39
+ return Number.isFinite(timestamp) ? timestamp : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ async function repairSessionMtimes(dryRun, source, minGapMs) {
49
+ const directories = [
50
+ ...(source !== "codex" ? [node_path_1.default.join((0, paths_1.getClaudeDataDir)(), "projects")] : []),
51
+ ...(source !== "claude" ? (0, paths_1.getCodexSessionHomes)().map((home) => node_path_1.default.join(home, "sessions")) : []),
52
+ ];
53
+ const files = [...new Set((await Promise.all(directories.map(claude_1.collectProjectJsonlFiles))).flat())];
54
+ const results = [];
55
+ const seenFiles = new Set();
56
+ for (const filePath of files) {
57
+ try {
58
+ const file = await promises_1.default.open(filePath, dryRun ? "r" : "r+");
59
+ try {
60
+ const before = await file.stat({ bigint: true });
61
+ const identity = before.ino === 0n ? `path:${filePath}` : `${before.dev}:${before.ino}`;
62
+ if (seenFiles.has(identity))
63
+ continue;
64
+ seenFiles.add(identity);
65
+ const timestamp = await readLastTimestamp(file, Number(before.size));
66
+ if (timestamp === null) {
67
+ results.push({ filePath, status: "skipped", reason: "末条记录无有效时间戳" });
68
+ continue;
69
+ }
70
+ const current = await file.stat({ bigint: true });
71
+ if (current.size !== before.size || current.mtimeMs !== before.mtimeMs) {
72
+ results.push({ filePath, status: "skipped", reason: "读取期间文件发生变化,请稍后重试" });
73
+ continue;
74
+ }
75
+ const after = new Date(timestamp);
76
+ const gap = timestamp - Number(current.mtimeMs);
77
+ const meetsThreshold = minGapMs === undefined
78
+ ? (0, time_1.localDateKey)(current.mtime) !== (0, time_1.localDateKey)(after)
79
+ : gap >= minGapMs;
80
+ if (gap <= 0 || !meetsThreshold) {
81
+ results.push({ filePath, status: "unchanged" });
82
+ continue;
83
+ }
84
+ if (!dryRun)
85
+ await file.utimes(before.atime, after);
86
+ results.push({ filePath, status: "repaired", before: current.mtime, after });
87
+ }
88
+ finally {
89
+ await file.close();
90
+ }
91
+ }
92
+ catch (error) {
93
+ results.push({ filePath, status: "failed", reason: error.message });
94
+ }
95
+ }
96
+ return results;
97
+ }
98
+ //# sourceMappingURL=session-mtime.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {