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 +21 -0
- package/README.md +86 -0
- package/README.zh-CN.md +86 -0
- package/dist/index.js +449 -0
- package/install-skill.sh +21 -0
- package/package.json +45 -0
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
|
package/README.zh-CN.md
ADDED
|
@@ -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 · avg ${stat.avg} · p10 ${stat.p10} · p90 ${stat.p90} · 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 · ${report.totalSamples} samples · based on user→assistant timestamp delta · output_tokens > 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();
|
package/install-skill.sh
ADDED
|
@@ -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
|
+
}
|