diff --git a/CHANGELOG.md b/CHANGELOG.md index 921027fe..60e40727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`agentdiff --card`: a shareable image of your spend.** Writes a + self-contained page (`agentdiff-card.html`, change with `--card-out`) and + opens it (`--no-open` to skip). The page draws the card on a canvas in the + brand fonts and exports a PNG sized for X and LinkedIn: landscape + 1200×675 or portrait 1080×1350, at 2x. It has light and dark themes, a + "Hide $" option, and Download / Copy buttons. Only totals and finding + categories go on the card, never session titles, project names, files or + commands. No new Python dependencies. +- The terminal report ends with a one-line pointer to `agentdiff --card`. + ## [0.6.2] - 2026-10-19 AgentDiff v0.6.2 ships the npm launcher under its working name: diff --git a/README.md b/README.md index 3c7e7fe9..9066519b 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,18 @@ agentdiff --since 30d # 24h, 7d, 30d, 12w … or all agentdiff --project api # only projects whose name contains "api" agentdiff --top 10 # list more findings and sessions agentdiff --json > spend.json # machine-readable +agentdiff --card # shareable image for LinkedIn / X agentdiff --path /other/.claude # a different Claude Code config dir ``` +### Share your numbers + +`agentdiff --card` opens a page in your browser that draws your spend as an +image, sized for X and LinkedIn (landscape or portrait), with light and dark +themes and an option to hide dollar amounts. Click **Download PNG** or +**Copy image** and paste it into your post. The card shows totals and +categories only: no project names, files or commands. + ## Install Pick whatever your machine already has. Every option runs the same CLI. diff --git a/src/agentdiff/cli.py b/src/agentdiff/cli.py index 2d266aa5..955eae28 100644 --- a/src/agentdiff/cli.py +++ b/src/agentdiff/cli.py @@ -322,6 +322,17 @@ def sessions( ), top: int = typer.Option(5, "--top", help="How many findings and sessions to list."), as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON."), + card: bool = typer.Option( + False, + "--card", + help="Write a shareable card (HTML page that exports a PNG for LinkedIn/X).", + ), + card_out: str = typer.Option( + "agentdiff-card.html", "--card-out", help="Where to write the card page." + ), + open_card: bool = typer.Option( + True, "--open/--no-open", help="Open the card in your browser." + ), path: str | None = typer.Option( None, "--path", @@ -349,6 +360,17 @@ def sessions( if as_json: typer.echo(json.dumps(to_json(analysis, period=period), indent=2)) return + if card: + from agentdiff.sessions.card import write_card + + written = write_card(analysis, period=period, out=Path(card_out)) + typer.echo(f"Card written to {written}") + typer.echo("It shows only totals and categories, no project names or files.") + if open_card: + import webbrowser + + webbrowser.open(written.resolve().as_uri()) + return render(analysis, period=period, top=top) diff --git a/src/agentdiff/sessions/card.py b/src/agentdiff/sessions/card.py new file mode 100644 index 00000000..c50d76a3 --- /dev/null +++ b/src/agentdiff/sessions/card.py @@ -0,0 +1,291 @@ +"""Shareable spend card: a self-contained HTML page that draws a PNG. + +The page renders the card on a in the browser (brand fonts from +Google Fonts, system fonts as fallback) and offers Download / Copy buttons, +sized for LinkedIn and X. No Python image dependencies. + +Privacy: the card only carries aggregate numbers and finding categories — +never session titles, project names, file paths or commands. +""" + +import json +from pathlib import Path +from typing import Any + +from agentdiff import __version__ +from agentdiff.sessions.analyzer import Analysis +from agentdiff.sessions.report import KIND_LABEL + +DEFAULT_NAME = "agentdiff-card.html" +RUN_COMMAND = "npx @kerrshift/agentdiff" + + +def _period_label(period: str) -> str: + """'last 7d' -> 'last 7 days'; 'all time' stays.""" + units = {"m": "minute", "h": "hour", "d": "day", "w": "week"} + if period.startswith("last "): + amount = period[5:] + if amount[:-1].isdigit() and amount[-1] in units: + n = int(amount[:-1]) + unit = units[amount[-1]] + ("s" if n != 1 else "") + return f"last {n} {unit}" + return period + + +def card_data( + analysis: Analysis, *, period: str, agent: str = "Claude Code" +) -> dict[str, Any]: + spend = analysis.spend + by_kind: dict[str, float] = {} + for finding in analysis.findings: + if finding.is_waste: + by_kind[finding.kind] = by_kind.get(finding.kind, 0.0) + finding.usd + leaks = [ + {"label": KIND_LABEL.get(kind, kind), "usd": round(usd, 2)} + for kind, usd in sorted(by_kind.items(), key=lambda kv: -kv[1]) + if usd >= 0.01 + ] + return { + "agent": agent, + "period": _period_label(period), + "sessions": len(analysis.sessions), + "projects": len({s.session.project for s in analysis.sessions}), + "total": round(analysis.total, 2), + "avoidable": round(analysis.waste, 2), + "breakdown": [ + {"label": "Re-reading context", "usd": round(spend.cache_read, 2)}, + {"label": "Writing context to cache", "usd": round(spend.cache_write, 2)}, + {"label": "Model output", "usd": round(spend.output, 2)}, + {"label": "Background calls", "usd": round(spend.background, 2)}, + ], + "leaks": leaks[:3], + "command": RUN_COMMAND, + "version": __version__, + } + + +def build_card_html(data: dict[str, Any]) -> str: + payload = json.dumps(data).replace(" Path: + out = Path(out) + if out.is_dir(): + out = out / DEFAULT_NAME + out.write_text( + build_card_html(card_data(analysis, period=period)), encoding="utf-8" + ) + return out + + +_TEMPLATE = r""" + + + + +AgentDiff card + + + + + + +
+ + + + +
+ +
+ + +
+
+

