Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0907d95
feat(install): same-version replace path for every emitted installer …
Sep 3, 2026
5a9ee1a
fix(install): harden replace policy per review (#400)
ScriptedAlchemy Sep 3, 2026
3f17e37
fix(install): guard symlinked ancestors and case aliases; patch chang…
ScriptedAlchemy Sep 3, 2026
27e1a0d
fix(install): receipt inventory, owned restructures, multi-scope doct…
ScriptedAlchemy Sep 3, 2026
2e43011
fix(install): empty directories are collisions; validate Claude rows …
ScriptedAlchemy Sep 3, 2026
38a0311
fix(install): legacy adoption preserves unknown files; empty descenda…
ScriptedAlchemy Sep 3, 2026
21c8d28
fix(install): case-alias identity only, alias-aware ancestors, exclus…
ScriptedAlchemy Sep 3, 2026
3ef368e
fix(install): prove case aliases via realpath; refuse symlinked recei…
ScriptedAlchemy Sep 3, 2026
5118dcc
fix(install): public-host version collisions fail closed; foreign bea…
ScriptedAlchemy Sep 3, 2026
726cf9b
fix(install): uncomparable host copies fail closed; alias-aware legac…
ScriptedAlchemy Sep 3, 2026
68cadbe
fix(install): require inventory versions; report public-host destinat…
ScriptedAlchemy Sep 3, 2026
b9afba8
fix(install): hash executable bits; reject Windows-normalised receipt…
ScriptedAlchemy Sep 3, 2026
cef7611
fix(install): runtime state roots are never inventoried, installed, o…
ScriptedAlchemy Sep 3, 2026
58007fb
fix(install,doctor): filter runtime roots during staging; real Claude…
ScriptedAlchemy Sep 3, 2026
650830e
fix(install): receipts never own runtime roots; receipt must be a reg…
ScriptedAlchemy Sep 3, 2026
a073032
fix(install): preserved roots match case-insensitively; tests for FIF…
ScriptedAlchemy Sep 3, 2026
9bf1270
fix(install): receipts own the directories the installer created; onl…
ScriptedAlchemy Sep 3, 2026
92736f7
fix(install): empty artifact directories are not content: never stage…
ScriptedAlchemy Sep 3, 2026
fa611bb
fix(install): refuse artifact paths that cannot round-trip through a …
ScriptedAlchemy Sep 3, 2026
ec29c97
fix(install): the receipt filename is reserved in every spelling (#400)
ScriptedAlchemy Sep 3, 2026
a56791e
fix(install): reserve the receipt name as a top-level segment, file o…
ScriptedAlchemy Sep 3, 2026
05a826b
fix(install): reject Windows device names in receipt and artifact pat…
ScriptedAlchemy Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/400-install-replace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Add `--replace` (alias `--force`) to `agent-bundle install <host>`, the package-relative installer bin, and the emitted standalone `install.mjs`, and replace a same-version stale copy automatically: an agent-bundle install of the same plugin whose version matches but whose content hash differs is replaced without a flag, identical reruns stay an `already-installed` no-op, and rebuilding without a version bump no longer needs a manual uninstall + `rm -rf`. Cursor copies now carry an install receipt (`.agent-bundle-install.json`: plugin, version, host, content hash, owned files); replacement is in place, touches owned files only, refuses to overwrite unowned entries such as `state/`, and `--replace` adopts a pre-receipt copy (`adopted`). Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling because `plugin update` is version-gated; Codex runs `codex plugin remove` before `add`; both fail `--replace` closed when `plugin list --json` is unusable. Foreign directories are still refused (`AB7005`) with an installed-versus-artifact content-hash comparison. `agent-bundle doctor --from` reports the installed copy per host as `current`, `stale` (`AB7308`), `version-mismatch` (`AB7309`), `foreign` (new `AB7321`), or `not-installed` (`AB7307`), and emitted `INSTALL.md` documents the same-version reinstall recipe per host (#420)
Comment thread
ScriptedAlchemy marked this conversation as resolved.
73 changes: 71 additions & 2 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ gate a build, a validation, or a dev rebuild.
| `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4906`) and commands `src/commands/*.md` (`AB4920`–`AB4926`); 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. |
| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; 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`–`AB7320` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, and static bytes-at-rest validation. |
| `AB7300`–`AB7321` | 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, and foreign-install detection (`AB7321`; see below). |
| `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. |
Expand Down Expand Up @@ -672,6 +672,75 @@ host CLI, repair a bundle, or perform a live protocol exchange.
| `AB7319` | error | A host tree resolved from `doctor --from` violates its pinned document schemas or process-free loader rules. The message retains the originating build-validator code and detail. | Rebuild that host bundle from valid source bytes, then rerun Doctor. |
| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules, when a root `plugin.json` install that declares an Agent Plugins `$schema` violates the pinned Agent Plugins 1.0.0 contract (`AB6035`–`AB6037`, retained in the message), or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info naming the contract applied to an Agent Plugins install, or stating that a `.claude-plugin/plugin.json` (or schema-less root `plugin.json`) install has no Cursor-side pinned static document contract; loader-recognized entries remain `installed`. | Reinstall an invalid Cursor plugin, rebuild an invalid portable bundle, or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. |

## Install replacement and Doctor install comparison (`AB7005`, `AB7307`–`AB7309`, `AB7321`)

`agent-bundle install <host>` and the emitted standalone `install.mjs` share one
replace policy, and `agent-bundle doctor --from <bundle-dir>` reports the same
verdict read-only. Every Cursor copy an agent-bundle installer places carries an
install receipt, `.agent-bundle-install.json`, beside the plugin manifest:

```json
{
"contentHash": "<sha256 over path\\0mode\\0bytes\\0 for every owned file; mode is x when executable>",
"directories": [".cursor-plugin", "..."],
"files": [".cursor-plugin/plugin.json", "INSTALL.md", "install.mjs", "..."],
"format": "agent-bundle-install-receipt/1",
"host": "cursor",
"installedAt": "2026-09-03T08:00:00.000Z",
"plugin": "<plugin name>",
"version": "<plugin version>"
}
```

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
same entries. Ownership of an existing
destination is decided as **receipt** (a receipt naming this plugin), **legacy**
(no receipt, but the emitted `INSTALL.md` + `install.mjs` and a manifest with
this plugin's name — a copy installed before receipts existed), or **foreign**
(anything else). Claude and Codex copies are located through the host's own
`plugin list --json` inventory (Doctor runs it once per host and also lists every
installed plugin from it; `AB7303` is emitted only when that listing is unusable);
the host owns those copies, so replacement runs `claude plugin uninstall
--keep-data` + `install` or `codex plugin remove` + `add`.

| Installed copy | `install` | `install --replace` (alias `--force`) | Doctor |
| --- | --- | --- | --- |
| Identical content (receipt / host-managed) | `already-installed` no-op | `already-installed` no-op | `current` |
| Identical content (legacy) | `already-installed` no-op | `adopted` — receipt written, no plugin file changes | `current` |
| Receipt / host-managed, same version, different content | replaced automatically (`replaced`) | replaced | `stale` — `AB7308` warning |
| Receipt / host-managed, different version | `AB7005` version collision | replaced | `version-mismatch` — `AB7309` warning |
| Legacy, different content | `AB7005` content collision | adopted: the artifact's files are rewritten, every other file is left in place and stays unowned, receipt written (`replaced`) | `stale` — `AB7308` warning, recovery names `--replace` |
| Foreign directory | `AB7005` foreign install | `AB7005` foreign install | `foreign` — `AB7321` warning |
| Nothing installed | installed | installed | `not-installed` — `AB7307` info |

Every `AB7005`, `AB7308`, `AB7309`, and `AB7321` message carries the comparison
`installed <name>@<version> content <hash> vs artifact <name>@<version> content
<hash> (same version, different content | different version | same content)`.
Cursor replacement is in place and touches owned files only: stale owned files
are removed and the emptied directories the installer itself created
(`directories` in the receipt) are pruned, staged files are renamed over their
predecessors, and the receipt lands last. Entries the installer does not own —
notably workspace-durable `state/` stores, and any directory that already
existed before the installer wrote beneath it — are never removed or rewritten;
when a rebuilt artifact introduces a path that an existing unowned entry already
occupies, replacement aborts before any change (`AB7004`, "Refusing to overwrite
unowned files") and names the colliding paths. Receipt file and directory lists
are validated as strict POSIX-relative paths (no backslashes, no
`..`/`.`/empty segments, no drive letters, nothing under a runtime root such as
`state/`) before they can drive a deletion; a receipt that fails validation
reads as absent, and a receipt that is not a regular file (a symbolic link, a
FIFO) is refused outright (`AB7004`) before it is read. The same rules apply to
the artifact itself: a file whose path could not round-trip through a receipt
(a backslash in a POSIX name, reserved characters, a trailing dot or space) is
refused (`AB7004`) before anything is staged.

| Code | Severity | Trigger | Recovery |
| --- | --- | --- | --- |
| `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. |

## Live development into hosts (`AB7200`–`AB7202`, `AB7210`–`AB7211`, `AB8024`–`AB8025`)

`agent-bundle dev` keeps a host's one stdio MCP process connected while it
Expand Down
14 changes: 11 additions & 3 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,14 @@ agent-bundle install cursor --from artifact/cursor

Cursor-compatible `cursor`, `portable`, and multi-host `plugin` targets also
include a standalone `install.mjs`. Its staged copy is idempotent for identical
content and refuses version or content collisions. It never invokes sudo or
changes PATH. Artifact validation rejects a built-in target whose required
install surface is missing.
content, records an install receipt (`.agent-bundle-install.json`: plugin,
version, content hash, owned files and directories), replaces a same-version stale copy of its
own plugin in place (owned files only; `state/` survives), and accepts
`--replace` (alias `--force`) to replace a different installed version or adopt
a pre-receipt copy. Foreign directories are refused with a content-hash
comparison. It never invokes sudo or changes PATH. `agent-bundle install <host>
[--replace]` applies the same policy for every host, and `agent-bundle doctor
--from` reports the installed copy versus the artifact as `current`, `stale`,
`version-mismatch`, `foreign`, or `not-installed` (see the package README's
"Reinstall after a same-version rebuild"). Artifact validation rejects a
built-in target whose required install surface is missing.
61 changes: 59 additions & 2 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,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`, and `--json` supported). |
| `agent-bundle install <host>` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, `--replace`/`--force`, 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 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 @@ -173,11 +174,67 @@ Cursor installation is user-scoped. Claude also accepts `--scope project` and
`--scope local`; Codex is user-scoped. A source-free artifact root is accepted
by `--from` when it contains the selected host target directory.

### Reinstall after a same-version rebuild

Rebuilding a plugin without bumping its `version` is the normal local loop, and
every host treats it differently. `agent-bundle install` and the emitted
`install.mjs` share one replace policy:

- **Receipt.** Every Cursor copy an agent-bundle installer places carries
`.agent-bundle-install.json` beside the plugin manifest: the plugin name,
version, host, the sha256 content hash of the artifact tree, when it was
installed, the exact list of files the installer owns, and the directories
it created (the only ones it will ever prune). The receipt never
participates in the content hash, so an installed copy hashes like the
artifact it came from.
- **No-op.** Re-running install on an identical artifact reports
`already-installed` and changes nothing, even with `--replace`.
- **Automatic same-version replace.** When the installed copy is an
agent-bundle install of the same plugin at the same version but its content
hash differs (a stale copy), install replaces it without a flag. Cursor
replacement is in place and touches owned files only: stale owned files are
removed, new files are renamed over their predecessors, and unowned entries
such as workspace-durable `state/` stores survive; if a rebuilt artifact
introduces a path an existing unowned file already occupies, replacement
aborts before any change and names it. Claude replacement runs
`claude plugin uninstall <plugin>@<marketplace> --scope <scope> --keep-data`
before `marketplace add` + `install`, because Claude's `plugin update` is
version-gated and a plain reinstall reports "already installed" while the
cache stays stale. Codex replacement runs `codex plugin remove` before
`marketplace add` + `add`, so files a rebuild removed do not linger.
- **`--replace` (alias `--force`).** Also replaces an agent-bundle install of
the same plugin at a *different* version, and adopts a Cursor copy that was
installed before receipts existed (recognised by its emitted `INSTALL.md` +
`install.mjs` and matching manifest name). A legacy copy has no owned-file
inventory, so adoption rewrites the files the new artifact ships and leaves
every other file in place (operator files, files an earlier rebuild dropped,
`state/`); those leftovers stay unowned under the new receipt, and later
same-version rebuilds replace automatically. A byte-identical legacy copy
under `--replace` reports `adopted` and changes no plugin file.
- **Foreign installs are always refused.** A directory under the plugin name
that is not an agent-bundle install of this plugin fails with `AB7005` and a
content-hash comparison (`installed <name>@<version> content <hash> vs
artifact <name>@<version> content <hash> (same version, different content)`),
even with `--replace`. Remove it manually.

For Claude and Codex the installed copy is located through the host's own
`plugin list --json` inventory (Claude reports the cache path; Codex confirms
the install and its pinned `~/.codex/plugins/cache/<marketplace>/<plugin>/<version>`
layout supplies the path). When that inventory is unusable, a plain install
proceeds as before and `--replace` fails closed rather than guessing.

`agent-bundle doctor --from <bundle-dir>` reports the same comparison per host
without changing anything: the installed version and content hash versus the
built artifact, summarised as `current`, `stale (same version, different
content)` (`AB7308`), `version mismatch` (`AB7309`), `foreign install`
(`AB7321`), `not installed` (`AB7307`), or `unknown` when the host inventory
could not be read.

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>] [--json]`. The executable locates the
`<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.
Expand Down
Loading
Loading