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
|
@@ -0,0 +1,219 @@
|
|
|
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.readSessionId = readSessionId;
|
|
7
|
+
exports.extractWorkspaceDir = extractWorkspaceDir;
|
|
8
|
+
exports.parseStatuslinePayload = parseStatuslinePayload;
|
|
9
|
+
exports.formatStatusLine = formatStatusLine;
|
|
10
|
+
exports.createPersistedStatuslineEvent = createPersistedStatuslineEvent;
|
|
11
|
+
exports.computeStatuslineEvent = computeStatuslineEvent;
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const time_1 = require("./time");
|
|
14
|
+
/** 仅把普通对象视作可继续读取字段的记录类型。 */
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
/** 宽松读取数字,兼容 number 和可转成 number 的字符串。 */
|
|
19
|
+
function getNumber(value) {
|
|
20
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
24
|
+
const parsed = Number(value);
|
|
25
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
/** 宽松读取非空字符串。 */
|
|
30
|
+
function getString(value) {
|
|
31
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
32
|
+
}
|
|
33
|
+
/** 从原始 payload 中提取 session id。 */
|
|
34
|
+
function readSessionId(payload) {
|
|
35
|
+
return getString(payload.session_id);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 从 `context_window` 中提取上下文窗口占用相关字段。
|
|
39
|
+
*
|
|
40
|
+
* 这里提取的是上下文窗口占用,不是 Claude 的 5 小时额度使用率。
|
|
41
|
+
* 优先信任官方给出的 `used_percentage`;如果不存在,再尝试由 `used/max` 反推。
|
|
42
|
+
*/
|
|
43
|
+
function readContextMetrics(payload) {
|
|
44
|
+
if (!isRecord(payload.context_window)) {
|
|
45
|
+
return { contextWindowPct: null, contextUsed: null, contextMax: null };
|
|
46
|
+
}
|
|
47
|
+
const contextWindowPct = getNumber(payload.context_window.used_percentage);
|
|
48
|
+
const legacyContextUsed = getNumber(payload.context_window.used_tokens) ??
|
|
49
|
+
getNumber(payload.context_window.current_tokens) ??
|
|
50
|
+
getNumber(payload.context_window.used);
|
|
51
|
+
const legacyContextMax = getNumber(payload.context_window.max_tokens) ??
|
|
52
|
+
getNumber(payload.context_window.limit_tokens) ??
|
|
53
|
+
getNumber(payload.context_window.max);
|
|
54
|
+
const totalInputTokens = getNumber(payload.context_window.total_input_tokens);
|
|
55
|
+
const totalOutputTokens = getNumber(payload.context_window.total_output_tokens);
|
|
56
|
+
const contextUsed = legacyContextUsed ??
|
|
57
|
+
(totalInputTokens !== null || totalOutputTokens !== null ? (totalInputTokens ?? 0) + (totalOutputTokens ?? 0) : null);
|
|
58
|
+
const contextMax = legacyContextMax ?? getNumber(payload.context_window.context_window_size);
|
|
59
|
+
if (contextWindowPct !== null) {
|
|
60
|
+
return {
|
|
61
|
+
contextWindowPct: (0, time_1.roundNumber)(contextWindowPct, 1),
|
|
62
|
+
contextUsed,
|
|
63
|
+
contextMax,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (contextUsed !== null && contextMax !== null && contextMax > 0) {
|
|
67
|
+
return {
|
|
68
|
+
contextWindowPct: (0, time_1.roundNumber)((contextUsed / contextMax) * 100, 1),
|
|
69
|
+
contextUsed,
|
|
70
|
+
contextMax,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { contextWindowPct: null, contextUsed, contextMax };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 读取 Claude 的 5 小时额度使用率。
|
|
77
|
+
*
|
|
78
|
+
* 官方字段位于 `rate_limits.five_hour.used_percentage`。
|
|
79
|
+
*/
|
|
80
|
+
function readFiveHourUsagePct(payload) {
|
|
81
|
+
if (!isRecord(payload.rate_limits)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const fiveHour = payload.rate_limits.five_hour;
|
|
85
|
+
if (!isRecord(fiveHour)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return (0, time_1.roundNumber)(getNumber(fiveHour.used_percentage), 1);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 读取 Claude 的 7 天额度使用率。
|
|
92
|
+
*
|
|
93
|
+
* 官方字段位于 `rate_limits.seven_day.used_percentage`。
|
|
94
|
+
*/
|
|
95
|
+
function readSevenDayUsagePct(payload) {
|
|
96
|
+
if (!isRecord(payload.rate_limits)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const sevenDay = payload.rate_limits.seven_day;
|
|
100
|
+
if (!isRecord(sevenDay)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return (0, time_1.roundNumber)(getNumber(sevenDay.used_percentage), 1);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 模型名称不同版本可能有 `display_name` 或 `name`,这里统一兼容。
|
|
107
|
+
*/
|
|
108
|
+
function readModelName(payload) {
|
|
109
|
+
if (!isRecord(payload.model)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return getString(payload.model.display_name) ?? getString(payload.model.name);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 工作目录优先走官方 `workspace.current_dir`,兼容旧字段 `cwd`。
|
|
116
|
+
*/
|
|
117
|
+
function readWorkspaceDir(payload) {
|
|
118
|
+
if (isRecord(payload.workspace)) {
|
|
119
|
+
return getString(payload.workspace.current_dir) ?? getString(payload.workspace.cwd);
|
|
120
|
+
}
|
|
121
|
+
return getString(payload.cwd);
|
|
122
|
+
}
|
|
123
|
+
/** 对外暴露工作区目录提取,供存储分片与 git 读取复用。 */
|
|
124
|
+
function extractWorkspaceDir(payload) {
|
|
125
|
+
return readWorkspaceDir(payload);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* dashboard 和 statusline 里展示更短的项目名,而不是整段绝对路径。
|
|
129
|
+
*/
|
|
130
|
+
function readWorkspaceName(workspaceDir) {
|
|
131
|
+
if (!workspaceDir) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return node_path_1.default.basename(workspaceDir);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 解析 stdin 传入的原始 payload。
|
|
138
|
+
*
|
|
139
|
+
* 空输入返回空对象,便于上层做降级而不是直接崩溃。
|
|
140
|
+
*/
|
|
141
|
+
function parseStatuslinePayload(input) {
|
|
142
|
+
const trimmed = input.trim();
|
|
143
|
+
if (trimmed.length === 0) {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
const parsed = JSON.parse(trimmed);
|
|
147
|
+
return isRecord(parsed) ? parsed : {};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 生成真正显示在 Claude Code statusline 上的短文本。
|
|
151
|
+
*
|
|
152
|
+
* 这里必须保持单行、紧凑,避免污染 statusline 展示区域。
|
|
153
|
+
*/
|
|
154
|
+
function formatStatusLine(event) {
|
|
155
|
+
const timeLabel = (0, time_1.formatClock)(new Date(event.timestamp));
|
|
156
|
+
const usageLabel = event.usagePct === null ? "5h --" : `5h ${event.usagePct.toFixed(1)}%`;
|
|
157
|
+
const sevenDayLabel = event.sevenDayUsagePct === null ? "7d --" : `7d ${event.sevenDayUsagePct.toFixed(1)}%`;
|
|
158
|
+
const contextLabel = event.contextWindowPct === null ? "ctx --" : `ctx ${event.contextWindowPct.toFixed(1)}%`;
|
|
159
|
+
const modelLabel = event.modelName ?? "model --";
|
|
160
|
+
const workspaceLabel = event.workspaceName ?? "workspace --";
|
|
161
|
+
return `${usageLabel} | ${sevenDayLabel} | ${contextLabel} | ${modelLabel} | ${workspaceLabel} | ${timeLabel}`;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 基于原始 payload 创建一条最小持久化事件。
|
|
165
|
+
*
|
|
166
|
+
* 分析字段不在这里持久化,而是在读取时按需计算。
|
|
167
|
+
*/
|
|
168
|
+
function createPersistedStatuslineEvent(payload, now = new Date()) {
|
|
169
|
+
return {
|
|
170
|
+
schemaVersion: 3,
|
|
171
|
+
timestamp: now.toISOString(),
|
|
172
|
+
gitUserName: null,
|
|
173
|
+
gitUserEmail: null,
|
|
174
|
+
gitUserAccount: null,
|
|
175
|
+
rawPayload: payload,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 从持久化事件计算出 dashboard/export/statusline 使用的完整视图。
|
|
180
|
+
*
|
|
181
|
+
* 对旧日志会优先使用 rawPayload 重新推导;若个别旧字段缺失,再回退到历史持久化字段。
|
|
182
|
+
*/
|
|
183
|
+
function computeStatuslineEvent(record) {
|
|
184
|
+
const legacy = record;
|
|
185
|
+
const payload = record.rawPayload ?? {};
|
|
186
|
+
const sessionId = readSessionId(payload) ?? legacy.sessionId ?? null;
|
|
187
|
+
const modelName = readModelName(payload) ?? legacy.modelName ?? null;
|
|
188
|
+
const workspaceDir = readWorkspaceDir(payload) ?? legacy.workspaceDir ?? null;
|
|
189
|
+
const workspaceName = readWorkspaceName(workspaceDir) ?? legacy.workspaceName ?? null;
|
|
190
|
+
const usagePct = readFiveHourUsagePct(payload) ?? legacy.usagePct ?? null;
|
|
191
|
+
const sevenDayUsagePct = readSevenDayUsagePct(payload) ?? legacy.sevenDayUsagePct ?? null;
|
|
192
|
+
const computedContext = readContextMetrics(payload);
|
|
193
|
+
const contextWindowPct = computedContext.contextWindowPct ?? legacy.contextWindowPct ?? null;
|
|
194
|
+
const contextUsed = computedContext.contextUsed ?? legacy.contextUsed ?? null;
|
|
195
|
+
const contextMax = computedContext.contextMax ?? legacy.contextMax ?? null;
|
|
196
|
+
const gitUserAccount = record.gitUserAccount ?? (0, time_1.extractGitEmailAccount)(record.gitUserEmail ?? null);
|
|
197
|
+
const baseEvent = {
|
|
198
|
+
timestamp: record.timestamp,
|
|
199
|
+
sessionId,
|
|
200
|
+
workspaceDir,
|
|
201
|
+
workspaceName,
|
|
202
|
+
modelName,
|
|
203
|
+
gitUserName: record.gitUserName ?? null,
|
|
204
|
+
gitUserEmail: record.gitUserEmail ?? null,
|
|
205
|
+
gitUserAccount,
|
|
206
|
+
usagePct,
|
|
207
|
+
sevenDayUsagePct,
|
|
208
|
+
contextWindowPct,
|
|
209
|
+
contextUsed,
|
|
210
|
+
contextMax,
|
|
211
|
+
statusLine: "",
|
|
212
|
+
rawPayload: payload,
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
...baseEvent,
|
|
216
|
+
statusLine: legacy.statusLine ?? formatStatusLine(baseEvent),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=payload.js.map
|
|
@@ -0,0 +1,217 @@
|
|
|
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.appendEvent = appendEvent;
|
|
7
|
+
exports.readEventsForRange = readEventsForRange;
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const paths_1 = require("./paths");
|
|
12
|
+
const payload_1 = require("./payload");
|
|
13
|
+
const time_1 = require("./time");
|
|
14
|
+
const LOCK_RETRY_DELAY_MS = 25;
|
|
15
|
+
const LOCK_RETRY_TIMES = 40;
|
|
16
|
+
const STALE_LOCK_TIMEOUT_MS = 10_000;
|
|
17
|
+
/** 所有写入前都先确保父目录存在。 */
|
|
18
|
+
async function ensureDirectory(directoryPath) {
|
|
19
|
+
await promises_1.default.mkdir(directoryPath, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
/** 当前采用按天分目录的布局,便于按范围扫描和后续迁移。 */
|
|
22
|
+
function getDayDirectory(eventsDir, dateKey) {
|
|
23
|
+
return node_path_1.default.join(eventsDir, dateKey);
|
|
24
|
+
}
|
|
25
|
+
/** 兼容 v1 早期的 JSONL 单文件布局,避免旧数据无法读取。 */
|
|
26
|
+
function getLegacyJsonlPath(eventsDir, dateKey) {
|
|
27
|
+
return node_path_1.default.join(eventsDir, `${dateKey}.jsonl`);
|
|
28
|
+
}
|
|
29
|
+
/** 将任意字符串压缩成适合文件名的片段。 */
|
|
30
|
+
function sanitizeFilePart(value) {
|
|
31
|
+
const normalized = value.replaceAll(/[^a-zA-Z0-9._-]+/g, "-").replaceAll(/-+/g, "-").replaceAll(/^[-.]+|[-.]+$/g, "");
|
|
32
|
+
return normalized.length > 0 ? normalized : "unknown";
|
|
33
|
+
}
|
|
34
|
+
/** 为没有 sessionId 的事件生成一个稳定的 workspace 分片键。 */
|
|
35
|
+
function buildWorkspaceShardKey(workspaceDir) {
|
|
36
|
+
if (!workspaceDir) {
|
|
37
|
+
return "workspace-unknown";
|
|
38
|
+
}
|
|
39
|
+
const digest = (0, node_crypto_1.createHash)("sha1").update(workspaceDir).digest("hex").slice(0, 12);
|
|
40
|
+
return `workspace-${digest}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 同一天、同一个 session 的事件写入同一个 shard 文件,减少碎文件数量。
|
|
44
|
+
*/
|
|
45
|
+
function getShardFileName(event) {
|
|
46
|
+
const sessionId = (0, payload_1.readSessionId)(event.rawPayload);
|
|
47
|
+
if (sessionId) {
|
|
48
|
+
return `${sanitizeFilePart(sessionId)}.jsonl`;
|
|
49
|
+
}
|
|
50
|
+
return `${buildWorkspaceShardKey((0, payload_1.extractWorkspaceDir)(event.rawPayload))}.jsonl`;
|
|
51
|
+
}
|
|
52
|
+
/** 简单休眠,用于锁竞争时退避重试。 */
|
|
53
|
+
async function sleep(ms) {
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 判断锁文件是否已经过期。
|
|
58
|
+
*
|
|
59
|
+
* statusline 写入应该很快完成;如果锁长期存在,通常意味着写入进程已经异常退出。
|
|
60
|
+
*/
|
|
61
|
+
async function isStaleLock(lockPath) {
|
|
62
|
+
try {
|
|
63
|
+
const stats = await promises_1.default.stat(lockPath);
|
|
64
|
+
return Date.now() - stats.mtimeMs > STALE_LOCK_TIMEOUT_MS;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === "ENOENT") {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 用独占锁文件做跨进程互斥,避免多个 Claude statusline 进程同时写坏同一 shard。
|
|
75
|
+
*/
|
|
76
|
+
async function withFileLock(lockPath, action) {
|
|
77
|
+
for (let attempt = 0; attempt < LOCK_RETRY_TIMES; attempt += 1) {
|
|
78
|
+
try {
|
|
79
|
+
await promises_1.default.writeFile(lockPath, `${process.pid}\n${Date.now()}`, { encoding: "utf8", flag: "wx" });
|
|
80
|
+
try {
|
|
81
|
+
return await action();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await promises_1.default.rm(lockPath, { force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const code = error.code;
|
|
89
|
+
if (code !== "EEXIST") {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
if (await isStaleLock(lockPath)) {
|
|
93
|
+
await promises_1.default.rm(lockPath, { force: true });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
await sleep(LOCK_RETRY_DELAY_MS);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Failed to acquire log shard lock: ${lockPath}`);
|
|
100
|
+
}
|
|
101
|
+
/** 读侧只接受我们认识的最小事件结构,避免脏数据污染报表。 */
|
|
102
|
+
function isPersistedStatuslineEvent(value) {
|
|
103
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const candidate = value;
|
|
107
|
+
return typeof candidate.timestamp === "string" && typeof candidate.rawPayload === "object" && candidate.rawPayload !== null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 将事件追加到按天、按 session 分片的 JSONL 文件中。
|
|
111
|
+
*
|
|
112
|
+
* 这样既能减少碎文件数量,也能通过锁文件避免多进程同时写坏同一个 shard。
|
|
113
|
+
*/
|
|
114
|
+
async function appendEvent(dataDir, event) {
|
|
115
|
+
const eventsDir = (0, paths_1.getEventsDir)(dataDir);
|
|
116
|
+
await ensureDirectory(eventsDir);
|
|
117
|
+
const dateKey = (0, time_1.localDateKey)(new Date(event.timestamp));
|
|
118
|
+
const dayDirectory = getDayDirectory(eventsDir, dateKey);
|
|
119
|
+
await ensureDirectory(dayDirectory);
|
|
120
|
+
const filePath = node_path_1.default.join(dayDirectory, getShardFileName(event));
|
|
121
|
+
const lockPath = `${filePath}.lock`;
|
|
122
|
+
await withFileLock(lockPath, async () => {
|
|
123
|
+
await promises_1.default.appendFile(filePath, `${JSON.stringify(event)}\n`, "utf8");
|
|
124
|
+
});
|
|
125
|
+
return filePath;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 兼容旧 JSONL 数据:逐行解析,坏行直接跳过,不让整批读取失败。
|
|
129
|
+
*/
|
|
130
|
+
async function readEventsFromJsonl(filePath) {
|
|
131
|
+
try {
|
|
132
|
+
const content = await promises_1.default.readFile(filePath, "utf8");
|
|
133
|
+
return content
|
|
134
|
+
.split(/\r?\n/)
|
|
135
|
+
.map((line) => line.trim())
|
|
136
|
+
.filter((line) => line.length > 0)
|
|
137
|
+
.flatMap((line) => {
|
|
138
|
+
try {
|
|
139
|
+
const parsed = JSON.parse(line);
|
|
140
|
+
return isPersistedStatuslineEvent(parsed) ? [parsed] : [];
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error.code === "ENOENT") {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 读取单个事件文件时容忍损坏或被删掉的文件,返回 null 即可。
|
|
156
|
+
*/
|
|
157
|
+
async function readEventFile(filePath) {
|
|
158
|
+
try {
|
|
159
|
+
const content = await promises_1.default.readFile(filePath, "utf8");
|
|
160
|
+
const parsed = JSON.parse(content);
|
|
161
|
+
return isPersistedStatuslineEvent(parsed) ? parsed : null;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error.code === "ENOENT") {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** 扫描某一天目录下的所有事件文件。 */
|
|
171
|
+
async function readEventsFromDayDirectory(directoryPath) {
|
|
172
|
+
try {
|
|
173
|
+
const entries = await promises_1.default.readdir(directoryPath, { withFileTypes: true });
|
|
174
|
+
const eventFiles = entries
|
|
175
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json") && !entry.name.endsWith(".tmp"))
|
|
176
|
+
.map((entry) => node_path_1.default.join(directoryPath, entry.name));
|
|
177
|
+
const shardFiles = entries
|
|
178
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl") && !entry.name.endsWith(".lock"))
|
|
179
|
+
.map((entry) => node_path_1.default.join(directoryPath, entry.name));
|
|
180
|
+
const [jsonEvents, jsonlEvents] = await Promise.all([
|
|
181
|
+
Promise.all(eventFiles.map((filePath) => readEventFile(filePath))),
|
|
182
|
+
Promise.all(shardFiles.map((filePath) => readEventsFromJsonl(filePath))),
|
|
183
|
+
]);
|
|
184
|
+
return [...jsonEvents.filter((event) => event !== null), ...jsonlEvents.flat()];
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error.code === "ENOENT") {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* 读取指定时间窗口内的事件集合。
|
|
195
|
+
*
|
|
196
|
+
* 同时兼容“按天目录 JSON 文件”和“旧版 JSONL 文件”两种存储布局。
|
|
197
|
+
*/
|
|
198
|
+
async function readEventsForRange(dataDir, range, now = new Date()) {
|
|
199
|
+
const window = (0, time_1.resolveRange)(range, now);
|
|
200
|
+
const keys = (0, time_1.enumerateDateKeys)(window.start, window.end);
|
|
201
|
+
const eventsDir = (0, paths_1.getEventsDir)(dataDir);
|
|
202
|
+
const lists = await Promise.all(keys.map(async (key) => {
|
|
203
|
+
const [directoryEvents, legacyEvents] = await Promise.all([
|
|
204
|
+
readEventsFromDayDirectory(getDayDirectory(eventsDir, key)),
|
|
205
|
+
readEventsFromJsonl(getLegacyJsonlPath(eventsDir, key)),
|
|
206
|
+
]);
|
|
207
|
+
return [...directoryEvents, ...legacyEvents];
|
|
208
|
+
}));
|
|
209
|
+
return lists
|
|
210
|
+
.flat()
|
|
211
|
+
.filter((event) => {
|
|
212
|
+
const timestamp = new Date(event.timestamp).getTime();
|
|
213
|
+
return timestamp >= window.start.getTime() && timestamp <= window.end.getTime();
|
|
214
|
+
})
|
|
215
|
+
.sort((left, right) => new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime());
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=storage.js.map
|
package/dist/lib/time.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveRange = resolveRange;
|
|
4
|
+
exports.localDateKey = localDateKey;
|
|
5
|
+
exports.formatRangeFileLabel = formatRangeFileLabel;
|
|
6
|
+
exports.extractGitEmailAccount = extractGitEmailAccount;
|
|
7
|
+
exports.formatGitEmailFilePrefix = formatGitEmailFilePrefix;
|
|
8
|
+
exports.enumerateDateKeys = enumerateDateKeys;
|
|
9
|
+
exports.formatLocalTimestamp = formatLocalTimestamp;
|
|
10
|
+
exports.formatClock = formatClock;
|
|
11
|
+
exports.roundNumber = roundNumber;
|
|
12
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
13
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
14
|
+
/**
|
|
15
|
+
* 统一按本地时区切日,避免“今天 / 本周”与用户直觉不一致。
|
|
16
|
+
*/
|
|
17
|
+
function startOfLocalDay(date) {
|
|
18
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 以周一作为一周开始,便于输出 `this-week` 统计。
|
|
22
|
+
*/
|
|
23
|
+
function startOfLocalWeek(date) {
|
|
24
|
+
const day = date.getDay();
|
|
25
|
+
const diff = day === 0 ? -6 : 1 - day;
|
|
26
|
+
const start = new Date(date);
|
|
27
|
+
start.setDate(date.getDate() + diff);
|
|
28
|
+
return startOfLocalDay(start);
|
|
29
|
+
}
|
|
30
|
+
/** range 短别名:保持解析后的 label 仍为规范名,避免影响文件名与 bundle 契约。 */
|
|
31
|
+
const RANGE_ALIASES = {
|
|
32
|
+
lw: "last-week",
|
|
33
|
+
tw: "this-week",
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* 解析 CLI 传入的时间范围。
|
|
37
|
+
*
|
|
38
|
+
* 当前支持:`today`、`this-week`(简写 `tw`)、`last-week`(简写 `lw`)、`5h`、`30m` 这类相对窗口。
|
|
39
|
+
*/
|
|
40
|
+
function resolveRange(range, now = new Date()) {
|
|
41
|
+
const raw = (range ?? "5h").trim().toLowerCase();
|
|
42
|
+
const normalized = RANGE_ALIASES[raw] ?? raw;
|
|
43
|
+
if (normalized === "today") {
|
|
44
|
+
return { label: "today", start: startOfLocalDay(now), end: now };
|
|
45
|
+
}
|
|
46
|
+
if (normalized === "this-week") {
|
|
47
|
+
return { label: "this-week", start: startOfLocalWeek(now), end: now };
|
|
48
|
+
}
|
|
49
|
+
if (normalized === "last-week") {
|
|
50
|
+
const thisWeekStart = startOfLocalWeek(now);
|
|
51
|
+
const start = new Date(thisWeekStart);
|
|
52
|
+
start.setDate(start.getDate() - 7);
|
|
53
|
+
const end = new Date(thisWeekStart.getTime() - 1);
|
|
54
|
+
return { label: "last-week", start, end };
|
|
55
|
+
}
|
|
56
|
+
const match = normalized.match(/^(\d+)([hm])$/);
|
|
57
|
+
if (!match) {
|
|
58
|
+
throw new Error(`Unsupported range: ${range ?? ""}`);
|
|
59
|
+
}
|
|
60
|
+
const amount = Number.parseInt(match[1], 10);
|
|
61
|
+
const unit = match[2];
|
|
62
|
+
const duration = unit === "h" ? amount * HOUR_MS : amount * 60 * 1000;
|
|
63
|
+
return {
|
|
64
|
+
label: normalized,
|
|
65
|
+
start: new Date(now.getTime() - duration),
|
|
66
|
+
end: now,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 把日期映射成稳定的本地日键,用于事件按天分桶存储。
|
|
71
|
+
*/
|
|
72
|
+
function localDateKey(date) {
|
|
73
|
+
const year = date.getFullYear();
|
|
74
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
75
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
76
|
+
return `${year}-${month}-${day}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 把时间窗口渲染成用于文件名的起止日期标记。
|
|
80
|
+
*/
|
|
81
|
+
function formatRangeFileLabel(start, end) {
|
|
82
|
+
return `${localDateKey(start)}_to_${localDateKey(end)}`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 从 git email 中提取规范化的帐号名(@ 之前的部分,小写并清洗特殊字符)。
|
|
86
|
+
*
|
|
87
|
+
* 这是身份层使用的人类可读用户名,被持久化事件和 aggregate personKey 共用。
|
|
88
|
+
*/
|
|
89
|
+
function extractGitEmailAccount(email) {
|
|
90
|
+
if (!email) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const localPart = email.split("@")[0]?.trim().toLowerCase();
|
|
94
|
+
if (!localPart) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const sanitized = localPart.replaceAll(/[^a-z0-9._-]+/g, "-").replaceAll(/-+/g, "-").replaceAll(/^[.-]+|[.-]+$/g, "");
|
|
98
|
+
return sanitized.length === 0 ? null : sanitized;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 从 git email 中提取适合放进文件名的帐号名前缀。
|
|
102
|
+
*
|
|
103
|
+
* 在 `extractGitEmailAccount` 基础上再排除 Windows 保留名,避免生成不可用的文件名。
|
|
104
|
+
*/
|
|
105
|
+
function formatGitEmailFilePrefix(email) {
|
|
106
|
+
const account = extractGitEmailAccount(email);
|
|
107
|
+
if (!account) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(account)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return account;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 生成一个时间窗口内所有涉及的日期键,供批量读取事件文件时使用。
|
|
117
|
+
*/
|
|
118
|
+
function enumerateDateKeys(start, end) {
|
|
119
|
+
const keys = [];
|
|
120
|
+
const cursor = startOfLocalDay(start);
|
|
121
|
+
while (cursor.getTime() <= end.getTime()) {
|
|
122
|
+
keys.push(localDateKey(cursor));
|
|
123
|
+
cursor.setTime(cursor.getTime() + DAY_MS);
|
|
124
|
+
}
|
|
125
|
+
return keys;
|
|
126
|
+
}
|
|
127
|
+
/** dashboard 和表格中展示的人类可读时间。 */
|
|
128
|
+
function formatLocalTimestamp(date) {
|
|
129
|
+
return new Intl.DateTimeFormat("zh-CN", {
|
|
130
|
+
hour: "2-digit",
|
|
131
|
+
minute: "2-digit",
|
|
132
|
+
month: "2-digit",
|
|
133
|
+
day: "2-digit",
|
|
134
|
+
}).format(date);
|
|
135
|
+
}
|
|
136
|
+
/** statusline 上更紧凑的时间格式。 */
|
|
137
|
+
function formatClock(date) {
|
|
138
|
+
return new Intl.DateTimeFormat("zh-CN", {
|
|
139
|
+
hour: "2-digit",
|
|
140
|
+
minute: "2-digit",
|
|
141
|
+
hour12: false,
|
|
142
|
+
}).format(date);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 统一做数值舍入,避免不同模块各自处理导致展示不一致。
|
|
146
|
+
*/
|
|
147
|
+
function roundNumber(value, digits = 1) {
|
|
148
|
+
if (value === null || Number.isNaN(value)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const factor = 10 ** digits;
|
|
152
|
+
return Math.round(value * factor) / factor;
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=time.js.map
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ccus-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code statusline usage logger and dashboard CLI",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ccus": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/cli.js",
|
|
11
|
+
"dist/lib/**/*.js",
|
|
12
|
+
"dist/types.js"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"prepublishOnly": "npm run build",
|
|
17
|
+
"test": "node --test dist/test",
|
|
18
|
+
"test:src": "node --import tsx --test src/test/payload.test.ts src/test/dashboard.test.ts src/test/export.test.ts src/test/storage.test.ts src/test/claude.test.ts src/test/aggregate.test.ts src/test/aggregate-dashboard.test.ts src/test/install.test.ts src/test/debug.test.ts"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"claude-code",
|
|
22
|
+
"statusline",
|
|
23
|
+
"cli",
|
|
24
|
+
"dashboard"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^24.0.0",
|
|
32
|
+
"tsx": "^4.20.0",
|
|
33
|
+
"typescript": "^5.8.3"
|
|
34
|
+
}
|
|
35
|
+
}
|