Only totals and categories are on the card: no project names, files or commands. +Made with AgentDiff, from your local Claude Code logs.

+ + + +""" diff --git a/src/agentdiff/sessions/report.py b/src/agentdiff/sessions/report.py index 5bcee2cb..7185edf4 100644 --- a/src/agentdiff/sessions/report.py +++ b/src/agentdiff/sessions/report.py @@ -153,6 +153,10 @@ def render( f"{len(reported)} sessions cross-checked against Claude Code's own counter. " f"Nothing left your machine.[/dim]" ) + console.print( + "[dim]Share it:[/dim] [bold]agentdiff --card[/bold] " + "[dim]makes an image for LinkedIn or X (totals only, no project names).[/dim]" + ) console.print() diff --git a/tests/test_card.py b/tests/test_card.py new file mode 100644 index 00000000..6e647949 --- /dev/null +++ b/tests/test_card.py @@ -0,0 +1,91 @@ +"""Shareable card: aggregate-only data, embedded safely, written by the CLI.""" + +import json +import re + +from typer.testing import CliRunner + +from agentdiff.cli import app +from agentdiff.sessions.analyzer import analyze +from agentdiff.sessions.card import _period_label, build_card_html, card_data +from agentdiff.sessions.claude_code import load_sessions +from test_sessions import Log + + +def _root(tmp_path): + log = Log(session_id="s1", cwd="/home/me/secret-client-project") + log.title("Fix billing for ACME Corp") + for i in range(3): + (cid,) = log.turn( + i, + tools=[("Bash", {"command": "pytest tests/test_acme_payments.py"})], + output=100_000, + ) + log.result(i + 0.1, cid, "ImportError: acme", error=True) + log.turn(5, cache_read=700_000) + log.write(tmp_path) + return tmp_path + + +def _embedded(html: str) -> dict: + match = re.search(r"const D = (\{.*?\});\n", html, re.S) + assert match + return json.loads(match.group(1).replace("<\\/", " 0 + assert embedded["leaks"][0]["label"] == "Stuck retries" + assert embedded["command"] == "npx @kerrshift/agentdiff" + + +def test_embedded_data_cannot_close_the_script_tag(): + data = {"agent": "", "leaks": [], "breakdown": []} + html = build_card_html(data) + assert "