Skip to content

Commit c15d0d8

Browse files
committed
Release ccmeter v0.3.0 - see CHANGELOG.md for details
1 parent 5deaafc commit c15d0d8

18 files changed

Lines changed: 1448 additions & 25 deletions

CHANGELOG.md

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

55
## [Unreleased]
66

7+
## [0.3.0] — 2026-04-26
8+
9+
Seven new features that knock items off the README roadmap. All in-tree;
10+
no new infrastructure required.
11+
12+
### Added
13+
14+
- **`ccmeter prompts`** — per-prompt quality scoring. Ranks every assistant
15+
turn by output-tokens-per-dollar. The top of the list is high-yield
16+
prompts (concise input → useful output); the bottom is low-yield (lots
17+
of cache + input → little output). Use the bottom list for self-coaching.
18+
- **iCal export**`ccmeter export --format ics` (alias `ical`) emits a
19+
standards-compliant `.ics` file with one VEVENT per session. Drop into
20+
Apple Calendar / Google Calendar / Outlook for billable-time tracking.
21+
- **Git-branch auto-tagging**`ccmeter --auto-tag-git ...` (or env
22+
`CCMETER_GIT_AUTOTAG=1`) reads `.git/HEAD` from each session's working
23+
directory and applies a `branch:<name>` tag to non-main sessions. Manual
24+
tags from `ccmeter tag` always win. Worktrees and detached-HEAD handled.
25+
- **`ccmeter notify`** — desktop budget notifications. One-shot or
26+
`--watch` polling daemon. macOS `osascript`, Linux `notify-send`,
27+
Windows toast (best-effort). Threshold and quiet-window are tunable.
28+
Reads your saved monthly budget from `ccmeter budget`.
29+
- **AI Coach in `ccmeter live`** — rules-based real-time warnings inline
30+
in the live ticker. Catches: idle-bust imminent (4+ minutes since last
31+
turn with active cache), 3 consecutive >$0.30 turns (likely loop),
32+
same tool fired 5+ times in last 10 turns (stuck), >$1 burned in the
33+
last minute (rate alarm). Each rule is 4 lines; PRs to add more are
34+
trivial.
35+
- **`ccmeter reconcile`** — diffs ccmeter's local total against
36+
Anthropic's authoritative `/v1/organizations/usage_report`. Requires
37+
`ANTHROPIC_API_KEY` in the environment with org-admin scope. Reports
38+
delta and percentage; flags >15% drift with diagnostic suggestions.
39+
Defensive about Anthropic's evolving API shape.
40+
- **`python/pyccmeter.py`** — Python companion module. Wraps the CLI's
41+
JSON export and exposes the analysis as Python dataclasses. Useful
42+
for piping into pandas / matplotlib / per-team rollups. Single-file,
43+
no install — copy into your project, `from pyccmeter import load_analysis`.
44+
45+
### Not built (multi-day projects)
46+
47+
The README roadmap also lists items that need infrastructure outside this
48+
repo's scope: native macOS menu-bar app (Xcode + signing + notarization),
49+
VS Code / JetBrains extensions (separate marketplaces), per-team
50+
aggregation server (hosting + auth), anonymized community comparator
51+
(SaaS backend), Go port (separate ecosystem). These remain on the
52+
roadmap but live in companion repos when they ship, not in `ccmeter` core.
53+
54+
## [0.2.1] — 2026-04-26
55+
56+
Cosmetic-only release. No behavioral changes.
57+
58+
### Fixed
59+
60+
- The published `0.2.0` tarball shipped stale Vite hashed assets from previous
61+
builds (multiple `index-XXXX.js` siblings). The published `index.html`
62+
always pointed at the current one, so the package worked fine, but the
63+
tarball was about 500 KB larger than necessary. v0.2.1 ships ~470 KB
64+
instead of ~948 KB.
65+
- One leftover `↓ Apr 2 cache TTL change` annotation in `docs/hero.svg` that
66+
the v0.2.0 reframing pass missed. Now reads `↓ Mar 2026 cache TTL rollout`
67+
to match the rest of the repo.
68+
- `package.json` `repository.url` is now in canonical `git+https://...` form,
69+
silencing the `npm pkg fix` warning at publish time.
70+
71+
### Changed
72+
73+
- Vite `emptyOutDir` defaults to `true` so future tarballs are slim by
74+
default. The opt-out is `VITE_EMPTY_OUT=0` for filesystems that can't
75+
unlink (sandboxed mounts, some CI runners).
76+
777
## [0.2.0] — 2026-04-26
878

979
### Fixed (critical, blocked launch)

docs/hero.svg

