From 2963104f65c44fe5912bba2df3e88beeba797a6c Mon Sep 17 00:00:00 2001
From: lostmartian
Date: Mon, 19 Oct 2026 07:22:56 +0530
Subject: [PATCH 1/2] feat(dist): add npx agentdiff and make every install path
work
Add an npm launcher (npm/) so `npx agentdiff` runs the Python CLI at
the same version via uv, pipx, or a private virtualenv built once with
any Python 3.10+. Publish it from the tag workflow after PyPI and test
it in CI. Give the Claude Code plugin's report script the same pipx and
plain-Python fallbacks, and add an install table to the README.
---
.github/workflows/ci.yml | 13 +-
.github/workflows/publish.yml | 41 +++++++
AGENTS.md | 7 +-
CHANGELOG.md | 11 ++
README.md | 28 ++++-
npm/README.md | 26 ++++
npm/bin/agentdiff.js | 113 ++++++++++++++++++
npm/package.json | 34 ++++++
npm/test/agentdiff.test.js | 103 ++++++++++++++++
.../agentdiff/skills/report/scripts/report.sh | 48 ++++++--
..._claude_plugin.py => test_distribution.py} | 24 +++-
11 files changed, 426 insertions(+), 22 deletions(-)
create mode 100644 npm/README.md
create mode 100755 npm/bin/agentdiff.js
create mode 100644 npm/package.json
create mode 100644 npm/test/agentdiff.test.js
rename tests/{test_claude_plugin.py => test_distribution.py} (77%)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 69461d73..adc8fdd9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,4 +49,15 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: dist
- path: dist/
\ No newline at end of file
+ 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
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 610905a5..ae65f873 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -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:
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 9181faa5..3a8fb090 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef16e87c..f9f41b43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index f5d0c6de..8cf3af6e 100644
--- a/README.md
+++ b/README.md
@@ -14,9 +14,11 @@
```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)
+
AgentDiff reads the session logs Claude Code already keeps on your machine and
@@ -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
@@ -99,7 +114,8 @@ 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
@@ -107,8 +123,8 @@ avoidable items:
```
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`.
diff --git a/npm/README.md b/npm/README.md
new file mode 100644
index 00000000..70d6b0ea
--- /dev/null
+++ b/npm/README.md
@@ -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.
diff --git a/npm/bin/agentdiff.js b/npm/bin/agentdiff.js
new file mode 100755
index 00000000..ac78e938
--- /dev/null
+++ b/npm/bin/agentdiff.js
@@ -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));
diff --git a/npm/package.json b/npm/package.json
new file mode 100644
index 00000000..bb8dd321
--- /dev/null
+++ b/npm/package.json
@@ -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"
+ }
+}
diff --git a/npm/test/agentdiff.test.js b/npm/test/agentdiff.test.js
new file mode 100644
index 00000000..3b37f043
--- /dev/null
+++ b/npm/test/agentdiff.test.js
@@ -0,0 +1,103 @@
+"use strict";
+// Exercises the launcher's runner selection with stub executables on PATH.
+// Each stub appends its argv to a log file so the test can see what ran.
+
+const { test } = require("node:test");
+const assert = require("node:assert/strict");
+const { spawnSync } = require("node:child_process");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+const BIN = path.join(__dirname, "..", "bin", "agentdiff.js");
+const { version } = require("../package.json");
+const SPEC = `agent-trajectory-diff==${version}`;
+
+const skipOnWindows = { skip: process.platform === "win32" };
+
+function sandbox() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentdiff-npm-"));
+ const bin = path.join(dir, "bin");
+ fs.mkdirSync(bin);
+ return { dir, bin, log: path.join(dir, "calls.log") };
+}
+
+function stub(binDir, name, body) {
+ const file = path.join(binDir, name);
+ fs.writeFileSync(file, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
+}
+
+function logArgs(log, name) {
+ return `echo "${name} $*" >> "${log}"`;
+}
+
+function launch(sb, args) {
+ return spawnSync(process.execPath, [BIN, ...args], {
+ env: { PATH: `${sb.bin}:/bin`, HOME: sb.dir, AGENTDIFF_CACHE_DIR: path.join(sb.dir, "cache") },
+ encoding: "utf8",
+ });
+}
+
+function calls(sb) {
+ return fs.existsSync(sb.log) ? fs.readFileSync(sb.log, "utf8").trim().split("\n") : [];
+}
+
+test("prefers uvx and pins the matching PyPI version", skipOnWindows, () => {
+ const sb = sandbox();
+ stub(sb.bin, "uvx", logArgs(sb.log, "uvx"));
+ stub(sb.bin, "pipx", logArgs(sb.log, "pipx"));
+ const result = launch(sb, ["--since", "30d"]);
+ assert.equal(result.status, 0);
+ assert.deepEqual(calls(sb), [`uvx -q --from ${SPEC} agentdiff --since 30d`]);
+});
+
+test("falls back to pipx when uv is missing", skipOnWindows, () => {
+ const sb = sandbox();
+ stub(sb.bin, "pipx", logArgs(sb.log, "pipx"));
+ const result = launch(sb, ["--json"]);
+ assert.equal(result.status, 0);
+ assert.deepEqual(calls(sb), [
+ `pipx run --spec ${SPEC} agentdiff --help`,
+ `pipx run --spec ${SPEC} agentdiff --json`,
+ ]);
+});
+
+test("builds a private virtualenv with plain Python, once", skipOnWindows, () => {
+ const sb = sandbox();
+ // Fake python3: passes the version check, and `-m venv DIR` creates a venv
+ // whose python (pip install) and agentdiff log their calls.
+ stub(
+ sb.bin,
+ "python3",
+ `${logArgs(sb.log, "python3")}
+if [ "$1" = "-m" ] && [ "$2" = "venv" ]; then
+ mkdir -p "$3/bin"
+ printf '#!/bin/sh\\necho "venv-python $*" >> "${sb.log}"\\n' > "$3/bin/python"
+ printf '#!/bin/sh\\necho "venv-agentdiff $*" >> "${sb.log}"\\nexit 3\\n' > "$3/bin/agentdiff"
+ chmod +x "$3/bin/python" "$3/bin/agentdiff"
+fi
+exit 0`,
+ );
+ const first = launch(sb, ["--top", "2"]);
+ assert.equal(first.status, 3, "propagates the CLI's exit code");
+ assert.match(first.stderr, /first run only/);
+ const venv = path.join(sb.dir, "cache", `venv-${version}`);
+ assert.deepEqual(calls(sb), [
+ "python3 -c import sys; sys.exit(sys.version_info < (3, 10))",
+ `python3 -m venv ${venv}`,
+ `venv-python -m pip install --quiet ${SPEC}`,
+ "venv-agentdiff --top 2",
+ ]);
+
+ fs.writeFileSync(sb.log, "");
+ launch(sb, []);
+ assert.deepEqual(calls(sb).filter(Boolean).slice(-1), ["venv-agentdiff"]);
+ assert.ok(!calls(sb).some((c) => c.includes("pip install")), "reuses the venv");
+});
+
+test("explains what to install when nothing is available", skipOnWindows, () => {
+ const sb = sandbox();
+ const result = launch(sb, []);
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /needs Python 3\.10\+ \(or uv\)/);
+});
diff --git a/plugins/agentdiff/skills/report/scripts/report.sh b/plugins/agentdiff/skills/report/scripts/report.sh
index f609b59d..c8bb2fda 100755
--- a/plugins/agentdiff/skills/report/scripts/report.sh
+++ b/plugins/agentdiff/skills/report/scripts/report.sh
@@ -1,26 +1,56 @@
#!/usr/bin/env bash
# Runs the AgentDiff session report for the /agentdiff:report skill.
#
-# Prefers an installed `agentdiff` that has the `sessions` command, then falls
-# back to uvx. Always exits 0: a failed skill shell command aborts the whole
-# skill, so problems are printed as text for Claude to relay instead.
+# Tries, in order: an installed `agentdiff` that has the `sessions` command,
+# uvx, pipx, then a private virtualenv built with any Python 3.10+ (created
+# once under ~/.cache/agentdiff). Always exits 0: a failed skill shell command
+# aborts the whole skill, so problems are printed as text for Claude to relay.
set -u
+SPEC='agent-trajectory-diff>=0.6.0'
export COLUMNS="${COLUMNS:-100}"
export NO_COLOR=1
-if command -v agentdiff >/dev/null 2>&1 && agentdiff sessions --help >/dev/null 2>&1; then
- agentdiff sessions "$@" 2>&1 || echo "AgentDiff exited with an error (see above)."
+fail() { echo "AgentDiff exited with an error (see above)."; }
+
+has_sessions() { "$1" sessions --help >/dev/null 2>&1; }
+
+find_python() {
+ for py in python3 python; do
+ if command -v "$py" >/dev/null 2>&1 \
+ && "$py" -c 'import sys; sys.exit(sys.version_info < (3, 10))' 2>/dev/null; then
+ echo "$py"
+ return 0
+ fi
+ done
+ return 1
+}
+
+if command -v agentdiff >/dev/null 2>&1 && has_sessions agentdiff; then
+ agentdiff sessions "$@" 2>&1 || fail
elif command -v uvx >/dev/null 2>&1; then
- uvx -q --from 'agent-trajectory-diff>=0.6.0' agentdiff sessions "$@" 2>&1 \
- || echo "AgentDiff exited with an error (see above)."
+ uvx -q --from "$SPEC" agentdiff sessions "$@" 2>&1 || fail
+elif command -v pipx >/dev/null 2>&1; then
+ # Warm pipx's cache silently so its setup chatter stays out of the report.
+ pipx run --spec "$SPEC" agentdiff --help >/dev/null 2>&1
+ pipx run --spec "$SPEC" agentdiff sessions "$@" 2>&1 || fail
+elif PY=$(find_python); then
+ VENV="${XDG_CACHE_HOME:-$HOME/.cache}/agentdiff/venv"
+ BIN="$VENV/bin/agentdiff"
+ if [ ! -x "$BIN" ] || ! has_sessions "$BIN"; then
+ echo "Setting up AgentDiff in $VENV (first run only)..."
+ "$PY" -m venv "$VENV" >/dev/null 2>&1 \
+ && "$VENV/bin/python" -m pip install --quiet --upgrade "$SPEC" >/dev/null 2>&1 \
+ || { echo "Could not install AgentDiff with $PY. Try: pip install \"$SPEC\""; exit 0; }
+ fi
+ "$BIN" sessions "$@" 2>&1 || fail
else
cat <<'EOF'
-AgentDiff could not run: neither `agentdiff` (0.6.0 or later) nor `uvx` was found.
+AgentDiff could not run: it needs Python 3.10+ (or uv) and none was found.
Install one of these, then run /agentdiff:report again:
- uv: https://docs.astral.sh/uv/getting-started/installation/
- - pip: pip install "agent-trajectory-diff>=0.6.0"
+ - Python: https://www.python.org/downloads/
EOF
fi
exit 0
diff --git a/tests/test_claude_plugin.py b/tests/test_distribution.py
similarity index 77%
rename from tests/test_claude_plugin.py
rename to tests/test_distribution.py
index dbd88cd1..fed6c76b 100644
--- a/tests/test_claude_plugin.py
+++ b/tests/test_distribution.py
@@ -1,4 +1,4 @@
-"""Claude Code plugin: marketplace/manifest consistency and the report script."""
+"""Distribution: Claude Code plugin, its report script, and the npm launcher."""
import json
import os
@@ -45,6 +45,16 @@ def test_plugin_version_tracks_package_version():
assert manifest["version"] == __version__
+def test_npm_launcher_version_tracks_package_version():
+ from agentdiff import __version__
+
+ package = json.loads((ROOT / "npm" / "package.json").read_text())
+ assert package["name"] == "agentdiff"
+ assert package["version"] == __version__
+ assert package["bin"] == {"agentdiff": "bin/agentdiff.js"}
+ assert os.access(ROOT / "npm" / "bin" / "agentdiff.js", os.X_OK)
+
+
def test_skill_preapproves_the_command_it_injects():
text = SKILL.read_text()
fields = _frontmatter(text)
@@ -82,8 +92,14 @@ def test_report_script_runs_and_never_fails(tmp_path):
@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash")
def test_report_script_explains_install_when_nothing_is_available(tmp_path):
- env = {"PATH": "/usr/bin:/bin", "HOME": str(tmp_path)}
- result = subprocess.run([str(SCRIPT)], env=env, capture_output=True, text=True)
+ # PATH holds only `cat`: no agentdiff, uvx, pipx or Python to fall back on.
+ empty = tmp_path / "bin"
+ empty.mkdir()
+ (empty / "cat").symlink_to(shutil.which("cat"))
+ env = {"PATH": str(empty), "HOME": str(tmp_path)}
+ result = subprocess.run(
+ [shutil.which("bash"), str(SCRIPT)], env=env, capture_output=True, text=True
+ )
assert result.returncode == 0
assert "AgentDiff could not run" in result.stdout
- assert "pip install" in result.stdout
+ assert "Python 3.10+" in result.stdout
From eed25c3e26be66d221fdc16a125ece479f8c5cff Mon Sep 17 00:00:00 2001
From: lostmartian
Date: Mon, 19 Oct 2026 07:36:45 +0530
Subject: [PATCH 2/2] test(npm): hide system bin in the nothing-installed case
On Ubuntu /bin is /usr/bin and holds python3, so the launcher correctly
found Python and the test's expectation of 'nothing available' failed in
CI. Run that case with only the stub directory on PATH.
---
npm/test/agentdiff.test.js | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/npm/test/agentdiff.test.js b/npm/test/agentdiff.test.js
index 3b37f043..3aebe760 100644
--- a/npm/test/agentdiff.test.js
+++ b/npm/test/agentdiff.test.js
@@ -31,9 +31,16 @@ function logArgs(log, name) {
return `echo "${name} $*" >> "${log}"`;
}
-function launch(sb, args) {
+// Stubs are POSIX sh scripts that need coreutils (mkdir, chmod) from /bin.
+// Pass systemBin=false to hide everything but the stubs, since /bin may hold
+// a real python3 (it does on Ubuntu, where /bin is /usr/bin).
+function launch(sb, args, { systemBin = true } = {}) {
return spawnSync(process.execPath, [BIN, ...args], {
- env: { PATH: `${sb.bin}:/bin`, HOME: sb.dir, AGENTDIFF_CACHE_DIR: path.join(sb.dir, "cache") },
+ env: {
+ PATH: systemBin ? `${sb.bin}:/bin` : sb.bin,
+ HOME: sb.dir,
+ AGENTDIFF_CACHE_DIR: path.join(sb.dir, "cache"),
+ },
encoding: "utf8",
});
}
@@ -97,7 +104,7 @@ exit 0`,
test("explains what to install when nothing is available", skipOnWindows, () => {
const sb = sandbox();
- const result = launch(sb, []);
+ const result = launch(sb, [], { systemBin: false });
assert.equal(result.status, 1);
assert.match(result.stderr, /needs Python 3\.10\+ \(or uv\)/);
});