Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,15 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
path: dist/

npm-launcher:
name: npm launcher
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm test
working-directory: npm
41 changes: 41 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: Publish to PyPI

# Build and publish a release to PyPI whenever a version tag (`v*`) is pushed.
# Uses OpenID Connect trusted publishing — no PyPI API token is stored.
# After PyPI, publishes the `agentdiff` npm launcher (npm/) at the same
# version, so `npx agentdiff` resolves to the release just uploaded.
on:
push:
tags:
Expand Down Expand Up @@ -51,6 +53,45 @@ jobs:
with:
packages-dir: dist/

publish-npm:
name: Publish npm launcher
needs: publish
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm provenance
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"

- name: Check versions match the tag
run: |
TAG="${GITHUB_REF_NAME#v}"
NPM_VERSION=$(node -p "require('./npm/package.json').version")
if [ "$TAG" != "$NPM_VERSION" ]; then
echo "::error::npm/package.json is $NPM_VERSION but the tag is $TAG"
exit 1
fi

- name: Test launcher
working-directory: npm
run: npm test

- name: Publish to npm
working-directory: npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "::warning::NPM_TOKEN secret is not set; skipping npm publish."
exit 0
fi
npm publish --provenance --access public

release:
name: Create GitHub Release
needs: publish
Expand Down
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ Any behavior change ships with tests; coverage must not drop.
- Every user-facing change adds a `CHANGELOG.md` entry under `[Unreleased]`.
- Version bumps + release notes happen in their own `chore/release-*` branch.
- PyPI package: `agent-trajectory-diff`. SemVer + Keep a Changelog.
- Release bumps also update `plugins/agentdiff/.claude-plugin/plugin.json` `version`
(Claude Code pins users to it; `tests/test_claude_plugin.py` enforces the match).
- Release bumps also update `plugins/agentdiff/.claude-plugin/plugin.json` and
`npm/package.json` `version` (Claude Code pins users to the plugin version; the
npm launcher runs the PyPI release of the same version). `tests/test_claude_plugin.py`
enforces both. The tag workflow publishes PyPI first, then npm (needs the
`NPM_TOKEN` repository secret).

## Project conventions

Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`--since`, `--project`, `--top`) and prints install help instead of failing
when neither `agentdiff` nor `uv` is available. The plugin version tracks the
package version.
- **`npx agentdiff`**: an npm package (`npm/`) that launches the Python CLI at
the same version using uv, else pipx, else a private virtualenv built once
with any Python 3.10+. Zero dependencies, about 2 kB. The tag workflow
publishes it to npm right after PyPI (needs the `NPM_TOKEN` secret), and CI
runs its tests.

### Changed
- The `/agentdiff:report` plugin skill now also falls back to pipx and to plain
Python 3.10+ (a private virtualenv under `~/.cache/agentdiff`), so it works
without uv.
- README: install table for npx, uv, pipx, pip and Claude Code.

## [0.6.0] - 2026-10-19

Expand Down
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
</p>

```bash
uvx --from agent-trajectory-diff agentdiff
npx agentdiff
```

or `uvx --from agent-trajectory-diff agentdiff` · or `/agentdiff:report` in [Claude Code](#inside-claude-code)

</div>

AgentDiff reads the session logs Claude Code already keeps on your machine and
Expand Down Expand Up @@ -83,8 +85,21 @@ agentdiff --json > spend.json # machine-readable
agentdiff --path /other/.claude # a different Claude Code config dir
```

Install it permanently with `pip install agent-trajectory-diff` (the command is
`agentdiff`), or run it once with `uvx` as shown above.
## Install

Pick whatever your machine already has. Every option runs the same CLI.

| You have | Run once | Install |
| :--- | :--- | :--- |
| Node 18+ | `npx agentdiff` | `npm i -g agentdiff` |
| [uv](https://docs.astral.sh/uv/) | `uvx --from agent-trajectory-diff agentdiff` | `uv tool install agent-trajectory-diff` |
| pipx | `pipx run --spec agent-trajectory-diff agentdiff` | `pipx install agent-trajectory-diff` |
| Python 3.10+ | | `pip install agent-trajectory-diff` |
| Claude Code | `/agentdiff:report` | see below |

The installed command is always `agentdiff`. The npm package is a small
launcher for the same Python CLI at the same version. It uses uv if present,
else pipx, else builds a private virtualenv once with your Python 3.10+.

### Inside Claude Code

Expand All @@ -99,16 +114,17 @@ On Claude Code versions before 2.1.275, add the marketplace first:
`/plugin install agentdiff@agentdiff`.

Then run the report in any session. Claude shows it and explains the biggest
avoidable items:
avoidable items. The plugin finds AgentDiff the same way as the npm launcher:
an installed `agentdiff`, else uv, pipx, or Python 3.10+.

```text
/agentdiff:report
/agentdiff:report --since 30d
```

Claude can also pick it up on its own when you ask things like "why is my
Claude Code usage so high?". The plugin needs either `uv` or an installed
`agentdiff` (0.6.0+). Without one, it tells you what to install.
Claude Code usage so high?". With no Python or uv at all, it tells you what to
install.
Without the plugin, prefix the command with `!` to run it in a session:
`!uvx --from agent-trajectory-diff agentdiff`.

Expand Down
26 changes: 26 additions & 0 deletions npm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# agentdiff

**See what your AI coding agent actually did, and what it cost.**

```bash
npx agentdiff
```

Reads the Claude Code session logs already on your machine and shows where the
money went and what was avoidable: long context re-sent every turn, prompt
caches rebuilt after breaks, stuck retries. No config, no API key, nothing
uploaded.

```bash
npx agentdiff --since 30d # 24h, 7d, 30d, 12w … or all
npx agentdiff --project api # only projects whose name contains "api"
npx agentdiff --json # machine-readable
```

This npm package is a thin launcher for the Python CLI
([`agent-trajectory-diff`](https://pypi.org/project/agent-trajectory-diff/))
at the same version. It uses `uvx` if you have uv, else `pipx`, else builds a
private virtualenv once with any Python 3.10+.

Docs and the Claude Code plugin: [agentdiff.app](https://agentdiff.app) ·
[GitHub](https://github.com/kerrshift/agentdiff). MIT licensed.
113 changes: 113 additions & 0 deletions npm/bin/agentdiff.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env node
// `npx agentdiff` — runs the AgentDiff Python CLI (PyPI: agent-trajectory-diff)
// at the same version as this npm package, using whichever Python runner the
// machine already has: uvx, then pipx, then a private virtualenv built once
// with any Python 3.10+. No runtime dependencies.
"use strict";

const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");

const { version } = require("../package.json");

const SPEC = `agent-trajectory-diff==${version}`;
const IS_WIN = process.platform === "win32";

function which(cmd) {
const exts = IS_WIN ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
if (!dir) continue;
for (const ext of exts) {
const candidate = path.join(dir, cmd + ext);
try {
if (fs.statSync(candidate).isFile()) {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
}
} catch {
// not here
}
}
}
return null;
}

function run(cmd, args) {
const result = spawnSync(cmd, args, { stdio: "inherit" });
if (result.error) {
console.error(`agentdiff: could not start ${cmd}: ${result.error.message}`);
return 1;
}
if (result.signal) process.kill(process.pid, result.signal);
return result.status ?? 1;
}

function quiet(cmd, args) {
const result = spawnSync(cmd, args, { stdio: "ignore" });
return result.error ? 1 : result.status ?? 1;
}

function findPython() {
for (const name of ["python3", "python"]) {
const exe = which(name);
if (exe && quiet(exe, ["-c", "import sys; sys.exit(sys.version_info < (3, 10))"]) === 0) {
return exe;
}
}
return null;
}

function cacheDir() {
if (process.env.AGENTDIFF_CACHE_DIR) return process.env.AGENTDIFF_CACHE_DIR;
if (IS_WIN) {
return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "agentdiff");
}
return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "agentdiff");
}

function viaVirtualenv(python, args) {
const venv = path.join(cacheDir(), `venv-${version}`);
const binDir = path.join(venv, IS_WIN ? "Scripts" : "bin");
const agentdiff = path.join(binDir, IS_WIN ? "agentdiff.exe" : "agentdiff");
if (!fs.existsSync(agentdiff)) {
console.error(`Setting up AgentDiff ${version} in ${venv} (first run only)...`);
const venvPython = path.join(binDir, IS_WIN ? "python.exe" : "python");
if (
quiet(python, ["-m", "venv", venv]) !== 0 ||
quiet(venvPython, ["-m", "pip", "install", "--quiet", SPEC]) !== 0
) {
console.error(`agentdiff: could not install ${SPEC} with ${python}.`);
console.error(`Try: pip install "${SPEC}"`);
return 1;
}
}
return run(agentdiff, args);
}

function main(args) {
const uvx = which("uvx");
if (uvx) return run(uvx, ["-q", "--from", SPEC, "agentdiff", ...args]);

const pipx = which("pipx");
if (pipx) {
// Warm pipx's cache silently so its setup chatter stays out of the output.
quiet(pipx, ["run", "--spec", SPEC, "agentdiff", "--help"]);
return run(pipx, ["run", "--spec", SPEC, "agentdiff", ...args]);
}

const python = findPython();
if (python) return viaVirtualenv(python, args);

console.error(
[
"agentdiff needs Python 3.10+ (or uv) and none was found.",
" uv: https://docs.astral.sh/uv/getting-started/installation/",
" Python: https://www.python.org/downloads/",
].join("\n"),
);
return 1;
}

process.exitCode = main(process.argv.slice(2));
34 changes: 34 additions & 0 deletions npm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "agentdiff",
"version": "0.6.0",
"description": "See what your AI coding agent actually did, and what it cost. Local spend report for Claude Code sessions.",
"keywords": [
"claude-code",
"cost",
"usage",
"tokens",
"prompt-caching",
"ai-agents",
"cli"
],
"homepage": "https://agentdiff.app",
"repository": {
"type": "git",
"url": "git+https://github.com/kerrshift/agentdiff.git",
"directory": "npm"
},
"bugs": "https://github.com/kerrshift/agentdiff/issues",
"license": "MIT",
"bin": {
"agentdiff": "bin/agentdiff.js"
},
"files": [
"bin"
],
"engines": {
"node": ">=18"
},
"scripts": {
"test": "node --test test/*.test.js"
}
}
Loading
Loading