ccus-cli 0.1.13 → 0.1.15

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/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] [--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`);
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 sessions [RANGE] [--out FILE] [--data-dir PATH] (把 ~/.claude/projects 本周活跃 session 打包成 zip,名如 projects_<dates>_<user>.zip)\n ccus aggregate --input-dir DIR [--out-dir DIR]\n ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]\n ccus sync [--data-dir PATH]\n ccus sync config [--target DIR] [--interval 3h|daily|<N>h|<N>m] [--range this-week] [--suffix NAME | --no-suffix] [--data-dir PATH]\n ccus sync install [--print] [--data-dir PATH] (注册每周五 18:00 的系统调度器)\n ccus sync uninstall [--print] (卸载系统调度器)\n ccus sync status [--data-dir PATH]\n ccus 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) {
@@ -563,6 +563,53 @@ function resolveExportOptions(action, args, rest) {
563
563
  }
564
564
  return options;
565
565
  }
566
+ /**
567
+ * `ccus sessions`:把 ~/.claude/projects 中在指定时间范围内有活动的 session 文件打包成 zip。
568
+ *
569
+ * zip 内部结构保持 <projectDir>/<sessionId>.jsonl 层级(路径分隔符统一用 /)。
570
+ * 文件名格式:projects_<start>_<end>_<gitUserName>.zip。
571
+ * 默认输出到 <data-dir>/sessions/,加 --out 可指定完整路径。
572
+ * 位置参数作为 range 简写,例如 `ccus sessions lw` 等价于 `--range last-week`。
573
+ */
574
+ async function handleSessions(options) {
575
+ const dataDir = getDataDir(options);
576
+ const range = getStringOption(options, "range") ?? "this-week";
577
+ const out = getStringOption(options, "out");
578
+ const now = new Date();
579
+ const window = (0, time_1.expandToFullWeekWindow)((0, time_1.resolveRange)(range, now));
580
+ (0, debug_1.debugLog)("sessions", "range resolved", { range, label: window.label, start: window.start.toISOString(), end: window.end.toISOString() });
581
+ const sessions = await (0, claude_1.findActiveSessionFiles)(window.start, window.end);
582
+ (0, debug_1.debugLog)("sessions", "active sessions found", { count: sessions.length });
583
+ const fsRead = (await Promise.resolve().then(() => __importStar(require("node:fs/promises")))).readFile;
584
+ const { buildZipBuffer } = await Promise.resolve().then(() => __importStar(require("./lib/zip")));
585
+ const entries = await Promise.all(sessions.map(async (session) => ({
586
+ name: `${session.projectDir.replaceAll("\\", "/")}/${session.sessionId}.jsonl`,
587
+ data: await fsRead(session.filePath),
588
+ })));
589
+ const zipBuffer = await buildZipBuffer(entries);
590
+ const gitIdentity = await (0, git_1.readGitIdentity)();
591
+ const userName = (0, time_1.formatGitEmailFilePrefix)(gitIdentity.userEmail) ?? "unknown";
592
+ const fileLabel = (0, time_1.formatRangeFileLabel)(window.start, window.end);
593
+ const defaultFileName = `projects_${fileLabel}_${userName}.zip`;
594
+ const outputPath = node_path_1.default.resolve(out ?? node_path_1.default.join(dataDir, "sessions", defaultFileName));
595
+ const fsNode = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
596
+ await fsNode.mkdir(node_path_1.default.dirname(outputPath), { recursive: true });
597
+ await fsNode.writeFile(outputPath, zipBuffer);
598
+ process.stdout.write(`${outputPath}\n`);
599
+ }
600
+ /**
601
+ * 解析 sessions 命令的参数,支持 RANGE 作为位置参数简写。
602
+ */
603
+ function resolveSessionsOptions(action, args, rest) {
604
+ if (!action || action.startsWith("--")) {
605
+ return parseOptions(args.slice(1));
606
+ }
607
+ const options = parseOptions(rest);
608
+ if (typeof options.range !== "string") {
609
+ options.range = action;
610
+ }
611
+ return options;
612
+ }
566
613
  /** 用 readline 向用户提问,返回用户输入的一行文本。 */
567
614
  async function prompt(question) {
568
615
  const rl = (await Promise.resolve().then(() => __importStar(require("node:readline")))).createInterface({
@@ -851,6 +898,11 @@ async function main(args = process.argv.slice(2)) {
851
898
  await handleExport(exportOptions);
852
899
  return;
853
900
  }
901
+ if (group === "sessions") {
902
+ const sessionsOptions = resolveSessionsOptions(action, args, rest);
903
+ await handleSessions(sessionsOptions);
904
+ return;
905
+ }
854
906
  if (group === "aggregate") {
855
907
  if (action === "serve") {
856
908
  await handleAggregateServe(parseOptions(rest));
@@ -122,14 +122,26 @@ async function loadWeeklyExportBundles(inputDir) {
122
122
  function bundlePersonKey(bundle) {
123
123
  return toPersonKey(bundle.identity.gitUserEmail, bundle.identity.gitUserName);
124
124
  }
125
- /** 某天 daySummary 是否承载真实数据(用于优先选中有数据的那份 bundle,而非占位空天)。 */
126
- function dayHasData(day) {
127
- return day.sampleCount > 0 || day.userMessageCount > 0 || day.apiRequestCount > 0;
125
+ /**
126
+ * 某天 daySummary 的数据质量等级(越高越优先):
127
+ * 2 = transcript 数据(userMessageCount / apiRequestCount > 0
128
+ * 1 = 仅有 statusline 采样(sampleCount > 0,但 transcript 字段全为 0)
129
+ * 0 = 全空占位天
130
+ *
131
+ * transcript 级别优先于纯 sampleCount 级别,避免只有采样事件但无消息数的 bundle
132
+ * 抢走有真实 transcript 数据的 bundle 的 winner 位置。
133
+ */
134
+ function dayDataTier(day) {
135
+ if (day.userMessageCount > 0 || day.apiRequestCount > 0)
136
+ return 2;
137
+ if (day.sampleCount > 0)
138
+ return 1;
139
+ return 0;
128
140
  }
129
- /** winner 比较:有数据优先,其次 generatedAt 较新,最后用 filePath 做稳定 tie-break。 */
130
- function isBetterCandidate(nextHasData, nextGeneratedAt, nextFilePath, currentHasData, currentGeneratedAt, currentFilePath) {
131
- if (nextHasData !== currentHasData) {
132
- return nextHasData;
141
+ /** winner 比较:数据质量等级高优先,同级别内 generatedAt 较新优先,最后用 filePath 做稳定 tie-break。 */
142
+ function isBetterCandidate(nextTier, nextGeneratedAt, nextFilePath, currentTier, currentGeneratedAt, currentFilePath) {
143
+ if (nextTier !== currentTier) {
144
+ return nextTier > currentTier;
133
145
  }
134
146
  if (nextGeneratedAt !== currentGeneratedAt) {
135
147
  return nextGeneratedAt > currentGeneratedAt;
@@ -145,7 +157,7 @@ function selectDailyWinners(bundles) {
145
157
  for (const day of bundle.dailySummaries) {
146
158
  const key = `${personKey}|${day.date}`;
147
159
  const current = winners.get(key);
148
- if (!current || isBetterCandidate(dayHasData(day), generatedAt, filePath, dayHasData(current.day), current.generatedAt, current.filePath)) {
160
+ if (!current || isBetterCandidate(dayDataTier(day), generatedAt, filePath, dayDataTier(current.day), current.generatedAt, current.filePath)) {
149
161
  winners.set(key, { personKey, date: day.date, day, bundle, generatedAt, filePath });
150
162
  }
151
163
  }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.summarizeClaudeProjectUsage = summarizeClaudeProjectUsage;
7
+ exports.findActiveSessionFiles = findActiveSessionFiles;
7
8
  exports.summarizeClaudeProjectUsageByDay = summarizeClaudeProjectUsageByDay;
8
9
  const promises_1 = __importDefault(require("node:fs/promises"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
@@ -142,6 +143,49 @@ async function summarizeClaudeProjectUsage(start, end) {
142
143
  }
143
144
  return totals;
144
145
  }
146
+ /**
147
+ * 找出 ~/.claude/projects 中在指定时间范围内有活动的 session 文件,返回文件路径及结构信息。
148
+ *
149
+ * 只判断文件里是否存在范围内的记录,不过滤内容,导出时完整复制原始文件。
150
+ */
151
+ async function findActiveSessionFiles(start, end) {
152
+ const claudeDataDir = (0, paths_1.getClaudeDataDir)();
153
+ const projectsDir = node_path_1.default.join(claudeDataDir, "projects");
154
+ const files = await collectProjectJsonlFiles(projectsDir);
155
+ const result = [];
156
+ for (const filePath of files) {
157
+ try {
158
+ const content = await promises_1.default.readFile(filePath, "utf8");
159
+ const lines = content.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
160
+ let hasInRange = false;
161
+ for (const line of lines) {
162
+ try {
163
+ const record = JSON.parse(line);
164
+ if (!isRecord(record))
165
+ continue;
166
+ if (timestampInRange(getString(record.timestamp), start, end)) {
167
+ hasInRange = true;
168
+ break;
169
+ }
170
+ }
171
+ catch {
172
+ continue;
173
+ }
174
+ }
175
+ if (hasInRange) {
176
+ result.push({
177
+ filePath,
178
+ projectDir: node_path_1.default.relative(projectsDir, node_path_1.default.dirname(filePath)),
179
+ sessionId: node_path_1.default.basename(filePath, ".jsonl"),
180
+ });
181
+ }
182
+ }
183
+ catch {
184
+ continue;
185
+ }
186
+ }
187
+ return result;
188
+ }
145
189
  /**
146
190
  * 按天汇总 Claude project transcript 中的消息数、请求数和 token 用量。
147
191
  */
@@ -0,0 +1,87 @@
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.buildZipBuffer = buildZipBuffer;
7
+ const node_zlib_1 = __importDefault(require("node:zlib"));
8
+ const node_util_1 = require("node:util");
9
+ const deflateRawAsync = (0, node_util_1.promisify)(node_zlib_1.default.deflateRaw);
10
+ function crc32(buf) {
11
+ const table = new Uint32Array(256);
12
+ for (let i = 0; i < 256; i++) {
13
+ let c = i;
14
+ for (let j = 0; j < 8; j++)
15
+ c = c & 1 ? (0xedb88320 ^ (c >>> 1)) : c >>> 1;
16
+ table[i] = c;
17
+ }
18
+ let crc = 0xffffffff;
19
+ for (let i = 0; i < buf.length; i++)
20
+ crc = (crc >>> 8) ^ table[(crc ^ buf[i]) & 0xff];
21
+ return (crc ^ 0xffffffff) >>> 0;
22
+ }
23
+ /**
24
+ * 把一组内存文件打包成合法的 ZIP 格式 Buffer。
25
+ *
26
+ * 使用 deflate 压缩,文件名按 UTF-8 编码(flags bit 11 置位),不写时间戳。
27
+ * 只依赖 Node.js 内置的 zlib,无外部依赖。
28
+ */
29
+ async function buildZipBuffer(entries) {
30
+ const locals = [];
31
+ const centrals = [];
32
+ let offset = 0;
33
+ for (const entry of entries) {
34
+ const name = Buffer.from(entry.name, "utf8");
35
+ const crc = crc32(entry.data);
36
+ const uncompressedSize = entry.data.length;
37
+ const compressed = await deflateRawAsync(entry.data);
38
+ const compressedSize = compressed.length;
39
+ const local = Buffer.alloc(30 + name.length);
40
+ local.writeUInt32LE(0x04034b50, 0); // local file header signature
41
+ local.writeUInt16LE(20, 4); // version needed
42
+ local.writeUInt16LE(0x0800, 6); // general purpose bit flag: UTF-8
43
+ local.writeUInt16LE(8, 8); // compression method: deflate
44
+ local.writeUInt16LE(0, 10); // last mod time
45
+ local.writeUInt16LE(0, 12); // last mod date
46
+ local.writeUInt32LE(crc, 14);
47
+ local.writeUInt32LE(compressedSize, 18);
48
+ local.writeUInt32LE(uncompressedSize, 22);
49
+ local.writeUInt16LE(name.length, 26);
50
+ local.writeUInt16LE(0, 28); // extra field length
51
+ name.copy(local, 30);
52
+ const central = Buffer.alloc(46 + name.length);
53
+ central.writeUInt32LE(0x02014b50, 0); // central directory signature
54
+ central.writeUInt16LE(20, 4); // version made by
55
+ central.writeUInt16LE(20, 6); // version needed
56
+ central.writeUInt16LE(0x0800, 8); // general purpose bit flag: UTF-8
57
+ central.writeUInt16LE(8, 10); // compression method: deflate
58
+ central.writeUInt16LE(0, 12); // last mod time
59
+ central.writeUInt16LE(0, 14); // last mod date
60
+ central.writeUInt32LE(crc, 16);
61
+ central.writeUInt32LE(compressedSize, 20);
62
+ central.writeUInt32LE(uncompressedSize, 24);
63
+ central.writeUInt16LE(name.length, 28);
64
+ central.writeUInt16LE(0, 30); // extra field length
65
+ central.writeUInt16LE(0, 32); // file comment length
66
+ central.writeUInt16LE(0, 34); // disk number start
67
+ central.writeUInt16LE(0, 36); // internal file attributes
68
+ central.writeUInt32LE(0, 38); // external file attributes
69
+ central.writeUInt32LE(offset, 42); // relative offset of local header
70
+ name.copy(central, 46);
71
+ locals.push(local, compressed);
72
+ centrals.push(central);
73
+ offset += local.length + compressedSize;
74
+ }
75
+ const centralSize = centrals.reduce((sum, c) => sum + c.length, 0);
76
+ const eocd = Buffer.alloc(22);
77
+ eocd.writeUInt32LE(0x06054b50, 0); // end of central dir signature
78
+ eocd.writeUInt16LE(0, 4); // disk number
79
+ eocd.writeUInt16LE(0, 6); // disk with central dir
80
+ eocd.writeUInt16LE(entries.length, 8);
81
+ eocd.writeUInt16LE(entries.length, 10);
82
+ eocd.writeUInt32LE(centralSize, 12);
83
+ eocd.writeUInt32LE(offset, 16);
84
+ eocd.writeUInt16LE(0, 20); // zip file comment length
85
+ return Buffer.concat([...locals, ...centrals, eocd]);
86
+ }
87
+ //# sourceMappingURL=zip.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {