Tracks Claude Code token usage and cost by skill and model (current session, per project directory, and all history)

English | 繁體中文
Tracks Claude Code token usage and cost by skill and model, for the current session, each project directory, and your entire history.
Command output and the summary line are in Traditional Chinese by default. Run
/skill-cost lang ento switch to English.
/plugin install skill-cost --marketplace bobosun0713/claude-mods
python3: parses past transcripts (scripts/scan.py). Without it you can still see the current session's cost, but history reports show a scan failure.| Command | Description | |
|---|---|---|
/skill-cost | Current session (actual cost) and history for the current project directory | |
/skill-cost all | Totals across your entire history, plus a cost ranking of project directories | |
/skill-cost scan | Refresh official prices, rescan transcripts, and show your entire history | |
/skill-cost prices | Model prices in use and where each comes from | |
/skill-cost reset | Reset the current session's statistics | |
/skill-cost hide | Hide the summary line above the prompt | |
/skill-cost show | Show the summary line above the prompt | |
| `/skill-cost lang [en\ | zh]` | Switch reports and the summary line between English and Traditional Chinese; with no argument, toggles. The choice is kept across sessions |
A line above the prompt shows the current session's cost, how many skills and models it used, and the current project directory's total cost (marked ≈ when part of it is estimated):
本次 US$9.23 · 1 種 skill · 1 種模型 · 此資料夾歷來 ≈US$327.66 · /skill-cost 看明細
Each scope has two tables.
Skill table
| Column | Description |
|---|---|
| Skill | Skill name. (no skill) is calls made with no skill loaded, (subagent) is subagent calls, and (before load) is cost incurred before the mod loaded (shown as (無 skill), (subagent) and (載入前) in Chinese) |
| 金額(USD) | Cost in US dollars |
| 占比 | Share of the scope's total cost |
| 次數 | Number of model calls |
| 輸入 / 輸出 | Uncached input tokens and output tokens |
| 快取讀 / 快取寫 | Prompt cache read and write tokens |
Model table
Lists each model's cost, call count, output tokens, and cache read tokens, plus the skills it was called under and how many times (for example git-commit×5).
Tables adapt to the terminal width: when space is short, lower-priority columns are hidden and long values wrap.
Taken from Claude Code's session cost ledger, the same source as /cost. After each model call, the increase in cost is attributed to the active skill.
History parses the transcripts (JSONL) under ~/.claude/projects/ and determines each session's cost in this order:
/cost). Every session after you install the mod uses its actual cost; the split across skills and models follows the estimated proportions.Report headings show how many sessions in each scope are actual and how many are estimated, for example 52 個 session,3 個實際、49 個估算 (52 sessions: 3 actual, 49 estimated).
Other rules:
Estimates exclude discounts, Batch pricing, and data residency surcharges. Models missing from the price list still have their tokens counted, but their estimated cost is 0 and the report notes them.
When CLAUDE_CONFIG_DIR is set, the mod reads $CLAUDE_CONFIG_DIR/projects/ instead.
Once a skill loads (through the Skill tool or a slash command), every model call until the user's next message counts toward that skill. If several skills load in one turn, later costs go to the last one loaded.
Estimates use prices from the first of these sources that has them (US dollars per million tokens):
| Priority | Source | Location |
|---|---|---|
| 1 | Custom prices | ~/.claude/skill-cost/prices.json |
| 2 | Official prices | The Claude pricing page (read from its Markdown version), cached in ~/.claude/skill-cost/online-prices.json |
| 3 | Bundled prices | scripts/prices.json, synced monthly from the official pricing page by this repository's GitHub Actions |
Run /skill-cost prices to see the price, source, and last official update for each model.
/skill-cost scan forces a refresh.-20251001) or a suffix like [1m]. Models that don't match are listed as unpriced rather than priced like a similar model.Bundled prices, the official price cache, and custom prices share one format. Every value is a price (US dollars per million tokens); nothing is derived from multipliers:
{
"models": {
"claude-opus-5-5": {
"input": 4.0, "output": 20.0, "cacheRead": 0.2, "cacheWrite5m": 5.0, "cacheWrite1h": 8.0,
"fastInput": 8.0, "fastOutput": 40.0
}
}
}
| Field | Description |
|---|---|
input / output | Input and output rates (required) |
cacheRead | Cache read rate |
cacheWrite5m / cacheWrite1h | 5-minute and 1-hour cache write rates |
fastInput / fastOutput | Fast mode input and output rates. Fast mode cache rates are scaled by fastInput ÷ input, because the official pricing applies cache multipliers on top of fast mode rates |
above100k | Rates for prompts over 100,000 tokens, with the same fields (currently Claude Haiku 5.5 only) |
If a call uses a token type whose price is missing (for example a fast mode call with no fastInput), the call is listed as unpriced instead of being charged at a guessed rate.
Create ~/.claude/skill-cost/prices.json (or $CLAUDE_CONFIG_DIR/skill-cost/prices.json when CLAUDE_CONFIG_DIR is set) and list only the models you want to add or correct, for example a new model that isn't listed yet:
{
"models": {
"claude-example-6": { "input": 3.0, "output": 15.0, "cacheRead": 0.3 }
}
}
/skill-cost scan after editing to apply the change.skill.prompt event isn't delivered to plugins you install yourself, so the current session's cost all counts toward (no skill). History is unaffected./skill-cost scan to update immediately./skill-cost lang, the command's description in the / menu changes from the next session. Error details passed through from scripts/scan.py (such as a failed price parse) stay in Chinese.| Path | Description |
|---|---|
hooks/register.tsx | Hooks module: cost tracking, saving actual costs, commands, summary line |
scripts/scan.py | Transcript parsing and cost calculation |
scripts/prices.json | Bundled prices (fallback when official prices are unavailable) |
scripts/update_prices.py | Updates bundled prices from the official pricing page (used by GitHub Actions; can also be run by hand) |
scripts/test_scan.py | Unit tests for price parsing and pricing (python3 -m unittest discover -s skill-cost/scripts) |
types/index.d.ts | $.state type declarations |
tests/ | Unit tests and summary line rendering tests |
hooks/register.tsx 825 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnUsage } from 'claude-code'
3
4import type { CostRow, FolderRecord, History, Lang, Ledger, ModelRow, ModelRows, OnlineStatus, Rows } from '../types'
5
6// Language-neutral names kept in data; shown through label(). NO_SKILL and SUBAGENT match scripts/scan.py
7const NO_SKILL = '(no skill)'
8const SUBAGENT = '(subagent)'
9const BEFORE = '(before load)'
10const UNKNOWN_MODEL = '(unknown model)'
11
12// ── Language ────────────────────────────────────────────
13
14const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
15
16const TEXT = {
17 zh: {
18 help: [
19 '其他用法:',
20 ' /skill-cost all 全部使用紀錄(所有資料夾合併,含各資料夾排行)',
21 ' /skill-cost scan 更新官方價格並重新掃描,顯示全部使用紀錄',
22 ' /skill-cost prices 目前採用的模型價格與來源',
23 ' /skill-cost reset 本次 session 歸零',
24 ' /skill-cost hide 隱藏提示列(show 顯示)',
25 ' /skill-cost lang 切換中文 / English(lang en、lang zh 指定)',
26 ].join('\n'),
27 description: '依 skill 與模型列出花費(all 全部紀錄、scan 重新掃描、prices 價格表、reset 歸零本次、hide/show 提示列、lang 切換語言)',
28 empty: '(尚無紀錄)',
29 summary: (cost: string, skills: number, models: number) => `${cost} · ${skills} 種 skill · ${models} 種模型`,
30 allActual: (sessions: number) => `${sessions} 個 session,皆為實際費用`,
31 mixed: (sessions: number, actual: number) => `${sessions} 個 session,${actual} 個實際、${sessions - actual} 個估算`,
32 estimated: (sessions: number) => `${sessions} 個 session,估算`,
33 scanning: '(掃描對話紀錄中…)',
34 scanFailed: (error: string) => `(掃描失敗:${error})`,
35 unpriced: (models: string) => `${models} 無牌價,金額未計`,
36 onlineFailed: '官方價格更新失敗,沿用先前價格',
37 notes: (notes: string[]) => `(${notes.join(';')})`,
38 session: (summary: string) => `本次 session(實際金額):${summary}`,
39 folder: (basis: string, folder: string, summary: string, note: string) => `此資料夾歷來(${basis})${folder}:${summary} ${note}`,
40 noFolders: (note: string) => `還沒有任何資料夾的紀錄。${note}`,
41 all: (folders: number, basis: string, summary: string, note: string) => `全部使用紀錄(${folders} 個資料夾,${basis}):${summary} ${note}`,
42 byFolder: '各資料夾:',
43 fetched: (hours: number) => `官方價格更新於 ${hours} 小時前`,
44 notFetched: '尚未取得官方價格',
45 fetchFailed: (error: string) => `;最近一次更新失敗:${error}`,
46 prices: (fetched: string) => `目前採用的價格(USD / 百萬 token)。${fetched}`,
47 priority: '優先順序:自訂 > 官方 > 內建;自訂檔位於 ~/.claude/skill-cost/prices.json',
48 pricesFailed: (error: string) => `無法讀取價格:${error}`,
49 reset: '本次 session 已歸零。',
50 hidden: '已隱藏提示列。',
51 shown: '已顯示提示列。',
52 switched: '已切換為中文。',
53 badLang: (arg: string) => `不支援的語言:${arg}(可用 zh、en)`,
54 band: (summary: string, folder: string) => `本次 ${summary} · 此資料夾歷來 ≈${folder} · /skill-cost 看明細`,
55 },
56 en: {
57 help: [
58 'More commands:',
59 ' /skill-cost all Entire history (all folders combined, with a folder ranking)',
60 ' /skill-cost scan Refresh official prices, rescan, and show the entire history',
61 ' /skill-cost prices Model prices in use and their sources',
62 ' /skill-cost reset Reset this session',
63 ' /skill-cost hide Hide the summary line (show to show it)',
64 ' /skill-cost lang Switch between 中文 and English (lang en, lang zh to pick)',
65 ].join('\n'),
66 description: 'Cost by skill and model (all: entire history, scan: rescan, prices: price list, reset: reset session, hide/show: summary line, lang: switch language)',
67 empty: '(no records yet)',
68 summary: (cost: string, skills: number, models: number) => `${cost} · ${plural(skills, 'skill')} · ${plural(models, 'model')}`,
69 allActual: (sessions: number) => `${plural(sessions, 'session')}, all actual`,
70 mixed: (sessions: number, actual: number) => `${plural(sessions, 'session')}: ${actual} actual, ${sessions - actual} estimated`,
71 estimated: (sessions: number) => `${plural(sessions, 'session')}, estimated`,
72 scanning: '(scanning transcripts…)',
73 scanFailed: (error: string) => `(scan failed: ${error})`,
74 unpriced: (models: string) => `${models} unpriced, cost not counted`,
75 onlineFailed: 'official price update failed, using previous prices',
76 notes: (notes: string[]) => `(${notes.join('; ')})`,
77 session: (summary: string) => `This session (actual cost): ${summary}`,
78 folder: (basis: string, folder: string, summary: string, note: string) => `This folder, all time (${basis}) ${folder}: ${summary} ${note}`,
79 noFolders: (note: string) => `No folder records yet. ${note}`,
80 all: (folders: number, basis: string, summary: string, note: string) => `Entire history (${plural(folders, 'folder')}, ${basis}): ${summary} ${note}`,
81 byFolder: 'By folder:',
82 fetched: (hours: number) => `Official prices updated ${plural(hours, 'hour')} ago`,
83 notFetched: 'Official prices not fetched yet',
84 fetchFailed: (error: string) => `; last update failed: ${error}`,
85 prices: (fetched: string) => `Prices in use (USD / million tokens). ${fetched}`,
86 priority: 'Priority: custom > official > bundled; custom file at ~/.claude/skill-cost/prices.json',
87 pricesFailed: (error: string) => `Could not read prices: ${error}`,
88 reset: 'This session has been reset.',
89 hidden: 'Summary line hidden.',
90 shown: 'Summary line shown.',
91 switched: 'Switched to English.',
92 badLang: (arg: string) => `Unsupported language: ${arg} (use zh or en)`,
93 band: (summary: string, folder: string) => `This session ${summary} · this folder all time ≈${folder} · /skill-cost for details`,
94 },
95} satisfies Record<Lang, unknown>
96
97/** Display names of the special skill and model names and the price sources */
98const NAMES: Record<string, Record<Lang, string>> = {
99 [NO_SKILL]: { zh: '(無 skill)', en: '(no skill)' },
100 [SUBAGENT]: { zh: '(subagent)', en: '(subagent)' },
101 [BEFORE]: { zh: '(載入前)', en: '(before load)' },
102 [UNKNOWN_MODEL]: { zh: '(未知模型)', en: '(unknown model)' },
103 bundled: { zh: '內建', en: 'bundled' },
104 official: { zh: '官方', en: 'official' },
105 custom: { zh: '自訂', en: 'custom' },
106}
107
108const label = (name: string, lang: Lang) => NAMES[name]?.[lang] ?? name
109
110export const isLang = (value: unknown): value is Lang => value === 'zh' || value === 'en'
111
112// Rescan when a report is opened this long after the last scan
113const RESCAN_MS = 60_000
114const EMPTY: Ledger = { lastUsd: null, rows: {}, models: {} }
115
116const ledger = atom({ plugin: 'skill-cost', key: 'ledger' } as const, EMPTY)
117const isHidden = atom({ plugin: 'skill-cost', key: 'isHidden' } as const, false)
118const history = atom({ plugin: 'skill-cost', key: 'history' } as const, null as History | null)
119const isScanning = atom({ plugin: 'skill-cost', key: 'isScanning' } as const, false)
120const lang = atom({ plugin: 'skill-cost', key: 'lang' } as const, 'zh' as Lang)
121
122// The language setting is kept across sessions
123const LANG_KEY = 'lang'
124
125const blankRow = (): CostRow => ({ usd: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, steps: 0 })
126
127const addRow = (a: CostRow | undefined, b: CostRow): CostRow => {
128 const base = a ?? blankRow()
129
130 return {
131 usd: base.usd + b.usd,
132 input: base.input + b.input,
133 output: base.output + b.output,
134 cacheRead: base.cacheRead + b.cacheRead,
135 cacheWrite: base.cacheWrite + b.cacheWrite,
136 steps: base.steps + b.steps,
137 }
138}
139
140const addModel = (models: ModelRows | undefined, model: string, skill: string, step: CostRow): ModelRows => {
141 const row = models?.[model]
142 const skills = row?.skills ?? {}
143
144 return {
145 ...models,
146 [model]: { ...addRow(row, step), skills: { ...skills, [skill]: (skills[skill] ?? 0) + step.steps } },
147 }
148}
149
150/** Amount inside a table: the header already says USD */
151const usd = (n: number) => `$${n.toFixed(n < 1 ? 3 : 2)}`
152
153/** Amount outside a table: no header, so mark it as US dollars */
154const usdLabel = (n: number) => `US${usd(n)}`
155
156const tokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
157
158const percent = (part: number, sum: number) => (sum > 0 ? `${Math.round((part / sum) * 100)}%` : '')
159
160/** Terminal display width: CJK and full-width characters take two cells */
161const width = (text: string) => [...text].reduce((sum, ch) => sum + ((ch.codePointAt(0) ?? 0) >= 0x2e80 ? 2 : 1), 0)
162
163export const padEnd = (text: string, size: number) => text + ' '.repeat(Math.max(0, size - width(text)))
164
165const padStart = (text: string, size: number) => ' '.repeat(Math.max(0, size - width(text))) + text
166
167const byCost = <T extends CostRow>(rows: Record<string, T>) => Object.entries(rows).sort(([, a], [, b]) => b.usd - a.usd)
168
169const skillCount = (rows: Rows) => Object.keys(rows).filter(name => name !== NO_SKILL && name !== BEFORE).length
170
171const total = (rows: Rows) => Object.values(rows).reduce((sum, row) => sum + row.usd, 0)
172
173/** Usage of one model call; the cost is the change in the session's running total */
174export const stepRow = (state: Ledger, sessionUsd: number, usage: TurnUsage | null): CostRow => {
175 const base = state.lastUsd ?? sessionUsd
176 // A smaller running total means /clear restarted the count, so start from the new baseline
177 const delta = sessionUsd >= base ? sessionUsd - base : sessionUsd
178
179 return {
180 usd: delta,
181 input: usage?.input_tokens ?? 0,
182 output: usage?.output_tokens ?? 0,
183 cacheRead: usage?.cache_read_input_tokens ?? 0,
184 cacheWrite: usage?.cache_creation_input_tokens ?? 0,
185 steps: 1,
186 }
187}
188
189/** Charge one model call's cost to its skill and model */
190export const charge = (state: Ledger, skill: string, model: string, sessionUsd: number, usage: TurnUsage | null): Ledger => {
191 const step = stepRow(state, sessionUsd, usage)
192
193 return {
194 lastUsd: sessionUsd,
195 rows: { ...state.rows, [skill]: addRow(state.rows[skill], step) },
196 models: addModel(state.models, model, skill, step),
197 }
198}
199
200/** Add a row for the part of the session's total cost not yet recorded (before the mod loaded) */
201export const withGap = (state: Ledger, sessionUsd: number): Ledger => {
202 const gap = sessionUsd - total(state.rows)
203
204 if (gap < 0.0005) {
205 return state
206 }
207
208 return { ...state, rows: { ...state.rows, [BEFORE]: addRow(state.rows[BEFORE], { ...blankRow(), usd: gap }) } }
209}
210
211// ── Tables ──────────────────────────────────────────────
212
213/**
214 * `keep`: lower numbers are hidden first, 0 is never hidden; `isFlex`: takes the remaining width and wraps long text
215 */
216type Column = { title: string; width: number; isRight?: boolean; keep?: number; isFlex?: boolean }
217
218type Table = { columns: Column[]; rows: string[][]; lang?: Lang }
219
220/** Column title and width per language; the width always fits the title */
221type LocalColumn = Omit<Column, 'title' | 'width'> & { title: Record<Lang, string>; width: number | Record<Lang, number> }
222
223const columnsOf = (columns: LocalColumn[], lang: Lang): Column[] =>
224 columns.map(column => {
225 const size = typeof column.width === 'number' ? column.width : column.width[lang]
226
227 return { ...column, title: column.title[lang], width: Math.max(size, width(column.title[lang])) }
228 })
229
230const GAP = 2
231
232const COST = { zh: '金額(USD)', en: 'Cost(USD)' }
233const SHARE = { zh: '占比', en: 'Share' }
234const CALLS = { zh: '次數', en: 'Calls' }
235const INPUT = { zh: '輸入', en: 'Input' }
236const OUTPUT = { zh: '輸出', en: 'Output' }
237const CACHE_READ = { zh: '快取讀', en: 'Cache R' }
238const MODEL = { zh: '模型', en: 'Model' }
239
240const SKILL_COLUMNS: LocalColumn[] = [
241 { title: { zh: 'Skill', en: 'Skill' }, width: 14, isFlex: true, keep: 0 },
242 { title: COST, width: 10, isRight: true, keep: 0 },
243 { title: SHARE, width: 4, isRight: true, keep: 4 },
244 { title: CALLS, width: 4, isRight: true, keep: 5 },
245 { title: INPUT, width: 6, isRight: true, keep: 1 },
246 { title: OUTPUT, width: 6, isRight: true, keep: 6 },
247 { title: CACHE_READ, width: 6, isRight: true, keep: 3 },
248 { title: { zh: '快取寫', en: 'Cache W' }, width: 6, isRight: true, keep: 2 },
249]
250
251const MODEL_COLUMNS: LocalColumn[] = [
252 { title: MODEL, width: 16, keep: 0 },
253 { title: COST, width: 10, isRight: true, keep: 0 },
254 { title: SHARE, width: 4, isRight: true, keep: 3 },
255 { title: CALLS, width: 4, isRight: true, keep: 4 },
256 { title: OUTPUT, width: 6, isRight: true, keep: 2 },
257 { title: CACHE_READ, width: 6, isRight: true, keep: 1 },
258 { title: { zh: '在哪些 skill 下呼叫', en: 'Called under skills' }, width: 16, isFlex: true, keep: 0 },
259]
260
261const FOLDER_COLUMNS: LocalColumn[] = [
262 { title: COST, width: 10, isRight: true, keep: 0 },
263 { title: { zh: 'Skill', en: 'Skills' }, width: 5, isRight: true, keep: 2 },
264 { title: { zh: '模型', en: 'Models' }, width: 4, isRight: true, keep: 1 },
265 { title: { zh: 'Session', en: 'Sessions' }, width: 7, isRight: true, keep: 3 },
266 { title: { zh: '資料夾', en: 'Folder' }, width: 16, isFlex: true, keep: 0 },
267]
268
269const spanOf = (columns: Column[]) => columns.reduce((sum, column) => sum + column.width, 0) + GAP * Math.max(0, columns.length - 1)
270
271/** Hide lower-priority columns to fit the available width; flex columns take what is left */
272export const fit = (table: Table, room: number): Table => {
273 let picked = table.columns.map((column, index) => ({ column, index }))
274
275 while (spanOf(picked.map(one => one.column)) > room) {
276 const droppable = picked.filter(one => (one.column.keep ?? 0) > 0)
277
278 if (droppable.length === 0) {
279 break
280 }
281
282 const drop = droppable.reduce((low, one) => ((one.column.keep ?? 0) < (low.column.keep ?? 0) ? one : low))
283 picked = picked.filter(one => one !== drop)
284 }
285
286 const spare = Math.max(0, room - spanOf(picked.map(one => one.column)))
287 const flexCount = picked.filter(one => one.column.isFlex).length
288 const columns = picked.map(({ column }) =>
289 column.isFlex && flexCount > 0 ? { ...column, width: column.width + Math.floor(spare / flexCount) } : column,
290 )
291
292 return { ...table, columns, rows: table.rows.map(cells => picked.map(one => cells[one.index] ?? '')) }
293}
294
295export const skillTable = (rows: Rows, lang: Lang = 'zh'): Table => {
296 const sum = total(rows)
297
298 return {
299 lang,
300 columns: columnsOf(SKILL_COLUMNS, lang),
301 rows: byCost(rows).map(([name, row]) => [
302 label(name, lang),
303 usd(row.usd),
304 percent(row.usd, sum),
305 String(row.steps),
306 tokens(row.input),
307 tokens(row.output),
308 tokens(row.cacheRead),
309 tokens(row.cacheWrite),
310 ]),
311 }
312}
313
314const skillsOf = (row: ModelRow, lang: Lang) =>
315 Object.entries(row.skills)
316 .sort(([, a], [, b]) => b - a)
317 .map(([name, count]) => `${label(name, lang)}×${count}`)
318 .join(', ')
319
320export const modelTable = (models: ModelRows, lang: Lang = 'zh'): Table => {
321 const sum = Object.values(models).reduce((acc, row) => acc + row.usd, 0)
322
323 return {
324 lang,
325 columns: columnsOf(MODEL_COLUMNS, lang),
326 rows: byCost(models).map(([name, row]) => [
327 label(name, lang),
328 usd(row.usd),
329 percent(row.usd, sum),
330 String(row.steps),
331 tokens(row.output),
332 tokens(row.cacheRead),
333 skillsOf(row, lang),
334 ]),
335 }
336}
337
338const folderTable = (records: FolderRecord[], lang: Lang): Table => ({
339 lang,
340 columns: columnsOf(FOLDER_COLUMNS, lang),
341 rows: records.map(one => [
342 usd(total(one.rows)),
343 String(skillCount(one.rows)),
344 String(Object.keys(one.models ?? {}).length),
345 String(one.sessions),
346 one.folder,
347 ]),
348})
349
350/** Split text into pieces by display width for wrapping columns; prefer breaking after ", " and "/" */
351export const chunk = (text: string, size: number): string[] => {
352 const parts: string[] = []
353 let line = ''
354
355 const push = (word: string) => {
356 if (line && width(line + word) > size) {
357 parts.push(line.trimEnd())
358 line = ''
359 }
360
361 // A single piece wider than the column is split character by character
362 for (const ch of word) {
363 if (width(line + ch) > size) {
364 parts.push(line)
365 line = ''
366 }
367
368 line += ch
369 }
370 }
371
372 text.split(/(?<=, |\/)/).forEach(push)
373
374 return [...parts, line]
375}
376
377export type TableLine = { kind: 'head' | 'rule' | 'row' | 'empty'; text: string; row?: number }
378
379/** Lay a table out as lines of text: pick columns by width first, then wrap long cells */
380export const tableLines = (table: Table, room: number): TableLine[] => {
381 const { columns, rows } = fit(table, room)
382
383 if (rows.length === 0) {
384 return [{ kind: 'empty', text: TEXT[table.lang ?? 'zh'].empty }]
385 }
386
387 const cell = (text: string, column: Column) => (column.isRight ? padStart(text, column.width) : padEnd(text, column.width))
388 const join = (cells: string[]) => cells.map((text, i) => cell(text, columns[i] ?? { title: '', width: 0 })).join(' '.repeat(GAP)).trimEnd()
389
390 const body = rows.flatMap((cells, row) => {
391 const pieces = cells.map((text, i) => {
392 const column = columns[i]
393
394 return column && !column.isRight ? chunk(text, column.width) : [text]
395 })
396 const height = Math.max(...pieces.map(one => one.length))
397
398 return Array.from({ length: height }, (_, n): TableLine => ({
399 kind: 'row',
400 row,
401 text: join(pieces.map(one => one[n] ?? '')),
402 }))
403 })
404
405 return [
406 { kind: 'head', text: join(columns.map(column => column.title)) },
407 { kind: 'rule', text: columns.map(column => '─'.repeat(column.width)).join('─'.repeat(GAP)) },
408 ...body,
409 ]
410}
411
412/** Plain-text table for command output */
413export const tableText = (table: Table, room = 110) =>
414 tableLines(table, room)
415 .map(line => ` ${line.text}`)
416 .join('\n')
417
418const summary = (rows: Rows, models: ModelRows, lang: Lang = 'zh') =>
419 TEXT[lang].summary(usdLabel(total(rows)), skillCount(rows), Object.keys(models).length)
420
421/** Describe how many sessions use actual cost and how many are estimated */
422export const basis = (sessions: number, actual: number, lang: Lang = 'zh') =>
423 actual >= sessions ? TEXT[lang].allActual(sessions) : actual > 0 ? TEXT[lang].mixed(sessions, actual) : TEXT[lang].estimated(sessions)
424
425const historyNote = (data: History | null, lang: Lang) => {
426 const t = TEXT[lang]
427
428 if (!data) {
429 return t.scanning
430 }
431
432 if (data.error) {
433 return t.scanFailed(data.error)
434 }
435
436 const notes = [
437 ...(data.unpriced.length ? [t.unpriced(data.unpriced.join(', '))] : []),
438 ...(data.online?.error ? [t.onlineFailed] : []),
439 ]
440
441 return notes.length ? t.notes(notes) : ''
442}
443
444export const report = (session: Ledger, folder: string, data: History | null, lang: Lang = 'zh') => {
445 const t = TEXT[lang]
446 const record = data?.folders.find(one => one.folder === folder)
447
448 return [
449 t.session(summary(session.rows, session.models ?? {}, lang)),
450 tableText(skillTable(session.rows, lang)),
451 '',
452 tableText(modelTable(session.models ?? {}, lang)),
453 '',
454 t.folder(
455 basis(record?.sessions ?? 0, record?.actualSessions ?? 0, lang),
456 folder,
457 summary(record?.rows ?? {}, record?.models ?? {}, lang),
458 historyNote(data, lang),
459 ),
460 tableText(skillTable(record?.rows ?? {}, lang)),
461 '',
462 tableText(modelTable(record?.models ?? {}, lang)),
463 ].join('\n')
464}
465/** Merge the skill and model usage of every folder into one */
466export const mergeFolders = (folders: FolderRecord[]) => {
467 const rows: Rows = {}
468 const models: ModelRows = {}
469
470 for (const one of folders) {
471 for (const [name, row] of Object.entries(one.rows)) {
472 rows[name] = addRow(rows[name], row)
473 }
474
475 for (const [name, row] of Object.entries(one.models ?? {})) {
476 const merged = models[name]
477 const skills = { ...merged?.skills }
478
479 for (const [skill, count] of Object.entries(row.skills)) {
480 skills[skill] = (skills[skill] ?? 0) + count
481 }
482
483 models[name] = { ...addRow(merged, row), skills }
484 }
485 }
486
487 return {
488 rows,
489 models,
490 sessions: folders.reduce((sum, one) => sum + one.sessions, 0),
491 actualSessions: folders.reduce((sum, one) => sum + (one.actualSessions ?? 0), 0),
492 }
493}
494
495export const folderReport = (data: History | null, lang: Lang = 'zh') => {
496 const t = TEXT[lang]
497
498 if (!data || data.folders.length === 0) {
499 return t.noFolders(historyNote(data, lang))
500 }
501
502 const all = mergeFolders(data.folders)
503
504 return [
505 t.all(data.folders.length, basis(all.sessions, all.actualSessions, lang), summary(all.rows, all.models, lang), historyNote(data, lang)),
506 tableText(skillTable(all.rows, lang)),
507 '',
508 tableText(modelTable(all.models, lang)),
509 '',
510 t.byFolder,
511 tableText(folderTable(data.folders, lang)),
512 ].join('\n')
513}
514
515// ── Prices ──────────────────────────────────────────────
516
517const configDir = async ($: EngineInterface) => (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
518
519export type PriceRow = {
520 source: string
521 input: number
522 output: number
523 cacheRead?: number
524 cacheWrite5m?: number
525 cacheWrite1h?: number
526 fastInput?: number
527 fastOutput?: number
528}
529
530const PRICE_COLUMNS: LocalColumn[] = [
531 { title: MODEL, width: 18, isFlex: true, keep: 0 },
532 { title: { zh: '來源', en: 'Source' }, width: { zh: 4, en: 8 }, keep: 5 },
533 { title: INPUT, width: 6, isRight: true, keep: 0 },
534 { title: OUTPUT, width: 6, isRight: true, keep: 0 },
535 { title: CACHE_READ, width: 6, isRight: true, keep: 4 },
536 { title: { zh: '寫 5m', en: 'Write 5m' }, width: 6, isRight: true, keep: 3 },
537 { title: { zh: '寫 1h', en: 'Write 1h' }, width: 6, isRight: true, keep: 2 },
538 { title: { zh: 'Fast 入/出', en: 'Fast in/out' }, width: 10, isRight: true, keep: 1 },
539]
540
541const rate = (n: number | undefined) => (n === undefined ? '—' : `${n}`)
542
543export const priceReport = (models: Record<string, PriceRow>, online: OnlineStatus | undefined, now: number, lang: Lang = 'zh') => {
544 const t = TEXT[lang]
545 const fetched = online?.fetchedAt ? t.fetched(Math.max(0, Math.round((now / 1000 - online.fetchedAt) / 3600))) : t.notFetched
546 const failed = online?.error ? t.fetchFailed(online.error) : ''
547
548 return [
549 t.prices(`${fetched}${failed}`),
550 t.priority,
551 tableText({
552 lang,
553 columns: columnsOf(PRICE_COLUMNS, lang),
554 rows: Object.entries(models).map(([name, row]) => [
555 name,
556 label(row.source, lang),
557 rate(row.input),
558 rate(row.output),
559 rate(row.cacheRead),
560 rate(row.cacheWrite5m),
561 rate(row.cacheWrite1h),
562 row.fastInput === undefined ? '—' : `${row.fastInput}/${rate(row.fastOutput)}`,
563 ]),
564 }),
565 ].join('\n')
566}
567
568const listPrices = async ($: EngineInterface, lang: Lang) => {
569 const config = await configDir($)
570 const { exitCode, stdout, stderr } = await $.process.run(
571 ['python3', '-I', `${$.plugin.root}/scripts/scan.py`, '--prices', `${$.plugin.root}/scripts/prices.json`, `${config}/skill-cost`],
572 { timeoutMs: 60_000 },
573 )
574
575 if (exitCode !== 0) {
576 return TEXT[lang].pricesFailed(stderr.trim().split('\n').pop() ?? `exit ${exitCode}`)
577 }
578
579 const { models, online } = JSON.parse(stdout) as { models: Record<string, PriceRow>; online?: OnlineStatus }
580
581 return priceReport(models, online, await $.clock.now(), lang)
582}
583
584// ── Transcript scan ────────────────────────────────────────
585
586type ScanOutput = {
587 folders: Record<string, Omit<FolderRecord, 'folder'>>
588 unpriced: string[]
589 online?: OnlineStatus
590}
591
592export const toHistory = (output: ScanOutput, scannedAt: number): History => ({
593 scannedAt,
594 unpriced: output.unpriced,
595 online: output.online,
596 folders: Object.entries(output.folders)
597 .map(([folder, record]) => ({ ...record, folder }))
598 .sort((a, b) => total(b.rows) - total(a.rows)),
599})
600
601// ── Actual cost per session ──────────────────────────────
602
603const ACTUALS_KEY = 'actuals'
604
605/** session id → actual cost (USD), kept across sessions */
606const loadActuals = async ($: EngineInterface) => ((await $.store.get(ACTUALS_KEY)) ?? {}) as Record<string, number>
607
608// Write one at a time so concurrent writes don't overwrite each other
609let saving: Promise<void> = Promise.resolve()
610
611const saveActual = ($: EngineInterface, session: string, usd: number) => {
612 saving = saving
613 .then(async () => {
614 const actuals = await loadActuals($)
615
616 if (actuals[session] !== usd) {
617 await $.store.set(ACTUALS_KEY, { ...actuals, [session]: usd })
618 }
619 })
620 .catch(() => undefined)
621
622 return saving
623}
624
625/** Read ~/.claude/projects with the bundled scripts/scan.py and store the result in history */
626const scan = async ($: EngineInterface, isRefresh = false) => {
627 if (await read($, isScanning)) {
628 return
629 }
630
631 await update($, isScanning, () => true)
632
633 try {
634 const config = await configDir($)
635 // Fill in the current session's live cost in case turn.complete hasn't saved it yet
636 const { cost } = await $.session.usage()
637 const actuals = { ...(await loadActuals($)), ...(cost ? { [await $.session.id()]: cost.usd } : {}) }
638 const { exitCode, stdout, stderr } = await $.process.run(
639 [
640 'python3',
641 '-I',
642 `${$.plugin.root}/scripts/scan.py`,
643 `${config}/projects`,
644 `${$.plugin.root}/scripts/prices.json`,
645 `${config}/skill-cost`,
646 ...(isRefresh ? ['--refresh'] : []),
647 ],
648 { timeoutMs: 120_000, stdin: JSON.stringify(actuals) },
649 )
650 const now = await $.clock.now()
651
652 if (exitCode !== 0) {
653 await update($, history, () => ({ scannedAt: now, folders: [], unpriced: [], error: stderr.trim().split('\n').pop() ?? `exit ${exitCode}` }))
654
655 return
656 }
657
658 await update($, history, () => toHistory(JSON.parse(stdout) as ScanOutput, now))
659 } catch (error) {
660 const now = await $.clock.now()
661 await update($, history, () => ({ scannedAt: now, folders: [], unpriced: [], error: String(error) }))
662 } finally {
663 await update($, isScanning, () => false)
664 }
665}
666
667const scanIfStale = async ($: EngineInterface) => {
668 const data = await read($, history)
669
670 if (!data || (await $.clock.now()) - data.scannedAt > RESCAN_MS) {
671 await scan($)
672 }
673}
674
675export const register: Register = on => {
676 // The skill costs are attributed to: cleared when the user sends a message, switched when a skill loads
677 let current = NO_SKILL
678
679 on('session.start', async ($, e, next) => {
680 const saved = await $.store.get(LANG_KEY)
681
682 if (isLang(saved)) {
683 await update($, lang, () => saved)
684 }
685
686 await $.command.register({
687 name: 'skill-cost',
688 description: TEXT[await read($, lang)].description,
689 argumentHint: '[all | scan | prices | reset | hide | show | lang [en|zh]]',
690 })
691
692 // Scan once in the background at startup without blocking the session
693 void scan($)
694
695 const state = await read($, ledger)
696
697 if (state.lastUsd === null) {
698 const { cost } = await $.session.usage()
699 await update($, ledger, s => ({ ...s, lastUsd: cost?.usd ?? 0 }))
700 }
701
702 return next(e)
703 })
704
705 on('prompt.submit', ($, e, next) => {
706 current = NO_SKILL
707
708 return next(e)
709 })
710
711 on('skill.prompt', ($, e, next) => {
712 current = e.skill
713
714 return next(e)
715 })
716
717 on('turn.complete', async ($, e, next) => {
718 const { cost } = await $.session.usage()
719
720 if (cost) {
721 await saveActual($, await $.session.id(), cost.usd)
722 }
723
724 return next(e)
725 })
726
727 on('turn.step', async function* ($, e, next) {
728 const result = yield* next(e)
729 const skill = current
730 const model = result.usage?.model ?? (e.model || UNKNOWN_MODEL)
731 const { cost } = await $.session.usage()
732
733 if (cost) {
734 await update($, ledger, state => charge(state, skill, model, cost.usd, result.usage))
735 }
736
737 return result
738 })
739
740 // The description registered at startup is fixed; follow the current language instead
741 on('command.describe', { command: 'skill-cost' }, async ($, e, next) =>
742 next({ ...e, description: TEXT[await read($, lang)].description }),
743 )
744
745 on('command.run', { command: 'skill-cost' }, async ($, e) => {
746 const arg = e.args.trim()
747 const folder = await $.session.cwd()
748 const language = await read($, lang)
749 const t = TEXT[language]
750
751 if (arg === 'lang' || arg.startsWith('lang ')) {
752 // With no language given, toggle between Chinese and English
753 const want = arg.slice('lang'.length).trim().toLowerCase() || (language === 'zh' ? 'en' : 'zh')
754
755 if (!isLang(want)) {
756 return { text: t.badLang(want) }
757 }
758
759 await update($, lang, () => want)
760 await $.store.set(LANG_KEY, want)
761 // The typeahead caches the description; ask it to describe the command again
762 $.ui.invalidate('command.describe')
763
764 return { text: TEXT[want].switched }
765 }
766
767 if (arg === 'reset') {
768 const { cost } = await $.session.usage()
769 await update($, ledger, () => ({ lastUsd: cost?.usd ?? 0, rows: {}, models: {} }))
770
771 return { text: t.reset }
772 }
773
774 if (arg === 'prices') {
775 return { text: await listPrices($, language) }
776 }
777
778 if (arg === 'scan') {
779 // Also force a refresh of official prices
780 await scan($, true)
781
782 return { text: folderReport(await read($, history), language) }
783 }
784
785 if (arg === 'hide' || arg === 'show') {
786 await update($, isHidden, () => arg === 'hide')
787
788 return { text: arg === 'hide' ? t.hidden : t.shown }
789 }
790
791 if (arg === 'all') {
792 await scanIfStale($)
793
794 return { text: folderReport(await read($, history), language) }
795 }
796
797 await scanIfStale($)
798
799 const { cost } = await $.session.usage()
800
801 return { text: `${report(withGap(await read($, ledger), cost?.usd ?? 0), folder, await read($, history), language)}\n\n${t.help}` }
802 })
803
804 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
805 if (e.props.hasSurvey || (await read($, isHidden))) {
806 return next(e)
807 }
808
809 const { cost } = await $.session.usage()
810 const session = withGap(await read($, ledger), cost?.usd ?? 0)
811 const folder = await $.session.cwd()
812 const record = (await read($, history))?.folders.find(one => one.folder === folder)
813 const language = await read($, lang)
814 const { Box, Text } = $.ui.resolve(e)
815
816 return (
817 <Box>
818 <Text dimColor>
819 {TEXT[language].band(summary(session.rows, session.models ?? {}, language), usdLabel(total(record?.rows ?? {})))}
820 </Text>
821 </Box>
822 )
823 })
824}
825types/index.d.ts 66 lines1/** Language of command output and the summary line */
2export type Lang = 'zh' | 'en'
3
4export type CostRow = {
5 usd: number
6 input: number
7 output: number
8 cacheRead: number
9 cacheWrite: number
10 steps: number
11}
12
13/** skill name → usage; calls made outside any skill are recorded under "(no skill)" */
14export type Rows = Record<string, CostRow>
15
16/** A model's usage, plus the skills it was called under (skill → call count) */
17export type ModelRow = CostRow & { skills: Record<string, number> }
18
19/** model id → usage */
20export type ModelRows = Record<string, ModelRow>
21
22export type Ledger = {
23 /** The session's running total at the last charge; null means no baseline yet */
24 lastUsd: number | null
25 rows: Rows
26 /** Missing in ledgers from older versions */
27 models?: ModelRows
28}
29
30/** One folder's totals computed from transcripts (all sessions) */
31export type FolderRecord = {
32 folder: string
33 rows: Rows
34 models?: ModelRows
35 sessions: number
36 /** How many of those sessions use actual cost; the rest are estimated */
37 actualSessions: number
38 /** Time of the latest record (ISO) */
39 lastAt: string
40}
41
42/** Result of scanning ~/.claude/projects */
43export type History = {
44 scannedAt: number
45 folders: FolderRecord[]
46 /** Models with no list price, counted at 0 */
47 unpriced: string[]
48 /** Why the scan failed, if it did */
49 error?: string
50 /** Update status of official prices */
51 online?: OnlineStatus
52}
53
54export type OnlineStatus = {
55 /** Time of the last successful fetch (seconds) */
56 fetchedAt?: number
57 /** Why the latest fetch failed; on failure, cached or bundled prices are used */
58 error?: string
59}
60
61declare module 'claude-code' {
62 interface PluginState {
63 'skill-cost': { ledger: Ledger; isHidden: boolean; history: History | null; isScanning: boolean; lang: Lang }
64 }
65}
66