SLOPSHOPPER

skill-cost

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

bandcommandpromptprocess
v0.2.2no licenseupdated 2026-10-18bobosun0713/claude-mods/skill-cost
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-cost
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /skill-cost ⎿ skill-cost: 本次 session(實際金額):US$0.420 · 0 種 skill · 0 種模型 ⎿ skill-cost: Skill 金額(USD) 占比 次數 輸入 輸出 快取讀 快取寫 ⎿ skill-cost: ──────────────────────────────────────────────────────────────────────────────────────────────────────────── ⎿ skill-cost: (載入前) $0.420 100% 0 0 0 0 0 ⎿ skill-cost: ⎿ skill-cost: (尚無紀錄) 本次 US$0.420 · 0 種 skill · 0 種模型 · 此資料夾歷來 ≈US$0.000 · /skill-cost 看明細 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
本次 US$0.420 · 0 種 skill · 0 種模型 · 此資料夾歷來 ≈US$0.000 · /skill-cost 看明細
README

skill-cost

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 en to switch to English.

Installation

/plugin install skill-cost --marketplace bobosun0713/claude-mods

Requirements

  • 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.
  • Network access (optional): refreshes model prices from the official Claude documentation. Offline, the mod uses cached or bundled prices.

Usage

CommandDescription
/skill-costCurrent session (actual cost) and history for the current project directory
/skill-cost allTotals across your entire history, plus a cost ranking of project directories
/skill-cost scanRefresh official prices, rescan transcripts, and show your entire history
/skill-cost pricesModel prices in use and where each comes from
/skill-cost resetReset the current session's statistics
/skill-cost hideHide the summary line above the prompt
/skill-cost showShow 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

Summary line

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 看明細

Reports

Each scope has two tables.

Skill table

ColumnDescription
SkillSkill 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.

How costs are calculated

Current session (actual cost)

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

History parses the transcripts (JSONL) under ~/.claude/projects/ and determines each session's cost in this order:

  1. Actual cost: at the end of each turn, the mod saves the session's actual cost (the same source as /cost). Every session after you install the mod uses its actual cost; the split across skills and models follows the estimated proportions.
  2. Estimated cost: sessions from before you installed the mod are estimated from model prices (see below):
  3. Input, output, and cache read tokens are priced at each model's rates
  4. Cache writes are priced at the 5-minute or 1-hour rate according to their TTL
  5. Fast mode calls are priced at the official fast mode rates
  6. Current prices are always used; after a price change, estimated sessions are recalculated at the new prices

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:

  • A response split across several transcript lines is counted once, by message ID
  • Subagent costs count toward the project directory of the session that started them

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.

Skill attribution

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.

Model prices

Estimates use prices from the first of these sources that has them (US dollars per million tokens):

PrioritySourceLocation
1Custom prices~/.claude/skill-cost/prices.json
2Official pricesThe Claude pricing page (read from its Markdown version), cached in ~/.claude/skill-cost/online-prices.json
3Bundled pricesscripts/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.

Official prices

  • Parses the "Model pricing" and "Fast mode pricing" tables on the official pricing page, including cache read, cache write, and fast mode rates.
  • When a Claude Haiku 5.5 prompt (including cache reads and writes) exceeds 100,000 tokens, the official long-prompt rates apply.
  • Refreshed automatically at most once a day; /skill-cost scan forces a refresh.
  • If the page can't be reached, or parsing returns something unexpected (for example after a page redesign), the previous cache is kept. Without a cache, bundled prices are used and the report notes the failed update.
  • A model ID must match a price list name exactly, or add only a date (such as -20251001) or a suffix like [1m]. Models that don't match are listed as unpriced rather than priced like a similar model.

Price format

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
    }
  }
}
FieldDescription
input / outputInput and output rates (required)
cacheReadCache read rate
cacheWrite5m / cacheWrite1h5-minute and 1-hour cache write rates
fastInput / fastOutputFast 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
above100kRates 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.

Custom prices

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 }
  }
}
  • Models listed in the custom file override official and bundled prices.
  • Run /skill-cost scan after editing to apply the change.

Limitations

  • History covers only transcripts still kept on this machine.
  • Actual costs are saved only on the machine where the mod is installed; the same transcripts on another machine are estimated.
  • In Claude Code environments managed by an organization, the 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.
  • History scan results are cached for 60 seconds, so repeated queries in that window don't rescan; use /skill-cost scan to update immediately.
  • After /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.

Files

PathDescription
hooks/register.tsxHooks module: cost tracking, saving actual costs, commands, summary line
scripts/scan.pyTranscript parsing and cost calculation
scripts/prices.jsonBundled prices (fallback when official prices are unavailable)
scripts/update_prices.pyUpdates bundled prices from the official pricing page (used by GitHub Actions; can also be run by hand)
scripts/test_scan.pyUnit 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
Source 2 files
hooks/register.tsx 825 lines
1import { 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}
825
types/index.d.ts 66 lines
1/** 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