diff --git a/.changeset/host-install-distribution.md b/.changeset/host-install-distribution.md new file mode 100644 index 000000000..85efeb4f8 --- /dev/null +++ b/.changeset/host-install-distribution.md @@ -0,0 +1,8 @@ +--- +"agent-bundle": minor +--- + +Emit evidence-backed install instructions for every target, add safe Cursor +placement and public Claude/Codex CLI delegation through +`agent-bundle install`, and require install surfaces during artifact +validation. diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 2b7da0459..0c513aeb7 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -66,3 +66,26 @@ results and never renders JSX. Routed `src/cli/**` commands and `src/scripts/**` scripts follow one sentence: `.tsx` renders through the Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts` is plain. + +## Distribution + +`agent-bundle build` makes each target directory independently distributable. +Every target includes `INSTALL.md` generated with its real plugin and +marketplace names. Claude and Codex bundles include local marketplace manifests +and install through their public plugin CLIs; Cursor bundles use the documented +`~/.cursor/plugins/local/` location because Cursor exposes marketplace +management but no non-interactive plugin install verb. + +The framework CLI performs those same operations: + +```sh +agent-bundle install claude --from artifact/claude --scope user +agent-bundle install codex --from artifact/codex +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. diff --git a/docs/superpowers/plans/2026-09-01-host-install-distribution.md b/docs/superpowers/plans/2026-09-01-host-install-distribution.md new file mode 100644 index 000000000..3f84b96f5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-host-install-distribution.md @@ -0,0 +1,103 @@ +# Host Install and Distribution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Emit and validate exact per-host install surfaces and add a safe `agent-bundle install` command. + +**Architecture:** Pinned target capability tables own host install facts. A +focused install module renders artifact files and implements Effect-native +host delegation/direct placement behind the existing Promise CLI boundary. +Adapters and artifact validation consume the same immutable contracts. + +**Tech Stack:** TypeScript 7, Node.js 22, Effect 4 RC, Commander, Rstest. + +## Global Constraints + +- Claude and Codex installation delegates to their public CLIs without a shell. +- Cursor placement never uses sudo, changes PATH, or overwrites a collision. +- Cursor copy is staged, atomic, symlink-free, and idempotent. +- Every built-in target emits `INSTALL.md`; only Cursor-compatible fallback profiles require `install.mjs`. +- New orchestration is Effect-native and crosses through `src/effect/boundary.ts`. + +--- + +### Task 1: Pin host install contracts + +**Files:** +- Modify: `packages/agent-bundle/src/adapters/capabilities/*.json` +- Modify: `packages/agent-bundle/src/adapters/{claude,codex,cursor,portable,plugin}.ts` +- Test: `packages/agent-bundle/tests/adapter-capability-states.test.ts` + +**Interfaces:** +- Produces: adapter capability `install` with evidence or an unavailable reason. + +- [ ] Write assertions for the five target install states and exact public commands. +- [ ] Run the focused adapter test and verify it fails because `install` is absent. +- [ ] Add pinned `install` table rows, update capability hashes/revisions, and expose the states. +- [ ] Re-run the focused adapter test. + +### Task 2: Emit deterministic install surfaces + +**Files:** +- Create: `packages/agent-bundle/src/install/contracts.ts` +- Create: `packages/agent-bundle/src/install/surface.ts` +- Modify: `packages/agent-bundle/src/adapters/types.ts` +- Modify: `packages/agent-bundle/src/adapters/{claude,codex,cursor,portable,plugin}.ts` +- Test: `packages/agent-bundle/tests/install-surface.test.ts` + +**Interfaces:** +- Produces: `installSurfaceEntries(target, model, contract): readonly TargetArtifactEntry[]`. + +- [ ] Test exact `INSTALL.md`, real names, marketplace availability, and fallback script inclusion for all targets. +- [ ] Run the test and verify the install files are missing. +- [ ] Implement immutable contract snapshots and deterministic Markdown/script rendering. +- [ ] Always emit Claude/Codex local marketplaces and append install entries to each built-in plan. +- [ ] Re-run the install-surface test. + +### Task 3: Implement native host installation + +**Files:** +- Create: `packages/agent-bundle/src/install/install.ts` +- Modify: `packages/agent-bundle/src/api.ts` +- Modify: `packages/agent-bundle/src/cli.ts` +- Test: `packages/agent-bundle/tests/install.test.ts` +- Test: `packages/agent-bundle/tests/cli.test.ts` + +**Interfaces:** +- Produces: `installBundle(options): Promise`. +- Consumes: a direct bundle root or an artifact root containing a target root. + +- [ ] Test Claude/Codex argv delegation, unsupported scopes, missing binaries, direct/artifact roots, Cursor copy/idempotency, unsafe entries, and collisions. +- [ ] Run the focused tests and verify missing API/command failures. +- [ ] Implement Effect orchestration with injected command runner and filesystem/home dependencies. +- [ ] Add the lazy-loaded Commander command and human/JSON output. +- [ ] Re-run focused install and CLI tests. + +### Task 4: Enforce artifact install surfaces + +**Files:** +- Modify: `packages/agent-bundle/src/build/artifact-diagnostics.ts` +- Modify: `packages/agent-bundle/src/build/validate-artifact.ts` +- Test: `packages/agent-bundle/tests/artifact-validator.test.ts` + +**Interfaces:** +- Consumes: manifest target names and immutable install requirements. +- Produces: stable diagnostics for missing or invalid install files. + +- [ ] Test missing `INSTALL.md`, missing required fallback script, and valid non-fallback targets. +- [ ] Run the focused validator test and verify it accepts the broken fixtures. +- [ ] Validate required names and reject non-regular install surface entries through existing ownership checks. +- [ ] Re-run the focused validator test. + +### Task 5: Document, verify, and land + +**Files:** +- Modify: `packages/agent-bundle/README.md` +- Modify: `docs/framework-mode.md` +- Create: `.changeset/.md` + +- [ ] Document target distribution and `agent-bundle install`. +- [ ] Add a minor `agent-bundle` changeset. +- [ ] Run scoped tests, package build, typecheck, and lint. +- [ ] Rebase on the latest `origin/main`, resolve only additive conflicts, rerun verification, and commit. +- [ ] Push, open the PR, comment the design on issue #100, merge when checks are green, and report the merge SHA. diff --git a/docs/superpowers/specs/2026-09-01-host-install-distribution-design.md b/docs/superpowers/specs/2026-09-01-host-install-distribution-design.md new file mode 100644 index 000000000..7b274f5c6 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-host-install-distribution-design.md @@ -0,0 +1,80 @@ +# Host Install and Distribution Design + +## Status + +Approved by the explicit implementation requirements in the host-install story. + +## Goal + +Every emitted target bundle explains an exact, evidence-backed installation +path. Claude and Codex use their public marketplace and install commands. +Cursor, which has no non-interactive plugin install command, uses a +framework-owned, safe local-plugin copy. Portable and composite bundles explain +which real hosts can consume them. + +## Host contract + +Pinned capability tables record an `install` section beside each host's plugin +contract: + +- Claude: `claude plugin marketplace add .`, then + `claude plugin install @ --scope `. +- Codex: `codex plugin marketplace add .`, then + `codex plugin add @`. +- Cursor: no shell install verb; copy a complete plugin to + `~/.cursor/plugins/local/`, then reload Cursor. +- Portable: no runtime or universal install location. The emitted Agent Plugin + can be installed into a compatible host, including Cursor. +- Plugin: a multi-host distribution profile. Its document contains the exact + Claude, Codex, and Cursor procedures. + +The Claude and Codex target plans always emit their local marketplace +documents, because those documents are required for the public commands to work +against a built directory. + +## Emitted surface + +Every target root contains `INSTALL.md`. Commands use `.` and the real compiled +plugin and marketplace names, so a user runs them from that target root without +editing placeholders. + +Cursor-compatible target roots (`cursor`, `portable`, and `plugin`) also contain +`install.mjs`. The script: + +- resolves the user install root from `HOME`; +- copies through a sibling staging directory and atomically renames it; +- never invokes sudo or edits PATH; +- treats a byte-identical existing tree as an idempotent success; +- refuses an existing different version or different content; +- rejects symlinks and other unsupported filesystem entries in either tree; +- prints the installed or already-installed destination. + +Both files are part of the artifact manifest and provenance table. Artifact +validation requires `INSTALL.md` for all five built-in targets and +`install.mjs` only for Cursor-compatible fallback targets. + +## Built-in installer + +`agent-bundle install [--from ] [--scope ]` accepts +the real destination hosts `claude`, `codex`, and `cursor`. + +- Claude and Codex validate the bundle's marketplace and plugin identity, check + that the host executable exists, then execute the public CLI sequence without + a shell. +- Cursor validates a Cursor Plugin manifest and performs the same safe copy as + `install.mjs`; portable bundles use their emitted installer directly. +- `--from` accepts either a direct target root or an artifact root containing a + matching target directory. +- Claude accepts `user`, `project`, and `local`; Codex and Cursor reject scopes + their public contracts do not support. + +Missing binaries, unsupported hosts/scopes, malformed bundles, unsafe trees, +and destination collisions fail as typed `DiagnosticError` diagnostics. Tests +inject a command runner and temporary home, so no real host binary is required. + +## Verification + +Unit tests cover exact generated documents and scripts for all targets, public +CLI argument delegation, missing-host diagnostics, Cursor copy/idempotency and +collision behavior, and artifact validation when an install surface is absent. +The landing bar is scoped tests, package build/typecheck, and lint. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 6f47af39f..2a5cdbe6c 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -67,6 +67,7 @@ manifests at files inside those payloads without compiling them. Payload files c | Command | Purpose | | --- | --- | | `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. | +| `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, and `--json` supported). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect normalized targets and adapter plans from source. | | `agent-bundle inspect --bundler` | Dump the synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) for every generated output. | @@ -98,6 +99,35 @@ During development, load a built target without installing it and verify registr claude --plugin-dir dist/claude plugin list --json ``` +## Distribute and install bundles + +Every built target directory contains a generated `INSTALL.md` with commands +that use the bundle's real plugin and marketplace names. Claude and Codex +targets always include local marketplace manifests, so their public CLIs can +install the emitted directory directly: + +```sh +agent-bundle install claude --from artifact/claude --scope user +agent-bundle install codex --from artifact/codex +``` + +The installer delegates to `claude plugin marketplace add` / +`claude plugin install` and `codex plugin marketplace add` / +`codex plugin add`; it fails with a typed diagnostic when the selected host +binary is unavailable. Cursor has no non-interactive install verb, so Cursor, +portable, and composite targets include `install.mjs`, which safely copies the +bundle into `~/.cursor/plugins/local/` without overwriting collisions: + +```sh +agent-bundle install cursor --from artifact/cursor +# or, from the emitted target directory: +node ./install.mjs +``` + +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. + ## Developer workbench `agent-bundle dev` serves a loopback-only prebuilt workbench. It shows project 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 37cc31086..13688aa3a 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 @@ -1,5 +1,16 @@ { "host": "claude", + "install": { + "evidence": [ + "Local marketplaces accept a directory containing .claude-plugin/marketplace.json.", + "claude plugin install accepts plugin@marketplace and user, project, or local scope." + ], + "marketplaceAdd": "claude plugin marketplace add .", + "pluginInstall": "claude plugin install @ --scope ", + "scopes": ["user", "project", "local"], + "source": "https://code.claude.com/docs/en/discover-plugins", + "state": "supported" + }, "hooks": { "config": "hooks/hooks.json", "events": { 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 f70b12abd..54b98a82e 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 @@ -1,5 +1,16 @@ { "host": "codex", + "install": { + "evidence": [ + "codex plugin marketplace add accepts a local marketplace root.", + "codex plugin add installs plugin@marketplace from a configured snapshot." + ], + "marketplaceAdd": "codex plugin marketplace add .", + "pluginInstall": "codex plugin add @", + "scopes": ["user"], + "source": "https://developers.openai.com/codex/cli/reference", + "state": "supported" + }, "hooks": { "config": "hooks/hooks.json", "events": { 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 512ce47e6..d7a7c437f 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 @@ -1,5 +1,17 @@ { "host": "cursor", + "install": { + "cliInstall": false, + "evidence": [ + "Cursor CLI exposes plugin marketplace management but no non-interactive plugin install verb.", + "Cursor documents physical local plugin copies under ~/.cursor/plugins/local/." + ], + "localRoot": "~/.cursor/plugins/local/", + "method": "copy", + "scopes": ["user"], + "source": "https://cursor.com/docs/plugins", + "state": "supported" + }, "hooks": { "config": "hooks/hooks.json", "events": { 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 c29e940f9..6eaff3310 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 @@ -1,4 +1,9 @@ { + "install": { + "reason": "Portable is a distribution profile, not a host runtime with one universal plugin installation location.", + "source": "https://agent-plugins.org/", + "state": "unavailable" + }, "eventRoutes": { "agent/start": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, "agent/stop": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 1429258a5..2331e5569 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -56,6 +56,7 @@ import { type TargetArtifactLayout, type TargetArtifactPlan, } from './types.ts'; +import { withInstallSurface } from '../install/surface.ts'; /** * One Claude Code plugin LSP server. The binary is never vendored: Claude @@ -139,7 +140,7 @@ const hookContract = Object.freeze({ const metadata = Object.freeze({ adapterRevision: '1.4.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: '6b8a3b222b49c0ad22f32ecdf8157bd353ce5be05d56e40ae5cf4ad2b9eb917f', + capabilitySha256: '58141a999ac3d39d9b7aa2bc6bb945aae145773ea6f37806eecc93d3b5c7ed38', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -471,7 +472,7 @@ export const planClaudeArtifacts = ( }; diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors)); - const marketplace = model.marketplace !== true ? undefined : { + const marketplace = { description: model.metadata.description ?? model.metadata.name, name: `${model.metadata.name}-marketplace`, owner: { name: model.metadata.name }, @@ -482,10 +483,8 @@ export const planClaudeArtifacts = ( version: model.metadata.version, }], }; - const marketplaceValid = marketplace !== undefined && validateMarketplace(marketplace); - if (marketplace !== undefined) { - diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); - } + const marketplaceValid = validateMarketplace(marketplace); + diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); const basePlan = standardPluginArtifactPlan({ diagnostics, @@ -511,13 +510,13 @@ export const planClaudeArtifacts = ( pluginRelativePath: claudeArtifactPaths.plugin, targetName, }); - return Object.freeze({ + return withInstallSurface(Object.freeze({ ...basePlan, entries: sortedEntries([ ...basePlan.entries, ...commandWriteEntries(model, isSelected, claudeCommandMarkdown), ]), - }); + }), model, targetName === 'plugin' ? 'plugin' : 'claude'); }; const artifactLayout: TargetArtifactLayout = Object.freeze({ @@ -538,6 +537,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude Code plugin contract does not support commands.', ), + install: supportedCapability(evidence), marketplace: supportedCapability(evidence), hooks: supportedCapability(evidence), lsp: capabilityStateFromSupport( diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 7a9102093..fb4e2580d 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -50,6 +50,7 @@ import { type TargetArtifactDocumentValidator, type TargetArtifactPlan, } from './types.ts'; +import { withInstallSurface } from '../install/surface.ts'; export interface CodexConfigExtension { codex?: AgentBundleHostConfig; @@ -121,7 +122,7 @@ const hookContract = Object.freeze({ const metadata = Object.freeze({ adapterRevision: '1.2.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: '44e697be71a29db9ec029ed7d9eb8807b90e95d6a15f3a71a47148125c902194', + capabilitySha256: 'd944e508941a0660272a253601019957ae94e9140501f0624f85d111e66d9f28', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -370,7 +371,7 @@ export const planCodexArtifacts = ( const pluginValidator = pluginValidatorFor(mcpRelativePath); diagnostics.push(...schemaDiagnostics('plugin', pluginValidator(plugin), pluginValidator.errors)); - const marketplace = model.marketplace !== true ? undefined : { + const marketplace = { interface: { displayName: model.metadata.name }, name: `${model.metadata.name}-marketplace`, plugins: [{ @@ -380,12 +381,10 @@ export const planCodexArtifacts = ( source: { path: './', source: 'local' }, }], }; - const marketplaceValid = marketplace !== undefined && validateMarketplace(marketplace); - if (marketplace !== undefined) { - diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); - } + const marketplaceValid = validateMarketplace(marketplace); + diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); - return standardPluginArtifactPlan({ + return withInstallSurface(standardPluginArtifactPlan({ diagnostics, hookDocument, hookDocumentValid, @@ -403,7 +402,7 @@ export const planCodexArtifacts = ( ...(options.sharedCopyEntries === undefined ? {} : { sharedCopyEntries: options.sharedCopyEntries }), pluginRelativePath: codexArtifactPaths.plugin, targetName, - }); + }), model, targetName === 'plugin' ? 'plugin' : 'codex'); }; export const codexAdapter: TargetAdapter = Object.freeze({ @@ -414,6 +413,7 @@ export const codexAdapter: TargetAdapter = Object.freeze({ commands: unavailableCapability( 'The pinned Codex plugin contract (0.147.0) defines no commands component.', ), + install: supportedCapability(evidence), marketplace: supportedCapability(evidence), hooks: supportedCapability(evidence), // The pinned Codex plugin contract documents no LSP surface at all, so diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 72315e718..b475f054c 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -50,6 +50,7 @@ import { type TargetArtifactLayout, type TargetArtifactPlan, } from './types.ts'; +import { withInstallSurface } from '../install/surface.ts'; const cursorName = 'cursor'; @@ -263,7 +264,7 @@ export const cursorManifest = ( const metadata = Object.freeze({ adapterRevision: '1.4.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: '9884e0ffdb1c7fd1fe6d1071fa6944729d596e51f0ab87ad5ef2b0dd6fd8a981', + capabilitySha256: 'e755dbaa54e36001e8046152cb2a630b2ac6252e2a3fd8ba4bb559e61e6bcf0a', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -389,7 +390,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan pluginRelativePath: cursorArtifactPaths.plugin, targetName: cursorName, }); - return Object.freeze({ + return withInstallSurface(Object.freeze({ ...basePlan, entries: sortedEntries([ ...basePlan.entries, @@ -397,7 +398,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan command.markdown === command.body ? command.markdown : command.body), ...ruleWriteEntries(model, isSelected), ]), - }); + }), model, 'cursor'); }; export const cursorAdapter: TargetAdapter = Object.freeze({ @@ -411,6 +412,7 @@ export const cursorAdapter: TargetAdapter = Object.freeze({ 'The pinned Cursor Plugin contract does not support commands.', ), hooks: supportedCapability(evidence), + install: supportedCapability(evidence), marketplace: unavailableCapability('The pinned Cursor Plugin contract does not define a marketplace document.'), mcp: capabilityStateFromSupport( capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index f8631f936..1ebc8c613 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -8,7 +8,11 @@ import { standardMcpPathTokens, } from '../services/mcp-path-tokens.ts'; import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; -import { intersectCapabilityStates, supportedEventRouteNamesFrom } from './capability-state.ts'; +import { + intersectCapabilityStates, + supportedEventRouteNamesFrom, + unavailableCapability, +} from './capability-state.ts'; import claudeCapabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' }; import codexCapabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; import { claudeAdapter, claudeArtifactPaths, claudeHooksValidator, planClaudeArtifacts } from './claude.ts'; @@ -203,7 +207,7 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({ hookWrappers: standardArtifactLayout.hookWrappers, mcpApps: standardArtifactLayout.mcpApps, mcpEntries: standardArtifactLayout.mcpEntries, - rootDocuments: Object.freeze(['AGENTS.md']), + rootDocuments: Object.freeze(['AGENTS.md', ...(standardArtifactLayout.rootDocuments ?? [])]), rules: Object.freeze({ allowedSuffixes: Object.freeze(['.mdc']), directory: 'rules' }), scripts: standardArtifactLayout.scripts, skills: standardArtifactLayout.skills, @@ -233,9 +237,8 @@ const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions) '', '## Install', '', - '- **Claude Code**: add this directory (or its repository) as a plugin — `claude plugin marketplace add `.', - '- **Codex**: `codex plugin marketplace add `; the manifest is `.codex-plugin/plugin.json`.', - `- **Cursor**: copy this directory into \`~/.cursor/plugins/local/${model.metadata.name}\`; the manifest is \`.cursor-plugin/plugin.json\`. Symlinks that resolve outside \`~/.cursor/plugins/local\` are rejected by Cursor (staff confirmation: https://forum.cursor.com/t/local-plugins-symlink-on-windows-doesnt-work/159427/6).`, + 'See `INSTALL.md` for exact Claude Code, Codex, and Cursor commands using this bundle\'s compiled names.', + `Cursor can also be installed with \`node ./install.mjs\` into \`~/.cursor/plugins/local/${model.metadata.name}\`.`, '- **VS Code / GitHub Copilot**: install the repository as an agent plugin, or consume `skills/` directly.', '- **skills CLI**: `npx skills add --skill ` reads the `skills/` directory.', '', @@ -490,6 +493,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ capabilities: Object.freeze({ ...compositeEventCapabilities, commands: intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!), + install: unavailableCapability( + 'Plugin is a multi-host distribution profile, not one host runtime with a single installation transaction.', + ), marketplace: intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!), hooks: intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!), // Claude supports LSP and Codex has no LSP surface, so the intersection diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index bcc7aafed..d313387f5 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -36,6 +36,7 @@ import { type TargetArtifactEntry, type TargetArtifactPlan, } from './types.ts'; +import { withInstallSurface } from '../install/surface.ts'; export interface PortableConfigExtension { portable?: AgentBundlePortableConfig; @@ -55,9 +56,9 @@ const schemaValidator = createAdapterValidator(); const validatePlugin = schemaValidator.compile(pluginSchema); const validateMcp = schemaValidator.compile(mcpSchema); const metadata = Object.freeze({ - adapterRevision: '1.1.0', + adapterRevision: '1.2.0', capabilityRevision: capabilityTable.observedSpecificationVersion, - capabilitySha256: '99a27bd327f2afdb0a71fa869e9fad27438f07d843c5e61062d2dc3f978dce9a', + capabilitySha256: '60f63a2cf3c6783a178173c5006234a89ae186fe3542fe61339542cac117389e', observedVersion: capabilityTable.observedSpecificationVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version), }); @@ -310,11 +311,11 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { } } - return Object.freeze({ + return withInstallSurface(Object.freeze({ diagnostics: Object.freeze(diagnostics), entries: Object.freeze(entries), hookEntries: Object.freeze([]), - }); + }), model, 'portable'); }; export const portableAdapter: TargetAdapter = Object.freeze({ @@ -323,6 +324,7 @@ export const portableAdapter: TargetAdapter = Object.freeze({ assets: 'assets', mcpApps: Object.freeze({ allowedSuffixes: Object.freeze(['.html']), directory: 'mcp-apps' }), mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), + rootDocuments: Object.freeze(['INSTALL.md', 'install.mjs']), scripts: Object.freeze({ allowedSuffixes: Object.freeze(['.bash', '.mjs', '.py', '.sh']), directory: 'scripts' }), skills: 'skills', }), @@ -332,6 +334,7 @@ export const portableAdapter: TargetAdapter = Object.freeze({ 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no commands surface.', ), hooks: unavailableCapability('Agent Plugins 1.0.0 does not define a hooks component.'), + install: unavailableCapability(capabilityTable.install.reason), marketplace: unavailableCapability('Agent Plugins 1.0.0 does not define a marketplace document.'), mcp: capabilityStateFromSupport( capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 7717e3699..e3cc06d43 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -429,6 +429,7 @@ export const standardArtifactLayout: TargetArtifactLayout = Object.freeze({ hookWrappers: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'hooks' }), mcpApps: Object.freeze({ allowedSuffixes: Object.freeze(['.html']), directory: 'mcp-apps' }), mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), + rootDocuments: Object.freeze(['INSTALL.md', 'install.mjs']), scripts: Object.freeze({ allowedSuffixes: Object.freeze(['.bash', '.mjs', '.py', '.sh']), directory: 'scripts' }), skills: 'skills', }); diff --git a/packages/agent-bundle/src/build/artifact-diagnostics.ts b/packages/agent-bundle/src/build/artifact-diagnostics.ts index 12c07f33d..f096280c1 100644 --- a/packages/agent-bundle/src/build/artifact-diagnostics.ts +++ b/packages/agent-bundle/src/build/artifact-diagnostics.ts @@ -23,7 +23,9 @@ export type ArtifactDiagnosticCode = | 'AB6019' | 'AB6020' | 'AB6021' - | 'AB6022'; + | 'AB6022' + | 'AB6023' + | 'AB6024'; export const artifactDiagnosticRecoveries: Readonly> = Object.freeze({ AB6000: 'Restore a readable artifact root and canonical manifest, then rebuild the artifact.', @@ -49,6 +51,8 @@ export const artifactDiagnosticRecoveries: Readonly --strict`, repair the warning, and rebuild.', AB6021: 'Run `claude plugin validate --strict`, repair the error, and rebuild.', AB6022: 'Restore a bounded Claude validator process, then rerun artifact validation.', + AB6023: 'Rebuild the artifact so every built-in target includes its generated INSTALL.md.', + AB6024: 'Rebuild the Cursor-compatible artifact so it includes its generated install.mjs.', }); const isArtifactDiagnosticCode = (code: string): code is ArtifactDiagnosticCode => diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 53865205b..074a9242e 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -39,6 +39,7 @@ import { validateJavaScriptModules } from './validate-artifact-modules.ts'; import { validateHookCoherence } from './validate-artifact-hooks.ts'; import { validateMcpCoherence } from './validate-artifact-mcp.ts'; import { pathTarget, targetNamespaces, validateEmittedSkills } from './validate-artifact-skills.ts'; +import { installSurfaceRequirements } from '../install/surface.ts'; export { artifactDiagnosticRecoveries, type ArtifactDiagnosticCode } from './artifact-diagnostics.ts'; export type * from './artifact-validation-types.ts'; @@ -351,6 +352,17 @@ const validateTargetContracts = async (options: { continue; } + for (const relativePath of installSurfaceRequirements(target.name)) { + const generatedPath = `${target.name}/${relativePath}`; + if (files.has(generatedPath)) continue; + diagnostics.push(diagnostic( + relativePath === 'INSTALL.md' ? 'AB6023' : 'AB6024', + `Target ${JSON.stringify(target.name)} is missing required install surface ${JSON.stringify(relativePath)}.`, + generatedPath, + target.name, + )); + } + const validation = options.registry.artifactValidation(target.name); const validators = new Map(validation.schemas.map((schema) => [schema.name, schema.validate])); for (const document of validation.documents) { diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 330d9f172..3232ddd6c 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -18,6 +18,12 @@ import type { validate, ProjectOptions, } from './api.ts'; +import type { + installBundle, + InstallHost, + InstallResult, + InstallScope, +} from './install/install.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; @@ -40,6 +46,7 @@ interface CliSignalSource { } export interface CliDependencies { + readonly installBundle?: typeof installBundle; /** Injectable only to make foreground shutdown behavior deterministic in tests. */ readonly signals?: CliSignalSource; readonly startDevServer?: typeof startDevServer; @@ -61,6 +68,12 @@ interface BuildCommandOptions extends SourceCommandOptions { readonly output?: string; } +interface InstallCommandOptions { + readonly from: string; + readonly json?: boolean; + readonly scope: string; +} + interface EvalCommandOptions extends SourceCommandOptions { readonly artifact?: string; readonly case?: readonly string[]; @@ -118,6 +131,16 @@ const trialCount = (value: string): number => { return number; }; +const installHost = (value: string): InstallHost => { + if (value === 'claude' || value === 'codex' || value === 'cursor') return value; + throw new TypeError('Install host must be claude, codex, or cursor.'); +}; + +const installScope = (value: string): InstallScope => { + if (value === 'user' || value === 'project' || value === 'local') return value; + throw new TypeError('Install scope must be user, project, or local.'); +}; + const configureSourceOptions = (command: Command): Command => command .option('--root ', 'Project root', process.cwd()) .option('--config ', 'Configuration file relative to --root') @@ -207,6 +230,14 @@ const writeHumanBuild = (output: Output, result: Awaited { + const destination = result.destination ?? result.bundleRoot; + output.write( + `${result.state === 'already-installed' ? 'Already installed' : 'Installed'} ` + + `${result.plugin}@${result.version} for ${result.host} at ${destination}\n`, + ); +}; + const writeHumanInspect = (output: Output, result: Awaited>): void => { if (result.state === 'invalid') { for (const diagnostic of result.diagnostics) { @@ -367,6 +398,26 @@ export const runCli = async ( else writeHumanBuild(stdout, result); }); + const installCommand = program.command('install') + .description('Install a built bundle into a supported host') + .argument('', 'Destination host: claude, codex, or cursor', installHost) + .option('--from ', 'Target bundle directory or artifact root', process.cwd()) + .option('--scope ', 'Host install scope', installScope, 'user') + .option('--json', 'Write one machine-readable JSON document'); + installCommand.action(async ( + host: InstallHost, + options: InstallCommandOptions, + ) => { + const install = dependencies.installBundle ?? (await import('./install/install.ts')).installBundle; + const result = await install({ + from: options.from, + host, + scope: installScope(options.scope), + }); + if (options.json === true) writeMachine(stdout, result); + else writeHumanInstall(stdout, result); + }); + const validateCommand = configureSourceOptions( program.command('validate').description('Validate project source or one artifact'), ) diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts new file mode 100644 index 000000000..fd84ebb45 --- /dev/null +++ b/packages/agent-bundle/src/install/install.ts @@ -0,0 +1,398 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + cp, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, +} from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; + +import { Effect } from 'effect'; + +import { DiagnosticError } from '../core/diagnostics.ts'; +import { runPromise } from '../effect/boundary.ts'; +import { liftPromise } from '../effect/lift.ts'; + +export type InstallHost = 'claude' | 'codex' | 'cursor'; +export type InstallScope = 'local' | 'project' | 'user'; + +export interface InstallCommandResult { + readonly code: number; + readonly stderr: string; + readonly stdout: string; +} + +export interface InstallCommandRunner { + run( + command: string, + args: readonly string[], + options: { readonly cwd: string }, + ): Promise; +} + +export interface InstallBundleOptions { + readonly commandRunner?: InstallCommandRunner; + readonly from: string; + readonly home?: string; + readonly host: InstallHost; + readonly scope?: InstallScope; +} + +export interface InstallResult { + readonly bundleRoot: string; + readonly destination?: string; + readonly host: InstallHost; + readonly marketplace?: string; + readonly plugin: string; + readonly state: 'already-installed' | 'installed'; + readonly version: string; +} + +interface PluginIdentity { + readonly bundleRoot: string; + readonly marketplace?: string; + readonly plugin: string; + readonly version: string; +} + +const failure = ( + code: string, + message: string, + target: InstallHost, +): DiagnosticError => new DiagnosticError([{ + code, + message, + severity: 'error', + target, +}]); + +const isErrno = (error: unknown, code: string): boolean => + error instanceof Error && (error as NodeJS.ErrnoException).code === code; + +const exists = async (path: string): Promise => { + try { + await lstat(path); + return true; + } catch (error) { + if (isErrno(error, 'ENOENT')) return false; + throw error; + } +}; + +const hostManifestPath = (host: InstallHost): string => { + switch (host) { + case 'claude': + return '.claude-plugin/plugin.json'; + case 'codex': + return '.codex-plugin/plugin.json'; + case 'cursor': + return '.cursor-plugin/plugin.json'; + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown install host ${String(exhaustive)}.`); + } + } +}; + +const marketplacePath = (host: Exclude): string => + host === 'claude' + ? '.claude-plugin/marketplace.json' + : '.agents/plugins/marketplace.json'; + +const readRecord = async ( + path: string, + host: InstallHost, + kind: string, +): Promise> => { + let value: unknown; + try { + value = JSON.parse(await readFile(path, 'utf8')) as unknown; + } catch { + throw failure('AB7001', `Cannot read a valid ${kind} at ${JSON.stringify(path)}.`, host); + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw failure('AB7001', `${kind} at ${JSON.stringify(path)} must be a JSON object.`, host); + } + return value as Record; +}; + +const readString = ( + record: Readonly>, + key: string, + host: InstallHost, + kind: string, +): string => { + const value = record[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + throw failure('AB7001', `${kind} must declare a nonempty ${key}.`, host); + } + return value; +}; + +const resolveBundleRoot = async (from: string, host: InstallHost): Promise => { + const root = resolve(from); + const manifest = hostManifestPath(host); + if (await exists(join(root, manifest))) return root; + const targetRoot = join(root, host); + if (await exists(join(targetRoot, manifest))) return targetRoot; + throw failure( + 'AB7001', + `No ${host} bundle manifest was found in ${JSON.stringify(root)} or its ${JSON.stringify(host)} target directory.`, + host, + ); +}; + +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`); + const version = readString(pluginDocument, 'version', host, `${host} plugin manifest`); + if ( + host === 'cursor' && + (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(plugin) || plugin.length > 64) + ) { + throw failure('AB7001', `Cursor plugin name ${JSON.stringify(plugin)} is not a safe local plugin name.`, host); + } + if (host === 'cursor') return { bundleRoot, plugin, version }; + const marketplaceDocument = await readRecord( + join(bundleRoot, marketplacePath(host)), + host, + `${host} marketplace`, + ); + return { + bundleRoot, + marketplace: readString(marketplaceDocument, 'name', host, `${host} marketplace`), + plugin, + version, + }; +}; + +const defaultCommandRunner: InstallCommandRunner = Object.freeze({ + run: ( + command: string, + args: readonly string[], + options: { readonly cwd: string }, + ): Promise => new Promise((resolvePromise, reject) => { + execFile(command, [...args], { cwd: options.cwd }, (error, stdout, stderr) => { + if (error !== null && isErrno(error, 'ENOENT')) { + reject(error); + return; + } + resolvePromise({ + code: error === null ? 0 : typeof error.code === 'number' ? error.code : 1, + stderr, + stdout, + }); + }); + }), +}); + +const runHostCommand = async ( + runner: InstallCommandRunner, + identity: PluginIdentity, + host: Exclude, + args: readonly string[], +): Promise => { + let result: InstallCommandResult; + try { + result = await runner.run(host, args, { cwd: identity.bundleRoot }); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + throw failure('AB7002', `${host} is not installed or is not available on PATH.`, host); + } + throw error; + } + if (result.code !== 0) { + const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`; + throw failure('AB7004', `${host} plugin installation failed: ${detail}`, host); + } +}; + +const installPublicCli = async ( + options: InstallBundleOptions, + identity: PluginIdentity, + host: Exclude, + scope: InstallScope, +): Promise => { + if (host === 'codex' && scope !== 'user') { + throw failure('AB7003', `Codex plugin installation 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 runner = options.commandRunner ?? defaultCommandRunner; + await runHostCommand(runner, identity, host, [ + 'plugin', + 'marketplace', + 'add', + identity.bundleRoot, + ]); + await runHostCommand(runner, identity, host, host === 'claude' + ? ['plugin', 'install', `${identity.plugin}@${marketplace}`, '--scope', scope] + : ['plugin', 'add', `${identity.plugin}@${marketplace}`]); + return { + bundleRoot: identity.bundleRoot, + host, + marketplace, + plugin: identity.plugin, + state: 'installed', + version: identity.version, + }; +}; + +const treeHash = async (root: string): Promise => { + const rootMetadata = await lstat(root); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new Error('Refusing unsupported filesystem entry ".".'); + } + const hash = createHash('sha256'); + const visit = async (relativePath: string): Promise => { + const path = join(root, relativePath); + const metadata = await lstat(path); + if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) { + throw new Error(`Refusing unsupported filesystem entry ${JSON.stringify(relativePath || '.')}.`); + } + if (metadata.isDirectory()) { + for (const name of (await readdir(path)).sort((left, right) => left.localeCompare(right))) { + await visit(join(relativePath, name)); + } + return; + } + hash.update(relativePath.replaceAll('\\', '/')); + hash.update('\0'); + hash.update(await readFile(path)); + hash.update('\0'); + }; + for (const name of (await readdir(root)).sort((left, right) => left.localeCompare(right))) { + await visit(name); + } + return hash.digest('hex'); +}; + +const readInstalledVersion = async (destination: string): Promise => { + for (const manifest of ['.cursor-plugin/plugin.json', 'plugin.json']) { + try { + const document = JSON.parse(await readFile(join(destination, manifest), 'utf8')) as unknown; + if ( + document !== null && + typeof document === 'object' && + !Array.isArray(document) && + typeof (document as { readonly version?: unknown }).version === 'string' + ) { + return (document as { readonly version: string }).version; + } + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + } + return undefined; +}; + +const installCursor = async ( + options: InstallBundleOptions, + identity: PluginIdentity, + scope: InstallScope, +): Promise => { + if (scope !== 'user') { + throw failure('AB7003', `Cursor local plugin installation supports only user scope, not ${scope}.`, 'cursor'); + } + const cursorRoot = join(options.home ?? homedir(), '.cursor'); + let cursorMetadata: Awaited>; + try { + cursorMetadata = await lstat(cursorRoot); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + throw failure('AB7002', `Cursor is not installed in ${JSON.stringify(cursorRoot)}.`, 'cursor'); + } + throw error; + } + if (!cursorMetadata.isDirectory()) { + throw failure('AB7002', `Cursor home ${JSON.stringify(cursorRoot)} is not a directory.`, 'cursor'); + } + const installRoot = join(cursorRoot, 'plugins', 'local'); + const destination = join(installRoot, identity.plugin); + try { + await treeHash(identity.bundleRoot); + await mkdir(installRoot, { recursive: true }); + if (await exists(destination)) { + const currentVersion = await readInstalledVersion(destination); + if (currentVersion !== undefined && currentVersion !== identity.version) { + throw failure( + 'AB7005', + `Refusing version collision at ${destination}: found ${currentVersion}, requested ${identity.version}.`, + 'cursor', + ); + } + if (await treeHash(identity.bundleRoot) === await treeHash(destination)) { + return { + bundleRoot: identity.bundleRoot, + destination, + host: 'cursor', + plugin: identity.plugin, + state: 'already-installed', + version: identity.version, + }; + } + throw failure('AB7005', `Refusing content collision at ${destination}.`, 'cursor'); + } + const stageParent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`)); + const stage = join(stageParent, 'bundle'); + try { + await cp(identity.bundleRoot, stage, { + errorOnExist: true, + force: false, + recursive: true, + verbatimSymlinks: true, + }); + await treeHash(stage); + await rename(stage, destination); + } finally { + await rm(stageParent, { force: true, recursive: true }); + } + return { + bundleRoot: identity.bundleRoot, + destination, + host: 'cursor', + plugin: identity.plugin, + state: 'installed', + version: identity.version, + }; + } catch (error) { + if (error instanceof DiagnosticError) throw error; + throw failure( + 'AB7004', + error instanceof Error ? error.message : String(error), + 'cursor', + ); + } +}; + +const installProgram = Effect.fnUntraced(function*( + options: InstallBundleOptions, +): Effect.fn.Return { + const scope = options.scope ?? 'user'; + const identity = yield* liftPromise(() => readIdentity(options.from, options.host)); + switch (options.host) { + case 'claude': + return yield* liftPromise(() => installPublicCli(options, identity, 'claude', scope)); + case 'codex': + return yield* liftPromise(() => installPublicCli(options, identity, 'codex', scope)); + case 'cursor': + return yield* liftPromise(() => installCursor(options, identity, scope)); + default: { + const exhaustive: never = options.host; + return yield* Effect.fail(failure('AB7000', `Unsupported install host ${String(exhaustive)}.`, options.host)); + } + } +}); + +export const installBundle = ( + options: InstallBundleOptions, +): Promise => runPromise(installProgram(options)); diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts new file mode 100644 index 000000000..2060cdcac --- /dev/null +++ b/packages/agent-bundle/src/install/surface.ts @@ -0,0 +1,230 @@ +import type { NormalizedPlugin } from '../core/types.ts'; +import { + sortedEntries, + sourceInputs, + type TargetArtifactPlan, + type TargetArtifactWrite, +} from '../adapters/types.ts'; + +export type BuiltInTarget = 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable'; + +const marketplaceName = (model: NormalizedPlugin): string => `${model.metadata.name}-marketplace`; + +const header = (model: NormalizedPlugin): string[] => [ + `# Install ${model.metadata.name}`, + '', + model.metadata.description ?? model.metadata.name, + '', + `Version: \`${model.metadata.version}\``, + '', + 'Run these commands from this bundle directory.', + '', +]; + +const claudeInstructions = (model: NormalizedPlugin): string[] => [ + '## Claude Code', + '', + 'Claude Code installs this bundle through its local marketplace contract:', + '', + '```sh', + 'claude plugin marketplace add .', + `claude plugin install ${model.metadata.name}@${marketplaceName(model)} --scope user`, + '```', + '', + 'Replace `user` with `project` or `local` when that Claude scope is intended.', + '', +]; + +const codexInstructions = (model: NormalizedPlugin): string[] => [ + '## Codex', + '', + 'Codex installs this bundle from its local marketplace snapshot:', + '', + '```sh', + 'codex plugin marketplace add .', + `codex plugin add ${model.metadata.name}@${marketplaceName(model)}`, + '```', + '', +]; + +const cursorInstructions = (model: NormalizedPlugin): string[] => [ + '## Cursor', + '', + 'Cursor has no non-interactive plugin install command. Use the bundled safe-copy installer:', + '', + '```sh', + 'node ./install.mjs', + '```', + '', + `It installs to \`~/.cursor/plugins/local/${model.metadata.name}\`. Restart Cursor or run`, + '`Developer: Reload Window` after installation. The installer never overwrites a different', + 'version or different content.', + '', +]; + +const portableInstructions = (): string[] => [ + '## Portable Agent Plugin', + '', + 'Portable is a distribution profile, not a host runtime with one universal install location.', + 'This bundle follows Agent Plugins 1.0 and can be copied into a compatible host. Cursor supports', + 'that format directly, so the bundled installer provides a concrete local install path:', + '', + '```sh', + 'node ./install.mjs', + '```', + '', +]; + +const installMarkdown = (model: NormalizedPlugin, target: BuiltInTarget): string => { + const sections = (() => { + switch (target) { + case 'claude': + return claudeInstructions(model); + case 'codex': + return codexInstructions(model); + case 'cursor': + return cursorInstructions(model); + case 'portable': + return portableInstructions(); + case 'plugin': + return [...claudeInstructions(model), ...codexInstructions(model), ...cursorInstructions(model)]; + default: { + const exhaustive: never = target; + throw new TypeError(`Unknown built-in install target ${String(exhaustive)}.`); + } + } + })(); + return [...header(model), ...sections].join('\n'); +}; + +const cursorInstallerSource = (model: NormalizedPlugin): string => { + const name = JSON.stringify(model.metadata.name); + const version = JSON.stringify(model.metadata.version); + return [ + '#!/usr/bin/env node', + "import { createHash } from 'node:crypto';", + "import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm } from 'node:fs/promises';", + "import { homedir } from 'node:os';", + "import { basename, join, resolve } from 'node:path';", + "import { fileURLToPath } from 'node:url';", + '', + `const pluginName = ${name};`, + `const pluginVersion = ${version};`, + "const source = resolve(fileURLToPath(new URL('.', import.meta.url)));", + "const cursorRoot = join(homedir(), '.cursor');", + "const installRoot = join(cursorRoot, 'plugins', 'local');", + 'const destination = join(installRoot, pluginName);', + '', + 'const exists = async (path) => {', + ' try { await lstat(path); return true; }', + " catch (error) { if (error?.code === 'ENOENT') return false; throw error; }", + '};', + '', + 'const treeHash = async (root, prefix = \'\') => {', + ' const rootMetadata = await lstat(root);', + ' if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {', + ' throw new Error(\'Refusing unsupported filesystem entry ".".\');', + ' }', + " const hash = createHash('sha256');", + ' const visit = async (relative) => {', + ' const absolute = join(root, relative);', + ' const metadata = await lstat(absolute);', + ' if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) {', + " throw new Error(`Refusing unsupported filesystem entry ${JSON.stringify(relative || '.')}.`);", + ' }', + ' if (metadata.isDirectory()) {', + ' for (const name of (await readdir(absolute)).sort()) await visit(join(relative, name));', + ' return;', + ' }', + " hash.update(relative.replaceAll('\\\\', '/'));", + " hash.update('\\0');", + ' hash.update(await readFile(absolute));', + " hash.update('\\0');", + ' };', + " for (const name of (await readdir(root)).sort()) await visit(join(prefix, name));", + " return hash.digest('hex');", + '};', + '', + 'const installedVersion = async () => {', + " for (const manifest of ['.cursor-plugin/plugin.json', 'plugin.json']) {", + ' try {', + " const value = JSON.parse(await readFile(join(destination, manifest), 'utf8'));", + " if (typeof value.version === 'string') return value.version;", + " } catch (error) { if (error?.code !== 'ENOENT') throw error; }", + ' }', + ' return undefined;', + '};', + '', + 'if (!(await exists(cursorRoot)) || !(await lstat(cursorRoot)).isDirectory()) {', + ' throw new Error(`Cursor is not installed in ${cursorRoot}.`);', + '}', + 'await mkdir(installRoot, { recursive: true });', + 'if (await exists(destination)) {', + ' const currentVersion = await installedVersion();', + ' if (currentVersion !== undefined && currentVersion !== pluginVersion) {', + ' throw new Error(`Refusing version collision at ${destination}: found ${currentVersion}, requested ${pluginVersion}.`);', + ' }', + ' if (source === destination || await treeHash(source) === await treeHash(destination)) {', + ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination}`);', + ' process.exit(0);', + ' }', + ' throw new Error(`Refusing content collision at ${destination}.`);', + '}', + '', + 'const stageParent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', + "const stage = join(stageParent, 'bundle');", + 'try {', + ' await cp(source, stage, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true });', + ' await treeHash(stage);', + ' await rename(stage, destination);', + ' console.log(`Installed ${pluginName}@${pluginVersion} at ${destination}`);', + '} finally {', + ' await rm(stageParent, { force: true, recursive: true });', + '}', + '', + ].join('\n'); +}; + +const needsCursorInstaller = (target: BuiltInTarget): boolean => + target === 'cursor' || target === 'plugin' || target === 'portable'; + +export const installSurfaceRequirements = ( + target: string, +): readonly string[] => { + if (target === 'cursor' || target === 'plugin' || target === 'portable') { + return Object.freeze(['INSTALL.md', 'install.mjs']); + } + if (target === 'claude' || target === 'codex') { + return Object.freeze(['INSTALL.md']); + } + return Object.freeze([]); +}; + +export const installSurfaceEntries = ( + model: NormalizedPlugin, + target: BuiltInTarget, +): readonly TargetArtifactWrite[] => Object.freeze([ + Object.freeze({ + content: installMarkdown(model, target), + kind: 'write' as const, + relativePath: 'INSTALL.md', + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), + }), + ...(needsCursorInstaller(target) + ? [Object.freeze({ + content: cursorInstallerSource(model), + kind: 'write' as const, + relativePath: 'install.mjs', + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), + })] + : []), +]); + +export const withInstallSurface = ( + plan: TargetArtifactPlan, + model: NormalizedPlugin, + target: BuiltInTarget, +): TargetArtifactPlan => Object.freeze({ + ...plan, + entries: sortedEntries([...plan.entries, ...installSurfaceEntries(model, target)]), +}); diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 3185f2324..951c1fb00 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -227,7 +227,7 @@ it('surfaces built-in adapter metadata as immutable capability evidence', () => if (cursor.capabilities.mcp?.state !== 'supported') throw new Error('Expected Cursor MCP support evidence.'); expect(cursor.capabilities.mcp.evidence).toEqual({ capabilityRevision: '2026-08-28', - capabilitySha256: '9884e0ffdb1c7fd1fe6d1071fa6944729d596e51f0ab87ad5ef2b0dd6fd8a981', + capabilitySha256: 'e755dbaa54e36001e8046152cb2a630b2ac6252e2a3fd8ba4bb559e61e6bcf0a', observedVersion: '2026-08-28', target: 'cursor', }); @@ -276,3 +276,22 @@ it('reports the evidence-backed G10 event family matrix without inferred support state: 'unavailable', }); }); + +it('reports evidence-backed installation support only for real host targets', () => { + const registry = createDefaultRegistry(); + + for (const target of ['claude', 'codex', 'cursor'] as const) { + expect(registry.get(target).capabilities.install).toMatchObject({ + evidence: { target }, + state: 'supported', + }); + expect(registry.supports(target, 'install')).toBe(true); + } + for (const target of ['portable', 'plugin'] as const) { + expect(registry.get(target).capabilities.install).toMatchObject({ + reason: expect.stringContaining('profile'), + state: 'unavailable', + }); + expect(registry.supports(target, 'install')).toBe(false); + } +}); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index a46ce2974..fde479cd5 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -55,9 +55,9 @@ it('records exact immutable metadata for every built-in target', () => { const registry = createDefaultRegistry(); expect(registryMetadata(registry, 'portable')).toEqual({ - adapterRevision: '1.1.0', + adapterRevision: '1.2.0', capabilityRevision: '1.0.0', - capabilitySha256: '99a27bd327f2afdb0a71fa869e9fad27438f07d843c5e61062d2dc3f978dce9a', + capabilitySha256: '60f63a2cf3c6783a178173c5006234a89ae186fe3542fe61339542cac117389e', observedVersion: '1.0.0', schemas: [ { @@ -75,7 +75,7 @@ it('records exact immutable metadata for every built-in target', () => { expect(registryMetadata(registry, 'codex')).toEqual({ adapterRevision: '1.2.0', capabilityRevision: '0.147.0', - capabilitySha256: '44e697be71a29db9ec029ed7d9eb8807b90e95d6a15f3a71a47148125c902194', + capabilitySha256: 'd944e508941a0660272a253601019957ae94e9140501f0624f85d111e66d9f28', observedVersion: '0.147.0', schemas: [ { @@ -103,7 +103,7 @@ it('records exact immutable metadata for every built-in target', () => { expect(registryMetadata(registry, 'claude')).toEqual({ adapterRevision: '1.4.0', capabilityRevision: '2.1.250', - capabilitySha256: '6b8a3b222b49c0ad22f32ecdf8157bd353ce5be05d56e40ae5cf4ad2b9eb917f', + capabilitySha256: '58141a999ac3d39d9b7aa2bc6bb945aae145773ea6f37806eecc93d3b5c7ed38', observedVersion: '2.1.250', schemas: [ { @@ -136,7 +136,7 @@ it('records exact immutable metadata for every built-in target', () => { expect(registryMetadata(registry, 'cursor')).toEqual({ adapterRevision: '1.4.0', capabilityRevision: '2026-08-28', - capabilitySha256: '9884e0ffdb1c7fd1fe6d1071fa6944729d596e51f0ab87ad5ef2b0dd6fd8a981', + capabilitySha256: 'e755dbaa54e36001e8046152cb2a630b2ac6252e2a3fd8ba4bb559e61e6bcf0a', observedVersion: '2026-08-28', schemas: [ { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index b9f769405..b3ab522c1 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -14,6 +14,7 @@ import { validateModernMcpDocument, type TargetAdapter, type TargetArtifactDocumentValidator, + type TargetArtifactWrite, } from '../src/adapters/types.ts'; import { assembleArtifactManifest, type ArtifactManifest } from '../src/build/manifest.ts'; import { artifactDiagnosticRecoveries, validateArtifact, validateArtifactWithSnapshot } from '../src/build/validate-artifact.ts'; @@ -21,6 +22,7 @@ import { digest, sha256Hex } from '../src/core/digest.ts'; import { agentSkillsSchemaRevision } from '../src/schemas/agent-skills/contract.ts'; import { createMcpPathTokenResolver } from '../src/services/mcp-path-tokens.ts'; import { createTargetMcpRuntime } from '../src/services/mcp-runtime.ts'; +import type { NormalizedPlugin } from '../src/core/types.ts'; const hash = (value: string): string => sha256Hex(value); @@ -743,6 +745,8 @@ it('rejects forged hook output for a target without a hook contract', async () = const registry = createDefaultRegistry(); const portable = targetFromRegistry(registry, 'portable'); const files = [ + { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'portable/INSTALL.md' }, + { contents: 'export {};\n', kind: 'generated' as const, path: 'portable/install.mjs' }, { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated' as const, @@ -791,6 +795,8 @@ it('admits nested project assets in the target-owned recursive asset namespace', const registry = createDefaultRegistry(); const portable = targetFromRegistry(registry, 'portable'); const files = [ + { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'portable/INSTALL.md' }, + { contents: 'export {};\n', kind: 'generated' as const, path: 'portable/install.mjs' }, { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated' as const, @@ -1770,6 +1776,7 @@ it('validates a canonically rehashed Codex marketplace at its emitted path', asy }], }; const validFiles = [ + { contents: '# Install codex-test\n', kind: 'generated' as const, path: 'codex/INSTALL.md' }, { contents: `${JSON.stringify(plugin)}\n`, kind: 'generated' as const, path: 'codex/.codex-plugin/plugin.json' }, { contents: `${JSON.stringify(marketplace)}\n`, kind: 'generated' as const, path: 'codex/.agents/plugins/marketplace.json' }, ]; @@ -1780,6 +1787,7 @@ it('validates a canonically rehashed Codex marketplace at its emitted path', asy const invalidFiles = [ validFiles[0]!, + validFiles[1]!, { contents: '{}\n', kind: 'generated' as const, path: 'codex/.agents/plugins/marketplace.json' }, ]; await writeFile(join(root, 'codex', '.agents', 'plugins', 'marketplace.json'), '{}\n'); @@ -1879,7 +1887,7 @@ it('documents recovery for every stable artifact diagnostic code', async () => { 'AB6000', 'AB6001', 'AB6002', 'AB6003', 'AB6004', 'AB6005', 'AB6006', 'AB6007', 'AB6008', 'AB6009', 'AB6010', 'AB6011', 'AB6012', 'AB6013', 'AB6014', 'AB6015', 'AB6016', 'AB6017', 'AB6018', 'AB6019', 'AB6020', - 'AB6021', 'AB6022', + 'AB6021', 'AB6022', 'AB6023', 'AB6024', ]); expect(Object.values(artifactDiagnosticRecoveries).every((recovery) => recovery.trim().length > 0)).toBe(true); expect(artifactDiagnosticRecoveries.AB6015).not.toBe(artifactDiagnosticRecoveries.AB6016); @@ -1894,3 +1902,87 @@ it('documents recovery for every stable artifact diagnostic code', async () => { await rm(root, { force: true, recursive: true }); } }); + +const installSurfaceModel = (target: string): NormalizedPlugin => ({ + extensions: {}, + hooks: [], + mcpServers: [], + metadata: { + id: 'plugin:artifact-install', + name: 'artifact-install', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + version: '1.0.0', + }, + runtime: { node: '22.19.0' }, + scripts: [], + skills: [], + targets: [{ + id: `target:${target}`, + name: target, + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + }], +}); + +const installSurfaceArtifact = async ( + target: 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable', + omitted: string, +): Promise => { + const registry = createDefaultRegistry(); + const files = registry.get(target).plan(installSurfaceModel(target)).entries + .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') + .filter((entry) => entry.relativePath !== omitted) + .map((entry) => ({ + contents: entry.content, + kind: 'generated' as const, + path: `${target}/${entry.relativePath}`, + })); + return writeArtifact(files, true, [targetFromRegistry(registry, target)]); +}; + +it.each(['claude', 'codex', 'cursor', 'plugin', 'portable'] as const)( + 'rejects a %s artifact without INSTALL.md', + async (target) => { + const root = await installSurfaceArtifact(target, 'INSTALL.md'); + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6023', + generatedPath: `${target}/INSTALL.md`, + target, + }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } + }, +); + +it.each(['cursor', 'plugin', 'portable'] as const)( + 'rejects a %s fallback artifact without install.mjs', + async (target) => { + const root = await installSurfaceArtifact(target, 'install.mjs'); + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6024', + generatedPath: `${target}/install.mjs`, + target, + }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } + }, +); + +it.each(['claude', 'codex'] as const)( + 'does not require a fallback script for the %s public CLI target', + async (target) => { + const root = await installSurfaceArtifact(target, 'install.mjs'); + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }, +); diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 8534a7f62..315c58697 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -579,3 +579,44 @@ it('reports a generated Flight worker collision before compiling scripts', async await rm(resolve(project.root, '..'), { force: true, recursive: true }); } }, 30_000 * timeScale); + +it('dispatches the install command through the native installer surface', async () => { + const stderr: string[] = []; + const stdout: string[] = []; + const calls: unknown[] = []; + Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); + + const code = await runSourceCli( + ['install', 'claude', '--from', '/tmp/example bundle', '--scope', 'project', '--json'], + { + stderr: { write: (chunk: string) => stderr.push(chunk) }, + stdout: { write: (chunk: string) => stdout.push(chunk) }, + }, + { + installBundle: async (options: unknown) => { + calls.push(options); + return { + bundleRoot: '/tmp/example bundle', + host: 'claude', + marketplace: 'fixture-marketplace', + plugin: 'fixture', + state: 'installed', + version: '1.0.0', + }; + }, + } as unknown as Parameters[2], + ); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(calls).toEqual([{ + from: '/tmp/example bundle', + host: 'claude', + scope: 'project', + }]); + expect(JSON.parse(stdout.join(''))).toMatchObject({ + host: 'claude', + plugin: 'fixture', + state: 'installed', + }); +}); diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 62b1cd24d..97b6dcb79 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -165,7 +165,12 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit expect(plan.hookEntries).toEqual([]); const documents = writeContents(model); - expect(Object.keys(documents).sort()).toEqual(['.cursor-plugin/plugin.json', 'mcp.json']); + expect(Object.keys(documents).sort()).toEqual([ + '.cursor-plugin/plugin.json', + 'INSTALL.md', + 'install.mjs', + 'mcp.json', + ]); const manifest = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; expect(manifest).toEqual({ @@ -330,7 +335,7 @@ it('rejects the plugin-data token and omits the failed server from the document' expect.objectContaining({ code: 'cursor.mcp.token', severity: 'error', target: 'cursor' }), ]); const documents = plan.entries.filter((entry) => entry.kind === 'write').map((entry) => entry.relativePath); - expect(documents).toEqual(['.cursor-plugin/plugin.json']); + expect(documents).toEqual(['.cursor-plugin/plugin.json', 'INSTALL.md', 'install.mjs']); const manifest = JSON.parse( (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, ) as Record; diff --git a/packages/agent-bundle/tests/dev-artifact-service.test.ts b/packages/agent-bundle/tests/dev-artifact-service.test.ts index 7da2333e8..f1d63583a 100644 --- a/packages/agent-bundle/tests/dev-artifact-service.test.ts +++ b/packages/agent-bundle/tests/dev-artifact-service.test.ts @@ -216,6 +216,10 @@ it('allows only an exact epoch store marker as an extra staged artifact file', a join(root, 'portable', 'plugin.json'), '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid staged plugin.","name":"valid","version":"1.0.0"}\n', ); + await Promise.all([ + writeFile(join(root, 'portable', 'INSTALL.md'), '# Install valid\n'), + writeFile(join(root, 'portable', 'install.mjs'), 'export {};\n'), + ]); await writeFixtureManifest({ artifactRoot: root, targets: ['portable'] }); await writeFile(join(root, marker), '{"token":"8f2aa8b7-bdd2-4065-8cd3-5184c6bd9f74"}\n'); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index d9f83ff14..4e6bcc170 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -268,10 +268,13 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen const codex = planEntries(plugin, 'codex'); const claude = planEntries(plugin, 'claude'); + const codexPluginEntries = codex.filter((entry) => entry.relativePath !== 'INSTALL.md'); + const claudePluginEntries = claude.filter((entry) => entry.relativePath !== 'INSTALL.md'); expect(codex.map((entry) => entry.relativePath)).toEqual([ '.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.mcp.json', + 'INSTALL.md', 'skills/review/SKILL.md', 'skills/review/assets/icon.bin', 'skills/review/references/guide.md', @@ -280,11 +283,12 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen '.claude-plugin/marketplace.json', '.claude-plugin/plugin.json', '.mcp.json', + 'INSTALL.md', 'skills/review/SKILL.md', 'skills/review/assets/icon.bin', 'skills/review/references/guide.md', ]); - expect(codex).toMatchObject([ + expect(codexPluginEntries).toMatchObject([ { content: '{"interface":{"displayName":"review-tools"},"name":"review-tools-marketplace","plugins":[{"category":"Productivity","name":"review-tools","policy":{"authentication":"ON_INSTALL","installation":"AVAILABLE"},"source":{"path":"./","source":"local"}}]}\n', kind: 'write', @@ -304,7 +308,7 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen { bytes: 3, kind: 'copy', relativePath: 'skills/review/assets/icon.bin', source: '/workspace/skills/review/assets/icon.bin' }, { bytes: 8, kind: 'copy', relativePath: 'skills/review/references/guide.md', source: '/workspace/skills/review/references/guide.md' }, ]); - expect(claude).toMatchObject([ + expect(claudePluginEntries).toMatchObject([ { content: '{"description":"Review code and explain findings.","name":"review-tools-marketplace","owner":{"name":"review-tools"},"plugins":[{"description":"Review code and explain findings.","name":"review-tools","source":"./","version":"1.2.3"}]}\n', kind: 'write', @@ -324,7 +328,7 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen { bytes: 3, kind: 'copy', relativePath: 'skills/review/assets/icon.bin', source: '/workspace/skills/review/assets/icon.bin' }, { bytes: 8, kind: 'copy', relativePath: 'skills/review/references/guide.md', source: '/workspace/skills/review/references/guide.md' }, ]); - expect(codex.map((entry) => entry.sourceInputs)).toEqual([ + expect(codexPluginEntries.map((entry) => entry.sourceInputs)).toEqual([ ['/workspace/agent-bundle.config.ts'], ['/workspace/agent-bundle.config.ts', '/workspace/skills/review/SKILL.md'], ['/workspace/agent-bundle.config.ts'], @@ -332,7 +336,7 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen ['/workspace/skills/review/SKILL.md', '/workspace/skills/review/assets/icon.bin'], ['/workspace/skills/review/SKILL.md', '/workspace/skills/review/references/guide.md'], ]); - expect(claude.map((entry) => entry.sourceInputs)).toEqual([ + expect(claudePluginEntries.map((entry) => entry.sourceInputs)).toEqual([ ['/workspace/agent-bundle.config.ts'], ['/workspace/agent-bundle.config.ts', '/workspace/skills/review/SKILL.md'], ['/workspace/agent-bundle.config.ts'], @@ -923,6 +927,7 @@ it('filters host components and builds portable, Codex, and Claude target roots' expect(filteredPlan.entries.map((entry) => entry.relativePath)).toEqual([ '.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', + 'INSTALL.md', ]); const root = await mkdtemp(join(tmpdir(), 'agent-bundle-host-adapter-')); diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts new file mode 100644 index 000000000..d5b4251a2 --- /dev/null +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -0,0 +1,95 @@ +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'; + +const modelFor = (target: string): NormalizedPlugin => ({ + extensions: {}, + hooks: [], + mcpServers: [], + metadata: { + description: 'Checks host installation.', + id: 'plugin:install-fixture', + name: 'install-fixture', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + version: '1.2.3', + }, + runtime: { node: '22.19.0' }, + scripts: [], + skills: [], + targets: [{ + id: `target:${target}`, + name: target, + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + }], +}); + +const writesFor = (target: string): ReadonlyMap => { + const plan = createDefaultRegistry().get(target).plan(modelFor(target)); + return new Map(plan.entries + .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') + .map((entry) => [entry.relativePath, entry.content])); +}; + +it.each(['claude', 'codex', 'cursor', 'portable', 'plugin'])( + 'emits a concrete INSTALL.md for the %s target', + (target) => { + const install = writesFor(target).get('INSTALL.md'); + + expect(install).toContain('# Install install-fixture'); + expect(install).toContain('Version: `1.2.3`'); + expect(install).not.toContain(''); + expect(install).not.toContain(''); + expect(install).not.toContain(''); + }, +); + +it('emits always-installable Claude and Codex local marketplaces with exact commands', () => { + const claude = writesFor('claude'); + const codex = writesFor('codex'); + + expect(JSON.parse(claude.get('.claude-plugin/marketplace.json')!)).toMatchObject({ + name: 'install-fixture-marketplace', + plugins: [{ name: 'install-fixture', source: './', version: '1.2.3' }], + }); + expect(claude.get('INSTALL.md')).toContain('claude plugin marketplace add .'); + expect(claude.get('INSTALL.md')).toContain( + 'claude plugin install install-fixture@install-fixture-marketplace --scope user', + ); + + expect(JSON.parse(codex.get('.agents/plugins/marketplace.json')!)).toMatchObject({ + name: 'install-fixture-marketplace', + plugins: [{ name: 'install-fixture', source: { path: './', source: 'local' } }], + }); + expect(codex.get('INSTALL.md')).toContain('codex plugin marketplace add .'); + expect(codex.get('INSTALL.md')).toContain( + 'codex plugin add install-fixture@install-fixture-marketplace', + ); +}); + +it('emits a standalone safe-copy installer only for Cursor-compatible fallback profiles', () => { + for (const target of ['cursor', 'portable', 'plugin']) { + const writes = writesFor(target); + expect(writes.get('INSTALL.md')).toContain('node ./install.mjs'); + expect(writes.get('install.mjs')).toContain("join(cursorRoot, 'plugins', 'local')"); + expect(writes.get('install.mjs')).toContain('const rootMetadata = await lstat(root);'); + expect(writes.get('install.mjs')).toContain( + 'rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()', + ); + expect(writes.get('install.mjs')).toContain('install-fixture'); + expect(writes.get('install.mjs')).toContain('1.2.3'); + expect(writes.get('install.mjs')).not.toContain('sudo'); + } + for (const target of ['claude', 'codex']) { + expect(writesFor(target).has('install.mjs')).toBe(false); + } +}); + +it('documents every real host path from the composite profile', () => { + const install = writesFor('plugin').get('INSTALL.md'); + + expect(install).toContain('claude plugin install install-fixture@install-fixture-marketplace --scope user'); + expect(install).toContain('codex plugin add install-fixture@install-fixture-marketplace'); + expect(install).toContain('node ./install.mjs'); +}); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts new file mode 100644 index 000000000..bcf2f4f74 --- /dev/null +++ b/packages/agent-bundle/tests/install.test.ts @@ -0,0 +1,378 @@ +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { installBundle, type InstallCommandRunner } from '../src/install/install.ts'; +import { DiagnosticError } from '../src/core/diagnostics.ts'; +import { runCli } from '../src/cli.ts'; + +interface CommandCall { + readonly args: readonly string[]; + readonly command: string; + readonly cwd: string; +} + +const recordingRunner = (): { + readonly calls: CommandCall[]; + readonly runner: InstallCommandRunner; +} => { + const calls: CommandCall[] = []; + return { + calls, + runner: { + run: async (command, args, options) => { + calls.push({ args: [...args], command, cwd: options.cwd }); + return { code: 0, stderr: '', stdout: '' }; + }, + }, + }; +}; + +const writeJson = async (path: string, value: unknown): Promise => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value)}\n`); +}; + +const createHostBundle = async ( + host: 'claude' | 'codex' | 'cursor', + options: { readonly artifactRoot?: boolean } = {}, +): Promise<{ readonly bundleRoot: string; readonly cleanupRoot: string; readonly from: string }> => { + const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-install-')); + const from = options.artifactRoot === true ? cleanupRoot : join(cleanupRoot, 'bundle'); + const bundleRoot = options.artifactRoot === true ? join(cleanupRoot, host) : from; + await mkdir(bundleRoot, { recursive: true }); + await writeFile(join(bundleRoot, 'payload.txt'), 'payload\n'); + + if (host === 'claude') { + await Promise.all([ + writeJson(join(bundleRoot, '.claude-plugin/plugin.json'), { + name: 'install-fixture', + version: '1.2.3', + }), + writeJson(join(bundleRoot, '.claude-plugin/marketplace.json'), { + name: 'install-fixture-marketplace', + plugins: [{ name: 'install-fixture', source: './', version: '1.2.3' }], + }), + ]); + } else if (host === 'codex') { + await Promise.all([ + writeJson(join(bundleRoot, '.codex-plugin/plugin.json'), { + name: 'install-fixture', + version: '1.2.3', + }), + writeJson(join(bundleRoot, '.agents/plugins/marketplace.json'), { + name: 'install-fixture-marketplace', + plugins: [{ + category: 'Productivity', + name: 'install-fixture', + policy: { authentication: 'ON_INSTALL', installation: 'AVAILABLE' }, + source: { path: './', source: 'local' }, + }], + }), + ]); + } else { + await writeJson(join(bundleRoot, '.cursor-plugin/plugin.json'), { + name: 'install-fixture', + version: '1.2.3', + }); + } + return { bundleRoot, cleanupRoot, from }; +}; + +it.each([ + { + expected: [ + { args: ['plugin', 'marketplace', 'add', resolve('/bundle')], command: 'claude' }, + { + args: ['plugin', 'install', 'install-fixture@install-fixture-marketplace', '--scope', 'project'], + command: 'claude', + }, + ], + host: 'claude' as const, + scope: 'project' as const, + }, + { + expected: [ + { args: ['plugin', 'marketplace', 'add', resolve('/bundle')], command: 'codex' }, + { args: ['plugin', 'add', 'install-fixture@install-fixture-marketplace'], command: 'codex' }, + ], + host: 'codex' as const, + scope: 'user' as const, + }, +])('delegates $host installation to its public CLI without a shell', async ({ expected, host, scope }) => { + const fixture = await createHostBundle(host); + const { calls, runner } = recordingRunner(); + try { + const result = await installBundle({ commandRunner: runner, from: fixture.from, host, scope }); + + expect(result).toMatchObject({ host, plugin: 'install-fixture', state: 'installed' }); + expect(calls).toEqual(expected.map((call) => ({ ...call, args: call.args.map((arg) => + arg === resolve('/bundle') ? fixture.bundleRoot : arg), cwd: fixture.bundleRoot }))); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('accepts an artifact root containing the requested host target', async () => { + const fixture = await createHostBundle('claude', { artifactRoot: true }); + const { calls, runner } = recordingRunner(); + try { + const result = await installBundle({ + commandRunner: runner, + from: fixture.from, + host: 'claude', + scope: 'user', + }); + + expect(result.bundleRoot).toBe(fixture.bundleRoot); + expect(calls[0]).toMatchObject({ cwd: fixture.bundleRoot }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('fails with a typed diagnostic when the public host CLI is missing', async () => { + const fixture = await createHostBundle('codex'); + const missingRunner: InstallCommandRunner = { + run: async () => { + const error = new Error('spawn codex ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }, + }; + try { + const error = await installBundle({ + commandRunner: missingRunner, + from: fixture.from, + host: 'codex', + scope: 'user', + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ + code: 'AB7002', + severity: 'error', + target: 'codex', + }]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('rejects scopes the selected host does not support', async () => { + const fixture = await createHostBundle('codex'); + try { + const error = await installBundle({ + commandRunner: recordingRunner().runner, + from: fixture.from, + host: 'codex', + scope: 'project', + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7003', target: 'codex' }]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('copies a Cursor bundle into a fake home and is idempotent', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + await mkdir(join(home, '.cursor')); + const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); + try { + const first = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + const second = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + + expect(first).toMatchObject({ destination, host: 'cursor', state: 'installed' }); + expect(second).toMatchObject({ destination, host: 'cursor', state: 'already-installed' }); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('fails closed when Cursor is not detected in the selected home', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + try { + const error = await installBundle({ + from: fixture.from, + home, + host: 'cursor', + scope: 'user', + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ + code: 'AB7002', + target: 'cursor', + }]); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('refuses Cursor version and content collisions', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + await mkdir(join(home, '.cursor')); + const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); + try { + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + await writeFile(join(destination, 'payload.txt'), 'changed\n'); + const contentError = await installBundle({ + from: fixture.from, + home, + host: 'cursor', + scope: 'user', + }).catch((failure: unknown) => failure); + expect(contentError).toBeInstanceOf(DiagnosticError); + expect((contentError as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7005' }]); + + await writeJson(join(destination, '.cursor-plugin/plugin.json'), { + name: 'install-fixture', + version: '9.0.0', + }); + const versionError = await installBundle({ + from: fixture.from, + home, + host: 'cursor', + scope: 'user', + }).catch((failure: unknown) => failure); + expect(versionError).toBeInstanceOf(DiagnosticError); + expect((versionError as DiagnosticError).diagnostics[0]?.message).toContain('version collision'); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('refuses symlinks in a Cursor source bundle', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + await mkdir(join(home, '.cursor')); + await symlink('/tmp', join(fixture.bundleRoot, 'unsafe-link')); + try { + const error = await installBundle({ + from: fixture.from, + home, + host: 'cursor', + scope: 'user', + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7004', target: 'cursor' }]); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('refuses a symlinked Cursor install destination even when its content matches', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + const installRoot = join(home, '.cursor', 'plugins', 'local'); + const destination = join(installRoot, 'install-fixture'); + await mkdir(installRoot, { recursive: true }); + await symlink(fixture.bundleRoot, destination); + try { + const error = await installBundle({ + from: fixture.from, + home, + host: 'cursor', + scope: 'user', + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7004', target: 'cursor' }]); + expect((error as DiagnosticError).diagnostics[0]?.message).toContain( + 'Refusing unsupported filesystem entry "."', + ); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('rejects a Cursor plugin name that could escape the local install root', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + await writeJson(join(fixture.bundleRoot, '.cursor-plugin/plugin.json'), { + name: '../escape', + version: '1.2.3', + }); + try { + const error = await installBundle({ + from: fixture.from, + home, + host: 'cursor', + scope: 'user', + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7001', target: 'cursor' }]); + await expect(access(join(home, '.cursor', 'plugins', 'escape'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('dispatches the public CLI install command to the native installer', async () => { + const stderr: string[] = []; + const stdout: string[] = []; + const calls: unknown[] = []; + Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); + + const code = await runCli( + ['install', 'claude', '--from', '/tmp/example bundle', '--scope', 'project', '--json'], + { + stderr: { write: (chunk: string) => stderr.push(chunk) }, + stdout: { write: (chunk: string) => stdout.push(chunk) }, + }, + { + installBundle: async (options: unknown) => { + calls.push(options); + return { + bundleRoot: '/tmp/example bundle', + host: 'claude', + marketplace: 'fixture-marketplace', + plugin: 'fixture', + state: 'installed', + version: '1.0.0', + }; + }, + } as unknown as Parameters[2], + ); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(calls).toEqual([{ + from: '/tmp/example bundle', + host: 'claude', + scope: 'project', + }]); + expect(JSON.parse(stdout.join(''))).toMatchObject({ + host: 'claude', + plugin: 'fixture', + state: 'installed', + }); +}); diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index bcf2f3da4..0352655ee 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -127,9 +127,11 @@ it('lays both host manifests over one shared bundle root', () => { expect(documents['AGENTS.md']).toContain('Claude Code'); expect(documents['AGENTS.md']).toContain('Codex'); expect(documents['AGENTS.md']).toContain('Cursor'); - expect(documents['AGENTS.md']).toContain('copy this directory into `~/.cursor/plugins/local/bundle-example`'); - expect(documents['AGENTS.md']).toContain('Symlinks that resolve outside `~/.cursor/plugins/local` are rejected'); - expect(documents['AGENTS.md']).toContain('https://forum.cursor.com/t/local-plugins-symlink-on-windows-doesnt-work/159427/6'); + expect(documents['AGENTS.md']).toContain('See `INSTALL.md` for exact Claude Code, Codex, and Cursor commands'); + expect(documents['AGENTS.md']).toContain('`node ./install.mjs`'); + expect(documents['INSTALL.md']).toContain('claude plugin install bundle-example@bundle-example-marketplace --scope user'); + expect(documents['INSTALL.md']).toContain('codex plugin add bundle-example@bundle-example-marketplace'); + expect(documents['install.mjs']).toContain("join(cursorRoot, 'plugins', 'local')"); expect(documents['AGENTS.md']).toContain('VS Code / GitHub Copilot'); const cursorPlugin = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index 7a8d2f733..9b292db8d 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -89,7 +89,9 @@ it('plans a schema-valid skills-only plugin with every discovered resource', () expect(registry.defaultTargetNames()).toEqual(['portable']); expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); expect(plan.diagnostics).toEqual([]); - expect(plan.entries).toMatchObject([ + const pluginEntries = plan.entries.filter((entry) => + entry.relativePath !== 'INSTALL.md' && entry.relativePath !== 'install.mjs'); + expect(pluginEntries).toMatchObject([ { content: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"A portable test plugin","name":"portable-test","version":"1.2.3"}\n', @@ -109,7 +111,7 @@ it('plans a schema-valid skills-only plugin with every discovered resource', () source: '/workspace/skills/reporter/references/guide.md', }, ]); - expect(plan.entries.map((entry) => entry.sourceInputs)).toEqual([ + expect(pluginEntries.map((entry) => entry.sourceInputs)).toEqual([ ['/workspace/agent-bundle.config.ts'], ['/workspace/skills/reporter/SKILL.md'], ['/workspace/skills/reporter/SKILL.md', '/workspace/skills/reporter/references/guide.md'], diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index 7dc02ddb9..f024eeffa 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -191,6 +191,10 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { }, })}\n`, ); + await Promise.all([ + writeFile(join(artifact, 'portable', 'INSTALL.md'), '# Install packed-fixture\n'), + writeFile(join(artifact, 'portable', 'install.mjs'), '#!/usr/bin/env node\n'), + ]); await writeFixtureManifest({ artifactRoot: artifact, targets: ['portable'] }); await expect(readFile(join(artifact, 'agent-bundle.hooks.json'), 'utf8')).resolves.toBe( '{"hooks":[]}\n',