ccus-cli 0.1.10 → 0.1.12

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,7 @@
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 兜底触发,也可挂系统计划任务做到严格每天定时。
11
12
  - `ccus open`:用系统文件管理器打开 ccus 本地存储目录(事件日志、exports、dashboard 都在里面);加 `--print` 只输出目录路径、不打开。
12
13
  - `ccus update`:主动检查 npm 上是否有新版本,有则提示手动升级命令;`ccus --version` 查看当前版本。
13
14
 
@@ -84,6 +85,10 @@ ccus dashboard serve # 启动本地页面,默认看本周(this-we
84
85
 
85
86
  ccus aggregate --input-dir ./team-exports # 多人的数据汇总,可以导出 detail.csv、daily.csv、weekly.csv 三个维度的文件
86
87
  ccus aggregate serve --input-dir ./team-exports #直接打开一个看板
88
+
89
+ ccus sync config --target ./team-exports # 配置同步目标目录(之后 statusline 会兜底定时同步)
90
+ ccus sync # 立即同步一次
91
+ ccus sync status # 查看同步配置与上次同步时间
87
92
  ```
88
93
 
89
94
  ### 一键安装(推荐)
@@ -175,6 +180,48 @@ ccus aggregate serve --input-dir ./team-exports
175
180
  - CSV 里所有以 token 计的列(context 与 in/out/cache)都以百万(M)为单位(原始值除以 1,000,000),列名统一带 `M` 后缀;`contextWindowPct` 仍是百分比
176
181
  - 想直接查看团队多人 dashboard,可以用 `ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]`:默认监听 `127.0.0.1` 上的随机端口,启动后会自动用系统默认浏览器打开,每次请求实时读取目录里的 bundle,不写入任何文件
177
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 --suffix laptop # 给目标文件名加机器后缀,区分多台电脑
191
+ ccus sync config --no-suffix # 移除已配置的机器后缀
192
+ ccus sync config # 不带参数:打印当前配置
193
+ ccus sync # 用已存配置立即同步一次
194
+ ccus sync install # 注册系统调度器:每周五 18:00 自动同步(Windows 直接创建计划任务)
195
+ ccus sync uninstall # 卸载系统调度器
196
+ ccus sync status # 查看目标目录、周期、上次同步时间、是否到期
197
+ ```
198
+
199
+ 行为:
200
+
201
+ - **目标目录按周建子目录**:子目录名形如 `2026_06_01_2026_06_07`(该周周一~周日,全下划线),不存在时自动创建。
202
+ - **周一归档上一周**:周一同步时会额外把刚结束的上一整周(`last-week`)导出并归档到对应的上一周子目录(周一是第一个能拿到完整上一周数据的日子);同一天内多次同步用 `sync-state.lastArchivedWeek` 去重,不重复归档。
203
+ - **复制语义**:本地 `data-dir/exports` 仍保留一份,目标目录再放一份,本地照旧可 `aggregate` / `dashboard`。导出产物与 `ccus export` 完全一致,目标目录可直接喂给 `ccus aggregate`。
204
+ - **机器后缀**:`ccus sync config --suffix laptop` 会在**目标目录副本**的文件名扩展名前加 `-laptop`(如 `…_to_2026-06-07-laptop.json.gz`),本地原文件名不变。同一个人多台电脑同步到同一目标目录时,靠后缀让各机器的文件互不覆盖、都保留下来,正好供 `ccus aggregate` 按 personKey 合并去重。用 `ccus sync config --no-suffix` 移除后缀。
205
+ - **配置与同步分离**:`ccus sync config` 只读写配置(`--target` / `--interval` / `--range` 写进数据目录下的 `sync-config.json`,可手编;不带参数则打印当前配置);`ccus sync` 只用已存配置执行一次同步。上次同步时间记录在 `sync-state.json`。
206
+ - **周期**:默认 `3h`(每 3 小时最多同步一次的滚动周期);也支持 `daily`(按**自然日**判断——同一天内不重复同步,跨到下一天才再同步)与 `<N>h` / `<N>m` 的滚动周期。
207
+
208
+ ### 两种触发方式
209
+
210
+ 1. **statusline 兜底(零配置)**:配置过目标目录后,每次 statusline 渲染都会检查是否到周期,到了就 spawn 一个 detached 后台进程静默执行同步,**不阻塞 statusline、不污染单行输出**(与更新检查同款机制)。
211
+ - 局限:statusline 只在你**使用 Claude Code** 时触发。某天完全不开 Claude Code,则当天不会自动同步——但那天也没有新数据,下次任意一次交互都会把整周最新 bundle 重新覆盖同步,不丢数据。
212
+ 2. **系统调度器(不依赖是否开 Claude Code)**:如果要做到「哪怕一整天不碰 Claude Code,也在固定时间准时跑一次」,用 `ccus sync install` 一键注册一个**每周五 18:00** 跑 `ccus sync` 的系统调度器:
213
+
214
+ ```bash
215
+ ccus sync install # Windows 用 schtasks 创建计划任务(任务名 ccus-sync)
216
+ ccus sync install --print # 只打印将执行的命令、不实际安装
217
+ ccus sync uninstall # 卸载该调度器(--print 只打印不执行)
218
+ ```
219
+
220
+ - **Windows** 会真正创建/删除计划任务;也可手动 `schtasks /delete /tn ccus-sync /f`。
221
+ - **macOS / Linux** 不自动改系统,`ccus sync install` 会打印一条 cron 命令(`0 18 * * 5 …`)让你手动 `crontab` 安装,避免误改已有 crontab。
222
+
223
+ > 记得先 `ccus sync config --target DIR` 配好目标目录,调度任务才有地方可同步。
224
+
178
225
  ## 调试
