diff --git a/AGENTS.md b/AGENTS.md index 977c44d..4252bb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,7 @@ This repo **is** a CLI tool manager, so the word "upgrade" is overloaded: **AI CLI Preparation v2.0** — Tool version auditing and installation management for AI coding agents. - **Modules:** 21 Python modules under `cli_audit/` (see [`cli_audit/AGENTS.md`](./cli_audit/AGENTS.md)) -- **Catalog:** 98 JSON tool definitions under `catalog/` +- **Catalog:** 107 JSON tool definitions under `catalog/` - **Installers:** 34 Bash scripts under `scripts/` (see [`scripts/AGENTS.md`](./scripts/AGENTS.md)) - **Phase 1:** Detection & auditing (complete) - **Phase 2:** Installation & upgrade management (complete) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d9f598..76b6d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] ### Added +- `pi` catalog entry: the pi coding agent, installed from npm as `@earendil-works/pi-coding-agent` (the earlier `@mariozechner/pi-coding-agent` package is deprecated); upstream version from GitHub `earendil-works/pi`. +- `jules` catalog entry: the Google Jules CLI (`@google/jules`), Google's asynchronous coding agent, with bash completion from `jules completion bash`. - `herdr` catalog entry (`catalog/herdr.json`): terminal multiplexer for coding agents, installed as a raw linux binary from GitHub releases (x86_64, aarch64) with bash completion. - `wslu`/`wslview` catalog entry (`catalog/wslu.json` + `scripts/install_wslu.sh`), gated by a new `requires_wsl` catalog flag so it is only surfaced and installed under WSL. Its installer also points the xdg default browser at `wslview` (so links open in the Windows browser); opt out with `WSLU_SET_DEFAULT_BROWSER=0`. - Governance files: PR template, `CHANGELOG.md`. Security reporting is covered by the [org-level SECURITY.md](https://github.com/netresearch/.github/blob/main/SECURITY.md). @@ -20,6 +22,7 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. - Binary-probe fallback in `guide.sh` when the post-install snapshot refresh is stale. ### Fixed +- `make uninstall-` removed an npm global package by the tool name. For `pi` (package `@earendil-works/pi-coding-agent`) that ran `npm uninstall -g pi`, which leaves the agent installed and names an unrelated npm package `pi`. The package name is now read from the binary's symlink into `node_modules`. - The network-free refresh that `make upgrade` runs first (`audit.py --update-local`) skipped every multi-version row, so `python@3.14`, `node@26` and the other cycles kept the version they were last written with. After a successful upgrade the guide still offered the same upgrade until an install ran again. The refresh re-detects the cycle rows, the same way the post-install merge refresh already did. - Audit detection skips virtualenv/conda bin dirs, like reconcile already did. An activated `~/.venv` made the audit report its own copy (`~/.venv/bin/black` 25.11.0) instead of the installation (`uv tool` black 26.5.1), so every upgrade of black, isort and python@3.14 looked like a no-op. Catalog `version_command`s run with the same filtered PATH. Reconcile no longer drops uv-tool and pipx installations, whose per-tool directories also carry a `pyvenv.cfg`. A tool that exists only inside a virtualenv or conda environment is now reported as not installed; the bulk missing-tool check and the post-install validation use the same lookup. - `make upgrade` hid every pinned tool, whatever the pin. A release skipped with `s` ("ask again when newer patch available") hid the tool for good. A pin now hides a tool only while it is `never`, equals the target release (`s`), equals the installed version (`p`), or equals the cycle. diff --git a/README.md b/README.md index 73f10f7..db37448 100644 --- a/README.md +++ b/README.md @@ -468,7 +468,7 @@ make test-parallel # Parallel via pytest-xdist ## Installation scripts -All 97 cataloged tools can be installed, upgraded, uninstalled, or reconciled via generic Make targets: +All 107 cataloged tools can be installed, upgraded, uninstalled, or reconciled via generic Make targets: ```bash # Generic pattern targets - work for ANY cataloged tool @@ -539,7 +539,7 @@ When ambiguous, the audit may report a generic bucket (e.g., `~/.local/bin`). Th ### Actions: install, upgrade, uninstall, reconcile -All pattern targets (`install-%`, `upgrade-%`, `uninstall-%`, `reconcile-%`) work for any of the 97 cataloged tools. They use a three-step fallback: dedicated script, then generic installer, then error. +All pattern targets (`install-%`, `upgrade-%`, `uninstall-%`, `reconcile-%`) work for any of the 107 cataloged tools. They use a three-step fallback: dedicated script, then generic installer, then error. ```bash # Upgrade any tool diff --git a/catalog/COVERAGE.md b/catalog/COVERAGE.md index ffe0c90..03b308f 100644 --- a/catalog/COVERAGE.md +++ b/catalog/COVERAGE.md @@ -1,81 +1,42 @@ # Catalog Coverage -This file documents which tools have catalog entries and which use dedicated install scripts. - -## Tools with Catalog Entries (57) - -These tools use the catalog-based installation system with generic installers: - -- ansible, ast-grep, aws, bandit, bat, black, codex, composer, curlie, dasel -- delta, direnv, dive, entr, fd, flake8, fx, fzf, gem, gemini, gh, git-absorb -- git-branchless, git-lfs, gitleaks, glab, golangci-lint, httpie, isort, just -- kubectl, ninja, npm, opengrep, parallel, pip, pipx, pnpm, poetry, pre-commit -- prettier, rga, ripgrep, ruff, sd, semgrep, shellcheck, shfmt, sponge, terraform -- tfsec, trivy, vault, watchexec, xsv, yarn, yq - -## Tools with Dedicated Install Scripts - -### Runtime Environments -These have their own complex installers in `scripts/`: -- **go** - `install_go.sh` -- **rust** - `install_rust.sh` -- **python** - `install_python.sh` -- **node** - `install_node.sh` - -### Package Managers -Most now in catalog, one dedicated script: -- **uv** - `install_uv.sh` (special bootstrap installer) -- All others (pip, pipx, npm, pnpm, yarn, gem, composer, poetry, sponge) - Now in catalog! - -### Docker Tools -- **docker** - `install_docker.sh` (uses official Docker install script) -- **docker-compose** - Typically installed with Docker - -### System Tools -- **git** - System package (apt/dnf/brew) -- **ctags** - System package -- **sponge** - Part of moreutils package -- **prename** - System package (Perl rename) -- **rename.ul** - System package (util-linux rename) - -### Other -- **gam** - Google Apps Manager (special installation) -- **claude** - Claude CLI (special installation) -- **ansible-core** - Subset of ansible package -- **eslint** - Node.js package (installed via npm) - -## Installation Method Distribution - -- **github_release_binary**: 32 tools -- **uv_tool**: 8 tools (Python CLI tools) -- **package_manager**: 10 tools (pip, pipx, poetry, npm, pnpm, yarn, gem, composer, sponge, entr) -- **hashicorp_zip**: 2 tools (terraform, vault) -- **aws_installer**: 1 tool (aws) -- **npm_global**: 1 tool (prettier) -- **script**: 1 tool (parallel) -- **dedicated_script**: 10 tools (runtimes: go, rust, python, node; special: uv, docker, git, ctags, gam) -- **system_package**: 2 tools (cscope, rename variants) - -## Total: 72 tools tracked - -- **57 tools** have catalog entries -- **10 tools** use dedicated scripts (runtimes + special cases) -- **5 tools** are system packages only - -All installable tools either have catalog entries or use appropriate dedicated scripts. +This file documents which tools have catalog entries and how each one is installed. + +## Catalog entries (107) + +Counted from `catalog/*.json` on 2026-09-22. Every tool the audit tracks has a catalog entry; its `install_method` selects the installer (`scripts/installers/.sh`). `dedicated_script` runs the script named in the entry's `script` field. `auto` goes through the reconciliation system (`reconcile_tool` in `scripts/lib/reconcile.sh`). + +| install_method | Count | Tools | +|---|---|---| +| `github_release_binary` | 38 | ast-grep, curlie, dasel, direnv, dive, fx, fzf, gh, gh-aw, gh-aw-firewall, git-absorb, git-branchless, git-lfs, gitleaks, glab, golangci-lint, google-workspace-cli, gosec, herdr, jq, just, kubectl, mlr, ninja, opengrep, qsv, rga, sd, shellcheck, shfmt, symfony, tfsec, trivy, vhs, watchexec, xsv, yq, zellij | +| `dedicated_script` | 18 | blesh, byobu, claude, composer, docker, gem, go, node, parallel, pip, python, ruby, rust, tmux, tree, uv, wslu, yarn | +| `auto` | 13 | actionlint, bat, delta, difftastic, dust, fd, gup, hyperfine, jj, pnpm, ripgrep, scc, tokei | +| `uv_tool` | 12 | ansible-core, bandit, black, flake8, gam, git-filter-repo, httpie, isort, pre-commit, ruff, semgrep, trustmux | +| `package_manager` | 10 | bwrap, ctags, entr, git, php, pipx, poetry, prename, rename.ul, sponge | +| `npm_global` | 7 | bw, codex, eslint, gemini, jules, pi, prettier | +| `github_clone` | 2 | rbenv, ruby-build | +| `hashicorp_zip` | 2 | terraform, vault | +| `aws_installer` | 1 | aws | +| `docker_plugin` | 1 | compose | +| `gcloud_installer` | 1 | gcloud | +| `go_install` | 1 | templ | +| `npm_self_update` | 1 | npm | ## Bash completion coverage -Every catalog entry was audited for a bash-completion generator (sweep of -2026-07-22; each generator was executed and its output validated against -`complete -…` / `compgen ` / `COMPREPLY`, then checked to confirm it registers -the entry's own `binary_name`). +A sweep on 2026-07-22 (commit 9b6c053) audited the entries present in that +commit for a bash-completion generator: each generator was executed and its +output validated against `complete -…` / `compgen ` / `COMPREPLY`, then +checked to confirm it registers the entry's own `binary_name`. codex, pip and +pipx were not installed then; see the end of this section. herdr, jules, pi +and vault were checked when they were added. Five entries are not audited: +blesh, bw, bwrap, byobu, trustmux. -**40 entries declare `bash_completion`** — 39 `command`, 1 `source_path` (rbenv). +**42 entries declare `bash_completion`** — 41 `command`, 1 `source_path` (rbenv). Declared (`command`): ast-grep, bat, black, codex, composer, dasel, delta, dive, -docker, fd, fx, gh, git-absorb, git-lfs, gitleaks, glab, golangci-lint, gup, jj, -just, kubectl, mlr, node, npm, parallel, pip, pipx, pnpm, poetry, ripgrep, ruff, +docker, fd, fx, gh, git-absorb, git-lfs, gitleaks, glab, golangci-lint, gup, herdr, jj, +jules, just, kubectl, mlr, node, npm, parallel, pip, pipx, pnpm, poetry, ripgrep, ruff, scc, symfony, trivy, uv, vhs, watchexec, yq, zellij Declared (`source_path`): rbenv (`completions/rbenv.bash`, under its `clone_path`) @@ -99,7 +60,7 @@ The completion file is named after `binary_name`, so a script that registers a actionlint, ansible-core, aws, bandit, claude, ctags, curlie, difftastic, direnv, dust, entr, eslint, flake8, gam, gem, gemini, gh-aw-firewall, git, git-branchless, git-filter-repo, go, google-workspace-cli, gosec, httpie, -hyperfine, isort, jq, ninja, opengrep, php, pre-commit, prename, prettier, +hyperfine, isort, jq, ninja, opengrep, php, pi, pre-commit, prename, prettier, python, qsv, rename.ul, ruby, ruby-build, sd, semgrep, shellcheck, shfmt, sponge, templ, terraform, tfsec, tmux, tokei, tree, vault, wslu, xsv, yarn diff --git a/catalog/README.md b/catalog/README.md index 455c093..8dde570 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -71,7 +71,7 @@ Install tools via system package managers (apt, brew, dnf, pacman). 2. The tool will automatically be available via `make install-`, `make upgrade-`, `make uninstall-`, and `make reconcile-` 3. No need to create a custom install script! -Currently **103 tools** are cataloged. +Currently **107 tools** are cataloged. ## Environment Variables @@ -96,6 +96,6 @@ make upgrade ## Architecture -All 103 tools have catalog entries. The generic installer (`scripts/install_tool.sh`) reads a tool's catalog JSON and delegates to the appropriate method-specific installer under `scripts/installers/`. Tools with complex installation needs (python, node, docker, rust, etc.) use `install_method: "dedicated_script"` to route to their existing bespoke scripts. +All 107 tools have catalog entries. The generic installer (`scripts/install_tool.sh`) reads a tool's catalog JSON and delegates to the appropriate method-specific installer under `scripts/installers/`. Tools with complex installation needs (python, node, docker, rust, etc.) use `install_method: "dedicated_script"` to route to their existing bespoke scripts. See [ADR-007](../docs/adr/ADR-007-generic-tool-installation-architecture.md) for the full architectural decision record. diff --git a/catalog/jules.json b/catalog/jules.json new file mode 100644 index 0000000..d9360bc --- /dev/null +++ b/catalog/jules.json @@ -0,0 +1,22 @@ +{ + "name": "jules", + "category": "ai", + "install_method": "npm_global", + "description": "Google Jules CLI - terminal client for Jules, Google's asynchronous coding agent", + "homepage": "https://jules.google", + "package_name": "@google/jules", + "binary_name": "jules", + "requires": [ + "node" + ], + "version_command": "jules version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -1", + "guide": { + "display_name": "Google Jules CLI", + "install_action": "upgrade", + "order": 405 + }, + "notes": "Sessions run remotely (`jules remote`); `jules teleport` applies a session's changes locally. Sign in with `jules login`. Requires Node.js >= 18.", + "bash_completion": { + "command": "jules completion bash" + } +} diff --git a/catalog/pi.json b/catalog/pi.json new file mode 100644 index 0000000..c1d7668 --- /dev/null +++ b/catalog/pi.json @@ -0,0 +1,20 @@ +{ + "name": "pi", + "category": "ai", + "install_method": "npm_global", + "description": "pi - coding agent CLI with read, bash, edit and write tools and session management", + "homepage": "https://github.com/earendil-works/pi", + "github_repo": "earendil-works/pi", + "package_name": "@earendil-works/pi-coding-agent", + "binary_name": "pi", + "requires": [ + "node" + ], + "version_command": "pi --version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -1", + "guide": { + "display_name": "pi coding agent", + "install_action": "upgrade", + "order": 404 + }, + "notes": "Published as @earendil-works/pi-coding-agent; the earlier @mariozechner/pi-coding-agent package is deprecated. Requires Node.js >= 22.19." +} diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 1ed4bc3..e93e4ff 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -122,7 +122,7 @@ class ToolCatalog: ```python from cli_audit import ToolCatalog -# Load catalog (97 tools) +# Load catalog (107 tools) catalog = ToolCatalog() entries = catalog.load_all() @@ -137,7 +137,7 @@ if catalog.has("fzf"): fzf = catalog.get("fzf") # List all tools -all_tools = catalog.list_all() # Returns list of 97 tool names +all_tools = catalog.list_all() # Returns list of 107 tool names ``` #### `cli_audit.collectors` - Upstream Version Collection diff --git a/docs/CATALOG_GUIDE.md b/docs/CATALOG_GUIDE.md index aacfd2a..d7f4c39 100644 --- a/docs/CATALOG_GUIDE.md +++ b/docs/CATALOG_GUIDE.md @@ -12,7 +12,7 @@ The catalog system allows tool definitions to be managed as JSON files instead o - **Clear Separation**: Data (JSON) vs Logic (Python) **Location**: `catalog/` directory at project root -**Current Size**: 97 JSON tool definitions +**Current Size**: 107 JSON tool definitions ## Quick Start diff --git a/docs/INDEX.md b/docs/INDEX.md index 44feabe..19f2d7b 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -7,7 +7,7 @@ AI CLI Preparation is a specialized environment audit tool designed to ensure AI coding agents (like Claude Code) have access to all necessary developer tools. This documentation provides comprehensive technical details for developers, contributors, and integrators. -**Architecture:** Modular design with 21 specialized Python modules and 97 JSON tool catalog entries, evolved from a 3,387-line monolith to a maintainable, extensible system. +**Architecture:** Modular design with 21 specialized Python modules and 107 JSON tool catalog entries, evolved from a 3,387-line monolith to a maintainable, extensible system. **Project Status:** - **Phase 1 (Detection & Auditing):** ✅ Complete - Modular refactoring complete (v2.0.0) @@ -34,7 +34,7 @@ AI CLI Preparation is a specialized environment audit tool designed to ensure AI 2. **[CATALOG_GUIDE.md](CATALOG_GUIDE.md)** - JSON Catalog System ⭐ **NEW** - Tool definition schema and structure - - 97 JSON catalog entries + - 107 JSON catalog entries - Creating and managing catalog entries - ToolCatalog API and usage patterns - Community contribution workflows diff --git a/docs/TOOL_ECOSYSTEM.md b/docs/TOOL_ECOSYSTEM.md index 117ceeb..b180a5a 100644 --- a/docs/TOOL_ECOSYSTEM.md +++ b/docs/TOOL_ECOSYSTEM.md @@ -579,6 +579,22 @@ make audit-offline-data-core - **Install:** `npm install -g @google/gemini-cli` - **Upgrade:** `npm update -g @google/gemini-cli` +### jules +- **Purpose:** Google Jules CLI - terminal client for Jules, Google's asynchronous coding agent +- **Executable:** `jules` +- **Upstream:** npm (@google/jules) +- **Use Case:** Assign coding sessions that run remotely and apply their changes locally (`jules teleport`) +- **Install:** `npm install -g @google/jules` +- **Upgrade:** `npm update -g @google/jules` + +### pi +- **Purpose:** pi - coding agent CLI with read, bash, edit and write tools and session management +- **Executable:** `pi` +- **Upstream:** GitHub (earendil-works/pi), published on npm as @earendil-works/pi-coding-agent +- **Use Case:** Minimal terminal coding agent +- **Install:** `npm install -g @earendil-works/pi-coding-agent` +- **Upgrade:** `npm update -g @earendil-works/pi-coding-agent` + ### claude - **Purpose:** Anthropic Claude Code CLI - **Executable:** `claude` diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 63e9eb9..a98a8a9 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -123,7 +123,7 @@ make scripts-perms # Ensure all scripts are executable ./scripts/install_node.sh reconcile ``` -**Via Make (works for all 98 cataloged tools):** +**Via Make (works for all 107 cataloged tools):** ```bash make install-python # Install Python toolchain (dedicated script) make upgrade-python # Upgrade Python toolchain diff --git a/scripts/lib/reconcile.sh b/scripts/lib/reconcile.sh index d9427b2..7b61cda 100755 --- a/scripts/lib/reconcile.sh +++ b/scripts/lib/reconcile.sh @@ -65,8 +65,27 @@ remove_installation() { ;; npm) if command -v npm >/dev/null 2>&1; then - echo "[$tool] Uninstalling npm global package: $tool" >&2 - npm uninstall -g "$tool" 2>/dev/null || true + # The package name can differ from the tool name (pi is + # @earendil-works/pi-coding-agent, and an unrelated package `pi` + # exists), so read it from the bin symlink: node_modules//... + # One level only: `npm link` and folder installs make the package + # directory a symlink too, and readlink -f would resolve past it. + local npm_pkg="$tool" npm_path npm_target npm_rest + npm_path="${known_path:-$(command -v "$binary" 2>/dev/null || echo "")}" + npm_target="$(readlink "$npm_path" 2>/dev/null || echo "")" + case "$npm_target" in + */node_modules/*|node_modules/*) + # First node_modules = the global prefix; a later one would be + # a bundled dependency of the package + npm_rest="${npm_target#*node_modules/}" + case "$npm_rest" in + @*/*) npm_pkg="$(echo "$npm_rest" | cut -d/ -f1-2)" ;; + *) npm_pkg="${npm_rest%%/*}" ;; + esac + ;; + esac + echo "[$tool] Uninstalling npm global package: $npm_pkg" >&2 + npm uninstall -g "$npm_pkg" 2>/dev/null || true fi ;; nvm) diff --git a/tests/test_npm_uninstall.py b/tests/test_npm_uninstall.py new file mode 100644 index 0000000..b3f5de2 --- /dev/null +++ b/tests/test_npm_uninstall.py @@ -0,0 +1,113 @@ +"""Tests for the npm handler of remove_installation: the package name comes +from the binary's symlink into node_modules, not from the tool name.""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +skip_on_windows = pytest.mark.skipif(sys.platform == "win32", reason="Shell script tests require POSIX shell") + +SCRIPTS_DIR = Path(__file__).parent.parent / "scripts" + + +def _write_stub(stub_dir: Path, name: str, body: str) -> Path: + """Create an executable stub command in stub_dir.""" + stub = stub_dir / name + stub.write_text(f"#!/usr/bin/env bash\n{body}\n") + stub.chmod(0o755) + return stub + + +@skip_on_windows +class TestRemoveInstallationNpmPackageName: + """The npm handler must uninstall the package that owns the binary.""" + + def _remove(self, tmpdir: str, bin_target: str, tool: str, linked_pkg: str = "") -> str: + """Link /bin/ to bin_target and return the npm calls. + + linked_pkg: make node_modules/ a symlink to a source + folder, the layout of `npm link` and `npm install -g `. + """ + prefix = Path(tmpdir) / "prefix" + target = prefix / "lib" / bin_target + if linked_pkg: + pkg_dir = prefix / "lib" / "node_modules" / linked_pkg + source = Path(tmpdir) / "src" + (source / Path(bin_target).relative_to(Path("node_modules") / linked_pkg)).parent.mkdir(parents=True) + pkg_dir.parent.mkdir(parents=True, exist_ok=True) + pkg_dir.symlink_to(source) + else: + target.parent.mkdir(parents=True) + target.write_text("#!/usr/bin/env node\n") + (prefix / "bin").mkdir() + link = prefix / "bin" / tool + link.symlink_to(Path("..") / "lib" / bin_target) + + stub_dir = Path(tmpdir) / "stubs" + stub_dir.mkdir() + log_file = stub_dir / "calls.log" + log_file.touch() + _write_stub(stub_dir, "npm", f'echo "npm $*" >> "{log_file}"') + + result = subprocess.run( + [ + "bash", + "-c", + f""" +set -euo pipefail +source "{SCRIPTS_DIR}/lib/reconcile.sh" +export PATH="{stub_dir}:/usr/bin:/bin" +hash -r +remove_installation "{tool}" "npm" "{tool}" "{link}" +""", + ], + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr + return log_file.read_text() + + def test_scoped_package_is_read_from_the_bin_symlink(self): + # The layout of a real `npm install -g @earendil-works/pi-coding-agent`. + # A bare `npm uninstall -g pi` would target the unrelated package `pi`. + with tempfile.TemporaryDirectory() as tmpdir: + log = self._remove( + tmpdir, + "node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js", + "pi", + ) + assert log == "npm uninstall -g @earendil-works/pi-coding-agent\n" + + def test_unscoped_package_is_read_from_the_bin_symlink(self): + with tempfile.TemporaryDirectory() as tmpdir: + # `tsc` is the bin of the package `typescript` + log = self._remove(tmpdir, "node_modules/typescript/bin/tsc", "tsc") + assert log == "npm uninstall -g typescript\n" + + def test_linked_package_directory_is_not_resolved_past(self): + # `npm link` / `npm install -g `: the package directory is a + # symlink too, so a full resolution would leave node_modules behind + with tempfile.TemporaryDirectory() as tmpdir: + log = self._remove( + tmpdir, + "node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + "pi", + linked_pkg="@earendil-works/pi-coding-agent", + ) + assert log == "npm uninstall -g @earendil-works/pi-coding-agent\n" + + def test_nested_scoped_dependency_names_the_owning_package(self): + with tempfile.TemporaryDirectory() as tmpdir: + log = self._remove(tmpdir, "node_modules/owner/node_modules/@d/e/bin/x", "x") + assert log == "npm uninstall -g owner\n" + + def test_binary_outside_node_modules_falls_back_to_tool_name(self): + with tempfile.TemporaryDirectory() as tmpdir: + log = self._remove(tmpdir, "other/sometool", "sometool") + assert log == "npm uninstall -g sometool\n"