ECC/cost-report命令实战:从本地成本追踪数据生成 Claude Code 支出报告
【免费下载链接】ECCThe agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.项目地址: https://gitcode.com/GitHub_Trending/ev/ECC
导读
ECC(Everything Claude Code)提供了一条cost-report命令,用于从本地成本追踪数据中汇总 Claude Code 的使用支出,按日、按模型/项目、按会话维度生成紧凑报告,并支持一键导出 CSV。本文以仓库内docs/ja-JP/commands/cost-report.md(日文版命令文档)为主体脉络,结合当前仓库中命令文档、stop:cost-tracker钩子源码与测试用例,完整讲解该命令的查询逻辑、数据来源、每条查询语句的含义与底层实现原理。读完本文,你将掌握如何独立复现这条命令的所有聚合查询,并能理解追踪器写入的每一条数据的语义。
命令定位与数据来源
cost-report是 ECC 的命令体系之一,在 docs/COMMAND-REGISTRY.json 中注册,其描述为「从 ECC cost-tracker 指标日志生成本地 Claude Code 成本报告」,类型标记为testing。命令文档本体位于 commands/cost-report.md。
需要特别注意:这条命令存在两个历史版本,数据存储形态不同。
- 旧版(社区 PR 复活版):即日文文档
docs/ja-JP/commands/cost-report.md所描述的形态。它假设一个成本追踪钩子或插件已经把使用记录写入 SQLite 数据库~/.claude-cost-tracker/usage.db,命令通过sqlite3查询usage表。日文文档末尾注明,该命令源自MayurBhavsar的旧社区 PR #1304。 - 当前仓库实现(v2):英文命令文档 commands/cost-report.md 描述的是演进后的形态。ECC 的
stop:cost-tracker钩子(scripts/hooks/cost-tracker.js)在每个会话结束时向~/.claude/metrics/costs.jsonl追加一行 JSON,命令改用node读取该 JSONL 日志做聚合。改用node而非sqlite3/jq的原因在文档中写得很明确:保证在 macOS、Linux、Windows 三平台上行为一致。
当前仓库中commands/cost-report.md、钩子源码与测试均为 JSONL 版本,因此本文以 JSONL 版本为准讲解实际行为,同时完整保留日文文档中 SQLite 版本的查询骨架(汇总、分组、最近 7 天、CSV 导出),两者聚合逻辑一一对应,方便读者理解演进脉络。
命令执行流程
无论哪个版本,cost-report的工作流程都是四步:
- 检查查询工具是否可用(SQLite 版本检查
sqlite3;JSONL 版本使用node,天然跨平台); - 检查数据文件是否存在(
~/.claude-cost-tracker/usage.db或~/.claude/metrics/costs.jsonl); - 对数据执行聚合查询;
- 输出紧凑报告;若参数为
csv,则导出最近的使用行。
日文文档给出了数据文件存在性检查的等价命令:
test -f ~/.claude-cost-tracker/usage.db && echo "Database found" || echo "Database not found"对应到当前仓库的 JSONL 实现,检查命令为:
node -e 'const fs=require("fs"),os=require("os"),p=require("path");const f=p.join(os.homedir(),"claude","metrics","costs.jsonl");console.log(fs.existsSync(f)?"cost log found":"cost log not found: "+f)'前提条件:数据文件必须由本地成本追踪器写入。文件不存在时,命令会告知用户追踪器尚未启用,并提示先安装/启用可信的 Claude Code 成本追踪钩子或插件。对当前仓库而言,就是启用stop:cost-tracker钩子并完成至少一个会话——钩子只在会话结束时(Stop 事件)写入首行数据。
数据模型:每行的语义
理解报告的聚合逻辑,必须先理解数据行的语义。scripts/hooks/cost-tracker.js的头部注释与 skills/cost-tracking/SKILL.md 给出了行结构:
| 字段 | 含义 |
|---|---|
timestamp | 快照的 ISO 时间戳 |
session_id | Claude Code 会话标识 |
transcript_path | 会话 transcript 文件路径 |
model | 使用的模型 |
input_tokens/output_tokens | Token 计数 |
cache_write_tokens/cache_read_tokens | 提示缓存写入/读取的 Token 计数 |
estimated_cost_usd | 该会话累计成本的预估美元值(预计算) |
两个关键语义约束:
- 每行是会话的累计快照(cumulative snapshot):Stop 事件按「每次助手响应」触发而非按会话触发,因此同一会话会产生多行,每行代表截至该时刻的累计总量。汇总时必须取每个
session_id的最后一行再跨会话求和——把每一行都加起来会造成重复计数。这正是报告脚本中bySessionMap 去重逻辑存在的根本原因。 - 成本取预计算值:报告依赖追踪器写入的
estimated_cost_usd,绝不从原始 Token 数重新估算价格。因为模型与缓存价格会变化,追踪器才是事实来源。
汇总查询:今日 / 昨日 / 总计
日文文档给出了 SQLite 版本的汇总查询(usage表),完整继承如下:
sqlite3 -header -column ~/.claude-cost-tracker/usage.db " SELECT ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now') THEN cost_usd END), 0), 4) AS today_cost, ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now', '-1 day') THEN cost_usd END), 0), 4) AS yesterday_cost, ROUND(COALESCE(SUM(cost_usd), 0), 4) AS total_cost, COUNT(*) AS total_calls, COUNT(DISTINCT session_id) AS sessions FROM usage; "对应到当前仓库的 JSONL 版本,命令文档中的等价实现为:
node -e ' const fs=require("fs"),os=require("os"),path=require("path"); const f=path.join(os.homedir(),".claude","metrics","costs.jsonl"); if(!fs.existsSync(f)){console.log("Cost tracker not set up: "+f+" not found. Enable the stop:cost-tracker hook and finish a session first.");process.exit(0);} const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean); const bySession=new Map(); for(const r of rows){const k=r.session_id||r.transcript_path||r.timestamp;const p=bySession.get(k);if(!p||String(r.timestamp)>String(p.timestamp))bySession.set(k,r);} const latest=[...bySession.values()]; const cost=r=>Number(r.estimated_cost_usd)||0; const day=r=>String(r.timestamp||"").slice(0,10); const today=new Date().toISOString().slice(0,10); const d=new Date(Date.now()-864e5).toISOString().slice(0,10); const sum=a=>a.reduce((s,r)=>s+cost(r),0); const f4=n=>"$"+n.toFixed(4); console.log("=== Cost summary ==="); console.log("today: "+f4(sum(latest.filter(r=>day(r)===today)))); console.log("yesterday: "+f4(sum(latest.filter(r=>day(r)===d)))); console.log("total: "+f4(sum(latest))+" ("+latest.length+" sessions)"); '实现要点逐条对应:
- 去重:
bySession以session_id(缺失时回退到transcript_path或timestamp)为键,仅保留时间戳最新的一行——对应 SQL 版中「按会话取累计快照」的要求。 - 日切割:
day()截取 ISO 时间戳前 10 位得到YYYY-MM-DD;today用new Date().toISOString().slice(0,10)计算,yesterday用Date.now()-864e5(即 24 小时前)计算——对应 SQL 版中的date('now')与date('now','-1 day')。 - 成本归一:
cost()用Number(...) || 0兜底,任何缺失或非法值都按 0 处理,对应 SQL 版中的COALESCE(..., 0)。 - 金额格式:
f4统一输出四位小数,即文档约定的「1 美元以下金额保留 4 位小数」规则。
按维度分组:项目 / 工具 / 模型
日文文档提供了两个 SQLite 分组查询,分别按项目与工具聚合,均按成本降序排列:
# 按项目 sqlite3 -header -column ~/.claude-cost-tracker/usage.db " SELECT project, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls FROM usage GROUP BY project ORDER BY cost DESC; " # 按工具 sqlite3 -header -column ~/.claude-cost-tracker/usage.db " SELECT tool_name, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls FROM usage GROUP BY tool_name ORDER BY cost DESC; "当前仓库的 JSONL 行结构中没有project与tool_name字段,其维度被model取代。命令文档中的按模型分组实现为:
console.log("\n=== By model ==="); for(const [k,v] of by(r=>r.model))console.log(f4(v).padStart(12)+" "+k);其中by是通用的分组聚合器:
const by=(key)=>{ const m=new Map(); for(const r of latest){ const k=key(r)||"(unknown)"; m.set(k,(m.get(k)||0)+cost(r)); } return [...m.entries()].sort((a,b)=>b[1]-a[1]); };- 分组键取
r.model,缺失时归入(unknown)组; - 求和后按成本降序排列,输出时
f4(...).padStart(12)右对齐保证列对齐; - 语义与 SQL 版的
GROUP BY ... ORDER BY cost DESC完全一致。
最近 7 天趋势
日文文档的 SQLite 版本:
sqlite3 -header -column ~/.claude-cost-tracker/usage.db " SELECT date(timestamp) AS date, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls FROM usage GROUP BY date(timestamp) ORDER BY date DESC LIMIT 7; "JSONL 版本实现:
console.log("\n=== Last 7 days ==="); const days=new Map(); for(const r of latest){const k=day(r);days.set(k,(days.get(k)||0)+cost(r));} [...days.entries()].sort((a,b)=>b[0]<a[0]?-1:1).slice(0,7).forEach(([k,v])=>console.log(k+" "+f4(v)));按日期分组求和后,按日期倒序取前 7 天,输出格式为日期 + 成本。
CSV 导出:/cost-report csv
当用户以/cost-report csv调用时,导出最近的使用行。SQLite 版本使用显式列名:
sqlite3 -csv -header ~/.claude-cost-tracker/usage.db " SELECT timestamp, project, tool_name, input_tokens, output_tokens, cost_usd, session_id, model FROM usage ORDER BY timestamp DESC LIMIT 100; "JSONL 版本使用与行结构一致的列:
node -e ' const fs=require("fs"),os=require("os"),path=require("path"); const f=path.join(os.homedir(),".claude","metrics","costs.jsonl"); if(!fs.existsSync(f)){console.error("no data");process.exit(0);} const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean).slice(-100); console.log("timestamp,session_id,model,input_tokens,output_tokens,cache_write_tokens,cache_read_tokens,estimated_cost_usd"); for(const r of rows)console.log([r.timestamp,r.session_id,r.model,r.input_tokens,r.output_tokens,r.cache_write_tokens,r.cache_read_tokens,r.estimated_cost_usd].join(",")); '要点:
- 取原始行数组的最后 100 行(
slice(-100),等价于 SQL 的ORDER BY timestamp DESC LIMIT 100的最近行); - 首行输出带表头的 CSV 列名;
- 字段用逗号直接拼接;与 SQL 版相比,字段从
cost_usd变为estimated_cost_usd,并增加了cache_write_tokens/cache_read_tokens两个缓存字段,使 CSV 保留了完整的会话成本结构。
报告格式约定
命令将响应整理为固定结构:
- 汇总(Summary):今日、昨日、总计、调用次数(会话数);
- 按维度分组:按总成本降序排名(SQLite 版为项目/工具,JSONL 版为模型);
- 最近 7 天:日期、成本、调用次数。
金额格式化规则:1 美元以下保留 4 位小数($0.0123),更大金额可缩减位数。命令本身不做价格估算——它只信任追踪器写入的预计算成本值。
源码级原理:stop:cost-tracker钩子如何产出数据
要彻底理解报告,需要知道costs.jsonl的行是怎么算出来的。核心实现在 scripts/hooks/cost-tracker.js,该钩子通过 hooks/hooks.json 注册为stop:cost-tracker,匹配所有会话(matcher: ".*"),由run-with-flags.js以minimal,standard,strict模式运行,异步执行、超时 10 秒。
1. 数据不是从 Stop 载荷直接拿的
Stop 事件的 stdin 载荷形如{ session_id, transcript_path, cwd, hook_event_name, ... },并不直接包含usage或model字段。钩子文件头部注释记录了一个真实的教训:旧版本期望这些字段存在,结果 52 天内在 2340 行记录中非零 Token 率为 0.0%——全部是零值行。修复方式是改读 Claude Code 已经传入的 transcript 文件。
2. Transcript 解析与按 message.id 去重
Claude Code 的 transcript 是 JSONL,每行一个内容块。sumUsageFromTranscript()只处理type === "assistant"且带message.usage的行,并做一次关键去重:
Claude Code 每个内容块写一行 JSONL,因此同一次 API 响应(同一个
message.id)会横跨多行 assistant 记录,且每行重复相同的 usage。逐行求和会把总量放大 2.5~3 倍。实测:一个 704 行 assistant 记录的会话只有 286 个唯一message.id——逐行求和得 $867,按 id 去重后仅 $333。
因此钩子以message.id为键、保留每个 id 的最后一行 usage,再累计input_tokens、output_tokens、cache_creation_input_tokens、cache_read_input_tokens。对没有message.id的旧格式行,用合成键__line_N维持原有逐行行为。模型名取最后一个非unknown值。
3. 内置费率表
钩子内置了一张近似费率表(每百万 Token 美元),用于把 Token 折算为成本:
| 档位 | 输入 | 输出 | 缓存写入 | 缓存读取 |
|---|---|---|---|---|
haiku | 1.00 | 5.0 | 1.25 | 0.10 |
sonnet(Sonnet 4.6 等) | 3.00 | 15.0 | 3.75 | 0.30 |
sonnet5 | 2.00 | 10.0 | 2.50 | 0.20 |
opus(Opus 4.5+) | 5.00 | 25.0 | 6.25 | 0.50 |
opusLegacy(Opus 3 / 4.0 / 4.1) | 15.00 | 75.0 | 18.75 | 1.50 |
fable/mythos | 10.00 | 50.0 | 12.50 | 1.00 |
模型匹配规则由getRates()实现:包含fable/mythos走 Fable 档;包含haiku走 Haiku 档;正则精确匹配sonnet-5(避免把claude-sonnet-50误判);LEGACY_OPUS_RE用于识别带日期快照的 Opus 4.0(如claude-opus-4-20250514)等旧档模型。
4. Harness 权威成本优先
钩子还支持一个可选的权威成本通道:如果用户的 statusline 在每次渲染时把 Claude Code 直接下发的cost.total_cost_usd写入<os.tmpdir()>/harness-cost-<session_id>.json(内容为{ts, cost_usd}),钩子会优先采用该值,前提是缓存新鲜(HARNESS_COST_MAX_AGE_SECONDS = 300,即 5 秒内)。优先的原因写在文件头:
- 硬编码费率表无法表达 Opus 4.7 的 >200K Token 2x 档位与 1 小时缓存 2x 档位,长会话会低估;
- 对整个 transcript 求和会在
--resume边界重复计算,而cost.total_cost_usd是按进程计的,不会漂移。
缓存缺失或过期则回退到 transcript 求和值。最终行写入~/.claude/metrics/costs.jsonl(实际目录经getClaudeDir()解析,测试中以临时 HOME 覆盖验证)。
5. 失败不阻塞会话
整个处理包裹在 try/catch 中,任何解析错误都不会让 Stop 钩子失败(fail-open);stdin 超过 1MB 时(Stop 载荷携带last_assistant_message时常超旧版 64KB 上限)抑制透传并在 stderr 告警,避免把截断的 JSON 回显到 stdout 被报告为钩子失败。
测试验证:行为被测试用例锁死
tests/hooks/cost-tracker.test.js(node tests/hooks/cost-tracker.test.js运行)覆盖了上述全部关键行为,可作为复现与验证的依据:
- stdin 透传:钩子必须原样回写 stdin,空输入与非法 JSON 均不崩溃(fail-open 契约);
- Token 汇总:从 transcript 正确累计 input/output/cache 四类 Token,最后出现的 assistant 模型被记录;
- 按 message.id 去重:同一
msg_01AAA出现 3 行只计 1 次,input_tokens精确为 1025 而非 3 倍; - 会话 ID 优先级:
ECC_SESSION_ID优先于CLAUSE_SESSION_ID,其次才用载荷中的session_id——这是与 ECC2 会话关联的约定; - Harness 成本优先:新鲜缓存(
cost_usd: 1.23)胜过 transcript 估算,且 Token 仍来自 transcript;超过 300 秒的陈旧缓存(999.99)被忽略并回退到 transcript 估算; - 费率准确性:Sonnet 5 的 1M/1M Token 精确等于 $12;含缓存写入/读取时 $14.70;Sonnet 4.6 保持 $18 且不被误判为 Sonnet 5;带日期的 Sonnet 5(
claude-sonnet-5-20261001)按 $12;claude-sonnet-50近误判回退 $18;带日期的 Opus 4.0 保留 $15/$75 旧档(1M/1M = $90),Opus 4.5 用现行 $5/$25(= $30)。
这些断言直接印证了「报告只信任预计算值」的约定:估算精度由钩子侧统一保证,报告侧不重复定价。
关联资源
- 命令文档(当前实现):commands/cost-report.md(英文原版,数据为
costs.jsonl) - 日文命令文档(SQLite 旧版脉络):docs/ja-JP/commands/cost-report.md
- 钩子源码:scripts/hooks/cost-tracker.js
- 钩子注册:hooks/hooks.json
- 测试用例:tests/hooks/cost-tracker.test.js
- 成本追踪技能:skills/cost-tracking/SKILL.md(含行结构表格、反模式清单:不要全量求和、不要拿原始 Token 自行估价、不要假定日志存在、不要在面向用户的回答中硬编码模型价格)
- 命令注册表:docs/COMMAND-REGISTRY.json
小结
/cost-report的价值在于把「成本数据收集」与「成本数据展示」解耦:追踪侧由stop:cost-tracker钩子在会话停止时按message.id去重汇总 transcript,叠加内置费率表或优先采用 harness 权威成本,产出带estimated_cost_usd的累计快照;报告侧只做「每会话取最新一行 → 去重 → 聚合」这一件事,通过摘要、按模型分组、最近 7 天与 CSV 导出四种视图回答「今天/昨天花了多少、哪个模型最贵、趋势如何」等问题。理解行语义(累计快照而非增量行)是正确解读一切报告的前提,也是本命令最值得记住的设计要点。
【免费下载链接】ECCThe agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.项目地址: https://gitcode.com/GitHub_Trending/ev/ECC
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考