Lines changed: 2 additions & 2 deletions
Loading

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ccmeter",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
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",
@@ -17,7 +17,7 @@
1717
"homepage": "https://github.com/vnmoorthy/ccmeter",
1818
"repository": {
1919
"type": "git",
20-
"url": "https://github.com/vnmoorthy/ccmeter.git"
20+
"url": "git+https://github.com/vnmoorthy/ccmeter.git"
2121
},
2222
"bugs": {
2323
"url": "https://github.com/vnmoorthy/ccmeter/issues"
7.64 KB
Binary file not shown.

python/pyccmeter.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""
2+
pyccmeter — Python companion for ccmeter.
3+
4+
Wraps the ccmeter CLI so you can read your Claude Code spend analysis as
5+
ordinary Python dataclasses. Useful for piping into pandas, plotting in
6+
matplotlib, building per-team rollups, etc.
7+
8+
Why a wrapper instead of a re-port:
9+
- The TypeScript ccmeter is the source of truth (parser, pricing, rules).
10+
- Re-implementing all that in Python would mean two copies that drift.
11+
- This module shells out to `ccmeter export --format json` and parses
12+
the output. Stable schema (versioned via `schemaVersion`), zero copies.
13+
14+
Usage:
15+
from pyccmeter import load_analysis
16+
a = load_analysis(days=30)
17+
print(f"${a.totals.total_cost:.2f}")
18+
for s in sorted(a.sessions, key=lambda s: s.cost.total_cost, reverse=True)[:5]:
19+
print(s.id, s.project_path, s.cost.total_cost)
20+
21+
Requirements: Python 3.9+, ccmeter on PATH.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import json
27+
import shutil
28+
import subprocess
29+
from dataclasses import dataclass, field
30+
from typing import Any, Dict, List, Optional
31+
32+
33+
# --- public dataclasses ----------------------------------------------------
34+
35+
36+
@dataclass
37+
class Cost:
38+
input_cost: float
39+
output_cost: float
40+
cache_write_cost: float
41+
cache_read_cost: float
42+
total_cost: float
43+
input_tokens: int
44+
output_tokens: int
45+
cache_write_tokens: int
46+
cache_read_tokens: int
47+
model: str
48+
cache_tier: str
49+
50+
51+
@dataclass
52+
class CacheBust:
53+
ts: int
54+
tier: str
55+
gap_seconds: float
56+
wasted_cost: float
57+
session_id: str
58+
59+
60+
@dataclass
61+
class Session:
62+
id: str
63+
project_path: str
64+
start_ms: int
65+
end_ms: int
66+
duration_ms: int
67+
primary_model: str
68+
turn_count: int
69+
tool_use_count: int
70+
cost: Cost
71+
cache_busts: List[CacheBust]
72+
shape: str
73+
tag: Optional[str]
74+
tool_calls: Dict[str, int] = field(default_factory=dict)
75+
tool_cost: Dict[str, float] = field(default_factory=dict)
76+
77+
78+
@dataclass
79+
class Totals:
80+
total_cost: float
81+
input_tokens: int
82+
output_tokens: int
83+
cache_read_tokens: int
84+
cache_write_tokens: int
85+
busts: int
86+
bust_cost: float
87+
cache_hit_ratio: float
88+
sessions: int
89+
turns: int
90+
91+
92+
@dataclass
93+
class Recommendation:
94+
id: str
95+
severity: str
96+
title: str
97+
body: str
98+
estimated_monthly_savings: float
99+
100+
101+
@dataclass
102+
class Analysis:
103+
schema_version: int
104+
generated_at: int
105+
range_start_ms: int
106+
range_end_ms: int
107+
sessions: List[Session]
108+
totals: Totals
109+
recommendations: List[Recommendation]
110+
111+
112+
# --- public API ------------------------------------------------------------
113+
114+
115+
def load_analysis(days: int = 30, ccmeter_bin: str = "ccmeter") -> Analysis:
116+
"""Run ccmeter export and parse it into Python objects.
117+
118+
Raises RuntimeError if ccmeter isn't installed or returns non-zero.
119+
"""
120+
if shutil.which(ccmeter_bin) is None:
121+
raise RuntimeError(
122+
f"could not find `{ccmeter_bin}` on PATH. "
123+
"Install with: npm i -g ccmeter (or use `npx ccmeter`)."
124+
)
125+
proc = subprocess.run(
126+
[ccmeter_bin, "export", "--format", "json", "--days", str(days)],
127+
capture_output=True, text=True, check=False,
128+
)
129+
if proc.returncode != 0:
130+
raise RuntimeError(f"ccmeter exited {proc.returncode}: {proc.stderr.strip()}")
131+
return _parse(json.loads(proc.stdout))
132+
133+
134+
# --- internal: untyped JSON → typed dataclasses ----------------------------
135+
136+
137+
def _parse(j: Dict[str, Any]) -> Analysis:
138+
return Analysis(
139+
schema_version=int(j.get("schemaVersion", 1)),
140+
generated_at=int(j.get("generatedAt", 0)),
141+
range_start_ms=int(j.get("rangeStartMs", 0)),
142+
range_end_ms=int(j.get("rangeEndMs", 0)),
143+
sessions=[_session(s) for s in j.get("sessions", [])],
144+
totals=_totals(j.get("totals", {})),
145+
recommendations=[_rec(r) for r in j.get("recommendations", [])],
146+
)
147+
148+
149+
def _session(s: Dict[str, Any]) -> Session:
150+
return Session(
151+
id=s["id"],
152+
project_path=s.get("projectPath", ""),
153+
start_ms=int(s.get("startMs", 0)),
154+
end_ms=int(s.get("endMs", 0)),
155+
duration_ms=int(s.get("durationMs", 0)),
156+
primary_model=s.get("primaryModel", ""),
157+
turn_count=int(s.get("turnCount", 0)),
158+
tool_use_count=int(s.get("toolUseCount", 0)),
159+
cost=_cost(s.get("cost", {})),
160+
cache_busts=[_bust(b) for b in s.get("cacheBusts", [])],
161+
shape=s.get("shape", ""),
162+
tag=s.get("tag"),
163+
tool_calls=dict(s.get("toolCalls", {})),
164+
tool_cost={k: float(v) for k, v in s.get("toolCost", {}).items()},
165+
)
166+
167+
168+
def _cost(c: Dict[str, Any]) -> Cost:
169+
return Cost(
170+
input_cost=float(c.get("inputCost", 0)),
171+
output_cost=float(c.get("outputCost", 0)),
172+
cache_write_cost=float(c.get("cacheWriteCost", 0)),
173+
cache_read_cost=float(c.get("cacheReadCost", 0)),
174+
total_cost=float(c.get("totalCost", 0)),
175+
input_tokens=int(c.get("inputTokens", 0)),
176+
output_tokens=int(c.get("outputTokens", 0)),
177+
cache_write_tokens=int(c.get("cacheWriteTokens", 0)),
178+
cache_read_tokens=int(c.get("cacheReadTokens", 0)),
179+
model=c.get("model", ""),
180+
cache_tier=c.get("cacheTier", "none"),
181+
)
182+
183+
184+
def _bust(b: Dict[str, Any]) -> CacheBust:
185+
return CacheBust(
186+
ts=int(b.get("ts", 0)),
187+
tier=b.get("tier", "5m"),
188+
gap_seconds=float(b.get("gapSeconds", 0)),
189+
wasted_cost=float(b.get("wastedCost", 0)),
190+
session_id=b.get("sessionId", ""),
191+
)
192+
193+
194+
def _totals(t: Dict[str, Any]) -> Totals:
195+
return Totals(
196+
total_cost=float(t.get("totalCost", 0)),
197+
input_tokens=int(t.get("inputTokens", 0)),
198+
output_tokens=int(t.get("outputTokens", 0)),
199+
cache_read_tokens=int(t.get("cacheReadTokens", 0)),
200+
cache_write_tokens=int(t.get("cacheWriteTokens", 0)),
201+
busts=int(t.get("busts", 0)),
202+
bust_cost=float(t.get("bustCost", 0)),
203+
cache_hit_ratio=float(t.get("cacheHitRatio", 0)),
204+
sessions=int(t.get("sessions", 0)),
205+
turns=int(t.get("turns", 0)),
206+
)
207+
208+
209+
def _rec(r: Dict[str, Any]) -> Recommendation:
210+
return Recommendation(
211+
id=r.get("id", ""),
212+
severity=r.get("severity", "info"),
213+
title=r.get("title", ""),
214+
body=r.get("body", ""),
215+
estimated_monthly_savings=float(r.get("estimatedMonthlySavings", 0)),
216+
)
217+
218+
219+
if __name__ == "__main__":
220+
import sys
221+
days = int(sys.argv[1]) if len(sys.argv) > 1 else 30
222+
a = load_analysis(days=days)
223+
print(f"ccmeter — last {days} days")
224+
print(f" total spend: ${a.totals.total_cost:.2f}")
225+
print(f" sessions: {a.totals.sessions}")
226+
print(f" cache hit rate: {a.totals.cache_hit_ratio * 100:.1f}%")
227+
print(f" busts: {a.totals.busts} (wasted ${a.totals.bust_cost:.2f})")
228+
if a.recommendations:
229+
print("\n top recommendation:")
230+
r = a.recommendations[0]
231+
print(f" [{r.severity}] {r.title} (~${r.estimated_monthly_savings:.0f}/mo)")

0 commit comments

Comments
 (0)