ccus-cli 0.1.11 → 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
@@ -187,6 +187,8 @@ ccus aggregate serve --input-dir ./team-exports
187
187
  ```bash
188
188
  ccus sync config --target ./team-exports # 配置同步目标目录(默认周期 3h)
189
189
  ccus sync config --interval daily # 改周期,可写 3h(默认)/ daily / 6h / 30m
190
+ ccus sync config --suffix laptop # 给目标文件名加机器后缀,区分多台电脑
191
+ ccus sync config --no-suffix # 移除已配置的机器后缀
190
192
  ccus sync config # 不带参数:打印当前配置
191
193
  ccus sync # 用已存配置立即同步一次
192
194
  ccus sync install # 注册系统调度器:每周五 18:00 自动同步(Windows 直接创建计划任务)
@@ -199,6 +201,7 @@ ccus sync status # 查看目标目录、周期
199
201
  - **目标目录按周建子目录**:子目录名形如 `2026_06_01_2026_06_07`(该周周一~周日,全下划线),不存在时自动创建。
200
202
  - **周一归档上一周**:周一同步时会额外把刚结束的上一整周(`last-week`)导出并归档到对应的上一周子目录(周一是第一个能拿到完整上一周数据的日子);同一天内多次同步用 `sync-state.lastArchivedWeek` 去重,不重复归档。
201
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` 移除后缀。
202
205
  - **配置与同步分离**:`ccus sync config` 只读写配置(`--target` / `--interval` / `--range` 写进数据目录下的 `sync-config.json`,可手编;不带参数则打印当前配置);`ccus sync` 只用已存配置执行一次同步。上次同步时间记录在 `sync-state.json`。
203
206
  - **周期**:默认 `3h`(每 3 小时最多同步一次的滚动周期);也支持 `daily`(按**自然日**判断——同一天内不重复同步,跨到下一天才再同步)与 `<N>h` / `<N>m` 的滚动周期。
204
207
 
package/dist/cli.js CHANGED
@@ -61,7 +61,7 @@ const update_check_1 = require("./lib/update-check");
61
61
  const version_1 = require("./lib/version");
62
62
  /** CLI 帮助信息保持简洁,方便直接挂到 README 或终端里查看。 */
63
63
  function printHelp() {
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`);
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`);
65
65
  }
66
66
  /** 一个轻量的参数解析器,当前命令面不复杂,没必要引入额外依赖。 */