179
226
 
180
227
  出问题时(比如 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 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`);
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] [--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 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
  /**
@@ -619,6 +631,148 @@ async function handleBackgroundCheckUpdate(options) {
619
631
  const dataDir = getDataDir(options);
620
632
  await (0, update_check_1.performUpdateCheck)(dataDir);
621
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
+ const suffix = getStringOption(options, "suffix");
646
+ if (target !== undefined || interval !== undefined || range !== undefined || suffix !== undefined) {
647
+ const current = (0, sync_1.readSyncConfig)(dataDir);
648
+ const next = {
649
+ targetDir: target !== undefined ? node_path_1.default.resolve(target) : current.targetDir,
650
+ intervalLabel: interval ?? current.intervalLabel,
651
+ range: range ?? current.range,
652
+ suffix: suffix !== undefined ? (0, sync_1.sanitizeSuffix)(suffix) : current.suffix,
653
+ };
654
+ await (0, sync_1.writeSyncConfig)(dataDir, next);
655
+ (0, debug_1.debugLog)("sync", "config updated", next);
656
+ }
657
+ const config = (0, sync_1.readSyncConfig)(dataDir);
658
+ if (!config.targetDir) {
659
+ throw new Error("未配置同步目标目录。请先运行 `ccus sync config --target DIR`。");
660
+ }
661
+ const result = await (0, sync_1.performSync)(dataDir, runExport);
662
+ process.stdout.write(`已同步到 ${result.destPath}\n`);
663
+ if (result.archivedLastWeekDest) {
664
+ process.stdout.write(`已归档上一周到 ${result.archivedLastWeekDest}\n`);
665
+ }
666
+ }
667
+ /**
668
+ * `ccus sync status`:打印当前同步配置、上次同步时间与是否到期。
669
+ */
670
+ async function handleSyncStatus(options) {
671
+ const dataDir = getDataDir(options);
672
+ const config = (0, sync_1.readSyncConfig)(dataDir);
673
+ const state = (0, sync_1.readSyncStateSync)(dataDir);
674
+ const due = (0, sync_1.isSyncDue)(config, state);
675
+ const lines = [
676
+ `目标目录: ${config.targetDir ?? "(未配置)"}`,
677
+ `同步周期: ${config.intervalLabel}`,
678
+ `导出范围: ${config.range}`,
679
+ `文件后缀: ${config.suffix ?? "(无)"}`,
680
+ `上次同步: ${state?.lastSyncedAt ?? "(从未)"}${state?.lastResult ? ` [${state.lastResult}]` : ""}`,
681
+ state?.lastArchivedWeek ? `已归档上一周: ${state.lastArchivedWeek}` : null,
682
+ state?.lastError ? `上次错误: ${state.lastError}` : null,
683
+ `现在是否到期: ${config.targetDir ? (due ? "是" : "否") : "(未配置目标目录)"}`,
684
+ ].filter((line) => line !== null);
685
+ process.stdout.write(`${lines.join("\n")}\n`);
686
+ }
687
+ /**
688
+ * `ccus sync config`:只读写同步配置,不触发同步。
689
+ *
690
+ * 带 `--target` / `--interval` / `--range` 时合并并持久化到 `sync-config.json`;
691
+ * 不带任何参数时仅打印当前配置,方便确认。
692
+ */
693
+ async function handleSyncConfig(options) {
694
+ const dataDir = getDataDir(options);
695
+ const target = getStringOption(options, "target");
696
+ const interval = getStringOption(options, "interval");
697
+ const range = getStringOption(options, "range");
698
+ const suffix = getStringOption(options, "suffix");
699
+ const clearSuffix = getBooleanOption(options, "no-suffix");
700
+ const current = (0, sync_1.readSyncConfig)(dataDir);
701
+ const changed = target !== undefined || interval !== undefined || range !== undefined || suffix !== undefined || clearSuffix;
702
+ const next = {
703
+ targetDir: target !== undefined ? node_path_1.default.resolve(target) : current.targetDir,
704
+ intervalLabel: interval ?? current.intervalLabel,
705
+ range: range ?? current.range,
706
+ // --no-suffix 优先清除;否则有 --suffix 就更新,没有就保持原值。
707
+ suffix: clearSuffix ? null : suffix !== undefined ? (0, sync_1.sanitizeSuffix)(suffix) : current.suffix,
708
+ };
709
+ if (changed) {
710
+ await (0, sync_1.writeSyncConfig)(dataDir, next);
711
+ (0, debug_1.debugLog)("sync", "config updated", next);
712
+ }
713
+ const header = changed ? "同步配置已更新:" : "当前同步配置:";
714
+ process.stdout.write(`${header}\n 目标目录: ${next.targetDir ?? "(未配置)"}\n 同步周期: ${next.intervalLabel}\n 导出范围: ${next.range}\n 文件后缀: ${next.suffix ?? "(无)"}\n`);
715
+ }
716
+ /**
717
+ * `ccus sync install`:安装一个系统调度器,每周五 18:00 跑一次 `ccus sync`。
718
+ *
719
+ * Windows 用 schtasks 真正创建计划任务;macOS / Linux 打印 cron 命令交由用户手动安装。
720
+ * 加 `--print` 只打印将执行的调度器命令、不真正安装。
721
+ */
722
+ async function handleSyncInstall(options) {
723
+ const dataDir = getDataDir(options);
724
+ const print = getBooleanOption(options, "print");
725
+ const scriptPath = process.argv[1];
726
+ const result = (0, scheduler_1.installScheduler)(process.execPath, scriptPath, dataDir, { print });
727
+ const { plan } = result;
728
+ if (print) {
729
+ process.stdout.write(`将安装的调度器命令(每周五 18:00 同步):\n ${plan.displayCommand}\n`);
730
+ return;
731
+ }
732
+ if (result.installed) {
733
+ process.stdout.write(`已安装系统调度器「ccus-sync」:每周五 18:00 运行一次 ccus sync。\n卸载:\n ${plan.uninstallHint}\n`);
734
+ return;
735
+ }
736
+ // 非 Windows:不自动改系统,打印命令引导用户手动安装。
737
+ process.stdout.write(`当前平台(${plan.platform})不自动安装,请手动运行以下命令注册每周五 18:00 的 cron 任务:\n ${plan.displayCommand}\n卸载:\n ${plan.uninstallHint}\n`);
738
+ }
739
+ /**
740
+ * `ccus sync uninstall`:卸载每周五同步的系统调度器。
741
+ *
742
+ * Windows 用 schtasks 删除计划任务;macOS / Linux 打印 crontab 提示。加 `--print` 只打印命令、不执行。
743
+ */
744
+ async function handleSyncUninstall(options) {
745
+ const print = getBooleanOption(options, "print");
746
+ const result = (0, scheduler_1.uninstallScheduler)({ print });
747
+ if (print) {
748
+ process.stdout.write(`将执行的卸载命令:\n ${result.displayCommand}\n`);
749
+ return;
750
+ }
751
+ if (result.autoUninstallable) {
752
+ if (result.uninstalled) {
753
+ process.stdout.write(`已卸载系统调度器「ccus-sync」。\n`);
754
+ }
755
+ else {
756
+ process.stdout.write(`未能删除调度任务(可能本就未安装)。如需手动卸载:\n ${result.displayCommand}\n`);
757
+ }
758
+ return;
759
+ }
760
+ process.stdout.write(`当前平台(${result.platform})请手动卸载:\n ${result.displayCommand}\n`);
761
+ }
762
+ /**
763
+ * 隐藏命令 `__sync`:由 statusline 路径以 detached 后台进程触发。
764
+ *
765
+ * 静默执行一次同步:不写 stdout,失败一律吞掉,绝不影响触发它的 statusline 主进程。
766
+ */
767
+ async function handleBackgroundSync(options) {
768
+ const dataDir = getDataDir(options);
769
+ try {
770
+ await (0, sync_1.performSync)(dataDir, runExport);
771
+ }
772
+ catch (error) {
773
+ (0, debug_1.debugLog)("sync", "background sync failed", error instanceof Error ? (error.stack ?? error.message) : String(error));
774
+ }
775
+ }
622
776
  /** 顶层命令分发入口。 */
623
777
  async function main(args = process.argv.slice(2)) {
624
778
  (0, debug_1.setDebugEnabled)((0, debug_1.resolveDebugEnabled)(args));
@@ -645,6 +799,33 @@ async function main(args = process.argv.slice(2)) {
645
799
  await handleBackgroundCheckUpdate(parseOptions(args.slice(1)));
646
800
  return;
647
801
  }
802
+ if (group === "sync") {
803
+ if (action === "config") {
804
+ await handleSyncConfig(parseOptions(rest));
805
+ return;
806
+ }
807
+ if (action === "status") {
808
+ await handleSyncStatus(parseOptions(rest));
809
+ return;
810
+ }
811
+ if (action === "install") {
812
+ await handleSyncInstall(parseOptions(rest));
813
+ return;
814
+ }
815
+ if (action === "uninstall") {
816
+ await handleSyncUninstall(parseOptions(rest));
817
+ return;
818
+ }
819
+ if (action && !action.startsWith("--")) {
820
+ 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\`.`);
821
+ }
822
+ await handleSync(parseOptions(args.slice(1)));
823
+ return;
824
+ }
825
+ if (group === "__sync") {
826
+ await handleBackgroundSync(parseOptions(args.slice(1)));
827
+ return;
828
+ }
648
829
  if (group === "install") {
649
830
  await handleInstall(parseOptions(args.slice(1)));
650
831
  return;
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,249 @@
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.sanitizeSuffix = sanitizeSuffix;
8
+ exports.applyFileSuffix = applyFileSuffix;
9
+ exports.readSyncConfig = readSyncConfig;
10
+ exports.writeSyncConfig = writeSyncConfig;
11
+ exports.readSyncStateSync = readSyncStateSync;
12
+ exports.isSyncDue = isSyncDue;
13
+ exports.performSync = performSync;
14
+ exports.maybeSpawnBackgroundSync = maybeSpawnBackgroundSync;
15
+ const node_child_process_1 = require("node:child_process");
16
+ const node_fs_1 = __importDefault(require("node:fs"));
17
+ const promises_1 = __importDefault(require("node:fs/promises"));
18
+ const node_path_1 = __importDefault(require("node:path"));
19
+ const debug_1 = require("./debug");
20
+ const paths_1 = require("./paths");
21
+ const time_1 = require("./time");
22
+ /** 默认同步周期标签:每 3 小时最多同步一次(滚动 TTL)。 */
23
+ const DEFAULT_INTERVAL_LABEL = "3h";
24
+ /** 默认导出范围:当前周 bundle。 */
25
+ const DEFAULT_RANGE = "this-week";
26
+ /**
27
+ * 解析周期标签。
28
+ *
29
+ * `daily`(或空/无法识别)→ 按自然日;`<N>h` / `<N>m` → 滚动 TTL。
30
+ */
31
+ function parseSyncInterval(label) {
32
+ const raw = (label ?? "").trim().toLowerCase();
33
+ if (raw === "" || raw === "daily") {
34
+ return { kind: "daily" };
35
+ }
36
+ const match = raw.match(/^(\d+)([hm])$/);
37
+ if (match) {
38
+ const amount = Number.parseInt(match[1], 10);
39
+ const ms = match[2] === "h" ? amount * 60 * 60 * 1000 : amount * 60 * 1000;
40
+ return { kind: "ttl", ms };
41
+ }
42
+ // 无法识别的标签退回每天语义,保持「默认每天」的承诺。
43
+ return { kind: "daily" };
44
+ }
45
+ /**
46
+ * 清洗用户传入的文件名后缀:去首尾分隔、把非法字符折叠成连字符。
47
+ *
48
+ * 只保留字母数字与 `._-`,避免生成非法/危险的文件名;清洗后为空则返回 null。
49
+ */
50
+ function sanitizeSuffix(raw) {
51
+ if (raw === undefined) {
52
+ return null;
53
+ }
54
+ const cleaned = raw
55
+ .trim()
56
+ .replaceAll(/[^a-zA-Z0-9._-]+/g, "-")
57
+ .replaceAll(/-+/g, "-")
58
+ .replaceAll(/^[-_.]+|[-_.]+$/g, "");
59
+ return cleaned === "" ? null : cleaned;
60
+ }
61
+ /**
62
+ * 把后缀插到文件名的扩展名之前(`.json.gz` / `.json` 视为整体扩展名)。
63
+ *
64
+ * 例如 `alice_export_2026-06-01_to_2026-06-07.json.gz` + `laptop`
65
+ * → `alice_export_2026-06-01_to_2026-06-07-laptop.json.gz`。
66
+ */
67
+ function applyFileSuffix(fileName, suffix) {
68
+ if (!suffix) {
69
+ return fileName;
70
+ }
71
+ const tag = `-${suffix}`;
72
+ for (const ext of [".json.gz", ".json"]) {
73
+ if (fileName.endsWith(ext)) {
74
+ return `${fileName.slice(0, fileName.length - ext.length)}${tag}${ext}`;
75
+ }
76
+ }
77
+ const ext = node_path_1.default.extname(fileName);
78
+ return `${fileName.slice(0, fileName.length - ext.length)}${tag}${ext}`;
79
+ }
80
+ /** 把同一本地日映射成稳定日键,用于 daily 周期的「是否跨天」判断。 */
81
+ function localDayKey(date) {
82
+ const year = date.getFullYear();
83
+ const month = String(date.getMonth() + 1).padStart(2, "0");
84
+ const day = String(date.getDate()).padStart(2, "0");
85
+ return `${year}-${month}-${day}`;
86
+ }
87
+ /**
88
+ * 同步读取用户配置;缺省补齐默认值,任何异常都回退到「无目标目录」的安全默认。
89
+ *
90
+ * statusline 兜底路径需要同步、极快、绝不抛错,所以这里用同步 IO。
91
+ */
92
+ function readSyncConfig(dataDir) {
93
+ const fallback = { targetDir: null, intervalLabel: DEFAULT_INTERVAL_LABEL, range: DEFAULT_RANGE, suffix: null };
94
+ try {
95
+ const raw = node_fs_1.default.readFileSync((0, paths_1.getSyncConfigPath)(dataDir), "utf8");
96
+ const parsed = JSON.parse(raw);
97
+ return {
98
+ targetDir: typeof parsed.targetDir === "string" && parsed.targetDir.trim() !== "" ? parsed.targetDir : null,
99
+ intervalLabel: typeof parsed.intervalLabel === "string" && parsed.intervalLabel.trim() !== "" ? parsed.intervalLabel : DEFAULT_INTERVAL_LABEL,
100
+ range: typeof parsed.range === "string" && parsed.range.trim() !== "" ? parsed.range : DEFAULT_RANGE,
101
+ suffix: typeof parsed.suffix === "string" && parsed.suffix.trim() !== "" ? parsed.suffix : null,
102
+ };
103
+ }
104
+ catch {
105
+ return fallback;
106
+ }
107
+ }
108
+ /** 写入用户配置;目录可能尚未创建,先 mkdir。 */
109
+ async function writeSyncConfig(dataDir, config) {
110
+ await promises_1.default.mkdir(dataDir, { recursive: true });
111
+ await promises_1.default.writeFile((0, paths_1.getSyncConfigPath)(dataDir), `${JSON.stringify(config, null, 2)}\n`, "utf8");
112
+ }
113
+ /** 同步读取运行时状态;缺失或损坏一律返回 null。 */
114
+ function readSyncStateSync(dataDir) {
115
+ try {
116
+ const raw = node_fs_1.default.readFileSync((0, paths_1.getSyncStatePath)(dataDir), "utf8");
117
+ const parsed = JSON.parse(raw);
118
+ return {
119
+ lastSyncedAt: typeof parsed.lastSyncedAt === "string" ? parsed.lastSyncedAt : null,
120
+ lastResult: parsed.lastResult === "ok" || parsed.lastResult === "error" ? parsed.lastResult : null,
121
+ lastError: typeof parsed.lastError === "string" ? parsed.lastError : undefined,
122
+ lastArchivedWeek: typeof parsed.lastArchivedWeek === "string" ? parsed.lastArchivedWeek : undefined,
123
+ };
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ /** 写入运行时状态;失败静默(不能因状态写入失败影响主流程)。 */
130
+ async function writeSyncState(dataDir, state) {
131
+ try {
132
+ await promises_1.default.mkdir(dataDir, { recursive: true });
133
+ await promises_1.default.writeFile((0, paths_1.getSyncStatePath)(dataDir), `${JSON.stringify(state, null, 2)}\n`, "utf8");
134
+ }
135
+ catch (error) {
136
+ (0, debug_1.debugLog)("sync", "failed to write state", error instanceof Error ? error.message : String(error));
137
+ }
138
+ }
139
+ /**
140
+ * 判断当前是否到了该同步的时间点。
141
+ *
142
+ * 无目标目录永远返回 false;从未同步过返回 true;
143
+ * `daily` 用「上次同步与现在不在同一自然日」判断(最贴近「每天」),其它用滚动 TTL。
144
+ */
145
+ function isSyncDue(config, state, now = new Date()) {
146
+ if (!config.targetDir) {
147
+ return false;
148
+ }
149
+ if (!state || !state.lastSyncedAt) {
150
+ return true;
151
+ }
152
+ const last = Date.parse(state.lastSyncedAt);
153
+ if (!Number.isFinite(last)) {
154
+ return true;
155
+ }
156
+ const interval = parseSyncInterval(config.intervalLabel);
157
+ if (interval.kind === "daily") {
158
+ return localDayKey(new Date(last)) !== localDayKey(now);
159
+ }
160
+ return now.getTime() - last >= interval.ms;
161
+ }
162
+ /**
163
+ * 导出某个 range 的 bundle,并复制到目标目录下对应的「按周」子目录。
164
+ *
165
+ * 复制语义:本地 exports 仍保留一份,目标目录再放一份。周目录名按导出窗口的周一~周日推导。
166
+ */
167
+ async function exportAndCopy(dataDir, runExport, targetDir, range, suffix) {
168
+ const { outputPath, window } = await runExport({ "data-dir": dataDir, range });
169
+ const weekDir = (0, time_1.formatWeekDirName)(window.start, window.end);
170
+ const targetWeekDir = node_path_1.default.join(targetDir, weekDir);
171
+ await promises_1.default.mkdir(targetWeekDir, { recursive: true });
172
+ // 后缀只加在目标目录的副本上,本地 exports 原文件名保持不变。
173
+ const destPath = node_path_1.default.join(targetWeekDir, applyFileSuffix(node_path_1.default.basename(outputPath), suffix));
174
+ await promises_1.default.copyFile(outputPath, destPath);
175
+ (0, debug_1.debugLog)("sync", "copied bundle", { range, outputPath, destPath, weekDir, suffix });
176
+ return { outputPath, destPath, weekDir, targetWeekDir };
177
+ }
178
+ /**
179
+ * 执行一次同步:导出当前周 bundle,并复制到目标目录下的「按周」子目录。
180
+ *
181
+ * 复制语义:本地 exports 仍保留一份,目标目录再放一份,本地照旧可 aggregate / dashboard。
182
+ * 周一时额外把上一周(last-week,刚结束的完整周)导出并归档到对应子目录;
183
+ * 用 `sync-state.lastArchivedWeek` 去重,避免周一当天每次同步都重复归档。
184
+ * 失败时写 error 状态后向上抛,由调用方决定展示或静默。
185
+ */
186
+ async function performSync(dataDir, runExport, now = new Date()) {
187
+ const config = readSyncConfig(dataDir);
188
+ if (!config.targetDir) {
189
+ throw new Error("未配置同步目标目录。请先运行 `ccus sync config --target DIR`。");
190
+ }
191
+ const state = readSyncStateSync(dataDir);
192
+ let lastArchivedWeek = state?.lastArchivedWeek;
193
+ try {
194
+ const primary = await exportAndCopy(dataDir, runExport, config.targetDir, config.range, config.suffix);
195
+ // 周一(getDay() === 1)是上一周结束后第一个能拿到完整数据的日子:顺带归档 last-week。
196
+ let archivedLastWeekDest = null;
197
+ if (now.getDay() === 1) {
198
+ const lastWeek = (0, time_1.resolveRange)("last-week", now);
199
+ const lastWeekDir = (0, time_1.formatWeekDirName)(lastWeek.start, lastWeek.end);
200
+ if (lastArchivedWeek !== lastWeekDir) {
201
+ const archived = await exportAndCopy(dataDir, runExport, config.targetDir, "last-week", config.suffix);
202
+ lastArchivedWeek = archived.weekDir;
203
+ archivedLastWeekDest = archived.destPath;
204
+ (0, debug_1.debugLog)("sync", "archived last week", { weekDir: archived.weekDir, destPath: archived.destPath });
205
+ }
206
+ }
207
+ await writeSyncState(dataDir, { lastSyncedAt: now.toISOString(), lastResult: "ok", lastArchivedWeek });
208
+ return { ...primary, archivedLastWeekDest };
209
+ }
210
+ catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ await writeSyncState(dataDir, { lastSyncedAt: now.toISOString(), lastResult: "error", lastError: message, lastArchivedWeek });
213
+ throw error;
214
+ }
215
+ }
216
+ /**
217
+ * 如到了同步周期,spawn 一个 detached 后台进程执行同步,主进程不等待。
218
+ *
219
+ * 这是 statusline 路径能用的唯一安全方式(照搬 update-check 的 maybeSpawnBackgroundCheck):
220
+ * 同步读配置与状态决定是否触发,把 export + 复制甩给后台子进程,自己立刻返回。
221
+ * 未配置目标目录、或未到周期,都直接返回,对 statusline 单行 stdout 契约零侵入。
222
+ */
223
+ function maybeSpawnBackgroundSync(dataDir, now = new Date()) {
224
+ try {
225
+ const config = readSyncConfig(dataDir);
226
+ if (!config.targetDir) {
227
+ return;
228
+ }
229
+ if (!isSyncDue(config, readSyncStateSync(dataDir), now)) {
230
+ return;
231
+ }
232
+ const scriptPath = process.argv[1];
233
+ if (!scriptPath) {
234
+ return;
235
+ }
236
+ const child = (0, node_child_process_1.spawn)(process.execPath, [scriptPath, "__sync", "--data-dir", dataDir], {
237
+ detached: true,
238
+ stdio: "ignore",
239
+ windowsHide: true,
240
+ });
241
+ child.on("error", () => { });
242
+ child.unref();
243
+ (0, debug_1.debugLog)("sync", "spawned background sync");
244
+ }
245
+ catch (error) {
246
+ (0, debug_1.debugLog)("sync", "failed to spawn background sync", error instanceof Error ? error.message : String(error));
247
+ }
248
+ }
249
+ //# 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.10",
3
+ "version": "0.1.12",
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",