diff --git a/.changeset/101-managed-lifecycle.md b/.changeset/101-managed-lifecycle.md new file mode 100644 index 000000000..651dd4893 --- /dev/null +++ b/.changeset/101-managed-lifecycle.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Add `agent-bundle uninstall [--from ] [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]`, the package-relative installer bin's `uninstall `, and the emitted standalone `install.mjs --uninstall`: the receipt-owned reverse of `install`. Uninstall removes exactly the receipt's files and installer-created directories (Cursor local, including the `~/.cursor/plugins[/local]` directories the install created), the staged marketplace repository after its `HEAD` matches the recorded commit (Cursor `--mode marketplace`), or the recorded host registrations (`claude plugin uninstall --keep-data` + `claude plugin marketplace remove`, `codex plugin remove` + `codex plugin marketplace remove`, the marketplace retained while another installed plugin uses it — for Claude, including installs at another scope or in another project known only to Claude's `plugins/installed_plugins.json` registry) — and nothing else. Durable runtime state (`state/`, and the `PLUGIN_DATA` directory the receipt of a Cursor Agent Plugins copy records) is kept unless `--purge-data --confirm-purge` (`AB7008` without confirmation) with a typed per-host `data.outcome` (`kept`/`purged`/`absent`, Claude `retained-by-host`, Codex `removed-by-host`/`unavailable`); a missing receipt is `AB7009` and an owned-content, version, or `HEAD` mismatch is `AB7007` unless `--force`; foreign directories are refused regardless; `--plan` prints the exact paths and host verbs without changing anything; a rerun is a `not-installed` no-op. Install receipts move to format `agent-bundle-install-receipt/2` as the single lifecycle source of truth (mode, scope, registrations, created host directories, `updatedAt`), Claude/Codex/Cursor-marketplace installs write store receipts under `/agent-bundle/receipts/`, and format 1 receipts are read with synthesized fields and diagnosed (`AB7329`), never rejected. `agent-bundle doctor --from` reports the lifecycle stage per host (placed → registered → enabled → active, unobservable stages typed `unavailable`; `AB7330`), inventories store receipts and flags orphaned ones (`AB7328`), and explains a Cursor directory holding only preserved runtime state as `missing` (`AB7307`) instead of foreign. Host capability tables gain dated `lifecycle` rows and every adapter revision advances (#452) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 981d2f095..d7321400f 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -32,11 +32,11 @@ even when no error diagnostic was reported. | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | -| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below). | +| `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | | `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds. | -| `AB7300`–`AB7327` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), and a disabled Claude install (`AB7327`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | +| `AB7300`–`AB7330` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), and lifecycle receipts and activation states (`AB7328`–`AB7330`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route. | @@ -761,14 +761,55 @@ install receipt, `.agent-bundle-install.json`, beside the plugin manifest: "contentHash": "", "directories": [".cursor-plugin", "..."], "files": [".cursor-plugin/plugin.json", "INSTALL.md", "install.mjs", "..."], - "format": "agent-bundle-install-receipt/1", + "format": "agent-bundle-install-receipt/2", "host": "cursor", + "hostDirectories": ["plugins", "plugins/local"], "installedAt": "2026-09-03T08:00:00.000Z", + "mode": "local", "plugin": "", + "registrations": [{ "kind": "cursor-local-plugin" }], + "scope": "user", + "updatedAt": "2026-09-03T08:00:00.000Z", "version": "" } ``` +Format 2 (#101) adds the lifecycle fields that `agent-bundle uninstall` +consumes: `mode` (`local`, `marketplace`, or `host-cli`), `scope`, the +`registrations` the installer performed in order, the `hostDirectories` it +created under the host root on the way to the plugin root (pruned by uninstall +once empty; a directory the host made is never touched), and `updatedAt` +(`installedAt` stays the first install). Host-CLI installs (Claude, Codex) and +Cursor marketplace-mode staging cannot carry a receipt inside a host-owned or +committed tree, so theirs live in an Agent Bundle-owned store, +`/agent-bundle/receipts/...json` for +Claude and Codex and `.marketplace.json` for Cursor staging +(`~/.claude` or `$CLAUDE_CONFIG_DIR`, `~/.codex` or `$CODEX_HOME`, +`~/.cursor`), with `files: []` — they own no files, only the registrations and +the content hash. The host identifies a registration as `@`, +so the same plugin installed from two marketplaces is two installs with two +receipts. A Claude `project` / `local` scope registration belongs to +the working directory the host verbs ran in (the bundle root), so those +receipts are keyed `...<12-hex digest of projectRoot>.json` and +record `projectRoot`: two projects installing the same plugin at the same scope +are two receipts. The `-marketplace` registration is recorded only when +the install actually created it — `plugin marketplace list --json` did not list +the marketplace beforehand (or the receipted install it replaces recorded it); +a marketplace that already existed, or one whose state could not be read, +is not claimed, and `uninstall` then retains it and says why. Between +`plugin marketplace add` and the receipt write those registrations exist only +in memory, so if the plugin install or the receipt write fails the install +reverses what did complete — the plugin (`plugin uninstall … --keep-data` / +`plugin remove`) when it was installed, then the marketplace when this run +created it — before rethrowing; a failed reversal is reported with the exact +host commands to run before retrying. Nothing is left registered without a +receipt to record it. A format 1 +receipt (written by #420) is read with those +fields synthesized (`mode: local`, `scope: user`, one `cursor-local-plugin` +registration, no host directories) and reported as migrated (`AB7329`); an +identical rerun of the installer rewrites it as format 2. A current-format +receipt missing any field reads as absent, exactly like a malformed one. + The receipt never participates in the content hash, and neither do empty directories or runtime roots (`state/`): only regular files are plugin content, so the artifact hash, the installed tree, and the receipt always describe the @@ -819,6 +860,156 @@ refused (`AB7004`) before anything is staged. | `AB7005` | error | `install` refused an existing destination: a different installed version without `--replace`, a legacy pre-receipt copy with different content without `--replace`, or a foreign directory (refused even with `--replace`). | Re-run with `--replace` for the first two cases; remove a foreign directory manually. | | `AB7321` | warning | Doctor found a directory at the Cursor install path that is not an agent-bundle install of this plugin: no receipt naming it and no emitted install surface with a matching manifest, or a receipt naming another plugin. The message carries the installed-versus-artifact content-hash comparison. | Remove the foreign directory manually before installing; `--replace` refuses foreign installs by design. | +A Cursor destination that holds nothing but preserved runtime state (`state/`, +plus the remnant receipt described below) is what `uninstall --keep-data` +leaves behind: `install` fills it back in as an `installed` (not a +replacement, not a foreign refusal), and Doctor reports it as `missing` with an +`AB7307` info naming the preserved state instead of `AB7321` or `AB7304`. The +remnant receipt alone does not make a directory "state-only": when `uninstall` +also retained unowned entries beside (or instead of) `state/`, both the +inventory finding and the `--from` bundle finding read the directory and the +`AB7307` message names those retained entries and points at removing them by +hand, since `uninstall` never will. + +## Managed uninstall (`AB7007`–`AB7009`) + +`agent-bundle uninstall [--from ] [--scope ] +[--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] +[--force] [--plan] [--json]`, the package-relative installer bin's +`uninstall `, and the emitted `install.mjs --uninstall` are the +receipt-owned reverse of `install` (#101; the maintainer's 2026-09-01 G4 +deferral of mutation was reversed on 2026-09-03 with the request to fix every +open issue). Every mutation is opt-in and bounded by the receipt: + +- **Cursor local** — removes exactly the receipt's `files`, prunes its + `directories` and the plugin root once empty, then the `hostDirectories` the + install created (`~/.cursor/plugins/local`, `~/.cursor/plugins` in a fresh + home). Unowned entries are listed as retained and never removed — files by + path, and unowned directories that hold nothing retained as `name/` (the + prune only ever touches owned directories, so they survive too). When the + plugin root survives (retained state or unowned entries), a **remnant + receipt** — `files: []`, `registrations: []`, the carried `hostDirectories` — + is written there so a later purge can still prune the created directories and + Doctor can explain the directory. When the receipt records a Cursor + placeholder expansion (`cursorExpansion`, written by the emitted `install.mjs` + for an Agent Plugins pack), its `PLUGIN_DATA` directory + (`~/.cursor/agent-bundle/plugin-data/`) is receipt-owned durable state: + a written one is kept (the plugin root then survives with a remnant receipt + carrying the expansion, so a later `--purge-data --confirm-purge` still finds + it) or purged; an empty, installer-created one is pruned together with its + `agent-bundle/plugin-data` and `agent-bundle` parents once they empty out; a + recorded path outside this home's `plugin-data` is never touched and the + `data.detail` says so. +- **Cursor marketplace** — verifies the staged repository's `HEAD` against the + commit the store receipt recorded and its working tree against that commit + (`git --no-optional-locks status --porcelain --untracked-files=all + --ignored=matching`, the same probe Doctor uses: any uncommitted, untracked, + or ignored entry — or a tree that cannot be verified because git is missing + or `status` fails — is refused with `AB7007` until `--force`, since the + removal is recursive and those entries are not receipt-owned), then removes + the repository wholesale and the receipt; a copy Cursor imported into + `~/.cursor/plugins/cache` (recognised by the receipted commit and version, + not the version the bundle may have been rebuilt to) is Cursor-owned and is + reported `manual` with the Customize step in `nextSteps`. +- **Claude / Codex** — reads ` plugin list --json` (an unusable listing + fails closed, `AB7004`), compares the cached copy with the receipt, runs + `claude plugin uninstall --scope --keep-data` / + `codex plugin remove `, then `plugin marketplace remove ` + and removes the store receipt. Because `plugin marketplace remove` applies + to every scope, the marketplace is `retained` when the receipt does not + record Agent Bundle registering it (it pre-existed the install, or there is + no receipt), when another installed plugin still names it, when another + store receipt (another project's scoped install) installs from it — whether + that receipt records the marketplace registration or only its plugin, since + a plugin installed after the marketplace existed still needs it — when the + same plugin is installed at another Claude scope or in another project (live + row, Claude's cross-project `plugins/installed_plugins.json` registry — + which also records hand-made `project`/`local` installs elsewhere that have + no receipt and are invisible to `plugin list --json` run here — or stored + receipt), or when `plugin marketplace list --json`, the dependency re-read + of `plugin list --json`, that registry, or any receipt in the store + cannot be read (a failed read is not proof that nothing depends on it: an + unreadable receipt may be the very dependent or ownership heir, so the + dependency set is unknown and a purge of shared state is refused with + `AB7008` too). When the receipt being consumed is + the one recording that Agent Bundle registered the marketplace and a + dependent keeps the marketplace alive, that claim is not lost with the + receipt: it moves to a dependent's store receipt (the first not already + recording it, at that receipt's scope) so the last uninstall can still + remove the marketplace; when every dependent is a live row with no receipt + to carry it, the registration `detail` says the marketplace now counts as + user-owned. A registration the host no longer holds is `already-absent`, so + a receipt orphaned behind Agent Bundle's back is consumed without running + any host verb. + +Durable runtime state (`state/`: the state kernel and notices journal) is kept +by default; `--keep-data` says so explicitly. `--purge-data` removes it only +with `--confirm-purge`. The typed `data.outcome` is honest per host: `kept` / +`purged` / `absent` (Cursor local, Agent Bundle's own doing), +`retained-by-host` (Claude 2.1.257 orphans the cached copy, `state/` included, +for its ~14-day grace period; a purge additionally removes `state/` and +`plugins/data//`), `removed-by-host` (codex-cli 0.147.0 deletes the cached +tree on `plugin remove`), and `unavailable` (Codex has no keep-data option; a +staged Cursor marketplace holds no runtime state). `--plan` computes the same +report — exact absolute paths, registrations, data decision — without opening a +writer; planned directories are exactly the ones the run would prune (purged +`state/` first, then every owned directory that would be left empty, and for +store receipts the `/agent-bundle/receipts` and +`/agent-bundle` directories — plus Cursor's +`agent-bundle/marketplaces` — once the last entry leaves them), never a +directory kept alive by retained state or unowned entries: `removed` in a +`--plan` result equals `removed` in the completed one. A second run after a +successful uninstall is a `not-installed` no-op. When `--keep-data` left +`state/` behind under a Cursor local root, the remnant receipt written there +stays in place (`receipt.status: 'remnant'`) and a rerun without +`--purge-data` is the same `not-installed` no-op; `--purge-data +--confirm-purge` removes the preserved state and prunes the root, and consumes +the remnant (with the host directories it recorded) even when `state/` has +since been removed by hand. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB7007` | error | `uninstall` refused a mismatch or a foreign target: the owned files hash differently from the receipt, the cached host copy differs from the receipt in version or content, the staged repository's `HEAD` is not the recorded commit or its working tree is dirty / unverifiable, the receipt names another plugin, the directory is not this plugin's install at all, or a destination / `state/` entry is a symlink or special file. | `--force` overrides content and `HEAD` mismatches (the receipt-owned set is still the only thing removed); a receipt or manifest naming another plugin, and symlinked entries, are refused regardless — inspect and remove them manually. | +| `AB7008` | error | `--purge-data` without `--confirm-purge`, `--purge-data` together with `--keep-data`, or (Claude) `--purge-data` while the same plugin is installed at another scope or in another project (a live `plugin list --json` row, an entry in Claude's `plugins/installed_plugins.json` registry, or a stored receipt for the same plugin) — the cached copy and `plugins/data//` are scope-less and still in use — or while `claude plugin list --json` or that registry cannot be read to prove there is no other scope. | Pass `--purge-data --confirm-purge` to delete durable state, or neither flag to keep it; for a shared Claude scope, uninstall without `--purge-data` and purge after the last scope is removed. | +| `AB7009` | error | `uninstall` found the install but no receipt proving Agent Bundle owns it: a Cursor local copy in the pre-receipt legacy layout, a staged marketplace repository without its store receipt, or a host-registered Claude/Codex copy without its store receipt. | Re-run with `--force` (a legacy Cursor copy is removed by its inventory, `state/` kept; a host-CLI install is removed through the host verbs), or reinstall with `--replace` first to record a receipt. | + +The Cursor and portable host-install proofs (`tests/host-install-proof.test.ts`, +`tests/packed-host-install-proof.test.ts`) snapshot the isolated home before +install and after uninstall and require them byte-identical; the Claude and +Codex proofs require zero Agent Bundle residue and classify every remaining +host-owned entry (Claude: orphaned cache copy with `.orphaned_at`, empty +`installed_plugins.json` / `known_marketplaces.json`, `settings.json`, session +bookkeeping; Codex: an empty `config.toml` and empty cache directories). + +## Read-only Doctor lifecycle receipts and activation states (`AB7328`–`AB7330`) + +With `--from`, Doctor reports each host bundle's lifecycle as four typed +observations — **placed** (bytes at the host's install location), **registered** +(the host's registry names the plugin), **enabled** (the host reports it +enabled/trusted), **active** (loaded by a live host process) — each either +`observed` with the host evidence that made it true or false, or `unavailable` +with the reason no pinned read-only surface exposes it, and a `stage` (the +furthest observed-true stage; `absent` when placement is observed false, +`unknown` when it is unobservable). Doctor never guesses an activation state. + +| Host | placed | registered | enabled | active | +| --- | --- | --- | --- | --- | +| Claude 2.1.257 | cache path from `claude plugin list --json` exists | row present (scope noted) | row `enabled` flag | unavailable: no read-only verb reports what a live session loaded | +| Codex 0.147.0 | pinned cache path exists | row present in `installed` | row `enabled` flag | unavailable: no read-only verb; plugin hooks additionally stay untrusted until the user trusts them in the hook browser (no trust verb) | +| Cursor (local) | `~/.cursor/plugins/local/` exists | same as placed (the directory is the registration; loads at window reload) | unavailable: enabled state is server-assigned in `state.vscdb`, gated by `thirdPartyExtensibilityEnabled` / `enable_cc_plugin_import` (2026-09-03 audit, 3.18.25) | unavailable: no non-interactive plugin-loading surface | +| Cursor (marketplace) | staged repository exists | completed copy from this staging under `~/.cursor/plugins/cache` | unavailable (as above) | unavailable | + +Doctor also inventories the Agent Bundle receipt store under each host root +(`hosts[].receipts`) and cross-checks every receipt against the host, and +reports the in-tree receipt of every Cursor local copy (`receipt` on the +inventory finding and on the bundle finding). + +| Code | Severity | Meaning | Recovery | +| --- | --- | --- | --- | +| `AB7328` | warning | A store receipt is orphaned — the host no longer holds the registration it records (Claude/Codex listing lacks the plugin — a Claude `project`/`local` receipt is checked by `plugin list --json` run from its recorded `projectRoot`, and is `unknown`, never orphaned, when that root cannot be listed; the staged Cursor marketplace repository is gone) — or the receipt store / a receipt file could not be read or is not a valid receipt. | `agent-bundle uninstall --from [--mode marketplace]` consumes an orphaned receipt; reinstall to rewrite an invalid one; repair permissions. | +| `AB7329` | info | A receipt predates lifecycle receipts (`agent-bundle-install-receipt/1`) and was read with synthesized `mode`, `scope`, `registrations`, and `hostDirectories`. Doctor never rewrites it. | Rerun `agent-bundle install` (or `install.mjs`) once; an identical copy rewrites the receipt as format 2 without changing plugin files. `uninstall` accepts the migrated receipt as is. | +| `AB7330` | info | The bundle's lifecycle stage on this host and its four observations; the message lists every `unavailable` stage with its reason. When Claude lists the plugin at several scopes the observations aggregate every row — a stage holds only when it holds for every listed copy, and the evidence names the scopes that are disabled, unplaced, or carry no enabled flag — so the report never depends on Claude's row order. | Stage-specific: register (`agent-bundle install`), enable (`claude plugin enable`, Codex `/plugins`, Cursor Customize), or complete the Cursor import; unavailable stages need no action and are never guessed. | + ## Live development into hosts (`AB7200`–`AB7202`, `AB7210`–`AB7211`, `AB8024`–`AB8025`) `agent-bundle dev` keeps a host's one stdio MCP process connected while it diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 39e2fdf21..0e68d90c3 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -547,6 +547,45 @@ comparison. It never invokes sudo or changes PATH. `agent-bundle install "Reinstall after a same-version rebuild"). Artifact validation rejects a built-in target whose required install surface is missing. +### Managed uninstall and lifecycle receipts (#101) + +Receipts are the single source of truth for an install's lifecycle. Every +`install` writes one (format `agent-bundle-install-receipt/2`): version, content +hash, delivery `mode` (`local`, `marketplace`, `host-cli`), `scope`, the owned +`files` and `directories`, the `hostDirectories` the installer created under the +host root, the host `registrations` it performed in order, and `installedAt` / +`updatedAt`. Cursor local copies carry it in-tree; Claude, Codex, and Cursor +marketplace-mode installs keep theirs in `/agent-bundle/receipts/`. +`install --replace`, `uninstall`, and `doctor` all consume the same document; +a receipt written before #101 is read with its lifecycle fields synthesized and +diagnosed (`AB7329`), never rejected. + +```sh +agent-bundle uninstall claude --from artifact/claude --plan # exact paths and host verbs, no writer +agent-bundle uninstall claude --from artifact/claude # claude plugin uninstall --keep-data, marketplace remove, receipt +agent-bundle uninstall cursor --from artifact/cursor # receipt-owned files, directories, remnant state kept +agent-bundle uninstall cursor --from artifact/cursor --purge-data --confirm-purge +node artifact/cursor/install.mjs --uninstall [--plan] [--mode marketplace] +``` + +Uninstall removes exactly what the receipt owns and reverses exactly the +registrations it recorded; anything else stays and is listed as retained. +Durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge`; +the typed `data.outcome` says what the host itself decided where Agent Bundle +cannot (`retained-by-host` for Claude's ~14-day orphaned copy, +`removed-by-host` / `unavailable` for Codex, which has no keep-data option). A +missing receipt or an owned-content mismatch is refused (`AB7009`, `AB7007`) +unless `--force`; a receipt or manifest naming another plugin is refused +regardless; `--purge-data` without `--confirm-purge` is `AB7008`; a second run is +a `not-installed` no-op. `doctor --from` adds the lifecycle stage per host — +placed → registered → enabled → active, each observed or typed `unavailable` +(`AB7330`) — and cross-checks every store receipt against the host (`AB7328`). +The host-install proofs snapshot an isolated home before install and after +uninstall: byte-identical for Cursor and portable, zero Agent Bundle residue plus +classified host-owned bookkeeping for Claude and Codex. Details: +`docs/diagnostics.md`, "Managed uninstall" and "Read-only Doctor lifecycle +receipts and activation states". + ### Cursor delivery modes and hook registration (#407) `agent-bundle install cursor` and the emitted `install.mjs` accept diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 61f8fc9fb..8dce50277 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -118,7 +118,8 @@ manifests at files inside those payloads without compiling them. Payload files c | `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. | | `agent-bundle prepack` | Run the release build, dry-run npm packing without scripts, and verify packaged outputs, artifact hashes, bins, and versions (`--output` and `--json` supported). | | `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, `--replace`/`--force`, `--mode local\|marketplace` for Cursor, and `--json` supported). Same-version content drift of an agent-bundle-managed install is replaced automatically; identical reruns are a no-op. | -| `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`). | +| `agent-bundle uninstall ` | Remove a receipt-owned install and nothing else: the receipt's files and directories, the host registrations it recorded (`claude plugin uninstall --keep-data` + `marketplace remove`, `codex plugin remove` + `marketplace remove`, the Cursor local directory or staged marketplace). `--plan` prints the exact paths without changing anything; durable `state/` is kept unless `--purge-data --confirm-purge`; a missing receipt or content mismatch is refused unless `--force`; a rerun is `not-installed`. | +| `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect normalized targets and adapter plans from source, with per-target component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. | | `agent-bundle inspect --bundler` | Dump the synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) for every generated output. | @@ -270,10 +271,66 @@ When package outputs ship one of those host packs, the build also emits a package-relative installer bin. It uses the plugin name when no configured bin claims it and `-install` otherwise. Map that name to the generated `dist/bin/*.js` file in `package.json`; consumers run -` install [--scope ] [--replace|--force] [--json]`. The executable locates the -artifact directory beside the installed package, so it works from -`node_modules` regardless of the current directory. No npm lifecycle performs -an installation. +` install [--scope ] [--replace|--force] [--json]` and +` uninstall [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]`. +The executable locates the artifact directory beside the installed package, so +it works from `node_modules` regardless of the current directory. No npm +lifecycle performs an installation. + +### Uninstall by receipt + +Receipts (`agent-bundle-install-receipt/2`) are the single source of truth for +an install's lifecycle: version, content hash, delivery mode, scope, owned files +and directories, the host directories the installer created, the host +registrations it performed in order, and install/update timestamps. Cursor +local copies carry the receipt in-tree; Claude, Codex, and Cursor +marketplace-mode installs keep theirs under `/agent-bundle/receipts/` +(`~/.claude`, `~/.codex`, `~/.cursor`, honouring `CLAUDE_CONFIG_DIR` and +`CODEX_HOME`). `agent-bundle uninstall --from `, the package +bin's `uninstall `, and the emitted `install.mjs --uninstall` consume that +receipt and remove exactly what it owns: + +- Cursor local: the receipt's files, its installer-created directories and the + plugin root once empty, then the `~/.cursor/plugins[/local]` directories the + install itself created. Unowned entries are retained and listed; when the + root survives, a remnant receipt (owning no files) keeps the created host + directories accountable for a later purge and lets Doctor explain the + directory. Reinstalling around preserved state is an `installed`, not a + foreign refusal. +- Cursor marketplace: the staged repository after its `HEAD` matches the + recorded commit, plus the receipt; a copy Cursor imported is Cursor-owned and + reported `manual` with the Customize step. +- Claude / Codex: `claude plugin uninstall --scope --keep-data` / + `codex plugin remove `, then `plugin marketplace remove ` + unless another installed plugin still uses it (live row, another store + receipt, or — Claude — an install at another scope or in another project + recorded in `plugins/installed_plugins.json`), then the store receipt. An + unusable `plugin list --json` fails closed (`AB7004`); a registration the + host no longer holds is `already-absent`, so an orphaned receipt is consumed + without running a host verb. + +Durable runtime state (`state/`: state kernel, notices journal; for a Cursor +copy of an Agent Plugins pack, also the `PLUGIN_DATA` directory the receipt +records) is kept by default; `--purge-data --confirm-purge` removes it +(`AB7008` without the confirmation). The typed `data.outcome` is honest per host: Cursor `kept` / +`purged` / `absent`; Claude `retained-by-host` (Claude 2.1.257 orphans the +cached copy for its ~14-day grace period; a purge also removes `state/` and +`plugins/data//`); Codex `removed-by-host` / `unavailable` (codex-cli +0.147.0 deletes the cached tree on `plugin remove` and has no keep-data option). +`--plan` reports the same exact paths and host verbs without opening a writer. +A missing receipt (`AB7009`) or an owned-content, version, or `HEAD` mismatch +(`AB7007`) is refused unless `--force`; a receipt or manifest naming another +plugin is refused regardless; a second run is a `not-installed` no-op. + +`agent-bundle doctor` inventories the receipt store per host and flags receipts +the host no longer honours (`AB7328`), reports receipts that predate format 2 as +migrated (`AB7329`; an identical `install` rerun rewrites them), and with +`--from` reports the lifecycle stage per host (`AB7330`): placed → registered → +enabled → active, each observed from `plugin list --json` (Claude/Codex +`enabled` flags), the Cursor local directory, or the Cursor marketplace import +cache, or typed `unavailable` with the reason (no host exposes what a live +session loaded; Cursor's enabled state is server-assigned and gated by +`enable_cc_plugin_import`). ## Developer workbench diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index fb671258e..5c77ce958 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -12,6 +12,40 @@ "source": "https://code.claude.com/docs/en/discover-plugins", "state": "supported" }, + "lifecycle": { + "uninstall": { + "commands": [ + "claude plugin uninstall @ --scope --keep-data", + "claude plugin marketplace remove " + ], + "evidence": [ + "2026-09-03: local Claude Code 2.1.257 `claude plugin uninstall --help` documents --keep-data (preserve ~/.claude/plugins/data/{id}/), --scope user|project|local, and --prune/-y; `claude plugin marketplace remove --help` documents removal from every scope by default. `agent-bundle uninstall claude` runs exactly those two verbs, in that order, from the registrations its install receipt recorded (#101).", + "2026-09-03: in an isolated CLAUDE_CONFIG_DIR, `claude plugin uninstall --scope user --keep-data` marked the cached copy orphaned (`.orphaned_at`) instead of deleting it and left `plugins/installed_plugins.json` as `{\"plugins\":{}}`, `plugins/known_marketplaces.json` as `{}`, and `settings.json` with empty `enabledPlugins`/`extraKnownMarketplaces`; `claude plugin marketplace remove` then removed the marketplace declaration. Removing a marketplace or plugin that is not registered exits 1 (`not found`), so the uninstaller consults `claude plugin list --json` and `claude plugin marketplace list --json` first and reports already-absent registrations instead of failing." + ], + "state": "supported" + }, + "keepData": { + "evidence": [ + "2026-09-03: Claude Code 2.1.257 retains the orphaned cache copy, its `state/` included, for the documented ~14-day grace period, and --keep-data preserves ~/.claude/plugins/data/{id}/; the uninstaller reports this as data outcome `retained-by-host` and, under --purge-data --confirm-purge, removes `state/` under the cached copy and the plugins/data entry itself (`purged`)." + ], + "state": "supported" + }, + "activationState": { + "evidence": [ + "2026-09-03: `claude plugin list --json` rows carry `enabled` (observed true after `claude plugin install`), `scope`, `installPath`, and `version`; Doctor reads placed (installPath exists), registered (row present), and enabled (row flag) from them. No read-only verb reports which plugins a live session has loaded, so `active` is typed unavailable." + ], + "observable": [ + "placed", + "registered", + "enabled" + ], + "reason": "Live activation has no pinned read-only surface on Claude Code 2.1.257.", + "state": "degraded", + "unavailable": [ + "active" + ] + } + }, "deferredNativeEvents": { "ConfigChange-policy_settings": { "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: ConfigChange block decisions cannot stop policy_settings changes; recorded so the config/change family's deny channel is not overstated.", diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index d2aedea75..7cea0d03d 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -12,6 +12,40 @@ "source": "https://developers.openai.com/codex/cli/reference", "state": "supported" }, + "lifecycle": { + "uninstall": { + "commands": [ + "codex plugin remove @", + "codex plugin marketplace remove " + ], + "evidence": [ + "2026-09-03: live codex-cli 0.147.0 in an isolated CODEX_HOME: `codex plugin remove --json` printed pluginId/name/marketplaceName and deleted the cached tree under plugins/cache; `codex plugin marketplace remove --json` printed marketplaceName and installedRoot null; config.toml was left empty (0 bytes) with empty `.tmp/marketplaces` and `plugins/cache/` directories. `codex plugin remove` of an unknown plugin exits 0, `marketplace remove` of an unknown marketplace exits 1, so the uninstaller consults `codex plugin list --json` and `codex plugin marketplace list --json` first (#101)." + ], + "state": "supported" + }, + "keepData": { + "evidence": [ + "2026-09-03: `codex plugin remove` deletes the cached plugin tree, `state/` included, and codex-cli 0.147.0 documents no keep-data option; the uninstaller reports data outcome `unavailable` for --keep-data and `removed-by-host` for --purge-data instead of pretending to preserve state." + ], + "reason": "Codex owns the cached tree and removes it wholesale; durable state cannot be preserved through a Codex uninstall.", + "state": "unavailable" + }, + "activationState": { + "evidence": [ + "2026-09-03: `codex plugin list --json` installed rows carry `installed`, `enabled`, `version`, and the pinned cache layout supplies the path; Doctor reads placed, registered, and enabled from them. No read-only verb reports what a live session loaded, and plugin hooks stay untrusted until the user reviews them in the hook browser with no read-only trust verb, so `active` is typed unavailable." + ], + "observable": [ + "placed", + "registered", + "enabled" + ], + "reason": "Live activation and hook trust have no pinned read-only surface on codex-cli 0.147.0.", + "state": "degraded", + "unavailable": [ + "active" + ] + } + }, "marketplace": { "policy": { "authentication": ["ON_INSTALL", "ON_USE"], diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index 06d35e7bf..2ed635215 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -15,6 +15,36 @@ "source": "https://cursor.com/docs/plugins", "state": "supported" }, + "lifecycle": { + "uninstall": { + "evidence": [ + "2026-09-03: Cursor exposes no non-interactive plugin uninstall verb; a local plugin is a directory under ~/.cursor/plugins/local that Cursor loads at window reload, so removing the receipt-owned files, the installer-created directories, and the plugins[/local] directories the install itself created unregisters it (`agent-bundle uninstall cursor`, `install.mjs --uninstall`; #101). A staged marketplace repository under ~/.cursor/agent-bundle/marketplaces is removed wholesale after its HEAD matches the receipt's commit; a copy Cursor imported into plugins/cache is Cursor-owned (server-assigned installed ids, 2026-09-03 audit) and is reported `manual` with the Customize step." + ], + "state": "degraded", + "reason": "Local-mode removal is complete and byte-identical; marketplace-mode imports can only be reversed in Cursor's Customize UI." + }, + "keepData": { + "evidence": [ + "2026-09-03: the uninstaller keeps `state/` under the plugin root by default (data outcome `kept`), writes a remnant receipt owning no files so the created host directories stay accountable, and removes `state/` only under --purge-data --confirm-purge (`purged`); a staged marketplace repository holds no runtime state (`unavailable`)." + ], + "state": "supported" + }, + "activationState": { + "evidence": [ + "2026-09-03: placement (the plugins/local directory or the staged repository) and registration (the directory itself; a completed `.cache-complete` copy under plugins/cache for marketplace mode) are observable read-only. Enabled state lives in state.vscdb as server-assigned ids and plugin import/hooks are gated by thirdPartyExtensibilityEnabled / enable_cc_plugin_import (observed 3.18.25, docs/audits/2026-09-03-cursor-plugin-hooks-registration.md); Cursor exposes no non-interactive plugin-loading surface. Doctor types `enabled` and `active` unavailable." + ], + "observable": [ + "placed", + "registered" + ], + "reason": "Enabled and live-activation state are not exposed read-only by Cursor.", + "state": "degraded", + "unavailable": [ + "enabled", + "active" + ] + } + }, "deferredNativeEvents": { "afterAgentResponse": { "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native/deferred until a canonical consumer is accepted (#258 defer list). Input is { text } with no output fields; matcher value AgentResponse; runs in cloud agents.", diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index 5246f2fcc..2b08a49cd 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -9,6 +9,36 @@ "source": "https://agent-plugins.org/", "state": "unavailable" }, + "lifecycle": { + "uninstall": { + "evidence": [ + "2026-09-03: the emitted install.mjs --uninstall removes the receipt-owned files and installer-created directories from ~/.cursor/plugins/local/, leaving an isolated Cursor home byte-identical to its pre-install snapshot (tests/host-install-proof.test.ts); other Agent Plugins clients own their own install locations and are uninstalled through the client (#101)." + ], + "state": "degraded", + "reason": "Only the Cursor local copy the bundled installer places is receipt-owned; native clients other than Cursor manage their own copies." + }, + "keepData": { + "evidence": [ + "2026-09-03: `state/` under the Cursor local copy is kept by default and removed only under --purge-data --confirm-purge, exactly as for the Cursor target." + ], + "state": "supported" + }, + "activationState": { + "evidence": [ + "2026-09-03: placement and directory registration are observable for the Cursor local copy; Cursor exposes no read-only enabled or live-activation surface, and the Agent Plugins 1.0.0 standard defines no activation registry." + ], + "observable": [ + "placed", + "registered" + ], + "reason": "The standard defines no activation state; Cursor exposes none read-only.", + "state": "degraded", + "unavailable": [ + "enabled", + "active" + ] + } + }, "eventRoutes": { "agent/idle": { "reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native TeammateIdle equivalent (https://agent-plugins.org/).", diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 7af391281..c2cf2d3c7 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -431,7 +431,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.26.0', + adapterRevision: '1.27.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 912fc0369..8196be140 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -178,7 +178,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.12.0', + adapterRevision: '1.13.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index abf3a62c4..7b3c5ffb0 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -465,7 +465,7 @@ export const cursorManifest = ( }); const metadata = Object.freeze({ - adapterRevision: '1.12.0', + adapterRevision: '1.13.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index 0dc42a85c..d6ea114d5 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -94,7 +94,7 @@ const schemaValidator = createAdapterValidator(); const validatePlugin = schemaValidator.compile(pluginSchema); const validateMcp = schemaValidator.compile(mcpSchema); const metadata = Object.freeze({ - adapterRevision: '1.9.0', + adapterRevision: '1.10.0', observedVersion: capabilityTable.observedSpecificationVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version), }); diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index c2e95950f..f774d557c 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -32,13 +32,15 @@ import type { DoctorDurableStateReport, DoctorHost, DoctorInstallComparison, + DoctorLifecycle, DoctorReport, runDoctor, } from './install/doctor.ts'; +import type { uninstallBundle, UninstallResult } from './install/uninstall.ts'; import type { runHostMcpProxy } from './dev/host-mcp-proxy.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; import { errorMessage } from './core/errors.ts'; -import { formatInstallResult } from './install/format.ts'; +import { formatInstallResult, formatUninstallResult } from './install/format.ts'; import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; import type { EvalComparisonDelta, EvalConditionMetrics } from './eval/compare.ts'; @@ -69,6 +71,7 @@ export interface CliDependencies { /** Injectable only to make foreground shutdown behavior deterministic in tests. */ readonly signals?: CliSignalSource; readonly startDevServer?: typeof startDevServer; + readonly uninstallBundle?: typeof uninstallBundle; /** Injectable only to verify host-validation CLI policy without an installed host. */ readonly validate?: typeof validate; } @@ -96,6 +99,18 @@ interface InstallCommandOptions { readonly scope: string; } +interface UninstallCommandOptions { + readonly confirmPurge?: boolean; + readonly force?: boolean; + readonly from: string; + readonly json?: boolean; + readonly keepData?: boolean; + readonly mode?: InstallMode; + readonly plan?: boolean; + readonly purgeData?: boolean; + readonly scope: string; +} + interface DoctorCommandOptions { readonly from?: string; readonly host: readonly DoctorHost[]; @@ -306,6 +321,20 @@ const writeHumanInstall = (output: Output, result: InstallResult): void => { output.write(formatInstallResult(result)); }; +const writeHumanUninstall = (output: Output, result: UninstallResult): void => { + output.write(formatUninstallResult(result)); +}; + +const describeLifecycle = (lifecycle: DoctorLifecycle): string => { + const observations = (['placed', 'registered', 'enabled', 'active'] as const).map((stage) => { + const observation = lifecycle[stage]; + return observation.status === 'observed' + ? `${stage}=${observation.value ? 'yes' : 'no'}` + : `${stage}=unavailable`; + }); + return `${lifecycle.stage} (${observations.join(', ')})`; +}; + const describeInstallComparison = (comparison: DoctorInstallComparison): string => { const installed = (comparison.installedContentHash === undefined ? '' @@ -365,6 +394,15 @@ const writeHumanDoctor = (output: Output, result: DoctorReport): void => { `${validation.scope === undefined ? '' : `, scope ${validation.scope}`}): ${validation.status}\n`, ); } + if (host.bundle.lifecycle !== undefined) { + output.write(` lifecycle: ${describeLifecycle(host.bundle.lifecycle)}\n`); + } + } + if (host.receipts.length > 0) { + output.write(` receipts: ${host.receipts.length} store receipt(s)\n`); + for (const receipt of host.receipts) { + output.write(` ${receipt.plugin}@${receipt.version} (${receipt.mode}, ${receipt.scope}): ${receipt.state}\n`); + } } const reports = [ ...host.inventory.findings.map((finding) => finding.durableState), @@ -690,6 +728,42 @@ export const runCli = async ( else writeHumanInstall(stdout, result); }); + const uninstallCommand = program.command('uninstall') + .description('Remove a receipt-owned host install of a built bundle, and nothing else') + .argument('', 'Host to uninstall from: claude, codex, or cursor', installHost) + .option('--from ', 'Target bundle directory or artifact root that identifies the plugin', process.cwd()) + .option('--scope ', 'Host install scope', installScope, 'user') + .option('--mode ', 'Cursor delivery mode to uninstall: local (default) or marketplace', installMode) + .option('--keep-data', 'Keep the plugin\'s durable runtime state (state/) in place; this is the default') + .option('--purge-data', 'Also remove the plugin\'s durable runtime state; requires --confirm-purge') + .option('--confirm-purge', 'Confirm that --purge-data may delete durable state') + .option( + '--force', + 'Proceed without an install receipt (legacy or host-only install) or when owned content no longer matches the receipt; ' + + 'foreign directories are still refused', + ) + .option('--plan', 'Print the exact paths and host registrations that would be removed without changing anything') + .option('--json', 'Write one machine-readable JSON document'); + uninstallCommand.action(async ( + host: InstallHost, + options: UninstallCommandOptions, + ) => { + const uninstall = dependencies.uninstallBundle ?? (await import('./install/uninstall.ts')).uninstallBundle; + const result = await uninstall({ + ...(options.confirmPurge === undefined ? {} : { confirmPurge: options.confirmPurge }), + ...(options.force === undefined ? {} : { force: options.force }), + from: options.from, + host, + ...(options.keepData === undefined ? {} : { keepData: options.keepData }), + ...(options.mode === undefined ? {} : { mode: options.mode }), + ...(options.plan === undefined ? {} : { plan: options.plan }), + ...(options.purgeData === undefined ? {} : { purgeData: options.purgeData }), + scope: installScope(options.scope), + }); + if (options.json === true) writeMachine(stdout, result); + else writeHumanUninstall(stdout, result); + }); + const doctorCommand = program.command('doctor') .description('Inspect host installs and runtime endpoints without changing them') .option('--host ', 'Host to inspect (repeatable)', collectDoctorHost, []) diff --git a/packages/agent-bundle/src/install-entry.ts b/packages/agent-bundle/src/install-entry.ts index b8f3350ed..759db515d 100644 --- a/packages/agent-bundle/src/install-entry.ts +++ b/packages/agent-bundle/src/install-entry.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { stableJson } from './core/digest.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; -import { formatInstallResult } from './install/format.ts'; +import { formatInstallResult, formatUninstallResult } from './install/format.ts'; import { installBundle, type InstallHost, @@ -11,6 +11,7 @@ import { type InstallResult, type InstallScope, } from './install/install.ts'; +import { uninstallBundle, type UninstallResult } from './install/uninstall.ts'; export interface GeneratedInstallProcessOptions { readonly artifactRoot: string | URL; @@ -20,6 +21,7 @@ export interface GeneratedInstallProcessOptions { const usage = (options: GeneratedInstallProcessOptions): string => [ `Usage: ${options.name} install [--scope ] [--mode local|marketplace] [--replace|--force] [--json]`, + ` ${options.name} uninstall [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]`, '', `Built hosts: ${options.hosts.join(', ')}`, '', @@ -27,6 +29,11 @@ const usage = (options: GeneratedInstallProcessOptions): string => [ 'its version differs. Same-version content drift is replaced automatically; foreign installs', 'are always refused.', '', + 'uninstall removes exactly what the install receipt owns (files, directories, host registrations)', + 'and keeps durable runtime state unless --purge-data --confirm-purge is passed. It refuses a', + 'missing receipt or an owned-content mismatch unless --force; foreign directories are always', + 'refused. --plan prints the exact paths without changing anything.', + '', ].join('\n'); const diagnosticsFor = (error: unknown): readonly Diagnostic[] => @@ -38,10 +45,6 @@ const diagnosticsFor = (error: unknown): readonly Diagnostic[] => severity: 'error' as const, })]); -const writeHuman = (result: InstallResult): void => { - process.stdout.write(formatInstallResult(result)); -}; - const isHost = (value: string): value is InstallHost => value === 'claude' || value === 'codex' || value === 'cursor'; @@ -51,29 +54,43 @@ const isScope = (value: string): value is InstallScope => const isMode = (value: string): value is InstallMode => value === 'local' || value === 'marketplace'; +type InstallerVerb = 'install' | 'uninstall'; + interface ParsedInstallArguments { + readonly confirmPurge: boolean; + readonly force: boolean; readonly host: InstallHost; readonly json: boolean; - readonly replace: boolean; + readonly keepData: boolean; readonly mode?: InstallMode; + readonly plan: boolean; + readonly purgeData: boolean; + readonly replace: boolean; readonly scope: InstallScope; + readonly verb: InstallerVerb; } const parseArguments = ( argv: readonly string[], options: GeneratedInstallProcessOptions, ): ParsedInstallArguments => { - if (argv[0] !== 'install') { - throw new TypeError(`Expected "install "; built hosts: ${options.hosts.join(', ')}.`); + const verb = argv[0]; + if (verb !== 'install' && verb !== 'uninstall') { + throw new TypeError(`Expected "install " or "uninstall "; built hosts: ${options.hosts.join(', ')}.`); } const candidate = argv[1]; if (candidate === undefined || !isHost(candidate) || !options.hosts.includes(candidate)) { throw new TypeError( - `Cannot install host ${JSON.stringify(candidate ?? '')}; built hosts: ${options.hosts.join(', ')}.`, + `Cannot ${verb} host ${JSON.stringify(candidate ?? '')}; built hosts: ${options.hosts.join(', ')}.`, ); } let json = false; let replace = false; + let force = false; + let keepData = false; + let purgeData = false; + let confirmPurge = false; + let plan = false; let scope: InstallScope = 'user'; let mode: InstallMode | undefined; for (let index = 2; index < argv.length; index += 1) { @@ -82,10 +99,32 @@ const parseArguments = ( json = true; continue; } - if (argument === '--replace' || argument === '--force') { + if (argument === '--replace' && verb === 'install') { replace = true; continue; } + if (argument === '--force') { + // `install --force` is the --replace alias; `uninstall --force` overrides a missing or mismatched receipt. + replace = true; + force = true; + continue; + } + if (argument === '--keep-data' && verb === 'uninstall') { + keepData = true; + continue; + } + if (argument === '--purge-data' && verb === 'uninstall') { + purgeData = true; + continue; + } + if (argument === '--confirm-purge' && verb === 'uninstall') { + confirmPurge = true; + continue; + } + if (argument === '--plan' && verb === 'uninstall') { + plan = true; + continue; + } if (argument === '--mode') { const value = argv[index + 1]; if (value === undefined || !isMode(value)) { @@ -106,7 +145,23 @@ const parseArguments = ( } throw new TypeError(`Unknown installer argument ${JSON.stringify(argument)}.`); } - return Object.freeze({ host: candidate, json, ...(mode === undefined ? {} : { mode }), replace, scope }); + return Object.freeze({ + confirmPurge, + force, + host: candidate, + json, + keepData, + ...(mode === undefined ? {} : { mode }), + plan, + purgeData, + replace, + scope, + verb, + }); +}; + +const writeResult = (parsed: ParsedInstallArguments, result: InstallResult | UninstallResult, human: string): void => { + process.stdout.write(parsed.json ? `${stableJson(result)}\n` : human); }; export const runGeneratedInstallProcess = async ( @@ -130,15 +185,38 @@ export const runGeneratedInstallProcess = async ( 'the package must ship its generated artifact directory.', ); } - const result = await installBundle({ - from: artifactRoot, - host: parsed.host, - replace: parsed.replace, - ...(parsed.mode === undefined ? {} : { mode: parsed.mode }), - scope: parsed.scope, - }); - if (parsed.json) process.stdout.write(`${stableJson(result)}\n`); - else writeHuman(result); + switch (parsed.verb) { + case 'install': { + const result = await installBundle({ + from: artifactRoot, + host: parsed.host, + replace: parsed.replace, + ...(parsed.mode === undefined ? {} : { mode: parsed.mode }), + scope: parsed.scope, + }); + writeResult(parsed, result, formatInstallResult(result)); + break; + } + case 'uninstall': { + const result = await uninstallBundle({ + confirmPurge: parsed.confirmPurge, + force: parsed.force, + from: artifactRoot, + host: parsed.host, + keepData: parsed.keepData, + ...(parsed.mode === undefined ? {} : { mode: parsed.mode }), + plan: parsed.plan, + purgeData: parsed.purgeData, + scope: parsed.scope, + }); + writeResult(parsed, result, formatUninstallResult(result)); + break; + } + default: { + const exhaustive: never = parsed.verb; + throw new TypeError(`Unknown installer verb ${String(exhaustive)}.`); + } + } return 0; } catch (error) { if (parsed?.json === true) { diff --git a/packages/agent-bundle/src/install/cursor-hooks-registration.ts b/packages/agent-bundle/src/install/cursor-hooks-registration.ts index af6a475f1..125111e1c 100644 --- a/packages/agent-bundle/src/install/cursor-hooks-registration.ts +++ b/packages/agent-bundle/src/install/cursor-hooks-registration.ts @@ -472,7 +472,7 @@ export interface CursorMarketplaceStagingFinding { */ const sanitizeCacheSegment = (segment: string): string => segment.replaceAll(/[^A-Za-z0-9._-]/gu, '-'); -const cacheHasPlugin = async ( +export const cacheHasPlugin = async ( home: string, marketplace: string, name: string, @@ -503,7 +503,7 @@ const cacheHasPlugin = async ( const isCommitSha = (value: string | undefined): value is string => value !== undefined && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(value); /** Resolves HEAD to a commit SHA; `undefined` when HEAD is missing, unreadable, malformed, or unborn. */ -const readHeadCommit = async (repoRoot: string): Promise => { +export const readHeadCommit = async (repoRoot: string): Promise => { try { const head = (await readFile(join(repoRoot, '.git', 'HEAD'), 'utf8')).trim(); if (!head.startsWith('ref: ')) return isCommitSha(head) ? head : undefined; diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 67bb9e223..ab894eadb 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -32,16 +32,30 @@ import { claudePluginRowErrors, parsePublicHostInventory, publicHostCacheRoot, + publicHostRoot, treeHash, type InstallHost, + type PublicHostInstalledEntry, type PublicHostInventory, } from './install.ts'; import { compareInstalledTree, describeContentComparison, + installReceiptFile, + installReceiptFormat, + installReceiptStoreDirectory, + isPreservedRuntimeRoot, + isRemnantReceipt, + isRuntimeStateRemnant, + readInstallReceipt, + readInstallReceiptFile, treeInventory, type InstalledTreeComparison, type InstalledTreeOwnership, + type InstallReceipt, + type InstallReceiptMode, + type InstallReceiptScope, + type InstallRegistration, type TreeInventory, } from './receipt.ts'; import { @@ -100,6 +114,17 @@ export interface DoctorHostProbe { readonly version?: string; } +/** The install receipt an inventoried Cursor local copy carries, as read (a format/1 receipt is reported as migrated). */ +export interface DoctorReceiptSummary { + readonly contentHash: string; + readonly format: string; + readonly installedAt: string; + readonly migratedFrom?: string; + readonly mode: InstallReceiptMode; + readonly scope: InstallReceiptScope; + readonly updatedAt: string; +} + export interface DoctorFinding { /** Git commit of a staged Cursor marketplace repository. */ readonly commit?: string; @@ -125,11 +150,60 @@ export interface DoctorFinding { readonly manifest?: string; readonly name?: string; readonly path?: string; + /** The in-tree install receipt of a Cursor local copy, when it carries one. */ + readonly receipt?: DoctorReceiptSummary; readonly runtime?: DoctorRuntimeStatus; readonly state: DoctorFindingState; readonly version?: string; } +/** + * One lifecycle observation. `observed` carries the host evidence that made it + * true or false; `unavailable` names why no pinned read-only surface exposes it + * — Doctor never guesses an activation state. + */ +export type DoctorLifecycleObservation = + | Readonly<{ readonly evidence: string; readonly status: 'observed'; readonly value: boolean }> + | Readonly<{ readonly reason: string; readonly status: 'unavailable' }>; + +/** The furthest lifecycle stage observed true (`unknown` when placement itself is unobservable). */ +export type DoctorLifecycleStage = 'absent' | 'active' | 'enabled' | 'placed' | 'registered' | 'unknown'; + +/** + * placed (bytes at the host's install location) → registered (the host's + * registry names the plugin) → enabled (the host reports it enabled/trusted) → + * active (loaded by a live host process), each observed or typed unavailable + * per host (#101). + */ +export interface DoctorLifecycle { + readonly active: DoctorLifecycleObservation; + readonly enabled: DoctorLifecycleObservation; + readonly placed: DoctorLifecycleObservation; + readonly registered: DoctorLifecycleObservation; + readonly stage: DoctorLifecycleStage; +} + +/** + * A store receipt (`/agent-bundle/receipts/*.json`) and whether the + * host still holds the registration it records: `consistent`, `orphaned` (the + * host no longer lists the plugin or the staged repository is gone), or + * `unknown` (the host inventory was unusable). + */ +export interface DoctorReceiptFinding { + readonly contentHash: string; + readonly format: string; + readonly installedAt: string; + readonly migratedFrom?: string; + readonly mode: InstallReceiptMode; + readonly path: string; + readonly plugin: string; + readonly registrations: readonly InstallRegistration[]; + readonly scope: InstallReceiptScope; + readonly state: 'consistent' | 'orphaned' | 'unknown'; + readonly updatedAt: string; + readonly version: string; +} + export type DoctorRuntimeStatus = | Readonly<{ readonly artifactEpoch: string; @@ -215,12 +289,15 @@ export interface DoctorHostReport { readonly comparison?: DoctorInstallComparison; /** Claude only: host validator reports for the bundle and every installed copy, when `claude` is available. */ readonly hostValidation?: readonly DoctorHostValidation[]; + readonly lifecycle?: DoctorLifecycle; readonly marketplace?: string; }; readonly diagnostics: readonly Diagnostic[]; readonly host: DoctorHost; readonly inventory: DoctorInventory; readonly probe: DoctorHostProbe; + /** Agent Bundle store receipts under the host root, cross-checked against the host's inventory. */ + readonly receipts: readonly DoctorReceiptFinding[]; } export interface DoctorEndpointReport { @@ -384,6 +461,16 @@ export const resolveBundleRoot = async (from: string, host: DoctorHost): Promise ); }; +/** The cwd for `plugin list --json`: the resolved host bundle root under `--from`, else the given directory, else home. */ +const listingDirectory = async (from: string | undefined, host: DoctorHost, home: string): Promise => { + if (from === undefined) return home; + try { + return await resolveBundleRoot(from, host); + } catch { + return resolve(from); + } +}; + const readIdentity = async (from: string, host: DoctorHost): Promise => { const bundleRoot = await resolveBundleRoot(from, host); const kind = `${host} plugin manifest`; @@ -436,6 +523,36 @@ const durableStateReport = ( }); }; +/** + * AB7307 for a Cursor directory that holds no plugin but was left by `uninstall --keep-data`. + * A remnant receipt (owning no files) may also guard unowned entries the uninstall retained, so + * the message reports those extras instead of calling the directory state-only; `stateOnly` + * short-circuits the readdir when the caller already proved the directory holds only `state/`. + */ +const remnantDiagnostic = async (subject: string, path: string, stateOnly: boolean, pluginData?: string): Promise => { + const entries = stateOnly ? [] : (await readdir(path)).filter((name) => name !== installReceiptFile); + const extras = entries.filter((name) => !isPreservedRuntimeRoot(name)).sort((left, right) => left.localeCompare(right)); + const preserved = [ + ...(stateOnly || entries.some(isPreservedRuntimeRoot) ? ['state/'] : []), + ...(pluginData === undefined ? [] : [`the PLUGIN_DATA directory ${pluginData}`]), + ]; + const preservedText = preserved.length === 0 ? 'state/' : preserved.join(' and '); + return diagnostic( + 'AB7307', + extras.length === 0 + ? `${subject} holds only preserved runtime state (${preservedText}) from an earlier \`uninstall --keep-data\`; ` + + 'no plugin is installed there.' + : `${subject} holds no plugin: an earlier \`uninstall\` retained the unowned ` + + `${extras.length === 1 ? 'entry' : 'entries'} ${extras.map((name) => JSON.stringify(name)).join(', ')}` + + `${preserved.length === 0 ? '' : ` beside preserved runtime state (${preservedText})`}.`, + extras.length === 0 + ? 'Reinstall the plugin to use the preserved state, or run `agent-bundle uninstall cursor --purge-data --confirm-purge` to remove it.' + : 'Reinstall the plugin, or move the retained entries out and remove the directory by hand; `uninstall` never removes unowned entries.', + 'info', + 'cursor', + ); +}; + const inspectDurableState = async ( pluginRoot: string, target?: DoctorHost, @@ -785,6 +902,29 @@ const cursorInventory = async ( manifest = undefined; } if (manifest === undefined) { + // `uninstall --keep-data` leaves preserved runtime state (and a remnant receipt owning no files) behind: + // not a corrupt plugin, an uninstalled one whose durable state was kept on purpose. + let remnantReceipt: InstallReceipt | undefined; + try { + remnantReceipt = await readInstallReceipt(path); + } catch { + remnantReceipt = undefined; + } + const stateOnly = await isRuntimeStateRemnant(path); + const remnant = stateOnly || (remnantReceipt !== undefined && isRemnantReceipt(remnantReceipt)); + if (remnant) { + const durableState = await inspectDurableState(path, 'cursor'); + if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); + diagnostics.push(await remnantDiagnostic(`Cursor plugin entry ${JSON.stringify(path)}`, path, stateOnly, remnantReceipt?.cursorExpansion?.pluginData)); + findings.push({ + ...(durableState === undefined ? {} : { durableState }), + entry, + ...(remnantReceipt === undefined ? {} : { name: remnantReceipt.plugin, receipt: receiptSummary(remnantReceipt), version: remnantReceipt.version }), + path, + state: 'missing', + }); + continue; + } findings.push({ entry, path, state: 'corrupt' }); diagnostics.push(diagnostic( 'AB7304', @@ -834,6 +974,16 @@ const cursorInventory = async ( ? await inspectCursorPluginHooks(path, home, { caseInsensitivePaths: platform === 'win32' }) : undefined; if (hooks !== undefined) diagnostics.push(...hooks.diagnostics); + // The in-tree receipt is read-only evidence here: a pre-lifecycle receipt is diagnosed, never rewritten. + let receipt: InstallReceipt | undefined; + try { + receipt = await readInstallReceipt(path); + } catch { + receipt = undefined; + } + if (receipt?.migratedFrom !== undefined) { + diagnostics.push(migratedReceiptDiagnostic('cursor', join(path, installReceiptFile), receipt)); + } findings.push({ ...(durableState === undefined ? {} : { durableState }), entry, @@ -842,6 +992,7 @@ const cursorInventory = async ( manifest: manifest.manifest, name: manifest.name, path, + ...(receipt === undefined ? {} : { receipt: receiptSummary(receipt) }), // A drifted expansion means Cursor spawns paths that no longer exist: the install is corrupt, not merely stale. state: staticDiagnostics.some((entry) => entry.severity === 'error') || launch?.launch?.state === 'drifted' ? 'corrupt' @@ -1015,6 +1166,299 @@ const installComparison = ( status: comparison.status, }); +const observed = (value: boolean, evidence: string): DoctorLifecycleObservation => + Object.freeze({ evidence, status: 'observed', value }); + +const unavailable = (reason: string): DoctorLifecycleObservation => Object.freeze({ reason, status: 'unavailable' }); + +const lifecycleStages = Object.freeze(['placed', 'registered', 'enabled', 'active'] as const); + +/** The furthest stage observed true; an observed-false placement is `absent`, an unobservable one `unknown`. */ +const lifecycleOf = (observations: Omit): DoctorLifecycle => { + let stage: DoctorLifecycleStage = observations.placed.status === 'unavailable' ? 'unknown' : 'absent'; + for (const name of lifecycleStages) { + const observation = observations[name]; + if (observation.status !== 'observed' || !observation.value) break; + stage = name; + } + return Object.freeze({ ...observations, stage }); +}; + +const noLiveHostSurface = (host: DoctorHost): string => { + switch (host) { + case 'claude': + return 'Claude Code 2.1.257 exposes no read-only verb that reports which plugins a live session has loaded; `claude plugin list --json` reports registration and enablement only.'; + case 'codex': + return 'Codex 0.147.0 exposes no read-only verb that reports which plugins a live session has loaded, and skips plugin hooks until the user trusts them in the hook browser (no read-only trust verb).'; + case 'cursor': + return 'Cursor exposes no non-interactive plugin-loading surface; whether a live window loaded the plugin is not observable read-only.'; + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown Doctor host ${String(exhaustive)}.`); + } + } +}; + +const cursorEnabledUnavailable = + 'Cursor keeps enabled-plugin state as server-assigned ids in state.vscdb (2026-09-03 audit, #407) and gates plugin import and ' + + 'plugin hooks on its thirdPartyExtensibilityEnabled / enable_cc_plugin_import flags (observed 3.18.25); no pinned read-only surface exposes either.'; + +/** + * Lifecycle for a public host CLI copy: placement is the cache path the host + * reported, registration and enablement come from `plugin list --json`, and + * live activation has no read-only surface on either host. + */ +const publicHostLifecycle = async ( + host: Exclude, + inventory: PublicHostInventory, +): Promise => { + if (inventory.status === 'unavailable') { + const reason = `\`${host} plugin list --json\` was unusable (${inventory.detail}).`; + return lifecycleOf({ + active: unavailable(noLiveHostSurface(host)), + enabled: unavailable(reason), + placed: unavailable(reason), + registered: unavailable(reason), + }); + } + if (inventory.entries.length === 0) { + const evidence = `\`${host} plugin list --json\` lists no copy of the plugin.`; + return lifecycleOf({ + active: unavailable(noLiveHostSurface(host)), + enabled: observed(false, evidence), + placed: observed(false, evidence), + registered: observed(false, evidence), + }); + } + // Claude may list the plugin at several scopes (user, project, local) and Codex reports one row; the lifecycle + // aggregates every row, and a stage holds only when it holds for every listed copy — a disabled or unplaced + // copy at any scope is reported, never hidden behind the row Claude happened to list first. + const label = (entry: PublicHostInstalledEntry): string => entry.scope === undefined ? 'the row' : `scope ${entry.scope}`; + const placements: { readonly entry: PublicHostInstalledEntry; readonly placed: boolean }[] = []; + for (const entry of inventory.entries) { + let placed = false; + try { + placed = (await lstat(entry.installPath)).isDirectory(); + } catch (error) { + if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTDIR')) throw error; + } + placements.push({ entry, placed }); + } + const unplaced = placements.filter((placement) => !placement.placed); + const flagless = inventory.entries.filter((entry) => entry.enabled === undefined); + const disabled = inventory.entries.filter((entry) => entry.enabled === false); + const scopes = inventory.entries.map(label).join(', '); + return lifecycleOf({ + active: unavailable(noLiveHostSurface(host)), + enabled: flagless.length > 0 + ? unavailable(`the \`${host} plugin list --json\` row for ${flagless.map(label).join(', ')} carries no enabled flag.`) + : observed( + disabled.length === 0, + disabled.length === 0 + ? `\`${host} plugin list --json\` reports enabled: true for ${scopes}.` + : `\`${host} plugin list --json\` reports enabled: false for ${disabled.map(label).join(', ')}` + + `${disabled.length === inventory.entries.length ? '' : ` (enabled: true for the other listed ${inventory.entries.length - disabled.length === 1 ? 'scope' : 'scopes'})`}.`, + ), + placed: observed( + unplaced.length === 0, + unplaced.length === 0 + ? `${host} reports the ${placements.length === 1 ? 'copy' : 'copies'} at ${[...new Set(placements.map((placement) => placement.entry.installPath))].join(', ')}.` + : `${host} reports ${unplaced.map((placement) => `${placement.entry.installPath} (${label(placement.entry)})`).join(', ')}, which ${unplaced.length === 1 ? 'is' : 'are'} not ${unplaced.length === 1 ? 'a directory' : 'directories'}.`, + ), + registered: observed(true, `\`${host} plugin list --json\` lists the plugin${inventory.entries.some((entry) => entry.scope !== undefined) ? ` at ${scopes}` : ''}.`), + }); +}; + +const cursorLocalLifecycle = (destination: string, placed: boolean): DoctorLifecycle => lifecycleOf({ + active: unavailable(noLiveHostSurface('cursor')), + enabled: unavailable(cursorEnabledUnavailable), + placed: observed(placed, placed ? `${destination} exists.` : `${destination} does not exist.`), + registered: observed( + placed, + 'Cursor loads every directory under ~/.cursor/plugins/local at window reload; the directory is the registration.', + ), +}); + +const cursorStagedLifecycle = (repoRoot: string, imported: boolean): DoctorLifecycle => lifecycleOf({ + active: unavailable(noLiveHostSurface('cursor')), + enabled: unavailable(cursorEnabledUnavailable), + placed: observed(true, `Staged marketplace repository ${repoRoot} exists.`), + registered: observed( + imported, + imported + ? 'Cursor holds a completed (.cache-complete) copy from this staging under ~/.cursor/plugins/cache.' + : 'No completed copy from this staging exists under ~/.cursor/plugins/cache; the Customize import step is pending.', + ), +}); + +const describeObservation = (observation: DoctorLifecycleObservation): string => + observation.status === 'observed' ? (observation.value ? 'yes' : 'no') : 'unavailable'; + +const lifecycleDiagnostic = (host: DoctorHost, name: string, version: string, lifecycle: DoctorLifecycle): Diagnostic => { + const unavailableStages = lifecycleStages.filter((stage) => lifecycle[stage].status === 'unavailable'); + const detail = unavailableStages.length === 0 + ? '' + : ` Unavailable: ${unavailableStages.map((stage) => { + const observation = lifecycle[stage]; + return `${stage} (${observation.status === 'unavailable' ? observation.reason : ''})`; + }).join('; ')}`; + const recovery = lifecycle.stage === 'placed' && lifecycle.registered.status === 'observed' + ? host === 'cursor' + ? 'Complete the Cursor Customize import (marketplace mode), or reload the window (local mode).' + : `Register the plugin with \`agent-bundle install ${host} --from \`.` + : lifecycle.stage === 'registered' && lifecycle.enabled.status === 'observed' + ? host === 'claude' + ? 'Enable the plugin with `claude plugin enable @`.' + : host === 'codex' + ? 'Enable the plugin with `/plugins` in Codex or by editing `[plugins."@"] enabled` in config.toml.' + : 'Enable the plugin in Cursor Customize -> Plugins.' + : 'No action needed; stages marked unavailable have no pinned read-only host surface and are never guessed.'; + return diagnostic( + 'AB7330', + `${name}@${version} lifecycle on ${host}: stage ${lifecycle.stage}` + + ` (placed ${describeObservation(lifecycle.placed)}, registered ${describeObservation(lifecycle.registered)}, ` + + `enabled ${describeObservation(lifecycle.enabled)}, active ${describeObservation(lifecycle.active)}).${detail}`, + recovery, + 'info', + host, + ); +}; + +const receiptSummary = (receipt: InstallReceipt): DoctorReceiptSummary => Object.freeze({ + contentHash: receipt.contentHash, + format: receipt.migratedFrom ?? installReceiptFormat, + installedAt: receipt.installedAt, + ...(receipt.migratedFrom === undefined ? {} : { migratedFrom: receipt.migratedFrom }), + mode: receipt.mode, + scope: receipt.scope, + updatedAt: receipt.updatedAt, +}); + +const migratedReceiptDiagnostic = (host: DoctorHost, path: string, receipt: InstallReceipt): Diagnostic => diagnostic( + 'AB7329', + `Install receipt ${JSON.stringify(path)} predates lifecycle receipts (read as ${receipt.migratedFrom ?? 'an older format'}): ` + + `mode, scope, registrations, and host directories were synthesized (${receipt.mode}, ${receipt.scope}, ` + + `${receipt.registrations.map((registration) => registration.kind).join(', ')}, none).`, + 'Rerun `agent-bundle install` (or the bundle\'s `install.mjs`) once; an identical copy rewrites the receipt as ' + + `${installReceiptFormat} without changing plugin files. \`uninstall\` accepts the migrated receipt as is.`, + 'info', + host, +); + +/** Whether the host's listing still names the plugin registration a store receipt records. */ +const receiptRegistrationState = ( + host: Exclude, + receipt: InstallReceipt, + listing: PublicHostListing, +): DoctorReceiptFinding['state'] => { + if (listing.status === 'unavailable') return 'unknown'; + const registration = receipt.registrations.find((candidate) => candidate.kind === `${host}-plugin`); + if (registration?.id === undefined) return 'unknown'; + let document: unknown; + try { + document = JSON.parse(listing.stdout) as unknown; + } catch { + return 'unknown'; + } + const rows = host === 'claude' + ? document + : isRecord(document) ? document['installed'] : undefined; + if (!Array.isArray(rows)) return 'unknown'; + const present = rows.some((row) => isRecord(row) && (host === 'claude' + ? row['id'] === registration.id && (registration.scope === undefined || row['scope'] === registration.scope) + : row['pluginId'] === registration.id && row['installed'] !== false)); + return present ? 'consistent' : 'orphaned'; +}; + +const receiptFinding = (path: string, receipt: InstallReceipt, state: DoctorReceiptFinding['state']): DoctorReceiptFinding => + Object.freeze({ + contentHash: receipt.contentHash, + format: receipt.migratedFrom ?? installReceiptFormat, + installedAt: receipt.installedAt, + ...(receipt.migratedFrom === undefined ? {} : { migratedFrom: receipt.migratedFrom }), + mode: receipt.mode, + path, + plugin: receipt.plugin, + registrations: receipt.registrations, + scope: receipt.scope, + state, + updatedAt: receipt.updatedAt, + version: receipt.version, + }); + +/** + * Inventories the Agent Bundle store receipts under a host root and + * cross-checks each against the host: an orphaned receipt (the registration it + * records is gone) is `AB7328`, a pre-lifecycle receipt is `AB7329`. Unreadable + * receipt files are reported, never thrown. + */ +const inspectStoreReceipts = async ( + host: DoctorHost, + hostRoot: string, + stateOf: (receipt: InstallReceipt) => Promise, +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly receipts: readonly DoctorReceiptFinding[] }> => { + const directory = installReceiptStoreDirectory(hostRoot); + let entries: readonly string[]; + try { + entries = (await readdir(directory)).sort((left, right) => left.localeCompare(right)); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) return { diagnostics: Object.freeze([]), receipts: Object.freeze([]) }; + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7328', + `Agent Bundle receipt store ${JSON.stringify(directory)} could not be read.`, + 'Repair permissions for the receipt store, then rerun `agent-bundle doctor`.', + 'warning', + host, + )]), + receipts: Object.freeze([]), + }; + } + const diagnostics: Diagnostic[] = []; + const receipts: DoctorReceiptFinding[] = []; + for (const entry of entries.filter((name) => name.endsWith('.json'))) { + const path = join(directory, entry); + let receipt: InstallReceipt | undefined; + try { + receipt = await readInstallReceiptFile(path); + } catch (error) { + diagnostics.push(diagnostic( + 'AB7328', + `Agent Bundle receipt ${JSON.stringify(path)} could not be read: ${error instanceof Error ? error.message : String(error)}`, + 'Remove or repair the receipt file; `agent-bundle uninstall --force` removes the install it described.', + 'warning', + host, + )); + continue; + } + if (receipt === undefined) { + diagnostics.push(diagnostic( + 'AB7328', + `Agent Bundle receipt ${JSON.stringify(path)} is not a valid install receipt (unknown format or missing fields).`, + 'Reinstall the plugin to rewrite its receipt, or remove the file if the install is gone.', + 'warning', + host, + )); + continue; + } + const state = await stateOf(receipt); + receipts.push(receiptFinding(path, receipt, state)); + if (receipt.migratedFrom !== undefined) diagnostics.push(migratedReceiptDiagnostic(host, path, receipt)); + if (state === 'orphaned') { + diagnostics.push(diagnostic( + 'AB7328', + `Agent Bundle receipt ${JSON.stringify(path)} records ${receipt.plugin}@${receipt.version} (${receipt.mode}, scope ${receipt.scope}) ` + + `but ${host} no longer holds the registration it describes.`, + `Run \`agent-bundle uninstall ${host} --from ${receipt.mode === 'marketplace' ? ' --mode marketplace' : ''}\` to consume the ` + + 'orphaned receipt, or reinstall the plugin.', + 'warning', + host, + )); + } + } + return { diagnostics: freezeDiagnostics(diagnostics), receipts: Object.freeze(receipts) }; +}; + /** * This plugin's copies in the host's inventory, read through the same parser * `agent-bundle install` uses before replacing, from the listing Doctor @@ -1249,6 +1693,7 @@ const cursorStagedBundle = async ( diagnostics: freezeDiagnostics(staging.diagnostics.filter((candidate) => candidate.message.includes(repoRoot))), finding: Object.freeze({ ...base, + lifecycle: cursorStagedLifecycle(repoRoot, false), ...(entry?.marketplace === undefined ? {} : { marketplace: entry.marketplace }), path: repoRoot, state: 'corrupt', @@ -1256,27 +1701,41 @@ const cursorStagedBundle = async ( }; } const [sourceHash, stagedHash] = await Promise.all([treeHash(identity.bundleRoot), treeHash(pluginDirectory)]); + // The staging inspection also proves the working tree equals committed HEAD, so a source-matching but + // uncommitted tree cannot be reported as imported. It is read for a drifted staging too: a bundle rebuilt + // after Cursor imported the staged commit is still imported, and the lifecycle must say so. + const staging = await inspectCursorMarketplaceStaging(home, git); + const entry = staging.findings.find((candidate) => candidate.name === identity.name); if (sourceHash !== stagedHash) { + const imported = entry?.state === 'registered'; return { diagnostics: freezeDiagnostics([diagnostic( 'AB7308', - `Staged Cursor marketplace copy of ${identity.name}@${identity.version} at ${repoRoot} differs from the current bundle.`, - 'Remove the staged marketplace directory and rerun `agent-bundle install cursor --mode marketplace`.', + `Staged Cursor marketplace copy of ${identity.name}@${identity.version} at ${repoRoot} differs from the current bundle` + + `${imported ? '; Cursor has imported the staged copy, so the imported plugin is the older content' : ''}.`, + imported + ? 'Run `agent-bundle uninstall cursor --mode marketplace` (the imported copy is Cursor-owned; it lists the Customize step), ' + + 'rerun `agent-bundle install cursor --mode marketplace`, then re-import the plugin in Cursor.' + : 'Remove the staged marketplace directory and rerun `agent-bundle install cursor --mode marketplace`.', 'warning', 'cursor', )]), - finding: Object.freeze({ ...base, path: repoRoot, state: 'drifted' }), + finding: Object.freeze({ + ...base, + ...(entry?.commit === undefined ? {} : { commit: entry.commit }), + lifecycle: cursorStagedLifecycle(repoRoot, imported), + ...(entry?.marketplace === undefined ? {} : { marketplace: entry.marketplace }), + path: repoRoot, + state: 'drifted', + }), }; } - // The staging inspection also proves the working tree equals committed HEAD, so a source-matching but - // uncommitted tree cannot be reported as imported. - const staging = await inspectCursorMarketplaceStaging(home, git); - const entry = staging.findings.find((candidate) => candidate.name === identity.name); return { diagnostics: freezeDiagnostics(staging.diagnostics.filter((candidate) => candidate.message.includes(repoRoot) || candidate.message.includes(`${identity.name}@`))), finding: Object.freeze({ ...base, ...(entry?.commit === undefined ? {} : { commit: entry.commit }), + lifecycle: cursorStagedLifecycle(repoRoot, entry?.state === 'registered'), ...(entry?.marketplace === undefined ? {} : { marketplace: entry.marketplace }), path: repoRoot, state: entry === undefined ? 'unregistered' : entry.state, @@ -1312,6 +1771,7 @@ const cursorBundle = async ( finding: Object.freeze({ ...base, comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'not-installed' as const }), + lifecycle: cursorLocalLifecycle(destination, false), state: 'missing', }), }; @@ -1337,8 +1797,33 @@ const cursorBundle = async ( const withComparison = (state: DoctorFindingState): DoctorHostReport['bundle'] => Object.freeze({ ...base, comparison: installComparison(comparison, destination), + lifecycle: cursorLocalLifecycle(destination, true), + ...(comparison.receipt === undefined ? {} : { receipt: receiptSummary(comparison.receipt) }), state, }); + // `uninstall --keep-data` left state/ (with a remnant receipt owning no files) and possibly unowned entries it + // retained: not installed, state kept. The remnant receipt alone does not prove the directory is state-only. + const stateOnly = (comparison.ownership === 'receipt' || comparison.ownership === 'foreign') && + await isRuntimeStateRemnant(destination); + const remnant = stateOnly || + (comparison.ownership === 'receipt' && comparison.receipt !== undefined && isRemnantReceipt(comparison.receipt)); + if (remnant) { + return { + diagnostics: freezeDiagnostics([await remnantDiagnostic( + `Cursor destination ${destination} (${identity.name}@${identity.version})`, + destination, + stateOnly, + comparison.receipt?.cursorExpansion?.pluginData, + )]), + finding: Object.freeze({ + ...base, + comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'not-installed' as const }), + lifecycle: cursorLocalLifecycle(destination, false), + ...(comparison.receipt === undefined ? {} : { receipt: receiptSummary(comparison.receipt) }), + state: 'missing', + }), + }; + } switch (comparison.status) { case 'current': return { diagnostics: Object.freeze([]), finding: withComparison('installed') }; @@ -1579,6 +2064,7 @@ const claudeBundle = async ( ...registration.finding, comparison: compared.comparison, hostValidation: Object.freeze(validated.map((entry) => entry.validation)), + lifecycle: await publicHostLifecycle('claude', inventory), }), }; }; @@ -1616,6 +2102,7 @@ const codexBundle = async ( finding: Object.freeze({ ...base, comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'unknown' as const }), + lifecycle: await publicHostLifecycle('codex', inventory), state: 'unknown', }), }; @@ -1626,6 +2113,7 @@ const codexBundle = async ( finding: Object.freeze({ ...base, comparison: compared.comparison, + lifecycle: await publicHostLifecycle('codex', inventory), state: inventory.entries.length === 0 ? 'missing' : 'installed', }), }; @@ -1984,15 +2472,55 @@ const doctorHost = async ( : await probeBinary(host, home, run); const environment = options.environment ?? process.env; const git = stagingGit(run); + // Claude `project` / `local` registrations are keyed by the cwd the host verbs ran in, and install runs them + // from the resolved host bundle root (`/claude` for a multi-target artifact root), so the listing the + // bundle comparison and lifecycle use is taken from that same root; `--from` without a manifest for this host + // falls back to the given directory and the bundle step reports the missing manifest. + const listingCwd = await listingDirectory(options.from, host, home); const listing: PublicHostListing = host === 'cursor' || probed.probe.status !== 'available' ? { detail: `${host} is not available`, status: 'unavailable' } - : await readPublicHostListing(host, run, options.from === undefined ? home : resolve(options.from)); + : await readPublicHostListing(host, run, listingCwd); + // Claude `project` / `local` registrations live in the project's own `.claude/settings*.json`, so a + // receipt that records a project root is cross-checked against `plugin list --json` run from that root + // (once per root), never against the listing taken here: a valid install elsewhere is not an orphan. + // A root that cannot be listed (gone, or the host failed there) leaves the state unknown. + const projectListings = new Map>(); + const listingFor = (receipt: InstallReceipt): Promise => { + if (host === 'cursor' || listing.status === 'unavailable' || (receipt.scope !== 'project' && receipt.scope !== 'local')) { + return Promise.resolve(listing); + } + if (receipt.projectRoot === undefined) { + return Promise.resolve({ detail: `the ${receipt.scope}-scope receipt records no project root`, status: 'unavailable' }); + } + const projectRoot = resolve(receipt.projectRoot); + if (projectRoot === listingCwd) return Promise.resolve(listing); + let pending = projectListings.get(projectRoot); + if (pending === undefined) { + pending = readPublicHostListing(host, run, projectRoot); + projectListings.set(projectRoot, pending); + } + return pending; + }; const inventoried = host === 'cursor' ? await cursorInventory(home, probed.probe.status === 'available', git, options.platform ?? process.platform) : probed.probe.status !== 'available' ? { diagnostics: Object.freeze([]), inventory: freezeInventory('skipped') } : publicHostInventory(host, listing, environment, home); const diagnostics = [...probed.diagnostics, ...inventoried.diagnostics]; + // Store receipts are lifecycle evidence Agent Bundle itself wrote, so the store is inventoried from + // the filesystem whether or not the host can be probed: malformed and migrated receipts are always + // reported. The host cross-check that separates `consistent` from `orphaned` needs the host's + // inventory; without it the registration state is `unknown`, never guessed. + const receipts = host === 'cursor' + ? await inspectStoreReceipts(host, join(home, '.cursor'), async (receipt) => receipt.mode === 'marketplace' + ? (await exists(join(cursorMarketplaceRoot(join(home, '.cursor')), receipt.plugin)) ? 'consistent' : 'orphaned') + : 'unknown') + : await inspectStoreReceipts( + host, + publicHostRoot(host, environment, home), + async (receipt) => receiptRegistrationState(host, receipt, await listingFor(receipt)), + ); + diagnostics.push(...receipts.diagnostics); let bundle: DoctorHostReport['bundle']; if (options.from !== undefined) { try { @@ -2014,6 +2542,9 @@ const doctorHost = async ( if (checked.finding === undefined) { throw new TypeError(`The ${host} bundle check returned no finding.`); } + if (checked.finding.lifecycle !== undefined) { + diagnostics.push(lifecycleDiagnostic(host, identity.name, identity.version, checked.finding.lifecycle)); + } const durableState = await inspectDurableState(identity.bundleRoot, host); if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); bundle = Object.freeze({ @@ -2034,6 +2565,7 @@ const doctorHost = async ( host, inventory: inventoried.inventory, probe: probed.probe, + receipts: receipts.receipts, ...(bundle === undefined ? {} : { bundle }), }); }; diff --git a/packages/agent-bundle/src/install/format.ts b/packages/agent-bundle/src/install/format.ts index 6f9f909b0..029633062 100644 --- a/packages/agent-bundle/src/install/format.ts +++ b/packages/agent-bundle/src/install/format.ts @@ -1,4 +1,5 @@ import type { InstallResult } from './install.ts'; +import type { UninstallResult } from './uninstall.ts'; const shortContentHash = (hash: string): string => hash.slice(0, 12); @@ -43,3 +44,62 @@ export const formatInstallResult = (result: InstallResult): string => { } return `${lines.join('\n')}\n`; }; + +const uninstallVerb = (state: UninstallResult['state']): string => { + switch (state) { + case 'not-installed': + return 'Not installed'; + case 'planned': + return 'Would uninstall'; + case 'uninstalled': + return 'Uninstalled'; + default: { + const exhaustive: never = state; + throw new TypeError(`Unknown uninstall state ${String(exhaustive)}.`); + } + } +}; + +const registrationLabel = (registration: UninstallResult['registrations'][number]): string => + registration.id ?? registration.name ?? registration.kind; + +/** + * Human-readable uninstall summary shared by the CLI and generated installer + * bins. `--plan` output lists every exact path the run would remove, so an + * operator can audit the mutation before allowing it. + */ +export const formatUninstallResult = (result: UninstallResult): string => { + const where = result.destination === undefined ? '' : ` at ${result.destination}`; + const lines = [ + `${uninstallVerb(result.state)} ${result.plugin}@${result.version} for ${result.host} (${result.mode} mode)${where}` + + `${result.forced ? ' [--force]' : ''}`, + `Receipt: ${result.receipt.status} (${result.receipt.path})`, + ]; + for (const registration of result.registrations) { + lines.push(`Registration ${registration.kind} ${registrationLabel(registration)}: ${registration.action}` + + `${registration.detail === undefined ? '' : ` — ${registration.detail}`}`); + } + if (result.state !== 'not-installed') { + const verb = result.state === 'planned' ? 'Would remove' : 'Removed'; + lines.push(`${verb} ${result.removed.files.length} file(s):`); + lines.push(...result.removed.files.map((path) => ` ${path}`)); + lines.push(`${verb} ${result.removed.directories.length} director${result.removed.directories.length === 1 ? 'y' : 'ies'}` + + `${result.state === 'planned' ? ' (when empty)' : ''}:`); + lines.push(...result.removed.directories.map((path) => ` ${path}`)); + } + lines.push(`Data (${result.data.policy}): ${result.data.outcome} — ${result.data.detail}`); + lines.push(...result.data.paths.map((path) => ` ${path}`)); + if (result.retained.length > 0) { + lines.push(`Retained ${result.retained.length} unowned entr${result.retained.length === 1 ? 'y' : 'ies'} under ${result.destination ?? 'the destination'}:`); + lines.push(...result.retained.map((entry) => ` ${entry}`)); + } + if (result.remnantReceipt !== undefined) { + lines.push(`Remnant receipt${result.state === 'planned' ? ' (would be written)' : ''}: ${result.remnantReceipt} — owns no files; ` + + 'keeps the created host directories receipt-owned for a later purge.'); + } + if (result.nextSteps !== undefined && result.nextSteps.length > 0) { + lines.push('Next steps:'); + lines.push(...result.nextSteps.map((step, index) => ` ${index + 1}. ${step}`)); + } + return `${lines.join('\n')}\n`; +}; diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 4568db7e1..8836604d0 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -16,12 +16,21 @@ import { compareInstalledTree, createInstallReceipt, describeContentComparison, + installReceiptFile, + installReceiptScopeKey, + installReceiptStorePath, + isRemnantReceipt, + isRuntimeStateRemnant, + readInstallReceiptFile, replaceInstalledTree, stageArtifact, treeInventory, writeInstallReceipt, + writeStoredInstallReceipt, type InstalledManifestIdentity, type InstalledTreeComparison, + type InstallReceiptIdentity, + type InstallRegistration, type TreeInventory, } from './receipt.ts'; @@ -88,6 +97,11 @@ export interface InstallResult { readonly plugin: string; /** Content hash of the copy a `replaced` install superseded. */ readonly previousContentHash?: string; + /** + * The install receipt this run wrote or confirmed: inside the plugin root for Cursor local copies, + * in the host root's `agent-bundle/receipts` store for host-CLI and marketplace deliveries (#101). + */ + readonly receipt?: string; /** * `staged` means the marketplace repository is ready and Cursor's import step is still pending * (`mode: 'marketplace'`). @@ -96,7 +110,7 @@ export interface InstallResult { readonly version: string; } -interface PluginIdentity { +export interface PluginIdentity { readonly bundleRoot: string; readonly marketplace?: string; readonly plugin: string; @@ -179,7 +193,8 @@ const resolveBundleRoot = async (from: string, host: InstallHost): Promise => { +/** The plugin identity an install or uninstall acts on, read from the bundle's host manifests. */ +export const readIdentity = async (from: string, host: InstallHost): Promise => { const bundleRoot = await resolveBundleRoot(from, host); const pluginDocument = await readRecord(join(bundleRoot, hostManifestPath(host)), host, `${host} plugin manifest`); const plugin = readString(pluginDocument, 'name', host, `${host} plugin manifest`); @@ -204,7 +219,7 @@ const readIdentity = async (from: string, host: InstallHost): Promise, @@ -247,6 +262,19 @@ const runHostCommand = async ( return result; }; +/** + * A public host CLI's configuration root (`CLAUDE_CONFIG_DIR` or `~/.claude`; + * `CODEX_HOME` or `~/.codex`): where the host caches installed plugins and + * where Agent Bundle keeps its own `agent-bundle/receipts` store for them. + */ +export const publicHostRoot = ( + host: Exclude, + environment: Readonly, + home: string, +): string => host === 'claude' + ? environment['CLAUDE_CONFIG_DIR'] ?? join(home, '.claude') + : environment['CODEX_HOME'] ?? join(home, '.codex'); + /** * Where a public host CLI caches an installed marketplace plugin; pinned by * the real-host install proofs and shared with the development install sync. @@ -255,16 +283,14 @@ export const publicHostCacheRoot = ( host: Exclude, environment: Readonly, home: string, -): string => host === 'claude' - ? join(environment['CLAUDE_CONFIG_DIR'] ?? join(home, '.claude'), 'plugins', 'cache') - : join(environment['CODEX_HOME'] ?? join(home, '.codex'), 'plugins', 'cache'); +): string => join(publicHostRoot(host, environment, home), 'plugins', 'cache'); export interface PublicHostInstalledEntry { /** - * Claude only: the row's `enabled` flag. `false` means the copy is installed - * but switched off (`claude plugin disable`), so none of it reaches a - * session until `claude plugin enable` runs; absent when the row carries no - * boolean. + * The row's `enabled` flag (Claude and Codex both carry one). `false` means + * the copy is installed but switched off (`claude plugin disable`), so none + * of it reaches a session until `claude plugin enable` runs; absent when the + * row carries no boolean. */ readonly enabled?: boolean; /** @@ -363,6 +389,7 @@ export const parsePublicHostInventory = ( } return { entries: [{ + ...(typeof row['enabled'] === 'boolean' ? { enabled: row['enabled'] } : {}), installPath: join(options.cacheRoot, options.marketplace, options.plugin, row['version']), version: row['version'], }], @@ -371,7 +398,7 @@ export const parsePublicHostInventory = ( }; /** Runs the host's inventory verb so replacement only uninstalls what the host reports as installed. */ -const readPublicHostInventory = async ( +export const readPublicHostInventory = async ( runner: InstallCommandRunner, identity: PluginIdentity, host: Exclude, @@ -419,7 +446,12 @@ const refusedInstallFailure = ( host, ); -const publicHostUninstallArguments = ( +/** + * The host verb that removes an installed plugin. Claude's `--keep-data` + * always rides along: the installer and `uninstall` manage the plugin's + * durable data explicitly (`--purge-data`) instead of letting the host decide. + */ +export const publicHostUninstallArguments = ( host: Exclude, id: string, scope: InstallScope, @@ -427,6 +459,89 @@ const publicHostUninstallArguments = ( ? ['plugin', 'uninstall', id, '--scope', scope, '--keep-data'] : ['plugin', 'remove', id]; +/** The host verb that removes a configured marketplace (identical spelling on Claude 2.1.257 and Codex 0.147.0). */ +export const publicHostMarketplaceRemoveArguments = (marketplace: string): readonly string[] => + ['plugin', 'marketplace', 'remove', marketplace]; + +/** The registrations `install ` performs for a public host CLI, in order. */ +export const publicHostRegistrations = ( + host: Exclude, + id: string, + marketplace: string, + scope: InstallScope, +): readonly InstallRegistration[] => host === 'claude' + ? Object.freeze([ + Object.freeze({ kind: 'claude-marketplace' as const, name: marketplace, scope }), + Object.freeze({ id, kind: 'claude-plugin' as const, scope }), + ]) + : Object.freeze([ + Object.freeze({ kind: 'codex-marketplace' as const, name: marketplace }), + Object.freeze({ id, kind: 'codex-plugin' as const }), + ]); + +/** + * Where the store receipt for a host-CLI install lives: + * `/agent-bundle/receipts/..[.].json`. + * The host identifies a registration as `@`, so the same + * plugin installed from two marketplaces is two installs with two receipts. + * Claude `project` / `local` registrations belong to the working directory the + * host verbs ran in (the bundle root), so each project keeps its own receipt. + */ +export const publicHostReceiptPath = ( + host: Exclude, + plugin: string, + marketplace: string, + scope: InstallScope, + environment: Readonly, + home: string, + projectRoot?: string, +): string => installReceiptStorePath( + publicHostRoot(host, environment, home), + plugin, + `${marketplace}.${installReceiptScopeKey(scope, projectRoot)}`, +); + +/** The project root a Claude `project` / `local` registration belongs to: the cwd the host verbs run in. */ +export const publicHostProjectRoot = ( + host: Exclude, + scope: InstallScope, + identity: PluginIdentity, +): string | undefined => host === 'claude' && scope !== 'user' ? identity.bundleRoot : undefined; + +/** ` plugin marketplace list --json`: whether a marketplace of this name is configured; `unknown` when unusable. */ +export const readPublicHostMarketplaceState = async ( + runner: InstallCommandRunner, + identity: PluginIdentity, + host: Exclude, + marketplace: string, +): Promise<'absent' | 'present' | 'unknown'> => { + let stdout: string; + try { + const result = await runner.run(host, ['plugin', 'marketplace', 'list', '--json'], { cwd: identity.bundleRoot }); + if (result.code !== 0) return 'unknown'; + stdout = result.stdout; + } catch { + return 'unknown'; + } + let document: unknown; + try { + document = JSON.parse(stdout) as unknown; + } catch { + return 'unknown'; + } + const rows = host === 'claude' + ? document + : typeof document === 'object' && document !== null ? (document as { readonly marketplaces?: unknown }).marketplaces : undefined; + if (!Array.isArray(rows)) return 'unknown'; + return rows.some((row) => typeof row === 'object' && row !== null && (row as { readonly name?: unknown }).name === marketplace) + ? 'present' + : 'absent'; +}; + +/** Where the store receipt for a Cursor marketplace-mode install lives. */ +export const cursorMarketplaceReceiptPath = (cursorRoot: string, plugin: string): string => + installReceiptStorePath(cursorRoot, plugin, 'marketplace'); + const installPublicCli = async ( options: InstallBundleOptions, identity: PluginIdentity, @@ -453,12 +568,16 @@ const installPublicCli = async ( host, ); } + const projectRoot = publicHostProjectRoot(host, scope, identity); + const receiptPath = publicHostReceiptPath(host, identity.plugin, marketplace, scope, environment, home, projectRoot); + const previousReceipt = await readInstallReceiptFile(receiptPath); const base = { bundleRoot: identity.bundleRoot, contentHash: artifact.hash, host, marketplace, plugin: identity.plugin, + receipt: receiptPath, version: identity.version, } as const; let replaced = false; @@ -467,6 +586,30 @@ const installPublicCli = async ( // reported copy locates where the reinstalled version lands. let destination: string | undefined; const entry = inventory.status === 'available' ? inventory.entries[0] : undefined; + // The store receipt is the lifecycle record for this host-owned copy: written on every install and + // replacement, and refreshed when an identical copy is found without one (pre-#101 installs). It owns + // no files — the host owns its cache copy — so only the content hash rides along, never an inventory. + const storeInventory: TreeInventory = Object.freeze({ files: Object.freeze([]), hash: artifact.hash }); + // The marketplace registration is recorded — and therefore reversed by `uninstall` — only when this + // install (or the receipted install it replaces) created it. A marketplace that was already configured + // belongs to whoever configured it; when `plugin marketplace list --json` cannot say, the registration + // is not claimed either (fail-closed: `uninstall` then retains it and says why). + const receiptIdentity = async (): Promise => { + const ownsMarketplace = previousReceipt !== undefined + ? previousReceipt.registrations.some((registration) => registration.kind === `${host}-marketplace`) + : await readPublicHostMarketplaceState(runner, identity, host, marketplace) === 'absent'; + return { + host, + ...(previousReceipt === undefined ? {} : { installedAt: previousReceipt.installedAt }), + mode: 'host-cli', + plugin: identity.plugin, + ...(projectRoot === undefined ? {} : { projectRoot }), + registrations: publicHostRegistrations(host, id, marketplace, scope) + .filter((registration) => ownsMarketplace || registration.kind !== `${host}-marketplace`), + scope, + version: identity.version, + }; + }; if (entry !== undefined) { destination = join(dirname(entry.installPath), identity.version); let installed: TreeInventory | undefined; @@ -488,6 +631,9 @@ const installPublicCli = async ( if (installed !== undefined && sameVersion && installed.hash === artifact.hash) { // Byte-identical, so reinstalling cannot help: the host's refusal is the artifact's own defect. if (entry.errors !== undefined && entry.errors.length > 0) throw refusedInstallFailure(host, id, entry, 'existing'); + if (previousReceipt === undefined || previousReceipt.contentHash !== artifact.hash) { + await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ ...(await receiptIdentity()), inventory: storeInventory })); + } return { ...base, destination: entry.installPath, state: 'already-installed' }; } if (!sameVersion && options.replace !== true) { @@ -507,20 +653,63 @@ const installPublicCli = async ( } const contentDrift = installed !== undefined && sameVersion && installed.hash !== artifact.hash; if (options.replace === true || contentDrift) { - await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal'); replaced = true; - previousContentHash = installed?.hash; + // The receipt remembers what the superseded copy hashed when the copy itself cannot be read. + previousContentHash = installed?.hash ?? previousReceipt?.contentHash; } } + // Decided before any host verb runs, so the marketplace ownership check sees the pre-install state. + const recorded = await receiptIdentity(); + if (replaced) { + await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal'); + } await runHostCommand(runner, identity, host, [ 'plugin', 'marketplace', 'add', identity.bundleRoot, ]); - await runHostCommand(runner, identity, host, host === 'claude' - ? ['plugin', 'install', id, '--scope', scope] - : ['plugin', 'add', id]); + // Between `marketplace add` and the receipt write, everything this run registered is claimed only in memory. + // If the plugin install or the receipt write fails there, reverse what did complete rather than leave + // registrations nothing records: a plugin without a receipt would pass the byte-identical fast path on retry + // as `already-installed`, and a marketplace without one would be sampled as pre-existing and retained as + // user-owned by every later `uninstall`. Plugin first, then the marketplace — the order the host verbs + // themselves require. + const createdMarketplace = previousReceipt === undefined && + recorded.registrations.some((registration) => registration.kind === `${host}-marketplace`); + let pluginInstalled = false; + try { + await runHostCommand(runner, identity, host, host === 'claude' + ? ['plugin', 'install', id, '--scope', scope] + : ['plugin', 'add', id]); + pluginInstalled = true; + await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ + ...recorded, + inventory: storeInventory, + updatedAt: new Date().toISOString(), + })); + } catch (error) { + const rollbacks: (readonly string[])[] = [ + ...(pluginInstalled ? [publicHostUninstallArguments(host, id, scope)] : []), + ...(createdMarketplace ? [publicHostMarketplaceRemoveArguments(marketplace)] : []), + ]; + for (const args of rollbacks) { + try { + await runHostCommand(runner, identity, host, args, 'removal'); + } catch (rollbackError) { + throw failure( + 'AB7004', + `${errorMessage(error)} Rolling back what this install had registered also failed: ` + + `${errorMessage(rollbackError)}. ${host} still holds registrations no receipt records; run ` + + `${rollbacks.map((remaining) => `\`${host} ${remaining.join(' ')}\``).join(' then ')} before retrying.`, + host, + ); + } + } + throw error; + } + // The receipt lands before the load verdict: the host did install the copy, so a refused one stays + // receipt-owned and `uninstall` removes it without --force. if (host === 'claude') { // `claude plugin install` exits 0 for a plugin Claude then refuses to load (#464): the load verdict // is only in `plugin list --json` `errors`, so verify the fresh row before reporting success. An @@ -545,7 +734,8 @@ export const treeHash = async (root: string): Promise => (await treeInve const cursorManifestCandidates = Object.freeze(['.cursor-plugin/plugin.json', 'plugin.json']); -const readInstalledManifest = async (destination: string): Promise => { +/** The loader manifest identity of an installed Cursor copy (`.cursor-plugin/plugin.json`, then root `plugin.json`). */ +export const readInstalledManifest = async (destination: string): Promise => { for (const manifest of cursorManifestCandidates) { try { const document = JSON.parse(await readFile(join(destination, manifest), 'utf8')) as unknown; @@ -586,7 +776,8 @@ const collisionMessage = ( } }; -const resolveCursorRoot = async (options: InstallBundleOptions): Promise => { +/** `~/.cursor`, which must already exist as a directory: installers never create a Cursor home. */ +export const resolveCursorRoot = async (options: { readonly home?: string }): Promise => { const cursorRoot = join(options.home ?? homedir(), '.cursor'); let cursorMetadata: Awaited>; try { @@ -615,15 +806,45 @@ const installCursorMarketplace = async ( runner: options.commandRunner ?? defaultCommandRunner, treeHash, }); + // The staged repository is a committed Git tree, so its receipt lives in the store beside it; the + // registration records the commit Cursor imports so uninstall can prove the repository is still ours. + const receiptPath = cursorMarketplaceReceiptPath(cursorRoot, identity.plugin); + const previousReceipt = await readInstallReceiptFile(receiptPath); + const artifact = await treeInventory(identity.bundleRoot); + if ( + staged.state === 'staged' || + previousReceipt === undefined || + previousReceipt.contentHash !== artifact.hash || + previousReceipt.registrations[0]?.commit !== staged.commit + ) { + await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ + host: 'cursor', + ...(previousReceipt === undefined ? {} : { installedAt: previousReceipt.installedAt }), + // The committed repository is removed wholesale after a HEAD check; the receipt owns no individual files. + inventory: { files: [], hash: artifact.hash }, + mode: 'marketplace', + plugin: identity.plugin, + registrations: [{ + ...(staged.commit === undefined ? {} : { commit: staged.commit }), + kind: 'cursor-marketplace-staging', + name: staged.marketplace, + }], + scope: 'user', + updatedAt: new Date().toISOString(), + version: identity.version, + })); + } return { bundleRoot: identity.bundleRoot, ...(staged.commit === undefined ? {} : { commit: staged.commit }), + contentHash: artifact.hash, destination: staged.destination, host: 'cursor', marketplace: staged.marketplace, mode: 'marketplace', nextSteps: staged.nextSteps, plugin: identity.plugin, + receipt: receiptPath, state: staged.state, version: identity.version, }; @@ -651,12 +872,27 @@ const installCursor = async ( host: 'cursor', mode: 'local', plugin: identity.plugin, + receipt: join(destination, installReceiptFile), version: identity.version, } as const; try { const artifact = await treeInventory(identity.bundleRoot); + // The receipt records which host directories this installer created on the way to the plugin root + // (a fresh Cursor home has no `plugins/local`), so uninstall can prune exactly those and no more. + const hostDirectories: string[] = []; + for (const relativePath of ['plugins', 'plugins/local']) { + if (!await exists(join(cursorRoot, relativePath))) hostDirectories.push(relativePath); + } await mkdir(installRoot, { recursive: true }); - const receipt = { host: 'cursor', plugin: identity.plugin, version: identity.version } as const; + const receipt: InstallReceiptIdentity = { + host: 'cursor', + hostDirectories, + mode: 'local', + plugin: identity.plugin, + registrations: [{ kind: 'cursor-local-plugin' }], + scope: 'user', + version: identity.version, + }; if (!await exists(destination)) { const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }); try { @@ -669,39 +905,63 @@ const installCursor = async ( if (resolve(identity.bundleRoot) === destination) { return { ...base, contentHash: artifact.hash, state: 'already-installed' }; } - const comparison = await compareInstalledTree({ + const compared = await compareInstalledTree({ artifact, destination, installedManifest: await readInstalledManifest(destination), plugin: identity.plugin, version: identity.version, }); + // `uninstall --keep-data` leaves a shell holding only state/ (plus, normally, a remnant receipt that owns no + // files): a reinstall fills it back in around the preserved durable state instead of refusing it as foreign + // (nothing in it is anyone's plugin content) and reports an install, not a replacement. + const remnant = compared.ownership === 'receipt' && compared.receipt !== undefined + ? isRemnantReceipt(compared.receipt) + : compared.ownership === 'foreign' && await isRuntimeStateRemnant(destination); + const comparison: InstalledTreeComparison = remnant && compared.ownership === 'foreign' + ? { ...compared, ownership: 'legacy', status: 'stale' } + : compared; if (comparison.status === 'current') { if (comparison.ownership === 'legacy' && options.replace === true) { // Adoption created nothing: the legacy copy's directories are not the installer's to prune. await writeInstallReceipt(destination, createInstallReceipt({ + ...receipt, directories: [], - host: 'cursor', + hostDirectories: [], inventory: artifact, - plugin: identity.plugin, - version: identity.version, })); return { ...base, contentHash: artifact.hash, state: 'adopted' }; } + // A receipt-managed identical copy whose receipt predates format/2 is upgraded in place: the + // lifecycle fields are synthesized exactly as the reader migrates them, and nothing else changes. + if (comparison.ownership === 'receipt' && comparison.receipt?.migratedFrom !== undefined) { + await writeInstallReceipt(destination, createInstallReceipt({ + ...receipt, + directories: comparison.receipt.directories, + hostDirectories: comparison.receipt.hostDirectories, + installedAt: comparison.receipt.installedAt, + inventory: artifact, + updatedAt: new Date().toISOString(), + })); + } return { ...base, contentHash: artifact.hash, state: 'already-installed' }; } - const replaceable = comparison.status === 'stale' && comparison.ownership === 'receipt' + const replaceable = (comparison.status === 'stale' && comparison.ownership === 'receipt') || remnant ? true : comparison.status !== 'foreign' && options.replace === true; if (!replaceable) { throw failure('AB7005', collisionMessage(destination, identity, comparison), 'cursor'); } - const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }); + // Replacing an existing copy created no host directories; the previous receipt's carry over. + const replacement: InstallReceiptIdentity = { ...receipt, hostDirectories: comparison.receipt?.hostDirectories ?? [] }; + const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt: replacement, stageRoot: installRoot }); try { - await replaceInstalledTree({ comparison, destination, receipt, staged }); + await replaceInstalledTree({ comparison, destination, receipt: replacement, staged }); } finally { await rm(staged.parent, { force: true, recursive: true }); } + // Filling a state-only shell is a fresh install of plugin content, not a replacement of any. + if (remnant) return { ...base, contentHash: artifact.hash, state: 'installed' }; return { ...base, contentHash: artifact.hash, diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 71ec5bafa..2eb44c450 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -32,7 +32,59 @@ import { exists } from '../core/paths.ts'; /** Sidecar written at an installed plugin root by every agent-bundle installer. */ export const installReceiptFile = '.agent-bundle-install.json'; -export const installReceiptFormat = 'agent-bundle-install-receipt/1'; +/** + * Current receipt format. Format 2 (#101) adds the lifecycle fields — + * `mode`, `scope`, `registrations`, `hostDirectories`, `updatedAt` — that + * `agent-bundle uninstall` consumes; format 1 receipts (#420) are read with + * those fields synthesized (`migratedFrom` names the downgrade) and are + * rewritten as format 2 by the next replacement. + */ +export const installReceiptFormat = 'agent-bundle-install-receipt/2'; + +export const legacyInstallReceiptFormat = 'agent-bundle-install-receipt/1'; + +/** + * How the install was delivered: `local` copies into a host-loaded directory + * (Cursor `plugins/local`), `marketplace` stages a local marketplace repository + * (Cursor Customize import), `host-cli` registered through the host's own + * plugin CLI (Claude, Codex). + */ +export type InstallReceiptMode = 'host-cli' | 'local' | 'marketplace'; + +export type InstallReceiptScope = 'local' | 'project' | 'user'; + +/** + * One host registration the installer performed, recorded so uninstall + * reverses exactly that and nothing else. + */ +export type InstallRegistrationKind = + | 'claude-marketplace' + | 'claude-plugin' + | 'codex-marketplace' + | 'codex-plugin' + | 'cursor-local-plugin' + | 'cursor-marketplace-staging'; + +export const installRegistrationKinds: readonly InstallRegistrationKind[] = Object.freeze([ + 'claude-marketplace', + 'claude-plugin', + 'codex-marketplace', + 'codex-plugin', + 'cursor-local-plugin', + 'cursor-marketplace-staging', +]); + +export interface InstallRegistration { + /** Staged marketplace HEAD commit (`cursor-marketplace-staging`). */ + readonly commit?: string; + /** `@` for plugin registrations. */ + readonly id?: string; + readonly kind: InstallRegistrationKind; + /** Marketplace name for marketplace registrations. */ + readonly name?: string; + /** Claude only: the scope the registration was made at. */ + readonly scope?: InstallReceiptScope; +} /** Root entries owned by generated runtime code; installers never remove or rewrite them. */ export const preservedRuntimeEntries: readonly string[] = Object.freeze(['state']); @@ -80,8 +132,48 @@ export interface InstallReceipt { readonly files: readonly string[]; readonly format: typeof installReceiptFormat; readonly host: string; + /** + * Directories the installer created under the host root (`~/.cursor`) on the + * way to the plugin root (POSIX-relative to the host root, sorted), such as + * `plugins` and `plugins/local` in a fresh Cursor home. Uninstall prunes + * exactly these once they are empty and never a directory the host made. + */ + readonly hostDirectories: readonly string[]; readonly installedAt: string; + /** + * Present only on a receipt read from disk that predates the current + * format: names the format it was read as, and every lifecycle field was + * synthesized (best-effort defaults). Never written. + */ + readonly migratedFrom?: string; + readonly mode: InstallReceiptMode; readonly plugin: string; + /** + * Claude `project` / `local` scope only: the working directory whose + * `.claude/settings*.json` holds the registration (the cwd the host verbs ran + * in). Two projects installing the same plugin at the same scope are two + * installs with two receipts, keyed by this root. + */ + readonly projectRoot?: string; + /** Host registrations the installer performed, in the order it performed them. */ + readonly registrations: readonly InstallRegistration[]; + readonly scope: InstallReceiptScope; + /** When this receipt was last written (install or replacement); `installedAt` is the first install. */ + readonly updatedAt: string; + readonly version: string; +} + +/** The lifecycle identity every receipt writer supplies; inventory and timestamps come from the write. */ +export interface InstallReceiptIdentity { + readonly host: string; + readonly hostDirectories?: readonly string[]; + readonly installedAt?: string; + readonly mode: InstallReceiptMode; + readonly plugin: string; + readonly projectRoot?: string; + readonly registrations: readonly InstallRegistration[]; + readonly scope: InstallReceiptScope; + readonly updatedAt?: string; readonly version: string; } @@ -235,7 +327,7 @@ const isOwnedEntry = async ( * an owned regular file that a rebuild turns into a directory: it leaves as * stale before anything is written beneath it. */ -const assertRealAncestors = async ( +export const assertRealAncestors = async ( root: string, files: readonly string[], ownedFiles: ReadonlySet = new Set(), @@ -341,26 +433,53 @@ const readCursorExpansion = (value: unknown): InstallReceiptCursorExpansion | un }); }; +const isReceiptScope = (value: unknown): value is InstallReceiptScope => + value === 'local' || value === 'project' || value === 'user'; + +const isReceiptMode = (value: unknown): value is InstallReceiptMode => + value === 'host-cli' || value === 'local' || value === 'marketplace'; + +const isRegistrationKind = (value: unknown): value is InstallRegistrationKind => + typeof value === 'string' && (installRegistrationKinds as readonly string[]).includes(value); + +const optionalString = (value: unknown): value is string | undefined => + value === undefined || typeof value === 'string'; + +const readRegistration = (value: unknown): InstallRegistration | undefined => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + if ( + !isRegistrationKind(record['kind']) || + !optionalString(record['commit']) || + !optionalString(record['id']) || + !optionalString(record['name']) || + (record['scope'] !== undefined && !isReceiptScope(record['scope'])) + ) { + return undefined; + } + return Object.freeze({ + ...(record['commit'] === undefined ? {} : { commit: record['commit'] }), + ...(record['id'] === undefined ? {} : { id: record['id'] }), + kind: record['kind'], + ...(record['name'] === undefined ? {} : { name: record['name'] }), + ...(record['scope'] === undefined ? {} : { scope: record['scope'] }), + }); +}; + /** - * Reads the receipt at a plugin root; malformed or unsafe receipts read as - * absent. A receipt that is not a regular file (a symbolic link, a FIFO, a - * device) is refused outright before it is read: it would let another file - * supply the owned-file list that drives deletions, or block the read. + * Validates a parsed receipt document. Format 1 receipts (written by #420 + * for Cursor local copies) are upgraded in memory: `mode: 'local'`, + * `scope: 'user'`, a single `cursor-local-plugin` registration, no host + * directories, and `updatedAt = installedAt`; `migratedFrom` records the + * downgrade so Doctor can diagnose it. Any other format, or a current-format + * receipt missing a field, reads as absent. */ -export const readInstallReceipt = async (destination: string): Promise => { - const path = join(destination, installReceiptFile); - let value: unknown; - try { - if (!(await lstat(path)).isFile()) throw unsupportedEntry(installReceiptFile); - value = JSON.parse(await readFile(path, 'utf8')) as unknown; - } catch (error) { - if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return undefined; - throw error; - } +const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; const record = value as Record; + const format = record['format']; + if (format !== installReceiptFormat && format !== legacyInstallReceiptFormat) return undefined; if ( - record['format'] !== installReceiptFormat || typeof record['plugin'] !== 'string' || typeof record['version'] !== 'string' || typeof record['host'] !== 'string' || @@ -372,7 +491,7 @@ export const readInstallReceipt = async (destination: string): Promise => { + let value: unknown; + try { + if (!(await lstat(path)).isFile()) throw unsupportedEntry(basename(path)); + value = JSON.parse(await readFile(path, 'utf8')) as unknown; + } catch (error) { + if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return undefined; + throw error; + } + return receiptFromDocument(value); +}; + +/** Reads the receipt at a plugin root (`.agent-bundle-install.json`). */ +export const readInstallReceipt = (destination: string): Promise => + readInstallReceiptFile(join(destination, installReceiptFile)); + /** * Builds a receipt for an inventory. `directories` defaults to every ancestor * of the inventoried files — right for a fresh install, where the installer * created all of them; callers that adopt or replace an existing tree pass * exactly the directories they created. */ -export const createInstallReceipt = (options: { +export const createInstallReceipt = (options: InstallReceiptIdentity & { + readonly cursorExpansion?: InstallReceiptCursorExpansion; readonly directories?: readonly string[]; - readonly host: string; - readonly installedAt?: string; readonly inventory: TreeInventory; - readonly plugin: string; - readonly version: string; -}): InstallReceipt => Object.freeze({ - contentHash: options.inventory.hash, - directories: options.directories ?? directoriesOf(options.inventory.files), - files: options.inventory.files, - format: installReceiptFormat, - host: options.host, - installedAt: options.installedAt ?? new Date().toISOString(), - plugin: options.plugin, - version: options.version, -}); - -const receiptDocument = (receipt: InstallReceipt): string => `${stableJson(receipt)}\n`; +}): InstallReceipt => { + const installedAt = options.installedAt ?? new Date().toISOString(); + return Object.freeze({ + contentHash: options.inventory.hash, + ...(options.cursorExpansion === undefined ? {} : { cursorExpansion: options.cursorExpansion }), + directories: options.directories ?? directoriesOf(options.inventory.files), + files: options.inventory.files, + format: installReceiptFormat, + host: options.host, + hostDirectories: Object.freeze(sortNames(options.hostDirectories ?? [])), + installedAt, + mode: options.mode, + plugin: options.plugin, + ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), + registrations: Object.freeze(options.registrations.map((registration) => Object.freeze({ ...registration }))), + scope: options.scope, + updatedAt: options.updatedAt ?? installedAt, + version: options.version, + }); +}; + +/** The on-disk document: `migratedFrom` is a read-time annotation and is never persisted. */ +const receiptDocument = (receipt: InstallReceipt): string => { + const { migratedFrom: _migratedFrom, ...persisted } = receipt; + return `${stableJson({ ...persisted, format: installReceiptFormat })}\n`; +}; /** - * Lands a receipt at a plugin root atomically: an exclusively created, + * Writes a receipt document atomically at `path`: an exclusively created, * randomly named sibling (`wx` never follows an existing link or overwrites * a file) renamed into place. */ -export const writeInstallReceipt = async (destination: string, receipt: InstallReceipt): Promise => { - const temporary = join(destination, `${installReceiptFile}.${randomUUID()}.tmp`); +export const writeInstallReceiptFile = async (path: string, receipt: InstallReceipt): Promise => { + const temporary = `${path}.${randomUUID()}.tmp`; const handle = await open(temporary, 'wx'); try { await handle.writeFile(receiptDocument(receipt), 'utf8'); await handle.close(); - await rename(temporary, join(destination, installReceiptFile)); + await rename(temporary, path); } finally { await rm(temporary, { force: true }); } }; -const hasInstallSurfaceMarkers = async (destination: string): Promise => { +/** Lands a receipt at a plugin root atomically. */ +export const writeInstallReceipt = (destination: string, receipt: InstallReceipt): Promise => + writeInstallReceiptFile(join(destination, installReceiptFile), receipt); + +/** + * Agent Bundle-owned receipt store under a host root (`~/.claude`, + * `~/.codex`, `~/.cursor`) for installs whose plugin tree is host-owned + * (Claude and Codex cache copies) or is a staged marketplace repository + * (Cursor `--mode marketplace`), where a receipt cannot live inside the tree. + * Never inside the host's own `plugins/` directories. + */ +export const installReceiptStoreDirectory = (hostRoot: string): string => join(hostRoot, 'agent-bundle', 'receipts'); + +/** One receipt per plugin and delivery key (`` for host CLIs, `marketplace` for Cursor staging). */ +export const installReceiptStorePath = (hostRoot: string, plugin: string, key: string): string => + join(installReceiptStoreDirectory(hostRoot), `${plugin}.${key}.json`); + +/** + * The delivery key for a host-CLI receipt: the scope alone for `user`, and + * the scope plus a digest of the project root for `project` / `local`, whose + * registrations live in that project's own `.claude/settings*.json`. + */ +export const installReceiptScopeKey = (scope: string, projectRoot: string | undefined): string => + scope === 'user' || projectRoot === undefined + ? scope + : `${scope}.${createHash('sha256').update(projectRoot).digest('hex').slice(0, 12)}`; + +/** Writes a store receipt, creating the store directories as needed. */ +export const writeStoredInstallReceipt = async (path: string, receipt: InstallReceipt): Promise => { + await mkdir(dirname(path), { recursive: true }); + await writeInstallReceiptFile(path, receipt); +}; + +export interface StoredInstallReceipt { + readonly path: string; + readonly receipt: InstallReceipt; +} + +export interface StoredInstallReceiptListing { + /** Every readable receipt in the store, sorted by path. */ + readonly receipts: readonly StoredInstallReceipt[]; + /** + * Store entries that exist but could not be read or parsed as receipts (and + * the store directory itself when it exists but cannot be listed). A caller + * deciding whether another install still depends on something must treat a + * non-empty list as "unknown", never as "no dependents". + */ + readonly unreadable: readonly string[]; +} + +/** Lists a host root's receipt store. A missing store is an empty listing; unreadable entries are reported, not thrown. */ +export const listStoredInstallReceipts = async (hostRoot: string): Promise => { + const directory = installReceiptStoreDirectory(hostRoot); + let entries: readonly string[]; + try { + entries = (await readdir(directory)).sort((left, right) => left.localeCompare(right)); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) { + return Object.freeze({ receipts: Object.freeze([]), unreadable: Object.freeze([]) }); + } + return Object.freeze({ receipts: Object.freeze([]), unreadable: Object.freeze([directory]) }); + } + const receipts: StoredInstallReceipt[] = []; + const unreadable: string[] = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const path = join(directory, entry); + let receipt: InstallReceipt | undefined; + try { + receipt = await readInstallReceiptFile(path); + } catch { + unreadable.push(path); + continue; + } + if (receipt === undefined) unreadable.push(path); + else receipts.push(Object.freeze({ path, receipt })); + } + return Object.freeze({ receipts: Object.freeze(receipts), unreadable: Object.freeze(unreadable) }); +}; + +const rmdirIfEmpty = async (path: string): Promise => { + try { + await rmdir(path); + return true; + } catch (error) { + if (isErrno(error, 'ENOTEMPTY') || isErrno(error, 'ENOENT') || isErrno(error, 'EEXIST') || isErrno(error, 'ENOTDIR')) { + return false; + } + throw error; + } +}; + +/** + * Removes a store receipt and prunes the Agent Bundle-owned store directories + * (`agent-bundle/receipts`, then `agent-bundle`) once they are empty. Returns + * the paths actually removed. + */ +export const removeStoredInstallReceipt = async (path: string, hostRoot: string): Promise => { + const removed: string[] = []; + try { + const metadata = await lstat(path); + if (!metadata.isFile()) throw unsupportedEntry(basename(path)); + await rm(path); + removed.push(path); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + for (const directory of [installReceiptStoreDirectory(hostRoot), join(hostRoot, 'agent-bundle')]) { + if (await rmdirIfEmpty(directory)) removed.push(directory); + } + return Object.freeze(removed); +}; + +/** + * What `removeStoredInstallReceipt` would remove, without touching anything: + * the receipt file when it exists, then each store directory whose every + * remaining entry is itself gone. `--plan` reports exactly this set so the + * plan and the completed result name the same paths. + */ +export const simulateRemoveStoredInstallReceipt = async (path: string, hostRoot: string): Promise => { + const gone = new Set(); + const removed: string[] = []; + try { + if ((await lstat(path)).isFile()) { + gone.add(path); + removed.push(path); + } + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + for (const directory of [installReceiptStoreDirectory(hostRoot), join(hostRoot, 'agent-bundle')]) { + let entries: readonly string[]; + try { + entries = await readdir(directory); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) continue; + throw error; + } + if (entries.every((entry) => gone.has(join(directory, entry)))) { + gone.add(directory); + removed.push(directory); + } + } + return Object.freeze(removed); +}; + +/** Removes an empty directory; reports whether it was removed (non-empty or absent directories are left alone). */ +export const pruneEmptyDirectory = rmdirIfEmpty; + +/** + * A destination that holds nothing but runtime roots (`state/`) — and at most + * a remnant receipt — is what `uninstall --keep-data` leaves behind: not a + * foreign directory, but an empty shell around preserved durable state that a + * reinstall fills back in. + */ +export const isRuntimeStateRemnant = async (destination: string): Promise => { + const entries = (await readdir(destination)).filter((name) => name !== installReceiptFile); + return entries.length > 0 && entries.every(isPreservedRuntimeRoot); +}; + +/** + * A remnant receipt owns no files and records no registrations: `uninstall` + * writes it when the plugin root survives (retained runtime state or unowned + * entries) so the host directories the install created stay receipt-owned for + * a later purge and Doctor can explain the directory instead of calling it + * corrupt. + */ +export const isRemnantReceipt = (receipt: InstallReceipt): boolean => + receipt.files.length === 0 && receipt.registrations.length === 0; + +/** `sha256` of an empty owned set: what `hashOwnedFiles(root, [])` yields, and what a remnant receipt records. */ +export const emptyContentHash = createHash('sha256').digest('hex'); + +export const hasInstallSurfaceMarkers = async (destination: string): Promise => { for (const marker of installSurfaceMarkerFiles) { if (!await exists(join(destination, marker))) return false; } @@ -529,7 +889,7 @@ export interface StagedArtifact { export const stageArtifact = async (options: { readonly artifactRoot: string; readonly destination: string; - readonly receipt: { readonly host: string; readonly installedAt?: string; readonly plugin: string; readonly version: string }; + readonly receipt: InstallReceiptIdentity; readonly stageRoot: string; }): Promise => { const parent = await mkdtemp(join(options.stageRoot, `.${basename(options.destination)}.stage-`)); @@ -689,7 +1049,7 @@ const previouslyOwnedDirectories = (comparison: InstalledTreeComparison): readon export const replaceInstalledTree = async (options: { readonly comparison: InstalledTreeComparison; readonly destination: string; - readonly receipt: { readonly host: string; readonly installedAt?: string; readonly plugin: string; readonly version: string }; + readonly receipt: InstallReceiptIdentity; readonly staged: StagedArtifact; }): Promise => { if (options.comparison.ownership === 'foreign') { @@ -739,13 +1099,24 @@ export const replaceInstalledTree = async (options: { } // The receipt owns what the installer owns now: the surviving directories it created before plus // the ones this replacement created. It is finalised in the private staging copy, then committed. + // The first install time and the host directories the first install created carry over from the + // previous receipt; `updatedAt` is this replacement. const directories = sortNames([...new Set([ ...[...ownedDirectories].filter((directory) => !pruned.has(directory)), ...created, ])]); + const previous = options.comparison.receipt; + const now = new Date().toISOString(); await writeFile( join(options.staged.root, installReceiptFile), - receiptDocument(createInstallReceipt({ ...options.receipt, directories, inventory: options.staged.inventory })), + receiptDocument(createInstallReceipt({ + ...options.receipt, + directories, + hostDirectories: options.receipt.hostDirectories ?? previous?.hostDirectories ?? [], + installedAt: options.receipt.installedAt ?? previous?.installedAt ?? now, + inventory: options.staged.inventory, + updatedAt: options.receipt.updatedAt ?? now, + })), 'utf8', ); await rename(join(options.staged.root, installReceiptFile), join(options.destination, installReceiptFile)); diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 9418afbe6..613f11b86 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -8,7 +8,9 @@ import { import { installReceiptFile, installReceiptFormat, + installRegistrationKinds, installSurfaceMarkerFiles, + legacyInstallReceiptFormat, preservedRuntimeEntries, } from './receipt.ts'; @@ -56,6 +58,22 @@ const claudeInstructions = (model: NormalizedPlugin): string[] => [ '`agent-bundle install claude --from ./` runs this sequence automatically when the installed', 'copy has the same version but a different content hash; `--replace` (alias `--force`) forces it.', '', + '### Uninstall', + '', + '```sh', + 'agent-bundle uninstall claude --from ./ --plan # print exactly what would be removed', + 'agent-bundle uninstall claude --from ./ # reverse the recorded registrations', + '```', + '', + `\`agent-bundle install claude\` records a receipt at \`~/.claude/agent-bundle/receipts/${model.metadata.name}.${marketplaceName(model)}.user.json\``, + '(or under `$CLAUDE_CONFIG_DIR`; `user` is the install scope, pass `--scope project` or `--scope local` to match', + 'a scoped install). `uninstall` consumes it: `claude plugin uninstall ' + + `${pluginId(model)} --scope user --keep-data\` first, then \`claude plugin marketplace remove ${marketplaceName(model)}\``, + 'unless another installed plugin still uses that marketplace. Durable runtime state is kept by default (Claude', + 'orphans the cached copy, `state/` included, for its ~14-day grace period); `--purge-data --confirm-purge`', + 'removes `state/` and `~/.claude/plugins/data//` immediately. A missing receipt or a cached copy that no', + 'longer matches it is refused unless `--force`; a second run is a `not-installed` no-op.', + '', ]; const codexInstructions = (model: NormalizedPlugin): string[] => [ @@ -82,6 +100,21 @@ const codexInstructions = (model: NormalizedPlugin): string[] => [ '`agent-bundle install codex --from ./` runs this sequence automatically when the installed', 'copy has the same version but a different content hash; `--replace` (alias `--force`) forces it.', '', + '### Uninstall', + '', + '```sh', + 'agent-bundle uninstall codex --from ./ --plan # print exactly what would be removed', + 'agent-bundle uninstall codex --from ./ # reverse the recorded registrations', + '```', + '', + `\`agent-bundle install codex\` records a receipt at \`~/.codex/agent-bundle/receipts/${model.metadata.name}.${marketplaceName(model)}.user.json\` (or`, + `under \`$CODEX_HOME\`). \`uninstall\` consumes it: \`codex plugin remove ${pluginId(model)}\` first, then`, + `\`codex plugin marketplace remove ${marketplaceName(model)}\` unless another installed plugin still uses that`, + 'marketplace. Codex 0.147.0 deletes the cached plugin tree, `state/` included, on `plugin remove` and has no', + 'keep-data option, so `--keep-data` cannot preserve durable state on Codex; the result says so (`unavailable`).', + 'A missing receipt or a cached copy that no longer matches it is refused unless `--force`; a second run is a', + '`not-installed` no-op.', + '', ]; const cursorInstructions = (model: NormalizedPlugin): string[] => [ @@ -116,6 +149,28 @@ const cursorInstructions = (model: NormalizedPlugin): string[] => [ 'plugin is always refused with an installed-versus-artifact content-hash comparison; remove it', 'manually. `agent-bundle install cursor --from ./ [--replace]` applies the same policy.', '', + '### Uninstall', + '', + '```sh', + 'node ./install.mjs --uninstall --plan # print exactly what would be removed', + 'node ./install.mjs --uninstall # remove the receipt-owned files; keep state/', + 'node ./install.mjs --uninstall --purge-data --confirm-purge # also remove durable runtime state', + 'node ./install.mjs --uninstall --mode marketplace # remove a staged marketplace repository', + '```', + '', + 'Uninstall removes exactly what the receipt owns: the listed files, the directories the installer', + `created (including \`~/.cursor/plugins/local\` when the installer made it), and nothing else. Durable`, + 'runtime state under `state/` (state kernel, notices journal) — and, for an Agent Plugins pack with a stdio', + 'server, the `~/.cursor/agent-bundle/plugin-data/` directory the receipt records as `PLUGIN_DATA` — is kept', + 'unless `--purge-data --confirm-purge` is passed (a kept data directory leaves a remnant receipt behind so a later', + 'purge still finds it; an empty one is pruned); unowned files are left in place and listed. A directory without a', + 'receipt is refused unless', + '`--force` (which removes a pre-receipt legacy copy by its inventory); owned content that no longer matches', + 'the receipt is refused unless `--force`; a directory that is not this plugin\'s install is always refused.', + 'A second run is a `Not installed` no-op. `agent-bundle uninstall cursor --from ./ [--mode marketplace]`', + 'applies the same policy, and `agent-bundle doctor --from ./` shows the lifecycle stage', + '(placed, registered, enabled, active) with unobservable stages typed `unavailable`.', + '', '### Marketplace plugin', '', '```sh', @@ -166,6 +221,19 @@ const portableInstructions = (): string[] => [ 'comparison. For Codex and other native clients, remove and re-add the plugin through the client', 'when only content changed at the same version.', '', + '### Uninstall', + '', + '```sh', + 'node ./install.mjs --uninstall --plan # print exactly what would be removed', + 'node ./install.mjs --uninstall # remove the receipt-owned files; keep state/', + 'node ./install.mjs --uninstall --purge-data --confirm-purge # also remove durable runtime state', + '```', + '', + 'Uninstall removes exactly what the receipt owns (files, installer-created directories) and keeps', + 'durable runtime state under `state/` (and the recorded `PLUGIN_DATA` directory of an Agent Plugins pack)', + 'unless `--purge-data --confirm-purge` is passed. A missing', + 'receipt or modified owned content is refused unless `--force`; foreign directories are always refused.', + '', ]; const installMarkdown = (model: NormalizedPlugin, target: BuiltInTarget): string => { @@ -190,6 +258,334 @@ const installMarkdown = (model: NormalizedPlugin, target: BuiltInTarget): string return [...header(model), ...sections].join('\n'); }; +/** + * The `--uninstall` half of the standalone installer, mirroring + * `install/uninstall.ts` for the two Cursor deliveries: a receipt naming this + * plugin bounds what leaves (owned files, installer-created directories, the + * host directories it created), owned content must hash to the receipt unless + * `--force`, a pre-receipt legacy copy needs `--force`, foreign directories + * are refused regardless, `state/` and the receipt's recorded `PLUGIN_DATA` + * directory are kept unless `--purge-data --confirm-purge` (a kept data + * directory leaves a remnant receipt that records it), `--plan` prints the + * exact paths and writes nothing, and a second run is a + * `Not installed` no-op. + */ +const cursorUninstallerSource = (): readonly string[] => [ + '// Unowned entries under root that survive the uninstall, POSIX-relative: files that are neither owned nor runtime state', + '// (symlinks listed, never followed) plus unowned directories holding nothing retained (`name/`), which the prune never touches.', + 'const listRetained = async (root, owned, ownedDirectories) => {', + ' const retained = [];', + " const visit = async (relativePath) => {", + ' let entries;', + " try { entries = (await readdir(join(root, relativePath))).sort((left, right) => left.localeCompare(right)); }", + " catch (error) { if (error?.code === 'ENOENT') return 0; throw error; }", + ' let kept = 0;', + ' for (const name of entries) {', + " const child = relativePath === '' ? name : `${relativePath}/${name}`;", + " if (relativePath === '' && (name === receiptFile || isPreservedRoot(name))) continue;", + ' const metadata = await lstat(join(root, child));', + ' if (metadata.isDirectory() && !metadata.isSymbolicLink()) {', + ' const below = await visit(child);', + ' if (below === 0 && !ownedDirectories.has(child)) retained.push(`${child}/`);', + ' kept += below === 0 && !ownedDirectories.has(child) ? 1 : below;', + ' continue;', + ' }', + ' if (!owned.has(child)) { retained.push(child); kept += 1; }', + ' }', + ' return kept;', + ' };', + " await visit('');", + ' return retained;', + '};', + '// HEAD of a staged repository from its ref text (no git needed): the commit Cursor imports.', + 'const readHead = async (repoRoot) => {', + ' try {', + " const head = (await readFile(join(repoRoot, '.git', 'HEAD'), 'utf8')).trim();", + " if (!head.startsWith('ref: ')) return /^[0-9a-f]{40}$|^[0-9a-f]{64}$/u.test(head) ? head : undefined;", + " const ref = head.slice('ref: '.length);", + ' let resolved;', + " try { resolved = (await readFile(join(repoRoot, '.git', ref), 'utf8')).trim(); }", + " catch { resolved = (await readFile(join(repoRoot, '.git', 'packed-refs'), 'utf8')).split('\\n').find((line) => line.endsWith(` ${ref}`))?.split(' ')[0]; }", + ' return resolved !== undefined && /^[0-9a-f]{40}$|^[0-9a-f]{64}$/u.test(resolved) ? resolved : undefined;', + ' } catch { return undefined; }', + '};', + 'const printPaths = (label, paths) => {', + ' console.log(`${label} ${paths.length} ${paths.length === 1 ? "entry" : "entries"}:`);', + ' for (const path of paths) console.log(` ${path}`);', + '};', + '', + "if (uninstall && mode === 'local') {", + ' const notInstalled = () => { console.log(`Not installed ${pluginName}@${pluginVersion} for cursor (local mode) at ${destination}`); process.exit(0); };', + ' if (!(await exists(cursorRoot)) || !(await exists(destination))) notInstalled();', + ' const destinationMetadata = await lstat(destination);', + " if (destinationMetadata.isSymbolicLink() || !destinationMetadata.isDirectory()) throw unsupported('.');", + ' const receipt = await readReceipt(destination);', + ' let owned;', + ' let ownedDirectories;', + ' let hostDirectories;', + ' let receiptStatus;', + ' if (receipt === undefined) {', + ' // No receipt: only a legacy layout (emitted install surface + manifest naming this plugin) may go, under --force.', + ' const manifest = await readManifest(destination);', + ' const legacy = manifest?.name === pluginName && await hasMarkers(destination);', + ' if (!legacy) {', + ' throw new Error(`Refusing to uninstall foreign directory ${destination}: it carries no install receipt and is not a ` +', + ' `recognizable agent-bundle install of ${pluginName}${manifest === undefined ? " (no loader manifest)" : ` (manifest names ${JSON.stringify(manifest.name)})`}. ` +', + " 'Remove it manually if it is stale; --force does not apply to foreign directories.');", + ' }', + ' if (!force) {', + ' throw new Error(`Refusing to uninstall ${destination} without an install receipt: this copy predates install receipts, so ownership ` +', + " 'cannot be proven. Re-run with --force to remove its inventoried plugin files (runtime state under state/ is kept unless ' +", + " '--purge-data --confirm-purge is passed), or reinstall with --replace first to adopt it.');", + ' }', + ' const tree = await inventory(destination);', + ' owned = tree.files;', + ' ownedDirectories = directoriesOf(tree.files);', + ' hostDirectories = [];', + " receiptStatus = 'forced-legacy';", + ' } else {', + ' if (receipt.plugin !== pluginName) {', + ' throw new Error(`Refusing to uninstall ${destination}: its install receipt names plugin ${JSON.stringify(receipt.plugin)}, not ` +', + ' `${JSON.stringify(pluginName)}. Uninstall that plugin from its own bundle instead; --force does not apply.`);', + ' }', + ' const installedHash = await hashOwned(destination, receipt.files);', + " receiptStatus = receipt.migratedFrom === undefined ? 'consumed' : 'migrated';", + ' if (installedHash !== receipt.contentHash) {', + ' if (!force) {', + ' throw new Error(`Refusing to uninstall ${destination}: the owned files hash ${short(installedHash)} but the receipt recorded ` +', + " `${short(receipt.contentHash)}, so the installed copy was modified after installation. Re-run with --force to remove the ` +", + " 'receipt-owned files anyway (unowned entries are never removed).');", + ' }', + " receiptStatus = 'forced-mismatch';", + ' }', + ' owned = receipt.files;', + ' ownedDirectories = receipt.directories;', + ' hostDirectories = receipt.hostDirectories;', + ' }', + ' // A symlinked ancestor would let a leaf-only delete reach outside the plugin root: refused before any change.', + ' await assertRealAncestors(destination, owned);', + ' const files = [];', + ' for (const file of owned) {', + ' const path = join(destination, file);', + ' let metadata;', + " try { metadata = await lstat(path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }", + ' if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupported(file);', + ' files.push(path);', + ' }', + ' if (await exists(join(destination, receiptFile))) files.push(join(destination, receiptFile));', + " const stateDirectory = join(destination, 'state');", + ' let stateMetadata;', + " try { stateMetadata = await lstat(stateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", + " if (stateMetadata !== undefined && (stateMetadata.isSymbolicLink() || !stateMetadata.isDirectory())) throw unsupported('state');", + ' const dataPaths = stateMetadata === undefined ? [] : [stateDirectory];', + " const dataKinds = stateMetadata === undefined ? [] : ['state/ (state kernel, notices journal)'];", + ' // The receipt\'s cursorExpansion records the PLUGIN_DATA directory this installer created for the copy (spec 9.1). Only', + ' // the directory at this home\'s own plugin-data location is receipt-owned; a written one is durable state (kept or', + ' // purged like state/), an empty one is an installer-created directory that is pruned, a recorded path elsewhere is left alone.', + ' const recordedPluginData = receipt?.cursorExpansion?.pluginData;', + ' const pluginDataRecorded = recordedPluginData === pluginData;', + ' let emptyPluginData;', + ' let foreignNote = "";', + ' if (recordedPluginData !== undefined && !pluginDataRecorded) {', + ' foreignNote = ` The receipt records PLUGIN_DATA at ${recordedPluginData}, outside this home\'s agent-bundle/plugin-data; it is not touched.`;', + ' } else if (pluginDataRecorded) {', + ' let pluginDataMetadata;', + " try { pluginDataMetadata = await lstat(pluginData); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", + ' if (pluginDataMetadata !== undefined) {', + " if (pluginDataMetadata.isSymbolicLink() || !pluginDataMetadata.isDirectory()) throw unsupported(relative(cursorRoot, pluginData));", + ' if ((await readdir(pluginData)).length === 0) emptyPluginData = pluginData;', + ' else { dataPaths.push(pluginData); dataKinds.push(`the PLUGIN_DATA directory ${pluginData}`); }', + ' }', + ' }', + " const dataOutcome = dataPaths.length === 0 ? 'absent' : purgeData ? 'purged' : 'kept';", + ' const dataDetail = dataPaths.length === 0', + " ? `No durable runtime state exists (no state/ under the installed plugin root${emptyPluginData === undefined ? '' : `; the installer-created PLUGIN_DATA directory ${emptyPluginData} is empty and is pruned`}).${foreignNote}`", + ' : purgeData', + " ? `Durable runtime state — ${dataKinds.join(' and ')} — is removed (--purge-data --confirm-purge).${foreignNote}`", + " : `Durable runtime state — ${dataKinds.join(' and ')} — is kept; pass --purge-data --confirm-purge to remove it.${foreignNote}`;", + ' // A written PLUGIN_DATA kept by --keep-data lives outside the plugin root, so the root stays (with a remnant receipt', + ' // carrying the expansion) to keep that data receipt-owned for a later purge.', + ' const keepRoot = !purgeData && dataPaths.includes(pluginData);', + ' const directories = [', + ' ...ownedDirectories.map((directory) => join(destination, directory)),', + ' ...(keepRoot ? [] : [destination]),', + ' ...hostDirectories.map((directory) => join(cursorRoot, directory)),', + ' ...(emptyPluginData === undefined ? [] : [emptyPluginData]),', + " ...(pluginDataRecorded ? [join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle')] : []),", + ' ].sort((left, right) => right.length - left.length || left.localeCompare(right));', + ' const ownedSet = new Set(owned);', + ' const ownedDirectorySet = new Set(ownedDirectories);', + ' const remnantOnly = receipt !== undefined && receipt.files.length === 0 && receipt.registrations.length === 0;', + ' const purging = purgeData && dataPaths.length > 0;', + ' // A keep-data rerun over a remnant is the documented no-op; an explicit purge consumes the remnant even without state/.', + ' if (remnantOnly && !purgeData && files.length === 1 && files[0] === join(destination, receiptFile)) {', + ' // A rerun over what an earlier --keep-data uninstall left behind, still keeping the data: nothing to remove, so the', + ' // remnant receipt stays and the run is the documented no-op.', + ' console.log(`Not installed ${pluginName}@${pluginVersion} for cursor (local mode) at ${destination}`);', + ' console.log(`Receipt: remnant (${join(destination, receiptFile)}) — only preserved runtime state remains from an earlier --uninstall --keep-data; pass --purge-data --confirm-purge to remove it.`);', + ' process.exit(0);', + ' }', + ' const purgedDirectories = purging ? dataPaths : [];', + ' const summary = (verb) => {', + " console.log(`${verb} ${pluginName}@${pluginVersion} for cursor (local mode) at ${destination}${force ? ' [--force]' : ''}`);", + ' console.log(`Receipt: ${receiptStatus} (${join(destination, receiptFile)})`);', + ' console.log(remnantOnly', + " ? 'Registration cursor-local-plugin: already-absent — Only preserved runtime state remained from an earlier --uninstall --keep-data; no plugin content was registered.'", + " : `Registration cursor-local-plugin: ${verb === 'Would uninstall' ? 'planned' : 'removed'} — Cursor loads plugins/local/ directly; removing the directory unregisters the plugin at the next window reload.`);", + ' };', + ' if (plan) {', + " summary('Would uninstall');", + " printPaths('Would remove file', files);", + ' // Exactly the directories the run would prune: deepest first, once everything they hold is itself removed.', + ' const gone = new Set([...files, ...purgedDirectories]);', + ' const prunable = [];', + ' for (const directory of directories) {', + ' let entries;', + " try { entries = await readdir(directory); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue; throw error; }", + ' if (entries.every((entry) => gone.has(join(directory, entry)))) { gone.add(directory); prunable.push(directory); }', + ' }', + " printPaths('Would remove directory', [...purgedDirectories, ...prunable]);", + " console.log(`Data (${purgeData ? 'purge' : 'keep'}): ${dataOutcome} — ${dataDetail}`);", + ' for (const path of dataPaths) console.log(` ${path}`);', + ' const retained = await listRetained(destination, ownedSet, ownedDirectorySet);', + " if (retained.length > 0) printPaths(`Retained unowned under ${destination}:`, retained);", + ' if (!prunable.includes(destination)) console.log(`Remnant receipt (would be written): ${join(destination, receiptFile)} — owns no files; keeps the created host directories receipt-owned for a later purge.`);', + ' process.exit(0);', + ' }', + ' for (const path of files) await rm(path, { force: true });', + ' for (const path of purgedDirectories) await rm(path, { force: true, recursive: true });', + ' const pruned = [];', + ' for (const directory of directories) if (await rmdirIfEmpty(directory)) pruned.push(directory);', + " summary('Uninstalled');", + " printPaths('Removed file', files);", + " printPaths('Removed directory', [...purgedDirectories, ...pruned]);", + " console.log(`Data (${purgeData ? 'purge' : 'keep'}): ${dataOutcome} — ${dataDetail}`);", + ' for (const path of dataPaths) console.log(` ${path}`);', + ' const retained = await exists(destination) ? await listRetained(destination, ownedSet, ownedDirectorySet) : [];', + " if (retained.length > 0) printPaths(`Retained unowned under ${destination}:`, retained);", + ' if (await exists(destination)) {', + ' // The plugin root survives (retained runtime state or unowned entries): a remnant receipt owning no files keeps the', + ' // created host directories receipt-owned for a later purge and lets Doctor explain the directory; a reinstall fills it in.', + " await writeReceiptFile(join(destination, receiptFile), receiptFor({ files: [], hash: createHash('sha256').digest('hex') }, {", + ' // A kept PLUGIN_DATA directory stays receipt-owned through the remnant\'s expansion record.', + ' ...(keepRoot && receipt?.cursorExpansion !== undefined ? { cursorExpansion: receipt.cursorExpansion } : {}),', + ' directories: [], hostDirectories, installedAt: receipt?.installedAt, registrations: [],', + ' }));', + ' console.log(`Remnant receipt: ${join(destination, receiptFile)} — owns no files; keeps the created host directories receipt-owned for a later purge.`);', + ' }', + ' process.exit(0);', + '}', + '', + "if (uninstall && mode === 'marketplace') {", + ' const receipt = await exists(cursorRoot) ? await readReceiptFile(marketplaceReceipt) : undefined;', + ' const repoExists = await exists(cursorRoot) && await exists(marketplaceRepo);', + ' if (repoExists) {', + ' const repoMetadata = await lstat(marketplaceRepo);', + " if (repoMetadata.isSymbolicLink() || !repoMetadata.isDirectory()) throw unsupported(relative(cursorRoot, marketplaceRepo));", + ' }', + ' if (!repoExists && receipt === undefined) {', + ' console.log(`Not installed ${pluginName}@${pluginVersion} for cursor (marketplace mode) at ${marketplaceRepo}`);', + ' process.exit(0);', + ' }', + " let receiptStatus = receipt === undefined ? 'forced-missing' : receipt.migratedFrom === undefined ? 'consumed' : 'migrated';", + " const recorded = receipt?.registrations.find((registration) => registration.kind === 'cursor-marketplace-staging');", + ' if (repoExists) {', + ' if (receipt === undefined) {', + ' if (!force) {', + ' throw new Error(`Refusing to remove staged Cursor marketplace ${marketplaceRepo} without an install receipt at ${marketplaceReceipt}. ` +', + " 'Re-run with --force to remove it after verifying it is this plugin\\'s staging, or rerun `node install.mjs --mode marketplace` to record a receipt first.');", + ' }', + ' let stagedName;', + " try { stagedName = JSON.parse(await readFile(join(marketplaceRepo, '.cursor-plugin', 'marketplace.json'), 'utf8'))?.name; }", + " catch (error) { if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; }", + ' const stagedPlugin = await readManifest(marketplacePlugin);', + ' if (stagedName !== `${pluginName}-marketplace` || stagedPlugin?.name !== pluginName) {', + ' throw new Error(`Refusing to remove ${marketplaceRepo}: it is not a staged Agent Bundle marketplace for ${pluginName} ` +', + ' `(expected .cursor-plugin/marketplace.json naming ${pluginName}-marketplace and plugins/${pluginName}). --force does not apply.`);', + ' }', + ' } else if (receipt.plugin !== pluginName) {', + ' throw new Error(`Refusing to remove ${marketplaceRepo}: the receipt at ${marketplaceReceipt} names plugin ${JSON.stringify(receipt.plugin)}.`);', + ' } else if (recorded?.commit !== undefined) {', + ' const head = await readHead(marketplaceRepo);', + ' if (head !== recorded.commit) {', + ' if (!force) {', + " throw new Error(`Refusing to remove ${marketplaceRepo}: its HEAD is ${head ?? 'unresolvable'} but the receipt recorded commit ` +", + ' `${recorded.commit}, so the staged repository changed after staging. Re-run with --force to remove it anyway.`);', + ' }', + " receiptStatus = 'forced-mismatch';", + ' } else {', + ' // HEAD matching the receipt proves the commit, not the working tree: entries added since staging are not', + ' // receipt-owned and the removal below is recursive. Unverifiable (no git, status failing) is dirt too.', + ' const dirt = await new Promise((resolvePromise) => {', + " execFile('git', ['--no-optional-locks', 'status', '--porcelain', '--untracked-files=all', '--ignored=matching'], { cwd: marketplaceRepo }, (error, stdout, stderr) => {", + " if (error?.code === 'ENOENT') { resolvePromise('git is not available on PATH, so the staged working tree cannot be verified against the receipted commit.'); return; }", + ' if (error) { resolvePromise(`\\`git status\\` failed in the staged repository (${(stderr || error.message).trim()}), so its working tree cannot be verified against the receipted commit.`); return; }', + " const entries = stdout.split('\\n').map((line) => line.trimEnd()).filter((line) => line !== '');", + ' if (entries.length === 0) { resolvePromise(undefined); return; }', + " const shown = entries.slice(0, 5).map((line) => JSON.stringify(line.slice(3))).join(', ');", + " resolvePromise(`its working tree differs from the receipted commit (${entries.length} uncommitted, untracked, or ignored ${entries.length === 1 ? 'entry' : 'entries'}: ${shown}${entries.length > 5 ? ', …' : ''}) that the receipt does not own.`);", + ' });', + ' });', + ' if (dirt !== undefined) {', + ' if (!force) {', + ' throw new Error(`Refusing to remove ${marketplaceRepo}: ${dirt} Move those entries out (or commit them and rerun ` +', + " '`node install.mjs --mode marketplace`), or re-run with --force to remove them anyway.');", + ' }', + " receiptStatus = 'forced-mismatch';", + ' }', + ' }', + ' }', + ' }', + ' // A completed copy from this staging under Cursor\'s cache means Cursor imported it; that copy is Cursor-owned.', + " const cacheSegment = (value) => value.replaceAll(/[^A-Za-z0-9._-]/gu, '-');", + ' // Whether the staged repository still exists or not: the receipt\'s commit is what identifies the imported copy.', + ' let imported = false;', + ' if (recorded?.commit !== undefined) {', + " imported = await exists(join(cursorRoot, 'plugins', 'cache', cacheSegment(`${pluginName}-marketplace`), cacheSegment(pluginName), cacheSegment(recorded.commit), '.cache-complete'));", + ' }', + ' const files = await exists(marketplaceReceipt) ? [marketplaceReceipt] : [];', + ' const directories = repoExists ? [marketplaceRepo] : [];', + ' const summary = (verb) => {', + " console.log(`${verb} ${pluginName}@${pluginVersion} for cursor (marketplace mode) at ${marketplaceRepo}${force ? ' [--force]' : ''}`);", + ' console.log(`Receipt: ${receiptStatus} (${marketplaceReceipt})`);', + " console.log(`Registration cursor-marketplace-staging ${pluginName}-marketplace: ${repoExists ? (verb === 'Would uninstall' ? 'planned' : 'removed') : 'already-absent'}`);", + ' if (imported) {', + ' console.log(`Registration cursor-marketplace-staging ${pluginName}-marketplace: manual — Cursor imported this marketplace; its installed-plugin registry is server-assigned and exposes no non-interactive removal verb.`);', + ' }', + ' };', + ' const finish = (verb, removedDirectories) => {', + " printPaths(verb === 'Would uninstall' ? 'Would remove file' : 'Removed file', files);", + " printPaths(verb === 'Would uninstall' ? 'Would remove directory' : 'Removed directory', removedDirectories);", + " console.log(`Data (${purgeData ? 'purge' : 'keep'}): unavailable — A staged marketplace repository holds no runtime state; a copy Cursor imported from it is Cursor-owned and is not touched.`);", + ' if (imported) {', + " console.log('Next steps:');", + ' console.log(` 1. Open Cursor, then Customize -> Plugins, and uninstall "${pluginName}" (marketplace ${pluginName}-marketplace) there.`);', + ' }', + ' };', + ' if (plan) {', + ' // The plan names exactly what the run would prune: each parent below is removed only once every entry in it is gone.', + ' const gone = new Set([...files, ...directories]);', + ' const prunable = [];', + " for (const directory of [receiptsRoot, marketplaceRoot, join(cursorRoot, 'agent-bundle')]) {", + ' let entries;', + " try { entries = await readdir(directory); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue; throw error; }", + ' if (entries.every((entry) => gone.has(join(directory, entry)))) { gone.add(directory); prunable.push(directory); }', + ' }', + " summary('Would uninstall');", + " finish('Would uninstall', [...directories, ...prunable]);", + ' process.exit(0);', + ' }', + ' if (repoExists) await rm(marketplaceRepo, { force: true, recursive: true });', + ' for (const path of files) await rm(path, { force: true });', + ' const pruned = [...directories];', + " for (const directory of [receiptsRoot, marketplaceRoot, join(cursorRoot, 'agent-bundle')]) if (await rmdirIfEmpty(directory)) pruned.push(directory);", + " summary('Uninstalled');", + " finish('Uninstalled', pruned);", + ' process.exit(0);', + '}', + '', +]; + /** * The standalone Cursor safe-copy installer. It carries no imports from * agent-bundle, so it mirrors the receipt policy of `install/receipt.ts` @@ -214,6 +610,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { `const pluginVersion = ${version};`, `const receiptFile = ${JSON.stringify(installReceiptFile)};`, `const receiptFormat = ${JSON.stringify(installReceiptFormat)};`, + `const legacyReceiptFormat = ${JSON.stringify(legacyInstallReceiptFormat)};`, `const preservedEntries = ${JSON.stringify(preservedRuntimeEntries)};`, '// Runtime roots match case-insensitively: on case-insensitive filesystems State/ is state/.', 'const isPreservedRoot = (name) => preservedEntries.includes(String(name).toLowerCase());', @@ -227,14 +624,28 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "const marketplacePlugin = join(marketplaceRepo, 'plugins', pluginName);", "// Agent Plugins 1.0.0 §9.1 PLUGIN_DATA for the Cursor copy: a writable, install-independent directory the installer creates.", "const pluginData = join(cursorRoot, 'agent-bundle', 'plugin-data', pluginName);", - "const usage = 'Usage: node install.mjs [--mode local|marketplace] [--replace|--force] [--help]';", + "const receiptsRoot = join(cursorRoot, 'agent-bundle', 'receipts');", + 'const marketplaceReceipt = join(receiptsRoot, `${pluginName}.marketplace.json`);', + "const usage = 'Usage: node install.mjs [--mode local|marketplace] [--replace|--force] [--help]\\n" + + " node install.mjs --uninstall [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan]';", '', 'let replace = false;', + 'let force = false;', + 'let uninstall = false;', + 'let plan = false;', + 'let keepData = false;', + 'let purgeData = false;', + 'let confirmPurge = false;', "let mode = 'local';", 'const argv = process.argv.slice(2);', 'for (let index = 0; index < argv.length; index += 1) {', ' const argument = argv[index];', - " if (argument === '--replace' || argument === '--force') { replace = true; continue; }", + " if (argument === '--replace' || argument === '--force') { replace = true; force = argument === '--force'; continue; }", + " if (argument === '--uninstall') { uninstall = true; continue; }", + " if (argument === '--plan') { plan = true; continue; }", + " if (argument === '--keep-data') { keepData = true; continue; }", + " if (argument === '--purge-data') { purgeData = true; continue; }", + " if (argument === '--confirm-purge') { confirmPurge = true; continue; }", " if (argument === '--mode') {", ' mode = argv[index + 1];', " if (mode !== 'local' && mode !== 'marketplace') { console.error(`Install mode must be local or marketplace.\\n${usage}`); process.exit(2); }", @@ -245,6 +656,15 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' console.error(`Unknown installer argument ${JSON.stringify(argument)}.\\n${usage}`);', ' process.exit(2);', '}', + 'if (!uninstall && (plan || keepData || purgeData || confirmPurge)) {', + " console.error(`--plan, --keep-data, --purge-data, and --confirm-purge apply to --uninstall only.\\n${usage}`);", + ' process.exit(2);', + '}', + 'if (uninstall && purgeData && keepData) { console.error(`--keep-data and --purge-data are mutually exclusive.\\n${usage}`); process.exit(2); }', + 'if (uninstall && purgeData && !confirmPurge) {', + " console.error(`--purge-data deletes the plugin's durable runtime state (state kernel, notices journal) and requires --confirm-purge; omit both flags to keep the data.\\n${usage}`);", + ' process.exit(2);', + '}', '', 'const exists = async (path) => {', ' try { await lstat(path); return true; }', @@ -379,20 +799,53 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' [...segment].every((character) => character.charCodeAt(0) >= 0x20) &&', " !segment.endsWith('.') && !segment.endsWith(' '));", '', - 'const readReceipt = async (root) => {', + `const registrationKinds = ${JSON.stringify(installRegistrationKinds)};`, + "const isScope = (value) => value === 'local' || value === 'project' || value === 'user';", + "const isRegistration = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) &&", + ' registrationKinds.includes(value.kind) &&', + " (value.commit === undefined || typeof value.commit === 'string') && (value.id === undefined || typeof value.id === 'string') &&", + " (value.name === undefined || typeof value.name === 'string') && (value.scope === undefined || isScope(value.scope));", + '// Same shape check as the core reader: a receipt missing any field reads as absent. A format/1 receipt (#420)', + '// is read with its lifecycle fields synthesized (local mode, user scope, one cursor-local-plugin registration,', + '// no host directories) and `migratedFrom` set; the next replacement rewrites it as the current format.', + 'const readReceiptFile = async (path) => {', ' let value;', ' try {', - ' if (!(await lstat(join(root, receiptFile))).isFile()) throw unsupported(receiptFile);', - " value = JSON.parse(await readFile(join(root, receiptFile), 'utf8'));", + ' if (!(await lstat(path)).isFile()) throw unsupported(basename(path));', + " value = JSON.parse(await readFile(path, 'utf8'));", " } catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) return undefined; throw error; }", " if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;", - ' // Same shape check as the core reader: a receipt missing any field reads as absent.', - " if (value.format !== receiptFormat || typeof value.plugin !== 'string' || typeof value.version !== 'string' ||", + ' if ((value.format !== receiptFormat && value.format !== legacyReceiptFormat) ||', + " typeof value.plugin !== 'string' || typeof value.version !== 'string' ||", " typeof value.host !== 'string' || typeof value.contentHash !== 'string' || typeof value.installedAt !== 'string' ||", ' !Array.isArray(value.files) || !value.files.every(safeRelative) ||', ' !Array.isArray(value.directories) || !value.directories.every(safeRelative)) return undefined;', + ' if (value.format === legacyReceiptFormat) {', + " return { ...value, format: receiptFormat, hostDirectories: [], migratedFrom: legacyReceiptFormat, mode: 'local',", + " registrations: [{ kind: 'cursor-local-plugin' }], scope: 'user', updatedAt: value.installedAt };", + ' }', + " if (!['host-cli', 'local', 'marketplace'].includes(value.mode) || !isScope(value.scope) || typeof value.updatedAt !== 'string' ||", + ' !Array.isArray(value.hostDirectories) || !value.hostDirectories.every(safeRelative) ||', + ' !Array.isArray(value.registrations) || !value.registrations.every(isRegistration)) return undefined;', ' return value;', '};', + 'const readReceipt = (root) => readReceiptFile(join(root, receiptFile));', + '// Atomic receipt write: an exclusively created random sibling (never follows a link) renamed into place.', + 'const writeReceiptFile = async (path, text) => {', + ' const temporary = `${path}.${randomUUID()}.tmp`;', + " const handle = await open(temporary, 'wx');", + ' try {', + " await handle.writeFile(text, 'utf8');", + ' await handle.close();', + ' await rename(temporary, path);', + ' } finally {', + ' await rm(temporary, { force: true });', + ' }', + '};', + 'const rmdirIfEmpty = async (path) => {', + ' try { await rmdir(path); return true; }', + " catch (error) { if (['ENOTEMPTY', 'ENOENT', 'EEXIST', 'ENOTDIR'].includes(error?.code)) return false; throw error; }", + '};', '', 'const readManifest = async (root) => {', " for (const manifest of ['.cursor-plugin/plugin.json', 'plugin.json']) {", @@ -470,22 +923,35 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "const cursorTransform = expansion === undefined ? undefined : (relativePath, bytes) => relativePath === 'mcp.json' ? Buffer.from(expansion.expanded, 'utf8') : bytes;", '', '// directories: the ones the installer created (all of them on a fresh install); only those are ever pruned.', - 'const receiptFor = (tree, directories = directoriesOf(tree.files)) => JSON.stringify({', - ' contentHash: tree.hash,', - ' // The pre-expansion document bytes and the values substituted, so Doctor validates the Agent Plugins', - ' // contract against what the bundle shipped and proves the expansion against what Cursor spawns.', - ' ...(expansion === undefined ? {} : { cursorExpansion: { documents: expansion.documents, pluginData: expansion.pluginData, pluginRoot: expansion.pluginRoot } }),', - ' directories,', - ' files: tree.files,', - ' format: receiptFormat,', - " host: 'cursor',", - ' installedAt: new Date().toISOString(),', - ' plugin: pluginName,', - ' version: pluginVersion,', - "}, null, 2) + '\\n';", + '// hostDirectories: the ones created under ~/.cursor on the way to the plugin root; installedAt carries over from a', + '// replaced receipt; mode/registrations describe the delivery so --uninstall reverses exactly that.', + 'const receiptFor = (tree, options = {}) => {', + ' const now = new Date().toISOString();', + " const mode = options.mode ?? 'local';", + ' return JSON.stringify({', + ' contentHash: tree.hash,', + ' // The pre-expansion document bytes and the values substituted, so Doctor validates the Agent Plugins', + ' // contract against what the bundle shipped and proves the expansion against what Cursor spawns. Only a', + ' // local copy that owns files carries it: marketplace staging holds no mcp.json, and a remnant receipt carries', + ' // it only when --uninstall --keep-data preserved a written PLUGIN_DATA directory (options.cursorExpansion).', + " ...(options.cursorExpansion !== undefined ? { cursorExpansion: options.cursorExpansion } : expansion === undefined || mode !== 'local' || tree.files.length === 0 ? {} : { cursorExpansion: { documents: expansion.documents, pluginData: expansion.pluginData, pluginRoot: expansion.pluginRoot } }),", + ' directories: options.directories ?? directoriesOf(tree.files),', + ' files: tree.files,', + ' format: receiptFormat,', + " host: 'cursor',", + ' hostDirectories: sortNames(options.hostDirectories ?? []),', + ' installedAt: options.installedAt ?? now,', + ' mode,', + ' plugin: pluginName,', + " registrations: options.registrations ?? [{ kind: 'cursor-local-plugin' }],", + " scope: 'user',", + ' updatedAt: now,', + ' version: pluginVersion,', + " }, null, 2) + '\\n';", + '};', '', '// Staged sibling copy on the destination filesystem so every later rename is atomic.', - 'const stage = async (tree) => {', + 'const stage = async (tree, receiptOptions = {}) => {', ' const parent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', " const root = join(parent, 'bundle');", ' try {', @@ -499,7 +965,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' await cp(source, root, { errorOnExist: true, filter, force: false, recursive: true, verbatimSymlinks: true });', " if (expansion !== undefined) await writeFile(join(root, 'mcp.json'), expansion.expanded, 'utf8');", ' const staged = await inventory(root);', - " await writeFile(join(root, receiptFile), receiptFor(staged), 'utf8');", + " await writeFile(join(root, receiptFile), receiptFor(staged, receiptOptions), 'utf8');", ' return { inventory: staged, parent, root };', ' } catch (error) {', ' await rm(parent, { force: true, recursive: true });', @@ -530,6 +996,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' }', '};', '', + ...cursorUninstallerSource(), 'if (!(await exists(cursorRoot)) || !(await lstat(cursorRoot)).isDirectory()) {', ' throw new Error(`Cursor is not installed in ${cursorRoot}.`);', '}', @@ -555,6 +1022,22 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '};', '', "const marketplaceManifestPath = join(marketplaceRepo, '.cursor-plugin', 'marketplace.json');", + '// The staged repository is a committed Git tree, so its receipt lives in the store beside it; the recorded', + '// commit lets --uninstall prove the repository is still the one staging wrote.', + 'const writeMarketplaceReceipt = async (commit) => {', + ' const previous = await readReceiptFile(marketplaceReceipt);', + ' const tree = await inventory(source);', + ' if (previous !== undefined && previous.contentHash === tree.hash && previous.registrations[0]?.commit === commit) return;', + ' await mkdir(receiptsRoot, { recursive: true });', + ' // The committed repository is removed wholesale after a HEAD check; the receipt owns no individual files.', + ' await writeReceiptFile(marketplaceReceipt, receiptFor({ files: [], hash: tree.hash }, {', + ' directories: [],', + ' hostDirectories: [],', + ' installedAt: previous?.installedAt,', + " mode: 'marketplace',", + " registrations: [{ ...(commit ? { commit } : {}), kind: 'cursor-marketplace-staging', name: `${pluginName}-marketplace` }],", + ' }));', + '};', '', "if (mode === 'marketplace' && !(await exists(join(source, '.cursor-plugin', 'plugin.json')))) {", " throw new Error('--mode marketplace requires a Cursor Plugin (.cursor-plugin/plugin.json); Cursor marketplaces resolve plugins//.cursor-plugin/plugin.json. This bundle is an Agent Plugins (root plugin.json) pack: use the default local mode.');", @@ -624,7 +1107,9 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " if (await git(marketplaceRepo, ['status', '--porcelain', '--untracked-files=all', '--ignored=matching']) !== '') {", ' throw new Error(`Refusing content collision at ${marketplaceRepo}: the working tree differs from the committed HEAD Cursor would import; remove the staged repository and rerun.`);', ' }', - " printMarketplaceSteps('Already staged', await git(marketplaceRepo, ['rev-parse', 'HEAD']));", + " const commit = await git(marketplaceRepo, ['rev-parse', 'HEAD']);", + ' await writeMarketplaceReceipt(commit);', + " printMarketplaceSteps('Already staged', commit);", ' process.exit(0);', ' }', ' throw new Error(`Refusing content collision at ${marketplaceRepo}.`);', @@ -657,6 +1142,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' }', " const commit = await git(stage, ['rev-parse', 'HEAD']);", ' await rename(stage, marketplaceRepo);', + ' await writeMarketplaceReceipt(commit);', " printMarketplaceSteps('Staged', commit);", ' } finally {', ' await rm(stageParent, { force: true, recursive: true });', @@ -664,17 +1150,24 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' process.exit(0);', '}', '', + '// The artifact is inventoried (and any unsupported entry refused) before anything is created in the home.', + 'const artifact = await inventory(source, cursorTransform);', + '// The receipt records which host directories this run creates on the way to the plugin root (a fresh Cursor', + '// home has no plugins/local), so --uninstall can prune exactly those and no more.', + 'const createdHostDirectories = [];', + "for (const relativePath of ['plugins', 'plugins/local']) {", + ' if (!(await exists(join(cursorRoot, relativePath)))) createdHostDirectories.push(relativePath);', + '}', 'await mkdir(installRoot, { recursive: true });', '// Spec 9.1: the data directory exists before any plugin subprocess is launched.', 'if (expansion !== undefined) await mkdir(pluginData, { recursive: true });', - 'const artifact = await inventory(source, cursorTransform);', 'const reportExpansion = () => {', ' if (expansion === undefined) return;', ' console.log(`Expanded Agent Plugins placeholders for Cursor in mcp.json: PLUGIN_ROOT=${destination} PLUGIN_DATA=${pluginData} (Cursor does not expand them; recorded in ${receiptFile})`);', '};', '', 'if (!(await exists(destination))) {', - ' const staged = await stage(artifact);', + ' const staged = await stage(artifact, { hostDirectories: createdHostDirectories });', ' try {', ' await rename(staged.root, destination);', ' console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', @@ -695,12 +1188,18 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { 'const manifest = await readManifest(destination);', 'let ownership;', 'let installedHash;', + '// --uninstall --keep-data leaves a shell holding only state/: a reinstall fills it back in around the preserved', + '// durable state instead of refusing it as foreign (nothing in it is anyone\'s plugin content).', + 'const remnantEntries = (await readdir(destination)).filter((name) => name !== receiptFile);', + 'const stateOnlyRemnant = receipt === undefined', + ' ? remnantEntries.length > 0 && remnantEntries.every(isPreservedRoot)', + ' : receipt.plugin === pluginName && receipt.files.length === 0 && receipt.registrations.length === 0; // remnant receipt from --uninstall --keep-data', 'if (receipt !== undefined && receipt.plugin === pluginName) {', " ownership = 'receipt';", ' installedHash = await hashOwned(destination, receipt.files);', '} else {', ' installedHash = (await inventory(destination)).hash;', - " ownership = receipt === undefined && manifest?.name === pluginName && await hasMarkers(destination) ? 'legacy' : 'foreign';", + " ownership = stateOnlyRemnant || (receipt === undefined && manifest?.name === pluginName && await hasMarkers(destination)) ? 'legacy' : 'foreign';", '}', "const installedVersion = manifest?.version ?? (ownership === 'receipt' ? receipt.version : undefined);", "const installedName = manifest?.name ?? (ownership === 'receipt' ? receipt.plugin : pluginName);", @@ -722,28 +1221,27 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' (receipt.files.length === artifact.files.length && receipt.files.every((file, index) => file === artifact.files[index]));', 'if (installedHash === artifact.hash && inventoryMatches) {', " if (ownership === 'legacy' && replace) {", - ' // Byte-identical pre-receipt copy: adoption only writes the receipt, through an exclusively', - ' // created random sibling so no existing file or link is followed or overwritten.', - ' const temporary = join(destination, `${receiptFile}.${randomUUID()}.tmp`);', - " const handle = await open(temporary, 'wx');", - ' try {', - " await handle.writeFile(receiptFor(artifact, []), 'utf8'); // adoption created no directories", - ' await handle.close();', - ' await rename(temporary, join(destination, receiptFile));', - ' } finally {', - ' await rm(temporary, { force: true });', - ' }', + ' // Byte-identical pre-receipt copy: adoption only writes the receipt (adoption created no directories),', + ' // through an exclusively created random sibling so no existing file or link is followed or overwritten.', + ' await writeReceiptFile(join(destination, receiptFile), receiptFor(artifact, { directories: [], hostDirectories: [] }));', ' console.log(`Adopted ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' reportExpansion();', ' process.exit(0);', ' }', + " if (ownership === 'receipt' && receipt.migratedFrom !== undefined) {", + ' // An identical receipt-managed copy whose receipt predates the current format is upgraded in place:', + ' // lifecycle fields exactly as the reader synthesized them, and nothing else changes.', + ' await writeReceiptFile(join(destination, receiptFile), receiptFor(artifact, {', + ' directories: receipt.directories, hostDirectories: receipt.hostDirectories, installedAt: receipt.installedAt,', + ' }));', + ' }', ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' process.exit(0);', '}', 'if (installedVersion !== undefined && installedVersion !== pluginVersion && !replace) {', ' throw new Error(`Refusing version collision at ${destination}: ${detail}. Re-run with --replace to replace this agent-bundle install.`);', '}', - "if (ownership === 'legacy' && !replace) {", + "if (ownership === 'legacy' && !replace && !stateOnlyRemnant) {", ' throw new Error(`Refusing content collision at ${destination}: ${detail}; this copy predates install receipts. ` +', " 'Re-run with --replace once to adopt it; later same-version rebuilds replace automatically.');", '}', @@ -812,11 +1310,16 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' await rename(join(staged.root, file), join(destination, file));', ' }', ' // The receipt owns what the installer owns now: surviving directories it created before plus the', - ' // ones this replacement created. Finalised in the private staging copy, then committed by rename.', + ' // ones this replacement created; the first install time and the host directories it created carry', + ' // over. Finalised in the private staging copy, then committed by rename.', ' const directories = sortNames(new Set([...[...ownedDirectories].filter((directory) => !pruned.has(directory)), ...created]));', - " await writeFile(join(staged.root, receiptFile), receiptFor(staged.inventory, directories), 'utf8');", + " const previous = ownership === 'receipt' ? receipt : undefined;", + ' await writeFile(join(staged.root, receiptFile), receiptFor(staged.inventory, {', + ' directories, hostDirectories: previous?.hostDirectories ?? [], installedAt: previous?.installedAt,', + " }), 'utf8');", ' await rename(join(staged.root, receiptFile), join(destination, receiptFile));', - ' console.log(`Replaced ${pluginName}@${pluginVersion} at ${destination} (content ${short(installedHash)} -> ${short(artifact.hash)})`);', + ' if (stateOnlyRemnant) console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', + ' else console.log(`Replaced ${pluginName}@${pluginVersion} at ${destination} (content ${short(installedHash)} -> ${short(artifact.hash)})`);', ' reportExpansion();', '} finally {', ' await rm(staged.parent, { force: true, recursive: true });', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts new file mode 100644 index 000000000..a6025601f --- /dev/null +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -0,0 +1,1358 @@ +import type { Stats } from 'node:fs'; +import { lstat, readdir, readFile, rm } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { Effect } from 'effect'; + +import { DiagnosticError } from '../core/diagnostics.ts'; +import { errorMessage, isErrno } from '../core/errors.ts'; +import { exists } from '../core/paths.ts'; +import { runPromise } from '../effect/boundary.ts'; +import { liftPromise } from '../effect/lift.ts'; +import { cacheHasPlugin, readHeadCommit } from './cursor-hooks-registration.ts'; +import { cursorMarketplaceName, cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; +import { + cursorMarketplaceReceiptPath, + defaultCommandRunner, + publicHostMarketplaceRemoveArguments, + publicHostProjectRoot, + publicHostReceiptPath, + publicHostRegistrations, + publicHostRoot, + publicHostUninstallArguments, + readIdentity, + readInstalledManifest, + readPublicHostInventory, + readPublicHostMarketplaceState, + runHostCommand, + type InstallCommandResult, + type InstallCommandRunner, + type InstallHost, + type InstallMode, + type InstallScope, + type PluginIdentity, + type PublicHostInstalledEntry, +} from './install.ts'; +import { + assertRealAncestors, + createInstallReceipt, + directoriesOf, + emptyContentHash, + hasInstallSurfaceMarkers, + hashOwnedFiles, + installReceiptFile, + installReceiptFormat, + installReceiptStoreDirectory, + isPreservedRuntimeRoot, + isRemnantReceipt, + listStoredInstallReceipts, + pruneEmptyDirectory, + readInstallReceipt, + readInstallReceiptFile, + removeStoredInstallReceipt, + shortHash, + simulateRemoveStoredInstallReceipt, + treeInventory, + writeInstallReceipt, + writeStoredInstallReceipt, + type InstallReceipt, + type InstallReceiptMode, + type InstallRegistration, + type StoredInstallReceipt, +} from './receipt.ts'; + +/** + * `agent-bundle uninstall ` (#101): the receipt-owned reverse of + * `install`. Every mutation is opt-in, fail-closed, and bounded by what the + * install receipt records — owned files and directories, the host + * registrations the installer performed, and the host directories it created. + * Nothing outside that set is ever removed; durable runtime state (`state/`, + * the state kernel and notices journal) is kept unless `--purge-data` is + * confirmed explicitly. `--plan` computes the same result without opening a + * writer, and a second run after a successful uninstall is a `not-installed` + * no-op. + */ + +export type UninstallDataPolicy = 'keep' | 'purge'; + +/** + * What happened to the plugin's durable runtime state. `kept`/`purged`/`absent` + * are Agent Bundle's own doing; `retained-by-host` (Claude keeps the orphaned + * cache copy, `state/` included, for its ~14-day grace period) and + * `removed-by-host` (Codex deletes the cached tree, `state/` included, on + * `plugin remove`) name the host behaviour that decided instead; `unavailable` + * means the delivery holds no runtime state (a staged marketplace repository). + */ +export type UninstallDataOutcome = + | 'absent' + | 'kept' + | 'purged' + | 'removed-by-host' + | 'retained-by-host' + | 'unavailable'; + +export interface UninstallDataReport { + readonly detail: string; + readonly outcome: UninstallDataOutcome; + /** The durable-state paths the decision applied to (absolute). */ + readonly paths: readonly string[]; + readonly policy: UninstallDataPolicy; +} + +/** + * `removed`: reversed by this run; `already-absent`: the host no longer held it + * (idempotent rerun, or the host pruned it); `retained`: deliberately left in + * place (another installed plugin still uses the marketplace); `manual`: the + * host exposes no non-interactive verb, `nextSteps` names the UI step; + * `planned`: `--plan` would remove it. + */ +export type UninstallRegistrationAction = 'already-absent' | 'manual' | 'planned' | 'removed' | 'retained'; + +export interface UninstallRegistrationReport extends InstallRegistration { + readonly action: UninstallRegistrationAction; + readonly detail?: string; +} + +/** + * How the receipt drove this run: `consumed` (read, honoured, removed), + * `migrated` (a format/1 receipt read with synthesized lifecycle fields), + * `forced-missing`/`forced-legacy`/`forced-mismatch` (`--force` overrode a + * missing receipt, a pre-receipt legacy copy, or an owned-content hash + * mismatch), `missing` (nothing installed; no receipt to consume), `remnant` + * (a remnant receipt from an earlier `--keep-data` uninstall was found and + * left in place because only preserved state remains and it is being kept). + */ +export type UninstallReceiptStatus = + | 'consumed' + | 'forced-legacy' + | 'forced-mismatch' + | 'forced-missing' + | 'migrated' + | 'missing' + | 'remnant'; + +export interface UninstallReceiptReport { + readonly contentHash?: string; + readonly format?: string; + readonly installedAt?: string; + readonly migratedFrom?: string; + readonly path: string; + readonly status: UninstallReceiptStatus; + readonly version?: string; +} + +export type UninstallResultState = 'not-installed' | 'planned' | 'uninstalled'; + +export interface UninstallResult { + readonly bundleRoot: string; + readonly data: UninstallDataReport; + /** The installed plugin root the run acted on (cache copy, local directory, or staged repository). */ + readonly destination?: string; + readonly forced: boolean; + readonly host: InstallHost; + readonly marketplace?: string; + readonly mode: InstallReceiptMode; + /** Host-owned steps the uninstaller cannot perform non-interactively. */ + readonly nextSteps?: readonly string[]; + readonly plugin: string; + readonly receipt: UninstallReceiptReport; + readonly registrations: readonly UninstallRegistrationReport[]; + /** + * Cursor local only: the plugin root survived (retained runtime state or unowned entries), so a remnant + * receipt owning no files was written there to keep the created host directories receipt-owned. + */ + readonly remnantReceipt?: string; + /** Exact absolute paths removed (or, under `--plan`, that would be removed; directories only when empty). */ + readonly removed: { + readonly directories: readonly string[]; + readonly files: readonly string[]; + }; + /** Unowned entries left in place under the destination (relative to it); surviving unowned directories end in `/`. */ + readonly retained: readonly string[]; + readonly scope: InstallScope; + readonly state: UninstallResultState; + readonly version: string; +} + +export interface UninstallBundleOptions { + readonly commandRunner?: InstallCommandRunner; + /** Required with `purgeData`: the explicit confirmation that durable state may be deleted. */ + readonly confirmPurge?: boolean; + readonly environment?: Readonly; + /** + * Proceed without a receipt (a pre-receipt legacy copy, or a host-registered + * copy with no store receipt) or when owned content no longer matches the + * receipt. Foreign directories — a receipt or manifest naming another plugin + * — are refused regardless. + */ + readonly force?: boolean; + readonly from: string; + readonly home?: string; + readonly host: InstallHost; + /** Explicitly keep durable runtime state (the default). */ + readonly keepData?: boolean; + /** Cursor only; defaults to `local`. */ + readonly mode?: InstallMode; + /** Compute and report the exact plan without changing anything. */ + readonly plan?: boolean; + /** Remove durable runtime state too; refused without `confirmPurge`. */ + readonly purgeData?: boolean; + readonly scope?: InstallScope; +} + +const failure = (code: string, message: string, target: InstallHost): DiagnosticError => + new DiagnosticError([{ code, message, severity: 'error', target }]); + +const unsupportedEntry = (path: string, host: InstallHost): DiagnosticError => failure( + 'AB7007', + `Refusing unsupported filesystem entry at ${JSON.stringify(path)}: uninstall acts on regular files and real directories only.`, + host, +); + +const resolveDataPolicy = (options: UninstallBundleOptions): UninstallDataPolicy => { + if (options.purgeData === true && options.keepData === true) { + throw failure('AB7008', '`--keep-data` and `--purge-data` are mutually exclusive.', options.host); + } + if (options.purgeData === true && options.confirmPurge !== true) { + throw failure( + 'AB7008', + '`--purge-data` deletes the plugin\'s durable runtime state (state kernel, notices journal) and requires ' + + '`--confirm-purge`; omit both flags to keep the data.', + options.host, + ); + } + return options.purgeData === true ? 'purge' : 'keep'; +}; + +const realDirectory = async (path: string, host: InstallHost): Promise => { + let metadata: Stats; + try { + metadata = await lstat(path); + } catch (error) { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; + } + if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw unsupportedEntry(path, host); + return metadata; +}; + +const receiptReport = (path: string, receipt: InstallReceipt | undefined, status: UninstallReceiptStatus): UninstallReceiptReport => + Object.freeze({ + ...(receipt === undefined ? {} : { + contentHash: receipt.contentHash, + format: receipt.migratedFrom ?? installReceiptFormat, + installedAt: receipt.installedAt, + ...(receipt.migratedFrom === undefined ? {} : { migratedFrom: receipt.migratedFrom }), + version: receipt.version, + }), + path, + status, + }); + +const deepestFirst = (directories: readonly string[]): readonly string[] => + [...directories].sort((left, right) => right.length - left.length || left.localeCompare(right)); + +/** Removes empty directories deepest first; reports the ones actually removed. */ +const pruneDirectories = async (paths: readonly string[]): Promise => { + const removed: string[] = []; + for (const path of deepestFirst(paths)) { + if (await pruneEmptyDirectory(path)) removed.push(path); + } + return removed; +}; + +/** + * What `pruneDirectories` would remove once `removedPaths` (files and whole + * directories) are gone: deepest first, a candidate is pruned when every entry + * it still holds is itself removed or pruned. `--plan` reports exactly this set, + * so a directory kept alive by retained state or unowned entries is never listed. + */ +const simulatePrune = async (paths: readonly string[], removedPaths: ReadonlySet): Promise => { + const gone = new Set(removedPaths); + const pruned: string[] = []; + for (const path of deepestFirst(paths)) { + if (await wouldPrune(path, gone)) pruned.push(path); + } + return pruned; +}; + +/** Whether `pruneEmptyDirectory(path)` would succeed once every path in `gone` is removed; records it in `gone` when so. */ +const wouldPrune = async (path: string, gone: Set): Promise => { + let entries: readonly string[]; + try { + entries = await readdir(path); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) return false; + throw error; + } + if (!entries.every((entry) => gone.has(join(path, entry)))) return false; + gone.add(path); + return true; +}; + +/** + * Unowned entries under `root` that survive the uninstall, POSIX-relative: regular files (and symlinks, listed + * but never followed) that are not owned and not runtime state, plus unowned directories holding nothing + * retained (`name/`), which the prune never touches because only owned directories are candidates. A directory + * that holds a retained entry is implied by that entry and is not listed itself. + */ +const listRetained = async ( + root: string, + owned: ReadonlySet, + ownedDirectories: ReadonlySet, +): Promise => { + const retained: string[] = []; + const visit = async (relativePath: string): Promise => { + let entries: readonly string[]; + try { + entries = (await readdir(join(root, relativePath))).sort((left, right) => left.localeCompare(right)); + } catch (error) { + if (isErrno(error, 'ENOENT')) return 0; + throw error; + } + let kept = 0; + for (const name of entries) { + const child = relativePath === '' ? name : `${relativePath}/${name}`; + if (relativePath === '' && (name === installReceiptFile || isPreservedRuntimeRoot(name))) continue; + const metadata = await lstat(join(root, child)); + if (metadata.isDirectory() && !metadata.isSymbolicLink()) { + const below = await visit(child); + if (below === 0 && !ownedDirectories.has(child)) retained.push(`${child}/`); + kept += below === 0 && !ownedDirectories.has(child) ? 1 : below; + continue; + } + if (!owned.has(child)) { + retained.push(child); + kept += 1; + } + } + return kept; + }; + await visit(''); + return Object.freeze(retained); +}; + +interface CursorLocalOwnership { + readonly directories: readonly string[]; + readonly files: readonly string[]; + readonly hostDirectories: readonly string[]; + readonly receipt: InstallReceipt | undefined; + readonly status: UninstallReceiptStatus; +} + +/** + * Decides what a Cursor local uninstall may remove. A receipt naming this + * plugin owns exactly its files and directories; its owned content must hash + * to the recorded content hash unless `--force`. Without a receipt, only a + * legacy layout (emitted install surface plus a manifest naming this plugin) + * may be removed, and only under `--force`, by its inventory. Anything else is + * foreign and is refused with or without `--force`. + */ +const cursorLocalOwnership = async ( + destination: string, + identity: PluginIdentity, + force: boolean, +): Promise => { + const receipt = await readInstallReceipt(destination); + if (receipt === undefined) { + const manifest = await readInstalledManifest(destination); + const legacy = manifest?.name === identity.plugin && await hasInstallSurfaceMarkers(destination); + if (!legacy) { + throw failure( + 'AB7007', + `Refusing to uninstall foreign directory ${destination}: it carries no install receipt and is not a ` + + `recognizable agent-bundle install of ${identity.plugin}` + + `${manifest === undefined ? ' (no loader manifest)' : ` (manifest names ${JSON.stringify(manifest.name)})`}. ` + + 'Remove it manually if it is stale; --force does not apply to foreign directories.', + 'cursor', + ); + } + if (!force) { + throw failure( + 'AB7009', + `Refusing to uninstall ${destination} without an install receipt: this copy predates install receipts, so ` + + 'ownership cannot be proven. Re-run with --force to remove its inventoried plugin files (runtime state ' + + 'under state/ is kept unless --purge-data --confirm-purge is passed), or reinstall with --replace first to adopt it.', + 'cursor', + ); + } + const inventory = await treeInventory(destination); + return { + directories: directoriesOf(inventory.files), + files: inventory.files, + hostDirectories: [], + receipt: undefined, + status: 'forced-legacy', + }; + } + if (receipt.plugin !== identity.plugin) { + throw failure( + 'AB7007', + `Refusing to uninstall ${destination}: its install receipt names plugin ${JSON.stringify(receipt.plugin)}, ` + + `not ${JSON.stringify(identity.plugin)}. Uninstall that plugin from its own bundle instead; --force does not apply.`, + 'cursor', + ); + } + const installedContentHash = await hashOwnedFiles(destination, receipt.files); + let status: UninstallReceiptStatus = receipt.migratedFrom === undefined ? 'consumed' : 'migrated'; + if (installedContentHash !== receipt.contentHash) { + if (!force) { + throw failure( + 'AB7007', + `Refusing to uninstall ${destination}: the owned files hash ${shortHash(installedContentHash)} but the ` + + `receipt recorded ${shortHash(receipt.contentHash)}, so the installed copy was modified after installation. ` + + 'Re-run with --force to remove the receipt-owned files anyway (unowned entries are never removed).', + 'cursor', + ); + } + status = 'forced-mismatch'; + } + return { + directories: receipt.directories, + files: receipt.files, + hostDirectories: receipt.hostDirectories, + receipt, + status, + }; +}; + +/** The `PLUGIN_DATA` directory the emitted installer creates for an Agent Plugins pack copied into Cursor (spec §9.1). */ +export const cursorPluginDataDirectory = (cursorRoot: string, plugin: string): string => + join(cursorRoot, 'agent-bundle', 'plugin-data', plugin); + +interface CursorLocalData { + /** Installer-created `PLUGIN_DATA` directory that nothing wrote to: pruned like a created host directory, never "data". */ + readonly emptyPluginData?: string; + /** Whether any durable state (state/ or a written PLUGIN_DATA) exists. */ + readonly present: boolean; + /** A written `PLUGIN_DATA` directory kept by `--keep-data`: it lives outside the plugin root, so the root must survive to carry it. */ + readonly retainedPluginData?: string; + readonly report: UninstallDataReport; +} + +const cursorLocalData = async ( + destination: string, + policy: UninstallDataPolicy, + receipt: InstallReceipt | undefined, + cursorRoot: string, + plugin: string, +): Promise => { + const stateDirectory = join(destination, 'state'); + const paths: string[] = []; + const kinds: string[] = []; + if (await realDirectory(stateDirectory, 'cursor') !== undefined) { + paths.push(stateDirectory); + kinds.push('state/ (state kernel, notices journal)'); + } + // The receipt's cursorExpansion records the PLUGIN_DATA directory the installer created for this copy; only the + // directory at this home's own plugin-data location is receipt-owned — a recorded path elsewhere is left alone. + const recorded = receipt?.cursorExpansion?.pluginData; + const expected = cursorPluginDataDirectory(cursorRoot, plugin); + let emptyPluginData: string | undefined; + let foreignPluginData: string | undefined; + if (recorded !== undefined) { + if (recorded !== expected) { + foreignPluginData = recorded; + } else if (await realDirectory(recorded, 'cursor') !== undefined) { + if ((await readdir(recorded)).length === 0) { + emptyPluginData = recorded; + } else { + paths.push(recorded); + kinds.push(`the PLUGIN_DATA directory ${recorded}`); + } + } + } + const foreignNote = foreignPluginData === undefined + ? '' + : ` The receipt records PLUGIN_DATA at ${foreignPluginData}, outside this home's agent-bundle/plugin-data; it is not touched.`; + if (paths.length === 0) { + return { + ...(emptyPluginData === undefined ? {} : { emptyPluginData }), + present: false, + report: Object.freeze({ + detail: `No durable runtime state exists (no state/ under the installed plugin root${ + emptyPluginData === undefined ? '' : `; the installer-created PLUGIN_DATA directory ${emptyPluginData} is empty and is pruned` + }).${foreignNote}`, + outcome: 'absent', + paths: Object.freeze([]), + policy, + }), + }; + } + const retainedPluginData = policy === 'purge' ? undefined : paths.find((path) => path === expected); + return { + ...(emptyPluginData === undefined ? {} : { emptyPluginData }), + present: true, + report: Object.freeze({ + detail: policy === 'purge' + ? `Durable runtime state — ${kinds.join(' and ')} — is removed (--purge-data --confirm-purge).${foreignNote}` + : `Durable runtime state — ${kinds.join(' and ')} — is kept; pass --purge-data --confirm-purge to remove it.${foreignNote}`, + outcome: policy === 'purge' ? 'purged' : 'kept', + paths: Object.freeze(paths), + policy, + }), + ...(retainedPluginData === undefined ? {} : { retainedPluginData }), + }; +}; + +const uninstallCursorLocal = async ( + options: UninstallBundleOptions, + identity: PluginIdentity, + policy: UninstallDataPolicy, +): Promise => { + const force = options.force === true; + const base = { + bundleRoot: identity.bundleRoot, + forced: force, + host: 'cursor', + mode: 'local', + plugin: identity.plugin, + scope: 'user', + version: identity.version, + } as const; + const cursorRoot = join(options.home ?? homedir(), '.cursor'); + const destination = join(cursorRoot, 'plugins', 'local', identity.plugin); + const receiptPath = join(destination, installReceiptFile); + const notInstalled = (): UninstallResult => Object.freeze({ + ...base, + data: Object.freeze({ + detail: 'Nothing is installed, so no durable runtime state exists.', + outcome: 'absent', + paths: Object.freeze([]), + policy, + }), + destination, + receipt: receiptReport(receiptPath, undefined, 'missing'), + registrations: Object.freeze([Object.freeze({ action: 'already-absent' as const, kind: 'cursor-local-plugin' as const })]), + removed: Object.freeze({ directories: Object.freeze([]), files: Object.freeze([]) }), + retained: Object.freeze([]), + state: 'not-installed', + }); + if (await realDirectory(cursorRoot, 'cursor') === undefined) return notInstalled(); + if (await realDirectory(destination, 'cursor') === undefined) return notInstalled(); + const ownership = await cursorLocalOwnership(destination, identity, force); + const owned = new Set(ownership.files); + // A symlinked ancestor would let a leaf-only delete reach outside the plugin root: refused before any change. + await assertRealAncestors(destination, ownership.files); + const data = await cursorLocalData(destination, policy, ownership.receipt, cursorRoot, identity.plugin); + const files: string[] = []; + for (const file of ownership.files) { + const path = join(destination, file); + let metadata: Stats; + try { + metadata = await lstat(path); + } catch (error) { + if (isErrno(error, 'ENOENT')) continue; + throw error; + } + if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupportedEntry(path, 'cursor'); + files.push(path); + } + if (ownership.receipt !== undefined || await exists(receiptPath)) files.push(receiptPath); + // A written PLUGIN_DATA directory kept by --keep-data lives outside the plugin root, so the root stays (with a + // remnant receipt carrying the expansion) to keep that data receipt-owned for a later purge. + const keepRoot = data.retainedPluginData !== undefined; + const pluginDataRecorded = ownership.receipt?.cursorExpansion?.pluginData === cursorPluginDataDirectory(cursorRoot, identity.plugin); + const directoryCandidates = [ + ...ownership.directories.map((directory) => join(destination, directory)), + ...(keepRoot ? [] : [destination]), + ...ownership.hostDirectories.map((directory) => join(cursorRoot, directory)), + // The installer created PLUGIN_DATA and its agent-bundle parents; once empty they go too — never while + // receipts, marketplaces, or another plugin's data keep them alive. + ...(data.emptyPluginData === undefined ? [] : [data.emptyPluginData]), + ...(pluginDataRecorded ? [join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle')] : []), + ]; + const ownedDirectories = new Set(ownership.directories); + const retained = await listRetained(destination, owned, ownedDirectories); + const remnantOnly = ownership.receipt !== undefined && isRemnantReceipt(ownership.receipt); + const purging = data.present && policy === 'purge'; + if (remnantOnly && policy !== 'purge' && files.length === 1 && files[0] === receiptPath) { + // A rerun over what an earlier `--keep-data` uninstall left behind, still keeping the data: there is + // nothing to remove, so the remnant receipt stays in place and the run is the documented no-op. An explicit + // `--purge-data --confirm-purge` never takes this path: it consumes the remnant (and its recorded host + // directories) even when state/ has since been removed by hand. + return Object.freeze({ + ...base, + data: data.report, + destination, + receipt: receiptReport(receiptPath, ownership.receipt, 'remnant'), + registrations: Object.freeze([Object.freeze({ + action: 'already-absent' as const, + detail: 'Only preserved runtime state remained from an earlier `uninstall --keep-data`; no plugin content was registered.', + kind: 'cursor-local-plugin' as const, + })]), + remnantReceipt: receiptPath, + removed: Object.freeze({ directories: Object.freeze([]), files: Object.freeze([]) }), + retained, + state: 'not-installed', + }); + } + const registrations: readonly UninstallRegistrationReport[] = Object.freeze([Object.freeze({ + action: remnantOnly ? 'already-absent' as const : options.plan === true ? 'planned' as const : 'removed' as const, + detail: remnantOnly + ? 'Only preserved runtime state remained from an earlier `uninstall --keep-data`; no plugin content was registered.' + : 'Cursor loads plugins/local/ directly; removing the directory unregisters the plugin at the next window reload.', + kind: 'cursor-local-plugin' as const, + })]); + const purgedDirectories = purging ? data.report.paths : []; + if (options.plan === true) { + const directories = await simulatePrune(directoryCandidates, new Set([...files, ...purgedDirectories])); + // The plugin root survives (retained state, unowned entries, or kept PLUGIN_DATA) exactly when the simulation + // cannot prune it. + const survives = !directories.includes(destination); + return Object.freeze({ + ...base, + data: data.report, + destination, + receipt: receiptReport(receiptPath, ownership.receipt, ownership.status), + registrations, + ...(survives ? { remnantReceipt: receiptPath } : {}), + removed: Object.freeze({ + directories: Object.freeze([...purgedDirectories, ...directories]), + files: Object.freeze(files), + }), + retained, + state: 'planned', + }); + } + for (const path of files) await rm(path, { force: true }); + for (const path of purgedDirectories) await rm(path, { force: true, recursive: true }); + const directories = await pruneDirectories(directoryCandidates); + const retainedAfter = await exists(destination) ? await listRetained(destination, owned, ownedDirectories) : Object.freeze([]); + let remnantReceipt: string | undefined; + if (await exists(destination)) { + // The plugin root survives (retained runtime state or unowned entries), so a remnant receipt keeps the + // lifecycle receipt-owned: it owns no files and records no registrations, but carries the host + // directories this install created so a later purge can still prune them, and lets Doctor explain the + // directory instead of calling it corrupt. A reinstall fills it back in as an `installed`. + await writeInstallReceipt(destination, createInstallReceipt({ + // A kept PLUGIN_DATA directory stays receipt-owned through the remnant's expansion record. + ...(ownership.receipt?.cursorExpansion === undefined || data.retainedPluginData === undefined + ? {} + : { cursorExpansion: ownership.receipt.cursorExpansion }), + host: 'cursor', + hostDirectories: ownership.hostDirectories, + ...(ownership.receipt === undefined ? {} : { installedAt: ownership.receipt.installedAt }), + inventory: { files: [], hash: emptyContentHash }, + mode: 'local', + plugin: identity.plugin, + registrations: [], + scope: 'user', + updatedAt: new Date().toISOString(), + version: ownership.receipt?.version ?? identity.version, + })); + remnantReceipt = receiptPath; + } + return Object.freeze({ + ...base, + data: data.report, + destination, + receipt: receiptReport(receiptPath, ownership.receipt, ownership.status), + registrations, + ...(remnantReceipt === undefined ? {} : { remnantReceipt }), + removed: Object.freeze({ + directories: Object.freeze([...purgedDirectories, ...directories]), + files: Object.freeze(files), + }), + retained: retainedAfter, + state: 'uninstalled', + }); +}; + +const readMarketplaceStagingIdentity = async (repoRoot: string, plugin: string): Promise => { + const manifest = await readInstalledManifest(cursorMarketplacePluginPath(repoRoot, plugin)); + if (manifest?.name !== plugin) return false; + try { + const document = JSON.parse(await readFile(join(repoRoot, '.cursor-plugin', 'marketplace.json'), 'utf8')) as unknown; + return typeof document === 'object' && document !== null && !Array.isArray(document) && + (document as { readonly name?: unknown }).name === cursorMarketplaceName(plugin); + } catch (error) { + if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return false; + throw error; + } +}; + +/** + * Why the staged repository's working tree is not the receipted commit's tree, or `undefined` when it is. + * Same probe as Doctor's staging verifier (`--no-optional-locks` keeps it read-only; ignored entries count). + * A repository that cannot be verified — git missing, or `status` failing — is reported as dirt too: the removal + * is recursive, so "unverifiable" must not read as "clean". + */ +const stagedWorkingTreeDirt = async (runner: InstallCommandRunner, repoRoot: string): Promise => { + let result: InstallCommandResult; + try { + result = await runner.run( + 'git', + ['--no-optional-locks', 'status', '--porcelain', '--untracked-files=all', '--ignored=matching'], + { cwd: repoRoot }, + ); + } catch (error) { + if (isErrno(error, 'ENOENT')) return 'git is not available on PATH, so the staged working tree cannot be verified against the receipted commit.'; + throw error; + } + if (result.code !== 0) { + return `\`git status\` failed in the staged repository (${result.stderr.trim() || `exit code ${result.code}`}), so its working tree cannot be verified against the receipted commit.`; + } + const entries = result.stdout.split('\n').map((line) => line.trimEnd()).filter((line) => line !== ''); + if (entries.length === 0) return undefined; + const shown = entries.slice(0, 5).map((line) => JSON.stringify(line.slice(3))).join(', '); + return `its working tree differs from the receipted commit (${entries.length} uncommitted, untracked, or ignored ` + + `${entries.length === 1 ? 'entry' : 'entries'}: ${shown}${entries.length > 5 ? ', …' : ''}) that the receipt does not own.`; +}; + +const uninstallCursorMarketplace = async ( + options: UninstallBundleOptions, + identity: PluginIdentity, + policy: UninstallDataPolicy, +): Promise => { + const force = options.force === true; + const marketplace = cursorMarketplaceName(identity.plugin); + const base = { + bundleRoot: identity.bundleRoot, + forced: force, + host: 'cursor', + marketplace, + mode: 'marketplace', + plugin: identity.plugin, + scope: 'user', + version: identity.version, + } as const; + const cursorRoot = join(options.home ?? homedir(), '.cursor'); + const marketplacesRoot = cursorMarketplaceRoot(cursorRoot); + const repoRoot = join(marketplacesRoot, identity.plugin); + const receiptPath = cursorMarketplaceReceiptPath(cursorRoot, identity.plugin); + const data: UninstallDataReport = Object.freeze({ + detail: 'A staged marketplace repository holds no runtime state; a copy Cursor imported from it is Cursor-owned and is not touched.', + outcome: 'unavailable', + paths: Object.freeze([]), + policy, + }); + const cursorHome = await realDirectory(cursorRoot, 'cursor'); + const repo = cursorHome === undefined ? undefined : await realDirectory(repoRoot, 'cursor'); + const receipt = cursorHome === undefined ? undefined : await readInstallReceiptFile(receiptPath); + if (repo === undefined && receipt === undefined) { + return Object.freeze({ + ...base, + data, + destination: repoRoot, + receipt: receiptReport(receiptPath, undefined, 'missing'), + registrations: Object.freeze([Object.freeze({ + action: 'already-absent' as const, + kind: 'cursor-marketplace-staging' as const, + name: marketplace, + })]), + removed: Object.freeze({ directories: Object.freeze([]), files: Object.freeze([]) }), + retained: Object.freeze([]), + state: 'not-installed', + }); + } + let status: UninstallReceiptStatus = receipt === undefined + ? 'forced-missing' + : receipt.migratedFrom === undefined ? 'consumed' : 'migrated'; + const recorded = receipt?.registrations.find((registration) => registration.kind === 'cursor-marketplace-staging'); + if (repo !== undefined) { + if (receipt === undefined) { + if (!force) { + throw failure( + 'AB7009', + `Refusing to remove staged Cursor marketplace ${repoRoot} without an install receipt at ${receiptPath}. ` + + 'Re-run with --force to remove it after verifying it is this plugin\'s staging, or rerun ' + + '`agent-bundle install cursor --mode marketplace` to record a receipt first.', + 'cursor', + ); + } + if (!await readMarketplaceStagingIdentity(repoRoot, identity.plugin)) { + throw failure( + 'AB7007', + `Refusing to remove ${repoRoot}: it is not a staged Agent Bundle marketplace for ${identity.plugin} ` + + `(expected .cursor-plugin/marketplace.json naming ${marketplace} and plugins/${identity.plugin}). --force does not apply.`, + 'cursor', + ); + } + } else if (receipt.plugin !== identity.plugin) { + throw failure( + 'AB7007', + `Refusing to remove ${repoRoot}: the receipt at ${receiptPath} names plugin ${JSON.stringify(receipt.plugin)}.`, + 'cursor', + ); + } else { + const head = await readHeadCommit(repoRoot); + if (recorded?.commit !== undefined && head !== recorded.commit) { + if (!force) { + throw failure( + 'AB7007', + `Refusing to remove ${repoRoot}: its HEAD is ${head ?? 'unresolvable'} but the receipt recorded commit ` + + `${recorded.commit}, so the staged repository changed after staging. Re-run with --force to remove it anyway.`, + 'cursor', + ); + } + status = 'forced-mismatch'; + } else if (recorded?.commit !== undefined) { + // HEAD matching the receipt proves the commit, not the working tree: uncommitted, untracked, or ignored + // entries someone added since staging are not receipt-owned, and the removal below is recursive. + const dirty = await stagedWorkingTreeDirt(options.commandRunner ?? defaultCommandRunner, repoRoot); + if (dirty !== undefined) { + if (!force) { + throw failure( + 'AB7007', + `Refusing to remove ${repoRoot}: ${dirty} Move those entries out (or commit them and rerun ` + + '`agent-bundle install cursor --mode marketplace`), or re-run with --force to remove them anyway.', + 'cursor', + ); + } + status = 'forced-mismatch'; + } + } + } + } + // A completed copy Cursor imported from the recorded staging commit is Cursor-owned, whether or not the + // staged repository itself still exists: the receipt's commit — and the version the receipt recorded, not the + // version the bundle has been rebuilt to since — is what identifies it. + const imported = recorded?.commit !== undefined + ? await cacheHasPlugin(options.home ?? homedir(), marketplace, identity.plugin, receipt?.version ?? identity.version, recorded.commit) + : false; + const planned = options.plan === true; + const registrations: UninstallRegistrationReport[] = [Object.freeze({ + action: repo === undefined ? 'already-absent' as const : planned ? 'planned' as const : 'removed' as const, + ...(recorded?.commit === undefined ? {} : { commit: recorded.commit }), + kind: 'cursor-marketplace-staging' as const, + name: marketplace, + })]; + const nextSteps: string[] = []; + if (imported) { + registrations.push(Object.freeze({ + action: 'manual', + detail: `Cursor imported this marketplace (a completed copy exists under ~/.cursor/plugins/cache/${marketplace}); ` + + 'its installed-plugin registry is server-assigned and exposes no non-interactive removal verb.', + kind: 'cursor-marketplace-staging', + name: marketplace, + })); + nextSteps.push(`Open Cursor, then Customize -> Plugins, and uninstall "${identity.plugin}" (marketplace ${marketplace}) there.`); + } + const files = receipt === undefined && !await exists(receiptPath) ? [] : [receiptPath]; + const directories = repo === undefined ? [] : [repoRoot]; + if (planned) { + // Exactly the directories the run below would prune, in its order: the receipt store, the staging root, then + // the Agent Bundle namespace once both are gone. + const gone = new Set([...files, ...directories]); + const namespace = join(cursorRoot, 'agent-bundle'); + const wouldPruneStore = await wouldPrune(installReceiptStoreDirectory(cursorRoot), gone) ? [installReceiptStoreDirectory(cursorRoot)] : []; + const wouldPruneMarketplaces = await wouldPrune(marketplacesRoot, gone) ? [marketplacesRoot] : []; + const wouldPruneNamespace = await wouldPrune(namespace, gone) ? [namespace] : []; + return Object.freeze({ + ...base, + data, + destination: repoRoot, + ...(nextSteps.length === 0 ? {} : { nextSteps: Object.freeze(nextSteps) }), + receipt: receiptReport(receiptPath, receipt, status), + registrations: Object.freeze(registrations), + removed: Object.freeze({ + directories: Object.freeze([...directories, ...wouldPruneStore, ...wouldPruneMarketplaces, ...wouldPruneNamespace]), + files: Object.freeze(files), + }), + retained: Object.freeze([]), + state: 'planned', + }); + } + // The whole repository is installer-created (mkdtemp + rename), and the receipt just proved HEAD is + // the commit that staging wrote (or --force accepted the difference): removing it wholesale is bounded. + if (repo !== undefined) await rm(repoRoot, { force: true, recursive: true }); + const removedReceipt = await removeStoredInstallReceipt(receiptPath, cursorRoot); + const prunedMarketplaces = await pruneEmptyDirectory(marketplacesRoot) ? [marketplacesRoot] : []; + const prunedNamespace = await pruneEmptyDirectory(join(cursorRoot, 'agent-bundle')) ? [join(cursorRoot, 'agent-bundle')] : []; + return Object.freeze({ + ...base, + data, + destination: repoRoot, + ...(nextSteps.length === 0 ? {} : { nextSteps: Object.freeze(nextSteps) }), + receipt: receiptReport(receiptPath, receipt, status), + registrations: Object.freeze(registrations), + removed: Object.freeze({ + directories: Object.freeze([ + ...directories, + ...removedReceipt.filter((path) => path !== receiptPath), + ...prunedMarketplaces, + ...prunedNamespace, + ]), + files: Object.freeze(removedReceipt.filter((path) => path === receiptPath)), + }), + retained: Object.freeze([]), + state: 'uninstalled', + }); +}; + +/** + * What still depends on the marketplace once the `id`@`scope` copy is gone. + * `others`: installed rows of other plugins from the same marketplace, plus + * every other store receipt whose plugin installs from it (a Claude + * project-scope install elsewhere is invisible to `plugin list --json` run + * here, but its receipt is not). A receipt counts whether or not it records + * the marketplace registration itself: an install made after the marketplace + * already existed records only its plugin registration, and that plugin still + * needs the marketplace. `sameOtherScopes`: (Claude) the same plugin installed + * at another scope or in another project, from a live row, Claude's own + * registry, or a stored receipt — it shares the marketplace, whose `remove` + * applies to every scope, and the scope-less `plugins/data/`. For Claude, + * project- and local-scope installs made by hand in other projects have no + * receipt and are invisible to `plugin list --json` run here, so the host's + * cross-project registry `plugins/installed_plugins.json` is read as well: it + * is where Claude records every scope of every install. A `plugin list --json` + * or registry that cannot be read is `'unknown'`, never an empty list: a + * failed read is not proof that nothing depends on the marketplace, so the + * caller retains it (fail-closed). + */ +interface MarketplaceDependents { + readonly others: readonly string[]; + /** The stored receipts among the dependents: where a retained marketplace's ownership claim can move to. */ + readonly receipts: readonly StoredInstallReceipt[]; + readonly sameOtherScopes: readonly string[]; +} + +const claudeInstalledPluginsRegistry = 'plugins/installed_plugins.json'; + +interface ClaudeInstalledPluginsEntry { + readonly id: string; + readonly projectPath: string | undefined; + readonly scope: string; +} + +/** + * Claude's cross-scope, cross-project install registry (`{ plugins: { "@": [{ scope, projectPath?, ... }] } }`). + * Absent means Claude has never recorded an install into this config root (it writes the file on the first install and + * leaves `{ "plugins": {} }` behind after the last uninstall); any other read or parse failure is `'unknown'`. + */ +const readClaudeInstalledPluginsRegistry = async (hostRoot: string): Promise => { + let text: string; + try { + text = await readFile(join(hostRoot, claudeInstalledPluginsRegistry), 'utf8'); + } catch (error) { + return isErrno(error, 'ENOENT') ? Object.freeze([]) : 'unknown'; + } + let document: unknown; + try { + document = JSON.parse(text) as unknown; + } catch { + return 'unknown'; + } + if (typeof document !== 'object' || document === null) return 'unknown'; + const plugins = (document as { readonly plugins?: unknown }).plugins; + if (plugins === undefined) return Object.freeze([]); + if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) return 'unknown'; + const entries: ClaudeInstalledPluginsEntry[] = []; + for (const [id, value] of Object.entries(plugins as Record)) { + // Registry version 1 stored one object per id; version 2 stores one array of scoped installs per id. + const installs = Array.isArray(value) ? value : [value]; + for (const install of installs) { + if (typeof install !== 'object' || install === null) return 'unknown'; + const { projectPath, scope } = install as { readonly projectPath?: unknown; readonly scope?: unknown }; + if (typeof scope !== 'string' || (projectPath !== undefined && typeof projectPath !== 'string')) return 'unknown'; + entries.push(Object.freeze({ id, projectPath, scope })); + } + } + return Object.freeze(entries); +}; + +const marketplaceDependents = async ( + runner: InstallCommandRunner, + identity: PluginIdentity, + host: Exclude, + marketplace: string, + id: string, + scope: InstallScope, + projectRoot: string | undefined, + hostRoot: string, + receiptPath: string, +): Promise => { + const others: string[] = []; + const sameOtherScopes: string[] = []; + const seen = new Set(); + try { + const result = await runner.run(host, ['plugin', 'list', '--json'], { cwd: identity.bundleRoot }); + if (result.code !== 0) return 'unknown'; + const document = JSON.parse(result.stdout) as unknown; + const rows = host === 'claude' + ? document + : typeof document === 'object' && document !== null ? (document as { readonly installed?: unknown }).installed : undefined; + if (!Array.isArray(rows)) return 'unknown'; + for (const row of rows) { + if (typeof row !== 'object' || row === null) continue; + const candidate = (row as { readonly id?: unknown; readonly pluginId?: unknown; readonly scope?: unknown }); + const rowId = host === 'claude' ? candidate.id : candidate.pluginId; + if (typeof rowId !== 'string' || !rowId.endsWith(`@${marketplace}`)) continue; + if (rowId !== id) { + others.push(rowId); + seen.add(rowId); + } else if (host === 'claude' && typeof candidate.scope === 'string' && candidate.scope !== scope) { + sameOtherScopes.push(`${rowId} (scope ${candidate.scope})`); + seen.add(`${rowId}|${candidate.scope}`); + } + } + } catch { + return 'unknown'; + } + if (host === 'claude') { + const registry = await readClaudeInstalledPluginsRegistry(hostRoot); + if (registry === 'unknown') return 'unknown'; + for (const entry of registry) { + if (!entry.id.endsWith(`@${marketplace}`)) continue; + if (entry.id !== id) { + if (seen.has(entry.id)) continue; + seen.add(entry.id); + others.push(`${entry.id} (scope ${entry.scope}${entry.projectPath === undefined ? '' : ` in ${entry.projectPath}`}, per ${claudeInstalledPluginsRegistry})`); + continue; + } + // The copy being removed: same scope, and either no project or this project. + if (entry.scope === scope && (entry.projectPath === undefined || projectRoot === undefined || entry.projectPath === projectRoot)) continue; + const key = entry.projectPath === undefined ? `${entry.id}|${entry.scope}` : `${entry.id}|${entry.scope}|${entry.projectPath}`; + if (seen.has(key) || (entry.projectPath !== undefined && seen.has(`${entry.id}|${entry.scope}`))) continue; + seen.add(key); + sameOtherScopes.push(`${entry.id} (scope ${entry.scope}${entry.projectPath === undefined ? '' : ` in ${entry.projectPath}`}, per ${claudeInstalledPluginsRegistry})`); + } + } + const receipts: StoredInstallReceipt[] = []; + const store = await listStoredInstallReceipts(hostRoot); + // A store receipt that cannot be read may describe another install from this marketplace (or the one carrying + // its ownership claim); a failed read is not proof that nothing depends on it, so the answer is unknown. + if (store.unreadable.length > 0) return 'unknown'; + for (const stored of store.receipts) { + if (stored.path === receiptPath || stored.receipt.host !== host) continue; + const pluginRegistration = stored.receipt.registrations.find((registration) => registration.kind === `${host}-plugin`); + const installsFromMarketplace = stored.receipt.registrations.some((registration) => + registration.kind === `${host}-marketplace` && registration.name === marketplace) || + (pluginRegistration?.id !== undefined && pluginRegistration.id.endsWith(`@${marketplace}`)); + if (!installsFromMarketplace) continue; + receipts.push(stored); + if (pluginRegistration?.id === id) { + const where = stored.receipt.projectRoot === undefined ? '' : ` in ${stored.receipt.projectRoot}`; + sameOtherScopes.push(`receipt ${stored.path} (scope ${stored.receipt.scope}${where})`); + } else { + others.push(`receipt ${stored.path}`); + } + } + return Object.freeze({ + others: Object.freeze(others), + receipts: Object.freeze(receipts), + sameOtherScopes: Object.freeze(sameOtherScopes), + }); +}; + +const publicHostData = async ( + host: Exclude, + policy: UninstallDataPolicy, + entry: PublicHostInstalledEntry | undefined, + hostRoot: string, + id: string, + sharedWith: readonly string[] | 'unknown', +): Promise => { + if (host === 'codex') { + return Object.freeze({ + detail: policy === 'purge' + ? '`codex plugin remove` deletes the cached plugin tree, state/ included; nothing further to purge.' + : '`codex plugin remove` deletes the cached plugin tree, state/ included; Codex 0.147.0 exposes no keep-data option, so durable state cannot be preserved through a Codex uninstall.', + outcome: policy === 'purge' ? 'removed-by-host' : 'unavailable', + paths: Object.freeze(entry === undefined ? [] : [join(entry.installPath, 'state')]), + policy, + }); + } + const paths: string[] = []; + if (entry !== undefined && await realDirectory(join(entry.installPath, 'state'), host) !== undefined) { + paths.push(join(entry.installPath, 'state')); + } + const dataDirectory = join(hostRoot, 'plugins', 'data', id); + if (await realDirectory(dataDirectory, host) !== undefined) paths.push(dataDirectory); + if (paths.length === 0) { + return Object.freeze({ + detail: 'No durable runtime state exists (no state/ under the cached copy and no plugins/data entry).', + outcome: 'absent', + paths: Object.freeze([]), + policy, + }); + } + if (policy === 'purge' && (sharedWith === 'unknown' || sharedWith.length > 0)) { + // The cache copy and plugins/data/ are scope-less: another scope's install still uses them. + throw failure( + 'AB7008', + `Refusing --purge-data for ${id}: its durable state (${paths.join(', ')}) is shared with ` + + (sharedWith === 'unknown' + ? 'any other scope this plugin is installed at, and neither `claude plugin list --json` nor the Agent Bundle receipt store could be read to prove there is none' + : `${sharedWith.join(', ')}, which stays installed`) + + '. Uninstall this scope without --purge-data (the data is kept), and purge after the last scope is removed.', + host, + ); + } + return Object.freeze({ + detail: policy === 'purge' + ? 'Durable runtime state is removed after `claude plugin uninstall --keep-data` returns (--purge-data --confirm-purge).' + : '`claude plugin uninstall --keep-data` orphans the cached copy (state/ included) for Claude\'s ~14-day grace period and preserves plugins/data; pass --purge-data --confirm-purge to remove both now.', + outcome: policy === 'purge' ? 'purged' : 'retained-by-host', + paths: Object.freeze(paths), + policy, + }); +}; + +const uninstallPublicCli = async ( + options: UninstallBundleOptions, + identity: PluginIdentity, + host: Exclude, + scope: InstallScope, + policy: UninstallDataPolicy, +): Promise => { + if (host === 'codex' && scope !== 'user') { + throw failure('AB7003', `Codex plugin uninstallation supports only user scope, not ${scope}.`, host); + } + const marketplace = identity.marketplace; + if (marketplace === undefined) throw failure('AB7001', `${host} bundle has no marketplace identity.`, host); + const force = options.force === true; + const runner = options.commandRunner ?? defaultCommandRunner; + const environment = options.environment ?? process.env; + const home = options.home ?? homedir(); + const hostRoot = publicHostRoot(host, environment, home); + const id = `${identity.plugin}@${marketplace}`; + const receiptPath = publicHostReceiptPath(host, identity.plugin, marketplace, scope, environment, home, publicHostProjectRoot(host, scope, identity)); + const receipt = await readInstallReceiptFile(receiptPath); + const base = { + bundleRoot: identity.bundleRoot, + forced: force, + host, + marketplace, + mode: 'host-cli', + plugin: identity.plugin, + scope, + version: identity.version, + } as const; + const inventory = await readPublicHostInventory(runner, identity, host, scope, environment, home); + if (inventory.status === 'unavailable') { + throw failure( + 'AB7004', + `Cannot uninstall ${id} from ${host} safely: \`${host} plugin list --json\` was unusable (${inventory.detail}).`, + host, + ); + } + const entry = inventory.entries[0]; + if (entry === undefined && receipt === undefined) { + return Object.freeze({ + ...base, + data: Object.freeze({ + detail: 'Nothing is installed, so no durable runtime state is affected.', + outcome: 'absent', + paths: Object.freeze([]), + policy, + }), + receipt: receiptReport(receiptPath, undefined, 'missing'), + registrations: Object.freeze(publicHostRegistrations(host, id, marketplace, scope).map((registration) => + Object.freeze({ ...registration, action: 'already-absent' as const }))), + removed: Object.freeze({ directories: Object.freeze([]), files: Object.freeze([]) }), + retained: Object.freeze([]), + state: 'not-installed', + }); + } + let status: UninstallReceiptStatus = receipt === undefined + ? 'forced-missing' + : receipt.migratedFrom === undefined ? 'consumed' : 'migrated'; + if (entry !== undefined) { + if (receipt === undefined) { + if (!force) { + throw failure( + 'AB7009', + `Refusing to uninstall ${id} from ${host}${entry.scope === undefined ? '' : ` (scope ${entry.scope})`}: the host ` + + `reports it installed at ${entry.installPath} but no agent-bundle receipt exists at ${receiptPath}, so this ` + + 'install was not made by agent-bundle or predates lifecycle receipts. ' + + `Re-run with --force to uninstall through \`${host} plugin ${host === 'claude' ? 'uninstall' : 'remove'}\` anyway.`, + host, + ); + } + } else if (receipt.plugin !== identity.plugin) { + throw failure('AB7007', `Refusing to uninstall: the receipt at ${receiptPath} names plugin ${JSON.stringify(receipt.plugin)}.`, host); + } else { + let installedHash: string | undefined; + try { + installedHash = (await treeInventory(entry.installPath)).hash; + } catch (error) { + if (!isErrno(error, 'ENOENT')) { + if (!force) { + throw failure( + 'AB7007', + `Refusing to uninstall ${id}: the ${host} cached copy at ${entry.installPath} could not be compared with the ` + + `receipt (${errorMessage(error)}). Re-run with --force to uninstall through the host CLI anyway.`, + host, + ); + } + status = 'forced-mismatch'; + } + } + if (installedHash !== undefined && (installedHash !== receipt.contentHash || entry.version !== receipt.version)) { + if (!force) { + throw failure( + 'AB7007', + `Refusing to uninstall ${id}: the ${host} cached copy at ${entry.installPath} is ${entry.version} with content ` + + `${shortHash(installedHash)} but the receipt recorded ${receipt.version} with content ${shortHash(receipt.contentHash)}, ` + + 'so the installed copy was changed outside agent-bundle. Re-run with --force to uninstall it anyway.', + host, + ); + } + status = 'forced-mismatch'; + } + } + } + const defaults = publicHostRegistrations(host, id, marketplace, scope); + const pluginRegistration = (receipt?.registrations ?? defaults).find((registration) => registration.kind === `${host}-plugin`); + // The marketplace is Agent Bundle's to remove only when a receipt records that an install registered it. + // Without that record (no receipt, or the marketplace pre-existed the install) it is retained and said so. + const marketplaceRegistration = defaults.find((registration) => registration.kind === `${host}-marketplace`); + const marketplaceOwned = receipt !== undefined && + receipt.registrations.some((registration) => registration.kind === `${host}-marketplace`); + const marketplaceState = await readPublicHostMarketplaceState(runner, identity, host, marketplace); + // Dependents decide both whether the marketplace goes and whether Claude's scope-less durable state may be + // purged, so they are read whenever either decision is live — and always before any mutation. + const dependents = marketplaceState !== 'absent' || (host === 'claude' && policy === 'purge') + ? await marketplaceDependents(runner, identity, host, marketplace, id, scope, publicHostProjectRoot(host, scope, identity), hostRoot, receiptPath) + : Object.freeze({ others: Object.freeze([]), receipts: Object.freeze([]), sameOtherScopes: Object.freeze([]) }); + const dependentNames = dependents === 'unknown' ? 'unknown' : [...dependents.others, ...dependents.sameOtherScopes]; + const retainMarketplace = !marketplaceOwned || marketplaceState === 'unknown' || dependentNames === 'unknown' || + dependentNames.length > 0; + const planned = options.plan === true; + // Consuming the receipt that records the marketplace registration while dependents keep the marketplace alive + // would lose the only proof Agent Bundle created it, leaving the last uninstall to call it user-owned. The claim + // moves to a dependent's receipt instead (the first one not already recording it); with no dependent receipt to + // carry it — every dependent is a live row only — the loss is stated, not hidden. + const ownershipHeir: StoredInstallReceipt | 'already-recorded' | 'none' | undefined = + marketplaceOwned && retainMarketplace && marketplaceState !== 'absent' && dependents !== 'unknown' + ? dependents.receipts.some((stored) => stored.receipt.registrations.some((registration) => registration.kind === `${host}-marketplace`)) + ? 'already-recorded' + : dependents.receipts.length === 0 ? 'none' : dependents.receipts[0] + : undefined; + const ownershipDetail = ownershipHeir === undefined || ownershipHeir === 'already-recorded' + ? '' + : ownershipHeir === 'none' + ? ' This receipt was the only record that Agent Bundle registered the marketplace and no dependent has a receipt to ' + + 'carry that claim, so after this uninstall the marketplace counts as user-owned: remove it by hand once nothing installs from it.' + : ` The marketplace registration claim ${planned ? 'would move' : 'moves'} to receipt ${ownershipHeir.path} so the last ` + + 'uninstall can still remove it.'; + const data = await publicHostData(host, policy, entry, hostRoot, id, dependents === 'unknown' ? 'unknown' : dependents.sameOtherScopes); + const registrations: UninstallRegistrationReport[] = []; + if (pluginRegistration !== undefined) { + registrations.push(Object.freeze({ + ...pluginRegistration, + action: entry === undefined ? 'already-absent' : planned ? 'planned' : 'removed', + detail: entry === undefined + ? `${host} no longer lists ${id}${host === 'claude' ? ` at scope ${scope}` : ''}.` + : `\`${host} ${publicHostUninstallArguments(host, id, scope).join(' ')}\``, + })); + } + if (marketplaceRegistration !== undefined) { + registrations.push(Object.freeze({ + ...marketplaceRegistration, + action: marketplaceState === 'absent' + ? 'already-absent' + : retainMarketplace ? 'retained' : planned ? 'planned' : 'removed', + detail: marketplaceState === 'absent' + ? `${host} no longer lists marketplace ${marketplace}.` + : !marketplaceOwned + ? `Marketplace ${marketplace} stays registered: no Agent Bundle receipt records registering it, so it may have ` + + `been configured by hand or before lifecycle receipts. Run \`${host} ${publicHostMarketplaceRemoveArguments(marketplace).join(' ')}\` ` + + 'yourself once nothing else installs from it.' + : marketplaceState === 'unknown' || dependentNames === 'unknown' + ? `Marketplace ${marketplace} stays registered: ` + + (marketplaceState === 'unknown' + ? `\`${host} plugin marketplace list --json\` could not be read` + : `\`${host} plugin list --json\`, ${host === 'claude' ? `the ${claudeInstalledPluginsRegistry} registry, ` : ''}or a receipt in the Agent Bundle receipt store could not be read`) + + ' to prove nothing else installs from it, and `plugin marketplace remove` applies to every scope. ' + + 'Remove it by hand once the inventory is readable.' + : dependentNames.length > 0 + ? `Marketplace ${marketplace} stays registered: ${dependentNames.join(', ')} still install from it.${ownershipDetail}` + : `\`${host} ${publicHostMarketplaceRemoveArguments(marketplace).join(' ')}\``, + })); + } + const purgedDirectories = policy === 'purge' && data.outcome === 'purged' ? data.paths : []; + const result = { + ...base, + data, + ...(entry === undefined ? {} : { destination: entry.installPath }), + receipt: receiptReport(receiptPath, receipt, status), + registrations: Object.freeze(registrations), + retained: Object.freeze([]), + } as const; + if (planned) { + // The store pruning the run below performs, simulated: the receipt file, then the store directories it + // leaves empty, so the plan names every path the completed result would. + const wouldRemove = await simulateRemoveStoredInstallReceipt(receiptPath, hostRoot); + return Object.freeze({ + ...result, + removed: Object.freeze({ + directories: Object.freeze([...purgedDirectories, ...wouldRemove.filter((path) => path !== receiptPath)]), + files: Object.freeze(wouldRemove.filter((path) => path === receiptPath)), + }), + state: 'planned', + }); + } + if (entry !== undefined) { + await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal'); + } + if (marketplaceState !== 'absent' && !retainMarketplace) { + await runHostCommand(runner, identity, host, publicHostMarketplaceRemoveArguments(marketplace), 'removal'); + } + for (const path of purgedDirectories) await rm(path, { force: true, recursive: true }); + if (ownershipHeir !== undefined && ownershipHeir !== 'already-recorded' && ownershipHeir !== 'none') { + const heirRegistration = publicHostRegistrations(host, id, marketplace, ownershipHeir.receipt.scope) + .find((registration) => registration.kind === `${host}-marketplace`); + if (heirRegistration !== undefined) { + await writeStoredInstallReceipt(ownershipHeir.path, { + ...ownershipHeir.receipt, + registrations: Object.freeze([...ownershipHeir.receipt.registrations, heirRegistration]), + updatedAt: new Date().toISOString(), + }); + } + } + const removedReceipt = await removeStoredInstallReceipt(receiptPath, hostRoot); + return Object.freeze({ + ...result, + removed: Object.freeze({ + directories: Object.freeze([...purgedDirectories, ...removedReceipt.filter((path) => path !== receiptPath)]), + files: Object.freeze(removedReceipt.filter((path) => path === receiptPath)), + }), + state: 'uninstalled', + }); +}; + +const uninstallProgram = Effect.fnUntraced(function*( + options: UninstallBundleOptions, +): Effect.fn.Return { + const scope = options.scope ?? 'user'; + if (options.mode !== undefined && options.host !== 'cursor') { + return yield* Effect.fail(failure( + 'AB7003', + `Uninstall mode ${JSON.stringify(options.mode)} applies to the cursor host only.`, + options.host, + )); + } + const policy = resolveDataPolicy(options); + const identity = yield* liftPromise(() => readIdentity(options.from, options.host)); + switch (options.host) { + case 'claude': + return yield* liftPromise(() => uninstallPublicCli(options, identity, 'claude', scope, policy)); + case 'codex': + return yield* liftPromise(() => uninstallPublicCli(options, identity, 'codex', scope, policy)); + case 'cursor': { + if (scope !== 'user') { + return yield* Effect.fail(failure('AB7003', `Cursor plugin uninstallation supports only user scope, not ${scope}.`, 'cursor')); + } + // Cursor requires an existing home for install; uninstalling from a missing home is simply nothing to do. + return yield* liftPromise(() => options.mode === 'marketplace' + ? uninstallCursorMarketplace(options, identity, policy) + : uninstallCursorLocal(options, identity, policy)); + } + default: { + const exhaustive: never = options.host; + return yield* Effect.fail(failure('AB7000', `Unsupported uninstall host ${String(exhaustive)}.`, options.host)); + } + } +}); + +/** + * Uninstalls a plugin from a host, bounded by its install receipt. See the + * module comment for the policy; `--plan` (`options.plan`) returns the same + * report without changing anything. + */ +export const uninstallBundle = (options: UninstallBundleOptions): Promise => + runPromise(uninstallProgram(options)); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index d140e896a..b4d241be1 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -52,7 +52,7 @@ it('records exact immutable metadata for every built-in target', () => { const registry = createDefaultRegistry(); expect(registryMetadata(registry, 'portable')).toEqual({ - adapterRevision: '1.9.0', + adapterRevision: '1.10.0', observedVersion: '1.0.0', schemas: [ { @@ -68,7 +68,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'codex')).toEqual({ - adapterRevision: '1.12.0', + adapterRevision: '1.13.0', observedVersion: '0.147.0', schemas: [ { @@ -99,7 +99,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.26.0', + adapterRevision: '1.27.0', observedVersion: '2.1.250', schemas: [ { @@ -145,7 +145,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ - adapterRevision: '1.12.0', + adapterRevision: '1.13.0', observedVersion: '2026-08-28', schemas: [ { diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 189e4540c..684463b8f 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -11,7 +11,8 @@ import type { TargetArtifactWrite } from '../src/adapters/types.ts'; import { runCli } from '../src/cli.ts'; import { eventRuntimeEndpoint } from '../src/events/ipc.ts'; import { installBundle } from '../src/install/install.ts'; -import { treeInventory } from '../src/install/receipt.ts'; +import { emptyContentHash, installReceiptFormat, installReceiptScopeKey, treeInventory } from '../src/install/receipt.ts'; +import { uninstallBundle } from '../src/install/uninstall.ts'; import { doctorEndpointDirectory, doctorEndpointProbeConcurrency, @@ -914,6 +915,53 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof' } }); +it('lists Claude plugins from the resolved host bundle root when --from names a multi-target artifact root', async () => { + const fixture = await temporaryDoctor(); + const calls: { readonly args: readonly string[]; readonly cwd?: string }[] = []; + try { + // `/claude` holds the manifest; Claude `project`/`local` rows are keyed by the cwd the host verbs ran + // in, and install runs them from that resolved root — so must the listing, or such scopes read as absent. + const artifactRoot = join(fixture.root, 'artifact'); + await mkdir(artifactRoot, { recursive: true }); + const bundle = join(artifactRoot, 'claude'); + await mkdir(bundle, { recursive: true }); + await writeFile(join(bundle, 'payload.txt'), 'payload\n'); + await writeJson(join(bundle, '.claude-plugin/plugin.json'), { + author: { name: 'Doctor Fixture' }, + description: 'Doctor fixture plugin.', + name: 'doctor-fixture', + version: '1.2.3', + }); + await writeJson(join(bundle, '.claude-plugin/marketplace.json'), { + name: 'doctor-fixture-marketplace', + owner: { name: 'Doctor Fixture' }, + plugins: [{ name: 'doctor-fixture', source: './' }], + }); + + await runDoctor({ + commandRunner: async (request) => { + calls.push({ args: request.args, ...(request.cwd === undefined ? {} : { cwd: request.cwd }) }); + return request.args[0] === '--version' + ? commandResult({ stdout: 'claude 2.1.250\n' }) + : commandResult({ stdout: '[]' }); + }, + endpointDirectory: fixture.endpointDirectory, + from: artifactRoot, + home: fixture.home, + hosts: ['claude'], + }); + + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle }), + ])); + expect(calls).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: artifactRoot }), + ])); + } finally { + await fixture.cleanup(); + } +}); + it('skips static validation when Cursor home and --from are absent', async () => { const fixture = await temporaryDoctor(); try { @@ -1771,6 +1819,385 @@ it('compares the Codex cache copy against the artifact once plugin list --json n } }); +it('surfaces the placed → registered → enabled → active lifecycle per host, typing the unobservable stages unavailable', async () => { + const fixture = await temporaryDoctor(); + try { + const claudeBundle = await createBundle(fixture.root, 'claude'); + const installed = join(fixture.root, 'claude-config', 'plugins', 'cache', 'doctor-fixture-marketplace', 'doctor-fixture', '1.2.3'); + await cp(claudeBundle, installed, { recursive: true }); + let row: Record | undefined; + const runner: DoctorCommandRunner = async (request) => { + if (request.args[0] === '--version') return commandResult({ stdout: 'claude 2.1.257' }); + if (isInventoryRequest(request)) return commandResult({ stdout: JSON.stringify(row === undefined ? [] : [row]) }); + return commandResult({ stdout: JSON.stringify([{ id: 'doctor-fixture@inline' }]) }); + }; + const doctor = () => runDoctor({ + commandRunner: runner, + endpointDirectory: fixture.endpointDirectory, + from: claudeBundle, + home: fixture.home, + hosts: ['claude'], + }); + + const absent = hostReport(await doctor(), 'claude'); + expect(absent.bundle?.lifecycle).toMatchObject({ + active: { status: 'unavailable' }, + enabled: { status: 'observed', value: false }, + placed: { status: 'observed', value: false }, + registered: { status: 'observed', value: false }, + stage: 'absent', + }); + const absentLifecycle = absent.diagnostics.find((entry) => entry.code === 'AB7330'); + expect(absentLifecycle).toMatchObject({ severity: 'info', target: 'claude' }); + expect(absentLifecycle?.message).toContain('stage absent'); + expect(absentLifecycle?.message).toContain('active=unavailable'.replace('=', ' ')); + + row = { enabled: false, id: 'doctor-fixture@doctor-fixture-marketplace', installPath: installed, scope: 'user', version: '1.2.3' }; + const disabled = hostReport(await doctor(), 'claude'); + expect(disabled.bundle?.lifecycle).toMatchObject({ + enabled: { status: 'observed', value: false }, + placed: { status: 'observed', value: true }, + registered: { status: 'observed', value: true }, + stage: 'registered', + }); + expect(disabled.diagnostics.find((entry) => entry.code === 'AB7330')?.recovery).toContain('claude plugin enable'); + + row = { ...row, enabled: true }; + const enabled = hostReport(await doctor(), 'claude'); + expect(enabled.bundle?.lifecycle).toMatchObject({ + active: { status: 'unavailable' }, + enabled: { status: 'observed', value: true }, + stage: 'enabled', + }); + expect(enabled.diagnostics.find((entry) => entry.code === 'AB7330')?.message).toContain('Unavailable: active (Claude Code 2.1.257'); + + // Several Claude scopes: the lifecycle aggregates every listed copy, and a stage holds only when it holds for + // all of them — a disabled or unplaced copy at any scope is reported regardless of Claude's row order. + let rows: Record[] = []; + const multiRunner: DoctorCommandRunner = async (request) => { + if (request.args[0] === '--version') return commandResult({ stdout: 'claude 2.1.257' }); + if (isInventoryRequest(request)) return commandResult({ stdout: JSON.stringify(rows) }); + return commandResult({ stdout: JSON.stringify([{ id: 'doctor-fixture@inline' }]) }); + }; + const multiDoctor = () => runDoctor({ + commandRunner: multiRunner, + endpointDirectory: fixture.endpointDirectory, + from: claudeBundle, + home: fixture.home, + hosts: ['claude'], + }); + const userRow = { enabled: true, id: 'doctor-fixture@doctor-fixture-marketplace', installPath: installed, scope: 'user', version: '1.2.3' }; + rows = [userRow, { ...userRow, enabled: false, scope: 'project' }]; + const mixed = hostReport(await multiDoctor(), 'claude'); + expect(mixed.bundle?.lifecycle).toMatchObject({ + enabled: { evidence: expect.stringContaining('enabled: false for scope project'), status: 'observed', value: false }, + registered: { evidence: expect.stringContaining('scope user, scope project'), status: 'observed', value: true }, + stage: 'registered', + }); + rows = [{ ...userRow, scope: 'project', enabled: false }, userRow]; + expect(hostReport(await multiDoctor(), 'claude').bundle?.lifecycle).toMatchObject({ enabled: { value: false }, stage: 'registered' }); + rows = [userRow, { ...userRow, installPath: join(fixture.root, 'claude-config', 'nowhere'), scope: 'local' }]; + const unplaced = hostReport(await multiDoctor(), 'claude'); + expect(unplaced.bundle?.lifecycle).toMatchObject({ + placed: { evidence: expect.stringContaining('(scope local)'), status: 'observed', value: false }, + stage: 'absent', + }); + rows = [userRow, { ...userRow, enabled: undefined, scope: 'project' }]; + expect(hostReport(await multiDoctor(), 'claude').bundle?.lifecycle).toMatchObject({ + enabled: { reason: expect.stringContaining('scope project'), status: 'unavailable' }, + }); + rows = [userRow, { ...userRow, scope: 'project' }]; + expect(hostReport(await multiDoctor(), 'claude').bundle?.lifecycle).toMatchObject({ + enabled: { evidence: expect.stringContaining('scope user, scope project'), status: 'observed', value: true }, + stage: 'enabled', + }); + + // Cursor: placement is registration; enabled and active have no pinned read-only surface. + const cursorBundle = await createBundle(fixture.root, 'cursor'); + await mkdir(join(fixture.home, '.cursor'), { recursive: true }); + const cursorMissing = hostReport(await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: cursorBundle, + home: fixture.home, + hosts: ['cursor'], + }), 'cursor'); + expect(cursorMissing.bundle?.lifecycle).toMatchObject({ + enabled: { status: 'unavailable' }, + placed: { status: 'observed', value: false }, + stage: 'absent', + }); + await installBundle({ from: cursorBundle, home: fixture.home, host: 'cursor' }); + const cursorPlaced = hostReport(await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: cursorBundle, + home: fixture.home, + hosts: ['cursor'], + }), 'cursor'); + expect(cursorPlaced.bundle?.lifecycle).toMatchObject({ + active: { status: 'unavailable' }, + enabled: { reason: expect.stringContaining('enable_cc_plugin_import'), status: 'unavailable' }, + placed: { status: 'observed', value: true }, + registered: { status: 'observed', value: true }, + stage: 'registered', + }); + expect(cursorPlaced.bundle?.receipt).toMatchObject({ mode: 'local', scope: 'user' }); + expect(cursorPlaced.inventory.findings[0]?.receipt).toMatchObject({ mode: 'local' }); + } finally { + await fixture.cleanup(); + } +}); + +it('inventories store receipts, diagnoses orphaned ones (AB7328), and reports pre-lifecycle receipts as migrated (AB7329)', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'claude'); + const claudeConfig = join(fixture.root, 'claude-config'); + const installed = join(claudeConfig, 'plugins', 'cache', 'doctor-fixture-marketplace', 'doctor-fixture', '1.2.3'); + await cp(bundle, installed, { recursive: true }); + let rows: readonly unknown[] = []; + const doctorRunner: DoctorCommandRunner = async (request) => { + if (request.args[0] === '--version') return commandResult({ stdout: 'claude 2.1.257' }); + if (isInventoryRequest(request)) return commandResult({ stdout: JSON.stringify(rows) }); + return commandResult({ stdout: JSON.stringify([{ id: 'doctor-fixture@inline' }]) }); + }; + const installRunner = { + run: async (_command: string, args: readonly string[]) => ({ + code: 0, + stderr: '', + // No marketplace configured beforehand, so the install records (and owns) the marketplace registration. + stdout: args.join(' ') === 'plugin list --json' + ? JSON.stringify(rows) + : args.join(' ') === 'plugin marketplace list --json' ? JSON.stringify([]) : '', + }), + }; + const doctor = () => runDoctor({ + commandRunner: doctorRunner, + endpointDirectory: fixture.endpointDirectory, + environment: { CLAUDE_CONFIG_DIR: claudeConfig }, + home: fixture.home, + hosts: ['claude'], + }); + expect(hostReport(await doctor(), 'claude').receipts).toEqual([]); + + const result = await installBundle({ + commandRunner: installRunner, + environment: { CLAUDE_CONFIG_DIR: claudeConfig }, + from: bundle, + home: fixture.home, + host: 'claude', + }); + rows = [{ enabled: true, id: 'doctor-fixture@doctor-fixture-marketplace', installPath: installed, scope: 'user', version: '1.2.3' }]; + const consistent = hostReport(await doctor(), 'claude'); + expect(consistent.receipts).toEqual([expect.objectContaining({ + mode: 'host-cli', + path: result.receipt, + plugin: 'doctor-fixture', + registrations: [ + { kind: 'claude-marketplace', name: 'doctor-fixture-marketplace', scope: 'user' }, + { id: 'doctor-fixture@doctor-fixture-marketplace', kind: 'claude-plugin', scope: 'user' }, + ], + scope: 'user', + state: 'consistent', + })]); + expect(consistent.diagnostics.some((entry) => entry.code === 'AB7328')).toBe(false); + + // The host forgot the plugin (uninstalled behind agent-bundle's back): the receipt is orphaned. + rows = []; + const orphaned = hostReport(await doctor(), 'claude'); + expect(orphaned.receipts[0]?.state).toBe('orphaned'); + const orphanDiagnostic = orphaned.diagnostics.find((entry) => entry.code === 'AB7328'); + expect(orphanDiagnostic).toMatchObject({ severity: 'warning', target: 'claude' }); + expect(orphanDiagnostic?.recovery).toContain('agent-bundle uninstall claude'); + + // An unusable listing leaves the receipt state unknown rather than orphaned. + const unknownRunner: DoctorCommandRunner = async (request) => request.args[0] === '--version' + ? commandResult({ stdout: 'claude 2.1.257' }) + : commandResult({ stdout: 'not json' }); + const unknown = hostReport(await runDoctor({ + commandRunner: unknownRunner, + endpointDirectory: fixture.endpointDirectory, + environment: { CLAUDE_CONFIG_DIR: claudeConfig }, + home: fixture.home, + hosts: ['claude'], + }), 'claude'); + expect(unknown.receipts[0]?.state).toBe('unknown'); + + // A receipt file that is not a receipt is reported, never thrown. + await writeFile(join(claudeConfig, 'agent-bundle', 'receipts', 'broken.user.json'), '{"format":"nope"}\n'); + const broken = hostReport(await doctor(), 'claude'); + expect(broken.diagnostics.filter((entry) => entry.code === 'AB7328').some((entry) => entry.message.includes('not a valid install receipt'))).toBe(true); + + // The host executable cannot be probed at all: the store is still inventoried from disk, so the + // stored receipt (state unknown) and the malformed file are reported instead of hidden. + const absentHost: DoctorCommandRunner = async () => commandResult({ exitCode: 127, stderr: 'claude: command not found' }); + const unprobed = hostReport(await runDoctor({ + commandRunner: absentHost, + endpointDirectory: fixture.endpointDirectory, + environment: { CLAUDE_CONFIG_DIR: claudeConfig }, + home: fixture.home, + hosts: ['claude'], + }), 'claude'); + expect(unprobed.probe.status).not.toBe('available'); + expect(unprobed.receipts).toEqual([expect.objectContaining({ path: result.receipt, state: 'unknown' })]); + expect(unprobed.diagnostics.filter((entry) => entry.code === 'AB7328').some((entry) => entry.message.includes('not a valid install receipt'))).toBe(true); + await rm(join(claudeConfig, 'agent-bundle', 'receipts', 'broken.user.json')); + + // A Cursor local copy whose receipt predates format/2 is diagnosed as migrated, never rewritten by Doctor. + const cursorBundle = await createBundle(fixture.root, 'cursor'); + await mkdir(join(fixture.home, '.cursor'), { recursive: true }); + await installBundle({ from: cursorBundle, home: fixture.home, host: 'cursor' }); + const destination = join(fixture.home, '.cursor', 'plugins', 'local', 'doctor-fixture'); + const receiptPath = join(destination, '.agent-bundle-install.json'); + const written = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; + const { hostDirectories: _h, mode: _m, registrations: _r, scope: _s, updatedAt: _u, ...legacy } = written; + await writeFile(receiptPath, JSON.stringify({ ...legacy, format: 'agent-bundle-install-receipt/1' })); + const migrated = hostReport(await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: cursorBundle, + home: fixture.home, + hosts: ['cursor'], + }), 'cursor'); + expect(migrated.inventory.findings[0]?.receipt).toMatchObject({ format: 'agent-bundle-install-receipt/1', migratedFrom: 'agent-bundle-install-receipt/1' }); + expect(migrated.bundle).toMatchObject({ receipt: { migratedFrom: 'agent-bundle-install-receipt/1' }, state: 'installed' }); + const migration = migrated.diagnostics.filter((entry) => entry.code === 'AB7329'); + expect(migration).toHaveLength(1); + expect(migration[0]).toMatchObject({ severity: 'info', target: 'cursor' }); + expect(migration[0]?.recovery).toContain('agent-bundle-install-receipt/2'); + expect(await readFile(receiptPath, 'utf8')).toContain('agent-bundle-install-receipt/1'); + } finally { + await fixture.cleanup(); + } +}); + +it('cross-checks Claude project-scope receipts from their recorded project root, not the doctor cwd', async () => { + const fixture = await temporaryDoctor(); + try { + const claudeConfig = join(fixture.root, 'claude-config'); + const projectRoot = join(fixture.root, 'elsewhere-project'); + const goneRoot = join(fixture.root, 'gone-project'); + await mkdir(projectRoot, { recursive: true }); + const id = 'doctor-fixture@doctor-fixture-marketplace'; + const installed = join(claudeConfig, 'plugins', 'cache', 'doctor-fixture-marketplace', 'doctor-fixture', '1.2.3'); + const receipt = (root: string) => ({ + contentHash: emptyContentHash, + directories: [], + files: [], + format: installReceiptFormat, + host: 'claude', + hostDirectories: [], + installedAt: '2026-09-03T00:00:00.000Z', + mode: 'host-cli', + plugin: 'doctor-fixture', + projectRoot: root, + registrations: [{ id, kind: 'claude-plugin', scope: 'project' }], + scope: 'project', + updatedAt: '2026-09-03T00:00:00.000Z', + version: '1.2.3', + }); + const receipts = join(claudeConfig, 'agent-bundle', 'receipts'); + const elsewhere = join(receipts, `doctor-fixture.doctor-fixture-marketplace.${installReceiptScopeKey('project', projectRoot)}.json`); + const gone = join(receipts, `doctor-fixture.doctor-fixture-marketplace.${installReceiptScopeKey('project', goneRoot)}.json`); + await writeJson(elsewhere, receipt(projectRoot)); + await writeJson(gone, receipt(goneRoot)); + const inventoryCwds: string[] = []; + // `plugin list --json` sees the project registration only from the project it belongs to; a root that + // no longer exists cannot run the host at all. + const doctorRunner: DoctorCommandRunner = async (request) => { + if (request.args[0] === '--version') return commandResult({ stdout: 'claude 2.1.257' }); + if (isInventoryRequest(request)) { + inventoryCwds.push(request.cwd); + if (request.cwd === goneRoot) return commandResult({ exitCode: 1, stderr: `spawn claude ENOENT (cwd ${goneRoot})` }); + return commandResult({ + stdout: JSON.stringify(request.cwd === projectRoot + ? [{ enabled: true, id, installPath: installed, scope: 'project', version: '1.2.3' }] + : []), + }); + } + return commandResult({ stdout: '[]' }); + }; + const report = hostReport(await runDoctor({ + commandRunner: doctorRunner, + endpointDirectory: fixture.endpointDirectory, + environment: { CLAUDE_CONFIG_DIR: claudeConfig }, + home: fixture.home, + hosts: ['claude'], + }), 'claude'); + expect(report.receipts).toHaveLength(2); + expect(report.receipts.find((entry) => entry.path === elsewhere)).toMatchObject({ scope: 'project', state: 'consistent' }); + expect(report.receipts.find((entry) => entry.path === gone)).toMatchObject({ scope: 'project', state: 'unknown' }); + expect(report.diagnostics.some((entry) => entry.code === 'AB7328')).toBe(false); + // The doctor cwd (home) once, then one listing per recorded project root. + expect(inventoryCwds[0]).toBe(fixture.home); + expect(inventoryCwds.slice(1).sort()).toEqual([goneRoot, projectRoot].sort()); + } finally { + await fixture.cleanup(); + } +}); + +it('explains a Cursor directory holding only preserved runtime state instead of calling it corrupt or foreign', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'cursor'); + await mkdir(join(fixture.home, '.cursor'), { recursive: true }); + const destination = join(fixture.home, '.cursor', 'plugins', 'local', 'doctor-fixture'); + await mkdir(join(destination, 'state'), { recursive: true }); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + const doctor = () => runDoctor({ endpointDirectory: fixture.endpointDirectory, from: bundle, home: fixture.home, hosts: ['cursor'] }); + + // Without any receipt (a hand-cleaned directory), the state-only shell is still not foreign. + const bare = hostReport(await doctor(), 'cursor'); + expect(bare.inventory.findings).toEqual([expect.objectContaining({ path: destination, state: 'missing' })]); + expect(bare.bundle).toMatchObject({ comparison: { status: 'not-installed' }, state: 'missing' }); + expect(bare.diagnostics.filter((entry) => entry.severity !== 'info')).toEqual([]); + expect(bare.diagnostics.filter((entry) => entry.code === 'AB7307').every((entry) => entry.message.includes('preserved runtime state'))).toBe(true); + + // With the remnant receipt `uninstall --keep-data` writes, Doctor names the plugin and the receipt too. + await rm(destination, { force: true, recursive: true }); + await installBundle({ from: bundle, home: fixture.home, host: 'cursor' }); + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await uninstallBundle({ from: bundle, home: fixture.home, host: 'cursor' }); + const remnant = hostReport(await doctor(), 'cursor'); + expect(remnant.inventory.findings).toEqual([expect.objectContaining({ + durableState: expect.objectContaining({ summary: { bytes: 8, stores: 1 } }), + name: 'doctor-fixture', + path: destination, + receipt: expect.objectContaining({ mode: 'local' }), + state: 'missing', + })]); + expect(remnant.bundle).toMatchObject({ lifecycle: { stage: 'absent' }, state: 'missing' }); + expect(remnant.diagnostics.filter((entry) => entry.severity !== 'info')).toEqual([]); + const remnantCodes = remnant.diagnostics.filter((entry) => entry.code === 'AB7307'); + expect(remnantCodes.length).toBeGreaterThan(0); + expect(remnantCodes.every((entry) => entry.message.includes('holds only preserved runtime state'))).toBe(true); + + // A remnant receipt also guarding unowned entries the uninstall retained is not called state-only: Doctor + // names the retained entries and points at removing them by hand, since `uninstall` never will. Both the + // inventory finding and the exact-bundle (`--from`) finding check the directory contents, not just the receipt. + await writeFile(join(destination, 'operator-notes.md'), 'mine\n'); + const withExtras = hostReport(await doctor(), 'cursor'); + expect(withExtras.inventory.findings).toEqual([expect.objectContaining({ path: destination, state: 'missing' })]); + expect(withExtras.bundle).toMatchObject({ comparison: { status: 'not-installed' }, state: 'missing' }); + const extras = withExtras.diagnostics.filter((entry) => entry.code === 'AB7307'); + expect(extras.length).toBe(remnantCodes.length); + for (const entry of extras) { + expect(entry.message).toContain('retained the unowned entry "operator-notes.md" beside preserved runtime state'); + expect(entry.message).not.toContain('holds only preserved runtime state'); + expect(entry.recovery).toContain('never removes unowned entries'); + } + + // Without any state left, a remnant receipt over unowned entries is still not "state-only". + await rm(join(destination, 'state'), { force: true, recursive: true }); + const noState = hostReport(await doctor(), 'cursor'); + for (const entry of noState.diagnostics.filter((item) => item.code === 'AB7307')) { + expect(entry.message).toContain('retained the unowned entry "operator-notes.md"'); + expect(entry.message).not.toContain('beside preserved runtime state'); + } + } finally { + await fixture.cleanup(); + } +}); + const serverSockets = new WeakMap>(); const listen = async (path: string, response?: unknown): Promise => { @@ -2111,6 +2538,7 @@ it('prints human Doctor output and exits zero for warnings', async () => { host: 'cursor', inventory: Object.freeze({ findings: Object.freeze([]), status: 'known' }), probe: Object.freeze({ evidence: 'directory', status: 'available' }), + receipts: Object.freeze([]), }]); const code = await runCli( ['doctor'], @@ -2689,7 +3117,23 @@ it('tracks staged Cursor marketplaces from staged to imported', async () => { await writeFile(join(repo, 'plugins', 'doctor-fixture', 'payload.txt'), 'changed\n'); const drifted = await doctor(); expect(hostReport(drifted, 'cursor').bundle?.state).toBe('drifted'); - expect(drifted.diagnostics.filter((entry) => entry.code === 'AB7308')[0]?.message).toContain('Staged Cursor marketplace copy'); + const driftedDiagnostic = drifted.diagnostics.filter((entry) => entry.code === 'AB7308')[0]; + expect(driftedDiagnostic?.message).toContain('Staged Cursor marketplace copy'); + // Cursor already imported this staging: drift of the staged bytes does not un-import the copy Cursor holds, + // so the lifecycle stays registered, the message says the imported plugin is the older content, and the + // recovery is the managed uninstall + reinstall + re-import, not a bare directory removal. + expect(driftedDiagnostic?.message).toContain('Cursor has imported the staged copy'); + expect(driftedDiagnostic?.recovery).toContain('agent-bundle uninstall cursor --mode marketplace'); + expect(hostReport(drifted, 'cursor').bundle?.lifecycle?.registered).toMatchObject({ status: 'observed', value: true }); + + // Without an imported copy the same drift is only a stale staging: not registered, remove and restage. + await rm(cached, { recursive: true }); + const driftedUnimported = await doctor(); + expect(hostReport(driftedUnimported, 'cursor').bundle?.state).toBe('drifted'); + expect(hostReport(driftedUnimported, 'cursor').bundle?.lifecycle?.registered).toMatchObject({ status: 'observed', value: false }); + expect(driftedUnimported.diagnostics.filter((entry) => entry.code === 'AB7308')[0]?.recovery).toContain('Remove the staged marketplace directory'); + await writeJson(join(cached, '.cursor-plugin', 'plugin.json'), { name: 'doctor-fixture', version: '1.2.3' }); + await writeFile(join(cached, '.cache-complete'), ''); // A parseable manifest that no longer lists the staged plugin is corrupt even when the cache matches. await writeJson(join(repo, '.cursor-plugin/marketplace.json'), { diff --git a/packages/agent-bundle/tests/host-discovery-service.test.ts b/packages/agent-bundle/tests/host-discovery-service.test.ts index ec5122ae5..bb365f79e 100644 --- a/packages/agent-bundle/tests/host-discovery-service.test.ts +++ b/packages/agent-bundle/tests/host-discovery-service.test.ts @@ -58,11 +58,15 @@ const doctorReport = Object.freeze({ status: 'unknown' as const, }), probe: Object.freeze({ status: 'available' as const, version: '2.1.250' }), + receipts: Object.freeze([]), }), ]), summary: Object.freeze({ errors: 0, infos: 1, warnings: 0 }), }) satisfies DoctorReport; +/** What the discovery envelope carries per host: the Doctor report minus the CLI-only receipt store inventory. */ +const projectedHosts = doctorReport.hosts.map(({ receipts: _receipts, ...host }) => host); + it('forwards Doctor options and projects the prepared bundle into the discovery envelope', async () => { const calls: DoctorOptions[] = []; const commandRunner: DoctorCommandRunner = async () => Object.freeze({ @@ -104,7 +108,8 @@ it('forwards Doctor options and projects the prepared bundle into the discovery diagnostics: doctorReport.diagnostics, endpoints: doctorReport.endpoints, generatedAt: '2026-09-02T05:00:00.000Z', - hosts: doctorReport.hosts, + // The discovery envelope projects the Doctor host report; lifecycle receipts stay CLI-only (G6: no Workbench mutation surface). + hosts: projectedHosts, manifestDigest: 'revision-a', summary: doctorReport.summary, } satisfies HostDiscoveryReport); @@ -130,7 +135,7 @@ it('reports an absent prepared build without treating it as an error', async () expect(report).not.toHaveProperty('manifestDigest'); expect(report.summary.errors).toBe(0); expect(report.diagnostics).toEqual(doctorReport.diagnostics); - expect(report.hosts).toEqual(doctorReport.hosts); + expect(report.hosts).toEqual(projectedHosts); expect(report.endpoints).toEqual(doctorReport.endpoints); expect(report.summary).toEqual(doctorReport.summary); }); diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index 2aaee15c4..e57a463ad 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -14,7 +14,9 @@ import { runClaudeHostInstallProof, runCodexHostInstallProof, runCursorHostInstallProof, + runHostUninstallProof, runPortableHostInstallProof, + runPortableUninstallProof, type BuiltHostInstallFixture, type BuiltPortableHostInstallFixture, } from './support/host-install.ts'; @@ -514,3 +516,101 @@ it( }, 180_000, ); + +claudePluginIt( + claudeAvailable + ? 'uninstalls through Claude by its receipt, leaving only classified host-owned bookkeeping behind' + : `uninstalls through Claude by its receipt, leaving only classified host-owned bookkeeping behind [${claudeMissingEvidence}]`, + async () => { + const report = await runHostUninstallProof(builtFixture(), 'claude', { environment: process.env }); + + expect(report, proofLabel).toEqual({ + agentBundleResidue: [], + // Claude 2.1.257 orphans the cached copy (.orphaned_at, ~14-day grace), keeps its empty registries and + // settings, and writes session bookkeeping; none of it is Agent Bundle's, all of it is enumerated. + homeByteIdentical: false, + host: 'claude', + hostResidue: [ + 'claude-orphaned-cache-copy', + 'claude-plugin-registry-files', + 'claude-session-bookkeeping', + 'claude-settings', + ], + keepData: 'retained-by-host', + plan: 'no-op', + proofLevel: proofLabel, + purgeData: 'purged', + refusals: { foreignOrMismatch: 'AB7007', missingReceipt: 'AB7009', unconfirmedPurge: 'AB7008' }, + registrations: { 'claude-marketplace': 'removed', 'claude-plugin': 'removed' }, + rerun: 'not-installed', + status: 'passed', + }); + expectHygienicReport(report); + }, + 300_000, +); + +codexPluginIt( + codexAvailable + ? 'uninstalls through Codex by its receipt, leaving only empty host directories and an empty config.toml behind' + : `uninstalls through Codex by its receipt, leaving only empty host directories and an empty config.toml behind [${codexMissingEvidence}]`, + async () => { + const report = await runHostUninstallProof(builtFixture(), 'codex', { environment: process.env }); + + expect(report, proofLabel).toEqual({ + agentBundleResidue: [], + homeByteIdentical: false, + host: 'codex', + hostResidue: ['codex-empty-config', 'codex-empty-directories'], + // codex-cli 0.147.0 deletes the cached tree (state/ included) on `plugin remove` and has no keep-data option. + keepData: 'unavailable', + plan: 'no-op', + proofLevel: proofLabel, + purgeData: 'removed-by-host', + refusals: { foreignOrMismatch: 'AB7007', missingReceipt: 'AB7009', unconfirmedPurge: 'AB7008' }, + registrations: { 'codex-marketplace': 'removed', 'codex-plugin': 'removed' }, + rerun: 'not-installed', + status: 'passed', + }); + expectHygienicReport(report); + }, + 300_000, +); + +it('uninstalls the Cursor local copy by its receipt and leaves the isolated home byte-identical', async () => { + const report = await runHostUninstallProof(builtFixture(), 'cursor', { environment: process.env }); + + expect(report, proofLabel).toEqual({ + agentBundleResidue: [], + homeByteIdentical: true, + host: 'cursor', + hostResidue: [], + keepData: 'kept', + plan: 'no-op', + proofLevel: proofLabel, + purgeData: 'purged', + refusals: { foreignOrMismatch: 'AB7007', missingReceipt: 'AB7009', unconfirmedPurge: 'AB7008' }, + registrations: { 'cursor-local-plugin': 'removed' }, + rerun: 'not-installed', + status: 'passed', + }); + expectHygienicReport(report); +}, 180_000); + +it('uninstalls the emitted Agent Plugins package through install.mjs --uninstall and leaves the isolated home byte-identical', async () => { + const report = await runPortableUninstallProof(builtPortableFixture(), { environment: process.env }); + + expect(report, proofLabel).toEqual({ + homeByteIdentical: true, + host: 'cursor', + installer: 'emitted install.mjs --uninstall', + keepData: 'kept', + plan: 'no-op', + proofLevel: 'host-install (emitted install.mjs + isolated Cursor home filesystem + pinned Agent Plugins 1.0.0 schemas; NOT IDE plugin-loader evidence)', + purgeData: 'purged', + refusals: { foreign: 'refused', missingReceipt: 'refused', unconfirmedPurge: 'refused' }, + rerun: 'not-installed', + status: 'passed', + }); + expectHygienicReport(report); +}, 180_000); diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 0acf62735..cb3d4f386 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -9,7 +9,15 @@ import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { TargetArtifactWrite } from '../src/adapters/types.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; -import { installReceiptFile, readInstallReceipt, treeInventory } from '../src/install/receipt.ts'; +import { + installReceiptFile, + installReceiptFormat, + legacyInstallReceiptFormat, + readInstallReceipt, + readInstallReceiptFile, + treeInventory, +} from '../src/install/receipt.ts'; +import { diffTreeSnapshots, snapshotTree } from './support/tree-snapshot.ts'; const execFile = promisify(executeFile); @@ -252,6 +260,42 @@ it('emitted install.mjs expands Agent Plugins placeholders for the Cursor copy o expect(repaired.stdout).toContain('Replaced install-fixture@1.2.3'); expect((await readInstallReceipt(destination))?.cursorExpansion?.pluginRoot).toBe(destination); + // PLUGIN_DATA through --uninstall: a written directory is durable state — kept by default behind a remnant + // receipt that carries the expansion (the data lives outside the plugin root, so the root stays to own it), + // purged only by --purge-data --confirm-purge; an empty, installer-created one is pruned with its parents. + await mkdir(join(pluginData, 'cache'), { recursive: true }); + await writeFile(join(pluginData, 'cache', 'index.json'), '{}\n'); + const keptPlan = await run(installer, ['--uninstall', '--plan'], home); + expect(keptPlan).toMatchObject({ code: 0, stderr: '' }); + expect(keptPlan.stdout).toContain(`Data (keep): kept — Durable runtime state — the PLUGIN_DATA directory ${pluginData} — is kept`); + expect(keptPlan.stdout).toContain('Remnant receipt (would be written)'); + const kept = await run(installer, ['--uninstall'], home); + expect(kept).toMatchObject({ code: 0, stderr: '' }); + expect(kept.stdout).toContain('Uninstalled install-fixture@1.2.3'); + expect(await readFile(join(pluginData, 'cache', 'index.json'), 'utf8')).toBe('{}\n'); + expect(await readInstallReceipt(destination)).toMatchObject({ + cursorExpansion: { pluginData, pluginRoot: destination }, + files: [], + registrations: [], + }); + expect(await readdir(destination)).toEqual([installReceiptFile]); + const purged = await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home); + expect(purged).toMatchObject({ code: 0, stderr: '' }); + expect(purged.stdout).toContain(`Data (purge): purged — Durable runtime state — the PLUGIN_DATA directory ${pluginData} — is removed`); + expect(await stat(pluginData).catch(() => undefined)).toBeUndefined(); + expect(await stat(join(home, '.cursor', 'agent-bundle')).catch(() => undefined)).toBeUndefined(); + expect(await stat(destination).catch(() => undefined)).toBeUndefined(); + // Fresh install, nothing written to PLUGIN_DATA: the default uninstall prunes it (and the empty agent-bundle parents). + await writeFile(join(bundle, 'mcp.json'), mcpText); + expect(await run(installer, [], home)).toMatchObject({ code: 0, stderr: '' }); + expect((await stat(pluginData)).isDirectory()).toBe(true); + const emptyPlan = await run(installer, ['--uninstall', '--plan'], home); + expect(emptyPlan.stdout).toContain(`the installer-created PLUGIN_DATA directory ${pluginData} is empty and is pruned`); + expect(emptyPlan.stdout).toContain(` ${pluginData}\n`); + expect(await run(installer, ['--uninstall'], home)).toMatchObject({ code: 0, stderr: '' }); + expect(await stat(pluginData).catch(() => undefined)).toBeUndefined(); + expect(await stat(join(home, '.cursor', 'agent-bundle')).catch(() => undefined)).toBeUndefined(); + // A skills-only Agent Plugins pack (no stdio server) is copied byte-identically and records no expansion. await rm(destination, { force: true, recursive: true }); await writeFile(join(bundle, 'mcp.json'), `${JSON.stringify({ @@ -304,13 +348,26 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve expect(install).toContain(installReceiptFile); expect(install).toContain('--replace'); expect(install).toContain('`state/`'); + expect(install).toContain('### Uninstall'); + expect(install).toContain('node ./install.mjs --uninstall --plan'); + expect(install).toContain('--purge-data --confirm-purge'); } + expect(claude).toContain('agent-bundle uninstall claude --from ./ --plan'); + expect(claude).toContain('~/.claude/agent-bundle/receipts/install-fixture.install-fixture-marketplace.user.json'); + expect(codex).toContain('agent-bundle uninstall codex --from ./ --plan'); + expect(codex).toContain('no\nkeep-data option'); expect(writesFor('cursor').get('INSTALL.md')).toContain('node ./install.mjs --replace'); expect(writesFor('cursor').get('INSTALL.md')).toContain('content-hash comparison'); const installer = writesFor('cursor').get('install.mjs') ?? ''; expect(installer).toContain("argument === '--replace' || argument === '--force'"); expect(installer).toContain(`const receiptFile = ${JSON.stringify(installReceiptFile)};`); + expect(installer).toContain(`const receiptFormat = ${JSON.stringify(installReceiptFormat)};`); + expect(installer).toContain(`const legacyReceiptFormat = ${JSON.stringify(legacyInstallReceiptFormat)};`); + expect(installer).toContain("if (uninstall && mode === 'local') {"); + expect(installer).toContain("if (uninstall && mode === 'marketplace') {"); + expect(installer).toContain('Refusing to uninstall foreign directory'); + expect(installer).toContain('--purge-data deletes the plugin\'s durable runtime state'); expect(installer).toContain('Refusing foreign install'); expect(installer).toContain('Refusing content collision'); expect(installer).toContain('Refusing version collision'); @@ -398,13 +455,18 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla expect((await readdir(destination)).sort()).toEqual([installReceiptFile, '.cursor-plugin', 'INSTALL.md', 'install.mjs', 'payload.txt', 'removed-later.txt']); await rm(join(bundle, 'empty'), { recursive: true }); const firstArtifact = await treeInventory(bundle); - // The emitted receipt is byte-compatible with the core reader. + // The emitted receipt is byte-compatible with the core reader, lifecycle fields included (#101). expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: firstArtifact.hash, directories: ['.cursor-plugin'], files: firstArtifact.files, + format: installReceiptFormat, host: 'cursor', + hostDirectories: ['plugins', 'plugins/local'], + mode: 'local', plugin: 'install-fixture', + registrations: [{ kind: 'cursor-local-plugin' }], + scope: 'user', version: '1.2.3', }); @@ -590,3 +652,224 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla await rm(root, { force: true, recursive: true }); } }, 60_000); + +it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-owned removal, data policy, refusals', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-uninstall-mjs-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const cursorRoot = join(home, '.cursor'); + const destination = join(cursorRoot, 'plugins', 'local', 'install-fixture'); + const installer = join(bundle, 'install.mjs'); + try { + const writes = writesFor('cursor'); + await mkdir(join(bundle, '.cursor-plugin'), { recursive: true }); + await mkdir(join(bundle, 'skills', 'probe'), { recursive: true }); + await mkdir(cursorRoot, { recursive: true }); + await writeFile(join(cursorRoot, 'operator.json'), '{}\n'); + await Promise.all([ + writeFile(installer, writes.get('install.mjs') ?? ''), + writeFile(join(bundle, 'INSTALL.md'), writes.get('INSTALL.md') ?? ''), + writeFile(join(bundle, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })), + writeFile(join(bundle, 'payload.txt'), 'payload\n'), + writeFile(join(bundle, 'skills', 'probe', 'SKILL.md'), '# probe\n'), + ]); + const before = await snapshotTree(home); + + // Flag validation happens before anything is read or written. + for (const [args, message] of [ + [['--plan'], '--plan, --keep-data, --purge-data, and --confirm-purge apply to --uninstall only.'], + [['--uninstall', '--purge-data'], '--purge-data deletes the plugin\'s durable runtime state'], + [['--uninstall', '--purge-data', '--confirm-purge', '--keep-data'], '--keep-data and --purge-data are mutually exclusive.'], + ] as const) { + const refused = await run(installer, [...args], home); + expect(refused.code, args.join(' ')).toBe(2); + expect(refused.stderr, args.join(' ')).toContain(message); + } + const nothing = await run(installer, ['--uninstall'], home); + expect(nothing).toMatchObject({ code: 0, stderr: '' }); + expect(nothing.stdout).toContain(`Not installed install-fixture@1.2.3 for cursor (local mode) at ${destination}`); + expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); + + expect((await run(installer, [], home)).stdout).toContain('Installed install-fixture@1.2.3'); + const afterInstall = await snapshotTree(home); + const receipt = await readInstallReceipt(destination); + expect(receipt?.hostDirectories).toEqual(['plugins', 'plugins/local']); + + // --plan prints every exact path and changes nothing. + const plan = await run(installer, ['--uninstall', '--plan'], home); + expect(plan).toMatchObject({ code: 0, stderr: '' }); + expect(plan.stdout).toContain(`Would uninstall install-fixture@1.2.3 for cursor (local mode) at ${destination}`); + expect(plan.stdout).toContain(`Receipt: consumed (${join(destination, installReceiptFile)})`); + for (const file of receipt?.files ?? []) expect(plan.stdout).toContain(join(destination, file)); + expect(plan.stdout).toContain(join(cursorRoot, 'plugins', 'local')); + expect(plan.stdout).toContain('Data (keep): absent'); + expect(diffTreeSnapshots(afterInstall, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); + + // Real uninstall: the home is byte-identical to before the install; a rerun is a no-op. + const uninstalled = await run(installer, ['--uninstall'], home); + expect(uninstalled).toMatchObject({ code: 0, stderr: '' }); + expect(uninstalled.stdout).toContain(`Uninstalled install-fixture@1.2.3 for cursor (local mode) at ${destination}`); + expect(uninstalled.stdout).toContain(`Removed directory 6 entries:`); + expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); + expect((await run(installer, ['--uninstall'], home)).stdout).toContain('Not installed install-fixture@1.2.3'); + + // Durable state and unowned files survive a default uninstall; state goes only with confirmed --purge-data. + await run(installer, [], home); + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await writeFile(join(destination, 'notes.md'), 'operator\n'); + // An unowned empty directory is never pruned (only owned directories are candidates) and is listed as retained. + await mkdir(join(destination, 'scratch')); + const keepPlan = await run(installer, ['--uninstall', '--keep-data', '--plan'], home); + expect(keepPlan).toMatchObject({ code: 0, stderr: '' }); + expect(keepPlan.stdout).toContain(`Retained unowned under ${destination}:`); + expect(keepPlan.stdout).toContain(' notes.md'); + expect(keepPlan.stdout).toContain(' scratch/'); + const kept = await run(installer, ['--uninstall', '--keep-data'], home); + expect(kept).toMatchObject({ code: 0, stderr: '' }); + expect(kept.stdout).toContain(`Data (keep): kept`); + expect(kept.stdout).toContain(`Retained unowned under ${destination}:`); + expect(kept.stdout).toContain(' notes.md'); + expect(kept.stdout).toContain(' scratch/'); + expect(kept.stdout).toContain(`Remnant receipt: ${join(destination, installReceiptFile)}`); + expect((await readdir(destination)).sort()).toEqual([installReceiptFile, 'notes.md', 'scratch', 'state']); + // The remnant receipt owns nothing and remembers the host directories the install created. + expect(await readInstallReceipt(destination)).toMatchObject({ files: [], hostDirectories: ['plugins', 'plugins/local'], registrations: [] }); + await rm(join(destination, 'notes.md')); + await rm(join(destination, 'scratch'), { recursive: true }); + // Reinstalling around the preserved state is an install, not a foreign-directory refusal. + const reinstalled = await run(installer, [], home); + expect(reinstalled).toMatchObject({ code: 0, stderr: '' }); + expect(reinstalled.stdout).toContain('Installed install-fixture@1.2.3'); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + const purged = await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home); + expect(purged).toMatchObject({ code: 0, stderr: '' }); + expect(purged.stdout).toContain('Data (purge): purged'); + expect(purged.stdout).toContain(join(destination, 'state')); + expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); + + // A remnant whose state/ was removed by hand: a keep-data rerun is still the no-op, an explicit purge consumes + // the remnant receipt and prunes the host directories it recorded. + await run(installer, [], home); + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await run(installer, ['--uninstall'], home); + await rm(join(destination, 'state'), { recursive: true }); + expect((await run(installer, ['--uninstall'], home)).stdout).toContain('Not installed install-fixture@1.2.3'); + const emptyRemnant = await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home); + expect(emptyRemnant).toMatchObject({ code: 0, stderr: '' }); + expect(emptyRemnant.stdout).toContain('Uninstalled install-fixture@1.2.3'); + expect(emptyRemnant.stdout).toContain('Data (purge): absent'); + expect(emptyRemnant.stdout).not.toContain('Remnant receipt:'); + expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); + + // Modified owned content: refused with the hash comparison until --force. + await run(installer, [], home); + await writeFile(join(destination, 'payload.txt'), 'modified\n'); + const mismatch = await run(installer, ['--uninstall'], home); + expect(mismatch.code).toBe(1); + expect(mismatch.stderr).toContain('modified after installation'); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('modified\n'); + const forced = await run(installer, ['--uninstall', '--force'], home); + expect(forced).toMatchObject({ code: 0, stderr: '' }); + expect(forced.stdout).toContain('Receipt: forced-mismatch'); + expect(forced.stdout).toContain('[--force]'); + expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); + + // Legacy (receipt-less) copy: AB7009-style refusal, then --force removes the inventoried files only. + await run(installer, [], home); + await rm(join(destination, installReceiptFile)); + const legacy = await run(installer, ['--uninstall'], home); + expect(legacy.code).toBe(1); + expect(legacy.stderr).toContain('predates install receipts'); + const forcedLegacy = await run(installer, ['--uninstall', '--force'], home); + expect(forcedLegacy).toMatchObject({ code: 0, stderr: '' }); + expect(forcedLegacy.stdout).toContain('Receipt: forced-legacy'); + // The legacy inventory owns no host directories: plugins/local stays (it was not proven ours). + expect(await readdir(join(cursorRoot, 'plugins', 'local'))).toEqual([]); + await rm(join(cursorRoot, 'plugins'), { recursive: true }); + + // A format/1 receipt is consumed as migrated. + await run(installer, [], home); + const written = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as Record; + const { hostDirectories: _h, mode: _m, registrations: _r, scope: _s, updatedAt: _u, ...legacyReceipt } = written; + await writeFile(join(destination, installReceiptFile), JSON.stringify({ ...legacyReceipt, format: legacyInstallReceiptFormat })); + const migrated = await run(installer, ['--uninstall'], home); + expect(migrated).toMatchObject({ code: 0, stderr: '' }); + expect(migrated.stdout).toContain('Receipt: migrated'); + // The migrated receipt carried no host directories, so plugins/ stays behind; that is the honest downgrade. + await rm(join(cursorRoot, 'plugins'), { recursive: true }); + expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); + + // Foreign directory (another plugin's receipt, or no receipt and no install surface): refused even with --force. + await run(installer, [], home); + const own = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as Record; + await writeFile(join(destination, installReceiptFile), JSON.stringify({ ...own, plugin: 'someone-else' })); + const otherPlugin = await run(installer, ['--uninstall', '--force'], home); + expect(otherPlugin.code).toBe(1); + expect(otherPlugin.stderr).toContain('names plugin "someone-else"'); + await rm(destination, { force: true, recursive: true }); + await mkdir(destination); + await writeFile(join(destination, 'payload.txt'), 'someone else\n'); + const foreign = await run(installer, ['--uninstall', '--force'], home); + expect(foreign.code).toBe(1); + expect(foreign.stderr).toContain('Refusing to uninstall foreign directory'); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('someone else\n'); + await rm(join(cursorRoot, 'plugins'), { recursive: true }); + + // Marketplace mode: staging writes a store receipt; --uninstall --mode marketplace removes the repository and receipt. + const staged = await run(installer, ['--mode', 'marketplace'], home); + expect(staged).toMatchObject({ code: 0, stderr: '' }); + const receiptPath = join(cursorRoot, 'agent-bundle', 'receipts', 'install-fixture.marketplace.json'); + const commit = /@ ([0-9a-f]{40})$/u.exec(staged.stdout.split('\n')[1] ?? '')?.[1]; + expect(await readInstallReceiptFile(receiptPath)).toMatchObject({ + files: [], + mode: 'marketplace', + registrations: [{ commit, kind: 'cursor-marketplace-staging', name: 'install-fixture-marketplace' }], + }); + // An untracked file in the staged working tree is not receipt-owned: refused (plan included) until --force. + const stagedRepo = join(cursorRoot, 'agent-bundle', 'marketplaces', 'install-fixture'); + await writeFile(join(stagedRepo, 'notes.txt'), 'operator notes\n'); + const dirty = await run(installer, ['--uninstall', '--mode', 'marketplace', '--plan'], home); + expect(dirty.code).toBe(1); + expect(dirty.stderr).toContain('working tree differs from the receipted commit'); + expect(dirty.stderr).toContain('"notes.txt"'); + expect(await readFile(join(stagedRepo, 'notes.txt'), 'utf8')).toBe('operator notes\n'); + const dirtyForced = await run(installer, ['--uninstall', '--mode', 'marketplace', '--plan', '--force'], home); + expect(dirtyForced).toMatchObject({ code: 0, stderr: '' }); + expect(dirtyForced.stdout).toContain('Receipt: forced-mismatch'); + await rm(join(stagedRepo, 'notes.txt')); + const marketplacePlan = await run(installer, ['--uninstall', '--mode', 'marketplace', '--plan'], home); + expect(marketplacePlan).toMatchObject({ code: 0, stderr: '' }); + expect(marketplacePlan.stdout).toContain(`Would uninstall install-fixture@1.2.3 for cursor (marketplace mode) at ${join(cursorRoot, 'agent-bundle', 'marketplaces', 'install-fixture')}`); + expect(marketplacePlan.stdout).toContain(receiptPath); + expect(await readInstallReceiptFile(receiptPath)).toBeDefined(); + const marketplaceUninstalled = await run(installer, ['--uninstall', '--mode', 'marketplace'], home); + expect(marketplaceUninstalled).toMatchObject({ code: 0, stderr: '' }); + expect(marketplaceUninstalled.stdout).toContain('Registration cursor-marketplace-staging install-fixture-marketplace: removed'); + // The plan is exact: this was the last staged marketplace and store receipt, so the run prunes the receipt + // store, the marketplaces root, and the agent-bundle namespace — and the plan already named every one of them. + const pathLines = (stdout: string, label: string): string[] => { + const lines = stdout.split('\n'); + const start = lines.findIndex((line) => line.startsWith(label)); + const listed: string[] = []; + for (const line of lines.slice(start + 1)) { + if (!line.startsWith(' ')) break; + listed.push(line.trim()); + } + return listed.sort(); + }; + const plannedDirectories = pathLines(marketplacePlan.stdout, 'Would remove directory'); + expect(plannedDirectories).toEqual(pathLines(marketplaceUninstalled.stdout, 'Removed directory')); + expect(plannedDirectories).toEqual(expect.arrayContaining([ + join(cursorRoot, 'agent-bundle', 'receipts'), + join(cursorRoot, 'agent-bundle', 'marketplaces'), + join(cursorRoot, 'agent-bundle'), + ])); + expect(marketplaceUninstalled.stdout).toContain('Data (keep): unavailable'); + expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); + expect((await run(installer, ['--uninstall', '--mode', 'marketplace'], home)).stdout).toContain('Not installed install-fixture@1.2.3 for cursor (marketplace mode)'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 120_000); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 32081d265..23b42e0d0 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -15,7 +15,9 @@ import { formatInstallResult } from '../src/install/format.ts'; import { installBundle, type InstallCommandRunner } from '../src/install/install.ts'; import { installReceiptFile, + installReceiptFormat, readInstallReceipt, + readInstallReceiptFile, treeInventory, } from '../src/install/receipt.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; @@ -45,8 +47,20 @@ const fakeTreeListing = async (root: string, relative = ''): Promise => return listing; }; -/** Default fake git: answers `ls-tree` faithfully for the staged tree so byte proofs pass; everything else is silent. */ -const gitLike = async (call: CommandCall): Promise => (call.args[0] === 'ls-tree' ? fakeTreeListing(call.cwd) : ''); +const isMarketplaceListCall = (call: CommandCall): boolean => + call.args.join(' ') === 'plugin marketplace list --json'; + +/** A host with no marketplaces configured: the install then registers (and records owning) the bundle's marketplace. */ +const noMarketplaces = (call: CommandCall): string => + call.command === 'claude' ? JSON.stringify([]) : JSON.stringify({ marketplaces: [] }); + +/** + * Default fake host: answers `ls-tree` faithfully for the staged tree so byte proofs pass, reports no configured + * marketplaces, and is silent otherwise. + */ +const gitLike = async (call: CommandCall): Promise => call.args[0] === 'ls-tree' + ? fakeTreeListing(call.cwd) + : isMarketplaceListCall(call) ? noMarketplaces(call) : ''; const recordingRunner = ( respond: (call: CommandCall) => string | Promise = gitLike, @@ -134,10 +148,26 @@ const createHostBundle = async ( return { bundleRoot, cleanupRoot, from }; }; +/** + * Host-CLI installs now write a store receipt under the host root (#101), so every Claude/Codex + * scenario pins its host roots inside the fixture's cleanup root instead of the developer's home. + */ +const isolated = (fixture: { readonly cleanupRoot: string }): { + readonly environment: Readonly; + readonly home: string; +} => ({ + environment: { + CLAUDE_CONFIG_DIR: join(fixture.cleanupRoot, 'claude-config'), + CODEX_HOME: join(fixture.cleanupRoot, 'codex-home'), + }, + home: join(fixture.cleanupRoot, 'home'), +}); + it.each([ { expected: [ { args: ['plugin', 'list', '--json'], command: 'claude' }, + { args: ['plugin', 'marketplace', 'list', '--json'], command: 'claude' }, { args: ['plugin', 'marketplace', 'add', resolve('/bundle')], command: 'claude' }, { args: ['plugin', 'install', 'install-fixture@install-fixture-marketplace', '--scope', 'project'], @@ -152,6 +182,7 @@ it.each([ { expected: [ { args: ['plugin', 'list', '--json'], command: 'codex' }, + { args: ['plugin', 'marketplace', 'list', '--json'], command: 'codex' }, { args: ['plugin', 'marketplace', 'add', resolve('/bundle')], command: 'codex' }, { args: ['plugin', 'add', 'install-fixture@install-fixture-marketplace'], command: 'codex' }, ], @@ -162,16 +193,149 @@ it.each([ const fixture = await createHostBundle(host); const { calls, runner } = recordingRunner(); try { - const result = await installBundle({ commandRunner: runner, from: fixture.from, host, scope }); - + const result = await installBundle({ ...isolated(fixture), commandRunner: runner, from: fixture.from, host, scope }); + + const hostRoot = join(fixture.cleanupRoot, host === 'claude' ? 'claude-config' : 'codex-home'); + // A Claude project-scope registration belongs to the working directory the host verbs ran in (the bundle + // root), so its receipt is keyed by a digest of that root: two projects never share one receipt. + // The key also carries the marketplace: the host identifies the install as `@`. + const receiptPath = join(hostRoot, 'agent-bundle', 'receipts', scope === 'user' + ? `install-fixture.install-fixture-marketplace.${scope}.json` + : `install-fixture.install-fixture-marketplace.${scope}.${createHash('sha256').update(fixture.bundleRoot).digest('hex').slice(0, 12)}.json`); expect(result).toMatchObject({ contentHash: (await treeInventory(fixture.bundleRoot)).hash, host, plugin: 'install-fixture', + receipt: receiptPath, state: 'installed', }); + // The marketplace ownership read happens before any host verb: the receipt records the pre-install state. expect(calls).toEqual(expected.map((call) => ({ ...call, args: call.args.map((arg) => arg === resolve('/bundle') ? fixture.bundleRoot : arg), cwd: fixture.bundleRoot }))); + // The store receipt records the delivery and the exact host registrations, in order, for uninstall. + expect(await readInstallReceiptFile(receiptPath)).toMatchObject({ + contentHash: (await treeInventory(fixture.bundleRoot)).hash, + directories: [], + files: [], + format: installReceiptFormat, + host, + hostDirectories: [], + mode: 'host-cli', + plugin: 'install-fixture', + ...(scope === 'user' ? {} : { projectRoot: fixture.bundleRoot }), + registrations: host === 'claude' + ? [ + { kind: 'claude-marketplace', name: 'install-fixture-marketplace', scope }, + { id: 'install-fixture@install-fixture-marketplace', kind: 'claude-plugin', scope }, + ] + : [ + { kind: 'codex-marketplace', name: 'install-fixture-marketplace' }, + { id: 'install-fixture@install-fixture-marketplace', kind: 'codex-plugin' }, + ], + scope, + version: '1.2.3', + }); + if (scope === 'user') expect((await readInstallReceiptFile(receiptPath))?.projectRoot).toBeUndefined(); + + // A marketplace that was already configured before the install is not claimed: the receipt records the + // plugin registration only, so a later uninstall retains the marketplace instead of removing someone else's. + const preRegistered = recordingRunner((call) => isMarketplaceListCall(call) + ? (host === 'claude' + ? JSON.stringify([{ name: 'install-fixture-marketplace' }]) + : JSON.stringify({ marketplaces: [{ name: 'install-fixture-marketplace', root: '/elsewhere' }] })) + : ''); + await rm(receiptPath); + await installBundle({ ...isolated(fixture), commandRunner: preRegistered.runner, from: fixture.from, host, scope }); + expect((await readInstallReceiptFile(receiptPath))?.registrations).toEqual([ + host === 'claude' + ? { id: 'install-fixture@install-fixture-marketplace', kind: 'claude-plugin', scope } + : { id: 'install-fixture@install-fixture-marketplace', kind: 'codex-plugin' }, + ]); + // An unreadable marketplace list is not proof of ownership either (fail-closed). + const unreadable = recordingRunner(() => ''); + await rm(receiptPath); + await installBundle({ ...isolated(fixture), commandRunner: unreadable.runner, from: fixture.from, host, scope }); + expect((await readInstallReceiptFile(receiptPath))?.registrations.map((registration) => registration.kind)).toEqual([`${host}-plugin`]); + + // `marketplace add` succeeded but the plugin install failed: the marketplace this run created is claimed only + // in memory, so it is rolled back rather than left registered with no receipt (a retry would otherwise sample it + // as pre-existing, record only the plugin, and `uninstall` would retain it as user-owned forever). + await rm(receiptPath); + const installVerb = host === 'claude' ? 'plugin install' : 'plugin add'; + const failing: CommandCall[] = []; + const rolledBack = await installBundle({ + ...isolated(fixture), + commandRunner: { run: async (command, args, runOptions) => { + const call = { args: [...args], command, cwd: runOptions.cwd }; + failing.push(call); + if (isMarketplaceListCall(call)) return { code: 0, stderr: '', stdout: noMarketplaces(call) }; + return args.join(' ').startsWith(installVerb) + ? { code: 1, stderr: 'install exploded', stdout: '' } + : { code: 0, stderr: '', stdout: '' }; + } }, + from: fixture.from, + host, + scope, + }).catch((failure: unknown) => failure); + expect(rolledBack).toBeInstanceOf(DiagnosticError); + expect((rolledBack as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: host }); + expect((rolledBack as DiagnosticError).diagnostics[0]?.message).toContain('install exploded'); + expect(failing.map((call) => call.args.join(' ')).slice(-2)).toEqual([ + `${installVerb} install-fixture@install-fixture-marketplace${host === 'claude' ? ` --scope ${scope}` : ''}`, + 'plugin marketplace remove install-fixture-marketplace', + ]); + expect(await readInstallReceiptFile(receiptPath)).toBeUndefined(); + + // A marketplace that pre-existed the install is not this run's to roll back. + const preExisting: CommandCall[] = []; + await installBundle({ + ...isolated(fixture), + commandRunner: { run: async (command, args, runOptions) => { + const call = { args: [...args], command, cwd: runOptions.cwd }; + preExisting.push(call); + if (isMarketplaceListCall(call)) { + return { code: 0, stderr: '', stdout: host === 'claude' + ? JSON.stringify([{ name: 'install-fixture-marketplace' }]) + : JSON.stringify({ marketplaces: [{ name: 'install-fixture-marketplace', root: '/elsewhere' }] }) }; + } + return args.join(' ').startsWith(installVerb) + ? { code: 1, stderr: 'install exploded', stdout: '' } + : { code: 0, stderr: '', stdout: '' }; + } }, + from: fixture.from, + host, + scope, + }).catch(() => undefined); + expect(preExisting.map((call) => call.args.join(' '))).not.toContain('plugin marketplace remove install-fixture-marketplace'); + + // The host install succeeded but the receipt could not be written: the plugin registration is reversed too + // (plugin first, then the marketplace this run created), so nothing stays registered without a receipt. + const receiptStore = join(hostRoot, 'agent-bundle', 'receipts'); + await rm(join(hostRoot, 'agent-bundle'), { force: true, recursive: true }); + await mkdir(receiptStore, { recursive: true }); + if (process.getuid?.() === 0) return; // root ignores directory modes; the receipt write cannot be made to fail here. + await chmod(receiptStore, 0o555); + const unwritable: CommandCall[] = []; + const receiptFailed = await installBundle({ + ...isolated(fixture), + commandRunner: { run: async (command, args, runOptions) => { + const call = { args: [...args], command, cwd: runOptions.cwd }; + unwritable.push(call); + return { code: 0, stderr: '', stdout: isMarketplaceListCall(call) ? noMarketplaces(call) : '' }; + } }, + from: fixture.from, + host, + scope, + }).catch((failure: unknown) => failure); + expect(receiptFailed).toBeInstanceOf(Error); + expect(receiptFailed).not.toBeInstanceOf(DiagnosticError); + expect(unwritable.map((call) => call.args.join(' ')).slice(-2)).toEqual([ + host === 'claude' + ? `plugin uninstall install-fixture@install-fixture-marketplace --scope ${scope} --keep-data` + : 'plugin remove install-fixture@install-fixture-marketplace', + 'plugin marketplace remove install-fixture-marketplace', + ]); + await chmod(receiptStore, 0o755); } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); } @@ -201,21 +365,34 @@ it('replaces a stale same-version Claude install through uninstall + install and const fixture = await createHostBundle('claude'); const installed = join(fixture.cleanupRoot, 'claude-cache', '1.2.3'); await cp(fixture.bundleRoot, installed, { recursive: true }); - const { calls, runner } = recordingRunner((call) => isInventoryCall(call) ? claudeInventory(installed) : ''); + const { calls, runner } = recordingRunner((call) => isInventoryCall(call) + ? claudeInventory(installed) + : isMarketplaceListCall(call) ? JSON.stringify([{ name: 'install-fixture-marketplace' }]) : ''); + const options = { ...isolated(fixture), commandRunner: runner, from: fixture.from, host: 'claude' as const, scope: 'user' as const }; + const receiptPath = join(fixture.cleanupRoot, 'claude-config', 'agent-bundle', 'receipts', 'install-fixture.install-fixture-marketplace.user.json'); try { - const identical = await installBundle({ commandRunner: runner, from: fixture.from, host: 'claude', scope: 'user' }); + const identical = await installBundle(options); expect(identical).toMatchObject({ destination: installed, host: 'claude', state: 'already-installed' }); - expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); + // Only reads: the inventory, then the marketplace list that decides whether the receipt may claim the marketplace. + expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json', 'plugin marketplace list --json']); + // An identical pre-#101 install gains its store receipt without any host command; the marketplace it came + // from already existed, so the receipt does not claim it. + expect(await readInstallReceiptFile(receiptPath)).toMatchObject({ + mode: 'host-cli', + plugin: 'install-fixture', + registrations: [{ id: 'install-fixture@install-fixture-marketplace', kind: 'claude-plugin', scope: 'user' }], + }); calls.length = 0; await writeFile(join(installed, 'payload.txt'), 'stale\n'); - const replaced = await installBundle({ commandRunner: runner, from: fixture.from, host: 'claude', scope: 'user' }); + const replaced = await installBundle(options); expect(replaced).toMatchObject({ contentHash: (await treeInventory(fixture.bundleRoot)).hash, destination: installed, previousContentHash: (await treeInventory(installed)).hash, state: 'replaced', }); + // A receipted replacement keeps the previous receipt's ownership answer instead of re-reading the marketplace list. expect(calls.map((call) => call.args.join(' '))).toEqual([ 'plugin list --json', 'plugin uninstall install-fixture@install-fixture-marketplace --scope user --keep-data', @@ -228,15 +405,21 @@ it('replaces a stale same-version Claude install through uninstall + install and const missingCache = recordingRunner((call) => isInventoryCall(call) ? claudeInventory(join(fixture.cleanupRoot, 'claude-cache', 'missing')) : ''); - const uncomparable = await installBundle({ commandRunner: missingCache.runner, from: fixture.from, host: 'claude', scope: 'user' }) + const uncomparable = await installBundle({ ...options, commandRunner: missingCache.runner }) .catch((failure: unknown) => failure); expect(uncomparable).toBeInstanceOf(DiagnosticError); expect((uncomparable as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'claude' }); expect((uncomparable as DiagnosticError).diagnostics[0]?.message).toContain('could not be compared'); expect(missingCache.calls).toHaveLength(1); - const reinstalled = await installBundle({ commandRunner: missingCache.runner, from: fixture.from, host: 'claude', replace: true, scope: 'user' }); + const reinstalled = await installBundle({ ...options, commandRunner: missingCache.runner, replace: true }); expect(reinstalled).toMatchObject({ state: 'replaced' }); - expect(reinstalled.previousContentHash).toBeUndefined(); + // The cache copy could not be read, so the superseded hash comes from the store receipt the last install wrote. + expect(reinstalled.previousContentHash).toBe((await treeInventory(fixture.bundleRoot)).hash); + // Without a receipt there is nothing to remember: the superseded hash is honestly absent. + await rm(receiptPath); + const reinstalledWithoutReceipt = await installBundle({ ...options, commandRunner: missingCache.runner, replace: true }); + expect(reinstalledWithoutReceipt).toMatchObject({ state: 'replaced' }); + expect(reinstalledWithoutReceipt.previousContentHash).toBeUndefined(); // A matching row without a readable scope or version is an unusable inventory, not "not installed". for (const row of [ @@ -244,13 +427,8 @@ it('replaces a stale same-version Claude install through uninstall + install and { id: 'install-fixture@install-fixture-marketplace', installPath: installed, scope: 'user' }, ]) { const malformed = recordingRunner((call) => isInventoryCall(call) ? JSON.stringify([row]) : ''); - const error = await installBundle({ - commandRunner: malformed.runner, - from: fixture.from, - host: 'claude', - replace: true, - scope: 'user', - }).catch((failure: unknown) => failure); + const error = await installBundle({ ...options, commandRunner: malformed.runner, replace: true }) + .catch((failure: unknown) => failure); expect(error, JSON.stringify(row)).toBeInstanceOf(DiagnosticError); expect((error as DiagnosticError).diagnostics[0]?.message).toContain('plugin list --json was unusable'); expect(malformed.calls).toHaveLength(1); @@ -288,7 +466,7 @@ it('fails a Claude install (AB7006) when plugin list --json reports load errors listings += 1; return listings === 1 ? '[]' : claudeRefusedInventory(installed); }); - const error = await installBundle({ commandRunner: fresh.runner, from: fixture.from, host: 'claude', scope: 'user' }) + const error = await installBundle({ ...isolated(fixture), commandRunner: fresh.runner, from: fixture.from, host: 'claude', scope: 'user' }) .catch((failure: unknown) => failure); expect(error).toBeInstanceOf(DiagnosticError); const [diagnostic] = (error as DiagnosticError).diagnostics; @@ -296,18 +474,22 @@ it('fails a Claude install (AB7006) when plugin list --json reports load errors expect(diagnostic?.message).toContain(`claude refused to load install-fixture@install-fixture-marketplace (version 1.2.3) at ${JSON.stringify(installed)} (scope user) after installation`); expect(diagnostic?.message).toContain('Duplicate hooks file detected'); expect(diagnostic?.message).toContain('--replace'); + // The receipt lands before the load verdict, so the refused copy stays receipt-owned for `uninstall`. expect(fresh.calls.map((call) => call.args.join(' '))).toEqual([ 'plugin list --json', + 'plugin marketplace list --json', `plugin marketplace add ${fixture.bundleRoot}`, 'plugin install install-fixture@install-fixture-marketplace --scope user', 'plugin list --json', ]); + expect(await readInstallReceiptFile(join(fixture.cleanupRoot, 'claude-config', 'agent-bundle', 'receipts', 'install-fixture.install-fixture-marketplace.user.json'))) + .toMatchObject({ plugin: 'install-fixture', scope: 'user' }); // A byte-identical copy the host already refuses is never "already installed": reinstalling the same // bytes cannot help, so the defect is reported instead of a success. await cp(fixture.bundleRoot, installed, { recursive: true }); const identical = recordingRunner((call) => isInventoryCall(call) ? claudeRefusedInventory(installed) : ''); - const existing = await installBundle({ commandRunner: identical.runner, from: fixture.from, host: 'claude', scope: 'user' }) + const existing = await installBundle({ ...isolated(fixture), commandRunner: identical.runner, from: fixture.from, host: 'claude', scope: 'user' }) .catch((failure: unknown) => failure); expect(existing).toBeInstanceOf(DiagnosticError); expect((existing as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7006', target: 'claude' }); @@ -316,7 +498,7 @@ it('fails a Claude install (AB7006) when plugin list --json reports load errors // Healthy rows (no `errors` key) keep the install result unchanged. const healthy = recordingRunner((call) => isInventoryCall(call) ? claudeInventory(installed) : ''); - await expect(installBundle({ commandRunner: healthy.runner, from: fixture.from, host: 'claude', scope: 'user' })) + await expect(installBundle({ ...isolated(fixture), commandRunner: healthy.runner, from: fixture.from, host: 'claude', scope: 'user' })) .resolves.toMatchObject({ state: 'already-installed' }); } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); @@ -357,8 +539,10 @@ it('honours --replace for Codex through remove + add and fails closed without a previousContentHash: (await treeInventory(installed)).hash, state: 'replaced', }); + // No receipt yet, so the marketplace ownership read precedes every host verb. expect(calls.map((call) => call.args.join(' '))).toEqual([ 'plugin list --json', + 'plugin marketplace list --json', 'plugin remove install-fixture@install-fixture-marketplace', `plugin marketplace add ${fixture.bundleRoot}`, 'plugin add install-fixture@install-fixture-marketplace', @@ -370,13 +554,8 @@ it('honours --replace for Codex through remove + add and fails closed without a }); for (const stdout of ['not json', malformedRow]) { const unusable = recordingRunner(() => stdout); - const error = await installBundle({ - commandRunner: unusable.runner, - from: fixture.from, - host: 'codex', - replace: true, - scope: 'user', - }).catch((failure: unknown) => failure); + const error = await installBundle({ ...options, commandRunner: unusable.runner, replace: true }) + .catch((failure: unknown) => failure); expect(error, stdout).toBeInstanceOf(DiagnosticError); expect((error as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'codex' }); expect((error as DiagnosticError).diagnostics[0]?.message).toContain('plugin list --json was unusable'); @@ -392,6 +571,7 @@ it('accepts an artifact root containing the requested host target', async () => const { calls, runner } = recordingRunner(); try { const result = await installBundle({ + ...isolated(fixture), commandRunner: runner, from: fixture.from, host: 'claude', @@ -416,6 +596,7 @@ it('fails with a typed diagnostic when the public host CLI is missing', async () }; try { const error = await installBundle({ + ...isolated(fixture), commandRunner: missingRunner, from: fixture.from, host: 'codex', @@ -437,6 +618,7 @@ it('rejects scopes the selected host does not support', async () => { const fixture = await createHostBundle('codex'); try { const error = await installBundle({ + ...isolated(fixture), commandRunner: recordingRunner().runner, from: fixture.from, host: 'codex', @@ -472,9 +654,14 @@ it('copies a Cursor bundle into a fake home and is idempotent', async () => { // A fresh install created every directory, so it owns them all. directories: ['.cursor-plugin'], files: ['.cursor-plugin/plugin.json', 'payload.txt'], - format: 'agent-bundle-install-receipt/1', + format: installReceiptFormat, host: 'cursor', + // A fresh install into a home without plugins/local created both host directories (#101). + hostDirectories: ['plugins', 'plugins/local'], + mode: 'local', plugin: 'install-fixture', + registrations: [{ kind: 'cursor-local-plugin' }], + scope: 'user', version: '1.2.3', }); expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); @@ -881,10 +1068,54 @@ it('ignores receipts whose file list could escape the plugin root', async () => expect(await readInstallReceipt(root), missing).toBeUndefined(); } await writeJson(join(root, installReceiptFile), complete); - expect(await readInstallReceipt(root)).toMatchObject({ + // A format/1 receipt (#420) reads with its lifecycle fields synthesized and the downgrade recorded (#101). + expect(await readInstallReceipt(root)).toEqual({ + contentHash: 'abc', directories: ['skills', 'skills/probe'], files: ['skills/probe/SKILL.md', 'plugin.json'], + format: installReceiptFormat, + host: 'cursor', + hostDirectories: [], + installedAt: '2026-09-03T00:00:00.000Z', + migratedFrom: 'agent-bundle-install-receipt/1', + mode: 'local', + plugin: 'install-fixture', + registrations: [{ kind: 'cursor-local-plugin' }], + scope: 'user', + updatedAt: '2026-09-03T00:00:00.000Z', + version: '1.2.3', }); + // A current-format receipt must carry every lifecycle field with a valid shape, or it reads as absent. + const current = { + ...complete, + format: installReceiptFormat, + hostDirectories: ['plugins', 'plugins/local'], + mode: 'local', + registrations: [{ kind: 'cursor-local-plugin' }], + scope: 'user', + updatedAt: '2026-09-03T01:00:00.000Z', + }; + for (const broken of [ + { mode: 'remote' }, + { scope: 'team' }, + { updatedAt: 42 }, + { hostDirectories: ['../outside'] }, + { registrations: [{ kind: 'unknown-kind' }] }, + { registrations: [{ kind: 'claude-plugin', scope: 'team' }] }, + { registrations: 'cursor-local-plugin' }, + ]) { + await writeJson(join(root, installReceiptFile), { ...current, ...broken }); + expect(await readInstallReceipt(root), JSON.stringify(broken)).toBeUndefined(); + } + for (const missing of ['mode', 'scope', 'updatedAt', 'hostDirectories', 'registrations'] as const) { + const { [missing]: _omitted, ...partial } = current; + await writeJson(join(root, installReceiptFile), partial); + expect(await readInstallReceipt(root), missing).toBeUndefined(); + } + await writeJson(join(root, installReceiptFile), { ...current, format: 'agent-bundle-install-receipt/3' }); + expect(await readInstallReceipt(root)).toBeUndefined(); + await writeJson(join(root, installReceiptFile), current); + expect(await readInstallReceipt(root)).toEqual(current); } finally { await rm(root, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/installer-entry.test.ts b/packages/agent-bundle/tests/installer-entry.test.ts index 07c746d7b..d3fcffe79 100644 --- a/packages/agent-bundle/tests/installer-entry.test.ts +++ b/packages/agent-bundle/tests/installer-entry.test.ts @@ -87,6 +87,7 @@ it('builds a package-relative installer with fallback naming and built-host argv const help = await run(installer, [], { cwd: tmpdir() }); expect(help).toMatchObject({ code: 0, stderr: '' }); expect(help.stdout).toContain('install [--scope ] [--mode local|marketplace] [--replace|--force] [--json]'); + expect(help.stdout).toContain('uninstall [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]'); expect(help.stdout).toContain('cursor'); expect(help.stdout).not.toContain('claude'); @@ -162,6 +163,37 @@ it('builds a package-relative installer with fallback naming and built-host argv const badMode = await run(installer, ['install', 'cursor', '--mode', 'remote'], { cwd: tmpdir() }); expect(badMode.code).toBe(1); expect(badMode.stderr).toContain('Install mode must be local or marketplace.'); + + // The same bin uninstalls: --plan is a no-op that names every path, the real run consumes the receipt and + // leaves nothing of the local copy behind, and --purge-data is refused without --confirm-purge. + const env = { ...process.env, HOME: home }; + const plan = await run(installer, ['uninstall', 'cursor', '--plan', '--json'], { cwd: tmpdir(), env }); + expect(plan).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(plan.stdout)).toMatchObject({ + destination, + mode: 'local', + receipt: { status: 'consumed' }, + state: 'planned', + }); + await expect(stat(join(destination, installReceiptFile))).resolves.toBeDefined(); + const unconfirmed = await run(installer, ['uninstall', 'cursor', '--purge-data'], { cwd: tmpdir(), env }); + expect(unconfirmed.code).toBe(1); + expect(unconfirmed.stderr).toContain('AB7008'); + const uninstalled = await run(installer, ['uninstall', 'cursor'], { cwd: tmpdir(), env }); + expect(uninstalled).toMatchObject({ code: 0, stderr: '' }); + expect(uninstalled.stdout).toMatch(/^Uninstalled installer-fixture@1\.2\.3 for cursor \(local mode\) at /u); + await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }); + const again = await run(installer, ['uninstall', 'cursor', '--json'], { cwd: tmpdir(), env }); + expect(JSON.parse(again.stdout)).toMatchObject({ state: 'not-installed' }); + // Marketplace-mode staging from earlier in this test is removed the same way. + const marketplaceGone = await run(installer, ['uninstall', 'cursor', '--mode', 'marketplace', '--json'], { cwd: tmpdir(), env }); + expect(marketplaceGone).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(marketplaceGone.stdout)).toMatchObject({ mode: 'marketplace', state: 'uninstalled' }); + await expect(stat(repository)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(join(home, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); + const unknownVerbFlag = await run(installer, ['install', 'cursor', '--plan'], { cwd: tmpdir(), env }); + expect(unknownVerbFlag.code).toBe(1); + expect(unknownVerbFlag.stderr).toContain('Unknown installer argument "--plan"'); }, 120_000); it('chooses an unused installer name when both primary candidates are bins', async () => { diff --git a/packages/agent-bundle/tests/packed-host-install-proof.test.ts b/packages/agent-bundle/tests/packed-host-install-proof.test.ts index d5849b25d..3c7d44c2e 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -13,6 +13,7 @@ import { runClaudeHostInstallProof, runCodexHostInstallProof, runCursorHostInstallProof, + runHostUninstallProof, type BuiltHostInstallFixture, type HostInstallCommand, } from './support/host-install.ts'; @@ -275,3 +276,67 @@ it('installs the packed tarball into an isolated Cursor home, validates schemas, expect(report.install.version, proofLabel).toBe(fixturePackageVersion); expectHygienicReport(report); }, 180_000); + +claudePluginIt( + claudeAvailable + ? 'uninstalls the packed tarball through Claude with the package bin, leaving only host-owned bookkeeping' + : `uninstalls the packed tarball through Claude with the package bin, leaving only host-owned bookkeeping [${claudeMissingEvidence}]`, + async () => { + const report = await runHostUninstallProof(builtFixture(), 'claude', { + environment: process.env, + installCommand: installCommand(), + }); + expect(report, proofLabel).toMatchObject({ + agentBundleResidue: [], + host: 'claude', + hostResidue: ['claude-orphaned-cache-copy', 'claude-plugin-registry-files', 'claude-session-bookkeeping', 'claude-settings'], + registrations: { 'claude-marketplace': 'removed', 'claude-plugin': 'removed' }, + status: 'passed', + }); + expectHygienicReport(report); + }, + 300_000, +); + +codexPluginIt( + codexAvailable + ? 'uninstalls the packed tarball through Codex with the package bin' + : `uninstalls the packed tarball through Codex with the package bin [${codexMissingEvidence}]`, + async () => { + const report = await runHostUninstallProof(builtFixture(), 'codex', { + environment: process.env, + installCommand: installCommand(), + }); + expect(report, proofLabel).toMatchObject({ + agentBundleResidue: [], + host: 'codex', + hostResidue: ['codex-empty-config', 'codex-empty-directories'], + registrations: { 'codex-marketplace': 'removed', 'codex-plugin': 'removed' }, + status: 'passed', + }); + expectHygienicReport(report); + }, + 300_000, +); + +it('uninstalls the packed tarball from an isolated Cursor home with the package bin and leaves it byte-identical', async () => { + const report = await runHostUninstallProof(builtFixture(), 'cursor', { + environment: process.env, + installCommand: installCommand(), + }); + expect(report, proofLabel).toEqual({ + agentBundleResidue: [], + homeByteIdentical: true, + host: 'cursor', + hostResidue: [], + keepData: 'kept', + plan: 'no-op', + proofLevel: proofLabel, + purgeData: 'purged', + refusals: { foreignOrMismatch: 'AB7007', missingReceipt: 'AB7009', unconfirmedPurge: 'AB7008' }, + registrations: { 'cursor-local-plugin': 'removed' }, + rerun: 'not-installed', + status: 'passed', + }); + expectHygienicReport(report); +}, 180_000); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 994edbdba..5834b7920 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -43,6 +43,7 @@ import { normalClaudeSettingsAndPluginsUnchanged, packedNativeEnvironment, } from './packed-native-smoke.ts'; +import { diffTreeSnapshots, snapshotTree, treesIdentical } from './tree-snapshot.ts'; import { replaceWatchedSource } from './watched-files.ts'; const execFile = promisify(executeFile); @@ -431,25 +432,30 @@ const runNodeCli = ( { ...options, timeout: 180_000 }, ); -const runInstallCommand = ( +/** Runs `install` or `uninstall` through the source-built CLI (`--from `) or the packed package bin. */ +const runLifecycleCommand = ( fixture: BuiltHostInstallFixture, + verb: 'install' | 'uninstall', host: 'claude' | 'codex' | 'cursor', bundle: string, options: HostInstallProofOptions, + extraArguments: readonly string[] = [], ): Promise => { if (options.installCommand === undefined) { return runNodeCli(fixture, [ - 'install', + verb, host, '--from', bundle, + ...extraArguments, '--json', ], { cwd: bundle, environment: isolatedEnvironment(options.environment, {}) }); } return run(options.installCommand.executable, [ ...(options.installCommand.prefixArguments ?? []), - 'install', + verb, host, + ...extraArguments, '--json', ], { cwd: options.installCommand.cwd ?? bundle, @@ -458,6 +464,13 @@ const runInstallCommand = ( }); }; +const runInstallCommand = ( + fixture: BuiltHostInstallFixture, + host: 'claude' | 'codex' | 'cursor', + bundle: string, + options: HostInstallProofOptions, +): Promise => runLifecycleCommand(fixture, 'install', host, bundle, options); + const parseJson = (text: string, context: string): T => { try { return JSON.parse(text) as T; @@ -2320,3 +2333,400 @@ export const runClaudeTokenSessionProof = async ( status: 'passed', }); }; + +interface UninstallResultDocument { + readonly data?: { readonly outcome?: unknown; readonly policy?: unknown }; + readonly receipt?: { readonly status?: unknown }; + readonly registrations?: readonly { readonly action?: unknown; readonly kind?: unknown }[]; + readonly remnantReceipt?: unknown; + readonly state?: unknown; +} + +interface DiagnosticDocument { + readonly code?: unknown; +} + +/** + * Host-owned residue an uninstall cannot and must not remove, classified per + * host from what the real CLIs leave behind (observed 2026-09-03 against + * Claude Code 2.1.257 and codex-cli 0.147.0 in isolated homes). Anything + * outside these classes fails the proof: it would mean Agent Bundle left + * something of its own behind, or the host changed its bookkeeping. + */ +export type HostResidueClass = + | 'claude-orphaned-cache-copy' + | 'claude-plugin-registry-files' + | 'claude-session-bookkeeping' + | 'claude-settings' + | 'codex-empty-config' + | 'codex-empty-directories'; + +export interface HostUninstallProofReport { + /** Entries under the Agent Bundle namespace (`agent-bundle/`) or receipts left behind: always empty. */ + readonly agentBundleResidue: readonly string[]; + readonly homeByteIdentical: boolean; + readonly host: InstallHost; + /** Classified host-owned residue after uninstall, relative to the host root; empty when byte-identical. */ + readonly hostResidue: readonly HostResidueClass[]; + readonly keepData: 'kept' | 'retained-by-host' | 'unavailable'; + readonly plan: 'no-op'; + readonly proofLevel: string; + readonly purgeData: 'purged' | 'removed-by-host'; + readonly refusals: { + readonly foreignOrMismatch: 'AB7007'; + readonly missingReceipt: 'AB7009'; + readonly unconfirmedPurge: 'AB7008'; + }; + readonly registrations: Readonly>; + readonly rerun: 'not-installed'; + readonly status: 'passed'; +} + +const parseDiagnostics = (stderr: string, context: string): readonly DiagnosticDocument[] => { + const document = parseJson(stderr, context); + return Array.isArray(document) ? document as readonly DiagnosticDocument[] : fail(`${context} did not return a diagnostics array.`); +}; + +const expectRefusal = async ( + attempt: Promise, + code: string, + context: string, +): Promise => { + const result = await attempt; + assertProof(result.exitCode !== 0, `${context} was not refused.`); + const diagnostics = parseDiagnostics(result.stderr, context); + assertProof(diagnostics.some((entry) => entry.code === code), `${context} was refused without ${code}: ${JSON.stringify(diagnostics)}`); +}; + +const classifyClaudeResidue = (path: string, plugin: string, marketplace: string): HostResidueClass | undefined => { + if (path === '.claude.json' || path === '.claude.json.lock' || path === 'backups' || path.startsWith('backups/')) { + return 'claude-session-bookkeeping'; + } + if (path === 'settings.json') return 'claude-settings'; + if ( + path === 'plugins' || path === 'plugins/installed_plugins.json' || path === 'plugins/known_marketplaces.json' || + path === 'plugins/marketplaces' || path === 'plugins/cache' || path === `plugins/cache/${marketplace}` || + path === `plugins/cache/${marketplace}/${plugin}` + ) { + return 'claude-plugin-registry-files'; + } + if (path.startsWith(`plugins/cache/${marketplace}/${plugin}/`)) return 'claude-orphaned-cache-copy'; + return undefined; +}; + +const classifyCodexResidue = (path: string, marketplace: string): HostResidueClass | undefined => { + if (path === 'config.toml') return 'codex-empty-config'; + if ( + path === '.tmp' || path === '.tmp/marketplaces' || path === 'plugins' || path === 'plugins/cache' || + path === `plugins/cache/${marketplace}` + ) { + return 'codex-empty-directories'; + } + return undefined; +}; + +const agentBundleResidueOf = (added: readonly string[]): readonly string[] => + added.filter((path) => path === 'agent-bundle' || path.startsWith('agent-bundle/') || path.endsWith('/.agent-bundle-install.json')); + +/** + * Proves the receipt-owned uninstall against a real host in an isolated home: + * install → `--plan` (no-op) → uninstall → the host's own inventory no longer + * names the plugin or its marketplace → rerun is `not-installed`; then the + * `--keep-data` / `--purge-data` policy, the missing-receipt and mismatch + * refusals, and finally a snapshot diff of the host root against the state + * before the first install. Cursor is byte-identical; Claude and Codex leave + * only classified host-owned bookkeeping. + */ +export const runHostUninstallProof = async ( + fixture: BuiltHostInstallFixture, + host: 'claude' | 'codex' | 'cursor', + options: HostInstallProofOptions, +): Promise => { + const root = await mkdtemp(join(tmpdir(), `agent-bundle-host-uninstall-${host}-`)); + const home = join(root, 'home'); + const config = join(root, 'config'); + await Promise.all([mkdir(home, { recursive: true }), mkdir(config, { recursive: true })]); + if (host === 'cursor') await mkdir(join(home, '.cursor'), { recursive: true }); + const environment = isolatedEnvironment(options.environment, { + ...(host === 'claude' ? { CLAUDE_CONFIG_DIR: config } : {}), + ...(host === 'codex' ? { CODEX_HOME: config } : {}), + HOME: home, + }); + const hostRoot = host === 'cursor' ? home : config; + const bundle = fixture.bundles[host]; + const lifecycle = (verb: 'install' | 'uninstall', extra: readonly string[] = []): Promise => + runLifecycleCommand(fixture, verb, host, bundle, { ...options, environment }, extra); + const uninstall = async (extra: readonly string[] = []): Promise => { + const result = await lifecycle('uninstall', extra); + assertProof(result.exitCode === 0, `${host} uninstall ${extra.join(' ')} failed: ${commandDetail(result)}`); + return parseJson(result.stdout, `${host} uninstall`); + }; + const install = async (): Promise => { + const result = await lifecycle('install'); + assertProof(result.exitCode === 0, `${host} install failed: ${commandDetail(result)}`); + }; + const installedRoot = host === 'cursor' + ? join(home, '.cursor', 'plugins', 'local', plugin) + : join(config, 'plugins', 'cache', marketplace, plugin, version); + const receiptPath = host === 'cursor' + ? join(installedRoot, '.agent-bundle-install.json') + : join(config, 'agent-bundle', 'receipts', `${plugin}.${marketplace}.user.json`); + const stateDirectory = join(installedRoot, 'state'); + try { + const untouched = await snapshotTree(hostRoot); + const homeBefore = await snapshotTree(home); + + // Nothing installed yet: an honest no-op. Claude's and Codex's own read-only `plugin list --json` verbs + // create their session bookkeeping on first contact, so the baseline for the byte-level comparison is + // taken after this probe; Cursor needs no host verb and stays exactly as it was. + assertProof((await uninstall()).state === 'not-installed', `${host} uninstall on a fresh home was not a not-installed no-op.`); + const before = await snapshotTree(hostRoot); + if (host === 'cursor') { + assertProof(treesIdentical(untouched, before), 'Cursor uninstall on a fresh home changed the host root.'); + } else { + for (const path of diffTreeSnapshots(untouched, before).added) { + const classified = host === 'claude' ? classifyClaudeResidue(path, plugin, marketplace) : classifyCodexResidue(path, marketplace); + assertProof(classified !== undefined, `${host} read-only inventory probe created an unclassified entry: ${path}`); + } + } + + await install(); + await access(receiptPath).catch(() => fail(`${host} install wrote no receipt at ${receiptPath}.`)); + const afterInstall = await snapshotTree(hostRoot); + + // --plan names the exact paths and registrations and writes nothing. + const plan = await uninstall(['--plan']); + assertProof(plan.state === 'planned', `${host} uninstall --plan did not report planned.`); + assertProof(plan.receipt?.status === 'consumed', `${host} uninstall --plan did not find the receipt: ${JSON.stringify(plan.receipt)}`); + assertProof(treesIdentical(afterInstall, await snapshotTree(hostRoot)), `${host} uninstall --plan changed the host root.`); + + // --purge-data without confirmation is refused before anything changes. + await expectRefusal(lifecycle('uninstall', ['--purge-data']), 'AB7008', `${host} uninstall --purge-data without --confirm-purge`); + assertProof(treesIdentical(afterInstall, await snapshotTree(hostRoot)), `${host} refused purge still changed the host root.`); + + const uninstalled = await uninstall(); + assertProof(uninstalled.state === 'uninstalled', `${host} uninstall did not report uninstalled: ${JSON.stringify(uninstalled)}`); + const registrations: Record = {}; + for (const registration of uninstalled.registrations ?? []) { + assertProof( + (registration.action === 'removed' || registration.action === 'already-absent') && typeof registration.kind === 'string', + `${host} uninstall reported an unexpected registration action: ${JSON.stringify(registration)}`, + ); + registrations[registration.kind] = registration.action; + } + await access(receiptPath).then( + () => fail(`${host} uninstall left the receipt at ${receiptPath}.`), + () => undefined, + ); + if (host === 'claude') { + const listed = await run('claude', ['plugin', 'list', '--json'], { cwd: bundle, environment }); + assertProof(listed.exitCode === 0, `Claude plugin list failed after uninstall: ${commandDetail(listed)}`); + const rows = parseJson(listed.stdout, 'Claude plugin list'); + assertProof(!rows.some((row) => row.id === `${plugin}@${marketplace}`), 'Claude still lists the plugin after uninstall.'); + const marketplaces = await run('claude', ['plugin', 'marketplace', 'list', '--json'], { cwd: bundle, environment }); + assertProof(!marketplaces.stdout.includes(marketplace), 'Claude still lists the marketplace after uninstall.'); + } else if (host === 'codex') { + const listed = await run('codex', ['plugin', 'list', '--json'], { cwd: bundle, environment }); + assertProof(listed.exitCode === 0, `Codex plugin list failed after uninstall: ${commandDetail(listed)}`); + assertProof(!listed.stdout.includes(`${plugin}@${marketplace}`), 'Codex still lists the plugin after uninstall.'); + const marketplaces = await run('codex', ['plugin', 'marketplace', 'list', '--json'], { cwd: bundle, environment }); + assertProof(!marketplaces.stdout.includes(marketplace), 'Codex still lists the marketplace after uninstall.'); + } else { + await access(installedRoot).then( + () => fail('Cursor uninstall left the local plugin directory behind.'), + () => undefined, + ); + } + assertProof((await uninstall()).state === 'not-installed', `${host} uninstall rerun was not a not-installed no-op.`); + + // The host root after uninstall against the host root before install. Nothing that existed before may be + // removed; every added or rewritten entry must be classified host-owned bookkeeping (Claude rewrites its + // registries and settings in place), and none of it may be Agent Bundle's. + const afterUninstall = await snapshotTree(hostRoot); + const difference = diffTreeSnapshots(before, afterUninstall); + assertProof(difference.removed.length === 0, `${host} uninstall removed pre-existing entries: ${JSON.stringify(difference)}`); + const agentBundleResidue = agentBundleResidueOf([...difference.added, ...difference.changed]); + assertProof(agentBundleResidue.length === 0, `${host} uninstall left Agent Bundle-owned entries behind: ${agentBundleResidue.join(', ')}`); + const residueClasses = new Set(); + for (const path of [...difference.added, ...difference.changed]) { + const classified = host === 'claude' + ? classifyClaudeResidue(path, plugin, marketplace) + : host === 'codex' + ? classifyCodexResidue(path, marketplace) + : undefined; + assertProof(classified !== undefined, `${host} uninstall left an unclassified entry behind: ${path}`); + residueClasses.add(classified); + } + if (host === 'claude') { + await access(join(installedRoot, '.orphaned_at')).catch(() => + fail('Claude did not mark its retained cache copy as orphaned (.orphaned_at); the residue is not the documented grace-period copy.')); + const registry = await readJson(join(config, 'plugins', 'installed_plugins.json'), 'Claude installed_plugins.json'); + assertProof(!JSON.stringify(registry).includes(plugin), 'Claude installed_plugins.json still names the plugin.'); + const settings = await readJson(join(config, 'settings.json'), 'Claude settings.json'); + assertProof(!JSON.stringify(settings).includes(`${plugin}@${marketplace}`), 'Claude settings.json still enables the plugin.'); + } + if (host === 'codex') { + const configToml = await readText(join(config, 'config.toml'), 'Codex config.toml'); + assertProof(!configToml.includes(plugin) && !configToml.includes(marketplace), 'Codex config.toml still names the plugin or marketplace.'); + } + // HOME itself (distinct from the host config root for Claude and Codex) is untouched in every case. + assertProof(treesIdentical(homeBefore, await snapshotTree(home)) || host === 'cursor', `${host} uninstall changed HOME outside the host root.`); + const homeByteIdentical = treesIdentical(before, afterUninstall); + assertProof(host !== 'cursor' || homeByteIdentical, `Cursor uninstall did not leave the home byte-identical: ${JSON.stringify(difference)}`); + + // Data policy: --keep-data preserves durable state (or says honestly why it cannot), a confirmed purge removes it. + await install(); + await mkdir(stateDirectory, { recursive: true }); + await writeFile(join(stateDirectory, 'plugin.sqlite'), 'durable\n'); + const kept = await uninstall(['--keep-data']); + const keepOutcome = kept.data?.outcome; + assertProof( + keepOutcome === 'kept' || keepOutcome === 'retained-by-host' || keepOutcome === 'unavailable', + `${host} --keep-data reported an unexpected outcome: ${JSON.stringify(kept.data)}`, + ); + if (keepOutcome !== 'unavailable') { + await access(join(stateDirectory, 'plugin.sqlite')).catch(() => fail(`${host} --keep-data did not preserve state/plugin.sqlite.`)); + } + if (host === 'cursor') { + assertProof(kept.remnantReceipt === receiptPath, 'Cursor --keep-data wrote no remnant receipt beside the preserved state.'); + } + await install(); + if (host !== 'cursor') { + // Codex deletes the cache on remove and Claude's reinstall rewrites the cached copy from the bundle, so + // the host itself discarded the marker; recreate it so the purge run has state to report on. + await mkdir(stateDirectory, { recursive: true }); + await writeFile(join(stateDirectory, 'plugin.sqlite'), 'durable\n'); + } + const purged = await uninstall(['--purge-data', '--confirm-purge']); + const purgeOutcome = purged.data?.outcome; + assertProof(purgeOutcome === 'purged' || purgeOutcome === 'removed-by-host', `${host} --purge-data reported an unexpected outcome: ${JSON.stringify(purged.data)}`); + await access(stateDirectory).then( + () => fail(`${host} --purge-data --confirm-purge left state/ behind.`), + () => undefined, + ); + if (host === 'cursor') { + assertProof(treesIdentical(before, await snapshotTree(hostRoot)), 'Cursor keep→reinstall→purge cycle did not restore the home byte-identically.'); + } + + // Refusals: a missing receipt (AB7009) and a mismatch between the installed copy and the receipt (AB7007). + await install(); + await rm(receiptPath); + await expectRefusal(lifecycle('uninstall'), 'AB7009', `${host} uninstall without a receipt`); + // A receipt-less Cursor copy is the pre-receipt legacy layout (`forced-legacy`); a host-CLI install with no + // store receipt is `forced-missing`. Both need --force and both remove only what the receipt (or, for the + // legacy layout, the inventory) proves is this plugin's. + const forced = await uninstall(['--force']); + assertProof( + forced.state === 'uninstalled' && (forced.receipt?.status === 'forced-missing' || forced.receipt?.status === 'forced-legacy'), + `${host} uninstall --force did not proceed: ${JSON.stringify(forced)}`, + ); + await install(); + await writeFile(join(installedRoot, 'INSTALL.md'), '# tampered\n'); + await expectRefusal(lifecycle('uninstall'), 'AB7007', `${host} uninstall of a modified copy`); + const forcedMismatch = await uninstall(['--force']); + assertProof(forcedMismatch.state === 'uninstalled' && forcedMismatch.receipt?.status === 'forced-mismatch', `${host} uninstall --force after a mismatch did not proceed: ${JSON.stringify(forcedMismatch)}`); + + return Object.freeze({ + agentBundleResidue: Object.freeze(agentBundleResidue), + homeByteIdentical, + host, + hostResidue: Object.freeze([...residueClasses].sort()), + keepData: keepOutcome, + plan: 'no-op', + proofLevel, + purgeData: purgeOutcome, + refusals: Object.freeze({ foreignOrMismatch: 'AB7007', missingReceipt: 'AB7009', unconfirmedPurge: 'AB7008' }), + registrations: Object.freeze(registrations), + rerun: 'not-installed', + status: 'passed', + }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; + +export interface PortableUninstallProofReport { + readonly homeByteIdentical: true; + readonly host: 'cursor'; + readonly installer: 'emitted install.mjs --uninstall'; + readonly keepData: 'kept'; + readonly plan: 'no-op'; + readonly proofLevel: string; + readonly purgeData: 'purged'; + readonly refusals: { readonly foreign: 'refused'; readonly missingReceipt: 'refused'; readonly unconfirmedPurge: 'refused' }; + readonly rerun: 'not-installed'; + readonly status: 'passed'; +} + +/** The emitted standalone installer's `--uninstall` against an isolated Cursor home for the Agent Plugins pack. */ +export const runPortableUninstallProof = async ( + fixture: BuiltPortableHostInstallFixture, + options: { readonly environment: Readonly }, +): Promise => { + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-host-uninstall-portable-')); + try { + await mkdir(join(home, '.cursor'), { recursive: true }); + const environment = isolatedEnvironment(options.environment, { HOME: home }); + const installer = join(fixture.portableBundle, 'install.mjs'); + const destination = join(home, '.cursor', 'plugins', 'local', portablePlugin); + const receiptPath = join(destination, '.agent-bundle-install.json'); + const runInstaller = (args: readonly string[]): Promise => + run(process.execPath, [installer, ...args], { cwd: fixture.portableBundle, environment }); + const expectOk = async (args: readonly string[], prefix: string): Promise => { + const result = await runInstaller(args); + assertProof(result.exitCode === 0, `Portable installer ${args.join(' ')} failed: ${commandDetail(result)}`); + assertProof(result.stdout.startsWith(prefix), `Portable installer ${args.join(' ')} did not report "${prefix}": ${JSON.stringify(result.stdout)}`); + return result.stdout; + }; + const before = await snapshotTree(home); + await expectOk(['--uninstall'], `Not installed ${portablePlugin}@${version}`); + await expectOk([], `Installed ${portablePlugin}@${version}`); + const afterInstall = await snapshotTree(home); + const plan = await expectOk(['--uninstall', '--plan'], `Would uninstall ${portablePlugin}@${version}`); + assertProof(plan.includes(receiptPath), 'Portable --plan did not name the receipt path.'); + assertProof(treesIdentical(afterInstall, await snapshotTree(home)), 'Portable --plan changed the home.'); + const unconfirmed = await runInstaller(['--uninstall', '--purge-data']); + assertProof(unconfirmed.exitCode === 2 && unconfirmed.stderr.includes('--confirm-purge'), 'Portable --purge-data without confirmation was not refused.'); + await expectOk(['--uninstall'], `Uninstalled ${portablePlugin}@${version}`); + assertProof(treesIdentical(before, await snapshotTree(home)), `Portable uninstall did not leave the home byte-identical: ${JSON.stringify(diffTreeSnapshots(before, await snapshotTree(home)))}`); + await expectOk(['--uninstall'], `Not installed ${portablePlugin}@${version}`); + + await expectOk([], `Installed ${portablePlugin}@${version}`); + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + const kept = await expectOk(['--uninstall', '--keep-data'], `Uninstalled ${portablePlugin}@${version}`); + assertProof(kept.includes('Data (keep): kept'), 'Portable --keep-data did not report kept state.'); + await access(join(destination, 'state', 'plugin.sqlite')).catch(() => fail('Portable --keep-data did not preserve state/.')); + await expectOk([], `Installed ${portablePlugin}@${version}`); + const purged = await expectOk(['--uninstall', '--purge-data', '--confirm-purge'], `Uninstalled ${portablePlugin}@${version}`); + assertProof(purged.includes('Data (purge): purged'), 'Portable confirmed purge did not report purged state.'); + assertProof(treesIdentical(before, await snapshotTree(home)), 'Portable keep→reinstall→purge did not restore the home byte-identically.'); + + await expectOk([], `Installed ${portablePlugin}@${version}`); + await rm(receiptPath); + const missing = await runInstaller(['--uninstall']); + assertProof(missing.exitCode === 1 && missing.stderr.includes('predates install receipts'), 'Portable uninstall without a receipt was not refused.'); + await expectOk(['--uninstall', '--force'], `Uninstalled ${portablePlugin}@${version}`); + await rm(join(home, '.cursor', 'plugins'), { force: true, recursive: true }); + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, 'payload.txt'), 'someone else\n'); + const foreign = await runInstaller(['--uninstall', '--force']); + assertProof(foreign.exitCode === 1 && foreign.stderr.includes('Refusing to uninstall foreign directory'), 'Portable uninstall of a foreign directory was not refused.'); + await access(join(destination, 'payload.txt')).catch(() => fail('Portable refused uninstall still removed the foreign file.')); + + return Object.freeze({ + homeByteIdentical: true, + host: 'cursor', + installer: 'emitted install.mjs --uninstall', + keepData: 'kept', + plan: 'no-op', + proofLevel: portableProofLevel, + purgeData: 'purged', + refusals: Object.freeze({ foreign: 'refused', missingReceipt: 'refused', unconfirmedPurge: 'refused' }), + rerun: 'not-installed', + status: 'passed', + }); + } finally { + await rm(home, { force: true, recursive: true }); + } +}; diff --git a/packages/agent-bundle/tests/support/tree-snapshot.ts b/packages/agent-bundle/tests/support/tree-snapshot.ts new file mode 100644 index 000000000..f84082636 --- /dev/null +++ b/packages/agent-bundle/tests/support/tree-snapshot.ts @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto'; +import { lstat, readdir, readFile, readlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * A byte-level picture of a directory tree: every entry (files by sha256 and + * mode bits, directories, symlink targets), sorted, POSIX-relative to the + * root. Two snapshots are equal exactly when the trees are byte-identical + * (timestamps excepted), which is what the uninstall proofs compare a home + * against before install and after uninstall. + */ +export type TreeSnapshot = ReadonlyMap; + +export const snapshotTree = async (root: string): Promise => { + const entries = new Map(); + const visit = async (relativePath: string): Promise => { + let names: readonly string[]; + try { + names = (await readdir(join(root, relativePath))).sort((left, right) => left.localeCompare(right)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + for (const name of names) { + const child = relativePath === '' ? name : `${relativePath}/${name}`; + const path = join(root, child); + const metadata = await lstat(path); + if (metadata.isSymbolicLink()) { + entries.set(child, `link ${await readlink(path)}`); + } else if (metadata.isDirectory()) { + entries.set(child, 'dir'); + await visit(child); + } else if (metadata.isFile()) { + const digest = createHash('sha256').update(await readFile(path)).digest('hex'); + entries.set(child, `file ${digest} ${(metadata.mode & 0o777).toString(8)}`); + } else { + entries.set(child, 'special'); + } + } + }; + await visit(''); + return entries; +}; + +export interface TreeSnapshotDifference { + readonly added: readonly string[]; + readonly changed: readonly string[]; + readonly removed: readonly string[]; +} + +/** Entries present only after (`added`), only before (`removed`), or with different bytes (`changed`). */ +export const diffTreeSnapshots = (before: TreeSnapshot, after: TreeSnapshot): TreeSnapshotDifference => { + const added: string[] = []; + const changed: string[] = []; + const removed: string[] = []; + for (const [path, description] of after) { + const previous = before.get(path); + if (previous === undefined) added.push(path); + else if (previous !== description) changed.push(path); + } + for (const path of before.keys()) { + if (!after.has(path)) removed.push(path); + } + return Object.freeze({ added: Object.freeze(added), changed: Object.freeze(changed), removed: Object.freeze(removed) }); +}; + +export const treesIdentical = (before: TreeSnapshot, after: TreeSnapshot): boolean => { + const difference = diffTreeSnapshots(before, after); + return difference.added.length === 0 && difference.changed.length === 0 && difference.removed.length === 0; +}; diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts new file mode 100644 index 000000000..7c65cd864 --- /dev/null +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -0,0 +1,1252 @@ +import { execFile } from 'node:child_process'; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { runCli } from '../src/cli.ts'; +import { DiagnosticError } from '../src/core/diagnostics.ts'; +import { formatUninstallResult } from '../src/install/format.ts'; +import { installBundle, type InstallCommandRunner } from '../src/install/install.ts'; +import { + emptyContentHash, + installReceiptFile, + installReceiptFormat, + readInstallReceipt, + readInstallReceiptFile, +} from '../src/install/receipt.ts'; +import { uninstallBundle, type UninstallResult } from '../src/install/uninstall.ts'; +import { diffTreeSnapshots, snapshotTree, treesIdentical } from './support/tree-snapshot.ts'; + +interface CommandCall { + readonly args: readonly string[]; + readonly command: string; +} + +const writeJson = async (path: string, value: unknown): Promise => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value)}\n`); +}; + +interface Fixture { + readonly bundleRoot: string; + readonly cleanupRoot: string; + readonly home: string; +} + +const createFixture = async (host: 'claude' | 'codex' | 'cursor'): Promise => { + const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-uninstall-')); + const bundleRoot = join(cleanupRoot, 'bundle'); + const home = join(cleanupRoot, 'home'); + await mkdir(bundleRoot, { recursive: true }); + await mkdir(home, { recursive: true }); + await writeFile(join(bundleRoot, 'payload.txt'), 'payload\n'); + await writeFile(join(bundleRoot, 'INSTALL.md'), '# install\n'); + await writeFile(join(bundleRoot, 'install.mjs'), '// installer\n'); + await mkdir(join(bundleRoot, 'skills', 'probe'), { recursive: true }); + await writeFile(join(bundleRoot, 'skills', 'probe', 'SKILL.md'), '# probe\n'); + if (host === 'claude') { + await writeJson(join(bundleRoot, '.claude-plugin/plugin.json'), { name: 'uninstall-fixture', version: '1.2.3' }); + await writeJson(join(bundleRoot, '.claude-plugin/marketplace.json'), { + name: 'uninstall-fixture-marketplace', + plugins: [{ name: 'uninstall-fixture', source: './', version: '1.2.3' }], + }); + } else if (host === 'codex') { + await writeJson(join(bundleRoot, '.codex-plugin/plugin.json'), { name: 'uninstall-fixture', version: '1.2.3' }); + await writeJson(join(bundleRoot, '.agents/plugins/marketplace.json'), { + name: 'uninstall-fixture-marketplace', + plugins: [{ + category: 'Productivity', + name: 'uninstall-fixture', + policy: { authentication: 'ON_INSTALL', installation: 'AVAILABLE' }, + source: { path: './', source: 'local' }, + }], + }); + } else { + await writeJson(join(bundleRoot, '.cursor-plugin/plugin.json'), { name: 'uninstall-fixture', version: '1.2.3' }); + } + return { bundleRoot, cleanupRoot, home }; +}; + +const failureOf = async (promise: Promise): Promise => { + const error = await promise.catch((thrown: unknown) => thrown); + expect(error).toBeInstanceOf(DiagnosticError); + return error as DiagnosticError; +}; + +it('uninstalls a Cursor local install through its receipt and leaves the home byte-identical', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + await mkdir(cursorRoot); + await writeFile(join(cursorRoot, 'operator.json'), '{}\n'); + const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + try { + const before = await snapshotTree(fixture.home); + // Nothing installed: an honest no-op with no receipt to consume. + expect(await uninstallBundle(options)).toMatchObject({ + data: { outcome: 'absent', policy: 'keep' }, + receipt: { status: 'missing' }, + registrations: [{ action: 'already-absent', kind: 'cursor-local-plugin' }], + state: 'not-installed', + }); + expect(treesIdentical(before, await snapshotTree(fixture.home))).toBe(true); + + const installed = await installBundle(options); + expect(installed.state).toBe('installed'); + const receipt = await readInstallReceipt(destination); + expect(receipt?.hostDirectories).toEqual(['plugins', 'plugins/local']); + const afterInstall = await snapshotTree(fixture.home); + expect(diffTreeSnapshots(before, afterInstall).added.length).toBeGreaterThan(0); + + // --plan names every exact path and writes nothing. + const plan = await uninstallBundle({ ...options, plan: true }); + expect(plan).toMatchObject({ + destination, + forced: false, + mode: 'local', + receipt: { contentHash: installed.contentHash, path: join(destination, installReceiptFile), status: 'consumed' }, + registrations: [{ action: 'planned', kind: 'cursor-local-plugin' }], + state: 'planned', + }); + expect(plan.removed.files).toEqual([ + ...(receipt?.files ?? []).map((file) => join(destination, file)), + join(destination, installReceiptFile), + ]); + // Deepest first (children before parents), then the plugin root, then the host directories this install created. + expect(plan.removed.directories).toEqual([ + join(destination, '.cursor-plugin'), + join(destination, 'skills', 'probe'), + join(destination, 'skills'), + destination, + join(cursorRoot, 'plugins', 'local'), + join(cursorRoot, 'plugins'), + ]); + expect(treesIdentical(afterInstall, await snapshotTree(fixture.home))).toBe(true); + expect(formatUninstallResult(plan)).toContain('Would uninstall uninstall-fixture@1.2.3 for cursor (local mode)'); + expect(formatUninstallResult(plan)).toContain(join(destination, 'payload.txt')); + + const result = await uninstallBundle(options); + expect(result).toMatchObject({ + data: { outcome: 'absent', paths: [], policy: 'keep' }, + receipt: { status: 'consumed' }, + registrations: [{ action: 'removed', kind: 'cursor-local-plugin' }], + retained: [], + state: 'uninstalled', + }); + expect(result.removed.directories).toEqual(plan.removed.directories); + // The isolated home is exactly what it was before the install: plugins/ and plugins/local were ours to prune. + const afterUninstall = await snapshotTree(fixture.home); + expect(diffTreeSnapshots(before, afterUninstall)).toEqual({ added: [], changed: [], removed: [] }); + expect(formatUninstallResult(result)).toContain('Uninstalled uninstall-fixture@1.2.3 for cursor (local mode)'); + + // Rerun: idempotent no-op. + expect(await uninstallBundle(options)).toMatchObject({ state: 'not-installed' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('keeps Cursor runtime state and unowned entries by default and purges state only when confirmed', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + await mkdir(join(cursorRoot, 'plugins', 'local'), { recursive: true }); + const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + try { + const before = await snapshotTree(fixture.home); + await installBundle(options); + // plugins/local existed before: not ours, never pruned. + expect((await readInstallReceipt(destination))?.hostDirectories).toEqual([]); + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await writeFile(join(destination, 'operator-notes.md'), 'mine\n'); + // Unowned directories that hold nothing retained survive too (the prune only touches owned directories): + // one at the root and one nested inside an owned directory that would otherwise be pruned. + await mkdir(join(destination, 'scratch')); + await mkdir(join(destination, 'skills', 'drafts')); + + // --purge-data without confirmation is refused before anything changes. + const unconfirmed = await failureOf(uninstallBundle({ ...options, purgeData: true })); + expect(unconfirmed.diagnostics[0]).toMatchObject({ code: 'AB7008', target: 'cursor' }); + const conflicting = await failureOf(uninstallBundle({ ...options, confirmPurge: true, keepData: true, purgeData: true })); + expect(conflicting.diagnostics[0]?.code).toBe('AB7008'); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + + // --plan lists only the directories the run can actually prune: the plugin root is kept alive by state/ and + // the unowned note, so it is not planned for removal and the remnant receipt is announced instead. + const keepPlan = await uninstallBundle({ ...options, keepData: true, plan: true }); + expect(keepPlan.removed.directories).not.toContain(destination); + expect(keepPlan.removed.directories).toContain(join(destination, '.cursor-plugin')); + // skills/ is owned but skills/drafts is not: the unowned directory survives and keeps skills/ alive. + expect(keepPlan.removed.directories).not.toContain(join(destination, 'skills')); + expect(keepPlan.remnantReceipt).toBe(join(destination, installReceiptFile)); + expect(keepPlan.retained).toEqual(['operator-notes.md', 'scratch/', 'skills/drafts/']); + // Purging state/ still leaves the note, so the root survives that plan too; the purged directory is listed as one. + const purgePlan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); + expect(purgePlan.removed.directories[0]).toBe(join(destination, 'state')); + expect(purgePlan.removed.directories).not.toContain(destination); + expect(purgePlan.removed.files).not.toContain(join(destination, 'state')); + + const kept = await uninstallBundle({ ...options, keepData: true }); + expect(kept).toMatchObject({ + data: { outcome: 'kept', paths: [join(destination, 'state')], policy: 'keep' }, + remnantReceipt: join(destination, installReceiptFile), + retained: ['operator-notes.md', 'scratch/', 'skills/drafts/'], + state: 'uninstalled', + }); + expect(kept.removed.directories).toEqual(keepPlan.removed.directories); + expect(kept.removed.directories).not.toContain(destination); + // The surviving root keeps a remnant receipt: owns no files, records no registration, carries the host directories. + expect((await readdir(destination)).sort()).toEqual([installReceiptFile, 'operator-notes.md', 'scratch', 'skills', 'state']); + expect(await readdir(join(destination, 'skills'))).toEqual(['drafts']); + expect(await readInstallReceipt(destination)).toMatchObject({ files: [], hostDirectories: [], mode: 'local', registrations: [] }); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + expect(formatUninstallResult(kept)).toContain('Retained 3 unowned entries'); + expect(formatUninstallResult(kept)).toContain('Remnant receipt:'); + + // Reinstall beside the retained state (an install, not a replacement), then purge it with confirmation. + await rm(join(destination, 'operator-notes.md')); + await rm(join(destination, 'scratch'), { recursive: true }); + // skills/ survived the uninstall (its unowned child kept it alive), so a reinstall would find it pre-existing + // and not claim it; clear it so the reinstall owns its directories again. + await rm(join(destination, 'skills'), { recursive: true }); + expect(await installBundle(options)).toMatchObject({ state: 'installed' }); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(purged).toMatchObject({ + data: { outcome: 'purged', paths: [join(destination, 'state')], policy: 'purge' }, + retained: [], + state: 'uninstalled', + }); + expect(purged.remnantReceipt).toBeUndefined(); + // A purged state/ tree is a directory and is reported as one, ahead of the pruned owned directories. + expect(purged.removed.directories[0]).toBe(join(destination, 'state')); + expect(purged.removed.files).not.toContain(join(destination, 'state')); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('keeps created host directories receipt-owned across a --keep-data cycle in a fresh Cursor home', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + await mkdir(cursorRoot); + const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + try { + const before = await snapshotTree(fixture.home); + await installBundle(options); + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + // Keep: plugins/ and plugins/local cannot be pruned (they hold the state), so the remnant receipt remembers them. + const kept = await uninstallBundle(options); + expect(kept.remnantReceipt).toBe(join(destination, installReceiptFile)); + expect(await readInstallReceipt(destination)).toMatchObject({ hostDirectories: ['plugins', 'plugins/local'], registrations: [] }); + // Uninstalling the remnant itself while still keeping the data is the documented no-op: nothing to remove, the + // remnant receipt stays in place unchanged, and the run reports `not-installed`. + const remnantBefore = await readFile(join(destination, installReceiptFile), 'utf8'); + const rerun = await uninstallBundle(options); + expect(rerun).toMatchObject({ + data: { outcome: 'kept' }, + receipt: { status: 'remnant' }, + registrations: [{ action: 'already-absent', kind: 'cursor-local-plugin' }], + remnantReceipt: join(destination, installReceiptFile), + removed: { directories: [], files: [] }, + state: 'not-installed', + }); + expect(await readFile(join(destination, installReceiptFile), 'utf8')).toBe(remnantBefore); + expect(await uninstallBundle({ ...options, plan: true })).toMatchObject({ receipt: { status: 'remnant' }, state: 'not-installed' }); + // Reinstall around the state carries the host directories forward; a confirmed purge then restores the home exactly. + expect(await installBundle(options)).toMatchObject({ state: 'installed' }); + expect(await readInstallReceipt(destination)).toMatchObject({ hostDirectories: ['plugins', 'plugins/local'], registrations: [{ kind: 'cursor-local-plugin' }] }); + const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(purged.removed.directories).toEqual(expect.arrayContaining([join(cursorRoot, 'plugins', 'local'), join(cursorRoot, 'plugins')])); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + + // A remnant whose state/ was later removed by hand is still receipt-owned: an explicit purge consumes it and + // prunes the recorded host directories instead of taking the keep-data no-op path. + await installBundle(options); + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + expect((await uninstallBundle(options)).remnantReceipt).toBe(join(destination, installReceiptFile)); + await rm(join(destination, 'state'), { force: true, recursive: true }); + expect(await uninstallBundle(options)).toMatchObject({ receipt: { status: 'remnant' }, state: 'not-installed' }); + const emptyPlan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); + expect(emptyPlan).toMatchObject({ + data: { outcome: 'absent', policy: 'purge' }, + removed: { directories: [destination, join(cursorRoot, 'plugins', 'local'), join(cursorRoot, 'plugins')], files: [join(destination, installReceiptFile)] }, + state: 'planned', + }); + expect(emptyPlan.remnantReceipt).toBeUndefined(); + const consumed = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(consumed).toMatchObject({ data: { outcome: 'absent' }, removed: emptyPlan.removed, state: 'uninstalled' }); + expect(consumed.remnantReceipt).toBeUndefined(); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + + // A receipt recording a PLUGIN_DATA expansion (written by the emitted install.mjs for an Agent Plugins pack): + // the directory is receipt-owned durable state outside the plugin root. Written → kept behind a remnant that + // carries the expansion (the root survives to own it), purged only when confirmed; empty → pruned with its + // agent-bundle parents; recorded at another home → never touched. + const pluginData = join(cursorRoot, 'agent-bundle', 'plugin-data', 'uninstall-fixture'); + const withExpansion = async (recordedPluginData: string) => { + await installBundle(options); + const receipt = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as Record; + await writeFile(join(destination, installReceiptFile), JSON.stringify({ + ...receipt, + cursorExpansion: { documents: { 'mcp.json': '{}\n' }, pluginData: recordedPluginData, pluginRoot: destination }, + })); + }; + await withExpansion(pluginData); + await mkdir(join(pluginData, 'cache'), { recursive: true }); + await writeFile(join(pluginData, 'cache', 'index.json'), '{}\n'); + const keptPlan = await uninstallBundle({ ...options, plan: true }); + expect(keptPlan).toMatchObject({ data: { outcome: 'kept', paths: [pluginData] }, remnantReceipt: join(destination, installReceiptFile), state: 'planned' }); + expect(keptPlan.removed.directories).not.toContain(destination); + const keptData = await uninstallBundle(options); + expect(keptData).toMatchObject({ data: { outcome: 'kept', paths: [pluginData] }, remnantReceipt: join(destination, installReceiptFile), state: 'uninstalled' }); + expect(keptData.removed).toEqual(keptPlan.removed); + expect(await readFile(join(pluginData, 'cache', 'index.json'), 'utf8')).toBe('{}\n'); + expect(await readInstallReceipt(destination)).toMatchObject({ cursorExpansion: { pluginData, pluginRoot: destination }, files: [], registrations: [] }); + expect(await uninstallBundle(options)).toMatchObject({ data: { outcome: 'kept', paths: [pluginData] }, receipt: { status: 'remnant' }, state: 'not-installed' }); + const purgedData = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(purgedData).toMatchObject({ data: { outcome: 'purged', paths: [pluginData] }, state: 'uninstalled' }); + expect(purgedData.removed.directories).toEqual(expect.arrayContaining([pluginData, join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle'), destination])); + expect(purgedData.remnantReceipt).toBeUndefined(); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + + await withExpansion(pluginData); + await mkdir(pluginData, { recursive: true }); + const emptyData = await uninstallBundle({ ...options, plan: true }); + expect(emptyData).toMatchObject({ data: { detail: expect.stringContaining('is empty and is pruned'), outcome: 'absent' }, state: 'planned' }); + expect(emptyData.removed.directories).toEqual(expect.arrayContaining([pluginData, join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle')])); + expect((await uninstallBundle(options)).removed).toEqual(emptyData.removed); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + + const elsewhere = join(fixture.cleanupRoot, 'other-home', '.cursor', 'agent-bundle', 'plugin-data', 'uninstall-fixture'); + await withExpansion(elsewhere); + await mkdir(elsewhere, { recursive: true }); + await writeFile(join(elsewhere, 'note.txt'), 'theirs\n'); + const foreignData = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(foreignData).toMatchObject({ data: { detail: expect.stringContaining(`records PLUGIN_DATA at ${elsewhere}`), outcome: 'absent' }, state: 'uninstalled' }); + expect(await readFile(join(elsewhere, 'note.txt'), 'utf8')).toBe('theirs\n'); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('refuses Cursor local uninstalls without proof of ownership unless forced, and foreign directories always', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + await mkdir(cursorRoot); + const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + try { + await installBundle(options); + const receiptPath = join(destination, installReceiptFile); + + // Owned content modified after install: refused with the hash comparison; --force removes the owned set anyway. + await writeFile(join(destination, 'payload.txt'), 'modified\n'); + const mismatch = await failureOf(uninstallBundle(options)); + expect(mismatch.diagnostics[0]).toMatchObject({ code: 'AB7007', target: 'cursor' }); + expect(mismatch.diagnostics[0]?.message).toContain('modified after installation'); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('modified\n'); + const forcedMismatch = await uninstallBundle({ ...options, force: true, plan: true }); + expect(forcedMismatch).toMatchObject({ forced: true, receipt: { status: 'forced-mismatch' }, state: 'planned' }); + expect(await uninstallBundle({ ...options, force: true })).toMatchObject({ receipt: { status: 'forced-mismatch' }, state: 'uninstalled' }); + await expect(readdir(destination)).rejects.toMatchObject({ code: 'ENOENT' }); + + // Legacy pre-receipt layout: refused (AB7009) until --force, which removes the inventoried files only. + await installBundle(options); + await rm(receiptPath); + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + const legacy = await failureOf(uninstallBundle(options)); + expect(legacy.diagnostics[0]).toMatchObject({ code: 'AB7009', target: 'cursor' }); + expect(legacy.diagnostics[0]?.message).toContain('predates install receipts'); + const forcedLegacy = await uninstallBundle({ ...options, force: true }); + expect(forcedLegacy).toMatchObject({ + data: { outcome: 'kept' }, + receipt: { status: 'forced-legacy' }, + state: 'uninstalled', + }); + expect((await readdir(destination)).sort()).toEqual([installReceiptFile, 'state']); + await rm(destination, { force: true, recursive: true }); + + // A receipt naming another plugin, or a directory that is not ours at all: refused even with --force. + await installBundle(options); + const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; + await writeFile(receiptPath, JSON.stringify({ ...receipt, plugin: 'someone-else' })); + const otherPlugin = await failureOf(uninstallBundle({ ...options, force: true })); + expect(otherPlugin.diagnostics[0]).toMatchObject({ code: 'AB7007', target: 'cursor' }); + expect(otherPlugin.diagnostics[0]?.message).toContain('names plugin "someone-else"'); + await rm(destination, { force: true, recursive: true }); + await mkdir(join(destination, '.cursor-plugin'), { recursive: true }); + await writeJson(join(destination, '.cursor-plugin', 'plugin.json'), { name: 'uninstall-fixture', version: '1.2.3' }); + await writeFile(join(destination, 'payload.txt'), 'someone else\n'); + const foreign = await failureOf(uninstallBundle({ ...options, force: true })); + expect(foreign.diagnostics[0]).toMatchObject({ code: 'AB7007', target: 'cursor' }); + expect(foreign.diagnostics[0]?.message).toContain('foreign directory'); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('someone else\n'); + + // A symlinked destination is never traversed. + await rm(destination, { force: true, recursive: true }); + const elsewhere = join(fixture.cleanupRoot, 'elsewhere'); + await mkdir(elsewhere); + await symlink(elsewhere, destination); + const linked = await failureOf(uninstallBundle({ ...options, force: true })); + expect(linked.diagnostics[0]).toMatchObject({ code: 'AB7007' }); + expect(await readdir(elsewhere)).toEqual([]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('consumes a migrated format/1 Cursor receipt without a crash', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + await mkdir(join(cursorRoot, 'plugins', 'local'), { recursive: true }); + const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + try { + const before = await snapshotTree(fixture.home); + await installBundle(options); + const receiptPath = join(destination, installReceiptFile); + const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; + const { hostDirectories: _h, mode: _m, registrations: _r, scope: _s, updatedAt: _u, ...legacy } = receipt; + await writeFile(receiptPath, JSON.stringify({ ...legacy, format: 'agent-bundle-install-receipt/1' })); + expect((await readInstallReceipt(destination))?.migratedFrom).toBe('agent-bundle-install-receipt/1'); + + // An identical rerun of install upgrades the receipt in place without touching plugin files. + const upgraded = await installBundle(options); + expect(upgraded.state).toBe('already-installed'); + expect(await readInstallReceipt(destination)).toMatchObject({ format: installReceiptFormat, mode: 'local' }); + expect((await readInstallReceipt(destination))?.migratedFrom).toBeUndefined(); + + await writeFile(receiptPath, JSON.stringify({ ...legacy, format: 'agent-bundle-install-receipt/1' })); + const result = await uninstallBundle(options); + expect(result).toMatchObject({ + receipt: { migratedFrom: 'agent-bundle-install-receipt/1', status: 'migrated' }, + state: 'uninstalled', + }); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +const gitAvailable = (): Promise => new Promise((resolvePromise) => { + execFile('git', ['--version'], (error) => { resolvePromise(error === null); }); +}); + +it('removes a staged Cursor marketplace repository only when its HEAD matches the receipt', async () => { + if (!await gitAvailable()) return; + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + await mkdir(cursorRoot); + const repo = join(cursorRoot, 'agent-bundle', 'marketplaces', 'uninstall-fixture'); + const receiptPath = join(cursorRoot, 'agent-bundle', 'receipts', 'uninstall-fixture.marketplace.json'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const, mode: 'marketplace' as const }; + try { + const before = await snapshotTree(fixture.home); + expect(await uninstallBundle(options)).toMatchObject({ state: 'not-installed' }); + const staged = await installBundle(options); + expect(staged).toMatchObject({ mode: 'marketplace', receipt: receiptPath, state: 'staged' }); + expect(await readInstallReceiptFile(receiptPath)).toMatchObject({ + files: [], + mode: 'marketplace', + registrations: [{ commit: staged.commit, kind: 'cursor-marketplace-staging', name: 'uninstall-fixture-marketplace' }], + }); + // Rerunning the installer keeps the same receipt (same commit, same content). + expect(await installBundle(options)).toMatchObject({ state: 'already-installed' }); + + const plan = await uninstallBundle({ ...options, plan: true }); + expect(plan).toMatchObject({ + data: { outcome: 'unavailable' }, + destination: repo, + registrations: [{ action: 'planned', commit: staged.commit, kind: 'cursor-marketplace-staging' }], + // The plan names the store and staging directories the run would prune, in the run's order. + removed: { + directories: [repo, join(cursorRoot, 'agent-bundle', 'receipts'), join(cursorRoot, 'agent-bundle', 'marketplaces'), join(cursorRoot, 'agent-bundle')], + files: [receiptPath], + }, + state: 'planned', + }); + expect(plan.nextSteps).toBeUndefined(); + + // A staged tree that no longer matches the recorded commit is refused without --force. + await writeFile(join(repo, '.git', 'HEAD'), '0000000000000000000000000000000000000000\n'); + const drifted = await failureOf(uninstallBundle(options)); + expect(drifted.diagnostics[0]).toMatchObject({ code: 'AB7007', target: 'cursor' }); + expect(drifted.diagnostics[0]?.message).toContain('receipt recorded commit'); + await writeFile(join(repo, '.git', 'HEAD'), 'ref: refs/heads/master\n').catch(() => undefined); + // Restore HEAD to whatever branch git created (main or master). + const heads = await readdir(join(repo, '.git', 'refs', 'heads')); + await writeFile(join(repo, '.git', 'HEAD'), `ref: refs/heads/${heads[0]}\n`); + + // HEAD matches the receipt, but someone added an untracked file to the staged working tree: the receipt does + // not own it and the removal is recursive, so the uninstall (and its plan) refuses without --force. + await writeFile(join(repo, 'notes.txt'), 'operator notes\n'); + const dirty = await failureOf(uninstallBundle(options)); + expect(dirty.diagnostics[0]).toMatchObject({ code: 'AB7007', target: 'cursor' }); + expect(dirty.diagnostics[0]?.message).toContain('working tree differs from the receipted commit'); + expect(dirty.diagnostics[0]?.message).toContain('"notes.txt"'); + expect((await failureOf(uninstallBundle({ ...options, plan: true }))).diagnostics[0]).toMatchObject({ code: 'AB7007' }); + expect(await readFile(join(repo, 'notes.txt'), 'utf8')).toBe('operator notes\n'); + // --force removes it anyway and says so through the receipt status. + expect(await uninstallBundle({ ...options, force: true, plan: true })).toMatchObject({ receipt: { status: 'forced-mismatch' }, state: 'planned' }); + await rm(join(repo, 'notes.txt')); + + const result = await uninstallBundle(options); + expect(result).toMatchObject({ + receipt: { status: 'consumed' }, + registrations: [{ action: 'removed', kind: 'cursor-marketplace-staging' }], + state: 'uninstalled', + }); + // The completed result removes exactly what the plan named. + expect(result.removed).toEqual(plan.removed); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + + // Staging without a receipt (or a receipt someone deleted): refused, then removable with --force once verified ours. + await installBundle(options); + await rm(receiptPath); + const missing = await failureOf(uninstallBundle(options)); + expect(missing.diagnostics[0]).toMatchObject({ code: 'AB7009', target: 'cursor' }); + expect(await uninstallBundle({ ...options, force: true })).toMatchObject({ receipt: { status: 'forced-missing' }, state: 'uninstalled' }); + // An orphaned receipt (repository already gone) is consumed quietly. + await installBundle(options); + await rm(repo, { force: true, recursive: true }); + expect(await uninstallBundle(options)).toMatchObject({ + registrations: [{ action: 'already-absent', kind: 'cursor-marketplace-staging' }], + removed: { files: [receiptPath] }, + state: 'uninstalled', + }); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + + // Cursor imported the staging (a completed cache copy at the recorded commit) and the staged repository was + // deleted afterwards: the imported copy is still Cursor-owned, so the receipt's commit — not the repository — + // drives the `manual` registration and the Customize step. + const imported = await installBundle(options); + const cachedCopy = join(cursorRoot, 'plugins', 'cache', 'uninstall-fixture-marketplace', 'uninstall-fixture', imported.commit ?? ''); + await mkdir(join(cachedCopy, '.cursor-plugin'), { recursive: true }); + await writeFile(join(cachedCopy, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'uninstall-fixture', version: '1.2.3' })); + await writeFile(join(cachedCopy, '.cache-complete'), ''); + await rm(repo, { force: true, recursive: true }); + const stagingGone = await uninstallBundle(options); + expect(stagingGone.registrations).toEqual([ + expect.objectContaining({ action: 'already-absent', kind: 'cursor-marketplace-staging' }), + expect.objectContaining({ action: 'manual', kind: 'cursor-marketplace-staging' }), + ]); + expect(stagingGone.nextSteps?.[0]).toContain('Customize -> Plugins'); + expect(await readFile(join(cachedCopy, '.cache-complete'), 'utf8')).toBe(''); + await rm(join(cursorRoot, 'plugins'), { force: true, recursive: true }); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + + // The bundle was rebuilt to a newer version after Cursor imported the staging: the imported copy carries the + // version the receipt recorded, so detection keys on the receipt's version, not the rebuilt bundle's. + const rebuilt = await installBundle(options); + const olderCopy = join(cursorRoot, 'plugins', 'cache', 'uninstall-fixture-marketplace', 'uninstall-fixture', rebuilt.commit ?? ''); + await mkdir(join(olderCopy, '.cursor-plugin'), { recursive: true }); + await writeFile(join(olderCopy, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'uninstall-fixture', version: '1.2.3' })); + await writeFile(join(olderCopy, '.cache-complete'), ''); + await writeJson(join(fixture.bundleRoot, '.cursor-plugin/plugin.json'), { name: 'uninstall-fixture', version: '1.3.0' }); + const afterRebuild = await uninstallBundle(options); + expect(afterRebuild.registrations).toEqual([ + expect.objectContaining({ action: 'removed', kind: 'cursor-marketplace-staging' }), + expect.objectContaining({ action: 'manual', kind: 'cursor-marketplace-staging' }), + ]); + expect(afterRebuild.nextSteps?.[0]).toContain('Customize -> Plugins'); + await writeJson(join(fixture.bundleRoot, '.cursor-plugin/plugin.json'), { name: 'uninstall-fixture', version: '1.2.3' }); + await rm(join(cursorRoot, 'plugins'), { force: true, recursive: true }); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}, 60_000); + +const claudeListing = (rows: readonly Record[]): string => JSON.stringify(rows); + +const codexListing = (rows: readonly Record[]): string => JSON.stringify({ available: [], installed: rows }); + +const recordingRunner = ( + respond: (call: CommandCall) => string, +): { readonly calls: CommandCall[]; readonly runner: InstallCommandRunner } => { + const calls: CommandCall[] = []; + return { + calls, + runner: { + run: async (command, args) => { + const call = { args: [...args], command }; + calls.push(call); + return { code: 0, stderr: '', stdout: respond(call) }; + }, + }, + }; +}; + +it.each([ + { + host: 'claude' as const, + listing: (installed: boolean, installPath: string) => claudeListing(installed + ? [{ enabled: true, id: 'uninstall-fixture@uninstall-fixture-marketplace', installPath, scope: 'user', version: '1.2.3' }] + : []), + marketplaces: (present: boolean) => JSON.stringify(present ? [{ name: 'uninstall-fixture-marketplace', source: 'directory' }] : []), + removeMarketplace: 'plugin marketplace remove uninstall-fixture-marketplace', + uninstall: 'plugin uninstall uninstall-fixture@uninstall-fixture-marketplace --scope user --keep-data', + }, + { + host: 'codex' as const, + listing: (installed: boolean) => codexListing(installed + ? [{ enabled: true, installed: true, pluginId: 'uninstall-fixture@uninstall-fixture-marketplace', version: '1.2.3' }] + : []), + marketplaces: (present: boolean) => JSON.stringify({ marketplaces: present ? [{ name: 'uninstall-fixture-marketplace', root: '/x' }] : [] }), + removeMarketplace: 'plugin marketplace remove uninstall-fixture-marketplace', + uninstall: 'plugin remove uninstall-fixture@uninstall-fixture-marketplace', + }, +])('reverses the $host registrations the receipt records, in order, and consumes the store receipt', async ({ host, listing, marketplaces, removeMarketplace, uninstall }) => { + const fixture = await createFixture(host); + const hostRoot = join(fixture.cleanupRoot, `${host}-root`); + const environment = host === 'claude' ? { CLAUDE_CONFIG_DIR: hostRoot } : { CODEX_HOME: hostRoot }; + const installPath = join(hostRoot, 'plugins', 'cache', 'uninstall-fixture-marketplace', 'uninstall-fixture', '1.2.3'); + const receiptPath = join(hostRoot, 'agent-bundle', 'receipts', 'uninstall-fixture.uninstall-fixture-marketplace.user.json'); + let installed = false; + let marketplaceRegistered = false; + const { calls, runner } = recordingRunner((call) => { + const verb = call.args.join(' '); + if (verb === 'plugin list --json') return listing(installed, installPath); + if (verb === 'plugin marketplace list --json') return marketplaces(marketplaceRegistered); + if (verb.startsWith('plugin marketplace add')) marketplaceRegistered = true; + if (verb.startsWith('plugin install ') || verb.startsWith('plugin add ')) installed = true; + if (verb === uninstall) installed = false; + if (verb === removeMarketplace) marketplaceRegistered = false; + return ''; + }); + const options = { commandRunner: runner, environment, from: fixture.bundleRoot, home: fixture.home, host, scope: 'user' as const }; + try { + await mkdir(hostRoot, { recursive: true }); + const before = await snapshotTree(hostRoot); + expect(await uninstallBundle(options)).toMatchObject({ receipt: { status: 'missing' }, state: 'not-installed' }); + expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); + + calls.length = 0; + const result = await installBundle(options); + expect(result).toMatchObject({ receipt: receiptPath, state: 'installed' }); + // Simulate the host's cache copy so the content check has something to compare. + await cp(fixture.bundleRoot, installPath, { recursive: true }); + + calls.length = 0; + const plan = await uninstallBundle({ ...options, plan: true }); + expect(plan).toMatchObject({ + destination: installPath, + mode: 'host-cli', + receipt: { path: receiptPath, status: 'consumed' }, + // Reported in execution order: the plugin leaves before the marketplace it came from. + registrations: [ + { action: 'planned', id: 'uninstall-fixture@uninstall-fixture-marketplace', kind: `${host}-plugin` }, + { action: 'planned', kind: `${host}-marketplace`, name: 'uninstall-fixture-marketplace' }, + ], + // The plan names the store directories the run would prune once this last receipt is gone. + removed: { directories: [join(hostRoot, 'agent-bundle', 'receipts'), join(hostRoot, 'agent-bundle')], files: [receiptPath] }, + state: 'planned', + }); + // Planning reads the host inventory and marketplace list only; no mutation verb runs. + expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json', 'plugin marketplace list --json', 'plugin list --json']); + expect(await readInstallReceiptFile(receiptPath)).toBeDefined(); + + // The cache copy differs from the receipt: refused without --force; nothing ran but the reads. + calls.length = 0; + await writeFile(join(installPath, 'payload.txt'), 'modified\n'); + const mismatch = await failureOf(uninstallBundle(options)); + expect(mismatch.diagnostics[0]).toMatchObject({ code: 'AB7007', target: host }); + expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); + await writeFile(join(installPath, 'payload.txt'), 'payload\n'); + + calls.length = 0; + const uninstalled = await uninstallBundle(options); + expect(uninstalled).toMatchObject({ + registrations: [ + { action: 'removed', kind: `${host}-plugin` }, + { action: 'removed', kind: `${host}-marketplace` }, + ], + removed: { files: [receiptPath] }, + state: 'uninstalled', + }); + expect(uninstalled.removed).toEqual(plan.removed); + expect(calls.map((call) => call.args.join(' '))).toEqual([ + 'plugin list --json', + 'plugin marketplace list --json', + 'plugin list --json', + uninstall, + removeMarketplace, + ]); + expect(await readInstallReceiptFile(receiptPath)).toBeUndefined(); + // The simulated cache copy is host-owned residue in this unit test; everything Agent Bundle wrote is gone. + await rm(join(hostRoot, 'plugins'), { force: true, recursive: true }); + expect(diffTreeSnapshots(before, await snapshotTree(hostRoot))).toEqual({ added: [], changed: [], removed: [] }); + + // Host says installed but no receipt: refused (AB7009) until --force, which uninstalls through the host CLI. + await installBundle(options); + await rm(receiptPath); + calls.length = 0; + const missing = await failureOf(uninstallBundle(options)); + expect(missing.diagnostics[0]).toMatchObject({ code: 'AB7009', target: host }); + expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); + calls.length = 0; + const forced = await uninstallBundle({ ...options, force: true }); + expect(forced).toMatchObject({ forced: true, receipt: { status: 'forced-missing' }, state: 'uninstalled' }); + // Without a receipt nothing proves Agent Bundle registered the marketplace, so --force removes the plugin only + // and retains the marketplace, naming the verb to run by hand. + expect(forced.registrations).toEqual([ + expect.objectContaining({ action: 'removed', kind: `${host}-plugin` }), + expect.objectContaining({ action: 'retained', detail: expect.stringContaining('no Agent Bundle receipt records'), kind: `${host}-marketplace` }), + ]); + expect(calls.map((call) => call.args.join(' '))).toContain(uninstall); + expect(calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + expect(marketplaceRegistered).toBe(true); + + // The marketplace now pre-exists the next install, so that install's receipt does not claim it and its uninstall + // retains it for the same reason; the operator removes it by hand. + await installBundle(options); + expect((await readInstallReceiptFile(receiptPath))?.registrations.map((registration) => registration.kind)).toEqual([`${host}-plugin`]); + calls.length = 0; + const unclaimed = await uninstallBundle(options); + expect(unclaimed.registrations.find((registration) => registration.kind === `${host}-marketplace`)).toMatchObject({ action: 'retained' }); + expect(calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + marketplaceRegistered = false; + + // Another plugin still installs from the marketplace: the marketplace registration is retained. + await installBundle(options); + expect((await readInstallReceiptFile(receiptPath))?.registrations.map((registration) => registration.kind)) + .toEqual([`${host}-marketplace`, `${host}-plugin`]); + calls.length = 0; + const shared = recordingRunner((call) => { + const verb = call.args.join(' '); + if (verb === 'plugin list --json') { + return host === 'claude' + ? claudeListing([ + { enabled: true, id: 'uninstall-fixture@uninstall-fixture-marketplace', installPath, scope: 'user', version: '1.2.3' }, + { enabled: true, id: 'other@uninstall-fixture-marketplace', installPath: join(hostRoot, 'other'), scope: 'user', version: '1.0.0' }, + ]) + : codexListing([ + { enabled: true, installed: true, pluginId: 'uninstall-fixture@uninstall-fixture-marketplace', version: '1.2.3' }, + { enabled: true, installed: true, pluginId: 'other@uninstall-fixture-marketplace', version: '1.0.0' }, + ]); + } + if (verb === 'plugin marketplace list --json') return marketplaces(true); + return ''; + }); + const retained = await uninstallBundle({ ...options, commandRunner: shared.runner }); + expect(retained.registrations.find((registration) => registration.kind === `${host}-marketplace`)).toMatchObject({ + action: 'retained', + detail: expect.stringContaining('other@uninstall-fixture-marketplace'), + }); + // The consumed receipt was the only record that Agent Bundle registered the marketplace and the dependent is a + // live row without a receipt to inherit the claim: the loss is stated, not hidden. + expect(retained.registrations.find((registration) => registration.kind === `${host}-marketplace`)?.detail) + .toContain('no dependent has a receipt to carry that claim'); + expect(shared.calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + expect(shared.calls.map((call) => call.args.join(' '))).toContain(uninstall); + installed = false; + marketplaceRegistered = false; + + // The dependency re-read fails after the first inventory succeeded: a failed read is not proof that + // nothing depends on the marketplace, so the marketplace is retained (fail-closed), never removed. + await installBundle(options); + let listCalls = 0; + const flaky: { readonly calls: CommandCall[]; readonly runner: InstallCommandRunner } = { calls: [], runner: { + run: async (command, args) => { + const call = { args: [...args], command }; + flaky.calls.push(call); + const verb = args.join(' '); + if (verb === 'plugin list --json') { + listCalls += 1; + return listCalls === 1 + ? { code: 0, stderr: '', stdout: listing(true, installPath) } + : { code: 1, stderr: 'transient failure', stdout: '' }; + } + if (verb === 'plugin marketplace list --json') return { code: 0, stderr: '', stdout: marketplaces(true) }; + return { code: 0, stderr: '', stdout: '' }; + }, + } }; + const unproven = await uninstallBundle({ ...options, commandRunner: flaky.runner }); + expect(unproven).toMatchObject({ + registrations: [ + { action: 'removed', kind: `${host}-plugin` }, + { action: 'retained', detail: expect.stringContaining('could not be read to prove'), kind: `${host}-marketplace` }, + ], + state: 'uninstalled', + }); + expect(flaky.calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + installed = false; + marketplaceRegistered = false; + + if (host === 'claude') { + // The same plugin installed at another Claude scope still depends on the marketplace, and + // `plugin marketplace remove` applies to every scope: the marketplace is retained. + await installBundle(options); + const scoped = recordingRunner((call) => { + const verb = call.args.join(' '); + if (verb === 'plugin list --json') { + return claudeListing([ + { enabled: true, id: 'uninstall-fixture@uninstall-fixture-marketplace', installPath, scope: 'user', version: '1.2.3' }, + { enabled: true, id: 'uninstall-fixture@uninstall-fixture-marketplace', installPath, scope: 'project', version: '1.2.3' }, + ]); + } + if (verb === 'plugin marketplace list --json') return marketplaces(true); + return ''; + }); + // The cache copy and plugins/data/ are scope-less: while the project scope still uses them, purging + // from the user scope is refused before any host verb runs. + await cp(fixture.bundleRoot, installPath, { recursive: true }); + await mkdir(join(installPath, 'state'), { recursive: true }); + await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + const sharedPurge = await failureOf(uninstallBundle({ ...options, commandRunner: scoped.runner, confirmPurge: true, purgeData: true })); + expect(sharedPurge.diagnostics[0]).toMatchObject({ code: 'AB7008', target: 'claude' }); + expect(sharedPurge.diagnostics[0]?.message).toContain('(scope project)'); + expect(scoped.calls.map((call) => call.args.join(' '))).not.toContain(uninstall); + expect(await readFile(join(installPath, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + await rm(installPath, { force: true, recursive: true }); + scoped.calls.length = 0; + const otherScope = await uninstallBundle({ ...options, commandRunner: scoped.runner }); + expect(otherScope.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ + action: 'retained', + detail: expect.stringContaining('uninstall-fixture@uninstall-fixture-marketplace (scope project)'), + }); + expect(scoped.calls.map((call) => call.args.join(' '))).toContain(uninstall); + expect(scoped.calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + installed = false; + marketplaceRegistered = false; + + // A project-scope install elsewhere is invisible to `plugin list --json` run here, but its receipt is + // not: another store receipt recording the marketplace keeps it registered. + await installBundle(options); + const elsewhere = join(hostRoot, 'agent-bundle', 'receipts', 'uninstall-fixture.uninstall-fixture-marketplace.project.0123456789ab.json'); + await writeFile(elsewhere, JSON.stringify({ + contentHash: emptyContentHash, + directories: [], + files: [], + format: installReceiptFormat, + host: 'claude', + hostDirectories: [], + installedAt: '2026-09-03T00:00:00.000Z', + mode: 'host-cli', + plugin: 'uninstall-fixture', + projectRoot: '/elsewhere/project', + registrations: [ + { kind: 'claude-marketplace', name: 'uninstall-fixture-marketplace', scope: 'project' }, + { id: 'uninstall-fixture@uninstall-fixture-marketplace', kind: 'claude-plugin', scope: 'project' }, + ], + scope: 'project', + updatedAt: '2026-09-03T00:00:00.000Z', + version: '1.2.3', + })); + calls.length = 0; + const byReceipt = await uninstallBundle(options); + expect(byReceipt.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ + action: 'retained', + detail: expect.stringContaining(`receipt ${elsewhere}`), + }); + expect(calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + // The other project's receipt already records the marketplace, so nothing moves: it is untouched, and the + // store directory it lives in is not pruned. + expect(byReceipt.registrations.find((registration) => registration.kind === 'claude-marketplace')?.detail).not.toContain('claim'); + expect(await readInstallReceiptFile(elsewhere)).toMatchObject({ projectRoot: '/elsewhere/project', scope: 'project', updatedAt: '2026-09-03T00:00:00.000Z' }); + installed = false; + marketplaceRegistered = false; + + // A store receipt that cannot be parsed may be exactly the dependent (or the ownership heir) that keeps the + // marketplace alive: an unreadable receipt makes the dependency set unknown, and unknown fails closed — + // the marketplace is retained, never removed on the strength of a store that could not be read. + await installBundle(options); + await writeFile(elsewhere, '{ not a receipt'); + calls.length = 0; + const unreadable = await uninstallBundle(options); + expect(unreadable.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ + action: 'retained', + detail: expect.stringContaining('receipt in the Agent Bundle receipt store could not be read'), + }); + expect(calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + expect(calls.map((call) => call.args.join(' '))).toContain(uninstall); + expect(await readFile(elsewhere, 'utf8')).toBe('{ not a receipt'); + installed = false; + marketplaceRegistered = false; + + // An install made after the marketplace already existed records only its plugin registration, yet that + // plugin still installs from the marketplace: a plugin-only receipt elsewhere counts as a dependent too, + // both for the marketplace and — same plugin id — for the scope-less durable state a purge would remove. + const pluginOnlyReceipt = (plugin: string, projectRoot: string) => JSON.stringify({ + contentHash: emptyContentHash, + directories: [], + files: [], + format: installReceiptFormat, + host: 'claude', + hostDirectories: [], + installedAt: '2026-09-03T00:00:00.000Z', + mode: 'host-cli', + plugin, + projectRoot, + registrations: [{ id: `${plugin}@uninstall-fixture-marketplace`, kind: 'claude-plugin', scope: 'project' }], + scope: 'project', + updatedAt: '2026-09-03T00:00:00.000Z', + version: '1.2.3', + }); + await writeFile(elsewhere, pluginOnlyReceipt('uninstall-fixture', '/elsewhere/project')); + await installBundle(options); + await cp(fixture.bundleRoot, installPath, { recursive: true }); + await mkdir(join(installPath, 'state'), { recursive: true }); + await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + calls.length = 0; + const receiptSharedPurge = await failureOf(uninstallBundle({ ...options, confirmPurge: true, purgeData: true })); + expect(receiptSharedPurge.diagnostics[0]).toMatchObject({ code: 'AB7008', target: 'claude' }); + expect(receiptSharedPurge.diagnostics[0]?.message).toContain(`receipt ${elsewhere} (scope project in /elsewhere/project)`); + expect(calls.map((call) => call.args.join(' '))).not.toContain(uninstall); + expect(await readFile(join(installPath, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + await rm(installPath, { force: true, recursive: true }); + calls.length = 0; + // --plan announces the move without writing it. + const planMove = await uninstallBundle({ ...options, plan: true }); + expect(planMove.registrations.find((registration) => registration.kind === 'claude-marketplace')?.detail) + .toContain(`claim would move to receipt ${elsewhere}`); + expect((await readInstallReceiptFile(elsewhere))?.registrations.map((registration) => registration.kind)).toEqual(['claude-plugin']); + calls.length = 0; + const byPluginOnlyReceipt = await uninstallBundle(options); + expect(byPluginOnlyReceipt.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ + action: 'retained', + detail: expect.stringContaining(`receipt ${elsewhere} (scope project in /elsewhere/project)`), + }); + expect(calls.map((call) => call.args.join(' '))).toContain(uninstall); + expect(calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + // The consumed receipt held the only marketplace claim; it moves to the dependent's receipt (at that + // receipt's scope) so the last uninstall can still remove the marketplace Agent Bundle created. + expect(byPluginOnlyReceipt.registrations.find((registration) => registration.kind === 'claude-marketplace')?.detail) + .toContain(`claim moves to receipt ${elsewhere}`); + const heir = await readInstallReceiptFile(elsewhere); + expect(heir?.registrations).toEqual([ + { id: 'uninstall-fixture@uninstall-fixture-marketplace', kind: 'claude-plugin', scope: 'project' }, + { kind: 'claude-marketplace', name: 'uninstall-fixture-marketplace', scope: 'project' }, + ]); + expect(heir?.updatedAt).not.toBe('2026-09-03T00:00:00.000Z'); + expect(heir).toMatchObject({ installedAt: '2026-09-03T00:00:00.000Z', projectRoot: '/elsewhere/project' }); + installed = false; + marketplaceRegistered = false; + + // Another plugin's plugin-only receipt from the same marketplace retains the marketplace as well, but is + // not a same-plugin dependent: purging this plugin's own durable state is allowed. + const otherPlugin = join(hostRoot, 'agent-bundle', 'receipts', 'other-fixture.uninstall-fixture-marketplace.project.0123456789ab.json'); + await rm(elsewhere); + await writeFile(otherPlugin, pluginOnlyReceipt('other-fixture', '/elsewhere/other')); + await installBundle(options); + await cp(fixture.bundleRoot, installPath, { recursive: true }); + await mkdir(join(installPath, 'state'), { recursive: true }); + await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + calls.length = 0; + const byOtherReceipt = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(byOtherReceipt).toMatchObject({ data: { outcome: 'purged' }, state: 'uninstalled' }); + expect(byOtherReceipt.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ + action: 'retained', + detail: expect.stringContaining(`receipt ${otherPlugin}`), + }); + expect(calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + // The claim moves to the other plugin's receipt as well: any dependent receipt can carry it. + expect(await readInstallReceiptFile(otherPlugin)).toMatchObject({ + plugin: 'other-fixture', + registrations: [ + { id: 'other-fixture@uninstall-fixture-marketplace', kind: 'claude-plugin', scope: 'project' }, + { kind: 'claude-marketplace', name: 'uninstall-fixture-marketplace', scope: 'project' }, + ], + }); + await rm(installPath, { force: true, recursive: true }); + await rm(otherPlugin); + await rm(join(hostRoot, 'agent-bundle'), { force: true, recursive: true }); + installed = false; + marketplaceRegistered = false; + + // A project- or local-scope install made by hand in another project has no receipt and is invisible to + // `plugin list --json` run here, but Claude records every scope of every install in its own + // plugins/installed_plugins.json: that registry is read too, so the marketplace (and, for the same plugin, + // the scope-less durable state) is retained for consumers no receipt describes. + const registryPath = join(hostRoot, 'plugins', 'installed_plugins.json'); + const registry = (plugins: Record) => + JSON.stringify({ plugins: Object.fromEntries(Object.entries(plugins).map(([pluginId, installs]) => [pluginId, installs.map((install) => ({ + ...install, installPath, installedAt: '2026-09-03T00:00:00.000Z', lastUpdated: '2026-09-03T00:00:00.000Z', version: '1.2.3', + }))])), version: 2 }); + await mkdir(join(hostRoot, 'plugins'), { recursive: true }); + await installBundle(options); + await writeFile(registryPath, registry({ 'uninstall-fixture@uninstall-fixture-marketplace': [ + { scope: 'user' }, + { projectPath: '/elsewhere/by-hand', scope: 'project' }, + ] })); + await cp(fixture.bundleRoot, installPath, { recursive: true }); + await mkdir(join(installPath, 'state'), { recursive: true }); + await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + calls.length = 0; + const registrySharedPurge = await failureOf(uninstallBundle({ ...options, confirmPurge: true, purgeData: true })); + expect(registrySharedPurge.diagnostics[0]).toMatchObject({ code: 'AB7008', target: 'claude' }); + expect(registrySharedPurge.diagnostics[0]?.message) + .toContain('uninstall-fixture@uninstall-fixture-marketplace (scope project in /elsewhere/by-hand, per plugins/installed_plugins.json)'); + expect(calls.map((call) => call.args.join(' '))).not.toContain(uninstall); + expect(await readFile(join(installPath, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + await rm(installPath, { force: true, recursive: true }); + calls.length = 0; + const byRegistry = await uninstallBundle(options); + expect(byRegistry.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ + action: 'retained', + detail: expect.stringContaining('(scope project in /elsewhere/by-hand, per plugins/installed_plugins.json) still install from it'), + }); + expect(calls.map((call) => call.args.join(' '))).toContain(uninstall); + expect(calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + installed = false; + marketplaceRegistered = false; + + // Another plugin from the same marketplace, known only to the registry, retains the marketplace but is not a + // same-plugin dependent: purging this plugin's own durable state is allowed. + await installBundle(options); + await writeFile(registryPath, registry({ + 'other-fixture@uninstall-fixture-marketplace': [{ projectPath: '/elsewhere/other', scope: 'local' }], + 'uninstall-fixture@uninstall-fixture-marketplace': [{ scope: 'user' }], + })); + await cp(fixture.bundleRoot, installPath, { recursive: true }); + await mkdir(join(installPath, 'state'), { recursive: true }); + await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + calls.length = 0; + const byOtherRegistered = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(byOtherRegistered).toMatchObject({ data: { outcome: 'purged' }, state: 'uninstalled' }); + expect(byOtherRegistered.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ + action: 'retained', + detail: expect.stringContaining('other-fixture@uninstall-fixture-marketplace (scope local in /elsewhere/other, per plugins/installed_plugins.json)'), + }); + expect(calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + await rm(installPath, { force: true, recursive: true }); + installed = false; + marketplaceRegistered = false; + + // The registry naming only the copy being removed is not a dependent: the marketplace goes. + await installBundle(options); + await writeFile(registryPath, registry({ 'uninstall-fixture@uninstall-fixture-marketplace': [{ scope: 'user' }] })); + calls.length = 0; + const selfOnly = await uninstallBundle(options); + expect(selfOnly.registrations).toMatchObject([ + { action: 'removed', kind: 'claude-plugin' }, + { action: 'removed', kind: 'claude-marketplace' }, + ]); + expect(calls.map((call) => call.args.join(' '))).toContain(removeMarketplace); + installed = false; + marketplaceRegistered = false; + + // A registry that cannot be parsed makes the dependency set unknown, and unknown fails closed. + await installBundle(options); + await writeFile(registryPath, '{ not a registry'); + calls.length = 0; + const unreadableRegistry = await uninstallBundle(options); + expect(unreadableRegistry.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ + action: 'retained', + detail: expect.stringContaining('the plugins/installed_plugins.json registry, or a receipt in the Agent Bundle receipt store could not be read'), + }); + expect(calls.map((call) => call.args.join(' '))).toContain(uninstall); + expect(calls.map((call) => call.args.join(' '))).not.toContain(removeMarketplace); + await rm(join(hostRoot, 'plugins'), { force: true, recursive: true }); + installed = false; + marketplaceRegistered = false; + } + + // An orphaned receipt (host already forgot the plugin) is consumed without any host verb. + await installBundle(options); + installed = false; + marketplaceRegistered = false; + calls.length = 0; + const orphan = await uninstallBundle(options); + expect(orphan).toMatchObject({ + registrations: [ + { action: 'already-absent', kind: `${host}-plugin` }, + { action: 'already-absent', kind: `${host}-marketplace` }, + ], + state: 'uninstalled', + }); + expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json', 'plugin marketplace list --json']); + expect(await readInstallReceiptFile(receiptPath)).toBeUndefined(); + + // An unusable inventory fails closed before any mutation. + const unusable = recordingRunner(() => 'not json'); + const error = await failureOf(uninstallBundle({ ...options, commandRunner: unusable.runner })); + expect(error.diagnostics[0]).toMatchObject({ code: 'AB7004', target: host }); + expect(unusable.calls).toHaveLength(1); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('purges Claude durable state only when confirmed and reports the host-retained default honestly', async () => { + const fixture = await createFixture('claude'); + const hostRoot = join(fixture.cleanupRoot, 'claude-root'); + const installPath = join(hostRoot, 'plugins', 'cache', 'uninstall-fixture-marketplace', 'uninstall-fixture', '1.2.3'); + const dataDirectory = join(hostRoot, 'plugins', 'data', 'uninstall-fixture@uninstall-fixture-marketplace'); + let installed = false; + const { runner } = recordingRunner((call) => { + const verb = call.args.join(' '); + if (verb === 'plugin list --json') { + return claudeListing(installed + ? [{ enabled: true, id: 'uninstall-fixture@uninstall-fixture-marketplace', installPath, scope: 'user', version: '1.2.3' }] + : []); + } + if (verb === 'plugin marketplace list --json') return JSON.stringify([{ name: 'uninstall-fixture-marketplace' }]); + if (verb.startsWith('plugin install ')) installed = true; + if (verb.startsWith('plugin uninstall ')) installed = false; + return ''; + }); + const options = { + commandRunner: runner, + environment: { CLAUDE_CONFIG_DIR: hostRoot }, + from: fixture.bundleRoot, + home: fixture.home, + host: 'claude' as const, + }; + try { + await installBundle(options); + await cp(fixture.bundleRoot, installPath, { recursive: true }); + await mkdir(join(installPath, 'state')); + await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + await mkdir(dataDirectory, { recursive: true }); + await writeFile(join(dataDirectory, 'notes.txt'), 'data\n'); + + const kept = await uninstallBundle({ ...options, plan: true }); + expect(kept.data).toMatchObject({ + outcome: 'retained-by-host', + paths: [join(installPath, 'state'), dataDirectory], + policy: 'keep', + }); + expect(kept.data.detail).toContain('--keep-data'); + + // The plan and the run report the purged state trees as the directories they are, never as files. + const purgePlan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); + expect(purgePlan.removed.directories.slice(0, 2)).toEqual([join(installPath, 'state'), dataDirectory]); + expect(purgePlan.removed.files).toEqual([expect.stringContaining('uninstall-fixture.uninstall-fixture-marketplace.user.json')]); + const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(purged.data).toMatchObject({ outcome: 'purged', paths: [join(installPath, 'state'), dataDirectory], policy: 'purge' }); + expect(purged.removed.directories.slice(0, 2)).toEqual([join(installPath, 'state'), dataDirectory]); + expect(purged.removed.files).toEqual([expect.stringContaining('uninstall-fixture.uninstall-fixture-marketplace.user.json')]); + await expect(readdir(join(installPath, 'state'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readdir(dataDirectory)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('types the Codex data outcome as unavailable for keep and removed-by-host for purge', async () => { + const fixture = await createFixture('codex'); + const hostRoot = join(fixture.cleanupRoot, 'codex-root'); + let installed = false; + const { runner } = recordingRunner((call) => { + const verb = call.args.join(' '); + if (verb === 'plugin list --json') { + return codexListing(installed + ? [{ enabled: true, installed: true, pluginId: 'uninstall-fixture@uninstall-fixture-marketplace', version: '1.2.3' }] + : []); + } + if (verb === 'plugin marketplace list --json') return JSON.stringify({ marketplaces: [] }); + if (verb.startsWith('plugin add ')) installed = true; + if (verb.startsWith('plugin remove ')) installed = false; + return ''; + }); + const options = { + commandRunner: runner, + environment: { CODEX_HOME: hostRoot }, + from: fixture.bundleRoot, + home: fixture.home, + host: 'codex' as const, + }; + try { + await installBundle(options); + const installPath = join(hostRoot, 'plugins', 'cache', 'uninstall-fixture-marketplace', 'uninstall-fixture', '1.2.3'); + await cp(fixture.bundleRoot, installPath, { recursive: true }); + expect((await uninstallBundle({ ...options, plan: true })).data).toMatchObject({ outcome: 'unavailable', policy: 'keep' }); + expect((await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true })).data).toMatchObject({ + outcome: 'removed-by-host', + policy: 'purge', + }); + const scoped = await failureOf(uninstallBundle({ ...options, scope: 'project' })); + expect(scoped.diagnostics[0]).toMatchObject({ code: 'AB7003', target: 'codex' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('rejects an uninstall mode for hosts other than Cursor before touching anything', async () => { + const fixture = await createFixture('claude'); + const { calls, runner } = recordingRunner(() => ''); + try { + const error = await failureOf(uninstallBundle({ + commandRunner: runner, + environment: { CLAUDE_CONFIG_DIR: join(fixture.cleanupRoot, 'claude-root') }, + from: fixture.bundleRoot, + home: fixture.home, + host: 'claude', + mode: 'marketplace', + })); + expect(error.diagnostics).toMatchObject([{ code: 'AB7003', target: 'claude' }]); + expect(calls).toEqual([]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('exposes uninstall through the public CLI with every lifecycle flag', async () => { + const stderr: string[] = []; + const stdout: string[] = []; + const calls: unknown[] = []; + Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); + const result: UninstallResult = { + bundleRoot: '/tmp/example bundle', + data: { detail: 'kept', outcome: 'kept', paths: ['/tmp/example bundle/state'], policy: 'keep' }, + destination: '/home/example/.cursor/plugins/local/fixture', + forced: false, + host: 'cursor', + mode: 'local', + plugin: 'fixture', + receipt: { path: '/home/example/.cursor/plugins/local/fixture/.agent-bundle-install.json', status: 'consumed' }, + registrations: [{ action: 'planned', kind: 'cursor-local-plugin' }], + removed: { directories: ['/home/example/.cursor/plugins/local/fixture'], files: ['/home/example/.cursor/plugins/local/fixture/payload.txt'] }, + retained: [], + scope: 'user', + state: 'planned', + version: '1.0.0', + }; + const code = await runCli( + ['uninstall', 'cursor', '--from', '/tmp/example bundle', '--mode', 'local', '--plan', '--force', '--purge-data', '--confirm-purge', '--json'], + { + stderr: { write: (chunk: string) => stderr.push(chunk) }, + stdout: { write: (chunk: string) => stdout.push(chunk) }, + }, + { + uninstallBundle: async (options: unknown) => { + calls.push(options); + return result; + }, + } as unknown as Parameters[2], + ); + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(calls).toEqual([{ + confirmPurge: true, + force: true, + from: '/tmp/example bundle', + host: 'cursor', + mode: 'local', + plan: true, + purgeData: true, + scope: 'user', + }]); + expect(JSON.parse(stdout.join(''))).toMatchObject({ plugin: 'fixture', state: 'planned' }); + + const human: string[] = []; + await runCli( + ['uninstall', 'cursor', '--keep-data'], + { stderr: { write: () => undefined }, stdout: { write: (chunk: string) => human.push(chunk) } }, + { uninstallBundle: async () => result } as unknown as Parameters[2], + ); + expect(human.join('')).toContain('Would uninstall fixture@1.0.0 for cursor (local mode)'); + expect(human.join('')).toContain('/home/example/.cursor/plugins/local/fixture/payload.txt'); + expect(human.join('')).toContain('Data (keep): kept'); +}); diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index d4b2a896e..eb2ccc05c 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -98,6 +98,34 @@ a pre-receipt copy. Claude replacement runs `claude plugin uninstall --keep-data reinstalling because `plugin update` is version-gated; Codex runs `codex plugin remove` before `add`. The emitted `INSTALL.md` documents the same recipe per host. +## Uninstalling by receipt + +```sh +npx agent-bundle uninstall cursor --from artifact/cursor --plan # exact paths, nothing changes +npx agent-bundle uninstall cursor --from artifact/cursor # receipt-owned files; state/ kept +npx agent-bundle uninstall claude --from artifact/claude # claude plugin uninstall --keep-data + marketplace remove +npx agent-bundle uninstall codex --from artifact/codex --purge-data --confirm-purge +node artifact/cursor/install.mjs --uninstall [--mode marketplace] +``` + +Every install writes a lifecycle receipt (`agent-bundle-install-receipt/2`): version, content +hash, delivery mode, scope, the owned files and directories, the host directories the installer +created, the host registrations it performed, and timestamps. Cursor local copies carry it as +`.agent-bundle-install.json`; Claude, Codex, and Cursor marketplace-mode installs keep theirs under +`/agent-bundle/receipts/`. `uninstall` removes exactly what the receipt owns and +reverses exactly the registrations it recorded — never anything else; unowned entries are listed +as retained. Durable runtime state (`state/`, and for a Cursor copy of an Agent Plugins pack the +`PLUGIN_DATA` directory the receipt records) is kept unless you pass `--purge-data --confirm-purge`, and the result states honestly what the host itself decided where +Agent Bundle cannot (Claude orphans its cached copy for a ~14-day grace period; Codex deletes the +cached tree and offers no keep-data option). A missing receipt (`AB7009`) or a content mismatch +(`AB7007`) is refused unless `--force`; a directory that belongs to another plugin is refused +regardless; a second run is a `not-installed` no-op. Receipts written before format 2 are read with +their lifecycle fields synthesized and diagnosed (`AB7329`), never rejected. + +The host-install proofs snapshot an isolated home before install and after uninstall: byte-identical +for Cursor local and the portable Agent Plugins package, and zero Agent Bundle residue plus an +enumerated set of host-owned bookkeeping for Claude and Codex. + ## Inspect an install without changing it ```sh @@ -110,11 +138,13 @@ supplied bundle, checks registration proof, samples runtime endpoint health and inventories durable state, and re-runs the pinned process-free document and loader validators over installed bytes. It never repairs anything. With `--from`, it reports the installed copy per host as `current`, `stale` (`AB7308`), `version-mismatch` (`AB7309`), `foreign` (`AB7321`), `load-failed` -(`AB7325`), or `not-installed` (`AB7307`). `load-failed` means Claude Code lists the copy but refused to -load it — its `claude plugin list --json` row carries an `errors` array, which Doctor reports verbatim in -place of `current`, because a refused copy contributes no hooks, MCP servers, or skills to a session. The -installer reads the same array: `agent-bundle install claude` fails with `AB7006` when the freshly -installed (or byte-identical existing) copy carries `errors`, instead of reporting `installed`. +(`AB7325`), or `not-installed` (`AB7307`), and its lifecycle stage — placed → registered → enabled → active — with +every stage either observed from the host or typed `unavailable` with the reason (`AB7330`). `load-failed` means +Claude Code lists the copy but refused to load it — its `claude plugin list --json` row carries an `errors` array, +which Doctor reports verbatim in place of `current`, because a refused copy contributes no hooks, MCP servers, or +skills to a session. The installer reads the same array: `agent-bundle install claude` fails with `AB7006` when the +freshly installed (or byte-identical existing) copy carries `errors`, instead of reporting `installed`. Doctor also +inventories the receipt store under each host root and cross-checks it against the host (`AB7328`, `AB7329`). For Claude, Doctor also reads each row's `enabled` flag and runs the Claude Code developer validator. A copy listed with `enabled: false` (switched off with `claude plugin disable` or the @@ -129,6 +159,9 @@ registration proof and the rows' `errors` already hold that verdict. | Code | Severity | Meaning | | --- | --- | --- | +| `AB7330` | info | The bundle's lifecycle stage on this host and its four observations; unobservable stages (a live session's loaded plugins on every host, Cursor's server-assigned enabled state) are typed `unavailable`, never guessed. | +| `AB7328` | warning | A store receipt records a registration the host no longer holds (orphaned), or the receipt store cannot be read; `agent-bundle uninstall` consumes an orphaned receipt. | +| `AB7329` | info | A receipt predates lifecycle receipts and was read with synthesized fields; rerun `install` once to rewrite it as format 2. | | `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Doctor inventories state by directory entry and metadata only; it never opens a database. | | `AB7317` | info | A live event runtime implements the older strict protocol and does not expose runtime identity. | | `AB7318` | error | A live event runtime became unavailable, timed out, or returned an invalid status response during the bounded read-only identity probe. | diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 066d4a8a1..e17add4e6 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -18,6 +18,7 @@ npx agent-bundle --version | `agent-bundle build` | Build a validated Agent Bundle artifact. | | `agent-bundle prepack` | Build and validate the npm pack inventory. | | `agent-bundle install ` | Install a built bundle into a supported host. | +| `agent-bundle uninstall ` | Remove a receipt-owned host install of a built bundle, and nothing else. | | `agent-bundle doctor` | Inspect host installs and runtime endpoints without changing them. | | `agent-bundle validate` | Validate project source or one artifact. | | `agent-bundle eval` | Run deterministic or native eval suites against a built artifact. | @@ -105,7 +106,43 @@ The emitted standalone `install.mjs` and the package-relative installer bin acce `--replace`. Cursor copies carry an install receipt (`.agent-bundle-install.json`), replacement touches owned files only, and `--replace` adopts a pre-receipt copy; Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling and Codex runs `codex plugin remove` -before `add`. +before `add`. Every install writes a lifecycle receipt (format `agent-bundle-install-receipt/2`: +version, content hash, mode, scope, owned paths, host registrations, timestamps) — in-tree for +Cursor local copies, under `/agent-bundle/receipts/` for Claude, Codex, and Cursor +marketplace mode — that `uninstall` and `doctor` consume. + +## uninstall + +```sh +agent-bundle uninstall [--from ] [--scope ] [--mode ] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json] +``` + +| Option | Default | Meaning | +| --- | --- | --- | +| `` | **required** | `claude`, `codex`, or `cursor`. | +| `--from ` | `process.cwd()` | Bundle directory or artifact root that identifies the plugin (name, version, marketplace). | +| `--scope ` | `user` | The scope the plugin was installed at (Claude). | +| `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | +| `--keep-data` | on | Keep durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the recorded `PLUGIN_DATA` directory). This is the default; the flag makes it explicit. | +| `--purge-data` | off | Also remove durable runtime state. Refused (`AB7008`) without `--confirm-purge`. | +| `--force` | off | Proceed without a receipt (legacy Cursor copy, host-only install) or when owned content, version, or staged `HEAD` no longer matches the receipt. A receipt or manifest naming another plugin is refused regardless. | +| `--plan` | off | Print the exact paths and host registrations that would be removed and change nothing. | + +Uninstall removes exactly what the receipt owns: the recorded files and installer-created +directories (Cursor local, including the `~/.cursor/plugins[/local]` directories the install +created), the staged repository (Cursor marketplace, after its `HEAD` matches the recorded +commit), or the host registrations (`claude plugin uninstall --scope --keep-data` + +`claude plugin marketplace remove`, `codex plugin remove` + `codex plugin marketplace remove` — +the marketplace is retained while another installed plugin still uses it, including a Claude +install at another scope or in another project known only to Claude's `plugins/installed_plugins.json` +registry). Unowned entries are retained and listed; a missing receipt is `AB7009` and a mismatch is `AB7007` unless `--force`; a +second run is a `not-installed` no-op. The typed `data.outcome` says what happened to durable +state per host: Cursor `kept` / `purged` / `absent`; Claude `retained-by-host` (the cached copy is +orphaned for Claude's ~14-day grace period; a purge also removes `state/` and +`plugins/data//`); Codex `removed-by-host` / `unavailable` (`codex plugin remove` deletes the +cached tree and has no keep-data option). The package-relative installer bin accepts +`uninstall ` with the same flags; the emitted `install.mjs` accepts `--uninstall` with +`--mode`, `--keep-data`, `--purge-data --confirm-purge`, `--force`, and `--plan`. ## doctor @@ -129,7 +166,14 @@ Cursor it also proves plugin-scoped hook registration from the installed manifes when `~/.cursor/hooks.json` would deliver a plugin hook a second time (`AB7323`), and tracks a staged marketplace from `unregistered` to `registered` once Cursor imports it (`AB7324`). For an Agent Plugins install it proves the emitted installer's placeholder expansion — `expanded`, -`unexpanded` (spec forms Cursor 3.18.25 cannot launch), or `drifted` (`AB7326`). +`unexpanded` (spec forms Cursor 3.18.25 cannot launch), or `drifted` (`AB7326`). With +`--from` it also reports the lifecycle stage per host — placed → registered → enabled → active, +each observed (Claude and Codex `plugin list --json` rows and `enabled` flags, the Cursor local +directory, the Cursor marketplace import cache) or typed `unavailable` with the reason no read-only +surface exposes it (`AB7330`). It inventories the Agent Bundle receipt store under each host root +and warns about receipts the host no longer honours (`AB7328`), and reports receipts written +before format 2 as migrated (`AB7329`). A Cursor directory holding only preserved runtime state +from `uninstall --keep-data` is reported `missing` with an `AB7307` info, not corrupt or foreign. ## validate diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 8085acf3e..be958ef74 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -83,6 +83,28 @@ node ./install.mjs 因为 `plugin update` 受版本门控;Codex 先 `codex plugin remove` 再 `add`。输出的 `INSTALL.md` 按宿主记录了 同样的步骤。 +## 按回执卸载 + +```sh +npx agent-bundle uninstall cursor --from artifact/cursor --plan # 确切路径,不做改动 +npx agent-bundle uninstall cursor --from artifact/cursor # 回执归属的文件;保留 state/ +npx agent-bundle uninstall claude --from artifact/claude # claude plugin uninstall --keep-data + marketplace remove +npx agent-bundle uninstall codex --from artifact/codex --purge-data --confirm-purge +node artifact/cursor/install.mjs --uninstall [--mode marketplace] +``` + +每次安装都会写入生命周期回执(`agent-bundle-install-receipt/2`):版本、内容哈希、投递模式、作用域、归属的文件与 +目录、安装器创建的宿主目录、执行过的宿主注册,以及时间戳。Cursor 本地副本以 `.agent-bundle-install.json` 携带它; +Claude、Codex 与 Cursor 市场模式的安装把回执放在 `<宿主根目录>/agent-bundle/receipts/` 下。`uninstall` 只删除回执 +归属的内容,只撤销它记录的注册——绝不多删;非归属条目会被列为保留。持久运行时状态(`state/`,以及 Agent Plugins +包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录)除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 +副本标为 orphaned 并保留约 14 天;Codex 删除缓存树且没有 keep-data 选项)。缺少回执(`AB7009`)或内容不匹配 +(`AB7007`)会被拒绝,除非 `--force`;属于另一个插件的目录无论如何都被拒绝;再次运行是 `not-installed` 空操作。 +格式 2 之前写入的回执会在补全生命周期字段后读取并给出诊断(`AB7329`),绝不被拒绝。 + +宿主安装证明会在安装前与卸载后对隔离的 home 做快照:Cursor 本地与可移植 Agent Plugins 包做到字节一致;Claude 与 +Codex 做到零 Agent Bundle 残留,并逐项列出宿主自有的记录文件。 + ## 在不改动的前提下检查安装 ```sh @@ -93,10 +115,12 @@ npx agent-bundle doctor --from artifact --json Doctor 是只读的。它探测宿主、清点已安装的捆绑包、把它们与提供的捆绑包做比对、检查注册证明、采样运行时 端点的健康状况与身份、清点持久状态,并对已安装的字节重新运行被固定的、无进程的文档与加载器校验器。它 绝不修复任何东西。带 `--from` 时,它按宿主把已安装副本报告为 `current`、`stale`(`AB7308`)、`version-mismatch` -(`AB7309`)、`foreign`(`AB7321`)、`load-failed`(`AB7325`)或 `not-installed`(`AB7307`)。`load-failed` 表示 +(`AB7309`)、`foreign`(`AB7321`)、`load-failed`(`AB7325`)或 `not-installed`(`AB7307`),以及它的生命周期阶段——placed → +registered → enabled → active——每一阶段要么从宿主观察得到,要么被类型化为 `unavailable` 并给出原因(`AB7330`)。`load-failed` 表示 Claude Code 列出了该副本却拒绝加载它——它在 `claude plugin list --json` 中的行带有 `errors` 数组,Doctor 会原样报告 该数组而不是 `current`,因为被拒绝的副本不会为会话提供任何 hook、MCP 服务器或技能。安装器读取同一数组:当刚安装的 (或字节完全相同的既有)副本带有 `errors` 时,`agent-bundle install claude` 以 `AB7006` 失败,而不是报告 `installed`。 +Doctor 还会清点每个宿主根目录下的回执仓库并与宿主交叉核对(`AB7328`、`AB7329`)。 对 Claude,Doctor 还会读取每一行的 `enabled` 标志并运行 Claude Code 开发者校验器。被列为 `enabled: false` 的副本(用 `claude plugin disable` 或 `/plugin` 菜单关闭)在清点中报告为 `disabled`,在 `--from` 比对中保留其内容 @@ -108,6 +132,9 @@ Claude Code 列出了该副本却拒绝加载它——它在 `claude plugin list | 代码 | 严重级别 | 含义 | | --- | --- | --- | +| `AB7330` | info | 该捆绑包在此宿主上的生命周期阶段及四项观察;无法观察的阶段(任一宿主的活跃会话加载了哪些插件、Cursor 由服务端分配的启用状态)被类型化为 `unavailable`,绝不猜测。 | +| `AB7328` | warning | 某份仓库回执记录的注册宿主已不再持有(孤立),或回执仓库无法读取;`agent-bundle uninstall` 会消费孤立的回执。 | +| `AB7329` | info | 某份回执早于生命周期回执,读取时补全了字段;再运行一次 `install` 即可将其重写为格式 2。 | | `AB7316` | warning | 某个已安装捆绑包的 `state/` 目录,或它的某个 `*.sqlite`、`-wal`、`-shm` 文件无法通过文件系统元数据操作读取。Doctor 只按目录条目与元数据清点状态;它绝不打开数据库。 | | `AB7317` | info | 某个活跃的事件运行时实现的是较旧的严格协议,不暴露运行时身份。 | | `AB7318` | error | 在有界的只读身份探测过程中,某个活跃的事件运行时变为不可用、超时,或返回了无效的状态响应。 | diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 71b2d2b97..9ced8c517 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -18,6 +18,7 @@ npx agent-bundle --version | `agent-bundle build` | 构建一份已校验的 Agent Bundle 产物。 | | `agent-bundle prepack` | 构建并校验 npm pack 清单。 | | `agent-bundle install ` | 把已构建的捆绑包安装进受支持的宿主。 | +| `agent-bundle uninstall ` | 移除一份由回执归属的已构建捆绑包宿主安装,且不多删任何东西。 | | `agent-bundle doctor` | 在不做任何改动的前提下检查宿主安装与运行时端点。 | | `agent-bundle validate` | 校验项目源码或某一份产物。 | | `agent-bundle eval` | 针对已构建产物运行确定性或原生 eval 套件。 | @@ -104,6 +105,38 @@ agent-bundle install [--from ] [--scope ] [--mode /agent-bundle/receipts/` 下——`uninstall` 与 `doctor` 都消费它。 + +## uninstall + +```sh +agent-bundle uninstall [--from ] [--scope ] [--mode ] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json] +``` + +| 选项 | 默认值 | 含义 | +| --- | --- | --- | +| `` | **必填** | `claude`、`codex` 或 `cursor`。 | +| `--from ` | `process.cwd()` | 用于识别插件(名称、版本、市场)的捆绑包目录或产物根目录。 | +| `--scope ` | `user` | 安装时使用的作用域(Claude)。 | +| `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | +| `--keep-data` | 开启 | 保留持久运行时状态(`state/`:状态内核、通知日志;对 Agent Plugins 包的 Cursor 副本,还包括回执记录的 `PLUGIN_DATA` 目录)。这是默认行为;该标志只是显式声明。 | +| `--purge-data` | 关闭 | 同时删除持久运行时状态。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | +| `--force` | 关闭 | 在没有回执(旧版 Cursor 副本、仅宿主侧的安装)或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。回执或清单指向另一个插件时无论如何都会被拒绝。 | +| `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | + +uninstall 只删除回执归属的内容:记录的文件与安装器创建的目录(Cursor 本地,包括安装本身创建的 +`~/.cursor/plugins[/local]` 目录)、暂存仓库(Cursor 市场模式,在其 `HEAD` 与记录的提交匹配之后),或宿主注册 +(`claude plugin uninstall --scope --keep-data` + `claude plugin marketplace remove`, +`codex plugin remove` + `codex plugin marketplace remove`——若另一个已安装插件仍在使用该市场,则保留市场, +包括仅记录在 Claude 的 `plugins/installed_plugins.json` 注册表中、位于另一作用域或另一项目的 Claude 安装)。 +非归属条目被保留并列出;缺少回执为 `AB7009`,不匹配为 `AB7007`,除非 `--force`;再次运行是 `not-installed` +空操作。类型化的 `data.outcome` 按宿主如实说明持久状态的去向:Cursor 为 `kept` / `purged` / `absent`; +Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 还会删除 `state/` 与 +`plugins/data//`);Codex 为 `removed-by-host` / `unavailable`(`codex plugin remove` 删除缓存树且没有 +keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 +`--uninstall`,并支持 `--mode`、`--keep-data`、`--purge-data --confirm-purge`、`--force` 与 `--plan`。 ## doctor @@ -123,7 +156,12 @@ agent-bundle install [--from ] [--scope ] [--mode