glad-web 1.0.46 → 2.0.2
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 +4 -192
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/bin/glad.cjs +56 -0
- package/package.json +19 -61
- package/README.zh-CN.md +0 -198
- package/assets/logo.svg +0 -43
- package/bin/cli.js +0 -65
- package/lib/ai-tools/demo/enhanced-demo.js +0 -625
- package/lib/ai-tools/demo/index.js +0 -24
- package/lib/ai-tools/demo/responses.js +0 -88
- package/lib/ai-tools/detector.js +0 -76
- package/lib/ai-tools/registry.js +0 -300
- package/lib/claude/cli-usage.js +0 -95
- package/lib/claude/config.js +0 -82
- package/lib/claude/structured-session.js +0 -884
- package/lib/claude/transcript-repository.js +0 -216
- package/lib/codex/image-store.js +0 -174
- package/lib/codex/structured-session.js +0 -1590
- package/lib/commands/config.js +0 -78
- package/lib/commands/tools.js +0 -128
- package/lib/commands/web.js +0 -605
- package/lib/config/constants.js +0 -17
- package/lib/config/manager.js +0 -108
- package/lib/git/service.js +0 -83
- package/lib/notifications/message-formatter.js +0 -94
- package/lib/notifications/notification-service.js +0 -143
- package/lib/notifications/serverchan-client.js +0 -58
- package/lib/notifications/serverchan-settings-store.js +0 -115
- package/lib/schedule/job-runner.js +0 -162
- package/lib/schedule/job-store.js +0 -167
- package/lib/schedule/key-sequences.js +0 -49
- package/lib/schedule/scheduler-service.js +0 -39
- package/lib/server/routes/notifications.js +0 -52
- package/lib/server/routes/providers.js +0 -114
- package/lib/server/routes/schedules.js +0 -54
- package/lib/server/routes/skillhub.js +0 -104
- package/lib/server/routes/usage.js +0 -23
- package/lib/server/routes/workspace.js +0 -77
- package/lib/session/buffer.js +0 -102
- package/lib/session/file-attachment-store.js +0 -168
- package/lib/session/pty-manager.js +0 -255
- package/lib/session/rendered-history.js +0 -225
- package/lib/session/session-manager.js +0 -1032
- package/lib/session/text-history.js +0 -274
- package/lib/skillhub/client.js +0 -121
- package/lib/skillhub/settings-store.js +0 -168
- package/lib/skillhub/skill-installer.js +0 -320
- package/lib/usage/ccusage-runner.js +0 -128
- package/lib/usage/source-catalog.js +0 -26
- package/lib/usage/usage-service.js +0 -226
- package/lib/utils/logger.js +0 -74
- package/lib/utils/pid.js +0 -67
- package/lib/utils/validation.js +0 -53
- package/lib/web/bootstrap.js +0 -34
- package/lib/web/claude.js +0 -1150
- package/lib/web/codex.js +0 -1045
- package/lib/web/composer.js +0 -493
- package/lib/web/core.js +0 -385
- package/lib/web/git.js +0 -535
- package/lib/web/gitgraph.js +0 -293
- package/lib/web/index.html +0 -547
- package/lib/web/layout.js +0 -69
- package/lib/web/notifications.js +0 -164
- package/lib/web/schedules.js +0 -245
- package/lib/web/session.js +0 -361
- package/lib/web/shell.js +0 -74
- package/lib/web/skillhub.js +0 -197
- package/lib/web/styles.css +0 -932
- package/lib/web/terminal-scroll.js +0 -81
- package/lib/web/theme.js +0 -60
- package/lib/web/timed-inputs.js +0 -216
- package/lib/web/usage.js +0 -323
- package/lib/workspace/service.js +0 -77
- package/scripts/check-syntax.js +0 -26
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
const crypto = require('crypto');
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const { spawnSync } = require('child_process');
|
|
5
|
-
const yauzl = require('yauzl');
|
|
6
|
-
const YAML = require('yaml');
|
|
7
|
-
|
|
8
|
-
const MAX_FILES = 256;
|
|
9
|
-
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
10
|
-
const MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
11
|
-
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
12
|
-
|
|
13
|
-
function installProblem(message, statusCode = 400, code = 'SKILLHUB_INVALID_BUNDLE') {
|
|
14
|
-
const error = new Error(message);
|
|
15
|
-
error.statusCode = statusCode;
|
|
16
|
-
error.code = code;
|
|
17
|
-
return error;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function safeRelativePath(value) {
|
|
21
|
-
const raw = String(value || '');
|
|
22
|
-
if (!raw || raw.includes('\\') || raw.includes('\0') || raw.startsWith('/')) return null;
|
|
23
|
-
const normalized = path.posix.normalize(raw);
|
|
24
|
-
if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) return null;
|
|
25
|
-
if (normalized.split('/').some(part => !part || part === '.' || part === '..')) return null;
|
|
26
|
-
return normalized;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function parseSkillName(markdown) {
|
|
30
|
-
const frontmatter = String(markdown || '').match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
31
|
-
if (!frontmatter) throw installProblem('SKILL.md 缺少 frontmatter');
|
|
32
|
-
const match = frontmatter[1].match(/^name\s*:\s*(.+?)\s*$/m);
|
|
33
|
-
if (!match) throw installProblem('SKILL.md 缺少 name');
|
|
34
|
-
const name = match[1].trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
35
|
-
if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(name)) {
|
|
36
|
-
throw installProblem('SKILL.md name 格式无效');
|
|
37
|
-
}
|
|
38
|
-
return name;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function parseDefaultPrompt(content) {
|
|
42
|
-
try {
|
|
43
|
-
const document = YAML.parse(String(content || ''));
|
|
44
|
-
const prompt = document?.interface?.default_prompt;
|
|
45
|
-
if (typeof prompt !== 'string') return '';
|
|
46
|
-
const normalized = prompt.trim();
|
|
47
|
-
return normalized.length <= 8000 ? normalized : '';
|
|
48
|
-
} catch (_) {
|
|
49
|
-
return '';
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function mountTmpfs(root) {
|
|
54
|
-
try {
|
|
55
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
56
|
-
const gid = typeof process.getgid === 'function' ? process.getgid() : 0;
|
|
57
|
-
const result = spawnSync('mount', [
|
|
58
|
-
'-t', 'tmpfs',
|
|
59
|
-
'-o', `rw,nosuid,nodev,noexec,size=128m,mode=0700,uid=${uid},gid=${gid}`,
|
|
60
|
-
'tmpfs', root
|
|
61
|
-
], { stdio: 'ignore', timeout: 2000 });
|
|
62
|
-
return result.status === 0;
|
|
63
|
-
} catch (_) {
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function openZip(buffer) {
|
|
69
|
-
return new Promise((resolve, reject) => {
|
|
70
|
-
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => {
|
|
71
|
-
if (error) reject(installProblem(`Skill bundle 无法解析:${error.message}`));
|
|
72
|
-
else resolve(zip);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function readEntry(zip, entry) {
|
|
78
|
-
return new Promise((resolve, reject) => {
|
|
79
|
-
zip.openReadStream(entry, (error, stream) => {
|
|
80
|
-
if (error) return reject(error);
|
|
81
|
-
const chunks = [];
|
|
82
|
-
let size = 0;
|
|
83
|
-
stream.on('data', chunk => {
|
|
84
|
-
size += chunk.length;
|
|
85
|
-
if (size > MAX_FILE_BYTES) stream.destroy(installProblem('Skill 单文件超过 10 MB', 413));
|
|
86
|
-
else chunks.push(chunk);
|
|
87
|
-
});
|
|
88
|
-
stream.once('error', reject);
|
|
89
|
-
stream.once('end', () => resolve(Buffer.concat(chunks)));
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function extractVerifiedBundle(buffer, manifest, destination) {
|
|
95
|
-
const declaredFiles = Array.isArray(manifest?.files) ? manifest.files : [];
|
|
96
|
-
if (!declaredFiles.length || declaredFiles.length > MAX_FILES) {
|
|
97
|
-
throw installProblem(`Skill manifest 文件数必须在 1-${MAX_FILES} 之间`);
|
|
98
|
-
}
|
|
99
|
-
const expected = new Map();
|
|
100
|
-
let declaredTotal = 0;
|
|
101
|
-
for (const file of declaredFiles) {
|
|
102
|
-
const filePath = safeRelativePath(file?.path);
|
|
103
|
-
const size = Number(file?.size);
|
|
104
|
-
const sha256 = String(file?.sha256 || '').toLowerCase();
|
|
105
|
-
if (!filePath || !Number.isSafeInteger(size) || size < 0 || size > MAX_FILE_BYTES
|
|
106
|
-
|| !/^[0-9a-f]{64}$/.test(sha256) || expected.has(filePath)) {
|
|
107
|
-
throw installProblem('Skill manifest 文件声明无效');
|
|
108
|
-
}
|
|
109
|
-
declaredTotal += size;
|
|
110
|
-
if (declaredTotal > MAX_TOTAL_BYTES) throw installProblem('Skill 文件总大小超过 20 MB', 413);
|
|
111
|
-
expected.set(filePath, { size, sha256 });
|
|
112
|
-
}
|
|
113
|
-
if (!expected.has('SKILL.md')) throw installProblem('Skill bundle 缺少 SKILL.md');
|
|
114
|
-
|
|
115
|
-
await fs.promises.mkdir(destination, { recursive: true, mode: 0o700 });
|
|
116
|
-
const zip = await openZip(buffer);
|
|
117
|
-
const written = new Set();
|
|
118
|
-
let archivePrefix = null;
|
|
119
|
-
try {
|
|
120
|
-
await new Promise((resolve, reject) => {
|
|
121
|
-
zip.once('error', reject);
|
|
122
|
-
zip.once('end', resolve);
|
|
123
|
-
zip.on('entry', entry => {
|
|
124
|
-
void (async () => {
|
|
125
|
-
const rawName = String(entry.fileName || '');
|
|
126
|
-
if (rawName.endsWith('/')) {
|
|
127
|
-
const directory = safeRelativePath(rawName.slice(0, -1));
|
|
128
|
-
if (!directory) throw installProblem(`Skill bundle 包含不安全目录:${rawName}`);
|
|
129
|
-
zip.readEntry();
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
let filePath = safeRelativePath(rawName);
|
|
133
|
-
if (filePath && !expected.has(filePath)) {
|
|
134
|
-
const separator = filePath.indexOf('/');
|
|
135
|
-
const prefix = separator > 0 ? filePath.slice(0, separator) : '';
|
|
136
|
-
const nested = separator > 0 ? filePath.slice(separator + 1) : '';
|
|
137
|
-
if (prefix && expected.has(nested) && (archivePrefix == null || archivePrefix === prefix)) {
|
|
138
|
-
archivePrefix = prefix;
|
|
139
|
-
filePath = nested;
|
|
140
|
-
}
|
|
141
|
-
} else if (filePath && expected.has(filePath)) {
|
|
142
|
-
if (archivePrefix && archivePrefix !== '') {
|
|
143
|
-
throw installProblem('Skill bundle 同时包含带前缀和无前缀文件');
|
|
144
|
-
}
|
|
145
|
-
if (archivePrefix == null) archivePrefix = '';
|
|
146
|
-
}
|
|
147
|
-
const unixType = (entry.externalFileAttributes >>> 16) & 0xf000;
|
|
148
|
-
if (!filePath || unixType === 0xa000 || !expected.has(filePath) || written.has(filePath)) {
|
|
149
|
-
throw installProblem(`Skill bundle 包含未声明或不安全文件:${rawName}`);
|
|
150
|
-
}
|
|
151
|
-
const content = await readEntry(zip, entry);
|
|
152
|
-
const declared = expected.get(filePath);
|
|
153
|
-
const digest = crypto.createHash('sha256').update(content).digest('hex');
|
|
154
|
-
if (content.length !== declared.size || digest !== declared.sha256) {
|
|
155
|
-
throw installProblem(`Skill 文件校验失败:${filePath}`);
|
|
156
|
-
}
|
|
157
|
-
const target = path.join(destination, ...filePath.split('/'));
|
|
158
|
-
await fs.promises.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
159
|
-
const originalMode = (entry.externalFileAttributes >>> 16) & 0o777;
|
|
160
|
-
const mode = originalMode & 0o111 ? 0o700 : 0o600;
|
|
161
|
-
await fs.promises.writeFile(target, content, { mode, flag: 'wx' });
|
|
162
|
-
written.add(filePath);
|
|
163
|
-
zip.readEntry();
|
|
164
|
-
})().catch(reject);
|
|
165
|
-
});
|
|
166
|
-
zip.readEntry();
|
|
167
|
-
});
|
|
168
|
-
} finally {
|
|
169
|
-
try { zip.close(); } catch (_) { /* already closed */ }
|
|
170
|
-
}
|
|
171
|
-
if (written.size !== expected.size) {
|
|
172
|
-
const missing = [...expected.keys()].filter(item => !written.has(item));
|
|
173
|
-
throw installProblem(`Skill bundle 缺少文件:${missing.join(', ')}`);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
class SkillInstaller {
|
|
178
|
-
constructor({
|
|
179
|
-
client,
|
|
180
|
-
root = process.env.GLAD_SKILL_SESSION_ROOT || '/run/glad-skill-sessions',
|
|
181
|
-
readMounts = () => fs.readFileSync('/proc/mounts', 'utf8'),
|
|
182
|
-
tryMountTmpfs = mountTmpfs,
|
|
183
|
-
available = null
|
|
184
|
-
} = {}) {
|
|
185
|
-
this.client = client;
|
|
186
|
-
this.root = path.resolve(root);
|
|
187
|
-
this.readMounts = readMounts;
|
|
188
|
-
this.tryMountTmpfs = tryMountTmpfs;
|
|
189
|
-
this.available = available;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
sessionRoot(sessionId) {
|
|
193
|
-
if (!SESSION_ID.test(String(sessionId || ''))) throw installProblem('Glad Session ID 无效');
|
|
194
|
-
return path.join(this.root, sessionId);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
async initialize() {
|
|
198
|
-
this.available = this.isTmpfsMounted();
|
|
199
|
-
if (!this.available) {
|
|
200
|
-
let created = false;
|
|
201
|
-
try {
|
|
202
|
-
if (!fs.existsSync(this.root)) {
|
|
203
|
-
await fs.promises.mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
204
|
-
created = true;
|
|
205
|
-
}
|
|
206
|
-
this.tryMountTmpfs(this.root);
|
|
207
|
-
this.available = this.isTmpfsMounted();
|
|
208
|
-
} catch (_) {
|
|
209
|
-
this.available = false;
|
|
210
|
-
}
|
|
211
|
-
if (!this.available) {
|
|
212
|
-
if (created) {
|
|
213
|
-
try { fs.rmdirSync(this.root); } catch (_) { /* 只清理本次创建的空目录 */ }
|
|
214
|
-
}
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
await fs.promises.mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
219
|
-
const entries = await fs.promises.readdir(this.root, { withFileTypes: true });
|
|
220
|
-
for (const entry of entries) {
|
|
221
|
-
if (entry.isDirectory() && SESSION_ID.test(entry.name)) {
|
|
222
|
-
await fs.promises.rm(path.join(this.root, entry.name), { recursive: true, force: true });
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
return true;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
isTmpfsMounted() {
|
|
229
|
-
try {
|
|
230
|
-
const rows = String(this.readMounts() || '').split(/\r?\n/);
|
|
231
|
-
return rows.some(row => {
|
|
232
|
-
const fields = row.trim().split(/\s+/);
|
|
233
|
-
const mountPoint = String(fields[1] || '').replace(/\\040/g, ' ');
|
|
234
|
-
return mountPoint === this.root && fields[2] === 'tmpfs';
|
|
235
|
-
});
|
|
236
|
-
} catch (_) {
|
|
237
|
-
return false;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
assertAvailable() {
|
|
242
|
-
if (this.available === true) return;
|
|
243
|
-
throw installProblem('Skill暂不可用', 503, 'SKILL_TEMPORARILY_UNAVAILABLE');
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
async prepare(sessionId, selection) {
|
|
247
|
-
this.assertAvailable();
|
|
248
|
-
const id = String(selection?.id || '').trim();
|
|
249
|
-
if (!/^[0-9a-f-]{36}$/i.test(id)) throw installProblem('Skill ID 无效');
|
|
250
|
-
const detail = await this.client.getSkill({
|
|
251
|
-
id,
|
|
252
|
-
version: String(selection?.version || ''),
|
|
253
|
-
digest: String(selection?.digest || '')
|
|
254
|
-
});
|
|
255
|
-
if (!detail?.manifest || detail.id !== id || !detail.version || !detail.digest) {
|
|
256
|
-
throw installProblem('SkillHub Skill 详情格式无效', 502);
|
|
257
|
-
}
|
|
258
|
-
if (selection.version && detail.version !== selection.version) throw installProblem('Skill 版本已变更', 409);
|
|
259
|
-
if (selection.digest && detail.digest !== selection.digest) throw installProblem('Skill 内容已变更', 409);
|
|
260
|
-
if (detail.manifest.manifestDigest !== detail.digest) throw installProblem('Skill manifest digest 不一致', 502);
|
|
261
|
-
|
|
262
|
-
const downloaded = await this.client.downloadBundle({
|
|
263
|
-
id,
|
|
264
|
-
version: detail.version,
|
|
265
|
-
digest: detail.digest
|
|
266
|
-
});
|
|
267
|
-
if (downloaded.digest && downloaded.digest !== detail.digest) {
|
|
268
|
-
throw installProblem('Skill bundle digest 不一致', 502);
|
|
269
|
-
}
|
|
270
|
-
if (downloaded.sha256) {
|
|
271
|
-
const actual = crypto.createHash('sha256').update(downloaded.buffer).digest('hex');
|
|
272
|
-
if (actual !== downloaded.sha256.toLowerCase()) throw installProblem('Skill bundle SHA256 校验失败', 502);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const sessionRoot = this.sessionRoot(sessionId);
|
|
276
|
-
const skillsRoot = path.join(sessionRoot, 'skills');
|
|
277
|
-
const temporary = path.join(sessionRoot, `.extract-${crypto.randomUUID()}`);
|
|
278
|
-
const skillDirectory = path.join(skillsRoot, id);
|
|
279
|
-
try {
|
|
280
|
-
await fs.promises.mkdir(sessionRoot, { recursive: true, mode: 0o700 });
|
|
281
|
-
await extractVerifiedBundle(downloaded.buffer, detail.manifest, temporary);
|
|
282
|
-
const skillMd = await fs.promises.readFile(path.join(temporary, 'SKILL.md'), 'utf8');
|
|
283
|
-
const name = parseSkillName(skillMd);
|
|
284
|
-
let defaultPrompt = '';
|
|
285
|
-
const openAIMetadata = detail.manifest.files.find(file => file?.path === 'agents/openai.yaml');
|
|
286
|
-
if (openAIMetadata && Number(openAIMetadata.size) <= 64 * 1024) {
|
|
287
|
-
const metadata = await fs.promises.readFile(path.join(temporary, 'agents/openai.yaml'), 'utf8');
|
|
288
|
-
defaultPrompt = parseDefaultPrompt(metadata);
|
|
289
|
-
}
|
|
290
|
-
await fs.promises.mkdir(skillsRoot, { recursive: true, mode: 0o700 });
|
|
291
|
-
await fs.promises.rename(temporary, skillDirectory);
|
|
292
|
-
return {
|
|
293
|
-
id,
|
|
294
|
-
name,
|
|
295
|
-
version: detail.version,
|
|
296
|
-
digest: detail.digest,
|
|
297
|
-
defaultPrompt,
|
|
298
|
-
skillsRoot,
|
|
299
|
-
path: path.join(skillDirectory, 'SKILL.md')
|
|
300
|
-
};
|
|
301
|
-
} catch (error) {
|
|
302
|
-
await fs.promises.rm(sessionRoot, { recursive: true, force: true });
|
|
303
|
-
throw error;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
cleanupSync(sessionId) {
|
|
308
|
-
if (this.available === false) return;
|
|
309
|
-
const target = this.sessionRoot(sessionId);
|
|
310
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
module.exports = {
|
|
315
|
-
SkillInstaller,
|
|
316
|
-
extractVerifiedBundle,
|
|
317
|
-
parseSkillName,
|
|
318
|
-
safeRelativePath,
|
|
319
|
-
parseDefaultPrompt
|
|
320
|
-
};
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
const { spawn } = require('child_process');
|
|
2
|
-
const { chmodSync, statSync } = require('fs');
|
|
3
|
-
|
|
4
|
-
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
5
|
-
const DEFAULT_TIMEOUT_MS = 45000;
|
|
6
|
-
|
|
7
|
-
const NATIVE_PACKAGES = {
|
|
8
|
-
'darwin-arm64': '@ccusage/ccusage-darwin-arm64',
|
|
9
|
-
'darwin-x64': '@ccusage/ccusage-darwin-x64',
|
|
10
|
-
'linux-arm64': '@ccusage/ccusage-linux-arm64',
|
|
11
|
-
'linux-x64': '@ccusage/ccusage-linux-x64',
|
|
12
|
-
'win32-arm64': '@ccusage/ccusage-win32-arm64',
|
|
13
|
-
'win32-x64': '@ccusage/ccusage-win32-x64'
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
function resolveCcusageBinary(platform = process.platform, arch = process.arch) {
|
|
17
|
-
const packageName = NATIVE_PACKAGES[`${platform}-${arch}`];
|
|
18
|
-
if (!packageName) {
|
|
19
|
-
throw new Error(`ccusage is not available for ${platform}-${arch}`);
|
|
20
|
-
}
|
|
21
|
-
const binaryName = platform === 'win32' ? 'ccusage.exe' : 'ccusage';
|
|
22
|
-
try {
|
|
23
|
-
return require.resolve(`${packageName}/bin/${binaryName}`);
|
|
24
|
-
} catch (_error) {
|
|
25
|
-
throw new Error(`ccusage native package is missing for ${platform}-${arch}`);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function ensureCcusageBinaryExecutable(binaryPath, options = {}) {
|
|
30
|
-
const platform = options.platform || process.platform;
|
|
31
|
-
if (platform === 'win32') return binaryPath;
|
|
32
|
-
|
|
33
|
-
const statPath = options.statPath || statSync;
|
|
34
|
-
const chmodPath = options.chmodPath || chmodSync;
|
|
35
|
-
try {
|
|
36
|
-
// ccusage's platform packages can be installed without execute bits. Its JS
|
|
37
|
-
// wrapper repairs them too, but this runner intentionally spawns the native binary.
|
|
38
|
-
if ((statPath(binaryPath).mode & 0o111) === 0) chmodPath(binaryPath, 0o755);
|
|
39
|
-
return binaryPath;
|
|
40
|
-
} catch (error) {
|
|
41
|
-
throw new Error(`ccusage native binary is not executable: ${error.message}`);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function reportArgs(timezone) {
|
|
46
|
-
return [
|
|
47
|
-
'daily',
|
|
48
|
-
'--sections', 'daily,weekly,monthly',
|
|
49
|
-
'--by-agent',
|
|
50
|
-
'--json',
|
|
51
|
-
'--offline',
|
|
52
|
-
'--timezone', timezone
|
|
53
|
-
];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
class CcusageRunner {
|
|
57
|
-
constructor(options = {}) {
|
|
58
|
-
this.binaryPath = options.binaryPath
|
|
59
|
-
|| ensureCcusageBinaryExecutable(resolveCcusageBinary());
|
|
60
|
-
this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
61
|
-
this.spawnProcess = options.spawnProcess || spawn;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
loadAllPeriods(timezone) {
|
|
65
|
-
return this.runJson(reportArgs(timezone));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
runJson(args) {
|
|
69
|
-
return new Promise((resolve, reject) => {
|
|
70
|
-
const child = this.spawnProcess(this.binaryPath, args, {
|
|
71
|
-
env: { ...process.env, NO_COLOR: '1' },
|
|
72
|
-
shell: false,
|
|
73
|
-
windowsHide: true
|
|
74
|
-
});
|
|
75
|
-
const stdout = [];
|
|
76
|
-
const stderr = [];
|
|
77
|
-
let outputBytes = 0;
|
|
78
|
-
let stderrBytes = 0;
|
|
79
|
-
let settled = false;
|
|
80
|
-
|
|
81
|
-
const finish = callback => {
|
|
82
|
-
if (settled) return;
|
|
83
|
-
settled = true;
|
|
84
|
-
clearTimeout(timer);
|
|
85
|
-
callback();
|
|
86
|
-
};
|
|
87
|
-
const timer = setTimeout(() => {
|
|
88
|
-
child.kill();
|
|
89
|
-
finish(() => reject(new Error('ccusage timed out while reading local usage data')));
|
|
90
|
-
}, this.timeoutMs);
|
|
91
|
-
|
|
92
|
-
child.stdout.on('data', chunk => {
|
|
93
|
-
outputBytes += chunk.length;
|
|
94
|
-
if (outputBytes > MAX_OUTPUT_BYTES) {
|
|
95
|
-
child.kill();
|
|
96
|
-
finish(() => reject(new Error('ccusage report exceeded the safe output limit')));
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
stdout.push(chunk);
|
|
100
|
-
});
|
|
101
|
-
child.stderr.on('data', chunk => {
|
|
102
|
-
if (stderrBytes >= 64 * 1024) return;
|
|
103
|
-
stderr.push(chunk);
|
|
104
|
-
stderrBytes += chunk.length;
|
|
105
|
-
});
|
|
106
|
-
child.on('error', error => finish(() => reject(new Error(`Unable to start ccusage: ${error.message}`))));
|
|
107
|
-
child.on('close', code => finish(() => {
|
|
108
|
-
const errorText = Buffer.concat(stderr).toString('utf8').trim();
|
|
109
|
-
if (code !== 0) {
|
|
110
|
-
reject(new Error(errorText || `ccusage exited with code ${code}`));
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
try {
|
|
114
|
-
resolve(JSON.parse(Buffer.concat(stdout).toString('utf8')));
|
|
115
|
-
} catch (_error) {
|
|
116
|
-
reject(new Error('ccusage returned invalid JSON'));
|
|
117
|
-
}
|
|
118
|
-
}));
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
module.exports = {
|
|
124
|
-
CcusageRunner,
|
|
125
|
-
ensureCcusageBinaryExecutable,
|
|
126
|
-
reportArgs,
|
|
127
|
-
resolveCcusageBinary
|
|
128
|
-
};
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
const SOURCES = [
|
|
2
|
-
{ id: 'codex', label: 'Codex', badge: 'CX' },
|
|
3
|
-
{ id: 'claude', label: 'Claude', badge: 'CL' },
|
|
4
|
-
{ id: 'gemini', label: 'Gemini', badge: 'GE' },
|
|
5
|
-
{ id: 'opencode', label: 'OpenCode', badge: 'OC' },
|
|
6
|
-
{ id: 'copilot', label: 'Copilot', badge: 'CP' },
|
|
7
|
-
{ id: 'amp', label: 'Amp', badge: 'AM' },
|
|
8
|
-
{ id: 'droid', label: 'Droid', badge: 'DR' },
|
|
9
|
-
{ id: 'codebuff', label: 'Codebuff', badge: 'CB' },
|
|
10
|
-
{ id: 'hermes', label: 'Hermes', badge: 'HE' },
|
|
11
|
-
{ id: 'pi', label: 'Pi', badge: 'PI' },
|
|
12
|
-
{ id: 'goose', label: 'Goose', badge: 'GO' },
|
|
13
|
-
{ id: 'kilo', label: 'Kilo', badge: 'KI' },
|
|
14
|
-
{ id: 'kimi', label: 'Kimi', badge: 'KM' },
|
|
15
|
-
{ id: 'qwen', label: 'Qwen', badge: 'QW' },
|
|
16
|
-
{ id: 'openclaw', label: 'OpenClaw', badge: 'OA' },
|
|
17
|
-
{ id: 'grok', label: 'Grok', badge: 'GR' }
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
const SOURCE_BY_ID = new Map(SOURCES.map(source => [source.id, source]));
|
|
21
|
-
|
|
22
|
-
function getUsageSource(id) {
|
|
23
|
-
return SOURCE_BY_ID.get(String(id || '').toLowerCase()) || null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
module.exports = { SOURCES, getUsageSource };
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
const { CcusageRunner } = require('./ccusage-runner');
|
|
2
|
-
const { SOURCES, getUsageSource } = require('./source-catalog');
|
|
3
|
-
|
|
4
|
-
const SCOPES = new Set(['weekly', 'monthly']);
|
|
5
|
-
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
6
|
-
|
|
7
|
-
function defaultTimezone() {
|
|
8
|
-
try {
|
|
9
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
10
|
-
} catch (_error) {
|
|
11
|
-
return 'UTC';
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function ccusageVersion() {
|
|
16
|
-
try {
|
|
17
|
-
return require('ccusage/package.json').version;
|
|
18
|
-
} catch (_error) {
|
|
19
|
-
return 'unknown';
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function positiveNumber(value) {
|
|
24
|
-
const number = Number(value);
|
|
25
|
-
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function isGptModel(modelName) {
|
|
29
|
-
return /^gpt(?:-|$)/i.test(String(modelName || ''));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function modelCost(sourceId, modelName, cost) {
|
|
33
|
-
if (sourceId !== 'codex' || !isGptModel(modelName)) return null;
|
|
34
|
-
const value = positiveNumber(cost);
|
|
35
|
-
return value > 0 ? value : null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function normalizeModel(sourceId, breakdown) {
|
|
39
|
-
const uncachedInputTokens = positiveNumber(breakdown.inputTokens)
|
|
40
|
-
+ positiveNumber(breakdown.cacheCreationTokens);
|
|
41
|
-
const cachedInputTokens = positiveNumber(breakdown.cacheReadTokens);
|
|
42
|
-
const outputTokens = positiveNumber(breakdown.outputTokens);
|
|
43
|
-
return {
|
|
44
|
-
modelName: String(breakdown.modelName || 'Unknown'),
|
|
45
|
-
uncachedInputTokens,
|
|
46
|
-
cachedInputTokens,
|
|
47
|
-
outputTokens,
|
|
48
|
-
totalTokens: uncachedInputTokens + cachedInputTokens + outputTokens,
|
|
49
|
-
estimatedCostUSD: modelCost(sourceId, breakdown.modelName, breakdown.cost)
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function sumModels(models) {
|
|
54
|
-
return models.reduce((totals, model) => {
|
|
55
|
-
totals.uncachedInputTokens += model.uncachedInputTokens;
|
|
56
|
-
totals.cachedInputTokens += model.cachedInputTokens;
|
|
57
|
-
totals.outputTokens += model.outputTokens;
|
|
58
|
-
totals.totalTokens += model.totalTokens;
|
|
59
|
-
if (model.estimatedCostUSD !== null) {
|
|
60
|
-
totals.estimatedCostUSD = (totals.estimatedCostUSD || 0) + model.estimatedCostUSD;
|
|
61
|
-
}
|
|
62
|
-
return totals;
|
|
63
|
-
}, {
|
|
64
|
-
uncachedInputTokens: 0,
|
|
65
|
-
cachedInputTokens: 0,
|
|
66
|
-
outputTokens: 0,
|
|
67
|
-
totalTokens: 0,
|
|
68
|
-
estimatedCostUSD: null
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function fallbackModel(sourceId, agentRow) {
|
|
73
|
-
const names = Array.isArray(agentRow.modelsUsed) ? agentRow.modelsUsed : [];
|
|
74
|
-
const modelName = names.length === 1 ? names[0] : names.length > 1 ? 'Multiple models' : 'Unknown';
|
|
75
|
-
return normalizeModel(sourceId, {
|
|
76
|
-
modelName,
|
|
77
|
-
inputTokens: agentRow.inputTokens,
|
|
78
|
-
cacheCreationTokens: agentRow.cacheCreationTokens,
|
|
79
|
-
cacheReadTokens: agentRow.cacheReadTokens,
|
|
80
|
-
outputTokens: agentRow.outputTokens,
|
|
81
|
-
cost: names.length === 1 ? agentRow.totalCost : null
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function normalizeAgentRow(sourceId, period, agentRow) {
|
|
86
|
-
const breakdowns = Array.isArray(agentRow.modelBreakdowns) ? agentRow.modelBreakdowns : [];
|
|
87
|
-
const models = breakdowns.length
|
|
88
|
-
? breakdowns.map(item => normalizeModel(sourceId, item))
|
|
89
|
-
: [fallbackModel(sourceId, agentRow)];
|
|
90
|
-
models.sort((a, b) => b.totalTokens - a.totalTokens || a.modelName.localeCompare(b.modelName));
|
|
91
|
-
return { period, models, totals: sumModels(models) };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function findAgentRow(row, sourceId) {
|
|
95
|
-
return Array.isArray(row && row.agents)
|
|
96
|
-
? row.agents.find(agent => agent && agent.agent === sourceId) || null
|
|
97
|
-
: null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function availablePeriods(raw, scope, sourceId) {
|
|
101
|
-
return (raw[scope] || [])
|
|
102
|
-
.filter(row => findAgentRow(row, sourceId))
|
|
103
|
-
.map(row => String(row.period || ''))
|
|
104
|
-
.filter(Boolean)
|
|
105
|
-
.sort()
|
|
106
|
-
.reverse();
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function dateInsideScope(date, scope, selectedPeriod) {
|
|
110
|
-
if (scope === 'monthly') return date.startsWith(`${selectedPeriod}-`);
|
|
111
|
-
const start = new Date(`${selectedPeriod}T00:00:00Z`);
|
|
112
|
-
const candidate = new Date(`${date}T00:00:00Z`);
|
|
113
|
-
if (Number.isNaN(start.getTime()) || Number.isNaN(candidate.getTime())) return false;
|
|
114
|
-
const end = new Date(start);
|
|
115
|
-
end.setUTCDate(end.getUTCDate() + 7);
|
|
116
|
-
return candidate >= start && candidate < end;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function buildDashboard(raw, sourceId, scope, requestedPeriod) {
|
|
120
|
-
const periods = availablePeriods(raw, scope, sourceId);
|
|
121
|
-
const selectedPeriod = periods.includes(requestedPeriod) ? requestedPeriod : periods[0] || null;
|
|
122
|
-
if (!selectedPeriod) {
|
|
123
|
-
return { availablePeriods: [], selectedPeriod: null, summary: { models: [], totals: sumModels([]) }, days: [] };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const scopeRow = (raw[scope] || []).find(row => row.period === selectedPeriod);
|
|
127
|
-
const summaryAgent = findAgentRow(scopeRow, sourceId);
|
|
128
|
-
const summary = summaryAgent
|
|
129
|
-
? normalizeAgentRow(sourceId, selectedPeriod, summaryAgent)
|
|
130
|
-
: { models: [], totals: sumModels([]) };
|
|
131
|
-
const days = (raw.daily || [])
|
|
132
|
-
.filter(row => dateInsideScope(String(row.period || ''), scope, selectedPeriod))
|
|
133
|
-
.flatMap(row => {
|
|
134
|
-
const agent = findAgentRow(row, sourceId);
|
|
135
|
-
return agent ? [normalizeAgentRow(sourceId, String(row.period), agent)] : [];
|
|
136
|
-
});
|
|
137
|
-
return { availablePeriods: periods, selectedPeriod, summary, days };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
class UsageService {
|
|
141
|
-
constructor(options = {}) {
|
|
142
|
-
this.runner = options.runner || null;
|
|
143
|
-
this.timezone = options.timezone || defaultTimezone();
|
|
144
|
-
this.cacheTtlMs = options.cacheTtlMs ?? CACHE_TTL_MS;
|
|
145
|
-
this.logger = options.logger || { debug() {} };
|
|
146
|
-
this.cached = null;
|
|
147
|
-
this.loading = null;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async getSnapshot(refresh = false) {
|
|
151
|
-
const fresh = this.cached && Date.now() - this.cached.loadedAt < this.cacheTtlMs;
|
|
152
|
-
if (!refresh && fresh) return this.cached;
|
|
153
|
-
if (!refresh && this.cached) {
|
|
154
|
-
this.loadSnapshot().catch(error => this.logger.debug(`Background usage refresh failed: ${error.message}`));
|
|
155
|
-
return this.cached;
|
|
156
|
-
}
|
|
157
|
-
return this.loadSnapshot();
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async loadSnapshot() {
|
|
161
|
-
if (this.loading) return this.loading;
|
|
162
|
-
if (!this.runner) this.runner = new CcusageRunner();
|
|
163
|
-
this.loading = this.runner.loadAllPeriods(this.timezone)
|
|
164
|
-
.then(raw => {
|
|
165
|
-
this.cached = { raw, loadedAt: Date.now(), generatedAt: new Date().toISOString() };
|
|
166
|
-
return this.cached;
|
|
167
|
-
})
|
|
168
|
-
.finally(() => { this.loading = null; });
|
|
169
|
-
return this.loading;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async listSources(refresh = false) {
|
|
173
|
-
const snapshot = await this.getSnapshot(refresh);
|
|
174
|
-
const present = new Set();
|
|
175
|
-
for (const scope of ['daily', 'weekly', 'monthly']) {
|
|
176
|
-
for (const row of snapshot.raw[scope] || []) {
|
|
177
|
-
for (const agent of row.agents || []) present.add(agent.agent);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return {
|
|
181
|
-
sources: SOURCES.filter(source => present.has(source.id)),
|
|
182
|
-
generatedAt: snapshot.generatedAt,
|
|
183
|
-
timezone: this.timezone,
|
|
184
|
-
engine: { name: 'ccusage', version: ccusageVersion(), pricingMode: 'embedded' }
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async getDashboard(sourceId, scope, selectedPeriod, refresh = false) {
|
|
189
|
-
const source = getUsageSource(sourceId);
|
|
190
|
-
if (!source) {
|
|
191
|
-
const error = new Error('Unsupported usage source');
|
|
192
|
-
error.statusCode = 400;
|
|
193
|
-
throw error;
|
|
194
|
-
}
|
|
195
|
-
if (!SCOPES.has(scope)) {
|
|
196
|
-
const error = new Error('Scope must be weekly or monthly');
|
|
197
|
-
error.statusCode = 400;
|
|
198
|
-
throw error;
|
|
199
|
-
}
|
|
200
|
-
const snapshot = await this.getSnapshot(refresh);
|
|
201
|
-
return {
|
|
202
|
-
source,
|
|
203
|
-
scope,
|
|
204
|
-
...buildDashboard(snapshot.raw, source.id, scope, selectedPeriod),
|
|
205
|
-
generatedAt: snapshot.generatedAt,
|
|
206
|
-
timezone: this.timezone,
|
|
207
|
-
engine: { name: 'ccusage', version: ccusageVersion(), pricingMode: 'embedded' },
|
|
208
|
-
cost: source.id === 'codex' ? {
|
|
209
|
-
basis: 'ccusage estimate for Codex GPT models',
|
|
210
|
-
note: 'Estimated from ccusage model pricing; it is not an actual provider bill.'
|
|
211
|
-
} : null
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
module.exports = {
|
|
217
|
-
UsageService,
|
|
218
|
-
availablePeriods,
|
|
219
|
-
buildDashboard,
|
|
220
|
-
dateInsideScope,
|
|
221
|
-
isGptModel,
|
|
222
|
-
modelCost,
|
|
223
|
-
normalizeAgentRow,
|
|
224
|
-
normalizeModel,
|
|
225
|
-
sumModels
|
|
226
|
-
};
|