diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3fab748..affd445 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,8 +2,8 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-code-cost", "displayName": "Claude Code Cost", - "version": "1.0.0", - "description": "See your Claude Code spend in USD per project and per git branch, computed locally by reading the session logs Claude Code already writes to ~/.claude/projects. Never touches API traffic. Zero key, zero prompts, zero latency.", + "version": "1.1.0", + "description": "See your Claude Code spend in USD per project, per git branch, and over time (by day, week, month), computed locally by reading the session logs Claude Code already writes to ~/.claude/projects. Never touches API traffic. Zero key, zero prompts, zero latency.", "author": { "name": "RoninForge", "url": "https://roninforge.org" diff --git a/README.md b/README.md index 9105aae..890ca4f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # claude-code-cost -See your Claude Code spend in USD, per project and per git branch, without leaving Claude Code. +See your Claude Code spend in USD, per project, per git branch, and over time, without leaving Claude Code. -`/cost` reads the session logs Claude Code already writes to `~/.claude/projects/**/*.jsonl`, prices every assistant response with a dated, point-in-time price index, and prints a per-project, per-branch table. It never touches API traffic. Zero key, zero prompts, zero latency. +`/claude-code-cost:report` reads the session logs Claude Code already writes to `~/.claude/projects/**/*.jsonl`, prices every assistant response with a dated, point-in-time price index, and prints a per-project, per-branch table. It never touches API traffic. Zero key, zero prompts, zero latency. ``` PROJECT BRANCH TODAY WEEK MONTH @@ -12,6 +12,18 @@ other-repo main $23.41 $23.41 $85.85 TOTAL $31.20 $31.20 $292.18 ``` +Four sibling commands answer "when did I spend?": `/claude-code-cost:by-day`, `/claude-code-cost:by-week`, `/claude-code-cost:by-month` (each a dated, most-recent-first breakdown), and `/claude-code-cost:by-project` (a per-project rollup with each project's last-active date). + +``` +> /claude-code-cost:by-day + +DATE SPEND +2026-06-23 $9.98 +2026-06-22 $4.20 +2026-06-19 $7.49 +TOTAL $21.67 +``` + ## Why Claude Code shows you a running session cost, but not how much a given project or branch has cost you today, this week, or this month. This plugin answers that question from data already on your disk. It is a usage guard, not a proxy: it parses the local JSONL transcripts, it does not sit in front of the API, store keys, or add latency. @@ -27,20 +39,29 @@ In Claude Code: /plugin install claude-code-cost@roninforge ``` -Then run `/cost`. +Then run `/claude-code-cost:report`. Requires `node` (>= 18) on your PATH, which Claude Code users almost always have. -## Usage +## Commands ``` -/cost spend per project and branch (today / week / month) -/cost only rows whose project or branch contains -/cost --self force the built-in reader (skip BudgetClaw, see below) -/cost --json machine-readable JSON instead of the table +/claude-code-cost:report spend per project + branch (today / week / month) +/claude-code-cost:by-day spend per day, dated, most recent first (~30 days) +/claude-code-cost:by-week spend per Monday-based week, dated (~12 weeks) +/claude-code-cost:by-month spend per calendar month, dated (~12 months) +/claude-code-cost:by-project per project (branches merged) + last active date ``` -`/cost my-app` filters to one project or branch. The filter is a case-insensitive substring match on either column. +Every command takes an optional case-insensitive substring `[filter]` on the project or branch name, so the dated views double as a per-project history: + +``` +/claude-code-cost:report my-app only rows whose project or branch contains "my-app" +/claude-code-cost:by-day my-app my-app's spend per day, dated +/claude-code-cost:by-month my-app my-app's spend per month, dated +``` + +Flags (all commands): `--json` for machine-readable output, `--self` to force the built-in reader instead of BudgetClaw (report only; the by-* views always read the raw logs directly). ## How it works @@ -52,7 +73,7 @@ Requires `node` (>= 18) on your PATH, which Claude Code users almost always have ## Optional: BudgetClaw integration -If [BudgetClaw](https://roninforge.org/budgetclaw) is installed, `/cost` uses it automatically for a richer read backed by BudgetClaw's persistent database and budget caps. If it is not installed, the bundled self-contained reader is used instead. Either way the numbers come from your local logs. Pass `--self` to always use the built-in reader. +If [BudgetClaw](https://roninforge.org/budgetclaw) is installed, `/claude-code-cost:report` uses it automatically for a richer read backed by BudgetClaw's persistent database and budget caps. If it is not installed, the bundled self-contained reader is used instead. Either way the numbers come from your local logs. Pass `--self` to always use the built-in reader. The dated views (`by-day`, `by-week`, `by-month`, `by-project`) always read the raw logs directly, because `budgetclaw status` only emits the pre-rolled today/week/month snapshot with no per-date data. ## Privacy and trust @@ -63,13 +84,18 @@ If [BudgetClaw](https://roninforge.org/budgetclaw) is installed, `/cost` uses it ## Development ```sh -node scripts/cost.mjs --self # run against your real logs -node scripts/cost.mjs --self --json # JSON output -node scripts/test.mjs # run the test suite -node scripts/check-manifests.mjs # manifest sanity check -claude plugin validate . --strict # validate the plugin manifest +node scripts/cost.mjs --self # report, against your real logs +node scripts/cost.mjs --mode by-day --self # spend per day +node scripts/cost.mjs --mode by-project --self # per-project rollup +node scripts/cost.mjs --self --json # JSON output +node scripts/cost.mjs --help # all modes and flags +node scripts/test.mjs # run the test suite +node scripts/check-manifests.mjs # manifest sanity check +claude plugin validate . --strict # validate the plugin manifest ``` +Each command in `commands/` is a thin wrapper that runs `scripts/cost.mjs` with a `--mode`. The reader makes a single pass over the JSONL (dedup + point-in-time pricing) and every mode aggregates from that one priced-event list. + The pricing data under `scripts/vendor/ai-price-index/` is a bundled snapshot of the published `ai-price-index` package. To refresh it, re-copy `lib/{data.json,engine.js,index.js}` from a newer release and bump the plugin version. See `scripts/vendor/ai-price-index/VENDORED.md`. ## License diff --git a/commands/by-day.md b/commands/by-day.md new file mode 100644 index 0000000..a988181 --- /dev/null +++ b/commands/by-day.md @@ -0,0 +1,24 @@ +--- +description: Claude Code spend grouped by calendar day, dated, most recent first. Computed locally from the session logs Claude Code already writes. Never touches API traffic; zero key, zero prompts, zero latency. +argument-hint: "[filter] [--json]" +allowed-tools: Bash(node:*) +--- + +Show the user their Claude Code spend broken down by day. + +Run exactly this command with the Bash tool, passing the user's arguments through verbatim: + +``` +node "${CLAUDE_PLUGIN_ROOT}/scripts/cost.mjs" --mode by-day $ARGUMENTS +``` + +Then present the command's stdout to the user VERBATIM inside a fenced code block. Do not reformat the table, do not add columns, do not re-sort rows, and do not convert it to Markdown. Do not editorialize or add commentary about the numbers unless the user explicitly asks for analysis. + +Notes for you (the model), not for the user: + +- Output is a `DATE SPEND` table covering the last ~30 days, newest day first, with a TOTAL footer. Only days with spend appear. +- The script reads only the local JSONL session logs under `~/.claude/projects`. It never touches API traffic: zero key, zero prompts, zero latency. This view always reads the raw logs directly (it does not use budgetclaw, which has no per-date data). +- A first non-flag argument is a case-insensitive substring filter on project or branch name, scoping the daily totals to that project/branch (for example `/claude-code-cost:by-day roninforge`). +- `--json` makes it emit a JSON array of `{period, spend}` instead of the table. +- If the output ends with a "Note:" line about unpriced models, keep it. +- If the script prints "No Claude Code spend tracked yet." or "No Claude Code session logs found ...", relay that message as-is. diff --git a/commands/by-month.md b/commands/by-month.md new file mode 100644 index 0000000..7ae9353 --- /dev/null +++ b/commands/by-month.md @@ -0,0 +1,24 @@ +--- +description: Claude Code spend grouped by calendar month, dated, most recent first. Computed locally from the session logs Claude Code already writes. Never touches API traffic; zero key, zero prompts, zero latency. +argument-hint: "[filter] [--json]" +allowed-tools: Bash(node:*) +--- + +Show the user their Claude Code spend broken down by month. + +Run exactly this command with the Bash tool, passing the user's arguments through verbatim: + +``` +node "${CLAUDE_PLUGIN_ROOT}/scripts/cost.mjs" --mode by-month $ARGUMENTS +``` + +Then present the command's stdout to the user VERBATIM inside a fenced code block. Do not reformat the table, do not add columns, do not re-sort rows, and do not convert it to Markdown. Do not editorialize or add commentary about the numbers unless the user explicitly asks for analysis. + +Notes for you (the model), not for the user: + +- Output is a `MONTH SPEND` table covering the last ~12 months, newest month first, with a TOTAL footer. Each row is labelled `YYYY-MM` (UTC). Only months with spend appear. +- The script reads only the local JSONL session logs under `~/.claude/projects`. It never touches API traffic: zero key, zero prompts, zero latency. This view always reads the raw logs directly (it does not use budgetclaw, which has no per-date data). +- A first non-flag argument is a case-insensitive substring filter on project or branch name, scoping the monthly totals to that project/branch (for example `/claude-code-cost:by-month roninforge`). +- `--json` makes it emit a JSON array of `{period, spend}` instead of the table. +- If the output ends with a "Note:" line about unpriced models, keep it. +- If the script prints "No Claude Code spend tracked yet." or "No Claude Code session logs found ...", relay that message as-is. diff --git a/commands/by-project.md b/commands/by-project.md new file mode 100644 index 0000000..ac5d789 --- /dev/null +++ b/commands/by-project.md @@ -0,0 +1,24 @@ +--- +description: Claude Code spend per project (branches merged) with today / this week / this month and the last active date, ranked by month spend. Computed locally from the session logs Claude Code already writes. Never touches API traffic; zero key, zero prompts, zero latency. +argument-hint: "[filter] [--json]" +allowed-tools: Bash(node:*) +--- + +Show the user their Claude Code spend rolled up per project, with each project's last active date. + +Run exactly this command with the Bash tool, passing the user's arguments through verbatim: + +``` +node "${CLAUDE_PLUGIN_ROOT}/scripts/cost.mjs" --mode by-project $ARGUMENTS +``` + +Then present the command's stdout to the user VERBATIM inside a fenced code block. Do not reformat the table, do not add columns, do not re-sort rows, and do not convert it to Markdown. Do not editorialize or add commentary about the numbers unless the user explicitly asks for analysis. + +Notes for you (the model), not for the user: + +- Output is a `PROJECT TODAY WEEK MONTH LAST` table, one row per project with its git branches merged together, ranked by month spend, with a TOTAL footer. `LAST` is the most recent day (UTC) that project had any spend. +- The script reads only the local JSONL session logs under `~/.claude/projects`. It never touches API traffic: zero key, zero prompts, zero latency. This view always reads the raw logs directly (it does not use budgetclaw, which has no last-active data). +- A first non-flag argument is a case-insensitive substring filter on the project name, keeping only matching projects (for example `/claude-code-cost:by-project roninforge`). +- `--json` makes it emit a JSON array of `{project, today, week, month, lastActive}` instead of the table. +- If the output ends with a "Note:" line about unpriced models, keep it. +- If the script prints "No Claude Code spend tracked yet." or "No Claude Code session logs found ...", relay that message as-is. diff --git a/commands/by-week.md b/commands/by-week.md new file mode 100644 index 0000000..b15596e --- /dev/null +++ b/commands/by-week.md @@ -0,0 +1,24 @@ +--- +description: Claude Code spend grouped by Monday-based week, dated, most recent first. Computed locally from the session logs Claude Code already writes. Never touches API traffic; zero key, zero prompts, zero latency. +argument-hint: "[filter] [--json]" +allowed-tools: Bash(node:*) +--- + +Show the user their Claude Code spend broken down by week. + +Run exactly this command with the Bash tool, passing the user's arguments through verbatim: + +``` +node "${CLAUDE_PLUGIN_ROOT}/scripts/cost.mjs" --mode by-week $ARGUMENTS +``` + +Then present the command's stdout to the user VERBATIM inside a fenced code block. Do not reformat the table, do not add columns, do not re-sort rows, and do not convert it to Markdown. Do not editorialize or add commentary about the numbers unless the user explicitly asks for analysis. + +Notes for you (the model), not for the user: + +- Output is a `WEEK OF SPEND` table covering the last ~12 weeks, newest week first, with a TOTAL footer. Each row is labelled with that week's Monday (UTC). Only weeks with spend appear. +- The script reads only the local JSONL session logs under `~/.claude/projects`. It never touches API traffic: zero key, zero prompts, zero latency. This view always reads the raw logs directly (it does not use budgetclaw, which has no per-date data). +- A first non-flag argument is a case-insensitive substring filter on project or branch name, scoping the weekly totals to that project/branch (for example `/claude-code-cost:by-week roninforge`). +- `--json` makes it emit a JSON array of `{period, spend}` instead of the table. +- If the output ends with a "Note:" line about unpriced models, keep it. +- If the script prints "No Claude Code spend tracked yet." or "No Claude Code session logs found ...", relay that message as-is. diff --git a/commands/cost.md b/commands/report.md similarity index 68% rename from commands/cost.md rename to commands/report.md index cbf8d09..1619e58 100644 --- a/commands/cost.md +++ b/commands/report.md @@ -1,15 +1,15 @@ --- -description: Show your Claude Code spend in USD per project and per git branch, computed locally from the session logs Claude Code already writes. Never touches API traffic; zero key, zero prompts, zero latency. +description: Claude Code spend in USD per project and per git branch (today / this week / this month), computed locally from the session logs Claude Code already writes. Never touches API traffic; zero key, zero prompts, zero latency. argument-hint: "[filter] [--self] [--json]" allowed-tools: Bash(node:*), Bash(budgetclaw:*) --- -Run the bundled cost reader and show the user their Claude Code spend. +Run the bundled cost reader and show the user their Claude Code spend, grouped by project and git branch. Run exactly this command with the Bash tool, passing the user's arguments through verbatim: ``` -node "${CLAUDE_PLUGIN_ROOT}/scripts/cost.mjs" $ARGUMENTS +node "${CLAUDE_PLUGIN_ROOT}/scripts/cost.mjs" --mode report $ARGUMENTS ``` Then present the command's stdout to the user VERBATIM inside a fenced code block. Do not reformat the table, do not add columns, do not re-sort rows, and do not convert it to Markdown. Do not rank projects, editorialize, or add commentary about the numbers unless the user explicitly asks for analysis. @@ -18,7 +18,8 @@ Notes for you (the model), not for the user: - The script reads only the local JSONL session logs under `~/.claude/projects`. It never touches API traffic. If asked, you can state plainly: zero key, zero prompts, zero latency. - If `budgetclaw` is installed, the script uses it automatically (richer: persistent database plus budget caps). Otherwise it falls back to a self-contained reader. Pass `--self` to force the built-in reader. -- A first non-flag argument is a case-insensitive substring filter on project or branch name (for example `/cost roninforge`). +- A first non-flag argument is a case-insensitive substring filter on project or branch name (for example `/claude-code-cost:report roninforge`). +- For spend over time use the sibling commands `/claude-code-cost:by-day`, `/claude-code-cost:by-week`, `/claude-code-cost:by-month`. For a per-project rollup with last-active dates, `/claude-code-cost:by-project`. - `--json` makes it emit a JSON array instead of the table. - If the output ends with a "Note:" line about unpriced models, keep it; it surfaces spend the price index could not value yet. - If the script prints "No Claude Code spend tracked yet." or "No Claude Code session logs found ...", relay that message as-is. diff --git a/scripts/cost.mjs b/scripts/cost.mjs index 357fd32..254c2cd 100644 --- a/scripts/cost.mjs +++ b/scripts/cost.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// claude-code-cost: show Claude Code spend (USD) per project and per git branch. +// claude-code-cost: show Claude Code spend (USD) per project, branch, and over time. // // It reads only the transcript JSONL that Claude Code already writes locally to // ~/.claude/projects/**/*.jsonl. It never touches API traffic. Zero key, zero @@ -7,10 +7,19 @@ // // Pricing comes from the vendored ai-price-index lib (no runtime network). // +// Modes (--mode, default "report"): +// report per (project, branch) with TODAY / WEEK / MONTH columns. +// by-day total spend per calendar day (dated), most recent first. +// by-week total spend per Monday-based week (dated), most recent first. +// by-month total spend per calendar month (dated), most recent first. +// by-project per project (branches collapsed) with TODAY/WEEK/MONTH + last active. +// // Design: -// - By default, if `budgetclaw` is on PATH, run `budgetclaw status` (richer: +// - report mode: if `budgetclaw` is on PATH, run `budgetclaw status` (richer: // persistent DB + budgets) and print its output. On any failure, or with // --self, fall back to the self-contained reader below. +// - Every by-* mode reads the raw JSONL directly (it is the only source with +// per-event dates; `budgetclaw status` only emits the pre-rolled snapshot). // - The self-contained reader parses the raw JSONL = ground truth. import { spawnSync } from "node:child_process"; @@ -21,18 +30,34 @@ import { join, basename, isAbsolute, resolve } from "node:path"; import * as price from "./vendor/ai-price-index/index.js"; +// --------------------------------------------------------------------------- +// Modes +// --------------------------------------------------------------------------- + +const MODES = new Set(["report", "by-day", "by-week", "by-month", "by-project"]); + +// How far back each by-* view looks. Generous defaults; only buckets with spend +// are shown, so an idle range simply yields fewer rows. +const BY_DAY_LOOKBACK_DAYS = 30; +const BY_WEEK_LOOKBACK_WEEKS = 12; +const BY_MONTH_LOOKBACK_MONTHS = 12; + +const DAY_MS = 24 * 60 * 60 * 1000; + // --------------------------------------------------------------------------- // Arg parsing // --------------------------------------------------------------------------- function parseArgs(argv) { const opts = { + mode: "report", self: false, json: false, help: false, projectsDir: null, asof: null, // YYYY-MM-DD; pins period boundaries (testing/determinism) filter: null, + badMode: null, }; const positionals = []; for (let i = 0; i < argv.length; i++) { @@ -40,6 +65,8 @@ function parseArgs(argv) { if (a === "--self") opts.self = true; else if (a === "--json") opts.json = true; else if (a === "--help" || a === "-h") opts.help = true; + else if (a === "--mode") opts.mode = argv[++i] ?? "report"; + else if (a.startsWith("--mode=")) opts.mode = a.slice("--mode=".length); else if (a === "--projects-dir") opts.projectsDir = argv[++i] ?? null; else if (a.startsWith("--projects-dir=")) opts.projectsDir = a.slice("--projects-dir=".length); else if (a === "--asof") opts.asof = argv[++i] ?? null; @@ -51,10 +78,14 @@ function parseArgs(argv) { } // First non-flag arg is the substring filter. if (positionals.length > 0) opts.filter = positionals[0]; + if (!MODES.has(opts.mode)) { + opts.badMode = opts.mode; + opts.mode = "report"; + } return opts; } -const USAGE = `claude-code-cost - Claude Code spend per project and per git branch +const USAGE = `claude-code-cost - Claude Code spend per project, branch, and over time Reads the local session logs Claude Code writes to ~/.claude/projects/**/*.jsonl. Never touches API traffic. Zero key, zero prompts, zero latency. @@ -67,6 +98,12 @@ Arguments: or BRANCH contains it (TOTAL + header are preserved). Flags: + --mode report (default) | by-day | by-week | by-month | by-project + report per project + branch, TODAY/WEEK/MONTH + by-day spend per day, dated, most recent first + by-week spend per Monday-based week, dated + by-month spend per calendar month, dated + by-project per project (branches merged) + last active --self Force the self-contained JSONL reader (skip budgetclaw). --json Emit a JSON array instead of the table. --projects-dir Scan root override (default: ~/.claude/projects). @@ -74,8 +111,9 @@ Flags: --asof Pin "today" for period boundaries (deterministic). -h, --help Show this help. -By default, if budgetclaw is installed it is used (richer: persistent DB + -budget caps). Pass --self to always use the built-in reader.`; +In report mode, if budgetclaw is installed it is used (richer: persistent DB + +budget caps). The by-* modes always read the raw JSONL directly because that is +the only source with per-event dates. Pass --self to force the built-in reader.`; // --------------------------------------------------------------------------- // Period boundaries (UTC, matching BudgetClaw defaults) @@ -99,7 +137,7 @@ function periodBounds(now) { // Monday-based week: JS getUTCDay() has Sun=0..Sat=6; offset to Monday. const offset = (now.getUTCDay() + 6) % 7; const weekStart = Date.UTC(y, m, d - offset, 0, 0, 0, 0); - const weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000; + const weekEnd = weekStart + 7 * DAY_MS; const monthStart = Date.UTC(y, m, 1, 0, 0, 0, 0); const monthEnd = Date.UTC(y, m + 1, 1, 0, 0, 0, 0); @@ -111,6 +149,17 @@ function periodBounds(now) { }; } +// Bucket-key helpers (all UTC). ISO strings sort lexically == chronologically. +const dayKey = (ts) => new Date(ts).toISOString().slice(0, 10); // YYYY-MM-DD +const monthKey = (ts) => new Date(ts).toISOString().slice(0, 7); // YYYY-MM + +function weekStartMs(ts) { + const d = new Date(ts); + const offset = (d.getUTCDay() + 6) % 7; + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - offset, 0, 0, 0, 0); +} +const weekKey = (ts) => dayKey(weekStartMs(ts)); // Monday's date + // --------------------------------------------------------------------------- // JSONL parsing (canonical schema, mirrors BudgetClaw's Go parser) // --------------------------------------------------------------------------- @@ -219,7 +268,10 @@ async function readLines(file, onLine) { for await (const line of rl) onLine(line); } -async function selfContained(opts) { +// Read every JSONL once, dedup, and price each unique assistant response at its +// own UTC date. Returns the flat priced-event list that every mode aggregates +// from, plus the diagnostics (malformed / unpriced) the renderers surface. +async function collectEvents(opts) { const root = opts.projectsDir || process.env.CC_COST_PROJECTS_DIR || @@ -230,29 +282,12 @@ async function selfContained(opts) { return { missingPath: rootAbs }; } - const now = opts.asof ? new Date(opts.asof + "T12:00:00.000Z") : new Date(); - const bounds = periodBounds(now); - - // Aggregate cents per (project, branch) per period. - // key = project + "" + branch - const rows = new Map(); - const ensure = (project, branch) => { - const k = project + "" + branch; - let v = rows.get(k); - if (!v) { - v = { project, branch, day: 0, week: 0, month: 0 }; - rows.set(k, v); - } - return v; - }; - + const events = []; // { ts, project, branch, model, cents, modelKnown } const seen = new Set(); // dedup keys let malformed = 0; const unpricedModels = new Map(); // model -> count let unpricedCount = 0; - const inWindow = (ts, [start, end]) => ts >= start && ts < end; - for (const file of walkJsonl(rootAbs)) { // eslint-disable-next-line no-await-in-loop await readLines(file, (line) => { @@ -281,21 +316,19 @@ async function selfContained(opts) { // Do not silently drop: surfaced after rendering. cents is 0 here. } - const row = ensure(ev.project, ev.branch); - if (inWindow(ev.ts, bounds.day)) row.day += cents; - if (inWindow(ev.ts, bounds.week)) row.week += cents; - if (inWindow(ev.ts, bounds.month)) row.month += cents; + events.push({ + ts: ev.ts, + project: ev.project, + branch: ev.branch, + model: ev.model, + cents, + modelKnown, + }); }); } - // Drop rows with zero spend in all three periods (matches budgetclaw, which - // only stores periods with activity). Sort by project then branch. - const list = [...rows.values()] - .filter((r) => r.day > 0 || r.week > 0 || r.month > 0) - .sort((a, b) => (a.project < b.project ? -1 : a.project > b.project ? 1 : a.branch < b.branch ? -1 : a.branch > b.branch ? 1 : 0)); - return { - rows: list, + events, malformed, unpricedModels, unpricedCount, @@ -303,11 +336,130 @@ async function selfContained(opts) { }; } +function eventMatchesFilter(ev, filter, projectOnly) { + if (!filter) return true; + const f = filter.toLowerCase(); + if (projectOnly) return ev.project.toLowerCase().includes(f); + return ev.project.toLowerCase().includes(f) || ev.branch.toLowerCase().includes(f); +} + +// report mode: aggregate cents per (project, branch) into TODAY / WEEK / MONTH. +function aggregateReport(events, now) { + const bounds = periodBounds(now); + const inWindow = (ts, [start, end]) => ts >= start && ts < end; + + const rows = new Map(); + const ensure = (project, branch) => { + const k = project + " " + branch; + let v = rows.get(k); + if (!v) { + v = { project, branch, day: 0, week: 0, month: 0 }; + rows.set(k, v); + } + return v; + }; + + for (const ev of events) { + const row = ensure(ev.project, ev.branch); + if (inWindow(ev.ts, bounds.day)) row.day += ev.cents; + if (inWindow(ev.ts, bounds.week)) row.week += ev.cents; + if (inWindow(ev.ts, bounds.month)) row.month += ev.cents; + } + + // Drop rows with zero spend in all three periods (matches budgetclaw, which + // only stores periods with activity). Sort by project then branch. + return [...rows.values()] + .filter((r) => r.day > 0 || r.week > 0 || r.month > 0) + .sort((a, b) => + a.project < b.project ? -1 : a.project > b.project ? 1 : a.branch < b.branch ? -1 : a.branch > b.branch ? 1 : 0 + ); +} + +// by-day / by-week / by-month: total cents per time bucket within the lookback. +// Returns rows [{ label, cents }] sorted most-recent-first. +function aggregateByPeriod(events, now, granularity) { + const bounds = periodBounds(now); + const upper = bounds.day[1]; // end of "today"; ignore future-dated events + + let cutoff; + let keyOf; + if (granularity === "day") { + cutoff = bounds.day[0] - (BY_DAY_LOOKBACK_DAYS - 1) * DAY_MS; + keyOf = dayKey; + } else if (granularity === "week") { + cutoff = bounds.week[0] - (BY_WEEK_LOOKBACK_WEEKS - 1) * 7 * DAY_MS; + keyOf = weekKey; + } else { + // month + const y = now.getUTCFullYear(); + const m = now.getUTCMonth(); + cutoff = Date.UTC(y, m - (BY_MONTH_LOOKBACK_MONTHS - 1), 1, 0, 0, 0, 0); + keyOf = monthKey; + } + + const buckets = new Map(); // label -> cents + for (const ev of events) { + if (ev.ts < cutoff || ev.ts >= upper) continue; + const label = keyOf(ev.ts); + buckets.set(label, (buckets.get(label) || 0) + ev.cents); + } + + return [...buckets.entries()] + .map(([label, cents]) => ({ label, cents })) + .filter((r) => r.cents > 0) + .sort((a, b) => (a.label < b.label ? 1 : a.label > b.label ? -1 : 0)); // desc +} + +// by-project: collapse branches; TODAY / WEEK / MONTH plus last-active date. +function aggregateByProject(events, now) { + const bounds = periodBounds(now); + const inWindow = (ts, [start, end]) => ts >= start && ts < end; + + const rows = new Map(); + const ensure = (project) => { + let v = rows.get(project); + if (!v) { + v = { project, day: 0, week: 0, month: 0, lastTs: 0 }; + rows.set(project, v); + } + return v; + }; + + for (const ev of events) { + const row = ensure(ev.project); + if (inWindow(ev.ts, bounds.day)) row.day += ev.cents; + if (inWindow(ev.ts, bounds.week)) row.week += ev.cents; + if (inWindow(ev.ts, bounds.month)) row.month += ev.cents; + if (ev.ts > row.lastTs) row.lastTs = ev.ts; + } + + return [...rows.values()] + .filter((r) => r.day > 0 || r.week > 0 || r.month > 0) + .sort((a, b) => b.month - a.month || b.week - a.week || b.day - a.day || + (a.project < b.project ? -1 : a.project > b.project ? 1 : 0)); +} + // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- const dollars = (cents) => "$" + (cents / 100).toFixed(2); +const EMPTY = "No Claude Code spend tracked yet."; + +// Generic fixed-width grid: header + body rows + optional total row. Each cell +// is left-padded to its column's max width with a 2-space gutter (matches the +// column layout BudgetClaw's Go tabwriter produces for plain reading). +function renderGrid(header, body, total) { + const all = [header, ...body, ...(total ? [total] : [])]; + const widths = header.map((_, c) => Math.max(...all.map((row) => (row[c] ?? "").length))); + const GUTTER = " "; + const fmt = (row) => + row + .map((cell, c) => (cell ?? "").padEnd(c === row.length - 1 ? 0 : widths[c])) + .join(GUTTER) + .replace(/\s+$/, ""); + return all.map(fmt).join("\n"); +} function filterRows(rows, filter) { if (!filter) return rows; @@ -317,36 +469,51 @@ function filterRows(rows, filter) { ); } -// Render the same column layout BudgetClaw uses: PROJECT BRANCH TODAY WEEK MONTH -// with at least a 2-space gutter (Go's tabwriter uses padding 3; we compute -// max widths + a 2-space gutter, which lines up identically for plain reading). +// report: PROJECT BRANCH TODAY WEEK MONTH function renderTable(rows) { - if (rows.length === 0) return "No Claude Code spend tracked yet."; + if (rows.length === 0) return EMPTY; const header = ["PROJECT", "BRANCH", "TODAY", "WEEK", "MONTH"]; + const body = rows.map((r) => [r.project, r.branch, dollars(r.day), dollars(r.week), dollars(r.month)]); + + let total = null; + if (rows.length > 1) { + const sumD = rows.reduce((s, r) => s + r.day, 0); + const sumW = rows.reduce((s, r) => s + r.week, 0); + const sumM = rows.reduce((s, r) => s + r.month, 0); + total = ["TOTAL", "", dollars(sumD), dollars(sumW), dollars(sumM)]; + } + return renderGrid(header, body, total); +} + +// by-day / by-week / by-month: LABEL SPEND +function renderPeriodTable(rows, labelHeader) { + if (rows.length === 0) return EMPTY; + const header = [labelHeader, "SPEND"]; + const body = rows.map((r) => [r.label, dollars(r.cents)]); + const total = rows.length > 1 ? ["TOTAL", dollars(rows.reduce((s, r) => s + r.cents, 0))] : null; + return renderGrid(header, body, total); +} + +// by-project: PROJECT TODAY WEEK MONTH LAST +function renderProjectTable(rows) { + if (rows.length === 0) return EMPTY; + const header = ["PROJECT", "TODAY", "WEEK", "MONTH", "LAST"]; const body = rows.map((r) => [ r.project, - r.branch, dollars(r.day), dollars(r.week), dollars(r.month), + r.lastTs ? dayKey(r.lastTs) : "-", ]); - let total = null; if (rows.length > 1) { const sumD = rows.reduce((s, r) => s + r.day, 0); const sumW = rows.reduce((s, r) => s + r.week, 0); const sumM = rows.reduce((s, r) => s + r.month, 0); - total = ["TOTAL", "", dollars(sumD), dollars(sumW), dollars(sumM)]; + total = ["TOTAL", dollars(sumD), dollars(sumW), dollars(sumM), ""]; } - - const all = [header, ...body, ...(total ? [total] : [])]; - const widths = header.map((_, c) => Math.max(...all.map((row) => row[c].length))); - const GUTTER = " "; - const fmt = (row) => - row.map((cell, c) => cell.padEnd(c === row.length - 1 ? 0 : widths[c])).join(GUTTER).replace(/\s+$/, ""); - - return all.map(fmt).join("\n"); + return renderGrid(header, body, total); } function rowsToJson(rows) { @@ -359,6 +526,20 @@ function rowsToJson(rows) { })); } +function periodToJson(rows) { + return rows.map((r) => ({ period: r.label, spend: +(r.cents / 100).toFixed(2) })); +} + +function projectToJson(rows) { + return rows.map((r) => ({ + project: r.project, + today: +(r.day / 100).toFixed(2), + week: +(r.week / 100).toFixed(2), + month: +(r.month / 100).toFixed(2), + lastActive: r.lastTs ? dayKey(r.lastTs) : null, + })); +} + function renderUnpricedNote(unpricedModels, unpricedCount) { if (unpricedCount === 0) return null; const ids = [...unpricedModels.keys()].sort().join(", "); @@ -369,7 +550,7 @@ function renderUnpricedNote(unpricedModels, unpricedCount) { } // --------------------------------------------------------------------------- -// budgetclaw passthrough +// budgetclaw passthrough (report mode only) // --------------------------------------------------------------------------- function hasBudgetclaw() { @@ -458,6 +639,16 @@ function filterBudgetclawOutput(text, filter) { // Main // --------------------------------------------------------------------------- +function writeMeta(result) { + const meta = { + dataModified: result.dataModified, + unpricedCount: result.unpricedCount, + unpricedModels: [...result.unpricedModels.keys()].sort(), + malformedLines: result.malformed, + }; + process.stderr.write("_meta " + JSON.stringify(meta) + "\n"); +} + async function main() { const opts = parseArgs(process.argv.slice(2)); @@ -466,10 +657,23 @@ async function main() { return 0; } - // Try budgetclaw unless --self, --json, or a custom projects dir forces the - // built-in reader. (--json and --projects-dir imply the self-contained path, - // because budgetclaw status emits neither JSON nor a scan-root override.) - if (!opts.self && !opts.json && !opts.projectsDir && hasBudgetclaw()) { + if (opts.badMode) { + process.stderr.write( + `claude-code-cost: unknown --mode '${opts.badMode}'. ` + + `Valid: report, by-day, by-week, by-month, by-project.\n` + ); + return 2; + } + + // report mode: try budgetclaw first (richer) unless the built-in reader is + // forced. by-* modes always read JSONL (budgetclaw status has no per-date data). + if ( + opts.mode === "report" && + !opts.self && + !opts.json && + !opts.projectsDir && + hasBudgetclaw() + ) { const out = runBudgetclaw(); if (out !== null) { process.stdout.write(filterBudgetclawOutput(out, opts.filter)); @@ -479,7 +683,7 @@ async function main() { // budgetclaw failed; fall through to the self-contained reader. } - const result = await selfContained(opts); + const result = await collectEvents(opts); if (result.missingPath) { if (opts.json) { @@ -490,25 +694,57 @@ async function main() { return 0; } - const rows = filterRows(result.rows, opts.filter); + const now = opts.asof ? new Date(opts.asof + "T12:00:00.000Z") : new Date(); + + // ---- by-day / by-week / by-month ---- + if (opts.mode === "by-day" || opts.mode === "by-week" || opts.mode === "by-month") { + const granularity = opts.mode.slice("by-".length); // day | week | month + const matched = result.events.filter((ev) => eventMatchesFilter(ev, opts.filter, false)); + const rows = aggregateByPeriod(matched, now, granularity); + + if (opts.json) { + process.stdout.write(JSON.stringify(periodToJson(rows)) + "\n"); + writeMeta(result); + return 0; + } + + const labelHeader = granularity === "day" ? "DATE" : granularity === "week" ? "WEEK OF" : "MONTH"; + process.stdout.write(renderPeriodTable(rows, labelHeader) + "\n"); + const note = renderUnpricedNote(result.unpricedModels, result.unpricedCount); + if (note) process.stdout.write("\n" + note + "\n"); + return 0; + } + + // ---- by-project ---- + if (opts.mode === "by-project") { + const matched = result.events.filter((ev) => eventMatchesFilter(ev, opts.filter, true)); + const rows = aggregateByProject(matched, now); + + if (opts.json) { + process.stdout.write(JSON.stringify(projectToJson(rows)) + "\n"); + writeMeta(result); + return 0; + } + + process.stdout.write(renderProjectTable(rows) + "\n"); + const note = renderUnpricedNote(result.unpricedModels, result.unpricedCount); + if (note) process.stdout.write("\n" + note + "\n"); + return 0; + } + + // ---- report (default) ---- + const reportRows = filterRows(aggregateReport(result.events, now), opts.filter); if (opts.json) { // stdout stays a pure JSON array of {project, branch, today, week, month}. // _meta (dataModified + unpriced/malformed info) goes to stderr so stdout is // safe to pipe into `jq` or JSON.parse without stripping anything. - const payload = rowsToJson(rows); - process.stdout.write(JSON.stringify(payload) + "\n"); - const meta = { - dataModified: result.dataModified, - unpricedCount: result.unpricedCount, - unpricedModels: [...result.unpricedModels.keys()].sort(), - malformedLines: result.malformed, - }; - process.stderr.write("_meta " + JSON.stringify(meta) + "\n"); + process.stdout.write(JSON.stringify(rowsToJson(reportRows)) + "\n"); + writeMeta(result); return 0; } - process.stdout.write(renderTable(rows) + "\n"); + process.stdout.write(renderTable(reportRows) + "\n"); const note = renderUnpricedNote(result.unpricedModels, result.unpricedCount); if (note) process.stdout.write("\n" + note + "\n"); return 0; diff --git a/scripts/test.mjs b/scripts/test.mjs index f02c32b..c0982df 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -149,6 +149,103 @@ check( missing.stdout.trim() ); +// --- by-* modes: every fixture event is on 2026-06-01, so with --asof 2026-06-03 +// (same week + month, later day) the dated views collapse to a single bucket. --- +const cAlphaMain = cents({ input: 1000000, output: 200000 }, "claude-opus-4-6"); +const cAlphaFeat = cents( + { input: 500000, output: 100000, cache_read: 1000000, cache_write_5m: 200000 }, + "claude-sonnet-4-5-20250929" +); +const cBetaMain = cents({ cache_write_5m: 400000 }, "claude-haiku-4-5-20251001"); +const expPeriodTotal = toUsd(cAlphaMain + cAlphaFeat + cBetaMain); +const expAlphaProject = toUsd(cAlphaMain + cAlphaFeat); +const expBetaProject = toUsd(cBetaMain); + +function runMode(mode, extra = []) { + const r = spawnSync( + process.execPath, + [COST, "--mode", mode, "--self", "--json", "--asof", "2026-06-03", "--projects-dir", FIXTURES, ...extra], + { encoding: "utf8" } + ); + eq(`${mode} exits 0`, r.status, 0); + let parsed = null; + try { + parsed = JSON.parse(r.stdout); + } catch (e) { + check(`${mode} stdout is JSON`, false, e.message + " :: " + r.stdout); + } + return parsed; +} + +// by-day: one dated row for 2026-06-01 totalling every project's spend. +const byDay = runMode("by-day"); +if (byDay) { + eq("by-day row count", byDay.length, 1); + eq("by-day period label is the event date", byDay[0]?.period, "2026-06-01"); + eq("by-day spend is the full total", byDay[0]?.spend, expPeriodTotal); +} + +// by-week: one row labelled with the Monday of the event's week. +const byWeek = runMode("by-week"); +if (byWeek) { + eq("by-week row count", byWeek.length, 1); + eq("by-week period label is the Monday (2026-06-01)", byWeek[0]?.period, "2026-06-01"); + eq("by-week spend is the full total", byWeek[0]?.spend, expPeriodTotal); +} + +// by-month: one row labelled YYYY-MM. +const byMonth = runMode("by-month"); +if (byMonth) { + eq("by-month row count", byMonth.length, 1); + eq("by-month period label is YYYY-MM", byMonth[0]?.period, "2026-06"); + eq("by-month spend is the full total", byMonth[0]?.spend, expPeriodTotal); +} + +// by-day filter scopes the dated total to one project/branch. +const byDayBeta = runMode("by-day", ["beta"]); +if (byDayBeta) { + eq("by-day filter beta row count", byDayBeta.length, 1); + eq("by-day filter beta spend", byDayBeta[0]?.spend, expBetaProject); +} + +// by-project: branches collapse per project, ranked by month spend, with lastActive. +const byProject = runMode("by-project"); +if (byProject) { + eq("by-project row count", byProject.length, 2); + const pj = new Map(byProject.map((r) => [r.project, r])); + const a = pj.get("alpha"); + const b = pj.get("beta"); + check("by-project has alpha", !!a); + check("by-project has beta", !!b); + if (a) { + eq("by-project alpha month (branches merged)", a.month, expAlphaProject); + eq("by-project alpha lastActive", a.lastActive, "2026-06-01"); + } + if (b) eq("by-project beta month", b.month, expBetaProject); + // alpha outspends beta, so it must rank first. + eq("by-project ranked by month desc", byProject[0]?.project, "alpha"); +} + +// by-project filter keeps only matching projects. +const byProjectAlpha = runMode("by-project", ["alpha"]); +if (byProjectAlpha) { + eq("by-project filter alpha row count", byProjectAlpha.length, 1); + check("by-project filter alpha is alpha", byProjectAlpha[0]?.project === "alpha"); +} + +// An unknown --mode is a hard error (exit 2), not a silent fallback. +const badMode = spawnSync( + process.execPath, + [COST, "--mode", "by-quarter", "--self", "--projects-dir", FIXTURES], + { encoding: "utf8" } +); +eq("unknown mode exits 2", badMode.status, 2); +check( + "unknown mode names the valid modes", + badMode.stderr.includes("by-day") && badMode.stderr.includes("by-project"), + badMode.stderr.trim() +); + console.log(""); if (failures > 0) { console.error(`${failures} check(s) failed.`);