cc-speed 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ [English](#) | [简体中文](./README.zh-CN.md)
2
+
3
+ # cc-speed
4
+
5
+ Analyze Claude Code token output speed from local JSONL logs.
6
+
7
+ ## What it does
8
+
9
+ `cc-speed` reads your local Claude Code conversation logs (`~/.claude/projects/**/*.jsonl`) and calculates token output speed (tokens/sec) by measuring the time between user and assistant messages.
10
+
11
+ It provides:
12
+ - Per-model statistics (median, avg, p10, p90)
13
+ - Daily trend sparklines in terminal
14
+ - Interactive HTML charts (scatter plot + histogram) with dark theme
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g cc-speed
20
+ ```
21
+
22
+ Or run directly:
23
+
24
+ ```bash
25
+ npx cc-speed
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ # Default: terminal table for the last 7 days
32
+ cc-speed
33
+
34
+ # Custom time range
35
+ cc-speed --days 30
36
+
37
+ # Generate HTML chart and open in browser
38
+ cc-speed --chart
39
+
40
+ # JSON output (for piping)
41
+ cc-speed --json
42
+ ```
43
+
44
+ ### Options
45
+
46
+ | Flag | Description | Default |
47
+ |------|-------------|---------|
48
+ | `-d, --days <n>` | Number of days to analyze | `7` |
49
+ | `-c, --chart` | Generate HTML chart and open in browser | - |
50
+ | `-j, --json` | Output as JSON | - |
51
+
52
+ ## Claude Code Slash Command
53
+
54
+ You can use `cc-speed` as a `/speed` command inside Claude Code:
55
+
56
+ ```bash
57
+ # One-line install
58
+ bash <(curl -fsSL https://raw.githubusercontent.com/bryant24hao/cc-speed/main/install-skill.sh)
59
+ ```
60
+
61
+ Or manually create `~/.claude/skills/cc-speed/SKILL.md`:
62
+
63
+ ```markdown
64
+ ---
65
+ name: speed
66
+ description: Analyze Claude Code token output speed
67
+ ---
68
+
69
+ Run `npx cc-speed --chart` to analyze token output speed and generate charts.
70
+ ```
71
+
72
+ Then type `/speed` in Claude Code to trigger it.
73
+
74
+ ## How it works
75
+
76
+ 1. Scans `~/.claude/projects/**/*.jsonl` for recent conversation logs
77
+ 2. Pairs each `assistant` message with its preceding `user` message
78
+ 3. Calculates `output_tokens / (assistant_timestamp - user_timestamp)`
79
+ 4. Filters: `output_tokens > 10`, duration between 0.5s and 300s
80
+ 5. Groups by model, computes statistics and trends
81
+
82
+ > **Note**: The speed is an estimate based on timestamp deltas. It includes network latency and any processing overhead, so actual generation speed may be higher.
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,86 @@
1
+ [English](./README.md) | [简体中文](#)
2
+
3
+ # cc-speed
4
+
5
+ 分析 Claude Code 的 token 输出速度。
6
+
7
+ ## 功能介绍
8
+
9
+ `cc-speed` 读取本地 Claude Code 的对话日志(`~/.claude/projects/**/*.jsonl`),通过测量 user 和 assistant 消息之间的时间差来计算 token 输出速度(tokens/sec)。
10
+
11
+ 提供:
12
+ - 按模型分组的速度统计(中位数、平均值、P10、P90)
13
+ - 终端中的每日趋势 sparkline 图
14
+ - 交互式 HTML 图表(散点图 + 直方图),暗色主题
15
+
16
+ ## 安装
17
+
18
+ ```bash
19
+ npm install -g cc-speed
20
+ ```
21
+
22
+ 或直接运行:
23
+
24
+ ```bash
25
+ npx cc-speed
26
+ ```
27
+
28
+ ## 使用方法
29
+
30
+ ```bash
31
+ # 默认:终端表格展示最近 7 天
32
+ cc-speed
33
+
34
+ # 自定义时间范围
35
+ cc-speed --days 30
36
+
37
+ # 生成 HTML 图表并在浏览器打开
38
+ cc-speed --chart
39
+
40
+ # JSON 输出(方便管道处理)
41
+ cc-speed --json
42
+ ```
43
+
44
+ ### 参数
45
+
46
+ | 参数 | 说明 | 默认值 |
47
+ |------|------|--------|
48
+ | `-d, --days <n>` | 分析天数 | `7` |
49
+ | `-c, --chart` | 生成 HTML 图表并在浏览器打开 | - |
50
+ | `-j, --json` | 输出 JSON 格式 | - |
51
+
52
+ ## Claude Code Slash Command
53
+
54
+ 可以在 Claude Code 中通过 `/speed` 命令使用:
55
+
56
+ ```bash
57
+ # 一行命令安装
58
+ bash <(curl -fsSL https://raw.githubusercontent.com/bryant24hao/cc-speed/main/install-skill.sh)
59
+ ```
60
+
61
+ 或手动创建 `~/.claude/skills/cc-speed/SKILL.md`:
62
+
63
+ ```markdown
64
+ ---
65
+ name: speed
66
+ description: 分析 Claude Code 的 token 输出速度
67
+ ---
68
+
69
+ 运行 `npx cc-speed --chart` 分析 token 输出速度并生成图表。
70
+ ```
71
+
72
+ 然后在 Claude Code 中输入 `/speed` 触发。
73
+
74
+ ## 工作原理
75
+
76
+ 1. 扫描 `~/.claude/projects/**/*.jsonl` 中的对话日志
77
+ 2. 将每条 `assistant` 消息与其前一条 `user` 消息配对
78
+ 3. 计算 `output_tokens / (assistant 时间戳 - user 时间戳)`
79
+ 4. 过滤条件:`output_tokens > 10`,时长在 0.5s 到 300s 之间
80
+ 5. 按模型分组,计算统计数据和趋势
81
+
82
+ > **注意**:速度是基于时间戳差值的估算,包含了网络延迟和处理开销,实际生成速度可能更高。
83
+
84
+ ## 许可证
85
+
86
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { program } from "commander";
5
+ import ora from "ora";
6
+
7
+ // src/collector.ts
8
+ import { readFileSync, readdirSync, statSync } from "fs";
9
+ import { join } from "path";
10
+ import { homedir } from "os";
11
+ function getAllJsonlFiles(dir, cutoffMs) {
12
+ const results = [];
13
+ try {
14
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
15
+ const fullPath = join(dir, entry.name);
16
+ if (entry.isDirectory()) {
17
+ results.push(...getAllJsonlFiles(fullPath, cutoffMs));
18
+ } else if (entry.name.endsWith(".jsonl")) {
19
+ try {
20
+ const stat = statSync(fullPath);
21
+ if (stat.mtimeMs > cutoffMs) {
22
+ results.push(fullPath);
23
+ }
24
+ } catch {
25
+ }
26
+ }
27
+ }
28
+ } catch {
29
+ }
30
+ return results;
31
+ }
32
+ function parseJsonlFile(filePath) {
33
+ const messages = [];
34
+ try {
35
+ const lines = readFileSync(filePath, "utf-8").split("\n").filter(Boolean);
36
+ for (const line of lines) {
37
+ try {
38
+ messages.push(JSON.parse(line));
39
+ } catch {
40
+ }
41
+ }
42
+ } catch {
43
+ }
44
+ return messages;
45
+ }
46
+ function normalizeModel(msg) {
47
+ const raw = msg.message?.model || msg.model || "unknown";
48
+ return raw.replace("claude-", "").replace(/-\d{8}$/, "");
49
+ }
50
+ function collectSpeedData(days) {
51
+ const projectsDir = join(homedir(), ".claude", "projects");
52
+ const cutoff = /* @__PURE__ */ new Date();
53
+ cutoff.setDate(cutoff.getDate() - days);
54
+ const cutoffMs = cutoff.getTime();
55
+ const files = getAllJsonlFiles(projectsDir, cutoffMs);
56
+ const dataPoints = [];
57
+ for (const file of files) {
58
+ const messages = parseJsonlFile(file);
59
+ for (let i = 0; i < messages.length; i++) {
60
+ const msg = messages[i];
61
+ if (msg.type !== "assistant" || !msg.message?.usage?.output_tokens) continue;
62
+ if (!msg.timestamp) continue;
63
+ const ts = new Date(msg.timestamp);
64
+ if (ts.getTime() < cutoffMs) continue;
65
+ const outputTokens = msg.message.usage.output_tokens;
66
+ const model = normalizeModel(msg);
67
+ let userTs = null;
68
+ for (let j = i - 1; j >= 0; j--) {
69
+ if (messages[j].type === "user" && messages[j].timestamp) {
70
+ userTs = new Date(messages[j].timestamp);
71
+ break;
72
+ }
73
+ }
74
+ if (userTs && outputTokens > 10) {
75
+ const durationSec = (ts.getTime() - userTs.getTime()) / 1e3;
76
+ if (durationSec > 0.5 && durationSec < 300) {
77
+ dataPoints.push({
78
+ timestamp: ts.toISOString(),
79
+ outputTokens,
80
+ model,
81
+ durationSec: Math.round(durationSec * 10) / 10,
82
+ tokensPerSec: Math.round(outputTokens / durationSec * 10) / 10
83
+ });
84
+ }
85
+ }
86
+ }
87
+ }
88
+ dataPoints.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
89
+ return dataPoints;
90
+ }
91
+
92
+ // src/analyzer.ts
93
+ function percentile(sorted, p) {
94
+ const idx = Math.floor(sorted.length * p);
95
+ return sorted[Math.min(idx, sorted.length - 1)];
96
+ }
97
+ function median(sorted) {
98
+ const mid = Math.floor(sorted.length / 2);
99
+ if (sorted.length % 2 === 0) {
100
+ return Math.round((sorted[mid - 1] + sorted[mid]) / 2 * 10) / 10;
101
+ }
102
+ return sorted[mid];
103
+ }
104
+ function analyzeSpeed(data, days = 7) {
105
+ const byModel = /* @__PURE__ */ new Map();
106
+ for (const dp of data) {
107
+ const arr = byModel.get(dp.model) || [];
108
+ arr.push(dp);
109
+ byModel.set(dp.model, arr);
110
+ }
111
+ const modelStats = [];
112
+ for (const [model, points] of byModel) {
113
+ const speeds = points.map((p) => p.tokensPerSec).sort((a, b) => a - b);
114
+ modelStats.push({
115
+ model,
116
+ count: speeds.length,
117
+ median: median(speeds),
118
+ avg: Math.round(speeds.reduce((a, b) => a + b, 0) / speeds.length * 10) / 10,
119
+ p10: percentile(speeds, 0.1),
120
+ p90: percentile(speeds, 0.9),
121
+ min: speeds[0],
122
+ max: speeds[speeds.length - 1]
123
+ });
124
+ }
125
+ modelStats.sort((a, b) => a.model.localeCompare(b.model));
126
+ const dailyMap = /* @__PURE__ */ new Map();
127
+ for (const dp of data) {
128
+ const date = dp.timestamp.slice(0, 10);
129
+ const key = `${date}|${dp.model}`;
130
+ const arr = dailyMap.get(key) || [];
131
+ arr.push(dp);
132
+ dailyMap.set(key, arr);
133
+ }
134
+ const dailyTrends = [];
135
+ for (const [key, points] of dailyMap) {
136
+ const [date, model] = key.split("|");
137
+ const speeds = points.map((p) => p.tokensPerSec).sort((a, b) => a - b);
138
+ dailyTrends.push({
139
+ date,
140
+ model,
141
+ median: median(speeds),
142
+ count: speeds.length
143
+ });
144
+ }
145
+ dailyTrends.sort((a, b) => a.date.localeCompare(b.date) || a.model.localeCompare(b.model));
146
+ return {
147
+ modelStats,
148
+ dailyTrends,
149
+ totalSamples: data.length,
150
+ days
151
+ };
152
+ }
153
+
154
+ // src/terminal.ts
155
+ import chalk from "chalk";
156
+ import Table from "cli-table3";
157
+ var MODEL_COLORS = {
158
+ opus: chalk.hex("#f78166"),
159
+ sonnet: chalk.hex("#7ee787"),
160
+ haiku: chalk.hex("#79c0ff")
161
+ };
162
+ function colorForModel(model) {
163
+ for (const [key, colorFn] of Object.entries(MODEL_COLORS)) {
164
+ if (model.toLowerCase().includes(key)) return colorFn;
165
+ }
166
+ return chalk.white;
167
+ }
168
+ function sparkline(values) {
169
+ if (values.length === 0) return "";
170
+ const bars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
171
+ const min = Math.min(...values);
172
+ const max = Math.max(...values);
173
+ const range = max - min || 1;
174
+ return values.map((v) => {
175
+ const idx = Math.round((v - min) / range * (bars.length - 1));
176
+ return bars[idx];
177
+ }).join("");
178
+ }
179
+ function renderTerminal(report) {
180
+ if (report.totalSamples === 0) {
181
+ console.log(chalk.yellow("\nNo data found. Make sure you have Claude Code conversation logs in ~/.claude/projects/\n"));
182
+ return;
183
+ }
184
+ console.log(
185
+ chalk.bold(`
186
+ Claude Code Output Speed (last ${report.days} days, ${report.totalSamples} samples)
187
+ `)
188
+ );
189
+ const table = new Table({
190
+ head: ["Model", "Samples", "Median", "Avg", "P10", "P90", "Min", "Max"].map(
191
+ (h) => chalk.gray(h)
192
+ ),
193
+ style: { head: [], border: ["gray"] },
194
+ chars: {
195
+ top: "\u2500",
196
+ "top-mid": "\u252C",
197
+ "top-left": "\u250C",
198
+ "top-right": "\u2510",
199
+ bottom: "\u2500",
200
+ "bottom-mid": "\u2534",
201
+ "bottom-left": "\u2514",
202
+ "bottom-right": "\u2518",
203
+ left: "\u2502",
204
+ "left-mid": "\u251C",
205
+ mid: "\u2500",
206
+ "mid-mid": "\u253C",
207
+ right: "\u2502",
208
+ "right-mid": "\u2524",
209
+ middle: "\u2502"
210
+ }
211
+ });
212
+ for (const stat of report.modelStats) {
213
+ const color = colorForModel(stat.model);
214
+ table.push([
215
+ color(stat.model),
216
+ String(stat.count),
217
+ chalk.bold(stat.median + " tok/s"),
218
+ stat.avg + " tok/s",
219
+ stat.p10 + "",
220
+ stat.p90 + "",
221
+ stat.min + "",
222
+ stat.max + ""
223
+ ]);
224
+ }
225
+ console.log(table.toString());
226
+ if (report.dailyTrends.length > 0) {
227
+ console.log(chalk.bold("\n Daily Trend (median tok/s)\n"));
228
+ const models = [...new Set(report.dailyTrends.map((t) => t.model))];
229
+ const dates = [...new Set(report.dailyTrends.map((t) => t.date))].sort();
230
+ for (const model of models) {
231
+ const color = colorForModel(model);
232
+ const dailyValues = dates.map((date) => {
233
+ const found = report.dailyTrends.find((t) => t.date === date && t.model === model);
234
+ return found ? found.median : 0;
235
+ });
236
+ const nonZero = dailyValues.filter((v) => v > 0);
237
+ if (nonZero.length === 0) continue;
238
+ const spark = sparkline(nonZero);
239
+ const latest = nonZero[nonZero.length - 1];
240
+ console.log(` ${color(model.padEnd(16))} ${spark} ${chalk.bold(latest + "")} tok/s`);
241
+ }
242
+ }
243
+ console.log();
244
+ }
245
+
246
+ // src/chart.ts
247
+ import { writeFileSync } from "fs";
248
+ import { join as join2 } from "path";
249
+ import { tmpdir } from "os";
250
+ function generateChart(data, report) {
251
+ let statCards = "";
252
+ for (const stat of report.modelStats) {
253
+ const cls = stat.model.includes("opus") ? "opus" : stat.model.includes("haiku") ? "haiku" : "sonnet";
254
+ statCards += `<div class="stat-card">`;
255
+ statCards += `<div class="stat-label">${stat.model}</div>`;
256
+ statCards += `<div class="stat-value ${cls}">${stat.median} <span style="font-size:14px;color:#8b949e">tok/s</span></div>`;
257
+ statCards += `<div class="stat-label">median &middot; avg ${stat.avg} &middot; p10 ${stat.p10} &middot; p90 ${stat.p90} &middot; n=${stat.count}</div>`;
258
+ statCards += `</div>`;
259
+ }
260
+ const html = `<!DOCTYPE html>
261
+ <html lang="en"><head><meta charset="utf-8">
262
+ <title>Claude Code Token Output Speed</title>
263
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
264
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
265
+ <style>
266
+ body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0d1117; color: #e6edf3; padding: 24px; margin: 0; }
267
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
268
+ .subtitle { color: #8b949e; font-size: 14px; margin-bottom: 24px; }
269
+ .chart-container { background: #161b22; border-radius: 12px; padding: 20px; margin-bottom: 20px; border: 1px solid #30363d; }
270
+ .stats { display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
271
+ .stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px 20px; min-width: 140px; }
272
+ .stat-label { color: #8b949e; font-size: 12px; margin-bottom: 4px; }
273
+ .stat-value { font-size: 24px; font-weight: 700; }
274
+ .opus { color: #f78166; }
275
+ .sonnet { color: #7ee787; }
276
+ .haiku { color: #79c0ff; }
277
+ canvas { max-height: 400px; }
278
+ .lang-toggle { position: fixed; top: 16px; right: 24px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; padding: 4px; display: flex; gap: 2px; z-index: 100; }
279
+ .lang-toggle button { background: none; border: none; color: #8b949e; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 13px; }
280
+ .lang-toggle button.active { background: #30363d; color: #e6edf3; }
281
+ </style></head><body>
282
+ <div class="lang-toggle">
283
+ <button id="btn-en" class="active" onclick="setLang('en')">EN</button>
284
+ <button id="btn-zh" onclick="setLang('zh')">\u4E2D\u6587</button>
285
+ </div>
286
+ <h1 id="title">Claude Code Output Speed (tokens/sec)</h1>
287
+ <div class="subtitle" id="subtitle">Last ${report.days} days &middot; ${report.totalSamples} samples &middot; based on user&rarr;assistant timestamp delta &middot; output_tokens &gt; 10</div>
288
+ <div class="stats">${statCards}</div>
289
+ <div class="chart-container"><canvas id="speedChart"></canvas></div>
290
+ <div class="chart-container"><canvas id="histChart"></canvas></div>
291
+ <script>
292
+ var i18n = {
293
+ en: {
294
+ title: "Claude Code Output Speed (tokens/sec)",
295
+ subtitle: "Last ${report.days} days \\u00b7 ${report.totalSamples} samples \\u00b7 based on user\\u2192assistant timestamp delta \\u00b7 output_tokens > 10",
296
+ scatterTitle: "Output Speed Over Time",
297
+ histTitle: "Speed Distribution (tokens/sec)",
298
+ ySpeed: "tokens/sec",
299
+ yCount: "count"
300
+ },
301
+ zh: {
302
+ title: "Claude Code \\u8f93\\u51fa\\u901f\\u5ea6 (tokens/sec)",
303
+ subtitle: "\\u6700\\u8fd1 ${report.days} \\u5929 \\u00b7 ${report.totalSamples} \\u4e2a\\u6837\\u672c \\u00b7 \\u57fa\\u4e8e user\\u2192assistant \\u65f6\\u95f4\\u5dee\\u4f30\\u7b97 \\u00b7 \\u4ec5\\u7edf\\u8ba1 output_tokens > 10",
304
+ scatterTitle: "\\u8f93\\u51fa\\u901f\\u5ea6\\u968f\\u65f6\\u95f4\\u53d8\\u5316",
305
+ histTitle: "\\u901f\\u5ea6\\u5206\\u5e03 (tokens/sec)",
306
+ ySpeed: "tokens/sec",
307
+ yCount: "\\u6570\\u91cf"
308
+ }
309
+ };
310
+ var currentLang = "en";
311
+
312
+ function setLang(lang) {
313
+ currentLang = lang;
314
+ document.getElementById("btn-en").className = lang === "en" ? "active" : "";
315
+ document.getElementById("btn-zh").className = lang === "zh" ? "active" : "";
316
+ document.getElementById("title").textContent = i18n[lang].title;
317
+ document.getElementById("subtitle").textContent = i18n[lang].subtitle;
318
+ if (window.scatterChart) {
319
+ window.scatterChart.options.plugins.title.text = i18n[lang].scatterTitle;
320
+ window.scatterChart.options.scales.y.title.text = i18n[lang].ySpeed;
321
+ window.scatterChart.update();
322
+ }
323
+ if (window.histChart) {
324
+ window.histChart.options.plugins.title.text = i18n[lang].histTitle;
325
+ window.histChart.options.scales.y.title.text = i18n[lang].yCount;
326
+ window.histChart.options.scales.x.title.text = i18n[lang].ySpeed;
327
+ window.histChart.update();
328
+ }
329
+ }
330
+
331
+ const data = ${JSON.stringify(data)};
332
+ const models = [...new Set(data.map(d => d.model))];
333
+ const colors = {
334
+ 'opus-4-6': '#f78166',
335
+ 'sonnet-4-5': '#7ee787',
336
+ 'haiku-4-5': '#79c0ff',
337
+ };
338
+ function getColor(model) {
339
+ if (colors[model]) return colors[model];
340
+ if (model.includes('opus')) return '#f78166';
341
+ if (model.includes('haiku')) return '#79c0ff';
342
+ return '#7ee787';
343
+ }
344
+
345
+ window.scatterChart = new Chart(document.getElementById("speedChart"), {
346
+ type: "scatter",
347
+ data: {
348
+ datasets: models.map(model => ({
349
+ label: model,
350
+ data: data.filter(d => d.model === model).map(d => ({ x: new Date(d.timestamp), y: d.tokensPerSec })),
351
+ backgroundColor: getColor(model) + "99",
352
+ borderColor: getColor(model),
353
+ pointRadius: 4,
354
+ pointHoverRadius: 6,
355
+ })),
356
+ },
357
+ options: {
358
+ responsive: true,
359
+ plugins: {
360
+ title: { display: true, text: i18n[currentLang].scatterTitle, color: "#e6edf3" },
361
+ legend: { labels: { color: "#e6edf3" } },
362
+ tooltip: {
363
+ callbacks: {
364
+ label: (ctx) => {
365
+ const pts = data.filter(dd => dd.model === ctx.dataset.label);
366
+ const d = pts[ctx.dataIndex];
367
+ if (!d) return "";
368
+ return d.tokensPerSec + " tok/s \\u00b7 " + d.outputTokens + " tokens \\u00b7 " + d.durationSec + "s";
369
+ }
370
+ }
371
+ }
372
+ },
373
+ scales: {
374
+ x: { type: "time", time: { unit: "day" }, ticks: { color: "#8b949e" }, grid: { color: "#21262d" } },
375
+ y: { title: { display: true, text: i18n[currentLang].ySpeed, color: "#8b949e" }, ticks: { color: "#8b949e" }, grid: { color: "#21262d" } }
376
+ }
377
+ }
378
+ });
379
+
380
+ var bucketSize = 5;
381
+ var maxSpeed = Math.ceil(Math.max(...data.map(d => d.tokensPerSec)) / bucketSize) * bucketSize;
382
+ var buckets = Array.from({ length: maxSpeed / bucketSize + 1 }, (_, i) => i * bucketSize);
383
+ var histDataMap = {};
384
+ models.forEach(function(model) {
385
+ histDataMap[model] = new Array(buckets.length).fill(0);
386
+ data.filter(dd => dd.model === model).forEach(function(d) {
387
+ var idx = Math.floor(d.tokensPerSec / bucketSize);
388
+ if (idx < histDataMap[model].length) histDataMap[model][idx]++;
389
+ });
390
+ });
391
+
392
+ window.histChart = new Chart(document.getElementById("histChart"), {
393
+ type: "bar",
394
+ data: {
395
+ labels: buckets.map(b => b + "-" + (b + bucketSize)),
396
+ datasets: models.map(model => ({
397
+ label: model,
398
+ data: histDataMap[model],
399
+ backgroundColor: getColor(model) + "99",
400
+ borderColor: getColor(model),
401
+ borderWidth: 1,
402
+ })),
403
+ },
404
+ options: {
405
+ responsive: true,
406
+ plugins: {
407
+ title: { display: true, text: i18n[currentLang].histTitle, color: "#e6edf3" },
408
+ legend: { labels: { color: "#e6edf3" } }
409
+ },
410
+ scales: {
411
+ x: { title: { display: true, text: i18n[currentLang].ySpeed, color: "#8b949e" }, ticks: { color: "#8b949e" }, grid: { color: "#21262d" } },
412
+ y: { title: { display: true, text: i18n[currentLang].yCount, color: "#8b949e" }, ticks: { color: "#8b949e" }, grid: { color: "#21262d" } }
413
+ }
414
+ }
415
+ });
416
+ </script></body></html>`;
417
+ const outPath = join2(tmpdir(), "claude-speed.html");
418
+ writeFileSync(outPath, html);
419
+ return outPath;
420
+ }
421
+
422
+ // src/index.ts
423
+ import { exec } from "child_process";
424
+ import { platform } from "os";
425
+ program.name("cc-speed").description("Analyze Claude Code token output speed").version("0.1.0").option("-d, --days <number>", "Number of days to analyze", "7").option("-c, --chart", "Generate HTML chart and open in browser").option("-j, --json", "Output as JSON").action(async (opts) => {
426
+ const days = parseInt(opts.days, 10);
427
+ const spinner = ora("Scanning Claude Code logs...").start();
428
+ const data = collectSpeedData(days);
429
+ spinner.succeed(`Found ${data.length} data points from ${days} days`);
430
+ if (data.length === 0) {
431
+ console.log("\nNo data found. Make sure you have Claude Code conversation logs in ~/.claude/projects/\n");
432
+ return;
433
+ }
434
+ const report = analyzeSpeed(data, days);
435
+ if (opts.json) {
436
+ console.log(JSON.stringify(report, null, 2));
437
+ return;
438
+ }
439
+ if (opts.chart) {
440
+ const chartSpinner = ora("Generating chart...").start();
441
+ const chartPath = generateChart(data, report);
442
+ chartSpinner.succeed(`Chart saved to ${chartPath}`);
443
+ const openCmd = platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
444
+ exec(`${openCmd} "${chartPath}"`);
445
+ return;
446
+ }
447
+ renderTerminal(report);
448
+ });
449
+ program.parse();
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ # Install cc-speed as a Claude Code /speed slash command
3
+
4
+ set -e
5
+
6
+ SKILL_DIR="$HOME/.claude/skills/cc-speed"
7
+ SKILL_FILE="$SKILL_DIR/SKILL.md"
8
+
9
+ mkdir -p "$SKILL_DIR"
10
+
11
+ cat > "$SKILL_FILE" << 'EOF'
12
+ ---
13
+ name: speed
14
+ description: Analyze Claude Code token output speed / 分析 Claude Code 的 token 输出速度
15
+ ---
16
+
17
+ Run `npx cc-speed --chart` to analyze token output speed and generate charts.
18
+ EOF
19
+
20
+ echo "Installed /speed command to $SKILL_FILE"
21
+ echo "You can now use /speed in Claude Code."
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "cc-speed",
3
+ "version": "0.1.0",
4
+ "description": "Analyze Claude Code token output speed from local JSONL logs",
5
+ "type": "module",
6
+ "bin": {
7
+ "cc-speed": "./dist/index.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "files": [
11
+ "dist",
12
+ "install-skill.sh"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsup",
16
+ "dev": "tsup --watch",
17
+ "test": "vitest run",
18
+ "prepublishOnly": "pnpm build"
19
+ },
20
+ "keywords": [
21
+ "claude",
22
+ "claude-code",
23
+ "token-speed",
24
+ "benchmark",
25
+ "cli"
26
+ ],
27
+ "author": "",
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "chalk": "^5.3.0",
31
+ "cli-table3": "^0.6.5",
32
+ "commander": "^12.1.0",
33
+ "glob": "^11.0.0",
34
+ "ora": "^8.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.0.0",
38
+ "tsup": "^8.3.0",
39
+ "typescript": "^5.7.0",
40
+ "vitest": "^3.0.0"
41
+ },
42
+ "pnpm": {
43
+ "onlyBuiltDependencies": ["esbuild"]
44
+ }
45
+ }