ccus-cli 0.1.11 → 0.1.13

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
@@ -171,12 +171,12 @@ ccus aggregate serve --input-dir ./team-exports
171
171
 
172
172
  - 输入目录放很多通过 `ccus export` 导出的 bundle 文件,`.json.gz`(gzip 压缩)与明文 `.json` 都能识别,gzip 文件读取时自动解压
173
173
  - `aggregate` 目前只接受 `schemaVersion: 6` 的 bundle;旧导出请先用当前版本重新 `ccus export`
174
- - 同一个人在多台电脑上各自导出 bundle 时会自动合并去重:累加类指标(token、消息数、采样数等)按「同人同天 / 同人同周取 `generatedAt` 最新的那份导出」保留,不相加,避免同一台机器重复导出或周与周重叠造成翻倍;usage(5h / 7d)从选中那份的 `rawEvents` 按真实时间戳重算(peak 取 max、latest 取最新)
174
+ - 同一个人在多台电脑上各自导出 bundle 时会自动合并去重:去重以**天**为粒度,对每个「同人同天」取 `generatedAt` 最新的那份导出,避免同一台机器重复导出或周与周重叠造成翻倍;**周汇总不取整周单份,而是把按天去重后的各天数据上卷累加**,所以多台电脑在不同天产生的用量会正确合进同一周。usage(5h / 7d)从选中事件按真实时间戳重算(peak 取 max、latest 取最新)
175
175
  - `ccus aggregate --input-dir DIR --out-dir DIR`
176
176
  - 输出三个文件:
177
177
  - `detail.csv`:来自 winner bundle 的 `rawEvents`(同人同天只展开最新那份的事件),`contextUsedM` / `contextMaxM` 为单条事件的 context window token;另附带 `inputTokensM` / `outputTokensM` / `cacheReadInputTokensM`(按 `date` 取自当天 `dailySummaries` 的日总量,同一天多行会重复,不能按行求和)
178
178
  - `daily.csv`:同人同天取最新导出 bundle 的 `dailySummaries`,usage 从该 bundle 当天事件重算
179
- - `weekly.csv`:同人同周取最新导出 bundle 的 `weeklySummary`,usage 从该 bundle 全部事件重算
179
+ - `weekly.csv`:把同人同周的各天 winner(已按天去重)上卷累加得到,usage 从该周全部 winner 天的事件重算
180
180
  - CSV 里所有以 token 计的列(context 与 in/out/cache)都以百万(M)为单位(原始值除以 1,000,000),列名统一带 `M` 后缀;`contextWindowPct` 仍是百分比
181
181
  - 想直接查看团队多人 dashboard,可以用 `ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]`:默认监听 `127.0.0.1` 上的随机端口,启动后会自动用系统默认浏览器打开,每次请求实时读取目录里的 bundle,不写入任何文件
182
182
 
@@ -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`。
@@ -126,11 +126,6 @@ function bundlePersonKey(bundle) {
126
126
  function dayHasData(day) {
127
127
  return day.sampleCount > 0 || day.userMessageCount > 0 || day.apiRequestCount > 0;
128
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
129
  /** winner 比较:有数据优先,其次 generatedAt 较新,最后用 filePath 做稳定 tie-break。 */
135
130
  function isBetterCandidate(nextHasData, nextGeneratedAt, nextFilePath, currentHasData, currentGeneratedAt, currentFilePath) {
136
131
  if (nextHasData !== currentHasData) {
@@ -157,21 +152,6 @@ function selectDailyWinners(bundles) {
157
152
  }
158
153
  return winners;
159
154
  }
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
155
  /** 把 bundle 的 rawEvents 计算成 StatuslineEvent 并按本地自然日分组,结果做缓存复用。 */
176
156
  const bundleEventsCache = new WeakMap();
177
157
  function bundleEventsByDate(bundle) {
@@ -252,28 +232,78 @@ function buildAggregatedDailyRows(bundles) {
252
232
  }
253
233
  return rows.sort((left, right) => `${left.personKey}|${left.date}`.localeCompare(`${right.personKey}|${right.date}`));
254
234
  }
255
- /** 展开 weekly.csv:同人同周取 winner bundle 的累加值,usage 从该 bundle 全部事件重算。 */
235
+ /** 周级 usage 回退:rawEvents 缺失时从各天 daySummary 自带值取 peak(max)/ latest(date 最新非空)。 */
236
+ function fallbackWeeklyUsage(days) {
237
+ const byDateDesc = [...days].sort((left, right) => right.date.localeCompare(left.date));
238
+ const fivePeaks = days.map((day) => day.fiveHourPeakUsagePct).filter((value) => value !== null);
239
+ const sevenPeaks = days.map((day) => day.sevenDayPeakUsagePct).filter((value) => value !== null);
240
+ return {
241
+ fiveHourPeakUsagePct: fivePeaks.length > 0 ? (0, time_1.roundNumber)(Math.max(...fivePeaks), 1) : null,
242
+ fiveHourLatestUsagePct: byDateDesc.find((day) => day.fiveHourLatestUsagePct !== null)?.fiveHourLatestUsagePct ?? null,
243
+ sevenDayPeakUsagePct: sevenPeaks.length > 0 ? (0, time_1.roundNumber)(Math.max(...sevenPeaks), 1) : null,
244
+ sevenDayLatestUsagePct: byDateDesc.find((day) => day.sevenDayLatestUsagePct !== null)?.sevenDayLatestUsagePct ?? null,
245
+ };
246
+ }
247
+ /**
248
+ * 展开 weekly.csv:一个人同一周有多份 bundle(多台电脑各导出)时,不取单独一份的整周汇总,
249
+ * 而是复用按天去重后的 daily winner(每天选有数据的那份),按 (person, 周) 把每天的 token / 计数累加上卷,
250
+ * usage 从该周所有 winner 天的事件重算(peak 取 max、latest 取时间戳最新),缺失时回退到 daySummary 自带值。
251
+ */
256
252
  function buildAggregatedWeeklyRows(bundles) {
257
- const winners = selectWeeklyWinners(bundles);
253
+ const dailyWinners = selectDailyWinners(bundles);
254
+ const groups = new Map();
255
+ for (const winner of dailyWinners.values()) {
256
+ const week = weekKey(new Date(winner.bundle.range.start));
257
+ const key = `${winner.personKey}|${week}`;
258
+ let acc = groups.get(key);
259
+ if (!acc) {
260
+ acc = {
261
+ personKey: winner.personKey,
262
+ week,
263
+ userMessageCount: 0,
264
+ apiRequestCount: 0,
265
+ inputTokens: 0,
266
+ outputTokens: 0,
267
+ cacheReadInputTokens: 0,
268
+ sampleCount: 0,
269
+ uniqueSessions: 0,
270
+ uniqueWorkspaces: 0,
271
+ days: [],
272
+ events: [],
273
+ };
274
+ groups.set(key, acc);
275
+ }
276
+ const day = winner.day;
277
+ acc.userMessageCount += day.userMessageCount;
278
+ acc.apiRequestCount += day.apiRequestCount;
279
+ acc.inputTokens += day.inputTokens;
280
+ acc.outputTokens += day.outputTokens;
281
+ acc.cacheReadInputTokens += day.cacheReadInputTokens;
282
+ acc.sampleCount += day.sampleCount;
283
+ acc.uniqueSessions += day.uniqueSessions;
284
+ acc.uniqueWorkspaces += day.uniqueWorkspaces;
285
+ acc.days.push(day);
286
+ acc.events.push(...(bundleEventsByDate(winner.bundle).get(winner.date) ?? []));
287
+ }
258
288
  const rows = [];
259
- for (const winner of winners.values()) {
260
- const summary = winner.bundle.weeklySummary;
261
- const usage = recomputeUsage([...bundleEventsByDate(winner.bundle).values()].flat());
289
+ for (const acc of groups.values()) {
290
+ const usage = recomputeUsage(acc.events);
291
+ const fallback = fallbackWeeklyUsage(acc.days);
262
292
  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,
293
+ personKey: acc.personKey,
294
+ week: acc.week,
295
+ userMessageCount: acc.userMessageCount,
296
+ apiRequestCount: acc.apiRequestCount,
297
+ inputTokens: acc.inputTokens,
298
+ outputTokens: acc.outputTokens,
299
+ cacheReadInputTokens: acc.cacheReadInputTokens,
300
+ sampleCount: acc.sampleCount,
301
+ fiveHourPeakUsagePct: usage.fiveHourPeakUsagePct ?? fallback.fiveHourPeakUsagePct,
302
+ fiveHourLatestUsagePct: usage.fiveHourLatestUsagePct ?? fallback.fiveHourLatestUsagePct,
303
+ sevenDayPeakUsagePct: usage.sevenDayPeakUsagePct ?? fallback.sevenDayPeakUsagePct,
304
+ sevenDayLatestUsagePct: usage.sevenDayLatestUsagePct ?? fallback.sevenDayLatestUsagePct,
305
+ uniqueSessions: acc.uniqueSessions,
306
+ uniqueWorkspaces: acc.uniqueWorkspaces,
277
307
  });
278
308
  }
279
309
  return rows.sort((left, right) => `${left.personKey}|${left.week}`.localeCompare(`${right.personKey}|${right.week}`));
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.13",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {