Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/101-managed-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Add `agent-bundle uninstall <host> [--from <bundle-dir>] [--scope <scope>] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]`, the package-relative installer bin's `uninstall <host>`, 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 `<host root>/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)
197 changes: 194 additions & 3 deletions docs/diagnostics.md

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,45 @@ comparison. It never invokes sudo or changes PATH. `agent-bundle install <host>
"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 `<host root>/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
Expand Down
67 changes: 62 additions & 5 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` | 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 <host>` | 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. |
Expand Down Expand Up @@ -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 `<plugin-name>-install` otherwise. Map that name to the generated
`dist/bin/*.js` file in `package.json`; consumers run
`<bin> install <host> [--scope <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.
`<bin> install <host> [--scope <scope>] [--replace|--force] [--json]` and
`<bin> uninstall <host> [--scope <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 `<host root>/agent-bundle/receipts/`
(`~/.claude`, `~/.codex`, `~/.cursor`, honouring `CLAUDE_CONFIG_DIR` and
`CODEX_HOME`). `agent-bundle uninstall <host> --from <bundle-dir>`, the package
bin's `uninstall <host>`, 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 <id> --scope <scope> --keep-data` /
`codex plugin remove <id>`, then `plugin marketplace remove <marketplace>`
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/<id>/`); 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.
Comment thread
ScriptedAlchemy marked this conversation as resolved.

`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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,40 @@
"source": "https://code.claude.com/docs/en/discover-plugins",
"state": "supported"
},
"lifecycle": {
"uninstall": {
Comment on lines +15 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advance every changed adapter revision

This adds a lifecycle contract to the Claude capability table, but Claude's adapterRevision remains 1.26.0; Cursor and portable likewise keep their parent revisions even though their capability tables and generated install surfaces change, while only Codex advances. Since repository-owned capability tables are intentionally represented by the monotonic adapter revision rather than hashed (packages/agent-bundle/README.md:1003-1006), consumers cannot distinguish artifacts using these new contracts from the preceding adapter versions. Bump and repin the Claude, Cursor, and portable revisions as well.

Useful? React with 👍 / 👎.

"commands": [
"claude plugin uninstall <plugin>@<marketplace> --scope <scope> --keep-data",
"claude plugin marketplace remove <marketplace>"
],
"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 <id> --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.",
Expand Down
34 changes: 34 additions & 0 deletions packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,40 @@
"source": "https://developers.openai.com/codex/cli/reference",
"state": "supported"
},
"lifecycle": {
"uninstall": {
"commands": [
"codex plugin remove <plugin>@<marketplace>",
"codex plugin marketplace remove <marketplace>"
],
"evidence": [
"2026-09-03: live codex-cli 0.147.0 in an isolated CODEX_HOME: `codex plugin remove <id> --json` printed pluginId/name/marketplaceName and deleted the cached tree under plugins/cache; `codex plugin marketplace remove <name> --json` printed marketplaceName and installedRoot null; config.toml was left empty (0 bytes) with empty `.tmp/marketplaces` and `plugins/cache/<marketplace>` 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"],
Expand Down
Loading
Loading