67
67
  function parseOptions(args) {
@@ -642,12 +642,14 @@ async function handleSync(options) {
642
642
  const target = getStringOption(options, "target");
643
643
  const interval = getStringOption(options, "interval");
644
644
  const range = getStringOption(options, "range");
645
- if (target !== undefined || interval !== undefined || range !== undefined) {
645
+ const suffix = getStringOption(options, "suffix");
646
+ if (target !== undefined || interval !== undefined || range !== undefined || suffix !== undefined) {
646
647
  const current = (0, sync_1.readSyncConfig)(dataDir);
647
648
  const next = {
648
649
  targetDir: target !== undefined ? node_path_1.default.resolve(target) : current.targetDir,
649
650
  intervalLabel: interval ?? current.intervalLabel,
650
651
  range: range ?? current.range,
652
+ suffix: suffix !== undefined ? (0, sync_1.sanitizeSuffix)(suffix) : current.suffix,
651
653
  };
652
654
  await (0, sync_1.writeSyncConfig)(dataDir, next);
653
655
  (0, debug_1.debugLog)("sync", "config updated", next);
@@ -674,6 +676,7 @@ async function handleSyncStatus(options) {
674
676
  `目标目录: ${config.targetDir ?? "(未配置)"}`,
675
677
  `同步周期: ${config.intervalLabel}`,
676
678
  `导出范围: ${config.range}`,
679
+ `文件后缀: ${config.suffix ?? "(无)"}`,
677
680
  `上次同步: ${state?.lastSyncedAt ?? "(从未)"}${state?.lastResult ? ` [${state.lastResult}]` : ""}`,
678
681
  state?.lastArchivedWeek ? `已归档上一周: ${state.lastArchivedWeek}` : null,
679
682
  state?.lastError ? `上次错误: ${state.lastError}` : null,
@@ -692,19 +695,23 @@ async function handleSyncConfig(options) {
692
695
  const target = getStringOption(options, "target");
693
696
  const interval = getStringOption(options, "interval");
694
697
  const range = getStringOption(options, "range");
698
+ const suffix = getStringOption(options, "suffix");
699
+ const clearSuffix = getBooleanOption(options, "no-suffix");
695
700
  const current = (0, sync_1.readSyncConfig)(dataDir);
696
- const changed = target !== undefined || interval !== undefined || range !== undefined;
701
+ const changed = target !== undefined || interval !== undefined || range !== undefined || suffix !== undefined || clearSuffix;
697
702
  const next = {
698
703
  targetDir: target !== undefined ? node_path_1.default.resolve(target) : current.targetDir,
699
704
  intervalLabel: interval ?? current.intervalLabel,
700
705
  range: range ?? current.range,
706
+ // --no-suffix 优先清除;否则有 --suffix 就更新,没有就保持原值。
707
+ suffix: clearSuffix ? null : suffix !== undefined ? (0, sync_1.sanitizeSuffix)(suffix) : current.suffix,
701
708
  };
702
709
  if (changed) {
703
710
  await (0, sync_1.writeSyncConfig)(dataDir, next);
704
711
  (0, debug_1.debugLog)("sync", "config updated", next);
705
712
  }
706
713
  const header = changed ? "同步配置已更新:" : "当前同步配置:";
707
- process.stdout.write(`${header}\n 目标目录: ${next.targetDir ?? "(未配置)"}\n 同步周期: ${next.intervalLabel}\n 导出范围: ${next.range}\n`);
714
+ process.stdout.write(`${header}\n 目标目录: ${next.targetDir ?? "(未配置)"}\n 同步周期: ${next.intervalLabel}\n 导出范围: ${next.range}\n 文件后缀: ${next.suffix ?? "(无)"}\n`);
708
715
  }
709
716
  /**
710
717
  * `ccus sync install`:安装一个系统调度器,每周五 18:00 跑一次 `ccus sync`。
package/dist/lib/sync.js CHANGED
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.parseSyncInterval = parseSyncInterval;
7
+ exports.sanitizeSuffix = sanitizeSuffix;
8
+ exports.applyFileSuffix = applyFileSuffix;
7
9
  exports.readSyncConfig = readSyncConfig;
8
10
  exports.writeSyncConfig = writeSyncConfig;
9
11
  exports.readSyncStateSync = readSyncStateSync;
@@ -40,6 +42,41 @@ function parseSyncInterval(label) {
40
42
  // 无法识别的标签退回每天语义,保持「默认每天」的承诺。
41
43
  return { kind: "daily" };
42
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
+ }
43
80
  /** 把同一本地日映射成稳定日键,用于 daily 周期的「是否跨天」判断。 */
44
81
  function localDayKey(date) {
45
82
  const year = date.getFullYear();
@@ -53,7 +90,7 @@ function localDayKey(date) {
53
90
  * statusline 兜底路径需要同步、极快、绝不抛错,所以这里用同步 IO。
54
91
  */
55
92
  function readSyncConfig(dataDir) {
56
- const fallback = { targetDir: null, intervalLabel: DEFAULT_INTERVAL_LABEL, range: DEFAULT_RANGE };
93
+ const fallback = { targetDir: null, intervalLabel: DEFAULT_INTERVAL_LABEL, range: DEFAULT_RANGE, suffix: null };
57
94
  try {
58
95
  const raw = node_fs_1.default.readFileSync((0, paths_1.getSyncConfigPath)(dataDir), "utf8");
59
96
  const parsed = JSON.parse(raw);
@@ -61,6 +98,7 @@ function readSyncConfig(dataDir) {
61
98
  targetDir: typeof parsed.targetDir === "string" && parsed.targetDir.trim() !== "" ? parsed.targetDir : null,
62
99
  intervalLabel: typeof parsed.intervalLabel === "string" && parsed.intervalLabel.trim() !== "" ? parsed.intervalLabel : DEFAULT_INTERVAL_LABEL,
63
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,
64
102
  };
65
103
  }
66
104
  catch {
@@ -126,14 +164,15 @@ function isSyncDue(config, state, now = new Date()) {
126
164
  *
127
165
  * 复制语义:本地 exports 仍保留一份,目标目录再放一份。周目录名按导出窗口的周一~周日推导。
128
166
  */
129
- async function exportAndCopy(dataDir, runExport, targetDir, range) {
167
+ async function exportAndCopy(dataDir, runExport, targetDir, range, suffix) {
130
168
  const { outputPath, window } = await runExport({ "data-dir": dataDir, range });
131
169
  const weekDir = (0, time_1.formatWeekDirName)(window.start, window.end);
132
170
  const targetWeekDir = node_path_1.default.join(targetDir, weekDir);
133
171
  await promises_1.default.mkdir(targetWeekDir, { recursive: true });
134
- const destPath = node_path_1.default.join(targetWeekDir, node_path_1.default.basename(outputPath));
172
+ // 后缀只加在目标目录的副本上,本地 exports 原文件名保持不变。
173
+ const destPath = node_path_1.default.join(targetWeekDir, applyFileSuffix(node_path_1.default.basename(outputPath), suffix));
135
174
  await promises_1.default.copyFile(outputPath, destPath);
136
- (0, debug_1.debugLog)("sync", "copied bundle", { range, outputPath, destPath, weekDir });
175
+ (0, debug_1.debugLog)("sync", "copied bundle", { range, outputPath, destPath, weekDir, suffix });
137
176
  return { outputPath, destPath, weekDir, targetWeekDir };
138
177
  }
139
178
  /**
@@ -152,14 +191,14 @@ async function performSync(dataDir, runExport, now = new Date()) {
152
191
  const state = readSyncStateSync(dataDir);
153
192
  let lastArchivedWeek = state?.lastArchivedWeek;
154
193
  try {
155
- const primary = await exportAndCopy(dataDir, runExport, config.targetDir, config.range);
194
+ const primary = await exportAndCopy(dataDir, runExport, config.targetDir, config.range, config.suffix);
156
195
  // 周一(getDay() === 1)是上一周结束后第一个能拿到完整数据的日子:顺带归档 last-week。
157
196
  let archivedLastWeekDest = null;
158
197
  if (now.getDay() === 1) {
159
198
  const lastWeek = (0, time_1.resolveRange)("last-week", now);
160
199
  const lastWeekDir = (0, time_1.formatWeekDirName)(lastWeek.start, lastWeek.end);
161
200
  if (lastArchivedWeek !== lastWeekDir) {
162
- const archived = await exportAndCopy(dataDir, runExport, config.targetDir, "last-week");
201
+ const archived = await exportAndCopy(dataDir, runExport, config.targetDir, "last-week", config.suffix);
163
202
  lastArchivedWeek = archived.weekDir;
164
203
  archivedLastWeekDest = archived.destPath;
165
204
  (0, debug_1.debugLog)("sync", "archived last week", { weekDir: archived.weekDir, destPath: archived.destPath });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {