ccus-cli 0.1.0
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/LICENSE +21 -0
- package/README.md +178 -0
- package/dist/cli.js +555 -0
- package/dist/lib/aggregate-dashboard.js +749 -0
- package/dist/lib/aggregate.js +168 -0
- package/dist/lib/claude.js +199 -0
- package/dist/lib/dashboard.js +394 -0
- package/dist/lib/debug.js +61 -0
- package/dist/lib/export.js +275 -0
- package/dist/lib/git.js +39 -0
- package/dist/lib/install.js +73 -0
- package/dist/lib/io.js +16 -0
- package/dist/lib/open.js +26 -0
- package/dist/lib/paths.js +56 -0
- package/dist/lib/payload.js +219 -0
- package/dist/lib/storage.js +217 -0
- package/dist/lib/time.js +154 -0
- package/dist/types.js +3 -0
- package/package.json +35 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
37
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
|
+
};
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.resolveExportOptions = resolveExportOptions;
|
|
41
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
42
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
43
|
+
const dashboard_1 = require("./lib/dashboard");
|
|
44
|
+
const dashboard_2 = require("./lib/dashboard");
|
|
45
|
+
const aggregate_dashboard_1 = require("./lib/aggregate-dashboard");
|
|
46
|
+
const claude_1 = require("./lib/claude");
|
|
47
|
+
const export_1 = require("./lib/export");
|
|
48
|
+
const aggregate_1 = require("./lib/aggregate");
|
|
49
|
+
const debug_1 = require("./lib/debug");
|
|
50
|
+
const git_1 = require("./lib/git");
|
|
51
|
+
const install_1 = require("./lib/install");
|
|
52
|
+
const io_1 = require("./lib/io");
|
|
53
|
+
const open_1 = require("./lib/open");
|
|
54
|
+
const payload_1 = require("./lib/payload");
|
|
55
|
+
const paths_1 = require("./lib/paths");
|
|
56
|
+
const storage_1 = require("./lib/storage");
|
|
57
|
+
const time_1 = require("./lib/time");
|
|
58
|
+
/** CLI 帮助信息保持简洁,方便直接挂到 README 或终端里查看。 */
|
|
59
|
+
function printHelp() {
|
|
60
|
+
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]\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\nGlobal flags:\n --verbose | --debug | -v 输出详细调试日志到 stderr(等价于设置 CCUS_DEBUG=1),方便排查问题\n`);
|
|
61
|
+
}
|
|
62
|
+
/** 一个轻量的参数解析器,当前命令面不复杂,没必要引入额外依赖。 */
|
|
63
|
+
function parseOptions(args) {
|
|
64
|
+
const options = {};
|
|
65
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
66
|
+
const arg = args[index];
|
|
67
|
+
if (!arg.startsWith("--")) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const key = arg.slice(2);
|
|
71
|
+
const next = args[index + 1];
|
|
72
|
+
if (!next || next.startsWith("--")) {
|
|
73
|
+
options[key] = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
options[key] = next;
|
|
77
|
+
index += 1;
|
|
78
|
+
}
|
|
79
|
+
return options;
|
|
80
|
+
}
|
|
81
|
+
/** 读取某个字符串选项,不存在时返回 undefined。 */
|
|
82
|
+
function getStringOption(options, key) {
|
|
83
|
+
const value = options[key];
|
|
84
|
+
return typeof value === "string" ? value : undefined;
|
|
85
|
+
}
|
|
86
|
+
/** 读取布尔选项;当前 CLI 中存在即视为 true。 */
|
|
87
|
+
function getBooleanOption(options, key) {
|
|
88
|
+
return options[key] === true;
|
|
89
|
+
}
|
|
90
|
+
/** 所有命令都共享一套数据目录解析逻辑。 */
|
|
91
|
+
function getDataDir(options) {
|
|
92
|
+
return node_path_1.default.resolve(getStringOption(options, "data-dir") ?? (0, paths_1.getDefaultDataDir)());
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* statusline 可以从 stdin 输入,也支持测试时通过 `--input` 指向 fixture 文件。
|
|
96
|
+
*/
|
|
97
|
+
async function readInputPayload(options) {
|
|
98
|
+
const inputFile = getStringOption(options, "input");
|
|
99
|
+
if (!inputFile) {
|
|
100
|
+
return (0, io_1.readStdin)();
|
|
101
|
+
}
|
|
102
|
+
const fs = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
|
|
103
|
+
return fs.readFile(node_path_1.default.resolve(inputFile), "utf8");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 拼出默认的 statusLine 命令。
|
|
107
|
+
*
|
|
108
|
+
* 直接用全局安装的 `ccus` 命令;如果传了 data-dir 就追加,路径统一用正斜杠避免在 JSON / shell 里反复转义。
|
|
109
|
+
*/
|
|
110
|
+
function buildDefaultStatuslineCommand(dataDir) {
|
|
111
|
+
let command = "ccus statusline emit";
|
|
112
|
+
if (dataDir) {
|
|
113
|
+
command += ` --data-dir "${dataDir.replaceAll("\\", "/")}"`;
|
|
114
|
+
}
|
|
115
|
+
return command;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 把 ccus 的 statusLine 命令写进 Claude Code 的 settings.json。
|
|
119
|
+
*
|
|
120
|
+
* 默认写 `~/.claude/settings.json`,只覆盖 statusLine 字段,其它设置原样保留。
|
|
121
|
+
*/
|
|
122
|
+
async function handleInstall(options) {
|
|
123
|
+
const settingsPath = node_path_1.default.resolve(getStringOption(options, "settings") ?? (0, paths_1.getClaudeSettingsPath)());
|
|
124
|
+
const explicitCommand = getStringOption(options, "command");
|
|
125
|
+
const dataDirOption = getStringOption(options, "data-dir");
|
|
126
|
+
const command = explicitCommand ?? buildDefaultStatuslineCommand(dataDirOption ? node_path_1.default.resolve(dataDirOption) : undefined);
|
|
127
|
+
const result = await (0, install_1.installStatusline)(settingsPath, command);
|
|
128
|
+
const header = result.created
|
|
129
|
+
? `Created Claude settings and configured statusLine: ${result.settingsPath}`
|
|
130
|
+
: result.unchanged
|
|
131
|
+
? `Claude statusLine already configured: ${result.settingsPath}`
|
|
132
|
+
: `Updated Claude statusLine: ${result.settingsPath}`;
|
|
133
|
+
process.stdout.write(`${header}\n command: ${result.command}\n`);
|
|
134
|
+
if (result.previousCommand && !result.unchanged) {
|
|
135
|
+
process.stdout.write(` replaced: ${result.previousCommand}\n`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* statusline 主路径:读 payload、归一化、落盘、输出单行状态文本。
|
|
140
|
+
*
|
|
141
|
+
* 这里即使异常也要优雅降级,不能因为采样失败把 statusline 弄挂。
|
|
142
|
+
*/
|
|
143
|
+
async function handleStatuslineEmit(options) {
|
|
144
|
+
const dataDir = getDataDir(options);
|
|
145
|
+
(0, debug_1.debugLog)("statusline", "start", { dataDir });
|
|
146
|
+
try {
|
|
147
|
+
const raw = await readInputPayload(options);
|
|
148
|
+
(0, debug_1.debugLog)("statusline", "payload received", { length: raw.length });
|
|
149
|
+
const payload = (0, payload_1.parseStatuslinePayload)(raw);
|
|
150
|
+
const record = (0, payload_1.createPersistedStatuslineEvent)(payload);
|
|
151
|
+
const gitIdentity = await (0, git_1.readGitIdentity)();
|
|
152
|
+
record.gitUserName = gitIdentity.userName;
|
|
153
|
+
record.gitUserEmail = gitIdentity.userEmail;
|
|
154
|
+
record.gitUserAccount = (0, time_1.extractGitEmailAccount)(gitIdentity.userEmail);
|
|
155
|
+
await (0, storage_1.appendEvent)(dataDir, record);
|
|
156
|
+
const event = (0, payload_1.computeStatuslineEvent)(record);
|
|
157
|
+
(0, debug_1.debugLog)("statusline", "event computed", {
|
|
158
|
+
sessionId: event.sessionId,
|
|
159
|
+
usagePct: event.usagePct,
|
|
160
|
+
sevenDayUsagePct: event.sevenDayUsagePct,
|
|
161
|
+
contextWindowPct: event.contextWindowPct,
|
|
162
|
+
modelName: event.modelName,
|
|
163
|
+
workspaceName: event.workspaceName,
|
|
164
|
+
gitUserAccount: event.gitUserAccount,
|
|
165
|
+
});
|
|
166
|
+
process.stdout.write(`${event.statusLine}\n`);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
// 正常路径不能因为采样失败把 statusline 弄挂,所以这里仍然降级输出兜底文本;
|
|
170
|
+
// 但默认会吞掉真实错误,排查极其困难。开启调试时把完整错误打到 stderr。
|
|
171
|
+
(0, debug_1.debugLog)("statusline", "failed, emitting fallback", error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
172
|
+
const fallback = (0, payload_1.computeStatuslineEvent)((0, payload_1.createPersistedStatuslineEvent)({}));
|
|
173
|
+
process.stdout.write(`${fallback.statusLine}\n`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** 构建 dashboard 的公共逻辑,build/open 复用同一套生成流程。 */
|
|
177
|
+
async function buildDashboard(options) {
|
|
178
|
+
const dataDir = getDataDir(options);
|
|
179
|
+
const range = getStringOption(options, "range") ?? "today";
|
|
180
|
+
const now = new Date();
|
|
181
|
+
const window = (0, time_1.resolveRange)(range, now);
|
|
182
|
+
const events = (await (0, storage_1.readEventsForRange)(dataDir, range, now)).map((record) => (0, payload_1.computeStatuslineEvent)(record));
|
|
183
|
+
(0, debug_1.debugLog)("dashboard", "events loaded", { range, label: window.label, sampleCount: events.length });
|
|
184
|
+
const html = (0, dashboard_1.buildDashboardHtml)(events, window.label, window.start, window.end);
|
|
185
|
+
const outputPath = node_path_1.default.resolve(getStringOption(options, "out") ?? node_path_1.default.join((0, paths_1.getDashboardDir)(dataDir), `${window.label}.html`));
|
|
186
|
+
await (0, export_1.writeTextFile)(outputPath, html);
|
|
187
|
+
(0, debug_1.debugLog)("dashboard", "html written", { outputPath, bytes: html.length });
|
|
188
|
+
return outputPath;
|
|
189
|
+
}
|
|
190
|
+
/** 直接返回 dashboard HTML 字符串,供本地 HTTP 服务复用。 */
|
|
191
|
+
async function renderDashboardHtml(options) {
|
|
192
|
+
const dataDir = getDataDir(options);
|
|
193
|
+
const range = getStringOption(options, "range") ?? "today";
|
|
194
|
+
const now = new Date();
|
|
195
|
+
const window = (0, time_1.resolveRange)(range, now);
|
|
196
|
+
const events = (await (0, storage_1.readEventsForRange)(dataDir, range, now)).map((record) => (0, payload_1.computeStatuslineEvent)(record));
|
|
197
|
+
return (0, dashboard_1.buildDashboardHtml)(events, window.label, window.start, window.end);
|
|
198
|
+
}
|
|
199
|
+
/** 只生成 dashboard 文件,不主动打开浏览器。 */
|
|
200
|
+
async function handleDashboardBuild(options) {
|
|
201
|
+
const outputPath = await buildDashboard(options);
|
|
202
|
+
process.stdout.write(`${outputPath}\n`);
|
|
203
|
+
}
|
|
204
|
+
/** 生成并打开 dashboard,适合本地快速查看。 */
|
|
205
|
+
async function handleDashboardOpen(options) {
|
|
206
|
+
const outputPath = await buildDashboard(options);
|
|
207
|
+
await (0, open_1.openInBrowser)(outputPath);
|
|
208
|
+
process.stdout.write(`${outputPath}\n`);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 启动一个本地 HTTP 页面,直接渲染最新 dashboard。
|
|
212
|
+
*
|
|
213
|
+
* 与 `build/open` 不同,这里不依赖预先生成 HTML 文件,而是每次请求时实时读取日志。
|
|
214
|
+
*/
|
|
215
|
+
async function handleDashboardServe(options) {
|
|
216
|
+
const host = getStringOption(options, "host") ?? "127.0.0.1";
|
|
217
|
+
const portOption = getStringOption(options, "port");
|
|
218
|
+
const port = portOption ? Number.parseInt(portOption, 10) : 0;
|
|
219
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
220
|
+
throw new Error(`Invalid port: ${portOption ?? ""}`);
|
|
221
|
+
}
|
|
222
|
+
const server = node_http_1.default.createServer(async (_request, response) => {
|
|
223
|
+
try {
|
|
224
|
+
const html = await renderDashboardHtml(options);
|
|
225
|
+
response.writeHead(200, {
|
|
226
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
227
|
+
"Cache-Control": "no-store",
|
|
228
|
+
});
|
|
229
|
+
response.end(html);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
233
|
+
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
234
|
+
response.end(message);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
await new Promise((resolve, reject) => {
|
|
238
|
+
server.once("error", reject);
|
|
239
|
+
server.listen(port, host, () => resolve());
|
|
240
|
+
});
|
|
241
|
+
const address = server.address();
|
|
242
|
+
if (!address || typeof address === "string") {
|
|
243
|
+
throw new Error("Failed to determine dashboard server address");
|
|
244
|
+
}
|
|
245
|
+
const url = `http://${host}:${address.port}`;
|
|
246
|
+
if (getBooleanOption(options, "open")) {
|
|
247
|
+
await (0, open_1.openInBrowser)(url);
|
|
248
|
+
}
|
|
249
|
+
process.stdout.write(`${url}\n`);
|
|
250
|
+
await new Promise((resolve) => {
|
|
251
|
+
const shutdown = () => {
|
|
252
|
+
server.close(() => resolve());
|
|
253
|
+
};
|
|
254
|
+
process.once("SIGINT", shutdown);
|
|
255
|
+
process.once("SIGTERM", shutdown);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* 导出原始事件。
|
|
260
|
+
*
|
|
261
|
+
* 当前默认导出一个 JSON 包,同时包含原始事件和按天周汇总。
|
|
262
|
+
*/
|
|
263
|
+
async function handleExport(options) {
|
|
264
|
+
const dataDir = getDataDir(options);
|
|
265
|
+
const range = getStringOption(options, "range") ?? "this-week";
|
|
266
|
+
const output = getStringOption(options, "out");
|
|
267
|
+
if (options.mode !== undefined) {
|
|
268
|
+
throw new Error("--mode has been removed.");
|
|
269
|
+
}
|
|
270
|
+
if (options.format !== undefined) {
|
|
271
|
+
throw new Error("--format has been removed. Export now always writes a weekly bundle json.");
|
|
272
|
+
}
|
|
273
|
+
const now = new Date();
|
|
274
|
+
const window = (0, time_1.resolveRange)(range, now);
|
|
275
|
+
(0, debug_1.debugLog)("export", "range resolved", { range, label: window.label, start: window.start.toISOString(), end: window.end.toISOString() });
|
|
276
|
+
const records = await (0, storage_1.readEventsForRange)(dataDir, range, now);
|
|
277
|
+
const events = records.map((record) => (0, payload_1.computeStatuslineEvent)(record));
|
|
278
|
+
const statuslineSummary = (0, dashboard_2.summarizeEvents)(events);
|
|
279
|
+
const statuslineDailyRows = (0, export_1.buildSummaryRows)(events);
|
|
280
|
+
const claudeUsage = await (0, claude_1.summarizeClaudeProjectUsage)(window.start, window.end);
|
|
281
|
+
const claudeDailyUsage = await (0, claude_1.summarizeClaudeProjectUsageByDay)(window.start, window.end);
|
|
282
|
+
(0, debug_1.debugLog)("export", "data collected", {
|
|
283
|
+
statuslineSamples: records.length,
|
|
284
|
+
claudeProjectFiles: claudeUsage.matchedFileCount,
|
|
285
|
+
userMessageCount: claudeUsage.userMessageCount,
|
|
286
|
+
apiRequestCount: claudeUsage.apiRequestCount,
|
|
287
|
+
});
|
|
288
|
+
const reversedRecords = [...records].reverse();
|
|
289
|
+
let exportUserEmail = reversedRecords.map((record) => record.gitUserEmail).find((email) => Boolean(email)) ?? null;
|
|
290
|
+
let exportUserName = reversedRecords.map((record) => record.gitUserName).find((name) => Boolean(name)) ?? null;
|
|
291
|
+
// 窗口内没有带身份的采样(例如导出上一周但当时没运行 ccus)时,回退到当前 git 身份,
|
|
292
|
+
// 保证文件名前缀和 bundle identity 仍能标识导出人。
|
|
293
|
+
if (exportUserEmail === null && exportUserName === null) {
|
|
294
|
+
const gitIdentity = await (0, git_1.readGitIdentity)();
|
|
295
|
+
exportUserEmail = gitIdentity.userEmail;
|
|
296
|
+
exportUserName = gitIdentity.userName;
|
|
297
|
+
}
|
|
298
|
+
const weeklySummary = {
|
|
299
|
+
schemaVersion: 6,
|
|
300
|
+
generatedAt: new Date().toISOString(),
|
|
301
|
+
range: {
|
|
302
|
+
label: window.label,
|
|
303
|
+
start: window.start.toISOString(),
|
|
304
|
+
end: window.end.toISOString(),
|
|
305
|
+
},
|
|
306
|
+
identity: {
|
|
307
|
+
gitUserName: exportUserName,
|
|
308
|
+
gitUserEmail: exportUserEmail,
|
|
309
|
+
},
|
|
310
|
+
counts: {
|
|
311
|
+
userMessageCount: claudeUsage.userMessageCount,
|
|
312
|
+
apiRequestCount: claudeUsage.apiRequestCount,
|
|
313
|
+
},
|
|
314
|
+
tokens: {
|
|
315
|
+
inputTokens: claudeUsage.inputTokens,
|
|
316
|
+
outputTokens: claudeUsage.outputTokens,
|
|
317
|
+
cacheReadInputTokens: claudeUsage.cacheReadInputTokens,
|
|
318
|
+
},
|
|
319
|
+
statusline: {
|
|
320
|
+
sampleCount: statuslineSummary.sampleCount,
|
|
321
|
+
uniqueSessions: statuslineSummary.uniqueSessions,
|
|
322
|
+
uniqueWorkspaces: statuslineSummary.uniqueWorkspaces,
|
|
323
|
+
fiveHourLatestUsagePct: statuslineSummary.fiveHourLatestUsagePct,
|
|
324
|
+
fiveHourPeakUsagePct: statuslineSummary.fiveHourPeakUsagePct,
|
|
325
|
+
sevenDayLatestUsagePct: statuslineSummary.sevenDayLatestUsagePct,
|
|
326
|
+
sevenDayPeakUsagePct: statuslineSummary.sevenDayPeakUsagePct,
|
|
327
|
+
},
|
|
328
|
+
sources: {
|
|
329
|
+
ccusDataDir: dataDir,
|
|
330
|
+
claudeDataDir: claudeUsage.claudeDataDir,
|
|
331
|
+
projectFilesMatched: claudeUsage.matchedFileCount,
|
|
332
|
+
messageCountSource: "claude-projects:user-events",
|
|
333
|
+
apiRequestCountSource: "claude-projects:assistant-usage-events",
|
|
334
|
+
tokenSource: "claude-projects:assistant-usage-events",
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
const statuslineDailyMap = new Map(statuslineDailyRows.map((row) => [row.date, row]));
|
|
338
|
+
const dailySummaries = (0, time_1.enumerateDateKeys)(window.start, window.end).map((date) => {
|
|
339
|
+
const row = statuslineDailyMap.get(date);
|
|
340
|
+
const claudeDay = claudeDailyUsage.get(date);
|
|
341
|
+
return {
|
|
342
|
+
date,
|
|
343
|
+
userMessageCount: claudeDay?.userMessageCount ?? 0,
|
|
344
|
+
apiRequestCount: claudeDay?.apiRequestCount ?? 0,
|
|
345
|
+
inputTokens: claudeDay?.inputTokens ?? 0,
|
|
346
|
+
outputTokens: claudeDay?.outputTokens ?? 0,
|
|
347
|
+
cacheReadInputTokens: claudeDay?.cacheReadInputTokens ?? 0,
|
|
348
|
+
sampleCount: row?.sampleCount ?? 0,
|
|
349
|
+
fiveHourLatestUsagePct: row?.fiveHourLatestUsagePct ?? null,
|
|
350
|
+
fiveHourPeakUsagePct: row?.fiveHourPeakUsagePct ?? null,
|
|
351
|
+
sevenDayLatestUsagePct: row?.sevenDayLatestUsagePct ?? null,
|
|
352
|
+
sevenDayPeakUsagePct: row?.sevenDayPeakUsagePct ?? null,
|
|
353
|
+
uniqueSessions: row?.uniqueSessions ?? 0,
|
|
354
|
+
uniqueWorkspaces: row?.uniqueWorkspaces ?? 0,
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
const bundle = {
|
|
358
|
+
schemaVersion: 6,
|
|
359
|
+
generatedAt: new Date().toISOString(),
|
|
360
|
+
range: {
|
|
361
|
+
label: window.label,
|
|
362
|
+
start: window.start.toISOString(),
|
|
363
|
+
end: window.end.toISOString(),
|
|
364
|
+
},
|
|
365
|
+
identity: {
|
|
366
|
+
gitUserName: exportUserName,
|
|
367
|
+
gitUserEmail: exportUserEmail,
|
|
368
|
+
},
|
|
369
|
+
rawEvents: records,
|
|
370
|
+
weeklySummary,
|
|
371
|
+
dailySummaries,
|
|
372
|
+
};
|
|
373
|
+
const content = (0, export_1.buildWeeklyExportBundleJson)(bundle);
|
|
374
|
+
const fileLabel = (0, time_1.formatRangeFileLabel)(window.start, window.end);
|
|
375
|
+
const gitEmailPrefix = (0, time_1.formatGitEmailFilePrefix)(exportUserEmail);
|
|
376
|
+
const defaultFileName = gitEmailPrefix ? `${gitEmailPrefix}_export_${fileLabel}.json` : `export_${fileLabel}.json`;
|
|
377
|
+
const outputPath = node_path_1.default.resolve(output ?? node_path_1.default.join(dataDir, "exports", defaultFileName));
|
|
378
|
+
await (0, export_1.writeTextFile)(outputPath, content);
|
|
379
|
+
(0, debug_1.debugLog)("export", "bundle written", { outputPath, bytes: content.length });
|
|
380
|
+
process.stdout.write(`${outputPath}\n`);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* 聚合一个目录里的多人 export bundle json,输出 detail/daily/weekly 三个 CSV。
|
|
384
|
+
*/
|
|
385
|
+
async function handleAggregate(options) {
|
|
386
|
+
const inputDir = getStringOption(options, "input-dir");
|
|
387
|
+
if (!inputDir) {
|
|
388
|
+
throw new Error("--input-dir is required.");
|
|
389
|
+
}
|
|
390
|
+
const resolvedInputDir = node_path_1.default.resolve(inputDir);
|
|
391
|
+
const outputDir = node_path_1.default.resolve(getStringOption(options, "out-dir") ?? node_path_1.default.join(resolvedInputDir, "aggregated"));
|
|
392
|
+
(0, debug_1.debugLog)("aggregate", "loading bundles", { inputDir: resolvedInputDir, outputDir });
|
|
393
|
+
const bundles = await (0, aggregate_1.loadWeeklyExportBundles)(resolvedInputDir);
|
|
394
|
+
const detailRows = (0, aggregate_1.buildAggregatedDetailRows)(bundles);
|
|
395
|
+
const dailyRows = (0, aggregate_1.buildAggregatedDailyRows)(bundles);
|
|
396
|
+
const weeklyRows = (0, aggregate_1.buildAggregatedWeeklyRows)(bundles);
|
|
397
|
+
(0, debug_1.debugLog)("aggregate", "bundles loaded", { bundleCount: bundles.length, detailRows: detailRows.length, dailyRows: dailyRows.length, weeklyRows: weeklyRows.length });
|
|
398
|
+
const detailCsv = (0, export_1.buildAggregatedDetailCsv)(detailRows);
|
|
399
|
+
const dailyCsv = (0, export_1.buildAggregatedDailyCsv)(dailyRows);
|
|
400
|
+
const weeklyCsv = (0, export_1.buildAggregatedWeeklyCsv)(weeklyRows);
|
|
401
|
+
const detailPath = node_path_1.default.join(outputDir, "detail.csv");
|
|
402
|
+
const dailyPath = node_path_1.default.join(outputDir, "daily.csv");
|
|
403
|
+
const weeklyPath = node_path_1.default.join(outputDir, "weekly.csv");
|
|
404
|
+
await Promise.all([
|
|
405
|
+
(0, export_1.writeTextFile)(detailPath, detailCsv),
|
|
406
|
+
(0, export_1.writeTextFile)(dailyPath, dailyCsv),
|
|
407
|
+
(0, export_1.writeTextFile)(weeklyPath, weeklyCsv),
|
|
408
|
+
]);
|
|
409
|
+
(0, debug_1.debugLog)("aggregate", "csv written", { detailPath, dailyPath, weeklyPath });
|
|
410
|
+
process.stdout.write(`${detailPath}\n${dailyPath}\n${weeklyPath}\n`);
|
|
411
|
+
}
|
|
412
|
+
/** 实时把 input-dir 里的 bundle 渲染成多人 dashboard HTML,供 serve 路径复用。 */
|
|
413
|
+
async function renderAggregateDashboardHtml(inputDir) {
|
|
414
|
+
const bundles = await (0, aggregate_1.loadWeeklyExportBundles)(inputDir);
|
|
415
|
+
const detailRows = (0, aggregate_1.buildAggregatedDetailRows)(bundles);
|
|
416
|
+
const dailyRows = (0, aggregate_1.buildAggregatedDailyRows)(bundles);
|
|
417
|
+
const weeklyRows = (0, aggregate_1.buildAggregatedWeeklyRows)(bundles);
|
|
418
|
+
return (0, aggregate_dashboard_1.buildAggregateDashboardHtml)(detailRows, dailyRows, weeklyRows);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* 启动一个本地 HTTP 页面,直接渲染多人 aggregate dashboard。
|
|
422
|
+
*
|
|
423
|
+
* 与 `aggregate` 写 CSV 不同,这里不落地任何文件,每次请求时实时读 bundle 目录。
|
|
424
|
+
*/
|
|
425
|
+
async function handleAggregateServe(options) {
|
|
426
|
+
const inputDir = getStringOption(options, "input-dir");
|
|
427
|
+
if (!inputDir) {
|
|
428
|
+
throw new Error("--input-dir is required.");
|
|
429
|
+
}
|
|
430
|
+
const resolvedInputDir = node_path_1.default.resolve(inputDir);
|
|
431
|
+
const host = getStringOption(options, "host") ?? "127.0.0.1";
|
|
432
|
+
const portOption = getStringOption(options, "port");
|
|
433
|
+
const port = portOption ? Number.parseInt(portOption, 10) : 0;
|
|
434
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
435
|
+
throw new Error(`Invalid port: ${portOption ?? ""}`);
|
|
436
|
+
}
|
|
437
|
+
const server = node_http_1.default.createServer(async (_request, response) => {
|
|
438
|
+
try {
|
|
439
|
+
const html = await renderAggregateDashboardHtml(resolvedInputDir);
|
|
440
|
+
response.writeHead(200, {
|
|
441
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
442
|
+
"Cache-Control": "no-store",
|
|
443
|
+
});
|
|
444
|
+
response.end(html);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
448
|
+
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
449
|
+
response.end(message);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
await new Promise((resolve, reject) => {
|
|
453
|
+
server.once("error", reject);
|
|
454
|
+
server.listen(port, host, () => resolve());
|
|
455
|
+
});
|
|
456
|
+
const address = server.address();
|
|
457
|
+
if (!address || typeof address === "string") {
|
|
458
|
+
throw new Error("Failed to determine aggregate dashboard server address");
|
|
459
|
+
}
|
|
460
|
+
const url = `http://${host}:${address.port}`;
|
|
461
|
+
await (0, open_1.openInBrowser)(url);
|
|
462
|
+
process.stdout.write(`${url}\n`);
|
|
463
|
+
await new Promise((resolve) => {
|
|
464
|
+
const shutdown = () => {
|
|
465
|
+
server.close(() => resolve());
|
|
466
|
+
};
|
|
467
|
+
process.once("SIGINT", shutdown);
|
|
468
|
+
process.once("SIGTERM", shutdown);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/** 历史上被移除的导出格式 token,作为位置参数出现时要明确报错而不是当成 range。 */
|
|
472
|
+
const REMOVED_EXPORT_FORMAT_TOKENS = new Set(["csv", "jsonl", "raw"]);
|
|
473
|
+
/**
|
|
474
|
+
* 解析 export 命令的参数。
|
|
475
|
+
*
|
|
476
|
+
* 位置参数被当作 range 简写,例如 `ccus export lw` 等价于 `ccus export --range last-week`。
|
|
477
|
+
* 已移除的旧子命令 / 格式 token 仍然明确报错,避免旧脚本误判。
|
|
478
|
+
*/
|
|
479
|
+
function resolveExportOptions(action, args, rest) {
|
|
480
|
+
if (!action || action.startsWith("--")) {
|
|
481
|
+
return parseOptions(args.slice(1));
|
|
482
|
+
}
|
|
483
|
+
if (action === "summary") {
|
|
484
|
+
throw new Error("`ccus export summary` has been removed. Use `ccus export` to export raw jsonl data.");
|
|
485
|
+
}
|
|
486
|
+
if (REMOVED_EXPORT_FORMAT_TOKENS.has(action)) {
|
|
487
|
+
throw new Error(`Unsupported export argument: ${action}. Use \`ccus export [RANGE] [--out FILE]\`; legacy export formats have been removed.`);
|
|
488
|
+
}
|
|
489
|
+
const options = parseOptions(rest);
|
|
490
|
+
if (typeof options.range !== "string") {
|
|
491
|
+
options.range = action;
|
|
492
|
+
}
|
|
493
|
+
return options;
|
|
494
|
+
}
|
|
495
|
+
/** 顶层命令分发入口。 */
|
|
496
|
+
async function main(args = process.argv.slice(2)) {
|
|
497
|
+
(0, debug_1.setDebugEnabled)((0, debug_1.resolveDebugEnabled)(args));
|
|
498
|
+
const [group, action, ...rest] = args;
|
|
499
|
+
const options = parseOptions(rest);
|
|
500
|
+
(0, debug_1.debugLog)("cli", "invoked", { group, action, dataDir: getDataDir(options) });
|
|
501
|
+
if (!group) {
|
|
502
|
+
printHelp();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (group === "install") {
|
|
506
|
+
await handleInstall(parseOptions(args.slice(1)));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (group === "statusline" && action === "emit") {
|
|
510
|
+
await handleStatuslineEmit(options);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (group === "dashboard" && action === "build") {
|
|
514
|
+
await handleDashboardBuild(options);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (group === "dashboard" && action === "open") {
|
|
518
|
+
await handleDashboardOpen(options);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (group === "dashboard" && action === "serve") {
|
|
522
|
+
await handleDashboardServe(options);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (group === "export") {
|
|
526
|
+
const exportOptions = resolveExportOptions(action, args, rest);
|
|
527
|
+
await handleExport(exportOptions);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (group === "aggregate") {
|
|
531
|
+
if (action === "serve") {
|
|
532
|
+
await handleAggregateServe(parseOptions(rest));
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (action && !action.startsWith("--")) {
|
|
536
|
+
throw new Error(`Unsupported aggregate argument: ${action}. Use \`ccus aggregate --input-dir DIR\` or \`ccus aggregate serve --input-dir DIR\`.`);
|
|
537
|
+
}
|
|
538
|
+
await handleAggregate(parseOptions(args.slice(1)));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
printHelp();
|
|
542
|
+
process.exitCode = 1;
|
|
543
|
+
}
|
|
544
|
+
/** 顶层兜底错误处理:给出错误信息并返回非 0 退出码。 */
|
|
545
|
+
if (require.main === module) {
|
|
546
|
+
main().catch((error) => {
|
|
547
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
548
|
+
process.stderr.write(`${message}\n`);
|
|
549
|
+
if (error instanceof Error && error.stack) {
|
|
550
|
+
(0, debug_1.debugLog)("cli", "uncaught error", error.stack);
|
|
551
|
+
}
|
|
552
|
+
process.exitCode = 1;
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
//# sourceMappingURL=cli.js.map
|