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 +53 -1
- package/dist/lib/aggregate.js +20 -8
- package/dist/lib/claude.js +44 -0
- package/dist/lib/zip.js +87 -0
- package/package.json +1 -1
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));
|
package/dist/lib/aggregate.js
CHANGED
|
@@ -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
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
130
|
-
function isBetterCandidate(
|
|
131
|
-
if (
|
|
132
|
-
return
|
|
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(
|
|
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
|
}
|
package/dist/lib/claude.js
CHANGED
|
@@ -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
|
*/
|
package/dist/lib/zip.js
ADDED
|
@@ -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
|