ccus-cli 0.1.9 → 0.1.11

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
@@ -8,6 +8,8 @@
8
8
  - `ccus export`:默认导出当前周数据包(gzip 压缩的 `.json.gz`),里面同时包含原始事件和按天维度的周汇总。
9
9
  - `ccus aggregate`:读取一个目录里的多人 export bundle(`.json.gz` 或 `.json`),输出明细、按天、按周三个 CSV。
10
10
  - `ccus aggregate serve`:同样以 bundle 目录为输入,启动本地多人 dashboard 页面,不落地任何文件。
11
+ - `ccus sync`:定时把当前周数据包导出并复制到一个目标目录(如团队共享盘),目标目录下按周自动建子目录;日常由 statusline 兜底触发,也可挂系统计划任务做到严格每天定时。
12
+ - `ccus open`:用系统文件管理器打开 ccus 本地存储目录(事件日志、exports、dashboard 都在里面);加 `--print` 只输出目录路径、不打开。
11
13
  - `ccus update`:主动检查 npm 上是否有新版本,有则提示手动升级命令;`ccus --version` 查看当前版本。
12
14
 
13
15
  > **支持范围**:`ccus statusline emit` 依赖 Claude Code 的 statusLine 机制(从 `stdin` 读 JSON、向 `stdout` 回一行文本),**只在命令行版 Claude Code(CLI / 终端)里生效**。
@@ -83,6 +85,10 @@ ccus dashboard serve # 启动本地页面,默认看本周(this-we
83
85
 
84
86
  ccus aggregate --input-dir ./team-exports # 多人的数据汇总,可以导出 detail.csv、daily.csv、weekly.csv 三个维度的文件
85
87
  ccus aggregate serve --input-dir ./team-exports #直接打开一个看板
88
+
89
+ ccus sync config --target ./team-exports # 配置同步目标目录(之后 statusline 会兜底定时同步)
90
+ ccus sync # 立即同步一次
91
+ ccus sync status # 查看同步配置与上次同步时间
86
92
  ```
87
93
 
88
94
  ### 一键安装(推荐)
@@ -165,14 +171,54 @@ ccus aggregate serve --input-dir ./team-exports
165
171
 
166
172
  - 输入目录放很多通过 `ccus export` 导出的 bundle 文件,`.json.gz`(gzip 压缩)与明文 `.json` 都能识别,gzip 文件读取时自动解压
167
173
  - `aggregate` 目前只接受 `schemaVersion: 6` 的 bundle;旧导出请先用当前版本重新 `ccus export`
174
+ - 同一个人在多台电脑上各自导出 bundle 时会自动合并去重:累加类指标(token、消息数、采样数等)按「同人同天 / 同人同周取 `generatedAt` 最新的那份导出」保留,不相加,避免同一台机器重复导出或周与周重叠造成翻倍;usage(5h / 7d)从选中那份的 `rawEvents` 按真实时间戳重算(peak 取 max、latest 取最新)
168
175
  - `ccus aggregate --input-dir DIR --out-dir DIR`
169
176
  - 输出三个文件:
170
- - `detail.csv`:来自每个 bundle 的 `rawEvents`,`contextUsedM` / `contextMaxM` 为单条事件的 context window token;另附带 `inputTokensM` / `outputTokensM` / `cacheReadInputTokensM`(按 `date` 取自当天 `dailySummaries` 的日总量,同一天多行会重复,不能按行求和)
171
- - `daily.csv`:直接来自每个 bundle 的 `dailySummaries`
172
- - `weekly.csv`:直接来自每个 bundle 的 `weeklySummary`
177
+ - `detail.csv`:来自 winner bundle 的 `rawEvents`(同人同天只展开最新那份的事件),`contextUsedM` / `contextMaxM` 为单条事件的 context window token;另附带 `inputTokensM` / `outputTokensM` / `cacheReadInputTokensM`(按 `date` 取自当天 `dailySummaries` 的日总量,同一天多行会重复,不能按行求和)
178
+ - `daily.csv`:同人同天取最新导出 bundle 的 `dailySummaries`,usage 从该 bundle 当天事件重算
179
+ - `weekly.csv`:同人同周取最新导出 bundle 的 `weeklySummary`,usage 从该 bundle 全部事件重算
173
180
  - CSV 里所有以 token 计的列(context 与 in/out/cache)都以百万(M)为单位(原始值除以 1,000,000),列名统一带 `M` 后缀;`contextWindowPct` 仍是百分比
174
181
  - 想直接查看团队多人 dashboard,可以用 `ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]`:默认监听 `127.0.0.1` 上的随机端口,启动后会自动用系统默认浏览器打开,每次请求实时读取目录里的 bundle,不写入任何文件
175
182
 
183
+ ## 定时同步
184
+
185
+ 把每个人的 `ccus export` 攒到一个共享目录(团队网盘 / 共享盘)通常是手动的,容易漏。`ccus sync` 用来自动化这一步:周期到了就 `export` 一次,并把导出文件**复制**到目标目录下**按周命名的子目录**里。
186
+
187
+ ```bash
188
+ ccus sync config --target ./team-exports # 配置同步目标目录(默认周期 3h)
189
+ ccus sync config --interval daily # 改周期,可写 3h(默认)/ daily / 6h / 30m
190
+ ccus sync config # 不带参数:打印当前配置
191
+ ccus sync # 用已存配置立即同步一次
192
+ ccus sync install # 注册系统调度器:每周五 18:00 自动同步(Windows 直接创建计划任务)
193
+ ccus sync uninstall # 卸载系统调度器
194
+ ccus sync status # 查看目标目录、周期、上次同步时间、是否到期
195
+ ```
196
+
197
+ 行为:
198
+
199
+ - **目标目录按周建子目录**:子目录名形如 `2026_06_01_2026_06_07`(该周周一~周日,全下划线),不存在时自动创建。
200
+ - **周一归档上一周**:周一同步时会额外把刚结束的上一整周(`last-week`)导出并归档到对应的上一周子目录(周一是第一个能拿到完整上一周数据的日子);同一天内多次同步用 `sync-state.lastArchivedWeek` 去重,不重复归档。
201
+ - **复制语义**:本地 `data-dir/exports` 仍保留一份,目标目录再放一份,本地照旧可 `aggregate` / `dashboard`。导出产物与 `ccus export` 完全一致,目标目录可直接喂给 `ccus aggregate`。
202
+ - **配置与同步分离**:`ccus sync config` 只读写配置(`--target` / `--interval` / `--range` 写进数据目录下的 `sync-config.json`,可手编;不带参数则打印当前配置);`ccus sync` 只用已存配置执行一次同步。上次同步时间记录在 `sync-state.json`。
203
+ - **周期**:默认 `3h`(每 3 小时最多同步一次的滚动周期);也支持 `daily`(按**自然日**判断——同一天内不重复同步,跨到下一天才再同步)与 `<N>h` / `<N>m` 的滚动周期。
204
+
205
+ ### 两种触发方式
206
+
207
+ 1. **statusline 兜底(零配置)**:配置过目标目录后,每次 statusline 渲染都会检查是否到周期,到了就 spawn 一个 detached 后台进程静默执行同步,**不阻塞 statusline、不污染单行输出**(与更新检查同款机制)。
208
+ - 局限:statusline 只在你**使用 Claude Code** 时触发。某天完全不开 Claude Code,则当天不会自动同步——但那天也没有新数据,下次任意一次交互都会把整周最新 bundle 重新覆盖同步,不丢数据。
209
+ 2. **系统调度器(不依赖是否开 Claude Code)**:如果要做到「哪怕一整天不碰 Claude Code,也在固定时间准时跑一次」,用 `ccus sync install` 一键注册一个**每周五 18:00** 跑 `ccus sync` 的系统调度器:
210
+
211
+ ```bash
212
+ ccus sync install # Windows 用 schtasks 创建计划任务(任务名 ccus-sync)
213
+ ccus sync install --print # 只打印将执行的命令、不实际安装
214
+ ccus sync uninstall # 卸载该调度器(--print 只打印不执行)
215
+ ```
216
+
217
+ - **Windows** 会真正创建/删除计划任务;也可手动 `schtasks /delete /tn ccus-sync /f`。
218
+ - **macOS / Linux** 不自动改系统,`ccus sync install` 会打印一条 cron 命令(`0 18 * * 5 …`)让你手动 `crontab` 安装,避免误改已有 crontab。
219
+
220
+ > 记得先 `ccus sync config --target DIR` 配好目标目录,调度任务才有地方可同步。
221
+
176
222
  ## 调试
177
223
 
178
224
  出问题时(比如 statusline 不出数据、导出/聚合结果不对),可以打开详细日志:
package/dist/cli.js CHANGED
@@ -53,13 +53,15 @@ const io_1 = require("./lib/io");
53
53
  const open_1 = require("./lib/open");
54
54
  const payload_1 = require("./lib/payload");
55
55
  const paths_1 = require("./lib/paths");
56
+ const scheduler_1 = require("./lib/scheduler");
57
+ const sync_1 = require("./lib/sync");
56
58
  const storage_1 = require("./lib/storage");
57
59
  const time_1 = require("./lib/time");
58
60
  const update_check_1 = require("./lib/update-check");
59
61
  const version_1 = require("./lib/version");
60
62
  /** CLI 帮助信息保持简洁,方便直接挂到 README 或终端里查看。 */
61
63
  function printHelp() {
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`);
64
+ 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 sync [--data-dir PATH]\n ccus sync config [--target DIR] [--interval 3h|daily|<N>h|<N>m] [--range this-week] [--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 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`);
63
65
  }
64
66
  /** 一个轻量的参数解析器,当前命令面不复杂,没必要引入额外依赖。 */
65
67
  function parseOptions(args) {
@@ -179,6 +181,8 @@ async function handleStatuslineEmit(options) {
179
181
  // 更新检查必须对 statusline 完全无侵入:同步读旧缓存决定是否在行尾追加小标记,
180
182
  // 把网络请求甩给 detached 后台进程,主进程不等待、失败静默。
181
183
  (0, update_check_1.maybeSpawnBackgroundCheck)(dataDir);
184
+ // 定时同步同样对 statusline 无侵入:到周期才 spawn detached 后台进程执行 export+复制,主进程不等待。
185
+ (0, sync_1.maybeSpawnBackgroundSync)(dataDir);
182
186
  const notice = (0, update_check_1.computeUpdateNotice)(dataDir);
183
187
  const statusLine = notice ? `${event.statusLine} | ${notice}` : event.statusLine;
184
188
  process.stdout.write(`${statusLine}\n`);
@@ -288,11 +292,12 @@ async function handleDashboardServe(options) {
288
292
  });
289
293
  }
290
294
  /**
291
- * 导出原始事件。
295
+ * 导出原始事件:生成 bundle 并写入本地 exports,返回输出路径与时间窗口。
292
296
  *
293
297
  * 当前默认导出一个 JSON 包,同时包含原始事件和按天周汇总。
298
+ * 抽成独立函数供 `ccus export` 与 `ccus sync` 复用,行为完全一致。
294
299
  */
295
- async function handleExport(options) {
300
+ async function runExport(options) {
296
301
  const dataDir = getDataDir(options);
297
302
  const range = getStringOption(options, "range") ?? "this-week";
298
303
  const output = getStringOption(options, "out");
@@ -417,6 +422,13 @@ async function handleExport(options) {
417
422
  await (0, export_1.writeTextFile)(outputPath, content);
418
423
  }
419
424
  (0, debug_1.debugLog)("export", "bundle written", { outputPath, rawBytes: content.length, compressed });
425
+ return { outputPath, window };
426
+ }
427
+ /**
428
+ * `ccus export` 入口:执行 runExport 并把输出路径打到 stdout。
429
+ */
430
+ async function handleExport(options) {
431
+ const { outputPath } = await runExport(options);
420
432
  process.stdout.write(`${outputPath}\n`);
421
433
  }
422
434
  /**
@@ -508,6 +520,25 @@ async function handleAggregateServe(options) {
508
520
  process.once("SIGTERM", shutdown);
509
521
  });
510
522
  }
523
+ /**
524
+ * `ccus open`:用系统文件管理器打开 ccus 本地存储目录(事件日志、exports、dashboard 都在里面)。
525
+ *
526
+ * 默认目录由 `getDefaultDataDir` 推导,可用 `--data-dir` 覆盖。
527
+ * 加 `--print` 只把目录路径打到 stdout、不真正打开,方便在脚本里取路径。
528
+ */
529
+ async function handleOpenDataDir(options) {
530
+ const dataDir = getDataDir(options);
531
+ (0, debug_1.debugLog)("open", "resolved data dir", { dataDir, print: getBooleanOption(options, "print") });
532
+ if (getBooleanOption(options, "print")) {
533
+ process.stdout.write(`${dataDir}\n`);
534
+ return;
535
+ }
536
+ // 目录可能还没被 statusline 创建过,先确保存在,否则系统打开会失败。
537
+ const fs = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
538
+ await fs.mkdir(dataDir, { recursive: true });
539
+ await (0, open_1.openPath)(dataDir);
540
+ process.stdout.write(`${dataDir}\n`);
541
+ }
511
542
  /** 历史上被移除的导出格式 token,作为位置参数出现时要明确报错而不是当成 range。 */
512
543
  const REMOVED_EXPORT_FORMAT_TOKENS = new Set(["csv", "jsonl", "raw"]);
513
544
  /**
@@ -600,6 +631,141 @@ async function handleBackgroundCheckUpdate(options) {
600
631
  const dataDir = getDataDir(options);
601
632
  await (0, update_check_1.performUpdateCheck)(dataDir);
602
633
  }
634
+ /**
635
+ * `ccus sync`:执行一次定时同步(导出当前周 bundle 并复制到目标目录的按周子目录)。
636
+ *
637
+ * 带 `--target` / `--interval` / `--range` 时先合并并持久化配置,再立即同步一次;
638
+ * 不带参数则用已存配置同步。最终仍无目标目录则报错引导用户先配置。
639
+ */
640
+ async function handleSync(options) {
641
+ const dataDir = getDataDir(options);
642
+ const target = getStringOption(options, "target");
643
+ const interval = getStringOption(options, "interval");
644
+ const range = getStringOption(options, "range");
645
+ if (target !== undefined || interval !== undefined || range !== undefined) {
646
+ const current = (0, sync_1.readSyncConfig)(dataDir);
647
+ const next = {
648
+ targetDir: target !== undefined ? node_path_1.default.resolve(target) : current.targetDir,
649
+ intervalLabel: interval ?? current.intervalLabel,
650
+ range: range ?? current.range,
651
+ };
652
+ await (0, sync_1.writeSyncConfig)(dataDir, next);
653
+ (0, debug_1.debugLog)("sync", "config updated", next);
654
+ }
655
+ const config = (0, sync_1.readSyncConfig)(dataDir);
656
+ if (!config.targetDir) {
657
+ throw new Error("未配置同步目标目录。请先运行 `ccus sync config --target DIR`。");
658
+ }
659
+ const result = await (0, sync_1.performSync)(dataDir, runExport);
660
+ process.stdout.write(`已同步到 ${result.destPath}\n`);
661
+ if (result.archivedLastWeekDest) {
662
+ process.stdout.write(`已归档上一周到 ${result.archivedLastWeekDest}\n`);
663
+ }
664
+ }
665
+ /**
666
+ * `ccus sync status`:打印当前同步配置、上次同步时间与是否到期。
667
+ */
668
+ async function handleSyncStatus(options) {
669
+ const dataDir = getDataDir(options);
670
+ const config = (0, sync_1.readSyncConfig)(dataDir);
671
+ const state = (0, sync_1.readSyncStateSync)(dataDir);
672
+ const due = (0, sync_1.isSyncDue)(config, state);
673
+ const lines = [
674
+ `目标目录: ${config.targetDir ?? "(未配置)"}`,
675
+ `同步周期: ${config.intervalLabel}`,
676
+ `导出范围: ${config.range}`,
677
+ `上次同步: ${state?.lastSyncedAt ?? "(从未)"}${state?.lastResult ? ` [${state.lastResult}]` : ""}`,
678
+ state?.lastArchivedWeek ? `已归档上一周: ${state.lastArchivedWeek}` : null,
679
+ state?.lastError ? `上次错误: ${state.lastError}` : null,
680
+ `现在是否到期: ${config.targetDir ? (due ? "是" : "否") : "(未配置目标目录)"}`,
681
+ ].filter((line) => line !== null);
682
+ process.stdout.write(`${lines.join("\n")}\n`);
683
+ }
684
+ /**
685
+ * `ccus sync config`:只读写同步配置,不触发同步。
686
+ *
687
+ * 带 `--target` / `--interval` / `--range` 时合并并持久化到 `sync-config.json`;
688
+ * 不带任何参数时仅打印当前配置,方便确认。
689
+ */
690
+ async function handleSyncConfig(options) {
691
+ const dataDir = getDataDir(options);
692
+ const target = getStringOption(options, "target");
693
+ const interval = getStringOption(options, "interval");
694
+ const range = getStringOption(options, "range");
695
+ const current = (0, sync_1.readSyncConfig)(dataDir);
696
+ const changed = target !== undefined || interval !== undefined || range !== undefined;
697
+ const next = {
698
+ targetDir: target !== undefined ? node_path_1.default.resolve(target) : current.targetDir,
699
+ intervalLabel: interval ?? current.intervalLabel,
700
+ range: range ?? current.range,
701
+ };
702
+ if (changed) {
703
+ await (0, sync_1.writeSyncConfig)(dataDir, next);
704
+ (0, debug_1.debugLog)("sync", "config updated", next);
705
+ }
706
+ const header = changed ? "同步配置已更新:" : "当前同步配置:";
707
+ process.stdout.write(`${header}\n 目标目录: ${next.targetDir ?? "(未配置)"}\n 同步周期: ${next.intervalLabel}\n 导出范围: ${next.range}\n`);
708
+ }
709
+ /**
710
+ * `ccus sync install`:安装一个系统调度器,每周五 18:00 跑一次 `ccus sync`。
711
+ *
712
+ * Windows 用 schtasks 真正创建计划任务;macOS / Linux 打印 cron 命令交由用户手动安装。
713
+ * 加 `--print` 只打印将执行的调度器命令、不真正安装。
714
+ */
715
+ async function handleSyncInstall(options) {
716
+ const dataDir = getDataDir(options);
717
+ const print = getBooleanOption(options, "print");
718
+ const scriptPath = process.argv[1];
719
+ const result = (0, scheduler_1.installScheduler)(process.execPath, scriptPath, dataDir, { print });
720
+ const { plan } = result;
721
+ if (print) {
722
+ process.stdout.write(`将安装的调度器命令(每周五 18:00 同步):\n ${plan.displayCommand}\n`);
723
+ return;
724
+ }
725
+ if (result.installed) {
726
+ process.stdout.write(`已安装系统调度器「ccus-sync」:每周五 18:00 运行一次 ccus sync。\n卸载:\n ${plan.uninstallHint}\n`);
727
+ return;
728
+ }
729
+ // 非 Windows:不自动改系统,打印命令引导用户手动安装。
730
+ process.stdout.write(`当前平台(${plan.platform})不自动安装,请手动运行以下命令注册每周五 18:00 的 cron 任务:\n ${plan.displayCommand}\n卸载:\n ${plan.uninstallHint}\n`);
731
+ }
732
+ /**
733
+ * `ccus sync uninstall`:卸载每周五同步的系统调度器。
734
+ *
735
+ * Windows 用 schtasks 删除计划任务;macOS / Linux 打印 crontab 提示。加 `--print` 只打印命令、不执行。
736
+ */
737
+ async function handleSyncUninstall(options) {
738
+ const print = getBooleanOption(options, "print");
739
+ const result = (0, scheduler_1.uninstallScheduler)({ print });
740
+ if (print) {
741
+ process.stdout.write(`将执行的卸载命令:\n ${result.displayCommand}\n`);
742
+ return;
743
+ }
744
+ if (result.autoUninstallable) {
745
+ if (result.uninstalled) {
746
+ process.stdout.write(`已卸载系统调度器「ccus-sync」。\n`);
747
+ }
748
+ else {
749
+ process.stdout.write(`未能删除调度任务(可能本就未安装)。如需手动卸载:\n ${result.displayCommand}\n`);
750
+ }
751
+ return;
752
+ }
753
+ process.stdout.write(`当前平台(${result.platform})请手动卸载:\n ${result.displayCommand}\n`);
754
+ }
755
+ /**
756
+ * 隐藏命令 `__sync`:由 statusline 路径以 detached 后台进程触发。
757
+ *
758
+ * 静默执行一次同步:不写 stdout,失败一律吞掉,绝不影响触发它的 statusline 主进程。
759
+ */
760
+ async function handleBackgroundSync(options) {
761
+ const dataDir = getDataDir(options);
762
+ try {
763
+ await (0, sync_1.performSync)(dataDir, runExport);
764
+ }
765
+ catch (error) {
766
+ (0, debug_1.debugLog)("sync", "background sync failed", error instanceof Error ? (error.stack ?? error.message) : String(error));
767
+ }
768
+ }
603
769
  /** 顶层命令分发入口。 */
604
770
  async function main(args = process.argv.slice(2)) {
605
771
  (0, debug_1.setDebugEnabled)((0, debug_1.resolveDebugEnabled)(args));
@@ -614,6 +780,10 @@ async function main(args = process.argv.slice(2)) {
614
780
  process.stdout.write(`${(0, version_1.getCurrentVersion)()}\n`);
615
781
  return;
616
782
  }
783
+ if (group === "open") {
784
+ await handleOpenDataDir(parseOptions(args.slice(1)));
785
+ return;
786
+ }
617
787
  if (group === "update") {
618
788
  await handleUpdate(parseOptions(args.slice(1)));
619
789
  return;
@@ -622,6 +792,33 @@ async function main(args = process.argv.slice(2)) {
622
792
  await handleBackgroundCheckUpdate(parseOptions(args.slice(1)));
623
793
  return;
624
794
  }
795
+ if (group === "sync") {
796
+ if (action === "config") {
797
+ await handleSyncConfig(parseOptions(rest));
798
+ return;
799
+ }
800
+ if (action === "status") {
801
+ await handleSyncStatus(parseOptions(rest));
802
+ return;
803
+ }
804
+ if (action === "install") {
805
+ await handleSyncInstall(parseOptions(rest));
806
+ return;
807
+ }
808
+ if (action === "uninstall") {
809
+ await handleSyncUninstall(parseOptions(rest));
810
+ return;
811
+ }
812
+ if (action && !action.startsWith("--")) {
813
+ throw new Error(`Unsupported sync argument: ${action}. Use \`ccus sync\`, \`ccus sync config [--target DIR]\`, \`ccus sync install\`, \`ccus sync uninstall\` or \`ccus sync status\`.`);
814
+ }
815
+ await handleSync(parseOptions(args.slice(1)));
816
+ return;
817
+ }
818
+ if (group === "__sync") {
819
+ await handleBackgroundSync(parseOptions(args.slice(1)));
820
+ return;
821
+ }
625
822
  if (group === "install") {
626
823
  await handleInstall(parseOptions(args.slice(1)));
627
824
  return;
@@ -110,75 +110,172 @@ async function loadWeeklyExportBundles(inputDir) {
110
110
  }
111
111
  return bundles;
112
112
  }
113
- /** 从 bundle.rawEvents 展开 detail.csv 明细。 */
113
+ /**
114
+ * 同一个人在多台电脑导出多个 bundle 时的合并策略。
115
+ *
116
+ * 累加类字段(token / 消息数 / 采样数等)怕重复计数(同一台机器重复导出、周与周重叠),
117
+ * 所以按「同人同天 / 同人同周取 generatedAt 最新的那份导出 bundle」去重,不相加。
118
+ * usage 是百分比快照、不是累加量,从选中那份 winner bundle 的 rawEvents 按真实时间戳重算
119
+ * (peak 取 max,latest 取时间戳最新),某指标在 rawEvents 里缺失时回退到 daySummary/weeklySummary 自带值。
120
+ */
121
+ /** bundle 的 personKey 解析结果做一次缓存,避免反复计算。 */
122
+ function bundlePersonKey(bundle) {
123
+ return toPersonKey(bundle.identity.gitUserEmail, bundle.identity.gitUserName);
124
+ }
125
+ /** 某天 daySummary 是否承载真实数据(用于优先选中有数据的那份 bundle,而非占位空天)。 */
126
+ function dayHasData(day) {
127
+ return day.sampleCount > 0 || day.userMessageCount > 0 || day.apiRequestCount > 0;
128
+ }
129
+ /** 整周 weeklySummary 是否承载真实数据。 */
130
+ function weekHasData(bundle) {
131
+ const summary = bundle.weeklySummary;
132
+ return summary.statusline.sampleCount > 0 || summary.counts.userMessageCount > 0 || summary.counts.apiRequestCount > 0;
133
+ }
134
+ /** winner 比较:有数据优先,其次 generatedAt 较新,最后用 filePath 做稳定 tie-break。 */
135
+ function isBetterCandidate(nextHasData, nextGeneratedAt, nextFilePath, currentHasData, currentGeneratedAt, currentFilePath) {
136
+ if (nextHasData !== currentHasData) {
137
+ return nextHasData;
138
+ }
139
+ if (nextGeneratedAt !== currentGeneratedAt) {
140
+ return nextGeneratedAt > currentGeneratedAt;
141
+ }
142
+ return nextFilePath > currentFilePath;
143
+ }
144
+ /** 对每个 (personKey, date) 选出 generatedAt 最新、且尽量有数据的那份 bundle 当天数据。 */
145
+ function selectDailyWinners(bundles) {
146
+ const winners = new Map();
147
+ for (const { filePath, bundle } of bundles) {
148
+ const personKey = bundlePersonKey(bundle);
149
+ const generatedAt = bundle.generatedAt ?? "";
150
+ for (const day of bundle.dailySummaries) {
151
+ const key = `${personKey}|${day.date}`;
152
+ const current = winners.get(key);
153
+ if (!current || isBetterCandidate(dayHasData(day), generatedAt, filePath, dayHasData(current.day), current.generatedAt, current.filePath)) {
154
+ winners.set(key, { personKey, date: day.date, day, bundle, generatedAt, filePath });
155
+ }
156
+ }
157
+ }
158
+ return winners;
159
+ }
160
+ /** 对每个 (personKey, week) 选出 generatedAt 最新、且尽量有数据的那份 bundle。 */
161
+ function selectWeeklyWinners(bundles) {
162
+ const winners = new Map();
163
+ for (const { filePath, bundle } of bundles) {
164
+ const personKey = bundlePersonKey(bundle);
165
+ const week = weekKey(new Date(bundle.range.start));
166
+ const key = `${personKey}|${week}`;
167
+ const generatedAt = bundle.generatedAt ?? "";
168
+ const current = winners.get(key);
169
+ if (!current || isBetterCandidate(weekHasData(bundle), generatedAt, filePath, weekHasData(current.bundle), current.generatedAt, current.filePath)) {
170
+ winners.set(key, { personKey, week, bundle, generatedAt, filePath });
171
+ }
172
+ }
173
+ return winners;
174
+ }
175
+ /** 把 bundle 的 rawEvents 计算成 StatuslineEvent 并按本地自然日分组,结果做缓存复用。 */
176
+ const bundleEventsCache = new WeakMap();
177
+ function bundleEventsByDate(bundle) {
178
+ const cached = bundleEventsCache.get(bundle);
179
+ if (cached) {
180
+ return cached;
181
+ }
182
+ const byDate = new Map();
183
+ for (const record of bundle.rawEvents.filter(isPersistedStatuslineEvent)) {
184
+ const event = (0, payload_1.computeStatuslineEvent)(record);
185
+ const dateKey = localDateKey(new Date(event.timestamp));
186
+ const list = byDate.get(dateKey);
187
+ if (list) {
188
+ list.push(event);
189
+ }
190
+ else {
191
+ byDate.set(dateKey, [event]);
192
+ }
193
+ }
194
+ bundleEventsCache.set(bundle, byDate);
195
+ return byDate;
196
+ }
197
+ /** 从一组事件按真实时间戳重算 5h / 7d 的 peak(max)与 latest(时间戳最新非空)。 */
198
+ function recomputeUsage(events) {
199
+ const newestFirst = [...events].sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime());
200
+ const fiveValues = events.map((event) => event.usagePct).filter((value) => value !== null);
201
+ const sevenValues = events.map((event) => event.sevenDayUsagePct).filter((value) => value !== null);
202
+ return {
203
+ fiveHourPeakUsagePct: fiveValues.length > 0 ? (0, time_1.roundNumber)(Math.max(...fiveValues), 1) : null,
204
+ fiveHourLatestUsagePct: newestFirst.find((event) => event.usagePct !== null)?.usagePct ?? null,
205
+ sevenDayPeakUsagePct: sevenValues.length > 0 ? (0, time_1.roundNumber)(Math.max(...sevenValues), 1) : null,
206
+ sevenDayLatestUsagePct: newestFirst.find((event) => event.sevenDayUsagePct !== null)?.sevenDayUsagePct ?? null,
207
+ };
208
+ }
209
+ /** 从 winner bundle 的事件展开 detail.csv,同人同天只保留 winner,那份的当天 token 总量随行附带。 */
114
210
  function buildAggregatedDetailRows(bundles) {
211
+ const winners = selectDailyWinners(bundles);
115
212
  const rows = [];
116
- for (const { bundle } of bundles) {
117
- const personKey = toPersonKey(bundle.identity.gitUserEmail, bundle.identity.gitUserName);
118
- const tokensByDate = new Map(bundle.dailySummaries.map((day) => [day.date, day]));
119
- for (const record of bundle.rawEvents.filter(isPersistedStatuslineEvent)) {
120
- const event = (0, payload_1.computeStatuslineEvent)(record);
121
- const ts = new Date(event.timestamp);
122
- const dateKey = localDateKey(ts);
123
- const dayTokens = tokensByDate.get(dateKey);
213
+ for (const winner of winners.values()) {
214
+ const events = bundleEventsByDate(winner.bundle).get(winner.date) ?? [];
215
+ for (const event of events) {
124
216
  rows.push({
125
217
  ...event,
126
- personKey,
127
- weekKey: weekKey(ts),
128
- dateKey,
129
- inputTokens: dayTokens?.inputTokens ?? 0,
130
- outputTokens: dayTokens?.outputTokens ?? 0,
131
- cacheReadInputTokens: dayTokens?.cacheReadInputTokens ?? 0,
218
+ personKey: winner.personKey,
219
+ weekKey: weekKey(new Date(event.timestamp)),
220
+ dateKey: winner.date,
221
+ inputTokens: winner.day.inputTokens,
222
+ outputTokens: winner.day.outputTokens,
223
+ cacheReadInputTokens: winner.day.cacheReadInputTokens,
132
224
  });
133
225
  }
134
226
  }
135
227
  return rows.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
136
228
  }
137
- /** 直接从 bundle.dailySummaries 展开 daily.csv */
229
+ /** 展开 daily.csv:同人同天取 winner bundle 的累加值,usage 从该 bundle 当天事件重算。 */
138
230
  function buildAggregatedDailyRows(bundles) {
231
+ const winners = selectDailyWinners(bundles);
139
232
  const rows = [];
140
- for (const { bundle } of bundles) {
141
- const personKey = toPersonKey(bundle.identity.gitUserEmail, bundle.identity.gitUserName);
142
- for (const item of bundle.dailySummaries) {
143
- rows.push({
144
- personKey,
145
- date: item.date,
146
- userMessageCount: item.userMessageCount,
147
- apiRequestCount: item.apiRequestCount,
148
- inputTokens: item.inputTokens,
149
- outputTokens: item.outputTokens,
150
- cacheReadInputTokens: item.cacheReadInputTokens,
151
- sampleCount: item.sampleCount,
152
- fiveHourPeakUsagePct: item.fiveHourPeakUsagePct,
153
- fiveHourLatestUsagePct: item.fiveHourLatestUsagePct,
154
- sevenDayPeakUsagePct: item.sevenDayPeakUsagePct,
155
- sevenDayLatestUsagePct: item.sevenDayLatestUsagePct,
156
- uniqueSessions: item.uniqueSessions,
157
- uniqueWorkspaces: item.uniqueWorkspaces,
158
- });
159
- }
233
+ for (const winner of winners.values()) {
234
+ const day = winner.day;
235
+ const usage = recomputeUsage(bundleEventsByDate(winner.bundle).get(winner.date) ?? []);
236
+ rows.push({
237
+ personKey: winner.personKey,
238
+ date: day.date,
239
+ userMessageCount: day.userMessageCount,
240
+ apiRequestCount: day.apiRequestCount,
241
+ inputTokens: day.inputTokens,
242
+ outputTokens: day.outputTokens,
243
+ cacheReadInputTokens: day.cacheReadInputTokens,
244
+ sampleCount: day.sampleCount,
245
+ fiveHourPeakUsagePct: usage.fiveHourPeakUsagePct ?? day.fiveHourPeakUsagePct,
246
+ fiveHourLatestUsagePct: usage.fiveHourLatestUsagePct ?? day.fiveHourLatestUsagePct,
247
+ sevenDayPeakUsagePct: usage.sevenDayPeakUsagePct ?? day.sevenDayPeakUsagePct,
248
+ sevenDayLatestUsagePct: usage.sevenDayLatestUsagePct ?? day.sevenDayLatestUsagePct,
249
+ uniqueSessions: day.uniqueSessions,
250
+ uniqueWorkspaces: day.uniqueWorkspaces,
251
+ });
160
252
  }
161
253
  return rows.sort((left, right) => `${left.personKey}|${left.date}`.localeCompare(`${right.personKey}|${right.date}`));
162
254
  }
163
- /** 直接从 bundle.weeklySummary 展开 weekly.csv */
255
+ /** 展开 weekly.csv:同人同周取 winner bundle 的累加值,usage 从该 bundle 全部事件重算。 */
164
256
  function buildAggregatedWeeklyRows(bundles) {
165
- return bundles
166
- .map(({ bundle }) => ({
167
- personKey: toPersonKey(bundle.identity.gitUserEmail, bundle.identity.gitUserName),
168
- week: weekKey(new Date(bundle.range.start)),
169
- userMessageCount: bundle.weeklySummary.counts.userMessageCount,
170
- apiRequestCount: bundle.weeklySummary.counts.apiRequestCount,
171
- inputTokens: bundle.weeklySummary.tokens.inputTokens,
172
- outputTokens: bundle.weeklySummary.tokens.outputTokens,
173
- cacheReadInputTokens: bundle.weeklySummary.tokens.cacheReadInputTokens,
174
- sampleCount: bundle.weeklySummary.statusline.sampleCount,
175
- fiveHourPeakUsagePct: bundle.weeklySummary.statusline.fiveHourPeakUsagePct,
176
- fiveHourLatestUsagePct: bundle.weeklySummary.statusline.fiveHourLatestUsagePct,
177
- sevenDayPeakUsagePct: bundle.weeklySummary.statusline.sevenDayPeakUsagePct,
178
- sevenDayLatestUsagePct: bundle.weeklySummary.statusline.sevenDayLatestUsagePct,
179
- uniqueSessions: bundle.weeklySummary.statusline.uniqueSessions,
180
- uniqueWorkspaces: bundle.weeklySummary.statusline.uniqueWorkspaces,
181
- }))
182
- .sort((left, right) => `${left.personKey}|${left.week}`.localeCompare(`${right.personKey}|${right.week}`));
257
+ const winners = selectWeeklyWinners(bundles);
258
+ const rows = [];
259
+ for (const winner of winners.values()) {
260
+ const summary = winner.bundle.weeklySummary;
261
+ const usage = recomputeUsage([...bundleEventsByDate(winner.bundle).values()].flat());
262
+ rows.push({
263
+ personKey: winner.personKey,
264
+ week: winner.week,
265
+ userMessageCount: summary.counts.userMessageCount,
266
+ apiRequestCount: summary.counts.apiRequestCount,
267
+ inputTokens: summary.tokens.inputTokens,
268
+ outputTokens: summary.tokens.outputTokens,
269
+ cacheReadInputTokens: summary.tokens.cacheReadInputTokens,
270
+ sampleCount: summary.statusline.sampleCount,
271
+ fiveHourPeakUsagePct: usage.fiveHourPeakUsagePct ?? summary.statusline.fiveHourPeakUsagePct,
272
+ fiveHourLatestUsagePct: usage.fiveHourLatestUsagePct ?? summary.statusline.fiveHourLatestUsagePct,
273
+ sevenDayPeakUsagePct: usage.sevenDayPeakUsagePct ?? summary.statusline.sevenDayPeakUsagePct,
274
+ sevenDayLatestUsagePct: usage.sevenDayLatestUsagePct ?? summary.statusline.sevenDayLatestUsagePct,
275
+ uniqueSessions: summary.statusline.uniqueSessions,
276
+ uniqueWorkspaces: summary.statusline.uniqueWorkspaces,
277
+ });
278
+ }
279
+ return rows.sort((left, right) => `${left.personKey}|${left.week}`.localeCompare(`${right.personKey}|${right.week}`));
183
280
  }
184
281
  //# sourceMappingURL=aggregate.js.map
package/dist/lib/open.js CHANGED
@@ -1,18 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openPath = openPath;
3
4
  exports.openInBrowser = openInBrowser;
4
5
  const node_child_process_1 = require("node:child_process");
5
6
  /**
6
- * 用系统默认浏览器打开生成好的 dashboard。
7
+ * 用系统默认程序打开一个文件或目录:HTML 走默认浏览器,目录走文件管理器。
7
8
  *
8
9
  * 这里不拼接 shell 字符串,而是显式传参数,降低路径注入风险。
9
10
  */
10
- async function openInBrowser(filePath) {
11
+ async function openPath(target) {
11
12
  const command = process.platform === "win32"
12
- ? { program: "cmd", args: ["/c", "start", "", filePath] }
13
+ ? { program: "cmd", args: ["/c", "start", "", target] }
13
14
  : process.platform === "darwin"
14
- ? { program: "open", args: [filePath] }
15
- : { program: "xdg-open", args: [filePath] };
15
+ ? { program: "open", args: [target] }
16
+ : { program: "xdg-open", args: [target] };
16
17
  await new Promise((resolve, reject) => {
17
18
  const child = (0, node_child_process_1.spawn)(command.program, command.args, {
18
19
  detached: true,
@@ -23,4 +24,8 @@ async function openInBrowser(filePath) {
23
24
  resolve();
24
25
  });
25
26
  }
27
+ /** 用系统默认浏览器打开生成好的 dashboard。 */
28
+ async function openInBrowser(filePath) {
29
+ await openPath(filePath);
30
+ }
26
31
  //# sourceMappingURL=open.js.map
package/dist/lib/paths.js CHANGED
@@ -7,6 +7,8 @@ exports.getDefaultDataDir = getDefaultDataDir;
7
7
  exports.getEventsDir = getEventsDir;
8
8
  exports.getDashboardDir = getDashboardDir;
9
9
  exports.getUpdateCachePath = getUpdateCachePath;
10
+ exports.getSyncConfigPath = getSyncConfigPath;
11
+ exports.getSyncStatePath = getSyncStatePath;
10
12
  exports.getClaudeDataDir = getClaudeDataDir;
11
13
  exports.getClaudeSettingsPath = getClaudeSettingsPath;
12
14
  const node_os_1 = __importDefault(require("node:os"));
@@ -46,6 +48,14 @@ function getDashboardDir(dataDir) {
46
48
  function getUpdateCachePath(dataDir) {
47
49
  return node_path_1.default.join(dataDir, "update-check.json");
48
50
  }
51
+ /** 定时同步的用户配置文件(目标目录、周期、范围),可手编。 */
52
+ function getSyncConfigPath(dataDir) {
53
+ return node_path_1.default.join(dataDir, "sync-config.json");
54
+ }
55
+ /** 定时同步的运行时状态文件(上次同步时间与结果),由 ccus sync 写入。 */
56
+ function getSyncStatePath(dataDir) {
57
+ return node_path_1.default.join(dataDir, "sync-state.json");
58
+ }
49
59
  /** Claude Code 默认本地数据目录。 */
50
60
  function getClaudeDataDir() {
51
61
  const configured = process.env.CCUS_CLAUDE_DATA_DIR;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SCHEDULE_TIME = exports.SCHEDULE_CRON = exports.SCHEDULE_WEEKDAY_SHORT = exports.SCHEDULER_TASK_NAME = void 0;
4
+ exports.buildCcusSyncInvocation = buildCcusSyncInvocation;
5
+ exports.buildSchedulerPlan = buildSchedulerPlan;
6
+ exports.installScheduler = installScheduler;
7
+ exports.uninstallScheduler = uninstallScheduler;
8
+ const node_child_process_1 = require("node:child_process");
9
+ const debug_1 = require("./debug");
10
+ /** 系统调度器里使用的任务名(Windows 计划任务 / cron 注释里都用它)。 */
11
+ exports.SCHEDULER_TASK_NAME = "ccus-sync";
12
+ /** 调度时间:每周五 18:00(下午 6 点)。 */
13
+ exports.SCHEDULE_WEEKDAY_SHORT = "FRI"; // schtasks 用
14
+ exports.SCHEDULE_CRON = "0 18 * * 5"; // cron 用(周五 = 5)
15
+ exports.SCHEDULE_TIME = "18:00";
16
+ /** 给路径/命令片段加双引号,避免空格被拆分。 */
17
+ function quote(value) {
18
+ return `"${value}"`;
19
+ }
20
+ /**
21
+ * 构造 ccus sync 的调用串。
22
+ *
23
+ * 优先用绝对的 node + cli.js 路径(调度任务运行时 PATH 可能不含全局 npm bin);
24
+ * 拿不到脚本路径时退回 `ccus`。始终带显式 `--data-dir`,与交互式同步用同一目录。
25
+ */
26
+ function buildCcusSyncInvocation(execPath, scriptPath, dataDir) {
27
+ if (!scriptPath) {
28
+ return `ccus sync --data-dir ${quote(dataDir)}`;
29
+ }
30
+ return `${quote(execPath)} ${quote(scriptPath)} sync --data-dir ${quote(dataDir)}`;
31
+ }
32
+ /**
33
+ * 根据平台构造「每周五 18:00 跑 ccus sync」的调度器安装计划。
34
+ *
35
+ * Windows 用 `schtasks` 创建计划任务(ccus 可自动执行);
36
+ * macOS / Linux 给出 cron 一行命令(由用户手动执行,避免自动改 crontab 的风险)。
37
+ */
38
+ function buildSchedulerPlan(platform, execPath, scriptPath, dataDir) {
39
+ const invocation = buildCcusSyncInvocation(execPath, scriptPath, dataDir);
40
+ if (platform === "win32") {
41
+ const args = [
42
+ "/create",
43
+ "/tn",
44
+ exports.SCHEDULER_TASK_NAME,
45
+ "/tr",
46
+ invocation,
47
+ "/sc",
48
+ "weekly",
49
+ "/d",
50
+ exports.SCHEDULE_WEEKDAY_SHORT,
51
+ "/st",
52
+ exports.SCHEDULE_TIME,
53
+ "/f",
54
+ ];
55
+ return {
56
+ platform,
57
+ autoInstallable: true,
58
+ invocation,
59
+ command: "schtasks",
60
+ args,
61
+ displayCommand: `schtasks /create /tn ${exports.SCHEDULER_TASK_NAME} /tr ${quote(invocation)} /sc weekly /d ${exports.SCHEDULE_WEEKDAY_SHORT} /st ${exports.SCHEDULE_TIME} /f`,
62
+ uninstallHint: `schtasks /delete /tn ${exports.SCHEDULER_TASK_NAME} /f`,
63
+ };
64
+ }
65
+ // macOS / Linux:给出 cron 一行命令,由用户手动安装。
66
+ const cronLine = `${exports.SCHEDULE_CRON} ${invocation} # ${exports.SCHEDULER_TASK_NAME}`;
67
+ return {
68
+ platform,
69
+ autoInstallable: false,
70
+ invocation,
71
+ command: null,
72
+ args: null,
73
+ displayCommand: `(crontab -l 2>/dev/null; echo '${cronLine}') | crontab -`,
74
+ uninstallHint: `crontab -e # 删除标注 ${exports.SCHEDULER_TASK_NAME} 的那一行`,
75
+ };
76
+ }
77
+ /**
78
+ * 执行调度器安装。
79
+ *
80
+ * `print` 为 true 时只返回计划、不执行。Windows 下调用 schtasks 真正创建任务;
81
+ * 其它平台不自动改系统,交由调用方打印 displayCommand 引导用户手动安装。
82
+ */
83
+ function installScheduler(execPath, scriptPath, dataDir, options = {}) {
84
+ const platform = options.platform ?? process.platform;
85
+ const plan = buildSchedulerPlan(platform, execPath, scriptPath, dataDir);
86
+ if (options.print || !plan.autoInstallable || !plan.command || !plan.args) {
87
+ return { plan, installed: false };
88
+ }
89
+ (0, debug_1.debugLog)("scheduler", "installing", { command: plan.command, args: plan.args });
90
+ const result = (0, node_child_process_1.spawnSync)(plan.command, plan.args, { stdio: "inherit" });
91
+ if (result.status !== 0) {
92
+ throw new Error(`调度器安装失败(${plan.command} 退出码 ${result.status ?? "未知"})。可手动运行:\n ${plan.displayCommand}`);
93
+ }
94
+ return { plan, installed: true };
95
+ }
96
+ /**
97
+ * 卸载「每周五同步」的系统调度器。
98
+ *
99
+ * `print` 为 true 时只返回命令、不执行。Windows 调用 `schtasks /delete` 删除任务
100
+ * (任务不存在时 schtasks 返回非 0,这里视为「未卸载」而非抛错);其它平台只打印 crontab 提示。
101
+ */
102
+ function uninstallScheduler(options = {}) {
103
+ const platform = options.platform ?? process.platform;
104
+ if (platform === "win32") {
105
+ const displayCommand = `schtasks /delete /tn ${exports.SCHEDULER_TASK_NAME} /f`;
106
+ if (options.print) {
107
+ return { platform, autoUninstallable: true, uninstalled: false, displayCommand };
108
+ }
109
+ (0, debug_1.debugLog)("scheduler", "uninstalling", { task: exports.SCHEDULER_TASK_NAME });
110
+ const result = (0, node_child_process_1.spawnSync)("schtasks", ["/delete", "/tn", exports.SCHEDULER_TASK_NAME, "/f"], { stdio: "inherit" });
111
+ return { platform, autoUninstallable: true, uninstalled: result.status === 0, displayCommand };
112
+ }
113
+ return {
114
+ platform,
115
+ autoUninstallable: false,
116
+ uninstalled: false,
117
+ displayCommand: `crontab -e # 删除标注 ${exports.SCHEDULER_TASK_NAME} 的那一行`,
118
+ };
119
+ }
120
+ //# sourceMappingURL=scheduler.js.map
@@ -0,0 +1,210 @@
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.parseSyncInterval = parseSyncInterval;
7
+ exports.readSyncConfig = readSyncConfig;
8
+ exports.writeSyncConfig = writeSyncConfig;
9
+ exports.readSyncStateSync = readSyncStateSync;
10
+ exports.isSyncDue = isSyncDue;
11
+ exports.performSync = performSync;
12
+ exports.maybeSpawnBackgroundSync = maybeSpawnBackgroundSync;
13
+ const node_child_process_1 = require("node:child_process");
14
+ const node_fs_1 = __importDefault(require("node:fs"));
15
+ const promises_1 = __importDefault(require("node:fs/promises"));
16
+ const node_path_1 = __importDefault(require("node:path"));
17
+ const debug_1 = require("./debug");
18
+ const paths_1 = require("./paths");
19
+ const time_1 = require("./time");
20
+ /** 默认同步周期标签:每 3 小时最多同步一次(滚动 TTL)。 */
21
+ const DEFAULT_INTERVAL_LABEL = "3h";
22
+ /** 默认导出范围:当前周 bundle。 */
23
+ const DEFAULT_RANGE = "this-week";
24
+ /**
25
+ * 解析周期标签。
26
+ *
27
+ * `daily`(或空/无法识别)→ 按自然日;`<N>h` / `<N>m` → 滚动 TTL。
28
+ */
29
+ function parseSyncInterval(label) {
30
+ const raw = (label ?? "").trim().toLowerCase();
31
+ if (raw === "" || raw === "daily") {
32
+ return { kind: "daily" };
33
+ }
34
+ const match = raw.match(/^(\d+)([hm])$/);
35
+ if (match) {
36
+ const amount = Number.parseInt(match[1], 10);
37
+ const ms = match[2] === "h" ? amount * 60 * 60 * 1000 : amount * 60 * 1000;
38
+ return { kind: "ttl", ms };
39
+ }
40
+ // 无法识别的标签退回每天语义,保持「默认每天」的承诺。
41
+ return { kind: "daily" };
42
+ }
43
+ /** 把同一本地日映射成稳定日键,用于 daily 周期的「是否跨天」判断。 */
44
+ function localDayKey(date) {
45
+ const year = date.getFullYear();
46
+ const month = String(date.getMonth() + 1).padStart(2, "0");
47
+ const day = String(date.getDate()).padStart(2, "0");
48
+ return `${year}-${month}-${day}`;
49
+ }
50
+ /**
51
+ * 同步读取用户配置;缺省补齐默认值,任何异常都回退到「无目标目录」的安全默认。
52
+ *
53
+ * statusline 兜底路径需要同步、极快、绝不抛错,所以这里用同步 IO。
54
+ */
55
+ function readSyncConfig(dataDir) {
56
+ const fallback = { targetDir: null, intervalLabel: DEFAULT_INTERVAL_LABEL, range: DEFAULT_RANGE };
57
+ try {
58
+ const raw = node_fs_1.default.readFileSync((0, paths_1.getSyncConfigPath)(dataDir), "utf8");
59
+ const parsed = JSON.parse(raw);
60
+ return {
61
+ targetDir: typeof parsed.targetDir === "string" && parsed.targetDir.trim() !== "" ? parsed.targetDir : null,
62
+ intervalLabel: typeof parsed.intervalLabel === "string" && parsed.intervalLabel.trim() !== "" ? parsed.intervalLabel : DEFAULT_INTERVAL_LABEL,
63
+ range: typeof parsed.range === "string" && parsed.range.trim() !== "" ? parsed.range : DEFAULT_RANGE,
64
+ };
65
+ }
66
+ catch {
67
+ return fallback;
68
+ }
69
+ }
70
+ /** 写入用户配置;目录可能尚未创建,先 mkdir。 */
71
+ async function writeSyncConfig(dataDir, config) {
72
+ await promises_1.default.mkdir(dataDir, { recursive: true });
73
+ await promises_1.default.writeFile((0, paths_1.getSyncConfigPath)(dataDir), `${JSON.stringify(config, null, 2)}\n`, "utf8");
74
+ }
75
+ /** 同步读取运行时状态;缺失或损坏一律返回 null。 */
76
+ function readSyncStateSync(dataDir) {
77
+ try {
78
+ const raw = node_fs_1.default.readFileSync((0, paths_1.getSyncStatePath)(dataDir), "utf8");
79
+ const parsed = JSON.parse(raw);
80
+ return {
81
+ lastSyncedAt: typeof parsed.lastSyncedAt === "string" ? parsed.lastSyncedAt : null,
82
+ lastResult: parsed.lastResult === "ok" || parsed.lastResult === "error" ? parsed.lastResult : null,
83
+ lastError: typeof parsed.lastError === "string" ? parsed.lastError : undefined,
84
+ lastArchivedWeek: typeof parsed.lastArchivedWeek === "string" ? parsed.lastArchivedWeek : undefined,
85
+ };
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ /** 写入运行时状态;失败静默(不能因状态写入失败影响主流程)。 */
92
+ async function writeSyncState(dataDir, state) {
93
+ try {
94
+ await promises_1.default.mkdir(dataDir, { recursive: true });
95
+ await promises_1.default.writeFile((0, paths_1.getSyncStatePath)(dataDir), `${JSON.stringify(state, null, 2)}\n`, "utf8");
96
+ }
97
+ catch (error) {
98
+ (0, debug_1.debugLog)("sync", "failed to write state", error instanceof Error ? error.message : String(error));
99
+ }
100
+ }
101
+ /**
102
+ * 判断当前是否到了该同步的时间点。
103
+ *
104
+ * 无目标目录永远返回 false;从未同步过返回 true;
105
+ * `daily` 用「上次同步与现在不在同一自然日」判断(最贴近「每天」),其它用滚动 TTL。
106
+ */
107
+ function isSyncDue(config, state, now = new Date()) {
108
+ if (!config.targetDir) {
109
+ return false;
110
+ }
111
+ if (!state || !state.lastSyncedAt) {
112
+ return true;
113
+ }
114
+ const last = Date.parse(state.lastSyncedAt);
115
+ if (!Number.isFinite(last)) {
116
+ return true;
117
+ }
118
+ const interval = parseSyncInterval(config.intervalLabel);
119
+ if (interval.kind === "daily") {
120
+ return localDayKey(new Date(last)) !== localDayKey(now);
121
+ }
122
+ return now.getTime() - last >= interval.ms;
123
+ }
124
+ /**
125
+ * 导出某个 range 的 bundle,并复制到目标目录下对应的「按周」子目录。
126
+ *
127
+ * 复制语义:本地 exports 仍保留一份,目标目录再放一份。周目录名按导出窗口的周一~周日推导。
128
+ */
129
+ async function exportAndCopy(dataDir, runExport, targetDir, range) {
130
+ const { outputPath, window } = await runExport({ "data-dir": dataDir, range });
131
+ const weekDir = (0, time_1.formatWeekDirName)(window.start, window.end);
132
+ const targetWeekDir = node_path_1.default.join(targetDir, weekDir);
133
+ await promises_1.default.mkdir(targetWeekDir, { recursive: true });
134
+ const destPath = node_path_1.default.join(targetWeekDir, node_path_1.default.basename(outputPath));
135
+ await promises_1.default.copyFile(outputPath, destPath);
136
+ (0, debug_1.debugLog)("sync", "copied bundle", { range, outputPath, destPath, weekDir });
137
+ return { outputPath, destPath, weekDir, targetWeekDir };
138
+ }
139
+ /**
140
+ * 执行一次同步:导出当前周 bundle,并复制到目标目录下的「按周」子目录。
141
+ *
142
+ * 复制语义:本地 exports 仍保留一份,目标目录再放一份,本地照旧可 aggregate / dashboard。
143
+ * 周一时额外把上一周(last-week,刚结束的完整周)导出并归档到对应子目录;
144
+ * 用 `sync-state.lastArchivedWeek` 去重,避免周一当天每次同步都重复归档。
145
+ * 失败时写 error 状态后向上抛,由调用方决定展示或静默。
146
+ */
147
+ async function performSync(dataDir, runExport, now = new Date()) {
148
+ const config = readSyncConfig(dataDir);
149
+ if (!config.targetDir) {
150
+ throw new Error("未配置同步目标目录。请先运行 `ccus sync config --target DIR`。");
151
+ }
152
+ const state = readSyncStateSync(dataDir);
153
+ let lastArchivedWeek = state?.lastArchivedWeek;
154
+ try {
155
+ const primary = await exportAndCopy(dataDir, runExport, config.targetDir, config.range);
156
+ // 周一(getDay() === 1)是上一周结束后第一个能拿到完整数据的日子:顺带归档 last-week。
157
+ let archivedLastWeekDest = null;
158
+ if (now.getDay() === 1) {
159
+ const lastWeek = (0, time_1.resolveRange)("last-week", now);
160
+ const lastWeekDir = (0, time_1.formatWeekDirName)(lastWeek.start, lastWeek.end);
161
+ if (lastArchivedWeek !== lastWeekDir) {
162
+ const archived = await exportAndCopy(dataDir, runExport, config.targetDir, "last-week");
163
+ lastArchivedWeek = archived.weekDir;
164
+ archivedLastWeekDest = archived.destPath;
165
+ (0, debug_1.debugLog)("sync", "archived last week", { weekDir: archived.weekDir, destPath: archived.destPath });
166
+ }
167
+ }
168
+ await writeSyncState(dataDir, { lastSyncedAt: now.toISOString(), lastResult: "ok", lastArchivedWeek });
169
+ return { ...primary, archivedLastWeekDest };
170
+ }
171
+ catch (error) {
172
+ const message = error instanceof Error ? error.message : String(error);
173
+ await writeSyncState(dataDir, { lastSyncedAt: now.toISOString(), lastResult: "error", lastError: message, lastArchivedWeek });
174
+ throw error;
175
+ }
176
+ }
177
+ /**
178
+ * 如到了同步周期,spawn 一个 detached 后台进程执行同步,主进程不等待。
179
+ *
180
+ * 这是 statusline 路径能用的唯一安全方式(照搬 update-check 的 maybeSpawnBackgroundCheck):
181
+ * 同步读配置与状态决定是否触发,把 export + 复制甩给后台子进程,自己立刻返回。
182
+ * 未配置目标目录、或未到周期,都直接返回,对 statusline 单行 stdout 契约零侵入。
183
+ */
184
+ function maybeSpawnBackgroundSync(dataDir, now = new Date()) {
185
+ try {
186
+ const config = readSyncConfig(dataDir);
187
+ if (!config.targetDir) {
188
+ return;
189
+ }
190
+ if (!isSyncDue(config, readSyncStateSync(dataDir), now)) {
191
+ return;
192
+ }
193
+ const scriptPath = process.argv[1];
194
+ if (!scriptPath) {
195
+ return;
196
+ }
197
+ const child = (0, node_child_process_1.spawn)(process.execPath, [scriptPath, "__sync", "--data-dir", dataDir], {
198
+ detached: true,
199
+ stdio: "ignore",
200
+ windowsHide: true,
201
+ });
202
+ child.on("error", () => { });
203
+ child.unref();
204
+ (0, debug_1.debugLog)("sync", "spawned background sync");
205
+ }
206
+ catch (error) {
207
+ (0, debug_1.debugLog)("sync", "failed to spawn background sync", error instanceof Error ? error.message : String(error));
208
+ }
209
+ }
210
+ //# sourceMappingURL=sync.js.map
package/dist/lib/time.js CHANGED
@@ -4,6 +4,7 @@ exports.resolveRange = resolveRange;
4
4
  exports.expandToFullWeekWindow = expandToFullWeekWindow;
5
5
  exports.localDateKey = localDateKey;
6
6
  exports.formatRangeFileLabel = formatRangeFileLabel;
7
+ exports.formatWeekDirName = formatWeekDirName;
7
8
  exports.extractGitEmailAccount = extractGitEmailAccount;
8
9
  exports.formatGitEmailFilePrefix = formatGitEmailFilePrefix;
9
10
  exports.enumerateDateKeys = enumerateDateKeys;
@@ -104,6 +105,16 @@ function localDateKey(date) {
104
105
  function formatRangeFileLabel(start, end) {
105
106
  return `${localDateKey(start)}_to_${localDateKey(end)}`;
106
107
  }
108
+ /**
109
+ * 生成定时同步目标目录下「按周」子目录名:年_月_日_年_月_日(全下划线)。
110
+ *
111
+ * 与 formatRangeFileLabel 同源(都基于 localDateKey 的周一~周日窗口),
112
+ * 但分隔符统一成下划线,例如 `2026_06_01_2026_06_07`。
113
+ */
114
+ function formatWeekDirName(start, end) {
115
+ const normalize = (date) => localDateKey(date).replaceAll("-", "_");
116
+ return `${normalize(start)}_${normalize(end)}`;
117
+ }
107
118
  /**
108
119
  * 从 git email 中提取规范化的帐号名(@ 之前的部分,小写并清洗特殊字符)。
109
120
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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 src/test/update-check.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 src/test/sync.test.ts src/test/scheduler.test.ts"
19
19
  },
20
20
  "keywords": [
21
21
  "claude-code",