Skip to content

Commit a768470

Browse files
committed
Release v0.3.1: CLI input validation + CI/CD supply chain hardening
Bug-fix release covering issues found in /devex-review and /cso audits. Fixed: - selftest reports the canonical VERSION (was hardcoded "0.2.0") - unknown subcommands now error with did-you-mean instead of silently running the default summary - numeric flags (--days, --top, --port, --interval, --cache-ttl, etc.) reject non-positive-integer input via a commander argParser - whatif --swap validates both sides against the pricing table; closes a single-letter prefix loophole - README commands table now matches `--help` output Security: - Pinned all GitHub Actions by commit SHA (actions/checkout, actions/setup-node, softprops/action-gh-release) in ci.yml and release.yml. Closes a tag-repoint / namespace-takeover risk on the release pipeline that handles NPM_TOKEN. - Added .github/dependabot.yml for weekly github-actions + npm updates with grouping, so SHA pins stay fresh automatically. Tests: - New test/cli-validation.test.ts (20 cases) locks in every fix above. Total 55 tests, <1s runtime. See CHANGELOG.md for full details.
1 parent 77d77db commit a768470

11 files changed

Lines changed: 425 additions & 45 deletions

File tree

.github/dependabot.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
version: 2
2+
updates:
3+
# Keep workflow action SHAs current. Dependabot opens a PR when an upstream
4+
# tag's commit changes, so SHA-pinning stays maintainable.
5+
- package-ecosystem: github-actions
6+
directory: /
7+
schedule:
8+
interval: weekly
9+
groups:
10+
actions:
11+
patterns:
12+
- "*"
13+
14+
- package-ecosystem: npm
15+
directory: /
16+
schedule:
17+
interval: weekly
18+
open-pull-requests-limit: 5
19+
groups:
20+
dev-dependencies:
21+
dependency-type: development
22+
prod-dependencies:
23+
dependency-type: production

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ jobs:
1414
os: [ubuntu-latest, macos-latest, windows-latest]
1515
node: [20, 22]
1616
steps:
17-
- uses: actions/checkout@v4
18-
- uses: actions/setup-node@v4
17+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
18+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
1919
with:
2020
node-version: ${{ matrix.node }}
2121
cache: npm

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ jobs:
99
publish:
1010
runs-on: ubuntu-latest
1111
steps:
12-
- uses: actions/checkout@v4
13-
- uses: actions/setup-node@v4
12+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
13+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
1414
with:
1515
node-version: 22
1616
cache: npm
@@ -21,6 +21,6 @@ jobs:
2121
env:
2222
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
2323
- name: GitHub release
24-
uses: softprops/action-gh-release@v2
24+
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
2525
with:
2626
generate_release_notes: true

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@ docs/_frame_*.jpg
2828

2929
# Claude Code working files (in case they ever land here)
3030
.claude/
31+
32+
python/__pycache__/
33+
*.pyc
34+
.gstack/

CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,51 @@ All notable changes to ccmeter will be documented here. Format: [Keep a Changelo
44

55
## [Unreleased]
66

7+
## [0.3.1] — 2026-04-28
8+
9+
Bug-fix release driven by a `/devex-review` of the CLI surface and a `/cso`
10+
security audit. No behavior changes for valid inputs; better errors for invalid
11+
ones; tighter CI/CD supply chain.
12+
13+
### Fixed
14+
15+
- **`ccmeter selftest` reports the wrong version.** `src/cli/commands/selftest.ts:327`
16+
hardcoded `"0.2.0"` as a fallback, so `selftest` printed an old version even on
17+
fresh installs. Now imports the canonical `VERSION` constant from
18+
`src/cli/index.ts`.
19+
- **Unknown subcommands silently ran `summary`.** Typing `ccmeter dahsboard`
20+
produced a default summary instead of an error. Now prints
21+
`ccmeter: unknown command 'dahsboard'. Did you mean 'dashboard'?` and exits 1.
22+
Levenshtein-based did-you-mean across all 27 commands.
23+
- **`--days abc` printed `last NaN days`.** No int validation on numeric flags.
24+
Added a positive-integer parser (`parsePositiveInt`) on `--days`, `--top`,
25+
`--bottom`, `--port`, `--interval`, `--quiet`, `--cache-ttl`, `--max-files`,
26+
`--sample-turns`. Bad input now errors via commander before the command runs.
27+
- **`whatif --swap xyz->abc` returned a confident fake savings number.**
28+
`parseSwaps` accepted any token because `pricingFor` falls back to default
29+
pricing for unknown models. Now validates both sides of the swap against
30+
family aliases (`opus`/`sonnet`/`haiku`) and the built-in pricing table, and
31+
rejects single-letter prefixes that previously slipped through.
32+
- **`README.md` commands table missing rows.** Added `selftest`, `prompts`,
33+
`notify`, `reconcile`, `pricing` so the table matches `--help`.
34+
35+
### Security
36+
37+
- **Pinned all GitHub Actions by commit SHA** in `ci.yml` and `release.yml`
38+
(`actions/checkout`, `actions/setup-node`, `softprops/action-gh-release`).
39+
Prevents a tag-repoint or namespace-takeover attack from injecting code into
40+
the release pipeline that handles `NPM_TOKEN`.
41+
- **Added `.github/dependabot.yml`** for `github-actions` (weekly, grouped) and
42+
`npm` (weekly, dev/prod groups). Keeps SHA pins and dependencies fresh
43+
automatically.
44+
45+
### Tests
46+
47+
- New `test/cli-validation.test.ts` (20 tests) covers every fix above —
48+
selftest version, unknown-command rejection, `--days`/`--top` validators,
49+
`whatif --swap` validation including the single-letter loophole. Total now
50+
55 tests, still under one second.
51+
752
## [0.3.0] — 2026-04-26
853

954
Seven new features that knock items off the README roadmap. All in-tree;

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Don't have Claude Code installed yet but want to see what ccmeter looks like? Tr
7575
| `ccmeter tools` | per-tool cost breakdown — answers "which subagent ate the budget" |
7676
| `ccmeter compare` | week-over-week (or any two-period) deltas across all metrics |
7777
| `ccmeter whatif` | simulate model swaps & cache TTL scenarios on YOUR data |
78+
| `ccmeter prompts` | rank assistant turns by output-tokens-per-dollar |
7879
| `ccmeter share` | shareable Markdown stat-card or social SVG (paths redacted) |
7980
| `ccmeter live` | full-screen ticker — each turn lands as it happens |
8081
| `ccmeter dashboard` | local web UI on `127.0.0.1:7777` (no network egress) |
@@ -83,10 +84,14 @@ Don't have Claude Code installed yet but want to see what ccmeter looks like? Tr
8384
| `ccmeter watch` | one-line live tail today's spend (perfect for tmux/PS1) |
8485
| `ccmeter prompt --budget 10` | one-line, color-coded $ for your shell prompt |
8586
| `ccmeter metrics` | Prometheus exposition — scrape into Grafana |
87+
| `ccmeter notify` | desktop notification when projected spend nears your budget |
8688
| `ccmeter tag <id> <label>` | annotate a session for grouped reporting |
8789
| `ccmeter budget --set 200` | set & track a monthly budget |
8890
| `ccmeter digest --webhook URL` | post a weekly digest to Slack/Discord |
8991
| `ccmeter merge a.json b.json` | combine analyses from multiple machines |
92+
| `ccmeter pricing` | print the active pricing table (built-in + your overrides) |
93+
| `ccmeter reconcile` | diff ccmeter's local total vs Anthropic's authoritative usage |
94+
| `ccmeter selftest` | validate the parser/pricing against your real data |
9095
| `ccmeter doctor` | diagnose your setup |
9196
| `ccmeter check-privacy` | exhaustive list of every file ccmeter would read |
9297
| `ccmeter clear-cache` | drop the parsed cache at `~/.cache/ccmeter` |

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ccmeter",
3-
"version": "0.3.0",
3+
"version": "0.3.1",
44
"description": "Local-first spend & cache-efficiency dashboard for Claude Code. Reads ~/.claude/projects, tells you exactly what's costing you.",
55
"keywords": [
66
"claude",

src/cli/commands/selftest.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { costForTurn } from "../../core/pricing/compute.js";
2828
import { turnHasUsage, turnTimestampMs, turnModel } from "../../core/jsonl/schema.js";
2929
import { redactPath } from "../../core/privacy.js";
3030
import { getPricingVerifiedDate } from "../../core/pricing/models.js";
31+
import { VERSION } from "../index.js";
3132
import pc from "picocolors";
3233

3334
const { bold, dim, green, red, yellow, cyan } = pc;
@@ -322,9 +323,7 @@ export async function runSelftest(opts: SelftestOpts): Promise<void> {
322323
}
323324

324325
function pkgVersion(): string {
325-
// Avoid a hard import of package.json (which causes ESM resolution
326-
// headaches under different bundlers); fall back to a constant.
327-
return process.env.CCMETER_VERSION ?? "0.2.0";
326+
return process.env.CCMETER_VERSION ?? VERSION;
328327
}
329328

330329
function redactBasename(p: string): string {

src/cli/commands/whatif.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77

88
import pc from "picocolors";
99
import { analyze } from "../../core/analyze.js";
10-
import { pricingFor } from "../../core/pricing/models.js";
10+
import { pricingFor, listModels } from "../../core/pricing/models.js";
1111
import { bold, divider, fmtUSD } from "../ui/format.js";
1212

1313
interface WhatIfOpts {
14-
days?: string;
14+
days?: number | string;
1515
swap?: string[];
16-
cacheTtl?: string;
16+
cacheTtl?: number | string;
1717
disableCache?: boolean;
1818
}
1919

@@ -25,13 +25,16 @@ const FAMILY_TARGETS: Record<string, string> = {
2525

2626
export async function runWhatIf(opts: WhatIfOpts): Promise<void> {
2727
const days = parseInt(String(opts.days ?? 30), 10);
28+
29+
// parseSwaps throws on malformed input; main()'s top-level catch in
30+
// src/cli/index.ts prints the message in red and exits 1.
31+
const swaps = parseSwaps(opts.swap ?? []);
32+
2833
const a = await analyze({ days });
2934
const w = process.stdout.columns ? Math.min(process.stdout.columns, 88) : 80;
3035

3136
process.stdout.write(`\n${bold(`What-if simulation — last ${days} days`)}\n${divider(w)}\n`);
3237
process.stdout.write(`Actual spend: ${bold(fmtUSD(a.totals.totalCost))}\n\n`);
33-
34-
const swaps = parseSwaps(opts.swap ?? []);
3538
let simulatedTotal = 0;
3639
for (const s of a.sessions) {
3740
const targetModel = pickTarget(s.primaryModel, swaps) ?? s.primaryModel;
@@ -57,12 +60,13 @@ export async function runWhatIf(opts: WhatIfOpts): Promise<void> {
5760
if (opts.disableCache) {
5861
process.stdout.write(` • cache disabled (every read becomes full input)\n`);
5962
}
60-
if (opts.cacheTtl && opts.cacheTtl !== "300") {
63+
const ttlSec = opts.cacheTtl != null ? Number(opts.cacheTtl) : 300;
64+
if (ttlSec !== 300) {
6165
process.stdout.write(
62-
` • cache TTL: ${opts.cacheTtl}s (would re-bucket busts; needs phase-2 work)\n`,
66+
` • cache TTL: ${ttlSec}s (would re-bucket busts; needs phase-2 work)\n`,
6367
);
6468
}
65-
if (swaps.length === 0 && !opts.disableCache && (!opts.cacheTtl || opts.cacheTtl === "300")) {
69+
if (swaps.length === 0 && !opts.disableCache && ttlSec === 300) {
6670
process.stdout.write(pc.dim(` • no scenario flags set — try --swap opus->sonnet\n`));
6771
}
6872
process.stdout.write("\n");
@@ -89,11 +93,44 @@ interface Swap {
8993
to: string;
9094
}
9195

92-
function parseSwaps(raw: string[]): Swap[] {
96+
export function parseSwaps(raw: string[]): Swap[] {
97+
const knownModels = new Set(listModels().map((m) => m.id));
98+
const knownFamilies = new Set(Object.keys(FAMILY_TARGETS));
99+
const isValid = (token: string): boolean => {
100+
if (!token) return false;
101+
if (knownFamilies.has(token)) return true;
102+
if (knownModels.has(token)) return true;
103+
// Accept partial / dated suffixes that prefix-match a known model id
104+
// (mirrors pricingFor's lenient resolution). Require at least one hyphen
105+
// in the token so single-letter prefixes like "c" don't sneak through.
106+
if (!token.includes("-")) return false;
107+
for (const id of knownModels) if (token.startsWith(id) || id.startsWith(token)) return true;
108+
return false;
109+
};
110+
93111
const out: Swap[] = [];
94112
for (const r of raw) {
113+
if (!r.includes("->")) {
114+
throw new Error(
115+
`--swap expects 'from->to' (got "${r}"). Example: --swap opus->sonnet.`,
116+
);
117+
}
95118
const [from, to] = r.split("->").map((s) => s.trim().toLowerCase());
96-
if (!from || !to) continue;
119+
if (!from || !to) {
120+
throw new Error(
121+
`--swap expects 'from->to' (got "${r}"). Example: --swap opus->sonnet.`,
122+
);
123+
}
124+
if (!isValid(from)) {
125+
throw new Error(
126+
`--swap: unknown model '${from}'. Use one of: ${[...knownFamilies].join(", ")} (or run 'ccmeter pricing' for full ids).`,
127+
);
128+
}
129+
if (!isValid(to)) {
130+
throw new Error(
131+
`--swap: unknown model '${to}'. Use one of: ${[...knownFamilies].join(", ")} (or run 'ccmeter pricing' for full ids).`,
132+
);
133+
}
97134
out.push({ from, to });
98135
}
99136
return out;

0 commit comments

Comments
 (0)