diff --git a/.changeset/amp-native-plugin-factory.md b/.changeset/amp-native-plugin-factory.md new file mode 100644 index 000000000..2fc0671d3 --- /dev/null +++ b/.changeset/amp-native-plugin-factory.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add the `amp` target and `AmpSkillExtension`, emit PluginAPI factories with registered skills and events, install receipt-owned project or system copies through `agent-bundle install amp`, and advance artifacts to `manifestVersion: 4` with `projections[].documents.entry`; unrelocatable local MCP entries fail with `amp.mcp.generated-local` (#729) diff --git a/README.md b/README.md index 7dcb8c968..1f7cf3bc8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # agent-bundle -agent-bundle compiles an agent plugin — skills, hooks, MCP servers, and scripts, described by one typed config — into one installable plugin root that Claude Code, Codex, Cursor, and portable Agent Plugins clients load. You write the plugin once; the compiler emits each selected host's manifests and wrappers into that root. +agent-bundle compiles an agent plugin — skills, hooks, MCP servers, and scripts, described by one typed config — into installable output for Amp, Claude Code, Codex, Cursor, and portable Agent Plugins clients. You write the plugin once; the compiler emits each selected host's entry, manifests, and wrappers into one composite root. Documentation: [scriptedalchemy.github.io/agent-bundle](https://scriptedalchemy.github.io/agent-bundle/) (English and 简体中文; guide, reference, generated type API, host capability matrices). @@ -51,7 +51,7 @@ npx agent-bundle build --root . # write the plugin root to artif npx agent-bundle dev --root . # local workbench with live rebuilds ``` -`agent-bundle build` writes one composite plugin root (`artifact/` by default; `--output` or `output.distPath` relocates it), and `targets` selects which host projections it carries: the `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests and the portable `plugin.json` sit at the root over shared `skills/`, `hooks/`, `mcp/`, and `scripts/` directories, emitted once. Every selected host installs from that same directory, and the generated `INSTALL.md` explains how. Omitting `targets` emits only the `portable` projection. +`agent-bundle build` writes one composite root (`artifact/` by default; `--output` or `output.distPath` relocates it), and `targets` selects which host projections it carries: Amp's `.amp/plugins//index.js`, the `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests, and the portable `plugin.json`. Amp installs its nested directory; the other hosts install the composite root, and the generated `INSTALL.md` explains how. Omitting `targets` emits only the `portable` projection. The `portable` target is the [Agent Plugins open standard](https://agent-plugins.org/specification) (specification 1.0.0) adapter — the default projection, and the layout Cursor loads natively (Claude Code consumes it only through CLI translation). Every other client that reads this artifact is recorded with its tier, install command, and dated evidence in the `clients` section of that same capability table, rendered as the [hosts reference](https://scriptedalchemy.github.io/agent-bundle/reference/hosts). It emits the closed root `plugin.json` (canonical `$schema`, `name`, `version`, `description`, plus `author`, `homepage`, `repository`, `license`, `keywords`, and reverse-domain `extensions` authored under the `portable` config key), `skills//SKILL.md`, and `mcp.json` with stdio and Streamable HTTP servers whose `args`, `env` values, and `cwd` use the standard's `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` placeholders. Rules, commands, hooks, marketplaces, and client extension directories are honestly unavailable there because the v1 standard packages only skills and MCP servers. Both documents are validated against the vendored, hash-pinned 1.0.0 schemas and the normative text at plan time (`portable.mcp.*.standard`), after every ordinary build and `validate --artifact` (`AB6011`/`AB6012` plus the Agent Plugins byte lane `AB6035`–`AB6037`), under `validate --artifact --host-validation` (same lane with the `AB6038` provenance note), and by `agent-bundle doctor` for installed Cursor local plugins that declare the standard's `$schema` (`AB7320`); see [Diagnostics](docs/diagnostics.md#agent-plugins-portable-validation-ab6035ab6038). Pins live in `packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json`; the capability table `packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json` carries a dated row for every standard feature. diff --git a/docs/audits/2026-09-07-amp-native-adapter-proof.md b/docs/audits/2026-09-07-amp-native-adapter-proof.md new file mode 100644 index 000000000..d97a453e9 --- /dev/null +++ b/docs/audits/2026-09-07-amp-native-adapter-proof.md @@ -0,0 +1,78 @@ +# Amp native adapter proof — 2026-09-07 + +## Pins and boundary + +- `@ampcode/plugin@0.0.0-20260907001852-gf348fed` +- `@ampcode/cli@0.0.1788739286-gf348fe` +- Official Plugin, PluginAPI, Skills, and MCP pages retrieved 2026-09-07 +- No Amp account was available. Live thread, model, and tool execution was not run and remains + **unverified**, not unsupported. + +Everything below ran under `/tmp/agent-bundle-amp-proof` with +`HOME=/tmp/agent-bundle-amp-proof/home`. + +## Build and static artifact proof + +The disposable consumer installed the two pinned Amp packages, TypeScript, Node/Bun type +declarations, and the locally built `agent-bundle` package. npm 12 blocked the CLI postinstall by +default, so the proof completed the package's own `node node_modules/@ampcode/cli/install.cjs` +before invoking Amp. + +`agent-bundle build --target amp` emitted: + +```text +artifact/ +├── .amp/plugins/amp-proof/index.js +├── .amp/plugins/amp-proof/skills/review/SKILL.md +├── INSTALL.md +├── agent-bundle.compile-evidence.json +└── agent-bundle.manifest.json +``` + +The manifest recorded `manifestVersion: 4`, +`builtInHost: "amp"`, `documents.entry: ".amp/plugins/amp-proof/index.js"`, +adapter revision `1.0.0`, and the pinned PluginAPI version. The compile evidence used +`closed-world-externals` revision 1 and recorded no compiled assets for the content-only plugin. + +## PluginAPI type and fake-host proof + +Two generated entries were checked with TypeScript 7.0.2 in strict `checkJs` mode: + +1. the content-only factory with one explicit `registerSkill`; +2. a callback factory containing inline handlers for `session.start`, `tool.call`, `tool.result`, + `agent.start`, and `agent.end`. + +The disposable `tsconfig.json` alone used `skipLibCheck: true`, because the pinned upstream +declaration references `Symbol.observable`, which the selected TypeScript libs do not declare. +No repository compiler setting was weakened. + +Both entries passed. Neither has a runtime `@ampcode/plugin` import; its only reference is the +JSDoc type on the default async factory. A typed fake `PluginAPI` then imported the factories and +asserted: + +- exactly `registerSkill({ path: "skills/review" })`; +- no event registrations for the content-only entry; +- exactly the five documented event registrations for the callback entry; +- `tool.call` native results preserved as `reject-and-continue`, `modify`, and `synthesize`; +- `tool.result` replacement preserved as `{ status: "done", output: "replaced" }`. + +The repository adapter tests additionally cover `allow`, `agent.start` appended context, +`agent.end` continuation, generated wrapper execution, MCP/frontmatter precedence, relocation, +and receipt-owned install/replace/uninstall. + +## Account-free CLI observations + +- `amp --help`: exit 0; lists `plugins`, `skill`, and `mcp`. +- `amp skill add --help`: exit 0; source is `owner/repo[/path]`, a Git URL, or a local path; + `--global` targets `~/.config/agents/skills/`. +- `amp plugins list`: exit 1: + + ```text + Error: failed to load global plugins. Unable to connect to https://ampcode.com/. + ``` + +- `amp skills list --json`: started the login flow with `No API key found. Starting login flow...` + and was terminated rather than opening or completing an account login. + +Amp documents `plugins: reload` only as an interactive command-palette action. No standalone +plugin validator exists, so the implementation does not invent or automate either operation. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index a7a5d029b..866641949 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1928,10 +1928,10 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | -| `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .` — the exhaustive host switch received a host that is not `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.` — evaluating the configuration module or discovering source threw before validation. | Install: pass `--host claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | -| `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 3` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, digest, bytes, or executable state is invalid after installation (a declared package bin must remain executable; a file the manifest does not declare executable must remain non-executable; another manifest executable may have lost its bit while being packed from a filesystem without executable modes); a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. `install` restores manifest modes before copying an npm-installed artifact into a host, while Doctor only compares. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | +| `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .` — the exhaustive host switch received a host that is not `amp`, `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.` — evaluating the configuration module or discovering source threw before validation. | Install: pass `--host amp`, `claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | +| `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 4` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has neither its required `documents.plugin` nor Amp `documents.entry`, or whose `documents.entry` / `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, digest, bytes, or executable state is invalid after installation (a declared package bin must remain executable; a file the manifest does not declare executable must remain non-executable; another manifest executable may have lost its bit while being packed from a filesystem without executable modes); a Cursor or Amp `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. `install` restores manifest modes before copying an npm-installed artifact into a host, while Doctor only compares. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | -| `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | +| `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; Amp `--scope local` instead of `project` or `user`; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use a documented host scope, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | ## Development server (`AB80xx`) diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 858a393dc..e93573f1f 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -1,6 +1,6 @@ # agent-bundle -Compile a typed Agent Bundle configuration into portable, Codex, Claude Code, and Cursor artifacts. Node.js 22.19 or later is required. +Compile a typed Agent Bundle configuration into Amp, portable, Codex, Claude Code, and Cursor artifacts. Node.js 22.19 or later is required. Full documentation: [scriptedalchemy.github.io/agent-bundle](https://scriptedalchemy.github.io/agent-bundle/). @@ -189,6 +189,7 @@ so their public CLIs can install the emitted directory directly: ```sh agent-bundle install claude --from artifact --scope user agent-bundle install codex --from artifact +agent-bundle install amp --from artifact --scope user ``` The installer delegates to `claude plugin marketplace add` / @@ -205,6 +206,11 @@ agent-bundle install cursor --from artifact node ./install.mjs ``` +Amp installs only `artifact/.amp/plugins//` into the project or system +plugin root. The generated `index.js` default-exports the async PluginAPI +factory, registers each bundled Skill once, and imports no Amp runtime. +Reloading remains the interactive Ctrl+O → `plugins: reload` action. + Cursor loads the copied `.cursor-plugin/plugin.json` and its manifest-declared `.cursor-plugin/hooks.json` from that directory; plugin hooks run from the plugin root with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` @@ -227,8 +233,9 @@ the shipped document in the install receipt (`cursorExpansion`), and `agent-bundle doctor --host cursor` proves the expansion (`AB7326`). The bundle itself stays spec-conformant for other Agent Plugins clients. -Cursor installation is user-scoped. Claude also accepts `--scope project` and -`--scope local`; Codex is user-scoped. `--from` names the composite root itself — +Cursor installation is user-scoped. Amp accepts project scope and user scope +(its system root). Claude also accepts `--scope project` and `--scope local`; +Codex is user-scoped. `--from` names the composite root itself — the directory that holds `agent-bundle.manifest.json` — and a source-free copy of that root is accepted. Identity (name, version, marketplace) and the host plugin document come from that manifest's `application` and `projections[]` @@ -305,7 +312,7 @@ Receipts (`agent-bundle-install-receipt/2`) are the single source of truth for an install's lifecycle: version, content hash, delivery mode, scope, owned files and directories, the host directories the installer created, the host registrations it performed in order, and install/update timestamps. Cursor -local copies carry the receipt in-tree; Claude, Codex, and Cursor +local and Amp project/system copies carry the receipt in-tree; Claude, Codex, and Cursor marketplace-mode installs keep theirs under `/agent-bundle/receipts/` (`~/.claude`, `~/.codex`, `~/.cursor`, honouring `CLAUDE_CONFIG_DIR` and `CODEX_HOME`). `agent-bundle uninstall --from `, the package diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 4ec9a976d..fdb556adc 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -1,7 +1,7 @@ { "name": "agent-bundle", "version": "0.1.0", - "description": "Compile a typed Agent Bundle configuration into portable, Codex, Claude Code, and Cursor artifacts.", + "description": "Compile a typed Agent Bundle configuration into Amp, portable, Codex, Claude Code, and Cursor artifacts.", "license": "Apache-2.0", "keywords": [ "agent", diff --git a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json index 3d5108489..d6f053c36 100644 --- a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json +++ b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://scriptedalchemy.github.io/agent-bundle/schemas/agent-bundle.manifest.schema.json", "title": "agent-bundle artifact manifest", - "description": "The authoritative `agent-bundle.manifest.json` written at the root of every compiled artifact (manifestVersion 3). The public contract and the operational `compiler` record are versioned independently (`compiler.recordVersion`). Keys are closed at every level. This schema mirrors the structural rules of `parseArtifactManifest`; the parser additionally enforces what JSON Schema cannot express: canonical `stableJson` bytes, sorted arrays with unique sort keys, cross-references between sections (hosts name declared projections, referenced paths name manifest files, `compiler.provenance` paths equal `files` paths, `compiler.adapters` hosts equal `projections` hosts, `compiler.project.revision` and `compiler.project.configDigest` match `compiler.project.sourceInputs`, each provenance `sourceInput` names a `compiler.project.sourceInputs` path, `compiler.validation.projections` mirror `projections`, server routes carry their server's id, rendered scripts name a script route, `inputSchema.required` names declared properties), the generated runtime floor for `runtime.node`, and npm package-name and semantic-version validity for `compiler.project.packageName` and `compiler.project.packageVersion`.", + "description": "The authoritative `agent-bundle.manifest.json` written at the root of every compiled artifact (manifestVersion 4). The public contract and the operational `compiler` record are versioned independently (`compiler.recordVersion`). Keys are closed at every level. This schema mirrors the structural rules of `parseArtifactManifest`; the parser additionally enforces what JSON Schema cannot express: canonical `stableJson` bytes, sorted arrays with unique sort keys, cross-references between sections (hosts name declared projections, referenced paths name manifest files, `compiler.provenance` paths equal `files` paths, `compiler.adapters` hosts equal `projections` hosts, `compiler.project.revision` and `compiler.project.configDigest` match `compiler.project.sourceInputs`, each provenance `sourceInput` names a `compiler.project.sourceInputs` path, `compiler.validation.projections` mirror `projections`, server routes carry their server's id, rendered scripts name a script route, `inputSchema.required` names declared properties), the generated runtime floor for `runtime.node`, and npm package-name and semantic-version validity for `compiler.project.packageName` and `compiler.project.packageVersion`.", "type": "object", "additionalProperties": false, "properties": { @@ -27,7 +27,7 @@ "uniqueItems": true }, "manifestVersion": { - "const": 3 + "const": 4 }, "projections": { "type": "array", @@ -372,9 +372,12 @@ }, "projectionDocuments": { "type": "object", - "description": "Root-relative pointers to the host documents the projection derived; each names a manifest file (parser-enforced).", + "description": "Root-relative pointers to the host documents or executable entry the projection derived; each names a manifest file (parser-enforced).", "additionalProperties": false, "properties": { + "entry": { + "$ref": "#/$defs/relativePath" + }, "hooks": { "$ref": "#/$defs/relativePath" }, @@ -408,6 +411,7 @@ "builtInHost": { "type": "string", "enum": [ + "amp", "claude", "codex", "cursor", diff --git a/packages/agent-bundle/src/adapters/amp-mcp.ts b/packages/agent-bundle/src/adapters/amp-mcp.ts new file mode 100644 index 000000000..767e2dff6 --- /dev/null +++ b/packages/agent-bundle/src/adapters/amp-mcp.ts @@ -0,0 +1,59 @@ +import { isPlainDataRecord } from '../core/strict-json.ts'; + +export interface AmpMcpDocumentIssue { + readonly path: string; + readonly message: string; +} + +const fields = new Set(['args', 'command', 'env', 'headers', 'includeTools', 'url']); + +const issue = (path: string, message: string): AmpMcpDocumentIssue => + Object.freeze({ message, path }); + +const stringRecord = (value: unknown): boolean => + isPlainDataRecord(value) && Object.values(value).every((entry) => typeof entry === 'string'); + +const stringArray = (value: unknown): boolean => + Array.isArray(value) && value.every((entry) => typeof entry === 'string'); + +/** Validates Amp's flat skill-scoped MCP map from the pinned 2026-09-07 documentation. */ +export const ampMcpDocumentIssues = (value: unknown): readonly AmpMcpDocumentIssue[] => { + if (!isPlainDataRecord(value)) return Object.freeze([issue('', 'must be a flat server map')]); + const issues: AmpMcpDocumentIssue[] = []; + for (const [name, candidate] of Object.entries(value)) { + const path = name; + if (name.trim() === '') { + issues.push(issue('', 'server names must be nonempty')); + continue; + } + if (!isPlainDataRecord(candidate)) { + issues.push(issue(path, 'must be an object')); + continue; + } + for (const key of Object.keys(candidate)) { + if (!fields.has(key)) issues.push(issue(`${path}.${key}`, 'is not a documented Amp skill MCP field')); + } + const local = typeof candidate.command === 'string' && candidate.command.trim() !== ''; + const remote = typeof candidate.url === 'string' && candidate.url.trim() !== ''; + if (local === remote) issues.push(issue(path, 'must declare exactly one nonempty command or url')); + if (candidate.args !== undefined && !stringArray(candidate.args)) { + issues.push(issue(`${path}.args`, 'must be an array of strings')); + } + if (candidate.includeTools !== undefined && !stringArray(candidate.includeTools)) { + issues.push(issue(`${path}.includeTools`, 'must be an array of strings')); + } + if (candidate.env !== undefined && !stringRecord(candidate.env)) { + issues.push(issue(`${path}.env`, 'must map names to strings')); + } + if (candidate.headers !== undefined && !stringRecord(candidate.headers)) { + issues.push(issue(`${path}.headers`, 'must map names to strings')); + } + if (local && candidate.headers !== undefined) { + issues.push(issue(`${path}.headers`, 'is valid only for a url server')); + } + if (remote && (candidate.args !== undefined || candidate.env !== undefined)) { + issues.push(issue(path, 'url servers cannot declare args or env')); + } + } + return Object.freeze(issues); +}; diff --git a/packages/agent-bundle/src/adapters/amp.ts b/packages/agent-bundle/src/adapters/amp.ts new file mode 100644 index 000000000..fdbd75827 --- /dev/null +++ b/packages/agent-bundle/src/adapters/amp.ts @@ -0,0 +1,579 @@ +import { posix } from 'node:path'; + +import capabilityTable from './capabilities/amp-0.0.0-20260907001852-gf348fed.json' with { type: 'json' }; +import { + capabilityEvidence, + eventRouteCapabilitiesFrom, + supportedCapability, + unavailableCapability, +} from './capability-state.ts'; +import { createTargetDiagnostics } from './diagnostics.ts'; +import { + planHooks, + type TargetHookContract, + type TargetHookWrapper, +} from './hook-contract.ts'; +import { + hasPathToken, + sortedEntries, + sourceInputs, + type TargetAdapter, + type TargetArtifactEntry, + type TargetArtifactPlan, +} from './types.ts'; +import { operatorEnvLayerImport } from '../build/launch-env-shell.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import { isPortablePathSegment } from '../core/paths.ts'; +import { stableJson } from '../core/digest.ts'; +import { isPlainDataRecord } from '../core/strict-json.ts'; +import { + type NormalizedHook, + type NormalizedMcpServer, + type NormalizedPlugin, +} from '../core/types.ts'; + +const ampName = 'amp'; +const metadata = Object.freeze({ + adapterRevision: '1.0.0', + observedVersion: capabilityTable.pluginApi.version, + schemas: Object.freeze([]), +}); +const evidence = capabilityEvidence(ampName, metadata); +const { errorDiagnostic } = createTargetDiagnostics(ampName, 'Amp'); + +const ampPluginRoot = (plugin: string): string => `.amp/plugins/${plugin}`; + +const reservedFactoryNames = new Set([ + 'arguments', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', + 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'eval', 'export', + 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', 'import', + 'in', 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', + 'protected', 'public', 'return', 'static', 'super', 'switch', 'this', 'throw', + 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', +]); + +const factoryName = (name: string): string => { + const identifier = name.replace( + /[^A-Za-z\d_$]+([A-Za-z\d_$])?/gu, + (_, character: string | undefined) => character?.toUpperCase() ?? '', + ); + return /^[A-Za-z_$]/u.test(identifier) && !reservedFactoryNames.has(identifier) + ? identifier + : `plugin${identifier[0]?.toUpperCase() ?? ''}${identifier.slice(1)}`; +}; + +const mcpServerPlan = ( + server: NormalizedMcpServer, +): { readonly diagnostics: readonly Diagnostic[]; readonly value?: Record } => { + const diagnostics: Diagnostic[] = []; + if (server.source !== undefined) { + diagnostics.push(errorDiagnostic( + 'amp.mcp.generated-local', + `Amp skill MCP server ${JSON.stringify(server.name)} is compiler-owned, but the pinned skill MCP contract ` + + 'documents no plugin-root placeholder or execution cwd for a relocatable generated entry.', + )); + return { diagnostics }; + } + const values = server.transport === 'streamable-http' + ? [ + ['url', server.url], + ...Object.entries(server.headers ?? {}).map(([name, value]) => [`headers.${name}`, value] as const), + ] as const + : [ + ['command', server.command], + ...(server.args ?? []).map((value, index) => [`args[${index}]`, value] as const), + ...Object.entries(server.env ?? {}).map(([name, value]) => [`env.${name}`, value] as const), + ] as const; + for (const [location, value] of values) { + if (value !== undefined && hasPathToken(value)) { + diagnostics.push(errorDiagnostic( + `amp.mcp.path-token.${location.replaceAll(/[^a-z\d]+/giu, '-').toLowerCase()}`, + `Amp skill MCP server ${JSON.stringify(server.name)} ${location} uses an Agent Bundle path token, ` + + 'but the pinned Amp contract documents no plugin-root token.', + )); + } + } + if (server.transport === 'streamable-http') { + if (diagnostics.length > 0) return { diagnostics }; + return { + diagnostics, + value: { + ...(server.headers === undefined ? {} : { headers: server.headers }), + url: server.url, + }, + }; + } + if (server.cwd !== undefined) { + diagnostics.push(errorDiagnostic( + 'amp.mcp.cwd', + `Amp skill MCP server ${JSON.stringify(server.name)} declares cwd, which the pinned skill MCP document does not support.`, + )); + } + if ( + typeof server.command !== 'string' + || server.command.trim() === '' + || server.command.includes('/') + || server.command.includes('\\') + ) { + diagnostics.push(errorDiagnostic( + 'amp.mcp.command', + `Amp skill MCP server ${JSON.stringify(server.name)} must use a nonempty globally resolvable command, not a filesystem path.`, + )); + } + if (diagnostics.length > 0) return { diagnostics }; + return { + diagnostics, + value: { + ...(server.args === undefined ? {} : { args: server.args }), + command: server.command, + ...(server.env === undefined ? {} : { env: server.env }), + }, + }; +}; + +const encodeAmpPlaygroundInput = ( + input: Readonly>, + nativeEvent: string, +): Readonly> => { + const base = { + hook_event_name: nativeEvent, + session_id: input.sessionId, + }; + if (nativeEvent === 'tool.call') { + return deepFreeze({ + ...base, + tool_input: input.toolInput, + tool_name: input.toolName, + tool_use_id: input.toolUseId, + }); + } + return deepFreeze(base); +}; + +const encodeAmpPlaygroundOutput = ( + result: Readonly> | undefined, + canonicalEvent: string, +): Readonly> | undefined => { + if (result === undefined || canonicalEvent !== 'beforeTool') return undefined; + if (result.outcome === 'deny') { + return { + action: 'reject-and-continue', + message: result.reason, + }; + } + return isPlainDataRecord(result.updatedInput) + ? { action: 'modify', input: result.updatedInput } + : undefined; +}; + +const ampHookWrapperSource = (entry: TargetHookWrapper): string => [ + operatorEnvLayerImport, + `import * as handlerModule from ${JSON.stringify(entry.hook.source)};`, + '', + 'const target = "amp";', + `const canonicalEvent = ${JSON.stringify(entry.event)};`, + `const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`, + 'const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);', + 'const fail = (message) => { throw new Error(`Agent Bundle hook error: ${message}`); };', + 'const validateInput = (input) => {', + ' if (!isRecord(input)) fail("stdin JSON value must be an object");', + ' if (input.hook_event_name !== nativeEvent) fail(`native hook_event_name must equal ${nativeEvent}`);', + ' if (typeof input.session_id !== "string") fail("native session_id must be a string");', + ' if (canonicalEvent === "beforeTool") {', + ' if (typeof input.tool_name !== "string") fail("native tool_name must be a string");', + ' if (!isRecord(input.tool_input)) fail("native tool_input must be an object");', + ' if (typeof input.tool_use_id !== "string") fail("native tool_use_id must be a string");', + ' }', + '};', + 'const decodeInput = (input) => canonicalEvent === "beforeTool"', + ' ? { sessionId: input.session_id, toolInput: input.tool_input, toolName: input.tool_name, toolUseId: input.tool_use_id }', + ' : { sessionId: input.session_id };', + 'const validateResult = (result) => {', + ' if (result === undefined) return undefined;', + ' if (!isRecord(result)) fail("handler must return void or a result object");', + ' const allowed = new Set(["outcome", "reason", "updatedInput", "additionalContext"]);', + ' for (const key of Object.keys(result)) if (!allowed.has(key)) fail(`handler result has unsupported field ${key}`);', + ' if (result.outcome !== undefined && result.outcome !== "continue" && result.outcome !== "deny") fail("handler result outcome is invalid");', + ' if (result.reason !== undefined && typeof result.reason !== "string") fail("handler result reason must be a string");', + ' if (result.updatedInput !== undefined && !isRecord(result.updatedInput)) fail("handler result updatedInput must be an object");', + ' if (result.additionalContext !== undefined) fail(`${canonicalEvent} has no Amp additional-context channel as a plain hook`);', + ' if (canonicalEvent === "sessionStart" && (result.outcome === "deny" || result.reason !== undefined || result.updatedInput !== undefined)) fail("sessionStart is observation-only on Amp");', + ' if (canonicalEvent === "beforeTool" && result.outcome === "deny" && (typeof result.reason !== "string" || result.reason.trim() === "")) fail("denied beforeTool hook requires a nonempty reason");', + ' if (canonicalEvent === "beforeTool" && result.outcome === "deny" && result.updatedInput !== undefined) fail("beforeTool cannot replace input while denying");', + ' return result;', + '};', + 'const encodeOutput = (result) => {', + ' if (result === undefined || canonicalEvent === "sessionStart") return undefined;', + ' if (result.outcome === "deny") return { action: "reject-and-continue", message: result.reason };', + ' return result.updatedInput === undefined ? undefined : { action: "modify", input: result.updatedInput };', + '};', + 'const decodeOutput = (output) => {', + ' if (output?.action === "reject-and-continue") return { outcome: "deny", reason: output.message };', + ' if (output?.action === "modify") return { outcome: "continue", updatedInput: output.input };', + ' return undefined;', + '};', + 'const run = async () => {', + ' const handler = Reflect.get(handlerModule, "default");', + ' if (typeof handler !== "function") fail("default export must be a function");', + ' let raw = "";', + ' for await (const chunk of process.stdin) raw += chunk;', + ' if (raw.trim() === "") fail("stdin must contain exactly one JSON value");', + ' let input;', + ' try { input = JSON.parse(raw); } catch { fail("stdin must contain exactly one JSON value"); }', + ' const simulation = process.env.AGENT_BUNDLE_HOOK_SIMULATION === "1";', + ' const nativeInput = simulation', + ' ? canonicalEvent === "beforeTool"', + ' ? { hook_event_name: nativeEvent, session_id: input.sessionId, tool_input: input.toolInput, tool_name: input.toolName, tool_use_id: input.toolUseId }', + ' : { hook_event_name: nativeEvent, session_id: input.sessionId }', + ' : input;', + ' validateInput(nativeInput);', + ' const result = validateResult(await handler(decodeInput(nativeInput), { nativeEvent, nativeInput, target }));', + ' const nativeOutput = encodeOutput(result);', + ' const output = simulation ? decodeOutput(nativeOutput) : nativeOutput;', + ' if (output !== undefined) process.stdout.write(JSON.stringify(output));', + '};', + 'if (import.meta.main) {', + ' await run().catch((error) => {', + ' process.stderr.write(`${error instanceof Error ? error.message : String(error)}\\n`);', + ' process.exitCode = 1;', + ' });', + '}', + '', +].join('\n'); + +const hookContract = Object.freeze({ + hostContractRevision: capabilityTable.pluginApi.version, + commandRoot: '', + encodePlaygroundInput: encodeAmpPlaygroundInput, + encodePlaygroundOutput: encodeAmpPlaygroundOutput, + eventNames: { + beforeTool: 'tool.call', + sessionStart: 'session.start', + }, + eventRouteNames: { + 'prompt/submit': 'agent.start', + 'session/start': 'session.start', + stop: 'agent.end', + 'tool/after': 'tool.result', + 'tool/before': 'tool.call', + }, + manifestPath: '.amp/hooks.json', + matchers: {}, + registration: 'api', + wrapperPath: (hook) => `hooks/${hook.name}.mjs`, + wrapperSource: ampHookWrapperSource, +} satisfies TargetHookContract); + +interface FactoryHook { + readonly matcher?: string; + readonly path: string; +} + +const factorySource = ( + model: NormalizedPlugin, + root: string, + hooks: readonly TargetHookWrapper[], + skillNames: readonly string[], +): string => { + const grouped: Record = Object.create(null) as Record; + for (const hook of hooks) { + (grouped[hook.nativeEvent] ??= []).push({ + ...(hook.nativeMatcher === undefined ? {} : { matcher: hook.nativeMatcher }), + path: `./${posix.relative(root, hook.relativePath)}`, + }); + } + const hookTable = Object.fromEntries( + ['session.start', 'tool.call', 'tool.result', 'agent.start', 'agent.end'] + .flatMap((event) => grouped[event] === undefined ? [] : [[event, grouped[event]]]), + ); + const description = (model.metadata.description ?? model.metadata.name).slice(0, 300); + const registrations = skillNames.map((name) => + ` await amp.registerSkill({ path: 'skills/${name}' });`); + const handlers: string[] = []; + if (grouped['session.start'] !== undefined) { + handlers.push( + " amp.on('session.start', async (event) => {", + " const native = { hook_event_name: 'session.start', session_id: event.thread.id };", + ' for (const hook of hooks["session.start"]) {', + ' const output = await runHook(hook.path, native);', + ' if (output !== undefined) throw new Error("Amp session.start hooks cannot return a result.");', + ' }', + ' });', + ); + } + if (grouped['tool.call'] !== undefined) { + handlers.push( + " amp.on('tool.call', async (event) => {", + ' let input = event.input;', + ' let modified = false;', + ' for (const hook of hooks["tool.call"]) {', + ' if (hook.matcher !== undefined && !(new RegExp(hook.matcher, "u")).test(event.tool)) continue;', + " const output = await runHook(hook.path, { hook_event_name: 'tool.call', session_id: event.thread.id, tool_input: input, tool_name: event.tool, tool_use_id: event.toolUseID });", + ' if (output === undefined || output.action === "allow") continue;', + ' if (output.action === "modify" && isRecord(output.input)) { input = output.input; modified = true; continue; }', + ' if (output.action === "reject-and-continue" && typeof output.message === "string") return { action: "reject-and-continue", message: output.message };', + ' if (output.action === "synthesize" && isRecord(output.result) && typeof output.result.output === "string" && (output.result.exitCode === undefined || Number.isInteger(output.result.exitCode))) {', + ' return { action: "synthesize", result: { output: output.result.output, ...(output.result.exitCode === undefined ? {} : { exitCode: output.result.exitCode }) } };', + ' }', + ' if (output.action === "error" && typeof output.message === "string") return { action: "error", message: output.message };', + ' throw new Error("Amp tool.call hook returned an invalid result.");', + ' }', + ' return modified ? { action: "modify", input } : { action: "allow" };', + ' });', + ); + } + if (grouped['tool.result'] !== undefined) { + handlers.push( + " amp.on('tool.result', async (event) => {", + ' let current = { status: event.status, ...(event.error === undefined ? {} : { error: event.error }), ...(event.output === undefined ? {} : { output: event.output }) };', + ' let replaced = false;', + ' for (const hook of hooks["tool.result"]) {', + ' if (hook.matcher !== undefined && !(new RegExp(hook.matcher, "u")).test(event.tool)) continue;', + " const output = await runHook(hook.path, { hook_event_name: 'tool.result', session_id: event.thread.id, status: current.status, tool_error: current.error, tool_input: event.input, tool_name: event.tool, tool_response: current.output, tool_use_id: event.toolUseID });", + ' if (output === undefined) continue;', + ' if (!["done", "error", "cancelled"].includes(String(output.status))) throw new Error("Amp tool.result hook returned an invalid status.");', + ' if (output.error !== undefined && typeof output.error !== "string") throw new Error("Amp tool.result hook returned an invalid error.");', + ' current = { status: output.status, ...(output.error === undefined ? {} : { error: output.error }), ...(output.output === undefined ? {} : { output: output.output }) };', + ' replaced = true;', + ' }', + ' if (!replaced) return;', + ' if (current.status === "done") return { status: "done", ...(current.output === undefined ? {} : { output: current.output }) };', + ' if (current.status === "error") return { status: "error", ...(current.error === undefined ? {} : { error: current.error }), ...(current.output === undefined ? {} : { output: current.output }) };', + ' return { status: "cancelled", ...(current.error === undefined ? {} : { error: current.error }), ...(current.output === undefined ? {} : { output: current.output }) };', + ' });', + ); + } + if (grouped['agent.start'] !== undefined) { + handlers.push( + " amp.on('agent.start', async (event) => {", + ' const messages = [];', + ' for (const hook of hooks["agent.start"]) {', + " const output = await runHook(hook.path, { hook_event_name: 'agent.start', prompt: event.message, prompt_id: event.id, session_id: event.thread.id });", + ' if (output === undefined) continue;', + ' if (!isRecord(output.message) || typeof output.message.content !== "string" || (output.message.display !== undefined && typeof output.message.display !== "boolean")) throw new Error("Amp agent.start hook returned an invalid message.");', + ' messages.push(output.message);', + ' }', + ' if (messages.length === 0) return {};', + ' return { message: { content: messages.map((message) => message.content).join("\\n"), ...(messages.some((message) => message.display === true) ? { display: true } : {}) } };', + ' });', + ); + } + if (grouped['agent.end'] !== undefined) { + handlers.push( + " amp.on('agent.end', async (event) => {", + ' for (const hook of hooks["agent.end"]) {', + " const output = await runHook(hook.path, { hook_event_name: 'agent.end', prompt: event.message, prompt_id: event.id, session_id: event.thread.id, status: event.status });", + ' if (output === undefined) continue;', + ' if (output.action === "continue" && typeof output.userMessage === "string") return { action: "continue", userMessage: output.userMessage };', + ' throw new Error("Amp agent.end hook returned an invalid result.");', + ' }', + ' });', + ); + } + const runtime = Object.keys(hookTable).length === 0 ? [] : [ + '/** @type {Readonly>} */', + `const hooks = Object.freeze(${JSON.stringify(hookTable)});`, + '/** @param {unknown} value */', + 'const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);', + '/** @param {string} relativePath */', + 'const filePath = (relativePath) => {', + ' const pathname = decodeURIComponent(new URL(relativePath, import.meta.url).pathname);', + ' return process.platform === "win32" ? pathname.slice(1).replaceAll("/", "\\\\") : pathname;', + '};', + '/** @param {string} relativePath @param {Record} input */', + 'const runHook = async (relativePath, input) => {', + ' const child = Bun.spawn([process.execPath, filePath(relativePath)], { env: { ...process.env, BUN_BE_BUN: "1" }, stderr: "pipe", stdin: "pipe", stdout: "pipe" });', + ' const stdout = new Response(child.stdout).text();', + ' const stderr = new Response(child.stderr).text();', + ' child.stdin.write(JSON.stringify(input));', + ' child.stdin.end();', + ' const [code, output, error] = await Promise.all([child.exited, stdout, stderr]);', + ' if (code !== 0) throw new Error(error.trim() || `Amp hook process exited with code ${String(code)}.`);', + ' if (error !== "") process.stderr.write(error);', + ' if (output === "") return undefined;', + ' const parsed = JSON.parse(output);', + ' if (!isRecord(parsed)) throw new Error("Amp hook process returned a non-object result.");', + ' return parsed;', + '};', + '', + ]; + return [ + `export const description = ${JSON.stringify(description)};`, + ...runtime, + `/** @param {import('@ampcode/plugin').PluginAPI} amp */`, + `export default async function ${factoryName(model.metadata.name)}(amp) {`, + ...registrations, + ...handlers, + '}', + '', + ].join('\n'); +}; + +const plan = (model: NormalizedPlugin): TargetArtifactPlan => { + const diagnostics = []; + if (!isPortablePathSegment(model.metadata.name)) { + diagnostics.push(errorDiagnostic( + 'amp.name', + `Amp directory plugin name ${JSON.stringify(model.metadata.name)} must be a portable path segment.`, + )); + } + const root = ampPluginRoot(model.metadata.name); + const selected = (targets: readonly string[]): boolean => targets.includes(ampName); + const skills = model.skills.filter((skill) => selected(skill.targets)); + const servers: Record> = Object.create(null) as Record>; + for (const server of model.mcpServers) { + if (!selected(server.targets)) continue; + const planned = mcpServerPlan(server); + diagnostics.push(...planned.diagnostics); + if (planned.value !== undefined) servers[server.name] = planned.value; + } + const hasMcp = Object.keys(servers).length > 0; + if (hasMcp && skills.length !== 1) { + diagnostics.push(errorDiagnostic( + 'amp.mcp.skill-scope', + `Amp MCP is skill-scoped, so a canonical server requires exactly one Amp skill; this projection has ${skills.length}.`, + )); + } + for (const hook of model.hooks) { + if (!selected(hook.targets)) continue; + if (hook.prebuiltPath !== undefined) { + diagnostics.push(errorDiagnostic( + 'amp.hook.prebuilt', + `Amp hook ${JSON.stringify(hook.name)} is prebuilt, but a directory plugin registers callbacks from its generated factory rather than a native hook command document.`, + )); + } + if (hook.timeoutMs !== undefined) { + diagnostics.push(errorDiagnostic( + 'amp.hook.timeout', + `Amp hook ${JSON.stringify(hook.name)} declares a timeout, but PluginAPI event registration exposes no per-handler timeout.`, + )); + } + } + + const planContract: TargetHookContract = Object.freeze({ + ...hookContract, + wrapperPath: (hook: NormalizedHook) => `${root}/hooks/${hook.name}.mjs`, + }); + const generatedHooks = planHooks(model, ampName, planContract); + diagnostics.push(...generatedHooks.diagnostics); + + const entries: TargetArtifactEntry[] = []; + for (const skill of skills) { + const prefix = `${root}/skills/${skill.name}`; + const hostDocument = skill.hostDocuments?.[ampName]; + const generated = hostDocument !== undefined && !hostDocument.passThrough; + if (generated) { + entries.push({ + content: hostDocument.skillMarkdown, + kind: 'write', + relativePath: `${prefix}/SKILL.md`, + sourceInputs: sourceInputs(skill.source), + }); + } else if (skill.markdown !== undefined) { + entries.push({ + content: skill.markdown, + kind: 'write', + relativePath: `${prefix}/SKILL.md`, + sourceInputs: sourceInputs(skill.source), + }); + } + const generatedSidecars = new Set(generated + ? hostDocument.sidecars.map((sidecar) => sidecar.relativePath) + : []); + for (const sidecar of generated ? hostDocument.sidecars : []) { + if (sidecar.content === undefined) continue; + entries.push({ + content: sidecar.content.endsWith('\n') ? sidecar.content : `${sidecar.content}\n`, + kind: 'write', + relativePath: `${prefix}/${sidecar.relativePath}`, + sourceInputs: sourceInputs(skill.source, sidecar.source), + }); + } + const nativeMcp = isPlainDataRecord(hostDocument?.frontmatter.mcpServers); + const mcpResource = skill.resources.find((resource) => resource.relativePath === 'mcp.json'); + if (hasMcp && (nativeMcp || mcpResource !== undefined)) { + diagnostics.push(errorDiagnostic( + 'amp.mcp.precedence', + `Amp skill ${JSON.stringify(skill.name)} already declares ${nativeMcp ? 'mcpServers frontmatter' : 'mcp.json'}; ` + + 'that native source takes precedence over the canonical MCP servers, so the generated document is omitted.', + )); + } + if (hasMcp && skills.length === 1 && !nativeMcp && mcpResource === undefined) { + entries.push({ + content: `${stableJson(servers)}\n`, + kind: 'write', + relativePath: `${prefix}/mcp.json`, + sourceInputs: sourceInputs(...model.mcpServers + .filter((server) => selected(server.targets)) + .map((server) => server.provenance.sourcePath)), + }); + } + const skipped = new Set(generated ? ['SKILL.md', ...generatedSidecars] : []); + for (const resource of skill.resources) { + if (skipped.has(resource.relativePath)) continue; + entries.push({ + bytes: resource.bytes, + kind: 'copy', + relativePath: `${prefix}/${resource.relativePath}`, + source: resource.source, + sourceInputs: sourceInputs(skill.source, resource.source), + }); + } + } + entries.push({ + content: factorySource(model, root, generatedHooks.hookEntries, skills.map((skill) => skill.name)), + kind: 'write', + relativePath: `${root}/index.js`, + sourceInputs: sourceInputs( + model.metadata.provenance.sourcePath, + ...skills.map((skill) => skill.source), + ...model.hooks.filter((hook) => selected(hook.targets)).map((hook) => hook.provenance.sourcePath), + ), + }); + return deepFreeze({ + diagnostics, + documents: { entry: `${root}/index.js` }, + entries: sortedEntries(entries), + hookEntries: generatedHooks.hookEntries, + }); +}; + +export const ampAdapter: TargetAdapter = Object.freeze({ + artifactLayout: Object.freeze({ + rootDirectories: Object.freeze(['.amp']), + rootDocuments: Object.freeze(['INSTALL.md']), + skills: '.amp/plugins/{plugin}/skills', + }), + capabilities: Object.freeze({ + ...eventRouteCapabilitiesFrom(capabilityTable.eventRoutes, evidence), + 'events.sessionStart.context': unavailableCapability('Amp session.start is fire-and-forget and has no context result channel.'), + 'events.toolAfter.context': unavailableCapability('Amp tool.result can replace the result but has no separate additional-context channel.'), + commands: unavailableCapability(capabilityTable.plugin.commands.reason), + hooks: supportedCapability(evidence), + 'hooks.timeout': unavailableCapability('PluginAPI event handlers expose no per-handler timeout setting.'), + 'hooks.toolMatchers': Object.freeze({ + evidence, + reason: 'Amp publishes no canonical built-in tool-name table; only explicit amp: selectors are lowered.', + state: 'degraded', + }), + install: supportedCapability(evidence), + lsp: unavailableCapability(capabilityTable.plugin.lsp.reason), + marketplace: unavailableCapability('Amp project/system directory plugins do not use a marketplace document.'), + mcp: supportedCapability(evidence), + mcpLegacySse: unavailableCapability('The canonical Agent Bundle MCP transport set does not emit legacy SSE.'), + nativeDiagnostics: unavailableCapability(capabilityTable.plugin.nativeDiagnostics.reason), + nativeExtension: unavailableCapability(capabilityTable.plugin.nativeExtension.reason), + rules: unavailableCapability(capabilityTable.plugin.rules.reason), + skills: supportedCapability(evidence), + 'skills.builtinTools': supportedCapability(evidence), + 'skills.hostFrontmatter': supportedCapability(evidence), + 'skills.markdownTokens': unavailableCapability('Amp documents no Skill Markdown interpolation syntax.'), + 'skills.mcpServers': supportedCapability(evidence), + }), + hookContract, + mcpScope: 'skill', + metadata, + name: ampName, + plan, +}); diff --git a/packages/agent-bundle/src/adapters/capabilities/amp-0.0.0-20260907001852-gf348fed.json b/packages/agent-bundle/src/adapters/capabilities/amp-0.0.0-20260907001852-gf348fed.json new file mode 100644 index 000000000..062e26f83 --- /dev/null +++ b/packages/agent-bundle/src/adapters/capabilities/amp-0.0.0-20260907001852-gf348fed.json @@ -0,0 +1,296 @@ +{ + "host": "amp", + "observedCliVersion": "0.0.1788739286-gf348fe", + "pluginApi": { + "package": "@ampcode/plugin", + "version": "0.0.0-20260907001852-gf348fed", + "source": "https://ampcode.com/docs/plugin-api", + "retrieved": "2026-09-07" + }, + "plugins": { + "entry": "index.js", + "locations": { + "project": ".amp/plugins/", + "system": "$XDG_CONFIG_HOME/amp/plugins/ or ~/.config/amp/plugins/" + }, + "precedence": [ + "project", + "system", + "personal", + "workspace" + ], + "evidence": [ + "retrieved 2026-09-07: https://ampcode.com/docs/customize/plugins documents directory plugins as /index.ts or /index.js, with index.ts winning when both exist, and project/system/personal/workspace precedence.", + "retrieved 2026-09-07: every plugin entry default-exports a factory receiving PluginAPI; the optional description export is a static string literal no longer than 300 characters." + ] + }, + "install": { + "state": "supported", + "method": "copy", + "localRoot": ".amp/plugins/ or $XDG_CONFIG_HOME/amp/plugins/", + "scopes": [ + "project", + "user" + ], + "source": "https://ampcode.com/docs/customize/plugins", + "reason": "Agent Bundle copies only its generated Amp directory and records exact file ownership; plugins: reload remains an interactive operator action.", + "evidence": [ + "retrieved 2026-09-07: project plugins live in .amp/plugins and system plugins live under $XDG_CONFIG_HOME/amp/plugins or ~/.config/amp/plugins.", + "2026-09-07: install/replace/uninstall tests preserve neighboring Amp settings and unowned files and remove only receipt-owned plugin files." + ] + }, + "lifecycle": { + "activationState": { + "state": "degraded", + "observable": [ + "placed", + "registered" + ], + "unavailable": [ + "enabled", + "active" + ], + "reason": "The documented project/system directory is the registration; without an Amp account, enabled state and live activation remain unverified.", + "evidence": [ + "2026-09-07: amp plugins list with @ampcode/cli@0.0.1788739286-gf348fe returned an account-dependent connection error." + ] + }, + "uninstall": { + "state": "supported", + "reason": "The directory itself is the project/system registration; removing only receipt-owned files unregisters it on the next interactive plugin reload.", + "evidence": [ + "retrieved 2026-09-07: https://ampcode.com/docs/customize/plugins documents project/system directory discovery and the interactive plugins: reload action." + ] + }, + "keepData": { + "state": "supported", + "evidence": [ + "2026-09-07: receipt-owned uninstall leaves unowned runtime/configuration files in place." + ] + } + }, + "skills": { + "registration": { + "state": "supported", + "evidence": [ + "retrieved 2026-09-07: https://ampcode.com/docs/customize/plugins requires await amp.registerSkill({ path: 'skills/' }) for each bundled directory skill and does not scan plugins' skills directories automatically." + ] + }, + "builtinTools": { + "state": "supported", + "evidence": [ + "retrieved 2026-09-07: https://ampcode.com/docs/plugin-api documents builtin-tools as the gate for tools registered by the same plugin." + ] + }, + "mcpServers": { + "state": "supported", + "evidence": [ + "retrieved 2026-09-07: https://ampcode.com/docs/customize/skills documents mcpServers frontmatter taking precedence over sibling mcp.json." + ] + }, + "discoveryPrecedence": [ + "~/.config/agents/skills", + "~/.agents/skills", + "~/.config/amp/skills", + ".agents/skills", + ".claude/skills", + "~/.claude/skills", + "~/.claude/plugins/cache", + "amp.skills.path", + "built-in", + "personal repository", + "workspace repository" + ] + }, + "mcp": { + "stdio": true, + "streamableHttp": true, + "scope": "skill", + "config": "skills//mcp.json", + "frontmatter": "mcpServers", + "precedence": [ + "--mcp-config", + "workspace amp.mcpServers", + "user amp.mcpServers", + "skill" + ], + "pathTokens": { + "args": [], + "env": [] + }, + "tasks": { + "state": "unavailable", + "reason": "The pinned Amp skill MCP documentation does not state that the host issues MCP Tasks-augmented calls." + }, + "structuredContentValidation": { + "state": "unavailable", + "reason": "The pinned local skill MCP documentation does not state client-side structuredContent validation behavior." + }, + "evidence": [ + "retrieved 2026-09-07: https://ampcode.com/docs/customize/skills defines a flat server map with command/args/env or url/headers plus optional includeTools; no mcpServers wrapper is present.", + "retrieved 2026-09-07: skill frontmatter mcpServers wins over mcp.json, and CLI/direct configuration with the same server name wins over skill configuration." + ] + }, + "eventRoutes": { + "agent/idle": { + "state": "unavailable", + "reason": "PluginEventMap declares no idle event." + }, + "agent/start": { + "state": "unavailable", + "reason": "Amp agent.start is a user turn, not a subagent-start event." + }, + "agent/stop": { + "state": "unavailable", + "reason": "PluginEventMap declares no subagent-stop event." + }, + "compact/after": { + "state": "unavailable", + "reason": "PluginEventMap declares no compaction event." + }, + "compact/before": { + "state": "unavailable", + "reason": "PluginEventMap declares no compaction event." + }, + "config/change": { + "state": "unavailable", + "reason": "PluginEventMap declares no configuration-change event." + }, + "file/change": { + "state": "unavailable", + "reason": "PluginEventMap declares no file-change event." + }, + "model-switch/after": { + "state": "unavailable", + "reason": "PluginEventMap declares no model-switch event." + }, + "model-switch/before": { + "state": "unavailable", + "reason": "PluginEventMap declares no model-switch event." + }, + "permission/denied": { + "state": "unavailable", + "reason": "PluginEventMap declares no separate permission-denied event." + }, + "permission/request": { + "state": "unavailable", + "reason": "Permission control is part of tool.call; no separate permission event is declared." + }, + "prompt/submit": { + "state": "supported", + "nativeEvent": "agent.start", + "payload": { + "prompt": "prompt", + "sessionId": "session_id" + }, + "evidence": [ + "retrieved 2026-09-07: @ampcode/plugin PluginEventMap agent.start carries thread.id, message, and id and may return an appended message." + ] + }, + "session/end": { + "state": "unavailable", + "reason": "Amp explicitly documents that no session.end event exists." + }, + "session/start": { + "state": "supported", + "nativeEvent": "session.start", + "payload": { + "sessionId": "session_id" + }, + "evidence": [ + "retrieved 2026-09-07: @ampcode/plugin PluginEventMap session.start carries thread.id and is fire-and-forget." + ] + }, + "stop": { + "state": "supported", + "nativeEvent": "agent.end", + "payload": { + "sessionId": "session_id" + }, + "evidence": [ + "retrieved 2026-09-07: @ampcode/plugin agent.end carries the turn status and may return { action: 'continue', userMessage }; it is not observation-only." + ] + }, + "stop/failure": { + "state": "unavailable", + "reason": "PluginEventMap declares no separate failed-stop event." + }, + "task/complete": { + "state": "unavailable", + "reason": "PluginEventMap declares no task lifecycle event." + }, + "task/create": { + "state": "unavailable", + "reason": "PluginEventMap declares no task lifecycle event." + }, + "tool/after": { + "state": "supported", + "nativeEvent": "tool.result", + "payload": { + "sessionId": "session_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "toolResponse": "tool_response", + "toolUseId": "tool_use_id" + }, + "evidence": [ + "retrieved 2026-09-07: @ampcode/plugin tool.result carries done/error/cancelled status, input, output and error and may return a replacement result." + ] + }, + "tool/before": { + "state": "supported", + "nativeEvent": "tool.call", + "payload": { + "sessionId": "session_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "toolUseId": "tool_use_id" + }, + "evidence": [ + "retrieved 2026-09-07: @ampcode/plugin tool.call returns allow, reject-and-continue, modify, synthesize, or error." + ] + }, + "tool/failure": { + "state": "unavailable", + "reason": "Failures are a tool.result status, not a separate PluginEventMap event." + }, + "workspace/open": { + "state": "unavailable", + "reason": "PluginEventMap declares no workspace-open event." + } + }, + "plugin": { + "entry": ".amp/plugins//index.js", + "manifest": null, + "marketplace": null, + "commands": { + "state": "unavailable", + "reason": "Agent Bundle does not project command-palette commands from its canonical command documents." + }, + "rules": { + "state": "unavailable", + "reason": "Amp plugin discovery has no plugin-root rules component." + }, + "lsp": { + "state": "unavailable", + "reason": "The pinned PluginAPI declares no language-server registration surface." + }, + "nativeDiagnostics": { + "state": "unavailable", + "reason": "The pinned PluginAPI declares no native diagnostics-provider component." + }, + "nativeExtension": { + "state": "unavailable", + "reason": "The pinned PluginAPI declares no opaque native extension component." + } + }, + "runtimeProof": { + "state": "unverified", + "reason": "The owner has no Amp account. Type and fake-PluginAPI proofs cover the generated contract; live thread/model/tool execution is account-dependent." + }, + "tokens": { + "pluginData": false, + "pluginRoot": false, + "workspaceRoot": false + } +} diff --git a/packages/agent-bundle/src/adapters/composite-layout.ts b/packages/agent-bundle/src/adapters/composite-layout.ts index 54c3db497..046f440c3 100644 --- a/packages/agent-bundle/src/adapters/composite-layout.ts +++ b/packages/agent-bundle/src/adapters/composite-layout.ts @@ -31,10 +31,10 @@ */ /** The hosts Agent Bundle ships adapters for: the only targets that may share one composite root. */ -export type BuiltInHost = 'claude' | 'codex' | 'cursor' | 'portable'; +export type BuiltInHost = 'amp' | 'claude' | 'codex' | 'cursor' | 'portable'; /** The built-in hosts, in the fixed order the install surface documents them. */ -export const builtInHostNames: readonly BuiltInHost[] = Object.freeze(['claude', 'codex', 'cursor', 'portable']); +export const builtInHostNames: readonly BuiltInHost[] = Object.freeze(['amp', 'claude', 'codex', 'cursor', 'portable']); export const isBuiltInHost = (target: string): target is BuiltInHost => (builtInHostNames as readonly string[]).includes(target); diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 7ce358fcb..20421dd07 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -52,6 +52,8 @@ export interface TargetHookDocumentEntryInput { export interface TargetHookContract { readonly hostContractRevision?: string; readonly commandRoot: string; + /** `api` when the host entry registers callbacks directly instead of emitting a native hook manifest. */ + readonly registration?: 'api'; /** * Shapes one generated hook command into the host's per-event array entry. * Defaults to the Claude/Codex grouped shape; Cursor's document keeps flat diff --git a/packages/agent-bundle/src/adapters/hook-handler.ts b/packages/agent-bundle/src/adapters/hook-handler.ts index 6068b3ed9..31a1bf90d 100644 --- a/packages/agent-bundle/src/adapters/hook-handler.ts +++ b/packages/agent-bundle/src/adapters/hook-handler.ts @@ -218,7 +218,7 @@ export type HookEventFields = typeof hookEventFields; export interface HookHandlerContext { readonly nativeEvent: string; readonly nativeInput: Readonly>; - readonly target: 'claude' | 'codex' | 'cursor'; + readonly target: 'amp' | 'claude' | 'codex' | 'cursor'; } /** diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index c43e25290..273fbc2b6 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -15,6 +15,7 @@ import { noticeDeliveryAdvertisementFrom, type NoticeDeliveryCapabilityTableEntry, } from './capability-state.ts'; +import { ampAdapter } from './amp.ts'; import { claudeAdapter } from './claude.ts'; import { codexAdapter } from './codex.ts'; import type { BuiltInHost } from './composite-layout.ts'; @@ -22,6 +23,7 @@ import { cursorAdapter } from './cursor.ts'; import { readStandardNativeHookCommands, type TargetHookContract } from './hook-contract.ts'; import { portableAdapter } from './portable.ts'; import { + artifactLayoutPluginToken, routedCliBinLayout, type TargetAdapter, type TargetArtifactDocumentContract, @@ -131,6 +133,10 @@ const isSafeArtifactDocumentPath = (value: string): boolean => { const isSafeArtifactDirectory = (value: string): boolean => isSafeArtifactDocumentPath(value) && !value.includes('/'); +const isSafeSkillDirectory = (value: string): boolean => + value.split(artifactLayoutPluginToken).length <= 2 + && isSafeArtifactDocumentPath(value.replace(artifactLayoutPluginToken, 'plugin')); + const artifactSuffixPattern = /^\.[a-z\d]+$/u; const snapshotArtifactSuffixes = (value: unknown, field: string): readonly string[] => { @@ -171,13 +177,13 @@ const snapshotOutputLayout = (value: unknown, field: string): TargetArtifactOutp }); }; -const snapshotRootDocuments = (value: unknown): readonly string[] => { - const documents = dataArrayValues(value); - if (documents === undefined) throw new Error('Target adapter artifact layout root documents must be a data array.'); - return Object.freeze(documents.map((document) => { - const name = requireNonempty(document, 'artifact layout root document'); +const snapshotRootPaths = (value: unknown, field: string): readonly string[] => { + const paths = dataArrayValues(value); + if (paths === undefined) throw new Error(`Target adapter artifact layout ${field} must be a data array.`); + return Object.freeze(paths.map((path) => { + const name = requireNonempty(path, `artifact layout ${field}`); if (!isSafeArtifactDirectory(name)) { - throw new Error('Target adapter artifact layout root documents must be safe single-segment names.'); + throw new Error(`Target adapter artifact layout ${field} must contain safe single-segment names.`); } return name; })); @@ -245,7 +251,12 @@ const snapshotArtifactLayout = ( const workflows = layout.workflows === undefined ? undefined : requireNonempty(layout.workflows, 'artifact layout workflows namespace'); - const rootDocuments = layout.rootDocuments === undefined ? undefined : snapshotRootDocuments(layout.rootDocuments); + const rootDirectories = layout.rootDirectories === undefined + ? undefined + : snapshotRootPaths(layout.rootDirectories, 'root directories'); + const rootDocuments = layout.rootDocuments === undefined + ? undefined + : snapshotRootPaths(layout.rootDocuments, 'root documents'); if (assets !== undefined && !isSafeArtifactDirectory(assets)) { throw new Error('Target adapter artifact layout assets namespace must be a safe single namespace.'); @@ -253,8 +264,8 @@ const snapshotArtifactLayout = ( if (bin !== undefined && !isSafeArtifactDirectory(bin)) { throw new Error('Target adapter artifact layout bin namespace must be a safe single namespace.'); } - if (skills !== undefined && !isSafeArtifactDirectory(skills)) { - throw new Error('Target adapter artifact layout skills namespace must be a safe single namespace.'); + if (skills !== undefined && !isSafeSkillDirectory(skills)) { + throw new Error('Target adapter artifact layout skills namespace must be a safe path with at most one {plugin} segment.'); } if (workflows !== undefined && !isSafeArtifactDirectory(workflows)) { throw new Error('Target adapter artifact layout workflows namespace must be a safe single namespace.'); @@ -277,6 +288,7 @@ const snapshotArtifactLayout = ( ...(mcpApps === undefined ? {} : { mcpApps }), ...(mcpEntries === undefined ? {} : { mcpEntries }), ...(outputStyles === undefined ? {} : { outputStyles }), + ...(rootDirectories === undefined ? {} : { rootDirectories }), ...(rootDocuments === undefined ? {} : { rootDocuments }), ...(rules === undefined ? {} : { rules }), ...(scripts === undefined ? {} : { scripts }), @@ -402,9 +414,15 @@ const snapshotHookContract = (adapter: TargetAdapter): TargetHookContract | unde throw new Error(`Target adapter "${adapter.name}" declares a hook contract without hooks capability.`); } if (hookContract === undefined) return undefined; + if (hookContract.registration !== undefined && hookContract.registration !== 'api') { + throw new Error(`Target adapter "${adapter.name}" declares an invalid hook registration mode.`); + } return Object.freeze({ ...hookContract, eventNames: Object.freeze({ ...hookContract.eventNames }), + ...(hookContract.eventRouteNames === undefined + ? {} + : { eventRouteNames: Object.freeze({ ...hookContract.eventRouteNames }) }), matchers: Object.freeze({ ...hookContract.matchers }), readNativeCommands: hookContract.readNativeCommands ?? readStandardNativeHookCommands, }); @@ -412,12 +430,22 @@ const snapshotHookContract = (adapter: TargetAdapter): TargetHookContract | unde const snapshotMcpRuntime = (adapter: TargetAdapter): TargetMcpRuntimeContract | undefined => { const mcpRuntime = adapter.mcpRuntime; - if (capabilityIsSupported(adapter.capabilities.mcp) && mcpRuntime === undefined) { + const skillScoped = adapter.mcpScope === 'skill'; + if (adapter.mcpScope !== undefined && !skillScoped) { + throw new Error(`Target adapter "${adapter.name}" declares an invalid MCP scope.`); + } + if (capabilityIsSupported(adapter.capabilities.mcp) && mcpRuntime === undefined && !skillScoped) { throw new Error(`Target adapter "${adapter.name}" declares mcp capability without an MCP runtime contract.`); } if (!capabilityIsSupported(adapter.capabilities.mcp) && mcpRuntime !== undefined) { throw new Error(`Target adapter "${adapter.name}" declares an MCP runtime contract without mcp capability.`); } + if (!capabilityIsSupported(adapter.capabilities.mcp) && skillScoped) { + throw new Error(`Target adapter "${adapter.name}" declares skill-scoped MCP without mcp capability.`); + } + if (mcpRuntime !== undefined && skillScoped) { + throw new Error(`Target adapter "${adapter.name}" cannot declare both a plugin-root MCP runtime and skill-scoped MCP.`); + } if (mcpRuntime === undefined) return undefined; if (typeof mcpRuntime.manifestPath !== 'string' || !isSafeArtifactDocumentPath(mcpRuntime.manifestPath)) { throw new Error('Target adapter MCP runtime manifest path must be a safe relative POSIX path.'); @@ -504,10 +532,11 @@ const assertCapabilityContract = (adapter: TargetAdapter): void => { /** * The shipped adapters by identity. An advanced registry may register its own - * adapter under a built-in host's name; only these four are the built-in + * adapter under a built-in host's name; only these adapters are the built-in * hosts, whatever an adapter is called. */ const builtInAdapters: ReadonlyMap = new Map([ + [ampAdapter, 'amp'], [claudeAdapter, 'claude'], [codexAdapter, 'codex'], [cursorAdapter, 'cursor'], @@ -789,4 +818,5 @@ export const createDefaultRegistry = (): TargetRegistry => .register(portableAdapter, { default: true }) .register(codexAdapter) .register(claudeAdapter) - .register(cursorAdapter); + .register(cursorAdapter) + .register(ampAdapter); diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index a697e9baf..013e5ef0f 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -61,22 +61,24 @@ export const sortedEntries = (entries: TargetArtifactEntry[]): readonly TargetAr ); /** - * The host documents one projection derives from the model, as the artifact + * The host entry/documents one projection derives from the model, as the artifact * manifest records them: root-relative pointers, never data copied back out of * the documents (#592 step 3). The MCP and hooks documents are named by the * adapter's runtime and hook contracts (`mcpRuntime().manifestPath`, * `hookContract().manifestPath`), which the runtime reads through as well. */ export interface TargetPlanDocuments { + /** A host-native executable plugin entry when the host has no JSON plugin manifest (Amp's `index.js`). */ + readonly entry?: string; /** The marketplace document and the marketplace name it registers; absent when the projection emits none. */ readonly marketplace?: Readonly<{ readonly name: string; readonly path: string }>; /** The host plugin manifest (`.claude-plugin/plugin.json`, `plugin.json`, …). */ - readonly plugin: string; + readonly plugin?: string; } export interface TargetArtifactPlan { readonly diagnostics: readonly Diagnostic[]; - /** Absent only for adapters that emit no host plugin manifest (test doubles); every shipped adapter declares it. */ + /** Host entry/manifest pointers; absent only for adapters that emit neither (normally test doubles). */ readonly documents?: TargetPlanDocuments; readonly entries: readonly TargetArtifactEntry[]; readonly hookEntries?: readonly TargetHookEntry[]; @@ -426,6 +428,12 @@ export interface TargetArtifactOutputLayout { readonly directory: string; } +/** Dynamic application-name segment admitted in a target's Skill directory layout. */ +export const artifactLayoutPluginToken = '{plugin}'; + +export const resolveArtifactLayoutDirectory = (directory: string, plugin: string): string => + directory.replaceAll(artifactLayoutPluginToken, plugin); + const noArtifactDocumentIssues: readonly TargetArtifactDocumentIssue[] = Object.freeze([]); const invalidMcpDocumentIssues: readonly TargetArtifactDocumentIssue[] = deepFreeze([{ instancePath: '', @@ -451,10 +459,13 @@ export interface TargetArtifactLayout { readonly mcpApps?: TargetArtifactOutputLayout; readonly mcpEntries?: TargetArtifactOutputLayout; readonly outputStyles?: TargetArtifactOutputLayout; + /** Adapter-owned recursive namespaces at the artifact root. */ + readonly rootDirectories?: readonly string[]; /** Adapter-owned plain documents at the artifact root (for example a host manifest under its dotfolder). */ readonly rootDocuments?: readonly string[]; readonly rules?: TargetArtifactOutputLayout; readonly scripts?: TargetArtifactOutputLayout; + /** Skill root, optionally containing one `{plugin}` application-name segment. */ readonly skills?: string; readonly workflows?: string; } @@ -551,6 +562,8 @@ export interface TargetAdapter { readonly configExtension?: TargetConfigExtension; readonly hookContract?: TargetHookContract; readonly metadata: TargetAdapterMetadata; + /** `skill` when MCP configuration is emitted beside each skill rather than as one plugin-root runtime document. */ + readonly mcpScope?: 'skill'; readonly mcpRuntime?: TargetMcpRuntimeContract; readonly name: string; /** diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 89dd7fde4..800d43966 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -802,7 +802,7 @@ const temporaryArtifact = async ( }; const hostValidationReport = ( - target: BuiltInHost, + target: Exclude, pluginDirectory: string, strict: boolean | undefined, ): Promise[number]> => { @@ -839,6 +839,7 @@ export const validate = async (options: ValidateOptions): Promise projection.host)) + .filter((target): target is Exclude => target !== 'amp') .map((target) => hostValidationReport(target, artifact, options.strict)), ); return Object.freeze({ @@ -906,7 +907,8 @@ const skillFeatures = (skill: NormalizedPlugin['skills'][number]): readonly stri const ir = skill.skillIr; if (ir === undefined) return []; return [ - ...(ir.extensions.claude === undefined && ir.extensions.codex === undefined && ir.extensions.cursor === undefined + ...(ir.extensions.amp === undefined && ir.extensions.claude === undefined + && ir.extensions.codex === undefined && ir.extensions.cursor === undefined ? [] : ['hostFrontmatter']), ...(ir.placeholders.length === 0 ? [] : ['markdownTokens']), diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 1f69e63eb..952cc626b 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -342,6 +342,7 @@ const manifestProjections = (options: { const documents = projection.plan.documents; const emitted = (path: string | undefined): string | undefined => path !== undefined && options.filePaths.has(path) ? path : undefined; + const entry = emitted(documents?.entry); const plugin = emitted(documents?.plugin); const marketplace = emitted(documents?.marketplace?.path); const mcp = emitted(options.registry.mcpRuntime(host)?.manifestPath); @@ -350,6 +351,7 @@ const manifestProjections = (options: { return Object.freeze({ ...(builtInHost === undefined ? {} : { builtInHost }), documents: Object.freeze({ + ...(entry === undefined ? {} : { entry }), ...(hooks === undefined ? {} : { hooks }), ...(marketplace === undefined ? {} : { marketplace }), ...(mcp === undefined ? {} : { mcp }), diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index 76e25ea8f..340410ef8 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -136,11 +136,13 @@ export interface ArtifactManifestProjectionSchema { } /** - * Root-relative paths of the host documents one projection derived from the - * manifest: the host's plugin manifest, and its marketplace, MCP, and hooks - * documents when the projection emitted them. Every path is a `files[]` entry. + * Root-relative paths of the host entry/documents one projection derived: + * an executable plugin entry or the host's plugin, marketplace, MCP, and hooks + * documents when emitted. Every path is a `files[]` entry. */ export interface ArtifactManifestProjectionDocuments { + /** The host's executable plugin entry when it has no JSON plugin manifest. */ + readonly entry?: string; readonly hooks?: string; readonly marketplace?: string; readonly mcp?: string; @@ -162,7 +164,7 @@ export interface ArtifactManifestProjectionMarketplace { * Claude projection" the way the build did — by adapter, never by the name * the project selected it under (#578 audit: names are selection, not identity). */ -export type ArtifactManifestBuiltInHost = 'claude' | 'codex' | 'cursor' | 'portable'; +export type ArtifactManifestBuiltInHost = 'amp' | 'claude' | 'codex' | 'cursor' | 'portable'; export interface ArtifactManifestProjection { /** The shipped adapter that planned this projection; absent for an advanced-registry adapter. */ @@ -689,10 +691,11 @@ const parseProjectionSchemas = (value: unknown, location: string): readonly Arti const parseProjectionDocuments = (value: unknown, location: string): ArtifactManifestProjectionDocuments => { const documents = requireRecord(value, location); - requireExactKeys(documents, location, [], ['hooks', 'marketplace', 'mcp', 'plugin']); - const optionalPath = (key: 'hooks' | 'marketplace' | 'mcp' | 'plugin'): Record => + requireExactKeys(documents, location, [], ['entry', 'hooks', 'marketplace', 'mcp', 'plugin']); + const optionalPath = (key: 'entry' | 'hooks' | 'marketplace' | 'mcp' | 'plugin'): Record => documents[key] === undefined ? {} : { [key]: requirePath(documents[key], `${location}.${key}`) }; return { + ...optionalPath('entry'), ...optionalPath('hooks'), ...optionalPath('marketplace'), ...optionalPath('mcp'), @@ -722,7 +725,7 @@ const parseProjections = (value: unknown): readonly ArtifactManifestProjection[] } return { ...(projection.builtInHost === undefined ? {} : { - builtInHost: requireOneOf(projection.builtInHost, `${location}.builtInHost`, ['claude', 'codex', 'cursor', 'portable'] as const), + builtInHost: requireOneOf(projection.builtInHost, `${location}.builtInHost`, ['amp', 'claude', 'codex', 'cursor', 'portable'] as const), }), documents, host: requireString(projection.host, `${location}.host`), @@ -1392,6 +1395,7 @@ const referencedPaths = (manifest: { }; for (const projection of manifest.projections) { const location = `projections[${projection.host}].documents`; + reference(`${location}.entry`, projection.documents.entry); reference(`${location}.plugin`, projection.documents.plugin); reference(`${location}.marketplace`, projection.documents.marketplace); reference(`${location}.mcp`, projection.documents.mcp); diff --git a/packages/agent-bundle/src/build/validate-artifact-hooks.ts b/packages/agent-bundle/src/build/validate-artifact-hooks.ts index d592f8b98..d019f79bd 100644 --- a/packages/agent-bundle/src/build/validate-artifact-hooks.ts +++ b/packages/agent-bundle/src/build/validate-artifact-hooks.ts @@ -53,7 +53,7 @@ export const validateHookCoherence = async (options: { const manifestFile = manifestFiles.get(hook.path); if ( contract === undefined || - !isDirectOutputLayoutPath(hook.path, layout) || + (contract.registration !== 'api' && !isDirectOutputLayoutPath(hook.path, layout)) || file === undefined || manifestFile === undefined || !matchesManifestFile(file, manifestFile) @@ -72,6 +72,7 @@ export const validateHookCoherence = async (options: { const contract = options.registry.hookContract(target); if (contract === undefined) continue; const hooks = indexedByTarget.get(target) ?? []; + if (contract.registration === 'api') continue; // Every selected host's document lives at its contract path inside the // one composite root; the wrappers it names are the host's own (#555). const manifestPath = contract.manifestPath; diff --git a/packages/agent-bundle/src/build/validate-artifact-skills.ts b/packages/agent-bundle/src/build/validate-artifact-skills.ts index cd371e998..d7fb4b920 100644 --- a/packages/agent-bundle/src/build/validate-artifact-skills.ts +++ b/packages/agent-bundle/src/build/validate-artifact-skills.ts @@ -1,6 +1,8 @@ import { resolve } from 'node:path'; import type { TargetRegistry } from '../adapters/registry.ts'; +import { resolveArtifactLayoutDirectory } from '../adapters/types.ts'; +import { ampMcpDocumentIssues } from '../adapters/amp-mcp.ts'; import { parseSkillMarkdown, referencedResources } from '../config/skill-references.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; @@ -26,15 +28,16 @@ export const manifestTargets = (manifest: ArtifactManifest): readonly string[] = * by every selected host and must satisfy each host's frontmatter contract. */ const skillDirectories = ( - targets: readonly string[], + manifest: ArtifactManifest, registry: TargetRegistry, ): ReadonlyMap => { const directories = new Map(); - for (const target of targets) { + for (const target of manifestTargets(manifest)) { if (!registry.has(target)) continue; const directory = registry.artifactLayout(target).skills; if (directory === undefined) continue; - directories.set(directory, [...(directories.get(directory) ?? []), target]); + const resolved = resolveArtifactLayoutDirectory(directory, manifest.application.name); + directories.set(resolved, [...(directories.get(resolved) ?? []), target]); } return directories; }; @@ -51,16 +54,48 @@ const emittedSkillFor = ( file: ArtifactFile, directories: ReadonlyMap, ): EmittedSkill | undefined => { - const segments = file.path.split('/'); - const [layout, name, document] = segments; - if (layout === undefined || name === undefined || document !== 'SKILL.md' || segments.length !== 3) return undefined; - const targets = directories.get(layout); - if (targets === undefined) return undefined; - return { name, path: file.path, root: `${layout}/${name}`, targets }; + for (const [layout, targets] of directories) { + if (!file.path.startsWith(`${layout}/`)) continue; + const segments = file.path.slice(layout.length + 1).split('/'); + const [name, document] = segments; + if (name !== undefined && document === 'SKILL.md' && segments.length === 2) { + return { name, path: file.path, root: `${layout}/${name}`, targets }; + } + } + return undefined; +}; + +const ampFrontmatterValidator = (frontmatter: unknown): readonly AgentSkillsFrontmatterIssue[] => { + if (typeof frontmatter !== 'object' || frontmatter === null || Array.isArray(frontmatter)) { + return validateAgentSkillsFrontmatter(frontmatter); + } + const record = frontmatter as Readonly>; + const portable = Object.fromEntries(Object.entries(record).filter(([key]) => + ['allowed-tools', 'compatibility', 'description', 'license', 'metadata', 'name'].includes(key))); + const issues: AgentSkillsFrontmatterIssue[] = [...validateAgentSkillsFrontmatter(portable)]; + if ( + record['builtin-tools'] !== undefined + && ( + !Array.isArray(record['builtin-tools']) + || !record['builtin-tools'].every((tool) => typeof tool === 'string' && tool.length > 0) + ) + ) { + issues.push({ instancePath: '/builtin-tools', keyword: 'type', message: 'must be an array of nonempty strings' }); + } + if (record.mcpServers !== undefined) { + issues.push(...ampMcpDocumentIssues(record.mcpServers).map((issue) => ({ + instancePath: `/${issue.path.replaceAll('.', '/')}`, + keyword: 'amp-mcp', + message: issue.message, + }))); + } + return Object.freeze(issues); }; const frontmatterValidatorFor = (target: string): (frontmatter: unknown) => readonly AgentSkillsFrontmatterIssue[] => { switch (target) { + case 'amp': + return ampFrontmatterValidator; case 'claude': return validateClaudeSkillFrontmatter; case 'cursor': @@ -82,7 +117,7 @@ export const validateEmittedSkills = async (options: { readonly registry: TargetRegistry; }): Promise => { const diagnostics: Diagnostic[] = []; - const directories = skillDirectories(manifestTargets(options.manifest), options.registry); + const directories = skillDirectories(options.manifest, options.registry); const skills = options.files .map((file) => emittedSkillFor(file, directories)) .filter((skill): skill is EmittedSkill => skill !== undefined); @@ -92,7 +127,7 @@ export const validateEmittedSkills = async (options: { if (!file.path.endsWith('/SKILL.md') || emittedSkillFor(file, directories) !== undefined) continue; diagnostics.push(diagnostic( 'AB6015', - `Emitted Skill document ${JSON.stringify(file.path)} does not use the canonical skills//SKILL.md layout.`, + `Emitted Skill document ${JSON.stringify(file.path)} does not use a selected target's registered Skill layout.`, file.path, undefined, skillRecovery, @@ -101,11 +136,15 @@ export const validateEmittedSkills = async (options: { const resourceFilesBySkill = new Map(); for (const file of options.files) { - const [layout, name] = file.path.split('/'); - if (layout === undefined || name === undefined || !directories.has(layout)) continue; - const root = `${layout}/${name}`; - const existing = resourceFilesBySkill.get(root) ?? []; - resourceFilesBySkill.set(root, [...existing, file]); + for (const layout of directories.keys()) { + if (!file.path.startsWith(`${layout}/`)) continue; + const [name] = file.path.slice(layout.length + 1).split('/'); + if (name === undefined) continue; + const root = `${layout}/${name}`; + const existing = resourceFilesBySkill.get(root) ?? []; + resourceFilesBySkill.set(root, [...existing, file]); + break; + } } for (const [root, files] of resourceFilesBySkill) { @@ -170,6 +209,35 @@ export const validateEmittedSkills = async (options: { )); } } + if (skill.targets.includes('amp') && parsed.frontmatter.mcpServers === undefined) { + const mcpPath = `${skill.root}/mcp.json`; + if (options.files.some((file) => file.path === mcpPath)) { + let document: unknown; + try { + document = JSON.parse(await runWithPlatform(readFileString(resolve(options.artifactRoot, mcpPath)))) as unknown; + } catch { + diagnostics.push(diagnostic( + 'AB6015', + 'Emitted Amp Skill mcp.json must contain one JSON value.', + mcpPath, + 'amp', + skillRecovery, + )); + document = undefined; + } + if (document !== undefined) { + for (const issue of ampMcpDocumentIssues(document)) { + diagnostics.push(diagnostic( + 'AB6015', + `Emitted Amp Skill MCP ${issue.path || 'root'} ${issue.message}.`, + mcpPath, + 'amp', + skillRecovery, + )); + } + } + } + } if (typeof parsed.frontmatter.name === 'string' && parsed.frontmatter.name !== skill.name) { diagnostics.push(diagnostic( 'AB6015', diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 17fd65385..7ba690e5e 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -3,9 +3,10 @@ import { resolve } from 'node:path'; import { portableAdapter } from '../adapters/portable.ts'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; -import type { - TargetArtifactDocumentIssue, - TargetArtifactDocumentValidator, +import { + resolveArtifactLayoutDirectory, + type TargetArtifactDocumentIssue, + type TargetArtifactDocumentValidator, } from '../adapters/types.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; @@ -466,10 +467,16 @@ const validatePortableProjection = async (options: { const ownershipRecovery = artifactDiagnosticRecoveries.AB6014; -const isSkillArtifactPath = (relativePath: string, skills: string | undefined): boolean => { +const isSkillArtifactPath = ( + relativePath: string, + skills: string | undefined, + plugin: string, +): boolean => { if (skills === undefined) return false; - const [layout, name, resource] = relativePath.split('/'); - return layout === skills && name !== undefined && resource !== undefined; + const root = resolveArtifactLayoutDirectory(skills, plugin); + if (!relativePath.startsWith(`${root}/`)) return false; + const [name, resource] = relativePath.slice(root.length + 1).split('/'); + return name !== undefined && resource !== undefined; }; const isRecursiveArtifactPath = (relativePath: string, directory: string | undefined): boolean => { @@ -491,6 +498,7 @@ const isProjectionArtifactPath = ( relativePath: string, target: string, registry: TargetRegistry, + plugin: string, ): boolean => { const layout = registry.artifactLayout(target); const hookContract = registry.hookContract(target); @@ -504,7 +512,8 @@ const isProjectionArtifactPath = ( isDirectOutputLayoutPath(relativePath, layout.mcpEntries) || isDirectOutputLayoutPath(relativePath, layout.rules) || isDirectOutputLayoutPath(relativePath, layout.scripts) || - isSkillArtifactPath(relativePath, layout.skills) || + layout.rootDirectories?.some((directory) => isRecursiveArtifactPath(relativePath, directory)) === true || + isSkillArtifactPath(relativePath, layout.skills, plugin) || isAdapterRootDocument(relativePath, layout.rootDocuments) || relativePath === hookContract?.manifestPath || relativePath === mcpRuntime?.manifestPath || @@ -534,7 +543,8 @@ const validateArtifactOwnership = (options: { for (const file of options.files) { if (file.path === compileEvidenceFileName || admitsEverything) continue; - if (known.some((target) => isProjectionArtifactPath(file.path, target, options.registry))) continue; + if (known.some((target) => + isProjectionArtifactPath(file.path, target, options.registry, options.manifest.application.name))) continue; // Prebuilt payload files live in config-named directories under the // root, so no emitted layout describes them. if (manifestKinds.get(file.path) === 'prebuilt') continue; diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 42d38b28a..95429eba8 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -31,7 +31,7 @@ import type { McpAppProfileId, ProjectOptions, } from './api.ts'; -import type { installBundle, InstallHost } from './install/install.ts'; +import type { DevInstallHost, installBundle } from './install/install.ts'; import type { runDoctor } from './install/doctor.ts'; import type { uninstallBundle } from './install/uninstall.ts'; import type { runHostMcpProxy } from './dev/host-mcp-proxy.ts'; @@ -147,7 +147,7 @@ interface JsonInputOptions { interface DevCommandOptions { readonly agentApi?: boolean; - readonly installHost: readonly InstallHost[]; + readonly installHost: readonly DevInstallHost[]; readonly open?: boolean; readonly port?: number; readonly root: string; diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index b027814d6..e427338d0 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -79,7 +79,7 @@ import { normalizeNoticeRetention } from './notice-retention.ts'; import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './script-routes.ts'; const isSkillHost = (name: string): name is SkillHost => - name === 'claude' || name === 'codex' || name === 'cursor' || name === 'portable'; + name === 'amp' || name === 'claude' || name === 'codex' || name === 'cursor' || name === 'portable'; const loweringHosts = (targetNames: readonly string[]): SkillHost[] => [...new Set(targetNames.filter(isSkillHost))]; diff --git a/packages/agent-bundle/src/core/paths.ts b/packages/agent-bundle/src/core/paths.ts index c19a0abe4..633a417aa 100644 --- a/packages/agent-bundle/src/core/paths.ts +++ b/packages/agent-bundle/src/core/paths.ts @@ -92,7 +92,7 @@ export const isPortablePathSegment = (segment: string): boolean => segment.length > 0 && segment !== '.' && segment !== '..' && - !/[<>:"|?*]/u.test(segment) && + !/[<>:"/\\|?*]/u.test(segment) && [...segment].every((character) => character.charCodeAt(0) >= 0x20) && !windowsDeviceName.test(segment) && !segment.endsWith('.') && diff --git a/packages/agent-bundle/src/dev/artifacts/application-explorer.ts b/packages/agent-bundle/src/dev/artifacts/application-explorer.ts index d274978fe..424984601 100644 --- a/packages/agent-bundle/src/dev/artifacts/application-explorer.ts +++ b/packages/agent-bundle/src/dev/artifacts/application-explorer.ts @@ -30,7 +30,7 @@ export interface ApplicationExplorerIdentity { } export interface ApplicationExplorerDocument { - readonly kind: 'hooks' | 'marketplace' | 'mcp' | 'plugin'; + readonly kind: 'entry' | 'hooks' | 'marketplace' | 'mcp' | 'plugin'; readonly path: string; } @@ -138,7 +138,7 @@ export interface ApplicationExplorerDistribution { readonly payloads: readonly ApplicationExplorerPayload[]; } -const documentKinds = ['hooks', 'marketplace', 'mcp', 'plugin'] as const; +const documentKinds = ['entry', 'hooks', 'marketplace', 'mcp', 'plugin'] as const; const byId = (left: Value, right: Value): number => left.id.localeCompare(right.id); diff --git a/packages/agent-bundle/src/dev/dev-proxy-command.ts b/packages/agent-bundle/src/dev/dev-proxy-command.ts index 21f8fbce1..a1e15c945 100644 --- a/packages/agent-bundle/src/dev/dev-proxy-command.ts +++ b/packages/agent-bundle/src/dev/dev-proxy-command.ts @@ -2,7 +2,7 @@ import { lstat, readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; -import type { InstallHost } from '../install/install.ts'; +import type { DevInstallHost } from '../install/install.ts'; interface AgentBundlePackage { readonly bin?: unknown; @@ -61,7 +61,7 @@ const resolveAgentBundleCliEntry = async (): Promise => { export const devProxyServerCommand = async ( projectRoot: string, serverName: string, - host: InstallHost, + host: DevInstallHost, ): Promise; readonly eventHub: ProjectEventHub; readonly home?: string; - readonly hosts: readonly InstallHost[]; + readonly hosts: readonly DevInstallHost[]; readonly installBundle?: (options: InstallBundleOptions) => Promise; readonly projectRoot: string; readonly uninstallBundle?: (options: UninstallBundleOptions) => Promise; @@ -64,14 +64,14 @@ export interface DevHostInstallManagerOptions { interface InstalledDevHost { readonly destination: string; - readonly host: InstallHost; + readonly host: DevInstallHost; readonly plugin?: string; epochId: string; } interface DevInstallMarker { readonly epochId: string; - readonly host: InstallHost; + readonly host: DevInstallHost; readonly projectRoot: string; readonly schemaVersion: 1; } @@ -82,7 +82,7 @@ const isRecord = (value: unknown): value is Record => const rewriteMcpDocument = async ( bundleRoot: string, documentPath: string, - host: InstallHost, + host: DevInstallHost, projectRoot: string, run: PlatformRun, ): Promise => { @@ -113,7 +113,7 @@ const rewriteMcpDocument = async ( const marker = ( epochId: string, - host: InstallHost, + host: DevInstallHost, projectRoot: string, ): DevInstallMarker => Object.freeze({ epochId, @@ -129,7 +129,7 @@ const marker = ( */ const prepareDevBundle = async ( source: string, - host: InstallHost, + host: DevInstallHost, epochId: string, projectRoot: string, run: PlatformRun, @@ -174,7 +174,7 @@ const prepareDevBundle = async ( } }; -const stableDevBundle = (projectRoot: string, host: InstallHost): string => +const stableDevBundle = (projectRoot: string, host: DevInstallHost): string => join(projectRoot, '.agent-bundle', 'dev', host); const ensureStableDevBundle = async (preparedRoot: string, stableRoot: string): Promise => { @@ -330,7 +330,7 @@ const pruneGenerations = async ( } }; -const syncDiagnostic = (host: InstallHost, epochId: string, error: unknown): Diagnostic => Object.freeze({ +const syncDiagnostic = (host: DevInstallHost, epochId: string, error: unknown): Diagnostic => Object.freeze({ code: 'AB7202', message: `Failed to sync ${host} development install to epoch ${epochId}: ${ error instanceof Error ? error.message : String(error) @@ -347,9 +347,9 @@ export class DevHostInstallManager { readonly #environment: Readonly; readonly #eventHub: ProjectEventHub; readonly #home: string | undefined; - readonly #hosts: readonly InstallHost[]; + readonly #hosts: readonly DevInstallHost[]; readonly #installBundle: (options: InstallBundleOptions) => Promise; - readonly #installed = new Map(); + readonly #installed = new Map(); readonly #projectRoot: string; readonly #run: PlatformRun; readonly #uninstallBundle: (options: UninstallBundleOptions) => Promise; @@ -380,7 +380,7 @@ export class DevHostInstallManager { this.#uninstallBundle = options.uninstallBundle ?? defaultUninstallBundle; } - attached(host: InstallHost): Readonly<{ readonly destination: string; readonly epochId: string }> | undefined { + attached(host: DevInstallHost): Readonly<{ readonly destination: string; readonly epochId: string }> | undefined { const installed = this.#installed.get(host); return installed === undefined || installed.epochId.length === 0 ? undefined @@ -479,7 +479,7 @@ export class DevHostInstallManager { if (failures.length > 0) throw new AggregateError(failures, 'Failed to remove development host installs.'); } - async #syncHost(epochRoot: string, epochId: string, host: InstallHost): Promise { + async #syncHost(epochRoot: string, epochId: string, host: DevInstallHost): Promise { // Every selected host installs from the composite epoch root (#555). const prepared = await prepareDevBundle(epochRoot, host, epochId, this.#projectRoot, this.#run); try { diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index ee7e6c43b..dd05fe61b 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -3,7 +3,7 @@ import { join, resolve } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; import { readArtifactManifest } from '../build/manifest-file.ts'; -import type { InstallHost } from '../install/install.ts'; +import type { DevInstallHost } from '../install/install.ts'; import { HookService } from '../services/hook-service.ts'; import { AgentApi } from './agent-api.ts'; import { ArtifactInspectionService } from './artifacts/artifact-inspection-service.ts'; @@ -127,7 +127,7 @@ export interface StartDevServerOptions { /** Supplied by integration tests; published callers use the packaged assets. */ readonly assets?: WorkbenchAssetSource; /** Hosts whose installed development variant follows successful artifact epochs. */ - readonly installHosts?: readonly InstallHost[]; + readonly installHosts?: readonly DevInstallHost[]; /** Launch the foreground URL after it has started. Defaults to false. */ readonly open?: boolean; /** Injectable browser launcher for embedding and deterministic tests. */ diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index 4efb258f1..d8545ca7f 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -21,8 +21,12 @@ import type { EventPreflightResult } from './preflight.ts'; * projected as one (#461). */ const resultValueSchema = z.object({ - outcome: z.enum(['continue', 'allow', 'ask', 'deny']).optional(), + error: z.string().optional(), + exitCode: z.number().int().optional(), + outcome: z.enum(['continue', 'allow', 'ask', 'deny', 'synthesize']).optional(), + output: z.unknown().optional(), reason: z.string().min(1).optional(), + status: z.enum(['done', 'error', 'cancelled']).optional(), updatedInput: z.record(z.string(), z.unknown()).optional(), }).strict(); @@ -105,6 +109,63 @@ export const validateNativeEventEnvelope = ( if (native.hook_event_name !== nativeEvent) { return nativeEventError(`native hook_event_name must equal ${nativeEvent}`); } + if (target === 'amp') { + requireNativeString(native, 'session_id'); + switch (canonicalEvent) { + case 'session/start': + if (nativeEvent !== 'session.start') return nativeEventError('Amp session/start must use session.start'); + return native; + case 'prompt/submit': + if (nativeEvent !== 'agent.start') return nativeEventError('Amp prompt/submit must use agent.start'); + requireNativeStringValue(native, 'prompt'); + return native; + case 'stop': + if (nativeEvent !== 'agent.end') return nativeEventError('Amp stop must use agent.end'); + if (!['done', 'error', 'cancelled'].includes(String(native.status))) { + return nativeEventError('native status is invalid'); + } + return native; + case 'tool/before': + case 'tool/after': + if (nativeEvent !== (canonicalEvent === 'tool/before' ? 'tool.call' : 'tool.result')) { + return nativeEventError(`Amp ${canonicalEvent} uses the wrong native event`); + } + requireNativeString(native, 'tool_name'); + if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) { + return nativeEventError('native tool_input must be an object'); + } + requireNativeString(native, 'tool_use_id'); + if ( + canonicalEvent === 'tool/after' + && !['done', 'error', 'cancelled'].includes(String(native.status)) + ) { + return nativeEventError('native status is invalid'); + } + return native; + case 'agent/idle': + case 'agent/start': + case 'agent/stop': + case 'compact/after': + case 'compact/before': + case 'config/change': + case 'file/change': + case 'model-switch/after': + case 'model-switch/before': + case 'permission/denied': + case 'permission/request': + case 'session/end': + case 'stop/failure': + case 'task/complete': + case 'task/create': + case 'tool/failure': + case 'workspace/open': + return nativeEventError(`Amp PluginEventMap does not support ${canonicalEvent}`); + default: { + const exhaustive: never = canonicalEvent; + return exhaustive; + } + } + } if (target === 'cursor') { if (canonicalEvent === 'workspace/open') { if ( @@ -500,8 +561,23 @@ export const projectEventDocument = ( const additionalContext = contexts.length === 0 ? undefined : contexts.join(''); const parsedValue = document.value === undefined ? undefined : resultValueSchema.parse(document.value); if ( - (parsedValue?.outcome === 'allow' && event !== 'tool/before' && event !== 'permission/request' && event !== 'model-switch/before') - || (parsedValue?.outcome === 'ask' && event !== 'tool/before' && event !== 'model-switch/before') + target !== 'amp' && + ( + parsedValue?.error !== undefined || + parsedValue?.exitCode !== undefined || + parsedValue?.output !== undefined || + parsedValue?.status !== undefined + ) + ) { + throw new TypeError(`${event} does not accept Amp tool-result fields.`); + } + if ( + target !== 'amp' && + ( + parsedValue?.outcome === 'synthesize' + || (parsedValue?.outcome === 'allow' && event !== 'tool/before' && event !== 'permission/request' && event !== 'model-switch/before') + || (parsedValue?.outcome === 'ask' && event !== 'tool/before' && event !== 'model-switch/before') + ) ) { throw new TypeError( `${event} does not accept outcome "${parsedValue.outcome}": allow is a tool/before, model-switch/before, or permission/request decision and ask is a tool/before or model-switch/before decision; continue leaves the host's own flow untouched.`, @@ -517,6 +593,142 @@ export const projectEventDocument = ( return parsedValue.reason; }; + if (target === 'amp') { + switch (event) { + case 'session/start': + if (additionalContext !== undefined || parsedValue !== undefined) { + throw new TypeError('Amp session.start is observation-only and has no result channel.'); + } + return undefined; + case 'prompt/submit': + if ( + parsedValue?.outcome !== undefined && parsedValue.outcome !== 'continue' + || parsedValue?.error !== undefined + || parsedValue?.exitCode !== undefined + || parsedValue?.output !== undefined + || parsedValue?.reason !== undefined + || parsedValue?.status !== undefined + || parsedValue?.updatedInput !== undefined + ) { + throw new TypeError('Amp agent.start accepts appended context only.'); + } + return additionalContext === undefined + ? undefined + : Object.freeze({ message: Object.freeze({ content: additionalContext }) }); + case 'stop': + if ( + additionalContext !== undefined + || parsedValue?.error !== undefined + || parsedValue?.exitCode !== undefined + || parsedValue?.output !== undefined + || parsedValue?.status !== undefined + || parsedValue?.updatedInput !== undefined + || ( + parsedValue?.outcome !== undefined + && parsedValue.outcome !== 'continue' + && parsedValue.outcome !== 'deny' + ) + || (parsedValue?.reason !== undefined && parsedValue.outcome !== 'deny') + ) { + throw new TypeError('Amp agent.end accepts only a denied stop with a follow-up message.'); + } + return parsedValue?.outcome === 'deny' + ? Object.freeze({ action: 'continue', userMessage: requireDenyReason() }) + : undefined; + case 'tool/before': + if (additionalContext !== undefined || parsedValue?.status !== undefined || parsedValue?.error !== undefined) { + throw new TypeError('Amp tool.call has no context or terminal-status result fields.'); + } + if (parsedValue?.outcome === 'ask') { + throw new TypeError('Amp tool.call has no ask result; native permissions remain host-owned.'); + } + if (parsedValue?.outcome === 'synthesize') { + if ( + typeof parsedValue.output !== 'string' + || parsedValue.reason !== undefined + || parsedValue.updatedInput !== undefined + ) { + throw new TypeError('Amp tool.call synthesize requires string output and no decision or input-rewrite fields.'); + } + return Object.freeze({ + action: 'synthesize', + result: Object.freeze({ + ...(parsedValue.exitCode === undefined ? {} : { exitCode: parsedValue.exitCode }), + output: parsedValue.output, + }), + }); + } + if (parsedValue?.output !== undefined || parsedValue?.exitCode !== undefined) { + throw new TypeError('Amp tool.call output and exitCode are valid only with outcome synthesize.'); + } + if (parsedValue?.outcome === 'deny') { + if (parsedValue.updatedInput !== undefined) { + throw new TypeError('Amp tool.call cannot reject and modify one call.'); + } + return Object.freeze({ action: 'reject-and-continue', message: requireDenyReason() }); + } + if (parsedValue?.reason !== undefined) { + throw new TypeError('Amp tool.call reason is valid only with outcome deny.'); + } + if (parsedValue?.updatedInput !== undefined) { + return Object.freeze({ action: 'modify', input: parsedValue.updatedInput }); + } + return parsedValue?.outcome === 'allow' ? Object.freeze({ action: 'allow' }) : undefined; + case 'tool/after': { + if ( + additionalContext !== undefined + || parsedValue?.exitCode !== undefined + || parsedValue?.reason !== undefined + || parsedValue?.updatedInput !== undefined + || ( + parsedValue?.outcome !== undefined + && parsedValue.outcome !== 'continue' + ) + ) { + throw new TypeError('Amp tool.result accepts only a replacement status, output, and error.'); + } + if ( + parsedValue?.status === undefined + && parsedValue?.output === undefined + && parsedValue?.error === undefined + ) { + return undefined; + } + const status = parsedValue?.status ?? nativeInput?.status; + if (status !== 'done' && status !== 'error' && status !== 'cancelled') { + throw new TypeError('Amp tool.result replacement requires a terminal status.'); + } + return Object.freeze({ + ...(parsedValue?.error === undefined ? {} : { error: parsedValue.error }), + ...(parsedValue?.output === undefined ? {} : { output: parsedValue.output }), + status, + }); + } + case 'agent/idle': + case 'agent/start': + case 'agent/stop': + case 'compact/after': + case 'compact/before': + case 'config/change': + case 'file/change': + case 'model-switch/after': + case 'model-switch/before': + case 'permission/denied': + case 'permission/request': + case 'session/end': + case 'stop/failure': + case 'task/complete': + case 'task/create': + case 'tool/failure': + case 'workspace/open': + throw new TypeError(`Amp PluginEventMap does not support ${event}.`); + default: { + const exhaustive: never = event; + return exhaustive; + } + } + } + if (event === 'stop') { if (parsedValue?.outcome !== 'deny') return undefined; return target === 'cursor' diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 3aa01796c..11ee26246 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -11,6 +11,7 @@ export { skillTokenSpellings, } from './skills/tokens.ts'; export type { + AmpSkillExtension, ClaudeSkillExtension, CodexSkillExtension, CursorSkillExtension, diff --git a/packages/agent-bundle/src/install/commands.ts b/packages/agent-bundle/src/install/commands.ts index ffc80d37f..b2f9d284b 100644 --- a/packages/agent-bundle/src/install/commands.ts +++ b/packages/agent-bundle/src/install/commands.ts @@ -2,7 +2,7 @@ import { Command, InvalidArgumentError } from 'commander'; import type { DoctorHost, runDoctor } from './doctor.ts'; import { formatDoctorReport, formatInstallResult, formatUninstallResult } from './format.ts'; -import type { installBundle, InstallHost, InstallMode, InstallScope } from './install.ts'; +import type { DevInstallHost, installBundle, InstallHost, InstallMode, InstallScope } from './install.ts'; import type { uninstallBundle } from './uninstall.ts'; /** @@ -58,12 +58,17 @@ interface DoctorCommandOptions { } export const installHost = (value: string): InstallHost => { + if (value === 'amp' || value === 'claude' || value === 'codex' || value === 'cursor') return value; + throw new InvalidArgumentError('Install host must be amp, claude, codex, or cursor.'); +}; + +const devInstallHost = (value: string): DevInstallHost => { if (value === 'claude' || value === 'codex' || value === 'cursor') return value; - throw new InvalidArgumentError('Install host must be claude, codex, or cursor.'); + throw new InvalidArgumentError('Development install host must be claude, codex, or cursor.'); }; -export const collectInstallHost = (value: string, previous: readonly InstallHost[]): readonly InstallHost[] => - [...previous, installHost(value)]; +export const collectInstallHost = (value: string, previous: readonly DevInstallHost[]): readonly DevInstallHost[] => + [...previous, devInstallHost(value)]; const installMode = (value: string): InstallMode => { if (value === 'local' || value === 'marketplace') return value; @@ -92,7 +97,7 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo const installCommand = fromOption( program.command('install') .description('Install a built bundle into a supported host') - .argument('', 'Destination host: claude, codex, or cursor', installHost), + .argument('', 'Destination host: amp, claude, codex, or cursor', installHost), 'Target bundle directory or artifact root', true, ) @@ -120,7 +125,7 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo const uninstallCommand = fromOption( program.command('uninstall') .description('Remove a receipt-owned host install of a built bundle, and nothing else') - .argument('', 'Host to uninstall from: claude, codex, or cursor', installHost), + .argument('', 'Host to uninstall from: amp, claude, codex, or cursor', installHost), 'Target bundle directory or artifact root that identifies the plugin', true, ) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 5306802d1..cf8d79aa2 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -83,7 +83,7 @@ import { resolveInstalledStateRoots, } from './state-root.ts'; -export type DoctorHost = InstallHost; +export type DoctorHost = Exclude; export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable'; export type DoctorInventoryStatus = 'known' | 'skipped' | 'unknown'; export type DoctorFindingState = diff --git a/packages/agent-bundle/src/install/identity.ts b/packages/agent-bundle/src/install/identity.ts index ee3d67572..7e9eba7d1 100644 --- a/packages/agent-bundle/src/install/identity.ts +++ b/packages/agent-bundle/src/install/identity.ts @@ -8,9 +8,10 @@ import type { } from '../build/manifest.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { errorMessage, isErrno } from '../core/errors.ts'; +import { isPortablePathSegment } from '../core/paths.ts'; import { manifestInventory, treeInventory, type TreeInventory } from './receipt.ts'; -export type BundleIdentityHost = 'claude' | 'codex' | 'cursor'; +export type BundleIdentityHost = 'amp' | 'claude' | 'codex' | 'cursor'; export interface PluginIdentity { readonly bundleRoot: string; @@ -88,20 +89,27 @@ export const readBundleIdentity = async ( host, ); } - const pluginDocument = projection.documents.plugin; + const pluginDocument = host === 'amp' ? projection.documents.entry : projection.documents.plugin; if (pluginDocument === undefined) { - throw failure('AB7001', `The ${host} projection at ${result.root} has no host plugin manifest.`, host); + throw failure( + 'AB7001', + `The ${host} projection at ${result.root} has no host ${host === 'amp' ? 'plugin entry' : 'plugin manifest'}.`, + host, + ); } const marketplace = projection.marketplace?.name; - if (host !== 'cursor' && marketplace === undefined) { + if (host !== 'amp' && host !== 'cursor' && marketplace === undefined) { throw failure('AB7001', `${host} bundle has no marketplace identity.`, host); } const plugin = result.manifest.application.name; if ( - host === 'cursor' && - (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(plugin) || plugin.length > 64) + (host === 'amp' && !isPortablePathSegment(plugin)) || + (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); + throw failure('AB7001', `${host} plugin name ${JSON.stringify(plugin)} is not a safe local plugin name.`, host); } await requireDocument(result.root, pluginDocument, host); if (projection.documents.marketplace !== undefined) { diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 8936a67c5..17c5bd0b9 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; import { lstat, mkdir, readFile, rename, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, posix, resolve } from 'node:path'; import { Effect, Predicate } from 'effect'; @@ -21,6 +21,7 @@ import { type PluginIdentity, } from './identity.ts'; import { + assertRealAncestors, compareInstalledTree, createInstallReceipt, describeContentComparison, @@ -46,6 +47,8 @@ import { import { recordInstalledState } from './state-root.ts'; export type InstallHost = BundleIdentityHost; +export type DevInstallHost = Exclude; +export type PublicInstallHost = Exclude; export type InstallScope = 'local' | 'project' | 'user'; /** * `adopted`: a byte-identical pre-receipt Cursor copy gained its receipt under @@ -90,6 +93,8 @@ export interface InstallBundleOptions { readonly replace?: boolean; /** Cursor only; defaults to `local`. Other hosts reject an explicit mode. */ readonly mode?: InstallMode; + /** Amp project-scope destination; defaults to the calling process cwd. */ + readonly projectRoot?: string; readonly scope?: InstallScope; } @@ -109,7 +114,7 @@ export interface InstallResult { /** Content hash of the copy a `replaced` install superseded. */ readonly previousContentHash?: string; /** - * The install receipt this run wrote or confirmed: inside the plugin root for Cursor local copies, + * The install receipt this run wrote or confirmed: inside the plugin root for Cursor/Amp local copies, * in the host root's `agent-bundle/receipts` store for host-CLI and marketplace deliveries (#101). */ readonly receipt?: string; @@ -147,7 +152,7 @@ export const defaultCommandRunner: InstallCommandRunner = Object.freeze({ export const runHostCommand = async ( runner: InstallCommandRunner, identity: PluginIdentity, - host: Exclude, + host: PublicInstallHost, args: readonly string[], operation: 'installation' | 'removal' = 'installation', ): Promise => { @@ -173,7 +178,7 @@ export const runHostCommand = async ( * where Agent Bundle keeps its own `agent-bundle/receipts` store for them. */ export const publicHostRoot = ( - host: Exclude, + host: PublicInstallHost, environment: Readonly, home: string, ): string => host === 'claude' @@ -185,7 +190,7 @@ export const publicHostRoot = ( * the real-host install proofs and shared with the development install sync. */ export const publicHostCacheRoot = ( - host: Exclude, + host: PublicInstallHost, environment: Readonly, home: string, ): string => join(publicHostRoot(host, environment, home), 'plugins', 'cache'); @@ -234,7 +239,7 @@ const isRecord = (value: unknown): value is Record => * host's inventory identically. */ export const parsePublicHostInventory = ( - host: Exclude, + host: PublicInstallHost, stdout: string, options: { readonly cacheRoot: string; @@ -306,7 +311,7 @@ export const parsePublicHostInventory = ( export const readPublicHostInventory = async ( runner: InstallCommandRunner, identity: PluginIdentity, - host: Exclude, + host: PublicInstallHost, scope: InstallScope, environment: Readonly, home: string, @@ -337,7 +342,7 @@ export const readPublicHostInventory = async ( * "already installed" nor a fresh install may report it as healthy (`AB7006`). */ const refusedInstallFailure = ( - host: Exclude, + host: PublicInstallHost, id: string, entry: PublicHostInstalledEntry, phase: 'existing' | 'installed', @@ -357,7 +362,7 @@ const refusedInstallFailure = ( * durable data explicitly (`--purge-data`) instead of letting the host decide. */ export const publicHostUninstallArguments = ( - host: Exclude, + host: PublicInstallHost, id: string, scope: InstallScope, ): readonly string[] => host === 'claude' @@ -370,7 +375,7 @@ export const publicHostMarketplaceRemoveArguments = (marketplace: string): reado /** The registrations `install ` performs for a public host CLI, in order. */ export const publicHostRegistrations = ( - host: Exclude, + host: PublicInstallHost, id: string, marketplace: string, scope: InstallScope, @@ -393,7 +398,7 @@ export const publicHostRegistrations = ( * host verbs ran in (the bundle root), so each project keeps its own receipt. */ export const publicHostReceiptPath = ( - host: Exclude, + host: PublicInstallHost, plugin: string, marketplace: string, scope: InstallScope, @@ -408,7 +413,7 @@ export const publicHostReceiptPath = ( /** The project root a Claude `project` / `local` registration belongs to: the cwd the host verbs run in. */ export const publicHostProjectRoot = ( - host: Exclude, + host: PublicInstallHost, scope: InstallScope, identity: PluginIdentity, ): string | undefined => host === 'claude' && scope !== 'user' ? identity.bundleRoot : undefined; @@ -419,7 +424,7 @@ export interface PublicHostMarketplaceEntry { } export const parsePublicHostMarketplaces = ( - host: Exclude, + host: PublicInstallHost, stdout: string, ): readonly PublicHostMarketplaceEntry[] | undefined => { let document: unknown; @@ -491,7 +496,7 @@ export const readCodexMarketplaceSource = async ( export const readPublicHostMarketplaceState = async ( runner: InstallCommandRunner, identity: PluginIdentity, - host: Exclude, + host: PublicInstallHost, marketplace: string, ): Promise<'absent' | 'present' | 'unknown'> => { let stdout: string; @@ -516,7 +521,7 @@ export const cursorMarketplaceReceiptPath = (cursorRoot: string, plugin: string) const installPublicCli = async ( options: InstallBundleOptions, identity: PluginIdentity, - host: Exclude, + host: PublicInstallHost, scope: InstallScope, ): Promise => { if (host === 'codex' && scope !== 'user') { @@ -1098,6 +1103,161 @@ const installCursor = Effect.fnUntraced(function*( return yield* program.pipe(Effect.mapError(installFailure('cursor'))); }); +const ampNextSteps = Object.freeze([ + 'Open Amp’s command palette with Ctrl+O and run `plugins: reload`.', + 'Run `amp plugins list` in a shell to inspect the installed plugin.', +]); + +export const ampInstallLocation = ( + options: InstallBundleOptions, + identity: PluginIdentity, + scope: InstallScope, +): { + readonly destination: string; + readonly hostDirectories: readonly string[]; + readonly hostRoot: string; + readonly installRoot: string; + readonly relativeDestination: string; + readonly registration: 'amp-project-plugin' | 'amp-system-plugin'; +} => { + if (scope === 'local') { + throw failure('AB7003', 'Amp plugin installation supports project or user scope, not local.', 'amp'); + } + const home = options.home ?? homedir(); + const environment = options.environment ?? process.env; + const hostRoot = scope === 'project' + ? resolve(options.projectRoot ?? process.cwd()) + : join(environment['XDG_CONFIG_HOME'] ?? join(home, '.config'), 'amp'); + const installRoot = scope === 'project' + ? join(hostRoot, '.amp', 'plugins') + : join(hostRoot, 'plugins'); + const relativeDestination = `${scope === 'project' ? '.amp/plugins' : 'plugins'}/${identity.plugin}`; + return { + destination: join(hostRoot, relativeDestination), + hostDirectories: scope === 'project' + ? Object.freeze(['.amp', '.amp/plugins']) + : Object.freeze(['plugins']), + hostRoot, + installRoot, + relativeDestination, + registration: scope === 'project' ? 'amp-project-plugin' : 'amp-system-plugin', + }; +}; + +const installAmp = async ( + options: InstallBundleOptions, + identity: PluginIdentity, + scope: InstallScope, +): Promise => { + const entry = identity.documents.entry; + if (entry === undefined) throw failure('AB7001', 'The Amp projection has no plugin entry.', 'amp'); + const entryRoot = posix.dirname(entry); + const source = join(identity.bundleRoot, entryRoot); + const verified = await bundleInventory(identity, { restoreModes: true }); + const prefix = `${entryRoot}/`; + const expectedFiles = verified.files + .filter((file) => file.startsWith(prefix)) + .map((file) => file.slice(prefix.length)); + const artifact = await treeInventory(source); + if ( + artifact.files.length !== expectedFiles.length + || artifact.files.some((file, index) => file !== expectedFiles[index]) + ) { + throw failure( + 'AB7001', + `Amp plugin directory ${source} does not match its manifest-owned directory.`, + 'amp', + ); + } + const location = ampInstallLocation(options, identity, scope); + const base = { + bundleRoot: identity.bundleRoot, + contentHash: artifact.hash, + destination: location.destination, + host: 'amp', + mode: 'local', + nextSteps: ampNextSteps, + plugin: identity.plugin, + receipt: join(location.destination, installReceiptFile), + version: identity.version, + } as const; + if (resolve(source) === resolve(location.destination)) { + throw failure( + 'AB7005', + 'The Amp source directory is already the project destination; use a separate artifact output so installation can write an ownership receipt without modifying built bytes.', + 'amp', + ); + } + await assertRealAncestors( + location.hostRoot, + [`${location.relativeDestination}/index.js`], + ); + const created = []; + for (const relativePath of location.hostDirectories) { + if (!await exists(join(location.hostRoot, relativePath))) created.push(relativePath); + } + await mkdir(location.installRoot, { recursive: true }); + const receipt: InstallReceiptIdentity = { + host: 'amp', + hostDirectories: created, + mode: 'local', + plugin: identity.plugin, + registrations: [{ kind: location.registration }], + scope, + version: identity.version, + }; + if (!await exists(location.destination)) { + await runPromise(withStagedArtifact( + () => stageArtifact({ + artifactRoot: source, + destination: location.destination, + inventory: artifact, + receipt, + stageRoot: location.installRoot, + }), + (staged) => rename(staged.root, location.destination), + )); + return { ...base, state: 'installed' }; + } + const comparison = await compareInstalledTree({ + artifact, + destination: location.destination, + plugin: identity.plugin, + version: identity.version, + }); + if (comparison.status === 'current') return { ...base, state: 'already-installed' }; + if (comparison.status === 'foreign') { + throw failure('AB7005', collisionMessage(location.destination, identity, comparison), 'amp'); + } + if (comparison.status === 'version-mismatch' && options.replace !== true) { + throw failure('AB7005', collisionMessage(location.destination, identity, comparison), 'amp'); + } + const replacement: InstallReceiptIdentity = { + ...receipt, + hostDirectories: comparison.receipt?.hostDirectories ?? [], + }; + await runPromise(withStagedArtifact( + () => stageArtifact({ + artifactRoot: source, + destination: location.destination, + inventory: artifact, + receipt: replacement, + stageRoot: location.installRoot, + }), + (staged) => replaceInstalledTree({ + comparison, + destination: location.destination, + receipt: replacement, + staged, + }), + )); + return { + ...base, + previousContentHash: comparison.installedContentHash, + state: 'replaced', + }; +}; + /** * The install program. The Cursor branch is Effect-native with a * `DiagnosticError` channel; `readBundleIdentity` and the public-CLI installers are @@ -1118,6 +1278,8 @@ const installProgram = Effect.fnUntraced(function*( } const identity = yield* liftPromise(() => readBundleIdentity(options.from, options.host)); switch (options.host) { + case 'amp': + return yield* liftPromise(() => installAmp(options, identity, scope)); case 'claude': case 'codex': { const host = options.host; diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index d4871ecb0..e33dba0c0 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -52,7 +52,7 @@ export const legacyInstallReceiptFormat = 'agent-bundle-install-receipt/1'; /** * How the install was delivered: `local` copies into a host-loaded directory - * (Cursor `plugins/local`), `marketplace` stages a local marketplace repository + * (Cursor `plugins/local`, Amp project/system plugins), `marketplace` stages a local marketplace repository * (Cursor Customize import), `host-cli` registered through the host's own * plugin CLI (Claude, Codex). */ @@ -65,6 +65,8 @@ export type InstallReceiptScope = 'local' | 'project' | 'user'; * reverses exactly that and nothing else. */ export type InstallRegistrationKind = + | 'amp-project-plugin' + | 'amp-system-plugin' | 'claude-marketplace' | 'claude-plugin' | 'codex-marketplace' @@ -73,6 +75,8 @@ export type InstallRegistrationKind = | 'cursor-marketplace-staging'; export const installRegistrationKinds: readonly InstallRegistrationKind[] = Object.freeze([ + 'amp-project-plugin', + 'amp-system-plugin', 'claude-marketplace', 'claude-plugin', 'codex-marketplace', diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 880a8ff74..37cd2d313 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -231,10 +231,33 @@ const cursorInstructions = (model: NormalizedPlugin): string[] => [ '', ]; +const ampInstructions = (model: NormalizedPlugin): string[] => [ + '## Amp', + '', + `This build contains one directory plugin at \`.amp/plugins/${model.metadata.name}/\`. Copy that directory`, + 'into the target project at the same path, or into the system plugin root:', + '', + '```sh', + `mkdir -p "\${XDG_CONFIG_HOME:-$HOME/.config}/amp/plugins/${model.metadata.name}"`, + `cp -R ./.amp/plugins/${model.metadata.name}/. "\${XDG_CONFIG_HOME:-$HOME/.config}/amp/plugins/${model.metadata.name}/"`, + 'amp plugins list', + '```', + '', + `From the destination project root, the optional \`agent-bundle install amp --from --scope project\``, + 'makes the project copy. The default `--scope user` makes the system copy. Both write a receipt inside only', + 'the copied plugin directory; replacement preserves unowned files and Amp settings, and the optional', + '`agent-bundle uninstall amp` removes only receipt-owned files.', + '', + 'Amp reload is interactive. In a running session open the command palette with Ctrl+O and run', + '`plugins: reload`; Agent Bundle never automates it. `amp plugins list` in another shell inspects plugins but', + 'does not reload the running session.', + '', +]; + /** * The third-party clients recorded in the pinned portable capability table * (#693–#714). Every sentence this section prints about a client outside the - * four shipped adapters comes from a record there, so the install surface + * shipped adapters comes from a record there, so the install surface * names what each client's own documentation says it loads — and what it does * not — instead of asserting a bare list of native clients. */ @@ -421,6 +444,8 @@ const instructionsFor = ( planned: readonly string[], ): string[] => { switch (target) { + case 'amp': + return ampInstructions(model); case 'claude': return claudeInstructions(model); case 'codex': diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 43fed227f..6dd7acd7e 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -13,6 +13,7 @@ import { liftPromise } from '../effect/lift.ts'; import { cacheHasPlugin, readHeadCommit } from './cursor-hooks-registration.ts'; import { cursorMarketplaceName, cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; import { + ampInstallLocation, cursorMarketplaceReceiptPath, defaultCommandRunner, publicHostMarketplaceRemoveArguments, @@ -32,6 +33,7 @@ import { type InstallHost, type InstallMode, type InstallScope, + type PublicInstallHost, type PublicHostInstalledEntry, } from './install.ts'; import { installedBundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; @@ -88,7 +90,8 @@ export type UninstallDataPolicy = 'keep' | 'purge'; * cache copy, `state/` included, for its ~14-day grace period) and * `removed-by-host` (Codex deletes the cached tree, `state/` included, on * `plugin remove`) name the host behaviour that decided instead; `unavailable` - * means the delivery holds no runtime state (a staged marketplace repository). + * means the delivery has no Agent Bundle-owned runtime state (an Amp directory + * plugin or a staged marketplace repository). */ export type UninstallDataOutcome = | 'absent' @@ -204,6 +207,8 @@ export interface UninstallBundleOptions { readonly mode?: InstallMode; /** Compute and report the exact plan without changing anything. */ readonly plan?: boolean; + /** Amp project-scope destination; defaults to the calling process cwd. */ + readonly projectRoot?: string; /** Remove durable runtime state too; refused without `confirmPurge`. */ readonly purgeData?: boolean; readonly scope?: InstallScope; @@ -1052,7 +1057,7 @@ const readClaudeInstalledPluginsRegistry = async (hostRoot: string): Promise, + host: PublicInstallHost, marketplace: string, id: string, scope: InstallScope, @@ -1140,7 +1145,7 @@ interface PublicHostData { } const publicHostData = async ( - host: Exclude, + host: PublicInstallHost, policy: UninstallDataPolicy, entry: PublicHostInstalledEntry | undefined, hostRoot: string, @@ -1258,7 +1263,7 @@ const publicHostData = async ( const uninstallPublicCli = async ( options: UninstallBundleOptions, identity: PluginIdentity, - host: Exclude, + host: PublicInstallHost, scope: InstallScope, policy: UninstallDataPolicy, ): Promise => { @@ -1545,6 +1550,124 @@ const uninstallPublicCli = async ( }); }; +const uninstallAmp = async ( + options: UninstallBundleOptions, + identity: PluginIdentity, + scope: InstallScope, + policy: UninstallDataPolicy, +): Promise => { + const force = options.force === true; + const location = ampInstallLocation(options, identity, scope); + await assertRealAncestors( + location.hostRoot, + [`${location.relativeDestination}/index.js`], + ); + const receiptPath = join(location.destination, installReceiptFile); + const receipt = await readInstallReceipt(location.destination); + const registration = scope === 'project' ? 'amp-project-plugin' as const : 'amp-system-plugin' as const; + const base = { + bundleRoot: identity.bundleRoot, + data: Object.freeze({ + detail: 'Amp directory plugins have no Agent Bundle-owned durable state; unowned files are retained.', + outcome: 'unavailable' as const, + paths: Object.freeze([]), + policy, + }), + destination: location.destination, + forced: force, + host: 'amp', + mode: 'local' as const, + plugin: identity.plugin, + scope, + version: identity.version, + } as const; + if (await realDirectory(location.destination, 'amp') === undefined) { + return Object.freeze({ + ...base, + receipt: receiptReport(receiptPath, undefined, 'missing'), + registrations: Object.freeze([Object.freeze({ action: 'already-absent' as const, kind: registration })]), + removed: Object.freeze({ directories: Object.freeze([]), files: Object.freeze([]) }), + retained: Object.freeze([]), + state: 'not-installed', + }); + } + if (receipt === undefined) { + throw failure( + 'AB7009', + `Refusing to uninstall Amp plugin directory ${location.destination}: no Agent Bundle install receipt exists, ` + + 'so none of its files are proven owned. Remove the foreign directory manually.', + 'amp', + ); + } + if ( + receipt.host !== 'amp' + || receipt.plugin !== identity.plugin + || receipt.scope !== scope + || !receipt.registrations.some((entry) => entry.kind === registration) + ) { + throw failure( + 'AB7007', + `Refusing to uninstall Amp plugin directory ${location.destination}: its receipt does not match this plugin, scope, and registration.`, + 'amp', + ); + } + const installedHash = await hashOwnedFiles(location.destination, receipt.files); + const status: UninstallReceiptStatus = installedHash === receipt.contentHash + ? receipt.migratedFrom === undefined ? 'consumed' : 'migrated' + : 'forced-mismatch'; + if (installedHash !== receipt.contentHash && !force) { + throw failure( + 'AB7007', + `Refusing to uninstall Amp plugin directory ${location.destination}: owned content changed after installation. ` + + 'Re-run with --force to remove the receipt-owned files; unowned files are still retained.', + 'amp', + ); + } + await assertRealAncestors(location.destination, receipt.files); + const retained = await listRetained( + location.destination, + new Set(receipt.files), + new Set(receipt.directories), + ); + const files = [...receipt.files.map((file) => join(location.destination, file)), receiptPath]; + const internalDirectories = receipt.directories.map((directory) => join(location.destination, directory)); + const hostDirectories = receipt.hostDirectories.map((directory) => join(location.hostRoot, directory)); + const registrationReport = Object.freeze({ + action: options.plan === true ? 'planned' as const : 'removed' as const, + detail: 'The receipt-owned directory plugin is removed; run `plugins: reload` interactively in a running Amp session.', + kind: registration, + }); + const result = { + ...base, + receipt: receiptReport(receiptPath, receipt, status), + registrations: Object.freeze([registrationReport]), + retained: Object.freeze(retained), + }; + const directoryCandidates = [...internalDirectories, location.destination, ...hostDirectories]; + if (options.plan === true) { + const directories = await simulatePrune(directoryCandidates, new Set(files)); + return Object.freeze({ + ...result, + removed: Object.freeze({ + directories: Object.freeze(directories), + files: Object.freeze(files), + }), + state: 'planned', + }); + } + for (const file of receipt.files) await rm(join(location.destination, file), { force: true }); + await rm(receiptPath, { force: true }); + const removedDirectories = await pruneDirectories(directoryCandidates); + return Object.freeze({ + ...result, + removed: Object.freeze({ + directories: Object.freeze(removedDirectories), + files: Object.freeze(files), + }), + state: 'uninstalled', + }); +}; + const uninstallProgram = Effect.fnUntraced(function*( options: UninstallBundleOptions, ): Effect.fn.Return { @@ -1559,6 +1682,8 @@ const uninstallProgram = Effect.fnUntraced(function*( const policy = resolveDataPolicy(options); const identity = yield* liftPromise(() => readBundleIdentity(options.from, options.host)); switch (options.host) { + case 'amp': + return yield* liftPromise(() => uninstallAmp(options, identity, scope, policy)); case 'claude': return yield* liftPromise(() => uninstallPublicCli(options, identity, 'claude', scope, policy)); case 'codex': diff --git a/packages/agent-bundle/src/routes/events.ts b/packages/agent-bundle/src/routes/events.ts index a028840c0..575509461 100644 --- a/packages/agent-bundle/src/routes/events.ts +++ b/packages/agent-bundle/src/routes/events.ts @@ -206,7 +206,7 @@ export type AgentEventPayload>>> > = deepFreeze({ + amp: Object.freeze({ + 'prompt/submit': pick(standardKeys, ['sessionId', 'prompt']), + 'session/start': pick(standardKeys, ['sessionId']), + stop: pick(standardKeys, ['sessionId']), + 'tool/after': pick(standardKeys, ['sessionId', 'toolName', 'toolInput', 'toolUseId', 'toolResponse']), + 'tool/before': pick(standardKeys, ['sessionId', 'toolName', 'toolInput', 'toolUseId']), + }), claude: Object.freeze({ 'agent/idle': pick(standardKeys, [...claudeSession, 'teammateName', 'teamName']), 'agent/start': pick(standardKeys, claudeSession), @@ -362,4 +369,4 @@ export const agentEventPayloadNativeKeys: Readonly< }); export const isAgentEventPayloadHost = (target: string): target is AgentEventPayloadHost => - target === 'claude' || target === 'codex' || target === 'cursor'; + target === 'amp' || target === 'claude' || target === 'codex' || target === 'cursor'; diff --git a/packages/agent-bundle/src/skills/define.ts b/packages/agent-bundle/src/skills/define.ts index f6083b1ad..90e97841c 100644 --- a/packages/agent-bundle/src/skills/define.ts +++ b/packages/agent-bundle/src/skills/define.ts @@ -1,7 +1,8 @@ -import type { ClaudeSkillExtension, CodexSkillExtension, CursorSkillExtension } from './ir.ts'; +import type { AmpSkillExtension, ClaudeSkillExtension, CodexSkillExtension, CursorSkillExtension } from './ir.ts'; import { skillTokenSpellings } from './tokens.ts'; export interface DefinedSkillTargets { + readonly amp?: AmpSkillExtension; readonly claude?: ClaudeSkillExtension; readonly codex?: CodexSkillExtension; readonly cursor?: CursorSkillExtension; diff --git a/packages/agent-bundle/src/skills/index.ts b/packages/agent-bundle/src/skills/index.ts index b6749740f..c01794306 100644 --- a/packages/agent-bundle/src/skills/index.ts +++ b/packages/agent-bundle/src/skills/index.ts @@ -3,6 +3,7 @@ export type { DefinedSkill, DefinedSkillTargets } from './define.ts'; export { inspectSkillProjection } from './inspect.ts'; export type { SkillProjectionInspection } from './inspect.ts'; export type { + AmpSkillExtension, ClaudeSkillExtension, CodexSkillExtension, CodexSkillToolDependency, diff --git a/packages/agent-bundle/src/skills/ir.ts b/packages/agent-bundle/src/skills/ir.ts index 0cf9cf2ba..c25132666 100644 --- a/packages/agent-bundle/src/skills/ir.ts +++ b/packages/agent-bundle/src/skills/ir.ts @@ -36,6 +36,11 @@ export interface CursorSkillExtension { readonly paths?: readonly string[]; } +export interface AmpSkillExtension { + readonly builtinTools?: readonly string[]; + readonly mcpServers?: Readonly>; +} + export interface CodexSkillToolDependency { readonly description?: string; readonly transport?: string; @@ -62,6 +67,7 @@ export interface CodexSkillExtension { } export interface SkillIrExtensions { + readonly amp?: AmpSkillExtension; readonly claude?: ClaudeSkillExtension; readonly codex?: CodexSkillExtension; readonly cursor?: CursorSkillExtension; diff --git a/packages/agent-bundle/src/skills/lower.ts b/packages/agent-bundle/src/skills/lower.ts index 6a01d855e..257c99025 100644 --- a/packages/agent-bundle/src/skills/lower.ts +++ b/packages/agent-bundle/src/skills/lower.ts @@ -1,5 +1,6 @@ import { stringify as stringifyYaml } from 'yaml'; +import { ampMcpDocumentIssues } from '../adapters/amp-mcp.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; import { @@ -12,6 +13,7 @@ import { type SkillHostDocumentIssue, } from '../schemas/skill-hosts/contract.ts'; import type { + AmpSkillExtension, ClaudeSkillExtension, CodexSkillExtension, CursorSkillExtension, @@ -75,6 +77,15 @@ const cursorFrontmatter = ( paths: extension?.paths, }); +const ampFrontmatter = ( + portable: PortableSkillMetadata, + extension: AmpSkillExtension | undefined, +): Record => omitUndefined({ + ...portableFrontmatter(portable), + 'builtin-tools': extension?.builtinTools, + mcpServers: extension?.mcpServers, +}); + const codexSidecarDocument = (extension: CodexSkillExtension): Record => omitUndefined({ ...(extension.dependencies === undefined ? {} : { dependencies: omitUndefined({ @@ -120,6 +131,24 @@ const validateFrontmatter = ( source: string, ): Diagnostic[] => { switch (host) { + case 'amp': { + const portable = Object.fromEntries(Object.entries(frontmatter).filter(([key]) => + ['allowed-tools', 'compatibility', 'description', 'license', 'metadata', 'name'].includes(key))); + return [ + ...schemaIssues(host, validateAgentSkillsFrontmatter(portable), source), + ...schemaIssues(host, frontmatter.mcpServers === undefined + ? [] + : ampMcpDocumentIssues(frontmatter.mcpServers).map((issue) => { + const field = issue.path === '' ? 'mcpServers' : `mcpServers.${issue.path}`; + return { + field, + instancePath: `/${field.replaceAll('.', '/')}`, + keyword: 'amp-mcp', + message: issue.message, + }; + }), source), + ]; + } case 'claude': return schemaIssues(host, validateClaudeSkillFrontmatter(frontmatter), source); case 'cursor': @@ -202,6 +231,9 @@ export const lowerSkillIr = (ir: SkillIr, host: SkillHost): SkillHostDocument => let frontmatter: Record; switch (host) { + case 'amp': + frontmatter = ampFrontmatter(ir.portable, ir.extensions.amp); + break; case 'claude': frontmatter = claudeFrontmatter(ir.portable, ir.extensions.claude); break; diff --git a/packages/agent-bundle/src/skills/parse-ir.ts b/packages/agent-bundle/src/skills/parse-ir.ts index 5c96c7897..d8c0ed537 100644 --- a/packages/agent-bundle/src/skills/parse-ir.ts +++ b/packages/agent-bundle/src/skills/parse-ir.ts @@ -3,6 +3,7 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; import { isRecord } from '../core/strict-json.ts'; import type { + AmpSkillExtension, ClaudeSkillExtension, CodexSkillExtension, CursorSkillExtension, @@ -31,6 +32,7 @@ const claudeOnlyKeys = new Set([ ]); const sharedKeys = new Set(['disable-model-invocation', 'paths']); const cursorOnlyKeys = new Set(['color', 'globs', 'icon']); +const ampOnlyKeys = new Set(['builtin-tools', 'mcpServers']); const authoringKeys = new Set(['targets']); const claudeTargetKeys = new Set([ ...claudeOnlyKeys, @@ -66,6 +68,7 @@ const codexInterfaceKeys = new Set([ const codexPolicyKeys = new Set(['allowImplicitInvocation', 'allow_implicit_invocation']); const codexDependenciesKeys = new Set(['tools']); const codexToolKeys = new Set(['description', 'transport', 'type', 'url', 'value']); +const ampTargetKeys = new Set(['builtin-tools', 'builtinTools', 'mcpServers']); const asString = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined; @@ -153,6 +156,17 @@ const cursorFrom = (fields: Readonly>): CursorSkillExten return Object.keys(extension).length === 0 ? undefined : Object.freeze(extension); }; +const ampFrom = (fields: Readonly>): AmpSkillExtension | undefined => { + const builtinTools = fields.builtinTools ?? fields['builtin-tools']; + const extension: AmpSkillExtension = { + ...(Array.isArray(builtinTools) && builtinTools.every((tool) => typeof tool === 'string') + ? { builtinTools: Object.freeze([...builtinTools]) } + : {}), + ...(isRecord(fields.mcpServers) ? { mcpServers: deepFreeze({ ...fields.mcpServers }) } : {}), + }; + return Object.keys(extension).length === 0 ? undefined : Object.freeze(extension); +}; + const pickString = (record: Readonly>, camel: string, snake: string): string | undefined => asString(record[camel]) ?? asString(record[snake]); @@ -226,6 +240,15 @@ const mergeCursor = ( return Object.freeze({ ...left, ...right }); }; +const mergeAmp = ( + left: AmpSkillExtension | undefined, + right: AmpSkillExtension | undefined, +): AmpSkillExtension | undefined => { + if (left === undefined) return right; + if (right === undefined) return left; + return Object.freeze({ ...left, ...right }); +}; + const unknownField = (source: string, field: string): Diagnostic => ({ code: 'AB3006', message: `Skill frontmatter field ${JSON.stringify(field)} is not a portable Agent Skills field or a typed host extension.`, @@ -264,14 +287,15 @@ const peelTargets = ( if (!isRecord(value)) { diagnostics.push({ code: 'AB3006', - message: 'Skill `targets` must be an object with optional `claude`, `cursor`, and `codex` keys.', + message: 'Skill `targets` must be an object with optional `amp`, `claude`, `cursor`, and `codex` keys.', recovery: 'Replace `targets` with a typed per-host object.', severity: 'error', sourcePath: source, }); return {}; } - const unknown = Object.keys(value).filter((key) => key !== 'claude' && key !== 'codex' && key !== 'cursor'); + const unknown = Object.keys(value).filter((key) => + key !== 'amp' && key !== 'claude' && key !== 'codex' && key !== 'cursor'); for (const key of unknown) diagnostics.push(unknownField(source, `targets.${key}`)); if (isRecord(value.claude)) { reportUnknownFields(value.claude, claudeTargetKeys, 'targets.claude', source, diagnostics); @@ -322,6 +346,9 @@ const peelTargets = ( } } } + if (isRecord(value.amp)) { + reportUnknownFields(value.amp, ampTargetKeys, 'targets.amp', source, diagnostics); + } const claude = isRecord(value.claude) ? claudeFrom({ ...value.claude, @@ -341,6 +368,9 @@ const peelTargets = ( : undefined; const codex = codexFrom(value.codex); return { + ...(ampFrom(isRecord(value.amp) ? value.amp : {}) === undefined + ? {} + : { amp: ampFrom(isRecord(value.amp) ? value.amp : {}) }), ...(claude === undefined ? {} : { claude }), ...(codex === undefined ? {} : { codex }), ...(cursor === undefined ? {} : { cursor }), @@ -355,23 +385,30 @@ export const parseSkillIr = (document: SkillDocument): SkillIr => { !claudeOnlyKeys.has(key) && !sharedKeys.has(key) && !cursorOnlyKeys.has(key) && + !ampOnlyKeys.has(key) && !authoringKeys.has(key) ); for (const key of unknownKeys) diagnostics.push(unknownField(document.source, key)); const peeledClaude: Record = {}; const peeledCursor: Record = {}; + const peeledAmp: Record = {}; for (const [key, value] of Object.entries(frontmatter)) { if (claudeOnlyKeys.has(key) || sharedKeys.has(key)) peeledClaude[key] = value; if (cursorOnlyKeys.has(key) || sharedKeys.has(key)) peeledCursor[key] = value; + if (ampOnlyKeys.has(key)) peeledAmp[key] = value; } const fromFrontmatter: SkillIrExtensions = { + ...(ampFrom(peeledAmp) === undefined ? {} : { amp: ampFrom(peeledAmp) }), ...(claudeFrom(peeledClaude) === undefined ? {} : { claude: claudeFrom(peeledClaude) }), ...(cursorFrom(peeledCursor) === undefined ? {} : { cursor: cursorFrom(peeledCursor) }), }; const fromTargets = peelTargets(frontmatter.targets ?? document.authoredTargets, document.source, diagnostics); const extensions: SkillIrExtensions = Object.freeze({ + ...(mergeAmp(fromFrontmatter.amp, fromTargets.amp) === undefined + ? {} + : { amp: mergeAmp(fromFrontmatter.amp, fromTargets.amp) }), ...(mergeClaude(fromFrontmatter.claude, fromTargets.claude) === undefined ? {} : { claude: mergeClaude(fromFrontmatter.claude, fromTargets.claude) }), @@ -385,7 +422,8 @@ export const parseSkillIr = (document: SkillDocument): SkillIr => { Object.freeze({ ...occurrence, required: true as const }), ); const sidecar = sidecarFromResource(document); - const hasExtensions = extensions.claude !== undefined || + const hasExtensions = extensions.amp !== undefined || + extensions.claude !== undefined || extensions.codex !== undefined || extensions.cursor !== undefined; const passThrough = diagnostics.every((diagnostic) => diagnostic.severity !== 'error') && diff --git a/packages/agent-bundle/src/skills/tokens.ts b/packages/agent-bundle/src/skills/tokens.ts index 20bb55e81..57bcc65f5 100644 --- a/packages/agent-bundle/src/skills/tokens.ts +++ b/packages/agent-bundle/src/skills/tokens.ts @@ -20,7 +20,7 @@ export const skillTokenSpellings = Object.freeze({ export type SkillTokenId = keyof typeof skillTokenSpellings; -export type SkillHost = 'claude' | 'codex' | 'cursor' | 'portable'; +export type SkillHost = 'amp' | 'claude' | 'codex' | 'cursor' | 'portable'; export type SkillDocumentKind = | 'commands' @@ -49,6 +49,7 @@ const codexPlugins = 'https://developers.openai.com/plugins/build/plugins (Codex const cursorSkills = 'https://prod.cursor.com/docs/skills (Cursor 2026-08-28 pin)'; const cursorPlugins = 'https://prod.cursor.com/docs/reference/plugins (cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a)'; const portableSkills = 'https://agentskills.io/specification (69ef37e9424c0a7ea9dd2293b559e43ec8176379)'; +const ampSkills = 'https://ampcode.com/docs/customize/skills (retrieved 2026-09-07)'; const none = ( token: SkillTokenId, @@ -75,6 +76,7 @@ const portable = ( type HostDocumentTable = Partial>>>; const table: Record = { + amp: {}, claude: { 'plugin-config': { pluginData: portable('pluginData', 'claude', 'plugin-config', '${CLAUDE_PLUGIN_DATA}', claudePlugins), @@ -146,6 +148,7 @@ const table: Record = { }; const noSkillMarkdown = { + amp: `${ampSkills}: Amp documents no Skill Markdown interpolation engine`, claude: claudeSkills, codex: `${codexSkills}: Codex documents no Skill Markdown interpolation engine`, cursor: `${cursorSkills}: documented \${VAR} interpolation belongs to plugin configuration, not Skill Markdown`, @@ -217,7 +220,7 @@ const hostSkillMarkdownSyntax = (host: SkillHost): readonly string[] => export const foreignSkillMarkdownSyntax = (host: SkillHost): readonly string[] => { const owned = new Set(hostSkillMarkdownSyntax(host)); const foreign = new Set(); - for (const other of ['claude', 'codex', 'cursor', 'portable'] as const) { + for (const other of ['amp', 'claude', 'codex', 'cursor', 'portable'] as const) { if (other === host) continue; for (const syntax of hostSkillMarkdownSyntax(other)) { if (!owned.has(syntax)) foreign.add(syntax); diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index 59ce8513b..ce884e097 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -19,6 +19,8 @@ import { type AgentTestProofLevel, } from './manifest.ts'; +type InstalledMcpHost = Exclude; + export type InstalledHostCheckName = | 'component-paths' | 'hook-commands' @@ -53,7 +55,7 @@ export interface InstalledHostEvidenceMetadata { export interface InstalledHostObservation { readonly checks: Readonly>; - readonly host: InstallHost; + readonly host: InstalledMcpHost; readonly metadata: InstalledHostEvidenceMetadata; readonly proofLevel: string; readonly sessionEvidence: string; @@ -63,7 +65,7 @@ export interface InstalledHostObservation { export interface InstalledHostMcpProvenance { /** Installed-root-relative command entry, never an absolute host path. */ readonly entry: string; - readonly host: InstallHost; + readonly host: InstalledMcpHost; readonly pid: number | undefined; readonly proofLevel: typeof HOST_INSTALL_PROOF_LEVEL | typeof SIMULATED_PROOF_LEVEL; } @@ -82,7 +84,7 @@ export interface OpenInstalledHostMcpServerOptions { /** Root containing `agent-bundle.manifest.json` and target directories. */ readonly artifactRoot: string; readonly env?: Readonly>; - readonly host: InstallHost; + readonly host: InstalledMcpHost; /** Version observed from the real host binary, when that lane invoked one. */ readonly hostBinaryVersion?: string; /** Host-owned installed plugin root, not the build target directory. */ @@ -173,7 +175,7 @@ const commandStrings = (value: unknown): readonly string[] => { key === 'command' && typeof nested === 'string' ? [nested] : commandStrings(nested)); }; -const expandHostPath = (value: string, host: InstallHost, installedRoot: string): string => { +const expandHostPath = (value: string, host: InstalledMcpHost, installedRoot: string): string => { switch (host) { case 'claude': return value.replaceAll('${CLAUDE_PLUGIN_ROOT}', installedRoot); diff --git a/packages/agent-bundle/src/web-host/manifest.ts b/packages/agent-bundle/src/web-host/manifest.ts index db7edc96f..5022512d6 100644 --- a/packages/agent-bundle/src/web-host/manifest.ts +++ b/packages/agent-bundle/src/web-host/manifest.ts @@ -14,7 +14,7 @@ import { pathTokens } from '../core/types.ts'; * declared here so the lean web reader bundled into generated bins and the * full parser in `build/manifest.ts` refuse the same set of documents. */ -export const artifactManifestVersion = 3; +export const artifactManifestVersion = 4; export const artifactManifestName = 'agent-bundle.manifest.json'; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index aa871dd99..48a961665 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -13,6 +13,7 @@ import { unavailableCapability, webSurfaceCapability, } from '../src/adapters/capability-state.ts'; +import ampCapabilityTable from '../src/adapters/capabilities/amp-0.0.0-20260907001852-gf348fed.json' with { type: 'json' }; import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.260.json' with { type: 'json' }; import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; import cursorCapabilityTable from '../src/adapters/capabilities/cursor-2026-08-28.json' with { type: 'json' }; @@ -48,6 +49,10 @@ it('records an honest four-state commands row on every adapter', () => { reason: 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no commands surface.', state: 'unavailable', }); + expect(registry.get('amp').capabilities.commands).toEqual({ + reason: ampCapabilityTable.plugin.commands.reason, + state: 'unavailable', + }); }); it('records an honest four-state rules row on every adapter', () => { @@ -68,6 +73,10 @@ it('records an honest four-state rules row on every adapter', () => { reason: 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no rules surface.', state: 'unavailable', }); + expect(registry.get('amp').capabilities.rules).toEqual({ + reason: ampCapabilityTable.plugin.rules.reason, + state: 'unavailable', + }); }); const codexParityCapabilityRows = { @@ -190,6 +199,10 @@ it('publishes a dated four-state lsp row on every adapter so no host is judged b reason: codexCapabilityTable.plugin.components.lsp.reason, state: 'unavailable', }); + expect(registry.get('amp').capabilities.lsp).toEqual({ + reason: ampCapabilityTable.plugin.lsp.reason, + state: 'unavailable', + }); }); it('records dated unavailable native-diagnostics and native-extension rows on every host (#100)', () => { @@ -212,6 +225,10 @@ it('records dated unavailable native-diagnostics and native-extension rows on ev reason: expect.stringContaining('Agent Plugin contract (1.0.0)'), state: 'unavailable', }); + expect(registry.get('amp').capabilities[capability]).toEqual({ + reason: ampCapabilityTable.plugin[capability].reason, + state: 'unavailable', + }); } // Claude's row points at the LSP `diagnostics` option rather than inventing a component. expect(claudeCapabilityTable.plugin.nativeDiagnostics.reason).toContain('`lsp` kind'); @@ -223,6 +240,7 @@ it('publishes dated component feature rows per kind and host (#100 feature sets) const claude = registry.get('claude').capabilities; const codex = registry.get('codex').capabilities; const cursor = registry.get('cursor').capabilities; + const amp = registry.get('amp').capabilities; const portable = registry.get('portable').capabilities; // Commands: Claude documents the five frontmatter fields; Cursor's commands @@ -256,8 +274,12 @@ it('publishes dated component feature rows per kind and host (#100 feature sets) expect(claude['skills.hostFrontmatter']).toMatchObject({ state: 'supported' }); expect(codex['skills.hostFrontmatter']).toMatchObject({ state: 'supported' }); expect(cursor['skills.hostFrontmatter']).toMatchObject({ state: 'supported' }); + expect(amp['skills.hostFrontmatter']).toMatchObject({ state: 'supported' }); + expect(amp['skills.builtinTools']).toMatchObject({ state: 'supported' }); + expect(amp['skills.mcpServers']).toMatchObject({ state: 'supported' }); expect(portable['skills.hostFrontmatter']).toMatchObject({ reason: expect.stringContaining('Agent Skills'), state: 'unavailable' }); expect(claude['skills.markdownTokens']).toMatchObject({ state: 'supported' }); + expect(amp['skills.markdownTokens']).toMatchObject({ reason: expect.stringContaining('no Skill Markdown'), state: 'unavailable' }); for (const capabilities of [codex, cursor, portable]) { expect(capabilities['skills.markdownTokens']).toMatchObject({ reason: expect.stringContaining('AB3008'), state: 'unavailable' }); } @@ -853,13 +875,15 @@ it('rejects a malformed capability declaration when the adapter registers', () = expect(() => new TargetRegistry().register(source)).not.toThrow(); }); -it('publishes the routed CLI bin capability with its bin layout on every built-in target (#387)', () => { +it('publishes the routed CLI bin capability on root-plugin targets, not the isolated Amp directory (#387)', () => { const registry = createDefaultRegistry(); - for (const name of registry.names()) { + for (const name of registry.names().filter((target) => target !== 'amp')) { const adapter = registry.get(name); expect(adapter.capabilities.cli?.state, name).toBe('supported'); expect(registry.artifactLayout(name).cliBin, name).toEqual({ allowedSuffixes: ['.mjs'], directory: 'bin' }); } + expect(registry.get('amp').capabilities.cli).toBeUndefined(); + expect(registry.artifactLayout('amp').cliBin).toBeUndefined(); // A supported `cli` row promises a place for the executable, so an adapter // without the layout — or with no artifact layout at all — cannot register; @@ -917,7 +941,7 @@ it('publishes the routed CLI bin capability with its bin layout on every built-i expect(registry.hostsComponent('unknown-target', 'cli')).toBe(false); }); -it('pins a supported web surface row on every host capability table (#564)', () => { +it('pins a supported web surface row on every root-plugin capability table (#564)', () => { const row = { reason: 'browser host inside the composite artifact; web runs from the installed root on any host', state: 'supported', @@ -1082,7 +1106,7 @@ it('reports the evidence-backed G10 event family matrix without inferred support it('reports evidence-backed installation support only for real host targets', () => { const registry = createDefaultRegistry(); - for (const target of ['claude', 'codex', 'cursor'] as const) { + for (const target of ['amp', 'claude', 'codex', 'cursor'] as const) { expect(registry.get(target).capabilities.install).toMatchObject({ evidence: { target }, state: 'supported', diff --git a/packages/agent-bundle/tests/amp-adapter.test.ts b/packages/agent-bundle/tests/amp-adapter.test.ts new file mode 100644 index 000000000..9d800809f --- /dev/null +++ b/packages/agent-bundle/tests/amp-adapter.test.ts @@ -0,0 +1,747 @@ +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { expect, it } from '@rstest/core'; + +import capabilityTable from '../src/adapters/capabilities/amp-0.0.0-20260907001852-gf348fed.json' with { type: 'json' }; +import { ampAdapter } from '../src/adapters/amp.ts'; +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { compileEvidenceFileName } from '../src/build/compile-evidence.ts'; +import { validateArtifact } from '../src/build/validate-artifact.ts'; +import type { JsonObject } from '../src/core/strict-json.ts'; +import type { NormalizedHook, NormalizedPlugin } from '../src/core/types.ts'; +import { projectEventDocument } from '../src/events/projection.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; +import { build } from './support/build.ts'; +import { runNodeScript } from './support/run-node-script.ts'; + +const configPath = '/workspace/agent-bundle.config.ts'; +const skillSource = '/workspace/src/skills/review/SKILL.md'; + +const plugin = (): NormalizedPlugin => ({ + extensions: {}, + hooks: [], + mcpServers: [ + { + args: ['-y', 'example-mcp@1.0.0'], + command: 'npx', + env: { EXAMPLE_TOKEN: '${EXAMPLE_TOKEN}' }, + id: 'mcp:local', + name: 'local', + provenance: { kind: 'config', sourcePath: configPath }, + targets: ['amp'], + transport: 'stdio', + }, + { + headers: { Authorization: 'Bearer ${AMP_TOKEN}' }, + id: 'mcp:remote', + name: 'remote', + provenance: { kind: 'config', sourcePath: configPath }, + targets: ['amp'], + transport: 'streamable-http', + url: 'https://mcp.example.test/mcp', + }, + ], + metadata: { + description: 'Review code with Amp.', + id: 'plugin:amp-review', + name: 'amp-review', + provenance: { kind: 'config', sourcePath: configPath }, + version: '1.2.3', + }, + runtime: { node: '22.12.0' }, + scripts: [], + skills: [{ + body: '# Review\n', + description: 'Review code.', + dir: '/workspace/src/skills/review', + frontmatter: { description: 'Review code.', name: 'review' }, + id: 'skill:review', + name: 'review', + provenance: { kind: 'conventional', sourcePath: skillSource }, + resources: [ + { bytes: 64, relativePath: 'SKILL.md', source: skillSource }, + { bytes: 12, relativePath: 'references/checklist.md', source: '/workspace/src/skills/review/references/checklist.md' }, + ], + source: skillSource, + targets: ['amp'], + }], + targets: [{ + id: 'target:amp', + name: 'amp', + provenance: { kind: 'config', sourcePath: configPath }, + }], +}); + +const writes = (model: NormalizedPlugin): Readonly> => Object.fromEntries( + ampAdapter.plan(model).entries + .filter((entry): entry is Extract => entry.kind === 'write') + .map((entry) => [entry.relativePath, entry.content]), +); + +it('registers Amp as a built-in directory-plugin target with pinned evidence', () => { + const registry = createDefaultRegistry(); + + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'amp']); + expect(registry.builtInHost('amp')).toBe('amp'); + expect(registry.supports('amp', 'hooks')).toBe(true); + expect(registry.supports('amp', 'mcp')).toBe(true); + expect(registry.supports('amp', 'skills')).toBe(true); + expect(registry.supports('amp', 'install')).toBe(true); + expect(registry.supports('amp', 'event:session/start')).toBe(true); + expect(registry.supports('amp', 'events.sessionStart.context')).toBe(false); + expect(registry.supports('amp', 'events.toolAfter.context')).toBe(false); + expect(registry.supports('amp', 'events.toolBefore.deny')).toBe(true); + expect(registry.mcpRuntime('amp')).toBeUndefined(); + expect(registry.artifactLayout('amp')).toMatchObject({ + rootDirectories: ['.amp'], + skills: '.amp/plugins/{plugin}/skills', + }); + expect(registry.artifactLayout('amp').assets).toBeUndefined(); + expect(capabilityTable.pluginApi.package).toBe('@ampcode/plugin'); + expect(capabilityTable.pluginApi.version).toBe('0.0.0-20260907001852-gf348fed'); + expect(capabilityTable.runtimeProof.state).toBe('unverified'); + expect(capabilityTable.lifecycle.activationState).toMatchObject({ + observable: ['placed', 'registered'], + unavailable: ['enabled', 'active'], + }); + expect(capabilityTable.plugins.precedence).toEqual(['project', 'system', 'personal', 'workspace']); + expect(capabilityTable.skills.discoveryPrecedence).toEqual([ + '~/.config/agents/skills', + '~/.agents/skills', + '~/.config/amp/skills', + '.agents/skills', + '.claude/skills', + '~/.claude/skills', + '~/.claude/plugins/cache', + 'amp.skills.path', + 'built-in', + 'personal repository', + 'workspace repository', + ]); +}); + +it('emits one private directory plugin with explicit skill registration and flat skill MCP', () => { + const plan = ampAdapter.plan(plugin()); + const documents = writes(plugin()); + const root = '.amp/plugins/amp-review'; + const entry = documents[`${root}/index.js`]; + + expect(plan.diagnostics).toEqual([]); + expect(plan.documents).toEqual({ entry: `${root}/index.js` }); + expect(entry).toContain("/** @param {import('@ampcode/plugin').PluginAPI} amp */"); + expect(entry).toContain("await amp.registerSkill({ path: 'skills/review' });"); + expect(entry?.match(/registerSkill/g)).toHaveLength(1); + expect(entry).toContain('export const description = "Review code with Amp."'); + expect(entry).toContain('export default async function ampReview(amp)'); + expect(entry).not.toMatch(/^\s*import\s/mu); + expect(entry).not.toContain('export function'); + expect(entry).not.toContain('export {'); + expect(plan.entries.map((candidate) => candidate.relativePath)).toEqual([ + `${root}/index.js`, + `${root}/skills/review/SKILL.md`, + `${root}/skills/review/mcp.json`, + `${root}/skills/review/references/checklist.md`, + ]); + expect(JSON.parse(documents[`${root}/skills/review/mcp.json`]!)).toEqual({ + local: { + args: ['-y', 'example-mcp@1.0.0'], + command: 'npx', + env: { EXAMPLE_TOKEN: '${EXAMPLE_TOKEN}' }, + }, + remote: { + headers: { Authorization: 'Bearer ${AMP_TOKEN}' }, + url: 'https://mcp.example.test/mcp', + }, + }); +}); + +it('keeps content-only skills free of compiled runtimes', () => { + const model = { ...plugin(), mcpServers: [] }; + const plan = ampAdapter.plan(model); + + expect(plan.diagnostics).toEqual([]); + expect(plan.hookEntries).toEqual([]); + expect(plan.entries.map((entry) => entry.relativePath)).toEqual([ + '.amp/plugins/amp-review/index.js', + '.amp/plugins/amp-review/skills/review/SKILL.md', + '.amp/plugins/amp-review/skills/review/references/checklist.md', + ]); +}); + +it('emits compilable private factory names for punctuation and reserved bindings', async () => { + const model = plugin(); + const dotted = { ...model, metadata: { ...model.metadata, name: 'amp.review-tools' } }; + expect(writes(dotted)['.amp/plugins/amp.review-tools/index.js']).toContain( + 'export default async function ampReviewTools(amp)', + ); + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-identifiers-')); + try { + const cases = [ + ['arguments', 'pluginArguments'], + ['await', 'pluginAwait'], + ['class', 'pluginClass'], + ['eval', 'pluginEval'], + ['My_Plugin', 'My_Plugin'], + ] as const; + for (const [name, identifier] of cases) { + const entry = writes({ ...model, metadata: { ...model.metadata, name } })[`.amp/plugins/${name}/index.js`]!; + expect(entry).toContain(`export default async function ${identifier}(amp)`); + const path = join(root, `${name}.mjs`); + await writeFile(path, entry); + const loaded = await import(`${pathToFileURL(path).href}?reserved=${name}`) as { readonly default?: unknown }; + expect(typeof loaded.default).toBe('function'); + } + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('rejects plugin names that are not one portable directory segment', () => { + const model = plugin(); + for (const name of ['../escape', 'a/b', String.raw`a\b`]) { + expect(ampAdapter.plan({ + ...model, + metadata: { ...model.metadata, name }, + }).diagnostics).toContainEqual(expect.objectContaining({ + code: 'amp.name', + severity: 'error', + })); + } +}); + +it('preserves native frontmatter and sibling MCP precedence over generated skill MCP', () => { + const model = plugin(); + const skill = model.skills[0]!; + const ampSkill = { + diagnostics: [], + frontmatter: { + 'builtin-tools': ['review_status'], + description: 'Review code.', + mcpServers: { + native: { includeTools: ['review_*'], url: 'https://native.example.test/mcp' }, + }, + name: 'review', + }, + passThrough: false, + sidecars: [], + skillMarkdown: [ + '---', + 'name: review', + 'description: Review code.', + 'builtin-tools:', + ' - review_status', + 'mcpServers:', + ' native:', + ' url: https://native.example.test/mcp', + ' includeTools:', + ' - review_*', + '---', + '# Review', + '', + ].join('\n'), + target: 'amp', + tokenLowering: [], + } as const; + const planned = ampAdapter.plan({ + ...model, + skills: [{ + ...skill, + hostDocuments: { amp: ampSkill }, + resources: [ + ...skill.resources, + { bytes: 20, relativePath: 'mcp.json', source: '/workspace/src/skills/review/mcp.json' }, + ], + }], + }); + + expect(planned.diagnostics).toContainEqual(expect.objectContaining({ + code: 'amp.mcp.precedence', + severity: 'error', + target: 'amp', + })); + expect(planned.entries.filter((entry) => entry.relativePath.endsWith('/mcp.json'))).toEqual([ + expect.objectContaining({ + kind: 'copy', + relativePath: '.amp/plugins/amp-review/skills/review/mcp.json', + source: '/workspace/src/skills/review/mcp.json', + }), + ]); + expect(writes({ + ...model, + mcpServers: [], + skills: [{ ...skill, hostDocuments: { amp: ampSkill } }], + })['.amp/plugins/amp-review/skills/review/SKILL.md']).toContain('builtin-tools:'); +}); + +it('refuses generated local MCP, ambiguous skill scope, and unregistered prebuilt hooks', () => { + const model = plugin(); + const generated = ampAdapter.plan({ + ...model, + mcpServers: [{ + ...model.mcpServers[0]!, + args: ['mcp/mcp-local-deadbeef.mjs'], + source: '/workspace/src/mcp/local.ts', + }], + }); + const commandPath = ampAdapter.plan({ + ...model, + mcpServers: [{ ...model.mcpServers[0]!, command: './server.mjs' }], + }); + const remotePathTokens = ampAdapter.plan({ + ...model, + mcpServers: [{ + ...model.mcpServers[1]!, + headers: { Authorization: 'agent-bundle:path:plugin-root/token' }, + url: 'agent-bundle:path:plugin-root/mcp', + }], + }); + const secondSkill = { + ...model.skills[0]!, + id: 'skill:other', + name: 'other', + source: '/workspace/src/skills/other/SKILL.md', + targets: ['amp'], + }; + const ambiguous = ampAdapter.plan({ ...model, skills: [...model.skills, secondSkill] }); + const prebuilt = ampAdapter.plan({ + ...model, + hooks: [{ + args: ['--check'], + event: 'beforeTool', + id: 'hook:prebuilt', + name: 'prebuilt', + prebuiltPath: 'payload/prebuilt.mjs', + provenance: { kind: 'prebuilt', sourcePath: configPath }, + source: '/workspace/payload/prebuilt.mjs', + targets: ['amp'], + tools: [], + }], + }); + const timeout = ampAdapter.plan({ + ...model, + hooks: [{ + event: 'beforeTool', + id: 'hook:timeout', + name: 'timeout', + provenance: { kind: 'config', sourcePath: configPath }, + source: '/workspace/src/hooks/timeout.ts', + targets: ['amp'], + timeoutMs: 1_000, + tools: [], + }], + }); + + expect(generated.diagnostics).toContainEqual(expect.objectContaining({ + code: 'amp.mcp.generated-local', + severity: 'error', + })); + expect(commandPath.diagnostics).toContainEqual(expect.objectContaining({ + code: 'amp.mcp.command', + severity: 'error', + })); + expect(remotePathTokens.diagnostics).toContainEqual(expect.objectContaining({ + code: 'amp.mcp.path-token.url', + severity: 'error', + })); + expect(remotePathTokens.diagnostics).toContainEqual(expect.objectContaining({ + code: 'amp.mcp.path-token.headers-authorization', + severity: 'error', + })); + expect(ambiguous.diagnostics).toContainEqual(expect.objectContaining({ + code: 'amp.mcp.skill-scope', + severity: 'error', + })); + expect(prebuilt.diagnostics).toContainEqual(expect.objectContaining({ + code: 'amp.hook.prebuilt', + severity: 'error', + })); + expect(timeout.diagnostics).toContainEqual(expect.objectContaining({ + code: 'amp.hook.timeout', + severity: 'error', + })); +}); + +const eventHook = ( + id: string, + event: NormalizedHook['event'], + canonical: NonNullable['event'], +): NormalizedHook => ({ + event, + eventRoute: { + event: canonical, + fallback: 'standalone', + runtime: 'standalone', + }, + id: `event:${id}`, + name: id, + provenance: { kind: 'conventional', sourcePath: `/workspace/src/events/${id}.tsx` }, + source: `/workspace/src/events/${id}.tsx`, + targets: ['amp'], + tools: [], +}); + +const callbackPlugin = (): NormalizedPlugin => ({ + ...plugin(), + hooks: [ + eventHook('session-start', 'sessionStart', 'session/start'), + eventHook('tool-before', 'beforeTool', 'tool/before'), + eventHook('tool-after', 'afterTool', 'tool/after'), + eventHook('prompt-submit', 'promptSubmit', 'prompt/submit'), + eventHook('stop', 'stop', 'stop'), + ], + mcpServers: [], +}); + +interface SpawnInput { + readonly hook_event_name?: unknown; + readonly status?: unknown; + readonly tool_name?: unknown; +} + +interface SpawnLaunch { + readonly command: readonly string[]; + readonly environment: Readonly>; +} + +const fakeSpawn = ( + seen: SpawnInput[], + launches: SpawnLaunch[], +): (( + command: readonly string[], + options: { readonly env: Readonly> }, +) => Readonly>) => (command, options) => { + launches.push({ command, environment: options.env }); + let input = ''; + let output = ''; + let settle!: (code: number) => void; + const exited = new Promise((resolve) => { + settle = resolve; + }); + const body = new ReadableStream({ + async start(controller) { + await exited; + if (output !== '') controller.enqueue(new TextEncoder().encode(output)); + controller.close(); + }, + }); + return { + exited, + stderr: new ReadableStream({ + async start(controller) { + await exited; + controller.close(); + }, + }), + stdin: { + end() { + const native = JSON.parse(input) as SpawnInput; + seen.push(native); + if (native.hook_event_name === 'tool.call') { + if (native.tool_name === 'deny') output = JSON.stringify({ action: 'reject-and-continue', message: 'denied' }); + if (native.tool_name === 'modify') output = JSON.stringify({ action: 'modify', input: { changed: true } }); + if (native.tool_name === 'synthesize') output = JSON.stringify({ action: 'synthesize', result: { exitCode: 7, output: 'synthetic' } }); + } + if (native.hook_event_name === 'tool.result') { + output = JSON.stringify({ output: 'replaced', status: 'done' }); + } + if (native.hook_event_name === 'agent.start') { + output = JSON.stringify({ message: { content: 'context' } }); + } + if (native.hook_event_name === 'agent.end') { + output = JSON.stringify({ action: 'continue', userMessage: 'follow up' }); + } + settle(0); + }, + write(chunk: string | Uint8Array) { + input += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk); + }, + }, + stdout: body, + }; +}; + +it('registers inline documented callbacks and maps every native result exactly', async () => { + const source = writes(callbackPlugin())['.amp/plugins/amp-review/index.js']!; + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-factory-')); + const entry = join(root, 'index.mjs'); + const seen: SpawnInput[] = []; + const launches: SpawnLaunch[] = []; + const handlers = new Map, context: Record) => unknown>(); + const previousBun = Reflect.get(globalThis, 'Bun'); + Reflect.set(globalThis, 'Bun', { spawn: fakeSpawn(seen, launches) }); + await writeFile(entry, source); + try { + const loaded = await import(`${pathToFileURL(entry).href}?run=${Date.now()}`) as { + readonly default: (amp: Record) => Promise; + readonly description: string; + }; + const registrations: string[] = []; + await loaded.default({ + on(event: string, handler: (value: Record, context: Record) => unknown) { + handlers.set(event, handler); + return {}; + }, + async registerSkill(definition: { readonly path: string }) { + registrations.push(definition.path); + return {}; + }, + }); + + expect(loaded.description).toBe('Review code with Amp.'); + expect(Object.keys(loaded).sort()).toEqual(['default', 'description']); + expect(registrations).toEqual(['skills/review']); + expect([...handlers.keys()]).toEqual(['session.start', 'tool.call', 'tool.result', 'agent.start', 'agent.end']); + + const thread = { id: 'thread-1' }; + await handlers.get('session.start')!({ thread }, {}); + await expect(handlers.get('tool.call')!({ input: {}, thread, tool: 'pass', toolUseID: '1' }, {})).resolves.toEqual({ action: 'allow' }); + await expect(handlers.get('tool.call')!({ input: {}, thread, tool: 'deny', toolUseID: '2' }, {})).resolves.toEqual({ + action: 'reject-and-continue', + message: 'denied', + }); + await expect(handlers.get('tool.call')!({ input: {}, thread, tool: 'modify', toolUseID: '3' }, {})).resolves.toEqual({ + action: 'modify', + input: { changed: true }, + }); + await expect(handlers.get('tool.call')!({ input: {}, thread, tool: 'synthesize', toolUseID: '4' }, {})).resolves.toEqual({ + action: 'synthesize', + result: { exitCode: 7, output: 'synthetic' }, + }); + await expect(handlers.get('tool.result')!({ + input: {}, + output: 'original', + status: 'done', + thread, + tool: 'replace', + toolUseID: '5', + }, {})).resolves.toEqual({ output: 'replaced', status: 'done' }); + await expect(handlers.get('agent.start')!({ id: 'message-1', message: 'hello', thread }, {})).resolves.toEqual({ + message: { content: 'context' }, + }); + await expect(handlers.get('agent.end')!({ + id: 'message-1', + message: 'hello', + messages: [], + status: 'done', + thread, + }, {})).resolves.toEqual({ action: 'continue', userMessage: 'follow up' }); + expect(seen.every((event) => event.hook_event_name !== 'session.end')).toBe(true); + expect(launches).toHaveLength(seen.length); + expect(launches.every(({ command, environment }) => + command[0] === process.execPath && environment['BUN_BE_BUN'] === '1')).toBe(true); + } finally { + if (previousBun === undefined) Reflect.deleteProperty(globalThis, 'Bun'); + else Reflect.set(globalThis, 'Bun', previousBun); + await rm(root, { force: true, recursive: true }); + } +}); + +const document = ( + value?: JsonObject, + context?: string, +) => ({ + root: { + children: context === undefined ? [] : [{ kind: 'context' as const, text: context }], + kind: 'result' as const, + }, + status: 'success' as const, + ...(value === undefined ? {} : { value }), + version: 1 as const, +}); + +it('projects only documented Amp event outcomes', () => { + const tool = { + hook_event_name: 'tool.call', + session_id: 'thread-1', + tool_input: {}, + tool_name: 'shell', + tool_use_id: 'tool-1', + }; + expect(projectEventDocument(document({ outcome: 'allow' }), 'tool/before', 'amp', 'tool.call', tool)).toEqual({ action: 'allow' }); + expect(projectEventDocument(document({ outcome: 'deny', reason: 'no' }), 'tool/before', 'amp', 'tool.call', tool)).toEqual({ + action: 'reject-and-continue', + message: 'no', + }); + expect(projectEventDocument(document({ updatedInput: { safe: true } }), 'tool/before', 'amp', 'tool.call', tool)).toEqual({ + action: 'modify', + input: { safe: true }, + }); + expect(projectEventDocument(document({ exitCode: 2, outcome: 'synthesize', output: 'cached' }), 'tool/before', 'amp', 'tool.call', tool)).toEqual({ + action: 'synthesize', + result: { exitCode: 2, output: 'cached' }, + }); + expect(projectEventDocument(document({ output: 'replacement', status: 'done' }), 'tool/after', 'amp', 'tool.result', { + ...tool, + hook_event_name: 'tool.result', + status: 'done', + tool_response: 'original', + })).toEqual({ output: 'replacement', status: 'done' }); + expect(projectEventDocument(document(undefined, 'extra context'), 'prompt/submit', 'amp', 'agent.start', { + hook_event_name: 'agent.start', + prompt: 'hello', + session_id: 'thread-1', + })).toEqual({ message: { content: 'extra context' } }); + expect(projectEventDocument(document({ outcome: 'deny', reason: 'verify' }), 'stop', 'amp', 'agent.end', { + hook_event_name: 'agent.end', + session_id: 'thread-1', + status: 'done', + })).toEqual({ action: 'continue', userMessage: 'verify' }); + expect(() => projectEventDocument( + document({ status: 'done' }), + 'tool/after', + 'claude', + 'PostToolUse', + )).toThrow('tool/after does not accept Amp tool-result fields'); + expect(() => projectEventDocument(document(), 'session/end', 'amp', 'session.end', {})).toThrow(); +}); + +it('builds a relocatable self-contained Amp artifact with manifest and evidence records', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-build-')); + const config = join(projectRoot, 'agent-bundle.config.ts'); + const skillDir = join(projectRoot, 'src', 'skills', 'review'); + const skill = join(skillDir, 'SKILL.md'); + const outputRoot = join(projectRoot, 'artifact'); + const relocated = join(projectRoot, 'relocated'); + await mkdir(skillDir, { recursive: true }); + await writeFile(config, 'export default {};\n'); + await writeFile(skill, '---\nname: review\ndescription: Review code.\n---\n# Review\n'); + const model: NormalizedPlugin = { + ...plugin(), + mcpServers: [], + metadata: { + ...plugin().metadata, + provenance: { kind: 'config', sourcePath: config }, + }, + skills: [{ + ...plugin().skills[0]!, + dir: skillDir, + provenance: { kind: 'conventional', sourcePath: skill }, + resources: [{ bytes: (await readFile(skill)).length, relativePath: 'SKILL.md', source: skill }], + source: skill, + }], + targets: [{ + id: 'target:amp', + name: 'amp', + provenance: { kind: 'config', sourcePath: config }, + }], + }; + + try { + const registry = createDefaultRegistry(); + const result = await build({ + model, + outputRoot, + projectRoot, + registry, + routeGraph: emptyCompiledRouteGraph, + }); + const entryPath = '.amp/plugins/amp-review/index.js'; + expect(result.manifest.manifestVersion).toBe(4); + expect(result.manifest.projections).toEqual([{ + builtInHost: 'amp', + documents: { entry: entryPath }, + host: 'amp', + }]); + expect(result.manifest.compiler.adapters).toEqual([{ + adapterRevision: '1.0.0', + host: 'amp', + observedVersion: '0.0.0-20260907001852-gf348fed', + schemas: [], + }]); + expect(result.manifest.compiler.provenance).toContainEqual({ + path: entryPath, + sourceInputs: ['agent-bundle.config.ts', 'src/skills/review/SKILL.md'], + }); + await expect(readFile(join(outputRoot, compileEvidenceFileName), 'utf8')).resolves.toContain('"name":"closed-world-externals","revision":1'); + const entry = await readFile(join(outputRoot, entryPath), 'utf8'); + expect(entry).not.toMatch(/^\s*import\s/mu); + expect(result.manifest.files.filter((file) => file.path.includes('/hooks/') || file.path.startsWith('mcp/'))).toEqual([]); + expect(await validateArtifact({ artifactRoot: outputRoot, registry })).toEqual([]); + + await rename(outputRoot, relocated); + expect(await validateArtifact({ artifactRoot: relocated, registry })).toEqual([]); + const registrations: string[] = []; + const loaded = await import(`${pathToFileURL(join(relocated, entryPath)).href}?relocated=${Date.now()}`) as { + readonly default: (api: Readonly>) => Promise; + }; + await loaded.default({ + on() { + throw new Error('Content-only Amp factory must not register event callbacks.'); + }, + async registerSkill(value: { readonly path: string }) { + registrations.push(value.path); + return {}; + }, + }); + expect(registrations).toEqual(['skills/review']); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } +}); + +it('compiles a nested Amp hook wrapper that returns the documented tool.call decision', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-hook-')); + const config = join(projectRoot, 'agent-bundle.config.ts'); + const handler = join(projectRoot, 'src', 'hooks', 'before.ts'); + const outputRoot = join(projectRoot, 'artifact'); + await mkdir(join(projectRoot, 'src', 'hooks'), { recursive: true }); + await writeFile(config, 'export default {};\n'); + await writeFile(handler, "export default () => ({ outcome: 'deny', reason: 'blocked' });\n"); + const base = plugin(); + const model: NormalizedPlugin = { + ...base, + hooks: [{ + event: 'beforeTool', + id: 'hook:before', + name: 'before', + provenance: { kind: 'config', sourcePath: config }, + source: handler, + targets: ['amp'], + tools: [], + }], + mcpServers: [], + metadata: { ...base.metadata, provenance: { kind: 'config', sourcePath: config } }, + skills: [], + targets: [{ + id: 'target:amp', + name: 'amp', + provenance: { kind: 'config', sourcePath: config }, + }], + }; + + try { + const built = await build({ + model, + outputRoot, + projectRoot, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); + const wrapper = '.amp/plugins/amp-review/hooks/before.mjs'; + expect(built.manifest.executables.hooks).toContainEqual(expect.objectContaining({ + event: 'beforeTool', + host: 'amp', + path: wrapper, + })); + const result = await runNodeScript({ + args: [join(outputRoot, wrapper)], + input: JSON.stringify({ + hook_event_name: 'tool.call', + session_id: 'thread-1', + tool_input: { command: 'rm -rf /' }, + tool_name: 'shell', + tool_use_id: 'tool-1', + }), + }); + expect(result).toEqual({ + code: 0, + stderr: '', + stdout: '{"action":"reject-and-continue","message":"blocked"}', + }); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/amp-install.test.ts b/packages/agent-bundle/tests/amp-install.test.ts new file mode 100644 index 000000000..6b0964f13 --- /dev/null +++ b/packages/agent-bundle/tests/amp-install.test.ts @@ -0,0 +1,297 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { stableJson } from '../src/core/digest.ts'; +import { installBundle, type InstallCommandRunner } from '../src/install/install.ts'; +import { readInstallReceipt } from '../src/install/receipt.ts'; +import { uninstallBundle } from '../src/install/uninstall.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; + +const pluginName = 'amp-install-fixture'; + +const writeBundle = async ( + root: string, + version: string, + marker: string, + name = pluginName, +): Promise => { + const plugin = join(root, '.amp', 'plugins', name); + await mkdir(join(plugin, 'skills', 'review'), { recursive: true }); + await writeFile(join(plugin, 'index.js'), `export default async function () { /* ${marker} */ }\n`); + await writeFile(join(plugin, 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Review code.\n---\n'); + await writeFile(join(root, 'outside.txt'), 'must not be installed\n'); + await writeInstallFixtureManifest(root, { name, version }, [{ host: 'amp' }]); +}; + +const forbiddenRunner = (): InstallCommandRunner => ({ + async run() { + throw new Error('Amp directory installation must not invoke a host command.'); + }, +}); + +it('installs, replaces, and uninstalls only the receipt-owned Amp directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-install-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const settings = join(home, '.config', 'amp', 'settings.json'); + await mkdir(bundle, { recursive: true }); + await mkdir(join(home, '.config', 'amp'), { recursive: true }); + await writeFile(settings, '{"amp.plugins.disabled":["amp-install-fixture"]}\n'); + await writeBundle(bundle, '1.0.0', 'first'); + + const destination = join(home, '.config', 'amp', 'plugins', pluginName); + try { + const installed = await installBundle({ + commandRunner: forbiddenRunner(), + from: bundle, + home, + host: 'amp', + scope: 'user', + }); + + expect(installed).toMatchObject({ + destination, + host: 'amp', + mode: 'local', + plugin: pluginName, + state: 'installed', + version: '1.0.0', + }); + expect(installed.nextSteps).toEqual([ + 'Open Amp’s command palette with Ctrl+O and run `plugins: reload`.', + 'Run `amp plugins list` in a shell to inspect the installed plugin.', + ]); + await expect(readFile(join(destination, 'index.js'), 'utf8')).resolves.toContain('first'); + await expect(readFile(join(destination, 'outside.txt'), 'utf8')).rejects.toThrow(); + expect(await readInstallReceipt(destination)).toMatchObject({ + host: 'amp', + mode: 'local', + plugin: pluginName, + registrations: [{ kind: 'amp-system-plugin' }], + scope: 'user', + }); + + const unchanged = await installBundle({ + commandRunner: forbiddenRunner(), + from: bundle, + home, + host: 'amp', + scope: 'user', + }); + expect(unchanged.state).toBe('already-installed'); + + await writeFile(join(destination, 'disabled-state.json'), '{"disabled":true}\n'); + await writeBundle(bundle, '1.0.0', 'second'); + const replaced = await installBundle({ + commandRunner: forbiddenRunner(), + from: bundle, + home, + host: 'amp', + scope: 'user', + }); + expect(replaced.state).toBe('replaced'); + await expect(readFile(join(destination, 'index.js'), 'utf8')).resolves.toContain('second'); + await expect(readFile(join(destination, 'disabled-state.json'), 'utf8')).resolves.toBe('{"disabled":true}\n'); + await expect(readFile(settings, 'utf8')).resolves.toBe('{"amp.plugins.disabled":["amp-install-fixture"]}\n'); + + const planned = await uninstallBundle({ + commandRunner: forbiddenRunner(), + from: bundle, + home, + host: 'amp', + plan: true, + scope: 'user', + }); + expect(planned.state).toBe('planned'); + expect(planned.removed.directories).not.toContain(destination); + await expect(readFile(join(destination, 'index.js'), 'utf8')).resolves.toContain('second'); + + const uninstalled = await uninstallBundle({ + commandRunner: forbiddenRunner(), + from: bundle, + home, + host: 'amp', + scope: 'user', + }); + expect(uninstalled).toMatchObject({ + host: 'amp', + registrations: [{ action: 'removed', kind: 'amp-system-plugin' }], + state: 'uninstalled', + }); + await expect(readFile(join(destination, 'index.js'), 'utf8')).rejects.toThrow(); + await expect(readFile(join(destination, 'skills', 'review', 'SKILL.md'), 'utf8')).rejects.toThrow(); + await expect(readFile(join(destination, 'disabled-state.json'), 'utf8')).resolves.toBe('{"disabled":true}\n'); + await expect(readFile(settings, 'utf8')).resolves.toBe('{"amp.plugins.disabled":["amp-install-fixture"]}\n'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('uses the documented XDG system and project plugin roots without touching Amp settings', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-roots-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const xdg = join(root, 'xdg'); + const projectRoot = join(root, 'project'); + await mkdir(bundle, { recursive: true }); + await mkdir(join(projectRoot, '.amp'), { recursive: true }); + await writeFile(join(projectRoot, '.amp', 'settings.json'), '{"trusted":false}\n'); + await writeBundle(bundle, '1.0.0', 'roots'); + + try { + const system = await installBundle({ + environment: { XDG_CONFIG_HOME: xdg }, + from: bundle, + home, + host: 'amp', + scope: 'user', + }); + expect(system.destination).toBe(join(xdg, 'amp', 'plugins', pluginName)); + + const project = await installBundle({ + from: bundle, + home, + host: 'amp', + projectRoot, + scope: 'project', + }); + expect(project.destination).toBe(join(projectRoot, '.amp', 'plugins', pluginName)); + expect(await readInstallReceipt(project.destination!)).toMatchObject({ + registrations: [{ kind: 'amp-project-plugin' }], + scope: 'project', + }); + const uninstalled = await uninstallBundle({ + from: bundle, + home, + host: 'amp', + projectRoot, + scope: 'project', + }); + expect(uninstalled.state).toBe('uninstalled'); + await expect(readFile(join(projectRoot, '.amp', 'settings.json'), 'utf8')).resolves.toBe('{"trusted":false}\n'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('installs a mixed-case portable plugin name accepted by the Amp planner', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-portable-name-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const name = 'My_Plugin'; + await mkdir(bundle, { recursive: true }); + await writeBundle(bundle, '1.0.0', 'portable', name); + + try { + const installed = await installBundle({ + commandRunner: forbiddenRunner(), + from: bundle, + home, + host: 'amp', + scope: 'user', + }); + expect(installed.destination).toBe(join(home, '.config', 'amp', 'plugins', name)); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('rejects an Amp manifest name that could escape the plugin root', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-unsafe-name-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + await mkdir(bundle, { recursive: true }); + await writeBundle(bundle, '1.0.0', 'unsafe'); + const manifestPath = join(bundle, 'agent-bundle.manifest.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + application: { name: string }; + }; + manifest.application.name = '../../escape'; + await writeFile(manifestPath, `${stableJson(manifest)}\n`); + + try { + await expect(installBundle({ + commandRunner: forbiddenRunner(), + from: bundle, + home, + host: 'amp', + scope: 'user', + })).rejects.toThrow('not a safe local plugin name'); + await expect(readFile(join(home, '.config', 'escape', 'index.js'), 'utf8')).rejects.toThrow(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('refuses to replace a foreign Amp directory even with --replace', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-foreign-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const destination = join(home, '.config', 'amp', 'plugins', pluginName); + await mkdir(bundle, { recursive: true }); + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, 'index.js'), 'export default function foreign() {}\n'); + await writeBundle(bundle, '1.0.0', 'owned'); + + try { + await expect(installBundle({ + from: bundle, + home, + host: 'amp', + replace: true, + scope: 'user', + })).rejects.toThrow('foreign install'); + await expect(readFile(join(destination, 'index.js'), 'utf8')).resolves.toContain('foreign'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('refuses a symlinked Amp plugin ancestor before writing outside the host root', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-symlink-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const ampRoot = join(home, '.config', 'amp'); + const outside = join(root, 'outside'); + await mkdir(bundle, { recursive: true }); + await mkdir(ampRoot, { recursive: true }); + await mkdir(outside, { recursive: true }); + await symlink(outside, join(ampRoot, 'plugins'), 'dir'); + await writeBundle(bundle, '1.0.0', 'owned'); + + try { + await expect(installBundle({ + from: bundle, + home, + host: 'amp', + scope: 'user', + })).rejects.toThrow('unsupported filesystem entry'); + expect(await readdir(outside)).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('refuses modified or unlisted files inside the generated Amp directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-amp-inventory-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const plugin = join(bundle, '.amp', 'plugins', pluginName); + await mkdir(bundle, { recursive: true }); + await writeBundle(bundle, '1.0.0', 'owned'); + await writeFile(join(plugin, 'unlisted.js'), 'export default "unlisted";\n'); + + try { + await expect(installBundle({ + from: bundle, + home, + host: 'amp', + scope: 'user', + })).rejects.toThrow('does not match its manifest-owned directory'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/application-explorer.test.ts b/packages/agent-bundle/tests/application-explorer.test.ts index 24951022a..be72061a2 100644 --- a/packages/agent-bundle/tests/application-explorer.test.ts +++ b/packages/agent-bundle/tests/application-explorer.test.ts @@ -123,7 +123,7 @@ const manifest = (): ArtifactManifest => ({ }, }, files: [], - manifestVersion: 3, + manifestVersion: 4, projections: [ { documents: { mcp: 'codex/mcp.json', plugin: 'codex/plugin.json' }, diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index 808cbcb66..f7f88569c 100644 --- a/packages/agent-bundle/tests/artifact-inspection-service.test.ts +++ b/packages/agent-bundle/tests/artifact-inspection-service.test.ts @@ -287,7 +287,7 @@ const manifestFor = ( scripts, }, files: manifestFiles, - manifestVersion: 3, + manifestVersion: 4, projections: [projection], routes: { digest: 'e'.repeat(64), diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 4a37e3a03..5cbf5307e 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -2704,7 +2704,7 @@ const installSurfaceModel = (target: string): NormalizedPlugin => ({ }); const installSurfaceArtifact = async ( - target: 'claude' | 'codex' | 'cursor' | 'portable', + target: 'amp' | 'claude' | 'codex' | 'cursor' | 'portable', omitted: string, ): Promise => { const registry = createDefaultRegistry(); @@ -2719,7 +2719,7 @@ const installSurfaceArtifact = async ( return writeArtifact(files, true, [targetFromRegistry(registry, target)]); }; -it.each(['claude', 'codex', 'cursor', 'portable'] as const)( +it.each(['amp', 'claude', 'codex', 'cursor', 'portable'] as const)( 'rejects a %s artifact without INSTALL.md', async (target) => { const root = await installSurfaceArtifact(target, 'INSTALL.md'); diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index 041dd0a2d..ae83c5669 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -347,7 +347,7 @@ describe('composite plugin root (#555)', () => { it('defaults to the portable projection when targets are omitted (acceptance 4)', { timeout: 120_000 }, async () => { const { output, result } = await buildFixture(undefined); - expect(result.build.manifest.manifestVersion).toBe(3); + expect(result.build.manifest.manifestVersion).toBe(4); expect(result.build.manifest.projections.map((projection) => projection.host)).toEqual(['portable']); expect(result.build.manifest.projections[0]!.documents.plugin).toBe('plugin.json'); expect(result.build.manifest.projections[0]!.documents.mcp).toBe('mcp.json'); @@ -623,7 +623,7 @@ describe('composite plugin root (#555)', () => { await writeProject(root, { targets: ['claude', 'synthetic'] }); const refused = { code: 'AB4106', - message: 'Target "synthetic" cannot share one composite root with the other selected targets (claude): only the built-in hosts (claude, codex, cursor, portable) project into a shared root.', + message: 'Target "synthetic" cannot share one composite root with the other selected targets (claude): only the built-in hosts (amp, claude, codex, cursor, portable) project into a shared root.', recovery: 'Build "synthetic" alone — targets: ["synthetic"] — into its own --output, and the other targets into another.', severity: 'error', sourcePath: join(root, 'agent-bundle.config.ts'), diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index ce1b6a92f..7fc9dbc66 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -390,7 +390,7 @@ it('low-level build writes and returns the exact canonical manifest for a config source: { status: 'passed' }, }, }, - manifestVersion: 3, + manifestVersion: 4, projections: [expect.objectContaining({ host: 'portable' })], runtime: { node: '22.12.0' }, }); diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index df61d6f2e..70f1ed4a7 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -619,7 +619,7 @@ it('includes a built-manifest summary on inspect --json after a build, and omits expect(JSON.parse(after.stdout).output.manifest).toMatchObject({ application: { id: 'plugin:cli-fixture', name: 'cli-fixture', version: '1.0.0' }, executables: { bins: [], hooks: 0, mcpServers: [], scripts: [] }, - manifestVersion: 3, + manifestVersion: 4, projections: [{ host: 'codex' }, { host: 'portable' }], }); expect(JSON.parse(after.stdout).output.manifest.path).toMatch(/agent-bundle\.manifest\.json$/u); @@ -631,7 +631,7 @@ it('includes a built-manifest summary on inspect --json after a build, and omits const human = await runSourceCliWithOutput(['inspect', '--root', project.root]); expect(human).toMatchObject({ code: 0, stderr: '' }); - expect(human.stdout).toContain('Built manifest: v3 cli-fixture (codex, portable)'); + expect(human.stdout).toContain('Built manifest: v4 cli-fixture (codex, portable)'); const artifact = await runSourceCliWithOutput([ 'inspect', '--artifact', join(project.root, 'dist'), '--json', @@ -1101,6 +1101,38 @@ it('dispatches the install command through the native installer surface', async }); }); +it('accepts Amp on the owned install surface without exposing it as a development host', async () => { + const calls: unknown[] = []; + const installed = await runSourceCliWithOutput( + ['install', 'amp', '--from', '/tmp/amp-bundle', '--scope', 'project', '--json'], + { + installBundle: async (options) => { + calls.push(options); + return { + bundleRoot: '/tmp/amp-bundle', + destination: '/project/.amp/plugins/fixture', + host: 'amp', + mode: 'local', + plugin: 'fixture', + state: 'installed', + version: '1.0.0', + }; + }, + }, + ); + + expect(installed.code).toBe(0); + expect(calls).toEqual([{ + from: '/tmp/amp-bundle', + host: 'amp', + replace: false, + scope: 'project', + }]); + const dev = await runSourceCliWithOutput(['dev', '--install-host', 'amp']); + expect(dev.code).toBe(2); + expect(dev.stderr).toContain('Development install host must be claude, codex, or cursor.'); +}); + it('maps serve-app argv onto serveApp, prints the served URL, and closes the host once on a termination signal', async () => { const calls: unknown[] = []; const handlers = new Map void>(); @@ -1242,7 +1274,7 @@ it('reports invalid CLI arguments as Commander usage errors', async () => { { args: ['dev', '--install-host', 'windsurf'], option: '--install-host ', - reason: 'Install host must be claude, codex, or cursor.', + reason: 'Development install host must be claude, codex, or cursor.', value: 'windsurf', }, { @@ -1269,7 +1301,7 @@ it('reports invalid CLI arguments as Commander usage errors', async () => { const invalidInstallHost = await runSourceCliWithOutput(['install', 'windsurf']); expect(invalidInstallHost.code).toBe(2); expect(invalidInstallHost.stderr).toContain( - "error: command-argument value 'windsurf' is invalid for argument 'host'. Install host must be claude, codex, or cursor.", + "error: command-argument value 'windsurf' is invalid for argument 'host'. Install host must be amp, claude, codex, or cursor.", ); expect(invalidInstallHost.stderr).not.toContain('AB5000'); }); diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 8b18fa424..30b428a8c 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -77,7 +77,7 @@ const writeContents = (model: NormalizedPlugin): Record => Objec it('registers cursor as a first-class target with pinned schema validation', () => { const registry = createDefaultRegistry(); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'amp']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(registry.supports('cursor', 'mcp')).toBe(true); expect(registry.supports('cursor', 'rules')).toBe(true); diff --git a/packages/agent-bundle/tests/event-payload.test.ts b/packages/agent-bundle/tests/event-payload.test.ts index f2c768795..b56002247 100644 --- a/packages/agent-bundle/tests/event-payload.test.ts +++ b/packages/agent-bundle/tests/event-payload.test.ts @@ -36,22 +36,24 @@ interface CapturedEvent { readonly nativeEvent: string; } -const liveFixtures: Readonly> = { +type LivePayloadHost = Exclude; + +const liveFixtures: Readonly> = { claude: 'claude-2.1.259-orchestration', codex: 'codex-0.147.0', cursor: 'cursor-3.18.25', }; -const hostTables: Readonly>>> = { +const hostTables: Readonly>>> = { claude: claudeCapabilityTable.hooks.eventRoutes, codex: codexCapabilityTable.hooks.eventRoutes, cursor: cursorCapabilityTable.hooks.eventRoutes, }; -const hosts = Object.keys(liveFixtures) as AgentEventPayloadHost[]; +const hosts = Object.keys(liveFixtures) as LivePayloadHost[]; const signal = new AbortController().signal; -const capturedEvents = async (host: AgentEventPayloadHost): Promise => { +const capturedEvents = async (host: LivePayloadHost): Promise => { const text = await readFile(new URL(`../../../fixtures/host-lineage/${liveFixtures[host]}.ndjson`, import.meta.url), 'utf8'); const events: CapturedEvent[] = []; for (const line of text.split('\n')) { diff --git a/packages/agent-bundle/tests/fixtures/manifest-keys.v4.json b/packages/agent-bundle/tests/fixtures/manifest-keys.v4.json new file mode 100644 index 000000000..d1ac05edd --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/manifest-keys.v4.json @@ -0,0 +1,495 @@ +{ + "enums": { + "distribution.channels": [ + [ + "local", + "npm" + ], + [ + "local" + ] + ], + "distribution.channels[]": [ + "local", + "npm" + ], + "executables.hooks[].kind": [ + "config", + "event-route" + ], + "executables.mcpServers[].apps[].prebuilt": [ + true + ], + "executables.mcpServers[].kind": [ + "command", + "compiled", + "prebuilt", + "remote" + ], + "executables.mcpServers[].launch.args[].kind": [ + "artifact", + "literal" + ], + "executables.scripts[].mode": [ + "bundle", + "copy" + ], + "files[].kind": [ + "bundle", + "copy", + "generated", + "prebuilt" + ], + "manifestVersion": [ + 4 + ], + "projections[].builtInHost": [ + "amp", + "claude", + "codex", + "cursor", + "portable" + ], + "routes.cli.commands[].exitCode": [ + "result", + "zero" + ], + "routes.cli.commands[].options[].kind": [ + "boolean", + "enum", + "number", + "string" + ], + "routes.cli.mode": [ + "conflict", + "conventional", + "generated" + ], + "routes.cli.routes[].execution.fallback": [ + "none", + "standalone" + ], + "routes.cli.routes[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.cli.routes[].inputSchema.additionalProperties": [ + false + ], + "routes.cli.routes[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.cli.routes[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.cli.routes[].inputSchema.type": [ + "object" + ], + "routes.cli.routes[].kind": [ + "cli", + "tool" + ], + "routes.cli.routes[].provenance.kind": [ + "conventional" + ], + "routes.contracts[].input.additionalProperties": [ + false + ], + "routes.contracts[].input.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.contracts[].input.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.contracts[].input.type": [ + "object" + ], + "routes.events[].execution.fallback": [ + "none", + "standalone" + ], + "routes.events[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.events[].inputSchema.additionalProperties": [ + false + ], + "routes.events[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.events[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.events[].inputSchema.type": [ + "object" + ], + "routes.events[].kind": [ + "event-route" + ], + "routes.events[].provenance.kind": [ + "conventional" + ], + "routes.layouts[].scope": [ + "root", + "server" + ], + "routes.scripts[].execution.fallback": [ + "none", + "standalone" + ], + "routes.scripts[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.scripts[].inputSchema.additionalProperties": [ + false + ], + "routes.scripts[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.scripts[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.scripts[].inputSchema.type": [ + "object" + ], + "routes.scripts[].kind": [ + "script" + ], + "routes.scripts[].provenance.kind": [ + "conventional" + ], + "routes.servers[].mode": [ + "command", + "conflict", + "custom", + "generated", + "remote" + ], + "routes.servers[].routes[].execution.fallback": [ + "none", + "standalone" + ], + "routes.servers[].routes[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.servers[].routes[].inputSchema.additionalProperties": [ + false + ], + "routes.servers[].routes[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.servers[].routes[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.servers[].routes[].inputSchema.type": [ + "object" + ], + "routes.servers[].routes[].kind": [ + "app", + "prompt", + "resource", + "tool" + ], + "routes.servers[].routes[].provenance.kind": [ + "conventional" + ], + "web.apps[].allow[]": [ + "call-tool", + "download-file", + "open-external-link", + "request-display-mode" + ], + "web.open": [ + "browser", + "never" + ] + }, + "keys": [ + "application", + "application.description", + "application.id", + "application.name", + "application.version", + "compiler", + "distribution", + "distribution.channels", + "distribution.install", + "distribution.install.instructions", + "distribution.install.script", + "distribution.payloads", + "distribution.payloads[].hosts", + "distribution.payloads[].name", + "distribution.payloads[].runtimeDependencies", + "executables", + "executables.bins", + "executables.bins[].hosts", + "executables.bins[].name", + "executables.bins[].path", + "executables.bins[].worker", + "executables.hooks", + "executables.hooks[].event", + "executables.hooks[].host", + "executables.hooks[].id", + "executables.hooks[].kind", + "executables.hooks[].name", + "executables.hooks[].path", + "executables.hooks[].routeId", + "executables.hooks[].timeout", + "executables.mcpServers", + "executables.mcpServers[].apps", + "executables.mcpServers[].apps[].id", + "executables.mcpServers[].apps[].name", + "executables.mcpServers[].apps[].path", + "executables.mcpServers[].apps[].prebuilt", + "executables.mcpServers[].apps[].resourceUri", + "executables.mcpServers[].hosts", + "executables.mcpServers[].id", + "executables.mcpServers[].kind", + "executables.mcpServers[].launch", + "executables.mcpServers[].launch.args", + "executables.mcpServers[].launch.args[].kind", + "executables.mcpServers[].launch.args[].path", + "executables.mcpServers[].launch.args[].value", + "executables.mcpServers[].launch.entry", + "executables.mcpServers[].launch.env", + "executables.mcpServers[].launch.worker", + "executables.mcpServers[].name", + "executables.mcpServers[].transport", + "executables.scripts", + "executables.scripts[].hosts", + "executables.scripts[].id", + "executables.scripts[].mode", + "executables.scripts[].name", + "executables.scripts[].path", + "executables.scripts[].rendered", + "executables.scripts[].rendered.routeId", + "executables.scripts[].worker", + "files", + "files[].bytes", + "files[].kind", + "files[].mode", + "files[].path", + "files[].sha256", + "manifestVersion", + "projections", + "projections[].builtInHost", + "projections[].documents", + "projections[].documents.entry", + "projections[].documents.hooks", + "projections[].documents.marketplace", + "projections[].documents.mcp", + "projections[].documents.plugin", + "projections[].host", + "projections[].marketplace", + "projections[].marketplace.name", + "routes", + "routes.cli", + "routes.cli.commands", + "routes.cli.commands[].aliases", + "routes.cli.commands[].description", + "routes.cli.commands[].exitCode", + "routes.cli.commands[].mcp", + "routes.cli.commands[].mcp.confirm", + "routes.cli.commands[].mcp.server", + "routes.cli.commands[].mcp.tool", + "routes.cli.commands[].options", + "routes.cli.commands[].options[].aliases", + "routes.cli.commands[].options[].choices", + "routes.cli.commands[].options[].description", + "routes.cli.commands[].options[].key", + "routes.cli.commands[].options[].kind", + "routes.cli.commands[].options[].option", + "routes.cli.commands[].options[].positional", + "routes.cli.commands[].options[].repeated", + "routes.cli.commands[].options[].required", + "routes.cli.commands[].path", + "routes.cli.commands[].projection", + "routes.cli.commands[].projection.defaults", + "routes.cli.commands[].projection.mapInput", + "routes.cli.commands[].projection.module", + "routes.cli.commands[].projection.relaxed", + "routes.cli.commands[].routeId", + "routes.cli.mode", + "routes.cli.routes", + "routes.cli.routes[].contract", + "routes.cli.routes[].description", + "routes.cli.routes[].event", + "routes.cli.routes[].execution", + "routes.cli.routes[].execution.fallback", + "routes.cli.routes[].execution.preflight", + "routes.cli.routes[].execution.providers", + "routes.cli.routes[].execution.runtime", + "routes.cli.routes[].id", + "routes.cli.routes[].inputSchema", + "routes.cli.routes[].inputSchema.additionalProperties", + "routes.cli.routes[].inputSchema.properties", + "routes.cli.routes[].inputSchema.properties.*.default", + "routes.cli.routes[].inputSchema.properties.*.description", + "routes.cli.routes[].inputSchema.properties.*.enum", + "routes.cli.routes[].inputSchema.properties.*.items", + "routes.cli.routes[].inputSchema.properties.*.items.enum", + "routes.cli.routes[].inputSchema.properties.*.items.type", + "routes.cli.routes[].inputSchema.properties.*.type", + "routes.cli.routes[].inputSchema.required", + "routes.cli.routes[].inputSchema.type", + "routes.cli.routes[].kind", + "routes.cli.routes[].provenance", + "routes.cli.routes[].provenance.kind", + "routes.cli.routes[].serverId", + "routes.cli.routes[].source", + "routes.contracts", + "routes.contracts[].id", + "routes.contracts[].input", + "routes.contracts[].input.additionalProperties", + "routes.contracts[].input.properties", + "routes.contracts[].input.properties.*.default", + "routes.contracts[].input.properties.*.description", + "routes.contracts[].input.properties.*.enum", + "routes.contracts[].input.properties.*.items", + "routes.contracts[].input.properties.*.items.enum", + "routes.contracts[].input.properties.*.items.type", + "routes.contracts[].input.properties.*.type", + "routes.contracts[].input.required", + "routes.contracts[].input.type", + "routes.contracts[].origin", + "routes.contracts[].origin.binding", + "routes.contracts[].origin.module", + "routes.contracts[].routes", + "routes.digest", + "routes.events", + "routes.events[].contract", + "routes.events[].description", + "routes.events[].event", + "routes.events[].execution", + "routes.events[].execution.fallback", + "routes.events[].execution.preflight", + "routes.events[].execution.providers", + "routes.events[].execution.runtime", + "routes.events[].id", + "routes.events[].inputSchema", + "routes.events[].inputSchema.additionalProperties", + "routes.events[].inputSchema.properties", + "routes.events[].inputSchema.properties.*.default", + "routes.events[].inputSchema.properties.*.description", + "routes.events[].inputSchema.properties.*.enum", + "routes.events[].inputSchema.properties.*.items", + "routes.events[].inputSchema.properties.*.items.enum", + "routes.events[].inputSchema.properties.*.items.type", + "routes.events[].inputSchema.properties.*.type", + "routes.events[].inputSchema.required", + "routes.events[].inputSchema.type", + "routes.events[].kind", + "routes.events[].provenance", + "routes.events[].provenance.kind", + "routes.events[].serverId", + "routes.events[].source", + "routes.layouts", + "routes.layouts[].id", + "routes.layouts[].scope", + "routes.layouts[].serverId", + "routes.layouts[].source", + "routes.providers", + "routes.providers[].id", + "routes.providers[].name", + "routes.providers[].source", + "routes.scripts", + "routes.scripts[].contract", + "routes.scripts[].description", + "routes.scripts[].event", + "routes.scripts[].execution", + "routes.scripts[].execution.fallback", + "routes.scripts[].execution.preflight", + "routes.scripts[].execution.providers", + "routes.scripts[].execution.runtime", + "routes.scripts[].id", + "routes.scripts[].inputSchema", + "routes.scripts[].inputSchema.additionalProperties", + "routes.scripts[].inputSchema.properties", + "routes.scripts[].inputSchema.properties.*.default", + "routes.scripts[].inputSchema.properties.*.description", + "routes.scripts[].inputSchema.properties.*.enum", + "routes.scripts[].inputSchema.properties.*.items", + "routes.scripts[].inputSchema.properties.*.items.enum", + "routes.scripts[].inputSchema.properties.*.items.type", + "routes.scripts[].inputSchema.properties.*.type", + "routes.scripts[].inputSchema.required", + "routes.scripts[].inputSchema.type", + "routes.scripts[].kind", + "routes.scripts[].provenance", + "routes.scripts[].provenance.kind", + "routes.scripts[].serverId", + "routes.scripts[].source", + "routes.servers", + "routes.servers[].id", + "routes.servers[].mode", + "routes.servers[].name", + "routes.servers[].routes", + "routes.servers[].routes[].contract", + "routes.servers[].routes[].description", + "routes.servers[].routes[].event", + "routes.servers[].routes[].execution", + "routes.servers[].routes[].execution.fallback", + "routes.servers[].routes[].execution.preflight", + "routes.servers[].routes[].execution.providers", + "routes.servers[].routes[].execution.runtime", + "routes.servers[].routes[].id", + "routes.servers[].routes[].inputSchema", + "routes.servers[].routes[].inputSchema.additionalProperties", + "routes.servers[].routes[].inputSchema.properties", + "routes.servers[].routes[].inputSchema.properties.*.default", + "routes.servers[].routes[].inputSchema.properties.*.description", + "routes.servers[].routes[].inputSchema.properties.*.enum", + "routes.servers[].routes[].inputSchema.properties.*.items", + "routes.servers[].routes[].inputSchema.properties.*.items.enum", + "routes.servers[].routes[].inputSchema.properties.*.items.type", + "routes.servers[].routes[].inputSchema.properties.*.type", + "routes.servers[].routes[].inputSchema.required", + "routes.servers[].routes[].inputSchema.type", + "routes.servers[].routes[].kind", + "routes.servers[].routes[].provenance", + "routes.servers[].routes[].provenance.kind", + "routes.servers[].routes[].serverId", + "routes.servers[].routes[].source", + "runtime", + "runtime.node", + "web", + "web.apps", + "web.apps[].allow", + "web.apps[].app", + "web.apps[].input", + "web.apps[].name", + "web.apps[].resourceUri", + "web.apps[].server", + "web.apps[].tool", + "web.open" + ], + "manifestVersion": 4 +} diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 7f3a459a0..9a70dcda7 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -116,7 +116,7 @@ const fixtureHookManifest = ( distribution: { channels: ['local'], payloads: [] }, executables: { bins: [], hooks, mcpServers: [], scripts: [] }, files, - manifestVersion: 3, + manifestVersion: 4, projections, routes: { digest: emptyCompiledRouteGraph.digest, diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index dfc33b641..9d32033fb 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -1222,7 +1222,7 @@ it('does not manufacture Codex MCP or hook documents from a static Claude select it('plans byte-stable native Codex and Claude plugin trees from the same frozen model', async () => { const registry = createDefaultRegistry(); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'amp']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(Object.isFrozen(plugin)).toBe(true); diff --git a/packages/agent-bundle/tests/install-cli.test.ts b/packages/agent-bundle/tests/install-cli.test.ts index f9344d20d..9e8659a7a 100644 --- a/packages/agent-bundle/tests/install-cli.test.ts +++ b/packages/agent-bundle/tests/install-cli.test.ts @@ -127,7 +127,7 @@ describe('runInstallCli', () => { it('exits 2 on a usage error without touching the lifecycle', async () => { const out = capture(); expect(await runInstallCli(['install', 'windsurf'], { from: '/pkg', ...out.sinks })).toBe(2); - expect(out.stderr()).toContain('Install host must be claude, codex, or cursor.'); + expect(out.stderr()).toContain('Install host must be amp, claude, codex, or cursor.'); expect(await runInstallCli([], { from: '/pkg', ...out.sinks })).toBe(2); }); diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 0266ee4ad..715550332 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -53,7 +53,7 @@ const writesFor = (target: string, model: NormalizedPlugin = modelFor(target)): .map((entry) => [entry.relativePath, entry.content])); }; -it.each(['claude', 'codex', 'cursor', 'portable'])( +it.each(['amp', 'claude', 'codex', 'cursor', 'portable'])( 'emits a concrete INSTALL.md for the %s target', (target) => { const install = writesFor(target).get('INSTALL.md'); @@ -92,7 +92,7 @@ it('emits always-installable Claude and Codex local marketplaces with exact comm // A consumer never needs the framework CLI: the bundle is self-contained, so // install, reinstall, and uninstall are host commands, and `agent-bundle // install`/`uninstall`/`doctor` are documented as optional automation only. -it.each(['claude', 'codex', 'cursor', 'portable'])( +it.each(['amp', 'claude', 'codex', 'cursor', 'portable'])( 'documents host-native install and uninstall for %s and marks the agent-bundle CLI optional', (target) => { const install = writesFor(target).get('INSTALL.md')!; diff --git a/packages/agent-bundle/tests/manifest-schema.test.ts b/packages/agent-bundle/tests/manifest-schema.test.ts index 50cb74ecc..8847259cd 100644 --- a/packages/agent-bundle/tests/manifest-schema.test.ts +++ b/packages/agent-bundle/tests/manifest-schema.test.ts @@ -172,7 +172,7 @@ const validManifest = (): ArtifactManifest => ({ file('runtime/scripts/lint.mjs', 'bundle'), file('runtime/scripts/lint.worker.mjs', 'bundle'), ], - manifestVersion: 3, + manifestVersion: 4, projections: [ { builtInHost: 'claude', @@ -305,7 +305,7 @@ const minimalManifest = (): ArtifactManifest => ({ distribution: { channels: ['local'], payloads: [] }, executables: { bins: [], hooks: [], mcpServers: [], scripts: [] }, files: [], - manifestVersion: 3, + manifestVersion: 4, projections: [], routes: { digest: hash('1'), events: [], layouts: [], providers: [], scripts: [], servers: [] }, runtime: { node: '22.12.0' }, @@ -515,7 +515,7 @@ const parserOnlyRules: readonly { readonly apply: (manifest: MutableManifest) => * sweep covers key deletion, unknown keys, and retyping). Both reject. */ const schemaEncodedRules: readonly { readonly apply: (manifest: MutableManifest) => void; readonly rule: string }[] = [ - { apply: (manifest) => { (manifest as Record_).manifestVersion = 2; }, rule: 'manifestVersion is 3' }, + { apply: (manifest) => { (manifest as Record_).manifestVersion = 3; }, rule: 'manifestVersion is 4' }, { apply: (manifest) => { (manifest.compiler.producer as Record_).name = 'other'; }, rule: 'compiler.producer.name is agent-bundle' }, { apply: (manifest) => { (manifest.compiler as Record_).recordVersion = 2; }, rule: 'compiler.recordVersion is 1' }, { apply: (manifest) => { manifest.runtime.node = '22.12'; }, rule: 'runtime.node is major.minor.patch' }, @@ -731,7 +731,7 @@ it('leaves byte-level rules to the parser: a parsed value carries no formatting expect(validateArtifactManifestSchema(JSON.parse(pretty))).toEqual([]); }); -it('publishes a deep-frozen draft 2020-12 schema pinned to manifestVersion 3 that matches the shipped file', async () => { +it('publishes a deep-frozen draft 2020-12 schema pinned to manifestVersion 4 that matches the shipped file', async () => { expect(artifactManifestSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema'); expect(artifactManifestSchema.$id).toBe('https://scriptedalchemy.github.io/agent-bundle/schemas/agent-bundle.manifest.schema.json'); expect(artifactManifestSchema.type).toBe('object'); @@ -739,7 +739,7 @@ it('publishes a deep-frozen draft 2020-12 schema pinned to manifestVersion 3 tha expect(artifactManifestSchema.required).toEqual(Object.keys(minimalManifest()).sort()); const properties = asObject(artifactManifestSchema.properties); expect(Object.keys(properties)).toEqual([...Object.keys(minimalManifest()), 'web'].sort()); - expect(asObject(properties.manifestVersion).const).toBe(3); + expect(asObject(properties.manifestVersion).const).toBe(4); expect(Object.isFrozen(artifactManifestSchema)).toBe(true); expect(Object.isFrozen(properties)).toBe(true); diff --git a/packages/agent-bundle/tests/mcp-probe-service.test.ts b/packages/agent-bundle/tests/mcp-probe-service.test.ts index f792741ba..d34c726c0 100644 --- a/packages/agent-bundle/tests/mcp-probe-service.test.ts +++ b/packages/agent-bundle/tests/mcp-probe-service.test.ts @@ -165,7 +165,7 @@ const createBundle = async ( scripts: [], }, files, - manifestVersion: 3, + manifestVersion: 4, projections: [{ builtInHost: 'claude', documents: { diff --git a/packages/agent-bundle/tests/packed-small-plugin.test.ts b/packages/agent-bundle/tests/packed-small-plugin.test.ts index 21f1eae1d..2e25872f3 100644 --- a/packages/agent-bundle/tests/packed-small-plugin.test.ts +++ b/packages/agent-bundle/tests/packed-small-plugin.test.ts @@ -2,6 +2,7 @@ import { execFile as executeFile } from 'node:child_process'; import { access, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join, relative, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; @@ -67,6 +68,7 @@ const assertSmallRuntime = async ( it('keeps packed static and plain-hook plugins free of undeclared runtimes', async () => { const { tarball } = await sharedPackedTarball('agent-bundle'); const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-small-plugin-')); + const ampRoot = join(consumer, 'amp-static'); const staticRoot = join(consumer, 'static'); const hookRoot = join(consumer, 'plain-hook'); const processTrace = join(consumer, 'plugin-processes.txt'); @@ -82,6 +84,31 @@ it('keeps packed static and plain-hook plugins free of undeclared runtimes', asy '', ].join('\n')), ]); + await mkdir(join(ampRoot, 'src', 'skills', 'review'), { recursive: true }); + await Promise.all([ + writeFile(join(ampRoot, 'package.json'), JSON.stringify({ + name: 'packed-amp-static', + private: true, + type: 'module', + version: '1.0.0', + })), + writeFile(join(ampRoot, 'agent-bundle.config.ts'), [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + " plugin: { description: 'Packed Amp static proof.', name: 'packed-amp-static' },", + " targets: ['amp'],", + '});', + '', + ].join('\n')), + writeFile(join(ampRoot, 'src', 'skills', 'review', 'SKILL.md'), [ + '---', + 'name: review', + 'description: Review a change.', + '---', + '# Review', + '', + ].join('\n')), + ]); const baseEnvironment = installedEnvironment(); const environment = { ...baseEnvironment, @@ -118,7 +145,7 @@ it('keeps packed static and plain-hook plugins free of undeclared runtimes', asy ) .replace("name: 'skills-starter'", "name: 'skills-starter-hook'"); await writeFile(join(hookRoot, 'agent-bundle.config.ts'), hookConfig); - await Promise.all([staticRoot, hookRoot].map((root) => + await Promise.all([ampRoot, staticRoot, hookRoot].map((root) => execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: root, env: environment, @@ -181,6 +208,30 @@ it('keeps packed static and plain-hook plugins free of undeclared runtimes', asy expect.stringContaining(hook.path), ]); } + + await writeFile(processTrace, ''); + const ampCli = join(ampRoot, 'node_modules', '.bin', 'agent-bundle'); + const ampArtifact = join(ampRoot, 'artifact'); + const ampRelocated = join(ampRoot, 'relocated'); + await run(ampCli, ampRoot, ['build', '--root', ampRoot, '--output', ampArtifact], environment); + await assertSmallRuntime(ampArtifact, 0); + await rename(ampArtifact, ampRelocated); + await run(ampCli, ampRoot, ['validate', '--root', ampRoot, '--artifact', ampRelocated], environment); + const registrations: string[] = []; + const factory = await import( + `${pathToFileURL(join(ampRelocated, '.amp', 'plugins', 'packed-amp-static', 'index.js')).href}?packed=${Date.now()}` + ) as { readonly default: (api: Readonly>) => Promise }; + await factory.default({ + on() { + throw new Error('Packed content-only Amp factory registered an event callback.'); + }, + async registerSkill(definition: { readonly path: string }) { + registrations.push(definition.path); + return {}; + }, + }); + expect(registrations).toEqual(['skills/review']); + expect(await readFile(processTrace, 'utf8')).toBe(''); } finally { await rm(consumer, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index 430fe4759..be133d250 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -87,7 +87,7 @@ it('plans a schema-valid skills-only plugin with every discovered resource', () const plan = adapter.plan(plugin()); expect(registry.defaultTargetNames()).toEqual(['portable']); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'amp']); expect(plan.diagnostics).toEqual([]); const pluginEntries = plan.entries.filter((entry) => entry.relativePath !== 'INSTALL.md' && entry.relativePath !== 'install.mjs'); @@ -634,7 +634,7 @@ it('rejects duplicate adapters without exposing mutable registry snapshots', () expect(() => registry.register(portableAdapter)).toThrow('already registered'); expect(() => names.push('other')).toThrow(); expect(() => defaults.push('other')).toThrow(); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'amp']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(Object.isFrozen(registry.get('portable').capabilities)).toBe(true); expect(new TargetRegistry().has('portable')).toBe(false); diff --git a/packages/agent-bundle/tests/skill-ir.test.ts b/packages/agent-bundle/tests/skill-ir.test.ts index ebb911094..0e23e5171 100644 --- a/packages/agent-bundle/tests/skill-ir.test.ts +++ b/packages/agent-bundle/tests/skill-ir.test.ts @@ -107,8 +107,16 @@ describe('skill token registry', () => { }); it('classifies the six canonical tokens per host in Skill Markdown', () => { - const hosts = ['claude', 'codex', 'cursor', 'portable'] as const satisfies readonly SkillHost[]; + const hosts = ['amp', 'claude', 'codex', 'cursor', 'portable'] as const satisfies readonly SkillHost[]; const expected: Record> = { + amp: { + arguments: 'none', + pluginData: 'none', + pluginRoot: 'none', + projectRoot: 'none', + sessionIdentity: 'none', + skillRoot: 'none', + }, claude: { arguments: 'portable', pluginData: 'portable', @@ -176,7 +184,7 @@ describe('canonical Skill IR', () => { expect(ir.passThrough).toBe(true); expect(ir.markdown).toBe(portableMarkdown); - for (const host of ['claude', 'codex', 'cursor', 'portable'] as const) { + for (const host of ['amp', 'claude', 'codex', 'cursor', 'portable'] as const) { const lowered = lowerSkillIr(ir, host); expect(lowered.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); expect(lowered.skillMarkdown).toBe(portableMarkdown); @@ -191,10 +199,16 @@ describe('canonical Skill IR', () => { 'description: Review a change and report actionable findings.', 'model: sonnet', 'context: fork', + 'builtin-tools:', + ' - review_status', 'paths:', ' - src/**', 'disable-model-invocation: true', 'targets:', + ' amp:', + ' mcpServers:', + ' review:', + ' url: https://mcp.example.test/mcp', ' codex:', ' interface:', ' display_name: Review change', @@ -211,6 +225,10 @@ describe('canonical Skill IR', () => { const document = await parseSkill(join(root, 'src', 'skills', 'review'), root); const ir = parseSkillIr(document); expect(ir.passThrough).toBe(false); + expect(ir.extensions.amp).toEqual({ + builtinTools: ['review_status'], + mcpServers: { review: { url: 'https://mcp.example.test/mcp' } }, + }); expect(ir.extensions.claude).toEqual(expect.objectContaining({ context: 'fork', model: 'sonnet' })); expect(ir.extensions.cursor).toEqual(expect.objectContaining({ disableModelInvocation: true, @@ -221,10 +239,18 @@ describe('canonical Skill IR', () => { policy: { allowImplicitInvocation: true }, })); + const amp = lowerSkillIr(ir, 'amp'); + expect(amp.frontmatter['builtin-tools']).toEqual(['review_status']); + expect(amp.frontmatter.mcpServers).toEqual({ review: { url: 'https://mcp.example.test/mcp' } }); + expect(amp.frontmatter).not.toHaveProperty('model'); + expect(amp.sidecars).toEqual([]); + const claude = lowerSkillIr(ir, 'claude'); expect(claude.frontmatter.model).toBe('sonnet'); expect(claude.frontmatter.context).toBe('fork'); expect(claude.frontmatter).not.toHaveProperty('display_name'); + expect(claude.frontmatter).not.toHaveProperty('builtin-tools'); + expect(claude.frontmatter).not.toHaveProperty('mcpServers'); expect(claude.sidecars).toEqual([]); const cursor = lowerSkillIr(ir, 'cursor'); @@ -293,6 +319,33 @@ describe('canonical Skill IR', () => { })]); }); + it('rejects malformed Amp skill MCP instead of preserving an invented wrapper', async () => { + const markdown = [ + '---', + 'name: review', + 'description: Review a change.', + 'targets:', + ' amp:', + ' mcpServers:', + ' wrapped:', + ' mcpServers: {}', + '---', + '', + '# Review', + '', + ].join('\n'); + const root = await projectRoot({ 'src/skills/review/SKILL.md': markdown }); + const ir = parseSkillIr(await parseSkill(join(root, 'src', 'skills', 'review'), root)); + const amp = lowerSkillIr(ir, 'amp'); + + expect(amp.diagnostics).toContainEqual(expect.objectContaining({ + code: 'AB3010', + message: expect.stringContaining('mcpServers.wrapped.mcpServers'), + severity: 'error', + target: 'amp', + })); + }); + it('rejects unknown nested fields for every typed host target without dropping valid fields', async () => { const markdown = [ '---', diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 2dd2015b8..d280567fc 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -41,7 +41,7 @@ import { ProjectEventHub } from '../../src/dev/events.ts'; import type { ArtifactEpoch } from '../../src/dev/types.ts'; import { startDevServer } from '../../src/dev/workbench-server.ts'; import { runDoctor, type DoctorCommandRunner } from '../../src/install/doctor.ts'; -import { installBundle, publicHostRoot, type InstallHost } from '../../src/install/install.ts'; +import { installBundle, publicHostRoot, type DevInstallHost } from '../../src/install/install.ts'; import { manifestInventory, readInstallReceipt } from '../../src/install/receipt.ts'; import { uninstallBundle } from '../../src/install/uninstall.ts'; import { @@ -171,11 +171,11 @@ export interface BuiltPortableHostInstallFixture extends BuiltFixtureProject { } export interface DevHostInstallProofReport { - readonly host: InstallHost; + readonly host: DevInstallHost; readonly hookChanged: true; readonly marker: { readonly epochId: 'epoch-2'; - readonly host: InstallHost; + readonly host: DevInstallHost; readonly schemaVersion: 1; }; readonly mcpUnchanged: true; @@ -691,7 +691,7 @@ export const disposeHostInstallFixture = async (fixture: BuiltFixtureProject): P /** Proves initial host-owned installation followed by the host-specific development re-sync. */ export const runDevHostInstallProof = async ( fixture: BuiltHostInstallFixture, - host: InstallHost, + host: DevInstallHost, options: { readonly environment: Readonly }, ): Promise => { const root = await mkdtemp(join(tmpdir(), `agent-bundle-dev-install-${host}-`)); @@ -830,7 +830,7 @@ export const runDevHostInstallProof = async ( assertProof(await readFile(join(destination, mcpPath), 'utf8') === mcpBefore, `${host} re-sync changed its proxy MCP document.`); assertProof((await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).includes('Dev epoch two.'), `${host} skill did not re-sync.`); assertProof((await readFile(join(destination, 'hooks', hookName), 'utf8')).includes('epoch two'), `${host} hook did not re-sync.`); - const markerDocument = parseJson<{ readonly epochId: 'epoch-2'; readonly host: InstallHost; readonly schemaVersion: 1 }>( + const markerDocument = parseJson<{ readonly epochId: 'epoch-2'; readonly host: DevInstallHost; readonly schemaVersion: 1 }>( await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8'), `${host} dev marker`, ); @@ -1865,7 +1865,7 @@ export interface DevLiveHostProofReport { readonly observations: readonly [string, string]; readonly toolsListChanged: 1; }; - readonly host: InstallHost; + readonly host: DevInstallHost; readonly hostBinaryVersion: string | 'not-required'; readonly install: { readonly commandFromInstalledDocument: true; @@ -1938,7 +1938,7 @@ const liveSkillSource = (version: 'v1' | 'v2'): string => [ const liveHookSource = (version: 'v1' | 'v2'): string => `export default () => ({ additionalContext: 'live development proof ${version}', outcome: 'continue' as const });\n`; -const hostMcpDocument = (host: InstallHost): string => { +const hostMcpDocument = (host: DevInstallHost): string => { switch (host) { case 'claude': return '.mcp.json'; @@ -1954,7 +1954,7 @@ const hostMcpDocument = (host: InstallHost): string => { }; const liveHostDestination = ( - host: InstallHost, + host: DevInstallHost, roots: { readonly claudeConfig: string; readonly codexHome: string; readonly home: string }, ): string => { switch (host) { @@ -2007,7 +2007,7 @@ const hostCliInstallCommandCount = async (path: string): Promise => }).length; const installHostCommandRecorder = async ( - host: Exclude, + host: Exclude, root: string, environment: NodeJS.ProcessEnv, ): Promise<{ readonly environment: NodeJS.ProcessEnv; readonly log: string; readonly version: string }> => { @@ -2153,7 +2153,7 @@ const readCodexAppServerComponents = async ( const runLiveHostScenario = async ( fixture: BuiltHostInstallFixture, - host: InstallHost, + host: DevInstallHost, options: { readonly environment: Readonly; readonly persistentCodexAppServer?: boolean; @@ -2383,7 +2383,7 @@ const runLiveHostScenario = async ( export const runDevLiveHostProof = async ( fixture: BuiltHostInstallFixture, - host: InstallHost, + host: DevInstallHost, options: { readonly environment: Readonly; readonly persistentCodexAppServer?: boolean; @@ -2671,7 +2671,7 @@ export interface HostUninstallProofReport { /** Entries under the Agent Bundle namespace (`agent-bundle/`) or receipts left behind: always empty. */ readonly agentBundleResidue: readonly string[]; readonly homeByteIdentical: boolean; - readonly host: InstallHost; + readonly host: DevInstallHost; /** Classified host-owned residue after uninstall, relative to the host root; empty when byte-identical. */ readonly hostResidue: readonly HostResidueClass[]; readonly keepData: 'kept' | 'retained-by-host' | 'unavailable'; diff --git a/packages/agent-bundle/tests/support/install-fixture.ts b/packages/agent-bundle/tests/support/install-fixture.ts index 162560258..7f73ecc82 100644 --- a/packages/agent-bundle/tests/support/install-fixture.ts +++ b/packages/agent-bundle/tests/support/install-fixture.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { artifactCompilerRecordVersion, artifactManifestName, + artifactManifestVersion, assembleArtifactManifest, type ArtifactManifest, type ArtifactManifestCompilerAdapter, @@ -14,13 +15,13 @@ import type { WebManifest } from '../../src/web-host/manifest.ts'; import { digest, sha256Hex } from '../../src/core/digest.ts'; import type { InstallHost } from '../../src/install/install.ts'; -const pluginDocuments: Readonly> = Object.freeze({ +const pluginDocuments: Readonly, string>> = Object.freeze({ claude: '.claude-plugin/plugin.json', codex: '.codex-plugin/plugin.json', cursor: '.cursor-plugin/plugin.json', }); -const marketplaceDocuments: Readonly, string>> = Object.freeze({ +const marketplaceDocuments: Readonly, string>> = Object.freeze({ claude: '.claude-plugin/marketplace.json', codex: '.agents/plugins/marketplace.json', }); @@ -62,17 +63,21 @@ export const writeInstallFixtureManifest = async ( const projectionRows: ArtifactManifestProjection[] = []; const adapterRows: ArtifactManifestCompilerAdapter[] = []; for (const projection of projections) { - const plugin = pluginDocuments[projection.host]; - const marketplace = projection.host === 'cursor' + const entry = projection.host === 'amp' + ? `.amp/plugins/${application.name}/index.js` + : undefined; + const plugin = projection.host === 'amp' ? undefined : pluginDocuments[projection.host]; + const marketplace = projection.host === 'amp' || projection.host === 'cursor' ? undefined : marketplaceDocuments[projection.host]; projectionRows.push({ // The fixture hosts are the shipped adapters, so identity and name coincide. builtInHost: projection.host, documents: { + ...(entry === undefined ? {} : { entry }), ...(marketplace === undefined ? {} : { marketplace }), ...(projection.mcp === undefined ? {} : { mcp: projection.mcp }), - plugin, + ...(plugin === undefined ? {} : { plugin }), }, host: projection.host, ...(projection.marketplace === undefined @@ -150,7 +155,7 @@ export const writeInstallFixtureManifest = async ( distribution: { channels: ['local'], payloads: [] }, executables: { bins: [], hooks: [], mcpServers, scripts: [] }, files, - manifestVersion: 3, + manifestVersion: artifactManifestVersion, projections: projectionRows, ...(web === undefined ? {} : { web }), routes: { diff --git a/packages/agent-bundle/tests/web-command.test.ts b/packages/agent-bundle/tests/web-command.test.ts index f54373b3e..4ea2e36b7 100644 --- a/packages/agent-bundle/tests/web-command.test.ts +++ b/packages/agent-bundle/tests/web-command.test.ts @@ -195,7 +195,7 @@ const invoke = async ( const pluginRoot = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-web-command-'))); roots.push(pluginRoot); const manifestPath = join(pluginRoot, 'agent-bundle.manifest.json'); - if (options.manifest !== 'absent') await writeFile(manifestPath, '{"manifestVersion":3}\n'); + if (options.manifest !== 'absent') await writeFile(manifestPath, '{"manifestVersion":4}\n'); const stdout: string[] = []; const stderr: string[] = []; const commandOptions: WebCommandOptions = { diff --git a/packages/agent-bundle/tests/web-host-routes-unit.test.ts b/packages/agent-bundle/tests/web-host-routes-unit.test.ts index 1928e3f18..e9fac5d6b 100644 --- a/packages/agent-bundle/tests/web-host-routes-unit.test.ts +++ b/packages/agent-bundle/tests/web-host-routes-unit.test.ts @@ -72,7 +72,7 @@ const writeFixture = async (root: string, options: FixtureOptions): Promise ({ host })), web: { apps: [{ diff --git a/packages/agent-bundle/tests/web-manifest.test.ts b/packages/agent-bundle/tests/web-manifest.test.ts index 1bc6dbcda..76f851555 100644 --- a/packages/agent-bundle/tests/web-manifest.test.ts +++ b/packages/agent-bundle/tests/web-manifest.test.ts @@ -101,7 +101,7 @@ const document = (overrides: Readonly> = {}): Readonly withDoc })); it('refuses every manifestVersion but the one it was built for, before reading any section', () => withDocument(async (path, write) => { - for (const manifestVersion of [undefined, 1, 2, 4, '3']) { + for (const manifestVersion of [undefined, 1, 2, 3, '4']) { await write(document({ manifestVersion })); - await expect(readWebManifestDocument(path)).rejects.toThrow(/manifestVersion must be 3\./u); + await expect(readWebManifestDocument(path)).rejects.toThrow(/manifestVersion must be 4\./u); } })); diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 080e03af1..f8374c7db 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -4,7 +4,8 @@ description: 'Declare agent-bundle lifecycle hooks: the canonical events, tool s # Hooks -A hook is a handler module the compiler wraps and registers in each host's native hook document. +A hook is a handler module the compiler wraps and registers in each host's native hook document, +or from Amp's generated PluginAPI factory. You declare it once, keyed by a canonical event, and the adapters translate the event name, the matcher, and the result shape into whatever the selected host expects. @@ -71,6 +72,12 @@ export default defineConfig({ A host that does not implement an event simply does not receive that hook; the emitted document stays honest rather than inventing an equivalent. +Amp maps plain `sessionStart` to fire-and-forget `session.start` and plain `beforeTool` to +`tool.call`. Its `agent.start` and `agent.end` callbacks describe user turns, not subagents, so +plain `agentStart`/`agentStop` are not mapped. Event routes additionally map `prompt/submit` to +`agent.start`, `stop` to `agent.end`, and `tool/after` to `tool.result`. Amp explicitly has no +`session.end`; the adapter never manufactures one. + ## Tool selectors `tools` accepts the canonical selectors — `shell`, `file.read`, `file.write`, `mcp`, `agent` — @@ -82,6 +89,10 @@ renders the matcher table. `tools` also accepts explicit host-native selectors spelled `:`, such as `claude:WebSearch` or `codex:view_image`, which contribute only to that host's native matcher. +Amp publishes no stable canonical built-in tool-name table. An Amp hook with selectors therefore +uses explicit `amp:` selectors; canonical selectors fail rather than guessing a +native name. + A hook that selects tools must leave every selected target with at least one applicable selector. A hook restricted to `claude:WebSearch` while also targeting `codex` fails the build rather than emitting a Codex document with an empty matcher that would never fire. @@ -114,7 +125,7 @@ adds `toolResponse`; `StopHookEvent` and `AgentStopHookEvent` carry `stopHookAct only when every host delivers it, and the handler's second argument (`HookHandlerContext`) names the invoking `target`, its native event, and the validated native input verbatim for a handler that needs a host-specific field. `HookHandlerEventName` is the set of events a config hook can be -declared for — the six every host maps to a plain hook; `workspaceOpen` is served by an +declared for; each adapter maps only its documented subset, and `workspaceOpen` is served by an [event route](#event-routes) instead. The types are the portable contract: a field they admit is accepted by every host's wrapper *and* reaches that host's native output. Host-specific behaviour stays outside them and is reachable only from an untyped result: `additionalContext` from @@ -153,6 +164,21 @@ Per-event restrictions are enforced, not documented-and-hoped: An unknown key, a wrong type, or a violation of any rule above fails the hook with a clear message instead of being projected into a host document that would misbehave silently. +### Amp request results + +Amp's request callbacks use the exact pinned `@ampcode/plugin` unions. A `tool/before` event route +maps explicit `allow`, `deny`, and `updatedInput` to `allow`, `reject-and-continue`, and `modify`. +`{ outcome: 'synthesize', output, exitCode? }` becomes the native synthesized tool result. +Pass-through produces no wrapper decision; the generated inline `tool.call` handler returns the +API-required final `allow` only after every matching wrapper has passed. + +A `tool/after` route may return `status` (`done`, `error`, or `cancelled`) with replacement +`output`/`error`; no value preserves the original result. A `prompt/submit` route's +`Agent.Context` becomes the appended `agent.start` message. A denied `stop` becomes +`{ action: 'continue', userMessage }`, which starts another turn, so the route must carry its own +guard when it can re-enter. No permission prompt, model choice, or continuation loop is added by +the adapter. + ## Prebuilt handlers A project that owns its own compilation can point a hook at an already-built file inside a diff --git a/website/docs/en/guide/authoring/index.mdx b/website/docs/en/guide/authoring/index.mdx index d00140a95..6185de213 100644 --- a/website/docs/en/guide/authoring/index.mdx +++ b/website/docs/en/guide/authoring/index.mdx @@ -38,18 +38,20 @@ identity from `agent-bundle/meta` instead of a hand-maintained version module. ## Targets -`targets` selects the host projections the build lays into one composite plugin root — the -single directory at `artifact/` (or `output.distPath`) that every selected host reads: +`targets` selects the host projections the build lays into one composite root at `artifact/` +(or `output.distPath`): | Target | What it projects into the root | | --- | --- | +| `amp` | One directory plugin at `.amp/plugins//`: `index.js`, explicitly registered Skills, API callbacks, and skill-scoped MCP. | | `claude` | The Claude Code plugin layout: `.claude-plugin/`, `hooks/hooks.json`, `.mcp.json`. | | `codex` | The Codex plugin layout: `.codex-plugin/` with its own `hooks.json` and `mcp.json`. | | `cursor` | The Cursor plugin layout: `.cursor-plugin/` with its own `hooks.json` and `mcp.json`. | | `portable` | The [Agent Plugins open standard](https://agent-plugins.org) (specification 1.0.0): root `plugin.json` and `mcp.json`. Cursor loads it natively; every other client that reads this artifact is recorded, with the surfaces it does and does not load, under [recorded third-party clients](/reference/hosts). | -Host manifests live in their own dotfolders; `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and -`assets/` are emitted once and shared. Omit `targets` and the build emits only the `portable` +Amp's plugin is nested under `.amp/plugins/`; the other host manifests live in their own dotfolders, +with `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `assets/` emitted once and shared. +Omit `targets` and the build emits only the `portable` projection. Order is irrelevant — `['codex', 'claude']` and `['claude', 'codex']` produce the same bytes — and any other name, `plugin` included, is an unknown target (`AB4100`). diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 4d570fe16..0e5a1964d 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -648,9 +648,25 @@ export default defineConfig({ | `targets` | Restrict the server to specific targets. | | `apps` | Browser MCP Apps registered on this server. | +### Amp skill-scoped MCP + +Amp reads MCP from a registered skill, not one plugin-root document. With exactly one Amp skill, +canonical remote servers become `{ url, headers? }` entries and external stdio commands become +`{ command, args?, env? }` entries in that skill's flat `mcp.json` map — never under an +`mcpServers` wrapper. Native `mcpServers` frontmatter wins over the sibling file, and a +same-named CLI, workspace, or user server wins over the skill. + +Compiler-owned local entries (`entry`, generated routes, or prebuilt payload paths), `cwd`, and +Agent Bundle path tokens are rejected for Amp. The pinned Amp docs publish no plugin-root token or +skill-server execution cwd, so emitting a relative generated entry would not be relocatable. +Use a globally resolvable command, a remote URL, or native Amp frontmatter instead. Amp connects +skill servers during discovery while hiding their tools until activation; hidden tools do not +prove that no server process started. + ## The plugin-root environment anchor -Every emitted stdio MCP server entry carries an `AGENT_BUNDLE_PLUGIN_ROOT` environment variable +Every plugin-root stdio MCP server entry on the four root-document targets carries an +`AGENT_BUNDLE_PLUGIN_ROOT` environment variable holding the plugin install root in the target's native spelling: `${CLAUDE_PLUGIN_ROOT}` on Claude Code, `${PLUGIN_ROOT}` on portable, `${CURSOR_PLUGIN_ROOT}` on Cursor, and `./` on Codex, resolved against the entry's plugin-root `cwd`. Codex has no path-token interpolation, so a Codex diff --git a/website/docs/en/guide/authoring/skills.mdx b/website/docs/en/guide/authoring/skills.mdx index 1f52394d8..2aeaee840 100644 --- a/website/docs/en/guide/authoring/skills.mdx +++ b/website/docs/en/guide/authoring/skills.mdx @@ -79,6 +79,7 @@ their host so they reach only that adapter: | Host | Examples | | --- | --- | +| `amp` | `builtinTools`, `mcpServers` (emitted as `builtin-tools` and `mcpServers`) | | `claude` | `allowedTools`, `disallowedTools`, `argumentHint`, `model`, `effort`, `context: 'fork'`, `background`, `userInvocable`, `disableModelInvocation`, `whenToUse`, `shell`, `paths`, `hooks` | | `codex` | `dependencies.tools`, `interface` (display name, icons, brand color, default prompt), `policy.allowImplicitInvocation` | | `cursor` | `globs`, `icon`, `color`, `paths`, `disableModelInvocation` | @@ -87,12 +88,25 @@ their host so they reach only that adapter: `targets: ['claude']` is `AB3006`. A Skill is emitted to every target the project selects — there is no per-Skill artifact restriction. -Every selected host reads the same composite plugin root, so `skills//SKILL.md` exists -once. A host extension that lowers the document differently for one selected host than for +Claude Code, Codex, Cursor, and portable share `skills//SKILL.md`; Amp receives its copy +under `.amp/plugins//skills//SKILL.md`. A host extension that lowers a shared +document differently for one selected host than for another — `targets: { claude: { effort: high } }` in a `claude` + `codex` build, say — is a fatal `AB4103` collision. Keep the emitted document identical for every selected host, or build those hosts into separate artifacts (one `targets` entry per build). +### Amp registration and skill MCP + +Amp does not scan a directory plugin's `skills/` folder. Its generated `index.js` awaits +`amp.registerSkill({ path: 'skills/' })` exactly once for every bundled skill. Amp lists +the result as `:`, so it does not compete with a bare skill discovered from the +machine, project, Claude-compatible, built-in, personal, or workspace skill roots. + +`builtin-tools` gates tools registered by that same Amp plugin until the skill loads. +`mcpServers` in `SKILL.md` frontmatter wins over a sibling `mcp.json`; an authored sibling is +also preserved rather than overwritten. If canonical `mcp.servers` target Amp, the adapter emits +the flat server map beside exactly one bundled skill only when neither native source exists. + ## Path tokens Host placeholder syntax differs — `${CLAUDE_PLUGIN_ROOT}` is not what Cursor or the portable diff --git a/website/docs/en/guide/concepts/architecture.mdx b/website/docs/en/guide/concepts/architecture.mdx index 8b3808f49..1c279214a 100644 --- a/website/docs/en/guide/concepts/architecture.mdx +++ b/website/docs/en/guide/concepts/architecture.mdx @@ -311,11 +311,12 @@ used; compile evidence records the `dist` bundles named in the diagnostic. ### Composite root -Built with `targets: ['claude', 'codex', 'cursor', 'portable']`, the +Built with `targets: ['amp', 'claude', 'codex', 'cursor', 'portable']`, the host-test example's root is: ```text artifact/ +├── .amp/plugins//{index.js,skills/,hooks/} ├── .agents/plugins/marketplace.json ├── .claude-plugin/{plugin.json,marketplace.json} ├── .codex-plugin/{plugin.json,hooks.json,mcp.json} @@ -398,7 +399,7 @@ serializes it with `assembleArtifactManifest`: canonical `stableJson` plus a trailing newline. `parseArtifactManifest` rejects any other byte sequence, duplicate keys, unknown keys, missing keys, unsorted arrays, and a file entry that names the manifest itself. `manifestVersion` is -`2`. The public reader is `build/manifest-file.ts` +`4`. The public reader is `build/manifest-file.ts` `readArtifactManifest`: `ok`, `missing` (ENOENT only), or `invalid`. The schema is **closed in TypeScript** (`requireExactKeys` in @@ -423,7 +424,7 @@ keeps only the outline. ### Sections the writer emits The document has two parts with two version numbers. The **public -contract** (`manifestVersion: 3`) is what consumers read. The +contract** (`manifestVersion: 4`) is what consumers read. The **compiler record** (`compiler`, `recordVersion: 1`) is the operational account of the run — producer, project digests, provenance, adapter revisions, pinned schemas, validation — and changes without bumping @@ -431,11 +432,11 @@ revisions, pinned schemas, validation — and changes without bumping | Section | What it records | | --- | --- | -| `manifestVersion` | `3` | +| `manifestVersion` | `4` | | `application` | Host-independent identity: `id`, `name`, `version`, optional `description` | | `files[]` | Every emitted file except the manifest (`path`, `bytes`, `sha256`, `kind`, optional `mode`) | | `runtime` | `{ node }` from `NormalizedPlugin.runtime` | -| `projections[]` | One row per selected host. `host` is the selected name; `builtInHost` is the shipped adapter identity (absent for an advanced-registry adapter); `documents` (`plugin?`, `marketplace?`, `mcp?`, `hooks?`) point at the derived host documents; optional `marketplace.name`. Renamed from `targets[]`. | +| `projections[]` | One row per selected host. `host` is the selected name; `builtInHost` is the shipped adapter identity (absent for an advanced-registry adapter); `documents` (`plugin?`, `marketplace?`, `mcp?`, `hooks?`, `entry?`) point at the derived host documents; optional `marketplace.name`. Renamed from `targets[]`. | | `routes` | Compiled route graph: `servers[]`, `events[]`, `scripts[]`, `cli?`, `providers[]`, `layouts[]`, `contracts[]`, `digest` | | `executables` | Processes the root can start: `bins[]`, `hooks[]` (`kind` is `config` \| `event-route`), `mcpServers[]` (`entry`, `apps[]`), `scripts[]` | | `distribution` | `channels` (`local` always; `npm` when packaged) and optional `install` pointers | @@ -585,8 +586,8 @@ issue: | [#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) | Production `web` surface on that same bridge | Step 3 landed the authoritative `agent-bundle.manifest.json` as version 2; -the distribution install pointers advance the current contract to -`manifestVersion` 3. The field contract is +distribution install pointers advanced it to version 3, and Amp executable +entry pointers advance the current contract to `manifestVersion` 4. The field contract is [Artifact manifest](../../reference/artifact-manifest.mdx). #592 stays open until the remaining acceptance criteria on that issue land. diff --git a/website/docs/en/guide/distribution/index.mdx b/website/docs/en/guide/distribution/index.mdx index ac41160ca..c1d46e1c2 100644 --- a/website/docs/en/guide/distribution/index.mdx +++ b/website/docs/en/guide/distribution/index.mdx @@ -50,8 +50,9 @@ lowered Rspack configuration of every compiled output; its `output.path` is the ## What ships inside the root -Host manifests live in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, -`.cursor-plugin/`, and the portable `plugin.json`), each pointing at its own hook and MCP +Amp's directory plugin lives at `.amp/plugins//`; other host manifests live in their +dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, and the portable +`plugin.json`), each pointing at its own hook and MCP documents, while `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `assets/` are shared and emitted once. The full tree and the per-host document locations are in [Targets and artifacts](../../reference/targets-artifacts.mdx). diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index b1ccad390..514375766 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -1,5 +1,5 @@ --- -description: 'Install a built agent-bundle into Claude Code, Codex, or Cursor: the install command, host scopes, the standalone install.mjs, npm CLI parity, and Doctor.' +description: 'Install a built agent-bundle into Amp, Claude Code, Codex, or Cursor: host scopes, receipt-owned copies, install.mjs, npm CLI parity, and Doctor.' --- # Host installation @@ -16,12 +16,14 @@ same operations for developers who have it, and every host installs from the sam npx agent-bundle install claude --from artifact --scope user npx agent-bundle install codex --from artifact npx agent-bundle install cursor --from artifact +npx agent-bundle install amp --from artifact --scope user ``` `--from` names the **composite root**: the directory that holds `agent-bundle.manifest.json`. The installer reads that one file and nothing else to learn what it is installing — the plugin name and version come from `application`, the marketplace name from `projections[host].marketplace`, -and the file the host is pointed at from `projections[host].documents.plugin` (see the +and the file the host is pointed at from `projections[host].documents.plugin` (or +`documents.entry` for Amp; see the [Artifact manifest](../../reference/artifact-manifest.mdx)). It needs no project sources, and there is no `/` lookup: builds write no `artifact/` subdirectory, and the root is the bundle. `AB7001` refuses a root whose manifest is missing or not canonical, that has no projection @@ -33,6 +35,7 @@ plugin name for `cursor`. | Host | Mechanism | Scopes | | --- | --- | --- | +| Amp | Copies only `.amp/plugins//` into the project root or `$XDG_CONFIG_HOME/amp/plugins/` (falling back to `~/.config/amp/plugins/`). It does not edit settings, trust, disabled state, personal/workspace repositories, or any other plugin. `amp plugins list` inspects the copy; reloading a running session remains the interactive Ctrl+O → `plugins: reload` action. | `project`, `user` (system root) | | Claude Code | Delegates to `claude plugin marketplace add` and `claude plugin install`. Claude Code loads `hooks/hooks.json` and `.mcp.json` from the plugin root on its own, so `.claude-plugin/plugin.json` carries no `hooks` pointer (Claude Code reports one at that file as a duplicate hooks file); the Codex and Cursor documents beside their own manifests in the same root are never read by Claude Code. For a `directory` marketplace, Claude Code resolves `${CLAUDE_PLUGIN_ROOT}` to that directory itself, not to its plugin cache, so rebuilding it changes the hooks a running session executes. | `user`, `project`, `local` | | Codex | Delegates to `codex plugin marketplace add` and `codex plugin add`. `.codex-plugin/plugin.json` points at its own `.codex-plugin/hooks.json` and `.codex-plugin/mcp.json`, so Codex never falls back to discovering another host's documents in the root. | `user` | | Cursor | Copies the root into `~/.cursor/plugins/local/` (`--mode local`, the default), because Cursor publishes no non-interactive install verb; `--mode marketplace` instead stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step. Either way Cursor loads the hook and MCP documents `.cursor-plugin/plugin.json` names — `.cursor-plugin/hooks.json` and `.cursor-plugin/mcp.json`, beside the manifest — so plugin hooks run with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. | `user` | @@ -43,6 +46,11 @@ selected host binary is unavailable, rather than reporting a success it did not installation diagnostics are the `AB700x` family: bundle identity, host availability, scope, command failure, and collision checks. +Amp does not expose a non-interactive reload or standalone validator. Installation therefore +returns the interactive reload step and never runs it. In this account-less validation +environment, `amp plugins list` and `amp skills list --json` require login; live registration, +enabled state, and tool execution are recorded as **unverified**, not unsupported. + ## The standalone installer A root built with `cursor` or `portable` among its `targets` includes an `install.mjs` that copies @@ -135,7 +143,8 @@ diagnostics as one JSON line to stderr and exits 1. A usage error exits 2. `agent-bundle dev --install-host ` maintains a *marked development* install that follows successful rebuild epochs, with atomic generation switching and a stable proxy command. That is covered in [Developer Workbench](../development/workbench.mdx) and is not the same operation as -`agent-bundle install`. +`agent-bundle install`. Amp is intentionally not a development-install host; use its owned +`agent-bundle install amp` copy and the interactive reload action. ## Reinstalling after a rebuild @@ -144,7 +153,7 @@ shares one replace policy. An identical copy is an `already-installed` no-op. A copy of the **same version whose content hash differs** is replaced automatically, so rebuilding without a version bump no longer needs an uninstall and `rm -rf`. A different version is refused with `AB7005` unless you pass `--replace` (alias `--force`), and a foreign directory — one -this plugin's installer did not place — is refused either way. Cursor copies carry an install receipt +this plugin's installer did not place — is refused either way. Cursor and Amp copies carry an install receipt (`.agent-bundle-install.json`: plugin, version, host, content hash, owned files); replacement is in place and touches owned files only, never unowned entries such as legacy or in-place `state/`, and `--replace` adopts a pre-receipt copy. Current artifact builds keep framework state under @@ -225,6 +234,7 @@ variables each declares (`AB7331`, informational) — never a name or a value. ```sh # exact paths, nothing changes npx agent-bundle uninstall cursor --from artifact --plan +npx agent-bundle uninstall amp --from artifact --scope user --plan # receipt-owned files; legacy or in-place state/ kept npx agent-bundle uninstall cursor --from artifact # claude plugin uninstall --keep-data + marketplace remove @@ -236,7 +246,8 @@ node artifact/install.mjs --uninstall [--mode marketplace] Every install writes a lifecycle receipt (`agent-bundle-install-receipt/2`): version, content hash, delivery mode, scope, the owned files and directories, the host directories the installer created, the host registrations it performed, and timestamps. Cursor local copies carry it as -`.agent-bundle-install.json`; Claude, Codex, and Cursor marketplace-mode installs keep theirs under +`.agent-bundle-install.json`; Amp project/system copies carry the same sidecar inside their generated +directory; Claude, Codex, and Cursor marketplace-mode installs keep theirs under `/agent-bundle/receipts/`. `uninstall` removes exactly what the receipt owns and reverses exactly the registrations it recorded — never anything else; unowned entries are listed as retained. The effective framework state root, derived web-data, legacy `state/`, and for a diff --git a/website/docs/en/guide/start/index.mdx b/website/docs/en/guide/start/index.mdx index 0d9d7b406..73807da98 100644 --- a/website/docs/en/guide/start/index.mdx +++ b/website/docs/en/guide/start/index.mdx @@ -5,7 +5,7 @@ description: 'agent-bundle compiles one typed config into one installable plugin # Introduction agent-bundle compiles an agent plugin — Skills, hooks, MCP servers, and scripts, described -by one typed config — into one installable plugin root for Claude Code, Codex, and Cursor, plus +by one typed config — into installable output for Amp, Claude Code, Codex, and Cursor, plus the portable Agent Plugins format. You write the plugin once; the compiler emits each host's manifests and wrappers into that root. @@ -25,12 +25,12 @@ source tree: npx agent-bundle build --root . ``` -That single command emits one composite plugin root at `artifact/`: the manifests of every -selected host (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, the portable +That single command emits one composite root at `artifact/`: Amp's `.amp/plugins//` +directory and the manifests of every other selected host (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, the portable `plugin.json`) over shared `skills/`, `hooks/`, `mcp/`, `bin/`, and `scripts/` directories, -plus one `INSTALL.md`. `targets` selects which host projections the root carries — `claude`, -`codex`, `cursor`, `portable`; omitted, it selects `portable` alone — and every host installs -the same directory. +plus one `INSTALL.md`. `targets` selects which host projections the root carries — `amp`, +`claude`, `codex`, `cursor`, `portable`; omitted, it selects `portable` alone. Amp installs its +nested generated directory; the other hosts install the composite root. ## What the config owns diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index 64e2a8be1..1f6bc95f8 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -107,12 +107,13 @@ shadows it. The `bin: false` and `lib: false` opt-outs stay silent. One directory at the artifact output, whatever `targets` selects. The CLI defaults that output to `artifact/`, so it never collides with the package build below; `output.distPath` or `--output` -moves it. Every selected host reads this directory as its plugin root — there is no -`artifact//` partition — so `targets` decides which host manifests appear at the root, not -where anything lands. Omit `targets` and the root carries the `portable` projection alone. +moves it. Amp reads its nested `.amp/plugins//` directory; the other selected hosts read the +composite root as their plugin root. There is no `artifact//` partition. Omit `targets` and +the root carries the `portable` projection alone. ```text artifact/ +├── .amp/plugins//index.js # amp directory-plugin factory ├── .claude-plugin/plugin.json # claude, with marketplace.json beside it ├── .codex-plugin/plugin.json # codex, with hooks.json and mcp.json beside it ├── .agents/plugins/marketplace.json # codex marketplace diff --git a/website/docs/en/guide/start/quick-start.mdx b/website/docs/en/guide/start/quick-start.mdx index 4cac9308e..5ab964af2 100644 --- a/website/docs/en/guide/start/quick-start.mdx +++ b/website/docs/en/guide/start/quick-start.mdx @@ -76,7 +76,7 @@ export default defineConfig({ }); ``` -`targets` selects the host projections the one artifact root carries — `claude`, `codex`, +`targets` selects the host projections the one artifact root carries — `amp`, `claude`, `codex`, `cursor`, `portable`; omit it and the root carries `portable` alone. The release version comes from `package.json`. A `plugin.version` field still works as a deprecated compatibility axis, but a value that disagrees with `package.json` reports the `AB4008` warning. diff --git a/website/docs/en/index.mdx b/website/docs/en/index.mdx index 1db6d7921..9e6ea086d 100644 --- a/website/docs/en/index.mdx +++ b/website/docs/en/index.mdx @@ -1,12 +1,12 @@ --- pageType: home -description: 'Compile skills, hooks, MCP servers, and scripts from one typed config into one installable plugin root for Claude Code, Codex, and Cursor.' -titleSuffix: ' - Agent plugin compiler for Claude Code, Codex, and Cursor' +description: 'Compile skills, hooks, MCP servers, and scripts from one typed config into installable output for Amp, Claude Code, Codex, and Cursor.' +titleSuffix: ' - Agent plugin compiler for Amp, Claude Code, Codex, and Cursor' hero: name: agent-bundle text: One typed config, every agent host - tagline: Describe skills, hooks, MCP servers, and scripts once. Compile one installable plugin root for Claude Code, Codex, and Cursor. + tagline: Describe skills, hooks, MCP servers, and scripts once. Compile installable output for Amp, Claude Code, Codex, and Cursor. image: src: /logo.svg alt: agent-bundle logo @@ -198,11 +198,11 @@ root. ## One source, every host `targets` selects which host projections the one root carries; omit it and the root carries -`portable` alone. Every row below reads the same directory — only the manifest a host looks for -and the install verb differ. +`portable` alone. Amp reads its nested directory plugin; the other rows read the composite root. | Target | What it adds to the root | Installed with | | --- | --- | --- | +| `amp` | `.amp/plugins//index.js`, explicitly registered bundled Skills, PluginAPI event callbacks, and skill-scoped MCP. | Copy the generated directory into the project/system root, or `agent-bundle install amp --from artifact`. | | `claude` | `.claude-plugin/plugin.json` and a local `marketplace.json`, plus `hooks/hooks.json` and `.mcp.json`. | `claude plugin marketplace add` and `claude plugin install`, or `agent-bundle install claude --from artifact`. | | `codex` | `.codex-plugin/plugin.json` with its `hooks.json` and `mcp.json`, plus `.agents/plugins/marketplace.json`. | `codex plugin marketplace add` and `codex plugin add`, or `agent-bundle install codex --from artifact`. | | `cursor` | `.cursor-plugin/plugin.json` with its `hooks.json` and `mcp.json`. | The generated `install.mjs`, or `agent-bundle install cursor --from artifact`. | diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx index 860cffdf4..c35e5a676 100644 --- a/website/docs/en/reference/artifact-manifest.mdx +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -1,5 +1,5 @@ --- -description: 'The agent-bundle.manifest.json contract (manifestVersion 3): the index of the composite plugin root that build writes once — every section and field, who writes it, who reads it, the shipped JSON Schema, the reserved keys, and the versioning rule.' +description: 'The agent-bundle.manifest.json contract (manifestVersion 4): the index of the composite plugin root that build writes once — every section and field, who writes it, who reads it, the shipped JSON Schema, the reserved keys, and the versioning rule.' --- # Artifact manifest @@ -19,11 +19,11 @@ directory layout to find out. | Property | Rule | | --- | --- | | Written once | Only `agent-bundle build` writes it, after the staged root is complete. Nothing else creates it; the one edit after the build is the re-index of a derived variant described under `files[]`, which re-measures non-compiled rows and never touches a compiled one. | -| `manifestVersion` | `3`. A reader refuses any other value. | +| `manifestVersion` | `4`. A reader refuses any other value. | | Canonical bytes | The file is the canonical JSON serialization of the document — object keys sorted, no insignificant whitespace, one trailing newline. Every reader re-serializes what it parsed and rejects the file unless the bytes are identical. A hand edit, a reformat, or a duplicate key fails the read. | | Closed keys | Every object admits exactly its documented keys. An unknown key at any level is a parse error. | | Sorted arrays | Every array has an explicit sort key (named per section below) and no duplicates. | -| Lean reader | The rows above describe the full parser (`parseArtifactManifest`), which every consumer in the table below uses except one: the ` web` support bundled into a generated bin reads the manifest without the parser. It validates only the slices it launches from and ignores every other key, but those slices it refuses rather than filters, under the same checks the full parser runs (`web-host/manifest.ts` owns them; both import it): a `manifestVersion` other than `3` or a duplicate JSON key; a `projections[]` row that is not an object, names no host, or repeats one; an `executables.mcpServers[]` row that is not an object, has an unknown `kind`, carries a `launch` record when its kind must not (or lacks one when it must), or repeats a server `name` — two rows of one name never resolve to the later one; a malformed `launch` record, or one whose entry is not the `files[]` row its server kind starts (`bundle` for `compiled`, `prebuilt` for `prebuilt`), whose worker is not a `bundle` row, or whose `artifact` argument is not inside the root (an argument may name a directory under the root that holds rows); and a `web.apps[]` row whose `server` is not a row with a launch record. | +| Lean reader | The rows above describe the full parser (`parseArtifactManifest`), which every consumer in the table below uses except one: the ` web` support bundled into a generated bin reads the manifest without the parser. It validates only the slices it launches from and ignores every other key, but those slices it refuses rather than filters, under the same checks the full parser runs (`web-host/manifest.ts` owns them; both import it): a `manifestVersion` other than `4` or a duplicate JSON key; a `projections[]` row that is not an object, names no host, or repeats one; an `executables.mcpServers[]` row that is not an object, has an unknown `kind`, carries a `launch` record when its kind must not (or lacks one when it must), or repeats a server `name` — two rows of one name never resolve to the later one; a malformed `launch` record, or one whose entry is not the `files[]` row its server kind starts (`bundle` for `compiled`, `prebuilt` for `prebuilt`), whose worker is not a `bundle` row, or whose `artifact` argument is not inside the root (an argument may name a directory under the root that holds rows); and a `web.apps[]` row whose `server` is not a row with a launch record. | | Paths | Every path is POSIX, relative to the root, and safe: no leading `/`, no backslash, no `.` or `..` segment, and never a build-machine absolute path. Moving the entire composite root to a different absolute directory keeps every reader working: every path stays root-relative and resolves inside the moved tree. Every path a section points at — a host document, an executable, an App view, an install file — is also a `files[]` row. | ## Who writes it @@ -32,8 +32,8 @@ directory layout to find out. declares `agent-bundle.config.ts` (metadata, `targets`, hooks, MCP servers, scripts, marketplace) and the conventional filesystem (`src/mcp/**`, `src/cli/**`, `src/hooks/**`, `src/events/**`, `src/scripts/**`), and the build writes the manifest from the normalized model, the compiled route -graph, the composite plan, and the files it emitted. The host documents in the root — the Claude, -Codex, Cursor, and portable plugin manifests, the marketplace, MCP, and hooks documents — are +graph, the composite plan, and the files it emitted. Amp's `index.js` entry and the Claude, +Codex, Cursor, and portable plugin manifests, marketplace, MCP, and hooks documents are serialized with the manifest from the same compiled model in one build. Every host document is a digest-pinned `files[]` row, so a hand edit fails artifact digest validation. Nothing in the file is hand-editable: change the config or the sources and rebuild. @@ -42,7 +42,7 @@ file is hand-editable: change the config or the sources and rebuild. | Consumer | What it reads | | --- | --- | -| `install --from `, `uninstall` | The manifest at ``. Identity — plugin name and version — from `application`; the marketplace name from `projections[host].marketplace`; the file the host is pointed at from `projections[host].documents.plugin`. `install` takes its copy set and artifact content hash from `files[]` plus the manifest itself and any operator `.env` overlay, reading those fixed paths without walking the artifact directory. A root with no manifest, a non-canonical manifest, no projection for the host, a projection without a host plugin manifest, or a missing or digest-mismatched `files[]` entry is refused with `AB7001`. | +| `install --from `, `uninstall` | The manifest at ``. Identity — plugin name and version — from `application`; the marketplace name from `projections[host].marketplace`; the host file from `projections[host].documents.plugin`, or Amp's factory from `documents.entry`. Amp installs only that entry's containing directory; other hosts use the manifest-owned root. A root with no manifest, a non-canonical manifest, no projection for the host, a projection without its required host file, or a missing or digest-mismatched `files[]` entry is refused with `AB7001`. | | `doctor --from ` | The same identity, fixed-path file set, and content-hash read as `install`, per inspected host; the static validation it runs over the tree (`AB7319`) continues per projection. On an installed root, `doctor` and `uninstall` locate a declared `AGENT_BUNDLE_STATE_ROOT` override through the manifest the install copied — the host MCP document at `projections[host].documents.mcp` — never by probing host document paths; a root without a manifest declares no document override and falls back to an inherited `AGENT_BUNDLE_STATE_ROOT`, then to the derived state root. | | `serve-app`, `mcp list` / `invoke` / `run` | `executables.mcpServers[]` for the server's compiled `launch` record and its `apps[]`; `projections[]` to pick the host when `--target` is omitted. | | `hooks list` / `simulate` | `executables.hooks[]` — the compiler wrappers that can be simulated. | @@ -65,7 +65,7 @@ and are versioned separately. | Field | Contents | | --- | --- | -| `manifestVersion` | `3`. Versions the public contract consumers read. | +| `manifestVersion` | `4`. Versions the public contract consumers read. | | `runtime` | `{ node }` — the consumer-facing generated-executable runtime floor the root was built for, as a canonical `major.minor.patch` string that satisfies the framework's floor. | ### `application` @@ -111,16 +111,17 @@ One row per selected host, sorted by `host`. Targets select projections; they ar | Field | Required | Contents | | --- | --- | --- | -| `host` | yes | The target name the project selected the projection under: `claude`, `codex`, `cursor`, `portable`, or a registered adapter's name. A name is selection, not identity. | -| `builtInHost` | when a shipped adapter planned it | The shipped adapter that planned the projection, by identity: `claude`, `codex`, `cursor`, or `portable`. Absent for an advanced-registry adapter, even one registered under a built-in name. `install`, `doctor`, and the installed-host test harness pick a host's projection by this field, never by `host`. | +| `host` | yes | The target name the project selected the projection under: `amp`, `claude`, `codex`, `cursor`, `portable`, or a registered adapter's name. A name is selection, not identity. | +| `builtInHost` | when a shipped adapter planned it | The shipped adapter that planned the projection, by identity: `amp`, `claude`, `codex`, `cursor`, or `portable`. Absent for an advanced-registry adapter, even one registered under a built-in name. Install and artifact consumers pick a shipped host's projection by this field, never by `host`. | | `documents` | yes | Root-relative pointers to the host documents this projection emitted; every present pointer is a `files[]` row. See below. | | `marketplace` | no | `{ name }` — the marketplace the projection's marketplace document registers. Present only together with `documents.marketplace`. | -`documents` has four optional keys, each present only when the projection emitted the file: +`documents` has five optional keys, each present only when the projection emitted the file: | Key | Contents | | --- | --- | -| `plugin` | The host plugin manifest (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, `.cursor-plugin/plugin.json`, or the portable `plugin.json`). When absent, `install` and `doctor` refuse the host with `AB7001`. | +| `entry` | A host executable plugin entry with no JSON manifest. Amp records `.amp/plugins//index.js` here. | +| `plugin` | The host plugin manifest (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, `.cursor-plugin/plugin.json`, or the portable `plugin.json`). A host with neither this nor its required `entry` is refused with `AB7001`. | | `marketplace` | The marketplace document (`.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, `.cursor-plugin/marketplace.json`). | | `mcp` | The host MCP document (`.mcp.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/mcp.json`, `mcp.json`). | | `hooks` | The host hooks document (`hooks/hooks.json`, `.codex-plugin/hooks.json`, `.cursor-plugin/hooks.json`). | @@ -338,7 +339,7 @@ event route, and a routed CLI. Digests are elided; keys appear in canonical (sor { "bytes": 412, "kind": "generated", "path": ".claude-plugin/plugin.json", "sha256": "…" } ], - "manifestVersion": 3, + "manifestVersion": 4, "projections": [ { "builtInHost": "claude", "documents": { "hooks": "hooks/hooks.json", "marketplace": ".claude-plugin/marketplace.json", @@ -391,7 +392,7 @@ The same entry point exports the reader: | `readArtifactManifest(root)` | Opens `/agent-bundle.manifest.json` and returns `{ status: 'ok', manifest, path, root }`, `{ status: 'missing', … }`, or `{ status: 'invalid', detail, … }`. This is the one way the framework's own consumers open a built root. | | `parseArtifactManifest(bytes)` | Parses and validates canonical bytes into a frozen `ArtifactManifest`; throws on any violation, including a duplicate key or non-canonical bytes. | | `artifactManifestName` | `'agent-bundle.manifest.json'`. | -| `artifactManifestVersion` | `3`. | +| `artifactManifestVersion` | `4`. | | `artifactCompilerRecordVersion` | `1`. | The types (`ArtifactManifest`, `ArtifactManifestCompiler`, `ArtifactManifestCompilerAdapter`, @@ -400,7 +401,7 @@ are in the generated [Type API](./api.mdx). ## Reserved keys -These keys are part of the design but **not emitted**, and a v3 reader rejects them like any +These keys are part of the design but **not emitted**, and a v4 reader rejects them like any other unknown key. They are listed so that a consumer does not invent its own spelling: | Reserved key | Intended contents | @@ -420,7 +421,8 @@ lean web reader rejects the version mismatch itself, before it reads a slice). `runtime`, `projections[]`, `routes`, `executables`, `distribution`). It bumps on **any** change an old closed reader would reject — adding, renaming, or removing a public-contract key (optional or not), or changing an enumerated value set a reader closes. Within one - version the key inventory is frozen. Readers refuse any other `manifestVersion`. Version 3 marks + version the key inventory is frozen. Readers refuse any other `manifestVersion`. Version 4 adds + `projections[].documents.entry` and the `amp` built-in host. Version 3 marks the canonical npm-root guarantee that packages preserve the manifest-owned bytes and executable; version 2 already reserved the same `distribution.install` pointer shape. Version 2 renamed `targets[]` to `projections[]`, moved the hook rows that `agent-bundle.hooks.json` diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index ccf48f28d..588857dde 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -37,7 +37,7 @@ npx agent-bundle --version | `--root ` | `process.cwd()` | Project root. | | `--config ` | — | Configuration file relative to `--root`. | | `--mode ` | `production` | Configuration mode. | -| `--target ` | config `targets`, else `portable` | Host projection to select: `claude`, `codex`, `cursor`, or `portable`. Repeatable; the selection replaces the configured `targets` for this run, and every selected host lands in the one composite root whatever the order. Any other name, `plugin` included, is `AB4100`. | +| `--target ` | config `targets`, else `portable` | Host projection to select: `amp`, `claude`, `codex`, `cursor`, or `portable`. Repeatable; the selection replaces the configured `targets` for this run, and every selected host lands in the one composite root whatever the order. Any other name, `plugin` included, is `AB4100`. | | `--json` | off | Write one machine-readable JSON document. | **Artifact commands** — `mcp list`, `mcp invoke`, `mcp run`, `serve-app`, `hooks list`, `hooks simulate`: @@ -181,9 +181,9 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | Option | Default | Meaning | | --- | --- | --- | -| `` | **required** | `claude`, `codex`, or `cursor`. | -| `--from ` | `process.cwd()` | The composite root: the directory holding `agent-bundle.manifest.json`. Identity (name, version, marketplace) comes from the manifest's `application` and `projections[host]`, and the host is pointed at `projections[host].documents.plugin`; there is no `/` lookup, and every host installs from the same directory. `AB7001` when the manifest is missing or not canonical, has no projection for the host, its projection has no host plugin manifest or the pointed file is missing, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is unsafe. See [Artifact manifest](./artifact-manifest.mdx). | -| `--scope ` | `user` | `user`, `project`, or `local`. Claude accepts all three; Codex and Cursor are user-scoped. | +| `` | **required** | `amp`, `claude`, `codex`, or `cursor`. | +| `--from ` | `process.cwd()` | The composite root holding `agent-bundle.manifest.json`. Identity comes from `application` and `projections[host]`; other hosts use `documents.plugin`, while Amp copies only the directory containing `documents.entry`. `AB7001` covers a missing/invalid manifest, projection, required host file, unsafe local name, or missing Claude/Codex marketplace identity. | +| `--scope ` | `user` | `user`, `project`, or `local`. Claude accepts all three; Amp accepts `project` and `user` (its system root); Codex and Cursor are user-scoped. Run Amp project-scope installation from the destination project root. | | `--mode ` | `local` | Cursor only. `local` copies the bundle into `~/.cursor/plugins/local/`; `marketplace` stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step that makes Cursor manage the plugin as a marketplace install. | | `--replace` (alias `--force`) | off | Replace an existing agent-bundle install of this plugin even when its version differs. Without it, an identical copy is an `already-installed` no-op, a same-version copy whose content hash differs is replaced automatically, and a different version is `AB7005`. Foreign directories are always refused (`AB7005`). | @@ -193,7 +193,7 @@ touches owned files only, and `--replace` adopts a pre-receipt copy; Claude repl `claude plugin uninstall --keep-data` before reinstalling and Codex runs `codex plugin remove` before `add`. Every install writes a lifecycle receipt (format `agent-bundle-install-receipt/2`: version, content hash, mode, scope, owned paths, host registrations, timestamps) — in-tree for -Cursor local copies, under `/agent-bundle/receipts/` for Claude, Codex, and Cursor +Cursor and Amp local copies, under `/agent-bundle/receipts/` for Claude, Codex, and Cursor marketplace mode — that `uninstall` and `doctor` consume. During install, an explicit state root that does not yet exist is created and receives `.agent-bundle-state-owner.json`, which records the installation identity. Parent directories may @@ -208,9 +208,9 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | Option | Default | Meaning | | --- | --- | --- | -| `` | **required** | `claude`, `codex`, or `cursor`. | +| `` | **required** | `amp`, `claude`, `codex`, or `cursor`. | | `--from ` | `process.cwd()` | The composite root whose `agent-bundle.manifest.json` identifies the plugin (name, version, marketplace), read exactly as `install` reads it (`AB7001` on the same conditions). | -| `--scope ` | `user` | The scope the plugin was installed at (Claude). | +| `--scope ` | `user` | The scope the plugin was installed at (Claude or Amp). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | | `--keep-data` | on | Keep every recorded framework state root, derived web-data, legacy `state/`, and a recorded Cursor `PLUGIN_DATA` directory. This is the default; the flag makes it explicit and preserves the ownership receipt for a later purge. | | `--purge-data` | off | Remove only receipt-recorded, installation-owned durable-data roots. Refused (`AB7008`) without `--confirm-purge`; shared, externally managed, marker-less, foreign-marker, and otherwise unproven roots are retained and listed. | @@ -219,7 +219,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] Uninstall removes exactly what the receipt owns: the recorded files and installer-created directories (Cursor local, including the `~/.cursor/plugins[/local]` directories the install -created), the staged repository (Cursor marketplace, after its `HEAD` matches the recorded +created; Amp project/system, inside only `.amp/plugins/`), the staged repository (Cursor marketplace, after its `HEAD` matches the recorded commit), or the host registrations (`claude plugin uninstall --scope --keep-data` + `claude plugin marketplace remove`, `codex plugin remove` + `codex plugin marketplace remove` — the marketplace is retained while another installed plugin still uses it, including a Claude diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 49c856b81..427c25c8e 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -93,8 +93,8 @@ will be removed under the normal breaking-change policy. ## targets -The host projections `build` lays into the composite plugin root: any combination of `claude`, -`codex`, `cursor`, and `portable`. Order is irrelevant — the selection is sorted by name, so +The host projections `build` lays into the composite plugin root: any combination of `amp`, +`claude`, `codex`, `cursor`, and `portable`. Order is irrelevant — the selection is sorted by name, so `['codex', 'claude']` and `['claude', 'codex']` build the same bytes. Omit the key and only the `portable` projection is emitted. `--target` on the command line replaces the configured list for one run. Any other name, `plugin` included, is an unknown target (`AB4100`). What each projection diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 4b8b9adef..13c60cfd4 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -13,8 +13,9 @@ contract: the one directory `build` emits, and the artifact manifest its bytes h `agent-bundle build` writes **one directory** at the artifact output — `artifact/` by default; `output.distPath` or `--output` moves it. `targets` selects the **host projections** laid into -that root: `claude`, `codex`, `cursor`, and `portable`, in any combination. Every selected host -reads the same directory as its plugin root; there is no per-host subdirectory to point a host at. +that root: `amp`, `claude`, `codex`, `cursor`, and `portable`, in any combination. Claude Code, +Codex, Cursor, and portable read the root as their plugin root; Amp reads the one generated +directory at `.amp/plugins//`. - Omit `targets` — in config and on the command line — and the build emits only the `portable` projection. @@ -29,6 +30,8 @@ directories appear only when the project authors them): ```text artifact/ +├── .amp/plugins//index.js # Amp PluginAPI factory +├── .amp/plugins//skills/ # explicitly registered Amp skills ├── .agents/plugins/marketplace.json # Codex marketplace ├── .claude-plugin/plugin.json # Claude Code manifest ├── .claude-plugin/marketplace.json @@ -51,7 +54,7 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # emitted once ├── INSTALL.md # when any built-in host is selected ├── install.mjs # when cursor or portable is selected -├── agent-bundle.manifest.json # the artifact index (manifestVersion 3) +├── agent-bundle.manifest.json # the artifact index (manifestVersion 4) └── agent-bundle.compile-evidence.json # compiler record per compiled file ``` @@ -70,13 +73,14 @@ browser host for configured MCP Apps ships inside `bin/.mjs` as the fram | Host | Manifest | Hook document | MCP document | Marketplace | | --- | --- | --- | --- | --- | +| Amp | —; `index.js` is recorded as `documents.entry` | callbacks registered with `amp.on` | flat `skills//mcp.json` beside the registered skill | — | | Claude Code | `.claude-plugin/plugin.json` | `hooks/hooks.json` | `.mcp.json` | `.claude-plugin/marketplace.json` | | Codex | `.codex-plugin/plugin.json` | `.codex-plugin/hooks.json` | `.codex-plugin/mcp.json` | `.agents/plugins/marketplace.json` | | Cursor | `.cursor-plugin/plugin.json` | `.cursor-plugin/hooks.json` | `.cursor-plugin/mcp.json` | `.cursor-plugin/marketplace.json` when `marketplace: true` | | portable | `plugin.json` | — | `mcp.json` | — | -The paths are fixed whatever the selection, so a single-host root and a four-host root share one -layout. Claude Code and the portable Agent Plugins format load their documents from the +The paths are fixed whatever the selection, so a single-host root and a five-host root share one +layout. Amp's directory is isolated under `.amp/plugins//`; Claude Code and the portable Agent Plugins format load their documents from the conventional plugin-root locations and cannot be redirected; Codex and Cursor manifests carry explicit `hooks` and MCP pointers, so their documents sit beside their manifests. Both of those hosts also fall back to folder discovery of the conventional paths when the pointer is absent, so @@ -109,6 +113,11 @@ MCP entries, scripts, routed CLI bins, and MCP Apps are compiled **once** and at `claude+codex` — rather than to any one host. `agent-bundle inspect --bundler` shows the same thing: its `output.path` is the artifact output, ``, with no host segment beneath it. +Amp's MCP surface is the exception: it is skill-scoped, not a plugin-root runtime document. +The Amp adapter accepts relocatable remote servers and globally resolvable commands beside exactly +one bundled skill. It rejects compiler-owned local MCP entries because Amp documents neither a +plugin-root path token nor an execution cwd for resolving such an entry. + ### One root, one set of bytes Merging projections by path only works when the hosts agree on the bytes. Three rules keep the @@ -119,7 +128,7 @@ refuses: | --- | --- | | `AB4103` | Two selected projections plan the same path with different bytes. Projections are compared in host-name order and paths in path order, so a given selection reports the same collision however `targets` is written. The usual cause is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`), which lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Make the component identical for every selected host, or build the conflicting hosts into separate artifacts. | | `AB4105` | A component scoped to a subset of the selected hosts — a command or rule with frontmatter `targets` — sits in a conventional directory another selected host scans (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Extend `targets` to every selected host that discovers the directory, or build those hosts separately. Skills are never host-scoped: every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | -| `AB4106` | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with another target. Only the built-in hosts agree on where their unshared documents live, which directories each discovers, and one install surface, so a third-party adapter is built alone: `targets: ['']` into its own `--output`. A single-target selection never triggers it. | +| `AB4106` | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `amp`, `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with another target. Only the built-in hosts agree on where their unshared documents live, which directories each discovers, and one install surface, so a third-party adapter is built alone: `targets: ['']` into its own `--output`. A single-target selection never triggers it. | All three are listed with `AB4100` in the [Diagnostics reference](./diagnostics.md). @@ -132,6 +141,7 @@ require, emitted once for the whole selection: | Selected host | Marketplace manifest | Install surface | | --- | --- | --- | +| `amp` | — | Copy `.amp/plugins//` to the project or system plugin root. | | `claude` | `.claude-plugin/marketplace.json`. | `claude plugin marketplace add` + `claude plugin install`. | | `codex` | `.agents/plugins/marketplace.json`. | `codex plugin marketplace add` + `codex plugin add`. | | `cursor` | `.cursor-plugin/marketplace.json` when `marketplace: true`. | `install.mjs`. | @@ -144,7 +154,7 @@ inventory checks the same paths (`AB7010`). ## agent-bundle.manifest.json -One manifest is emitted per root (`manifestVersion: 3`). It is the root's **index** — the one +One manifest is emitted per root (`manifestVersion: 4`). It is the root's **index** — the one document every consumer reads to learn what the root contains — and the input to every later integrity check: `validate --artifact`, `prepack`, `install`, `doctor --from`, `serve-app`, `mcp`, `hooks`, and the packed and installed-host proof levels. The full field reference is the @@ -152,7 +162,7 @@ integrity check: `validate --artifact`, `prepack`, `install`, `doctor --from`, ` | Section | Contents | | --- | --- | -| `manifestVersion`, `runtime` | `3`, and `{ node }`, the consumer-facing generated-executable floor. | +| `manifestVersion`, `runtime` | `4`, and `{ node }`, the consumer-facing generated-executable floor. | | `application` | The identity, once and host-independent: `id`, `name`, `version`, optional `description`. What `install`, `doctor`, and `uninstall` act on. | | `files[]` | Every emitted file: `path`, `bytes`, `sha256`, `kind` (`bundle`, `copy`, `generated`, `prebuilt`), optional `mode`. | | `projections[]` | One row per selected host, sorted by `host`: `host`, optional `builtInHost`, the `documents` pointers to the host plugin, marketplace, MCP, and hooks documents the projection emitted, and its `marketplace` name. | diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 7b76db831..169150be6 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -4,7 +4,8 @@ description: '声明 agent-bundle 生命周期钩子:规范事件、工具选 # 钩子 -钩子是一个处理器模块,编译器会把它包装起来并注册到每个宿主原生的钩子文档中。你只需按规范事件分键 +钩子是一个处理器模块,编译器会把它包装起来并注册到每个宿主原生的钩子文档中,或由 Amp 生成的 PluginAPI +工厂注册。你只需按规范事件分键 声明一次,适配器就会把事件名、匹配器与结果形状翻译成所选宿主期望的样子。 ## 声明一个钩子 @@ -69,6 +70,11 @@ export default defineConfig({ 不实现某个事件的宿主,就不会收到对应的钩子;输出的文档保持诚实,而不是发明一个等价物。 +Amp 把朴素 `sessionStart` 映射到 fire-and-forget 的 `session.start`,把 `beforeTool` 映射到 +`tool.call`。它的 `agent.start` 与 `agent.end` 描述用户回合而非子智能体,因此不映射朴素 +`agentStart`/`agentStop`。事件路由还会把 `prompt/submit` 映射到 `agent.start`、`stop` 映射到 +`agent.end`、`tool/after` 映射到 `tool.result`。Amp 明确没有 `session.end`,适配器也绝不制造一个。 + ## 工具选择器 `tools` 接受规范选择器——`shell`、`file.read`、`file.write`、`mcp`、`agent`——在宿主有对应原生匹配器时 @@ -77,6 +83,9 @@ export default defineConfig({ 失败,尽管单独的 `shell` 本可以工作。[事件与钩子矩阵](../../reference/events.md)渲染了这张匹配器表。`tools` 同时接受写成 `:` 的显式宿主原生选择器,例如 `claude:WebSearch` 或 `codex:view_image`,这类选择器只会贡献给该宿主的原生匹配器。 +Amp 没有发布稳定的规范内置工具名表。Amp 钩子如需选择器,应使用显式的 `amp:`;规范选择器 +会失败,而不是猜测原生名字。 + 选择了工具的钩子必须让每个所选 target 都至少留下一个可用的选择器。一个只限定 `claude:WebSearch` 却同时以 `codex` 为 target 的钩子会导致构建失败,而不是输出一份匹配器为空、永远不会触发的 Codex 文档。 @@ -104,8 +113,8 @@ export default ((event) => ({ 增加 `toolResponse`;`StopHookEvent` 与 `AgentStopHookEvent` 携带 `stopHookActive`;`AgentStartHookEvent` 携带 `agentId` 与 `agentType`)都按名字导出,只有当每个宿主都会提供某个字段时它才是必填的;处理器的第二个 参数(`HookHandlerContext`)给出调用它的 `target`、该宿主的原生事件名以及原样的已校验原生输入,供需要 -宿主特定字段的处理器使用。`HookHandlerEventName` 是配置 hook 可以声明的事件集合——每个宿主都映射为 -普通 hook 的那六个;`workspaceOpen` 改由[事件路由](#事件路由)提供。这些类型是可移植契约:它们所接纳的 +宿主特定字段的处理器使用。`HookHandlerEventName` 是配置 hook 可以声明的事件集合;每个适配器只映射其 +文档化子集,`workspaceOpen` 改由[事件路由](#事件路由)提供。这些类型是可移植契约:它们所接纳的 字段既被每个宿主的包装层接受,*也*会到达该宿主的原生输出。宿主特定的行为不在其中,只能通过未带类型的 结果触及:来自 `beforeTool` 的 `additionalContext`(Claude 与 Codex 会投递;Cursor 的 `preToolUse` 输出 只携带权限或输入重写)、来自 `agentStart` 或 `agentStop` 的 `additionalContext`(分别为 Claude 与 Codex、 @@ -139,6 +148,18 @@ export default ((event) => ({ 未知的键、错误的类型,或违反上述任一规则,都会让钩子以清晰的错误消息失败,而不是被投影成一份行为 异常却毫无声响的宿主文档。 +### Amp 请求结果 + +Amp 的请求回调严格使用固定版本 `@ampcode/plugin` 的联合类型。`tool/before` 事件路由把显式 `allow`、 +`deny` 与 `updatedInput` 分别映射为 `allow`、`reject-and-continue` 与 `modify`。 +`{ outcome: 'synthesize', output, exitCode? }` 成为原生合成工具结果。透传不产生 wrapper 决定;生成的内联 +`tool.call` 处理器只在全部匹配 wrapper 都透传后,才返回 API 必需的最终 `allow`。 + +`tool/after` 路由可返回 `status`(`done`、`error` 或 `cancelled`)以及替换的 `output`/`error`;不返回值 +则保留原结果。`prompt/submit` 路由的 `Agent.Context` 成为 `agent.start` 追加消息。被拒绝的 `stop` 成为 +`{ action: 'continue', userMessage }`,会启动另一个回合,因此可重入的路由必须自行加 guard。适配器不添加 +权限提示、模型选择或继续循环。 + ## 预构建处理器 自行掌控编译的项目可以把钩子指向已声明 payload 中某个已构建好的文件: diff --git a/website/docs/zh/guide/authoring/index.mdx b/website/docs/zh/guide/authoring/index.mdx index 559bd0f9d..40524387e 100644 --- a/website/docs/zh/guide/authoring/index.mdx +++ b/website/docs/zh/guide/authoring/index.mdx @@ -36,18 +36,18 @@ export default defineConfig({ ## Targets -`targets` 选择构建铺进同一个复合插件根目录的宿主投影——也就是 `artifact/`(或 -`output.distPath`)下那个由所有选中宿主共同读取的单一目录: +`targets` 选择构建铺进 `artifact/`(或 `output.distPath`)复合根目录的宿主投影: | Target | 向根目录投影什么 | | --- | --- | +| `amp` | `.amp/plugins//` 下的一个目录插件:`index.js`、显式注册的 Skill、API 回调与 Skill 范围 MCP。 | | `claude` | Claude Code 插件布局:`.claude-plugin/`、`hooks/hooks.json`、`.mcp.json`。 | | `codex` | Codex 插件布局:`.codex-plugin/`,内含它自己的 `hooks.json` 与 `mcp.json`。 | | `cursor` | Cursor 插件布局:`.cursor-plugin/`,内含它自己的 `hooks.json` 与 `mcp.json`。 | | `portable` | [Agent Plugins 开放标准](https://agent-plugins.org)(规范 1.0.0):根目录的 `plugin.json` 与 `mcp.json`。Cursor 原生加载;其余读取该构件的客户端,连同它加载与不加载的界面,都记录在[已记录的第三方客户端](/zh/reference/hosts)中。 | -宿主清单各自位于自己的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` -只输出一份,由所有宿主共享。省略 `targets` 时构建只输出 `portable` 投影。顺序无关紧要—— +Amp 插件嵌套在 `.amp/plugins/` 下;其他宿主清单各自位于自己的点目录中,`skills/`、`hooks/`、`mcp/`、 +`scripts/`、`bin/` 与 `assets/` 只输出一份并共享。省略 `targets` 时构建只输出 `portable` 投影。顺序无关紧要—— `['codex', 'claude']` 与 `['claude', 'codex']` 产生完全相同的字节——而其他任何名称(包括 `plugin`)都是未知 target(`AB4100`)。 diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index bc89b9f3c..a9bc822af 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -576,9 +576,21 @@ export default defineConfig({ | `targets` | 把服务器限制到特定 target。 | | `apps` | 注册到该服务器上的浏览器 MCP App。 | +### Amp 的 Skill 范围 MCP + +Amp 从已注册 Skill 而非插件根文档读取 MCP。恰有一个 Amp Skill 时,规范远程服务器会成为 +`{ url, headers? }`,外部 stdio 命令会成为该 Skill 扁平 `mcp.json` 映射中的 +`{ command, args?, env? }`——外层绝不再包 `mcpServers`。原生 `mcpServers` frontmatter 优先于同级文件; +同名的 CLI、工作区或用户服务器又优先于 Skill。 + +Amp target 会拒绝编译器拥有的本地入口(`entry`、生成路由或预构建 payload 路径)、`cwd` 与 Agent Bundle +路径 token。固定版本的 Amp 文档没有发布插件根 token 或 Skill 服务器执行 `cwd`,因此输出相对的生成入口 +并不可搬迁。请改用全局可解析命令、远程 URL 或原生 Amp frontmatter。Amp 在发现 Skill 时连接服务器,但 +在激活前隐藏工具;看不见工具并不能证明服务器进程尚未启动。 + ## 插件根目录环境锚点 -每个输出的 stdio MCP 服务器入口都带有 `AGENT_BUNDLE_PLUGIN_ROOT` 环境变量,其值是插件安装根目录在 +四个插件根文档 target 的每个 stdio MCP 服务器入口都带有 `AGENT_BUNDLE_PLUGIN_ROOT` 环境变量,其值是插件安装根目录在 该 target 上的原生写法:Claude Code 上是 `${CLAUDE_PLUGIN_ROOT}`、可移植格式上是 `${PLUGIN_ROOT}`、 Cursor 上是 `${CURSOR_PLUGIN_ROOT}`、Codex 上是 `./`,并相对入口的插件根 `cwd` 解析。Codex 没有路径 token 插值,因此没有插件根工作目录的 Codex stdio 服务器会省略该锚点;由源码构建(`entry:`)的服务器 diff --git a/website/docs/zh/guide/authoring/skills.mdx b/website/docs/zh/guide/authoring/skills.mdx index ad3859e24..f8b7ee642 100644 --- a/website/docs/zh/guide/authoring/skills.mdx +++ b/website/docs/zh/guide/authoring/skills.mdx @@ -75,6 +75,7 @@ export default defineConfig({ | 宿主 | 示例 | | --- | --- | +| `amp` | `builtinTools`、`mcpServers`(输出为 `builtin-tools` 与 `mcpServers`) | | `claude` | `allowedTools`、`disallowedTools`、`argumentHint`、`model`、`effort`、`context: 'fork'`、`background`、`userInvocable`、`disableModelInvocation`、`whenToUse`、`shell`、`paths`、`hooks` | | `codex` | `dependencies.tools`、`interface`(显示名、图标、品牌色、默认提示词)、`policy.allowImplicitInvocation` | | `cursor` | `globs`、`icon`、`color`、`paths`、`disableModelInvocation` | @@ -82,11 +83,22 @@ export default defineConfig({ Skill frontmatter 中的 `targets` 就是上述按宿主划分的对象,别无他用;写成列表(如 `targets: ['claude']`)是 `AB3006`。Skill 会输出到项目选择的每一个 target——不存在按 Skill 限制产物的机制。 -所有选中宿主读取的是同一个复合插件根目录,因此 `skills//SKILL.md` 只存在一份。若某个宿主扩展 -让文档在一个选中宿主上的降级结果与另一个宿主不同——例如在 `claude` + `codex` 的构建中写 +Claude Code、Codex、Cursor 与 portable 共享 `skills//SKILL.md`;Amp 的副本位于 +`.amp/plugins//skills//SKILL.md`。若某个宿主扩展让共享文档在一个选中宿主上的降级结果 +与另一个宿主不同——例如在 `claude` + `codex` 的构建中写 `targets: { claude: { effort: high } }`——就是致命的 `AB4103` 冲突。请让输出的文档对每个选中宿主 都完全一致,或者把这些宿主分开构建到不同的产物中(每次构建只写一个 `targets` 条目)。 +### Amp 注册与 Skill MCP + +Amp 不会扫描目录插件中的 `skills/`。生成的 `index.js` 会为每个捆绑 Skill 恰好一次地等待 +`amp.registerSkill({ path: 'skills/' })`。Amp 把它列为 `:`,因此不会与机器、项目、 +Claude 兼容、内置、个人或工作区来源发现的裸 Skill 名竞争。 + +`builtin-tools` 会把同一个 Amp 插件注册的工具隐藏到 Skill 加载时。`SKILL.md` frontmatter 中的 +`mcpServers` 优先于同级 `mcp.json`;作者提供的同级文件也会保留而不会被覆盖。规范 +`mcp.servers` 以 Amp 为 target 时,仅在恰有一个捆绑 Skill 且两种原生来源都不存在时,才输出扁平服务器映射。 + ## 路径 token 各宿主的占位符语法并不相同——`${CLAUDE_PLUGIN_ROOT}` 并不是 Cursor 或可移植标准所使用的写法。请改为 diff --git a/website/docs/zh/guide/concepts/architecture.mdx b/website/docs/zh/guide/concepts/architecture.mdx index 4874e6849..15f0ce359 100644 --- a/website/docs/zh/guide/concepts/architecture.mdx +++ b/website/docs/zh/guide/concepts/architecture.mdx @@ -273,10 +273,11 @@ node 表面。 ### 复合根目录 -以 `targets: ['claude', 'codex', 'cursor', 'portable']` 构建时,host-test 示例的根目录为: +以 `targets: ['amp', 'claude', 'codex', 'cursor', 'portable']` 构建时,host-test 示例的根目录为: ```text artifact/ +├── .amp/plugins//{index.js,skills/,hooks/} ├── .agents/plugins/marketplace.json ├── .claude-plugin/{plugin.json,marketplace.json} ├── .codex-plugin/{plugin.json,hooks.json,mcp.json} @@ -349,7 +350,7 @@ prompt 路由则返回经校验的协议结果(来自 `document.value`)。CL `build/manifest-routes.ts` 的 `artifactRoutesFor`。`writeManifest` 再用 `assembleArtifactManifest` 将其序列化:规范的 `stableJson` 加一个末尾换行。 `parseArtifactManifest` 拒绝任何其他字节序列、重复键、未知键、缺失键、未排序 -的数组,以及指向清单自身的文件条目。`manifestVersion` 为 `3`。公开读取方是 +的数组,以及指向清单自身的文件条目。`manifestVersion` 为 `4`。公开读取方是 `build/manifest-file.ts` 的 `readArtifactManifest`:`ok`、`missing`(仅 ENOENT)或 `invalid`。 @@ -371,7 +372,7 @@ ENOENT)或 `invalid`。 ### 写入器发出的分节 -文档分为两部分,各有自己的版本号。**公开契约**(`manifestVersion: 3`) +文档分为两部分,各有自己的版本号。**公开契约**(`manifestVersion: 4`) 是消费者读取的部分。**编译器记录**(`compiler`,`recordVersion: 1`)是 这次运行的操作性账目——生产者、项目摘要、来源、适配器修订、固定的 schema、验证结果——它的变化不会推动 `manifestVersion`;消费者读取方 @@ -379,11 +380,11 @@ schema、验证结果——它的变化不会推动 `manifestVersion`;消费 | 分节 | 记录什么 | | --- | --- | -| `manifestVersion` | `3` | +| `manifestVersion` | `4` | | `application` | 与宿主无关的身份:`id`、`name`、`version`,可选的 `description` | | `files[]` | 除清单自身外的每个发出文件(`path`、`bytes`、`sha256`、`kind`,可选的 `mode`) | | `runtime` | 来自 `NormalizedPlugin.runtime` 的 `{ node }` | -| `projections[]` | 每个所选宿主一行。`host` 是所选名称;`builtInHost` 是随包适配器身份(高级注册表适配器则缺省);`documents`(`plugin?`、`marketplace?`、`mcp?`、`hooks?`)指向派生出的宿主文档;以及可选的 `marketplace.name`。由 `targets[]` 更名而来。 | +| `projections[]` | 每个所选宿主一行。`host` 是所选名称;`builtInHost` 是随包适配器身份(高级注册表适配器则缺省);`documents`(`plugin?`、`marketplace?`、`mcp?`、`hooks?`、`entry?`)指向派生出的宿主文档;以及可选的 `marketplace.name`。由 `targets[]` 更名而来。 | | `routes` | 编译后的路由图:`servers[]`、`events[]`、`scripts[]`、`cli?`、`providers[]`、`layouts[]`、`contracts[]`、`digest` | | `executables` | 根目录能启动的进程:`bins[]`、`hooks[]`(`kind` 为 `config` \| `event-route`)、`mcpServers[]`(`entry`、`apps[]`)、`scripts[]` | | `distribution` | `channels`(始终有 `local`;已打包时有 `npm`)以及可选的 `install` 指针 | @@ -512,8 +513,8 @@ schema、验证结果——它的变化不会推动 `manifestVersion`;消费 | [#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594) | 生成的类型化 App 客户端 + 共享桥接 | | [#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) | 基于同一桥接的生产 `web` 表面 | -第 3 步以版本 2 落地了权威的 `agent-bundle.manifest.json`;分发安装指针把当前契约推进到 -`manifestVersion` 3。 +第 3 步以版本 2 落地了权威的 `agent-bundle.manifest.json`;分发安装指针将其推进到版本 3, +Amp 可执行入口指针再把当前契约推进到 `manifestVersion` 4。 字段契约见 [产物清单](../../reference/artifact-manifest.mdx)。#592 会一直保持打开,直到该 issue 上剩余的验收标准落地。 diff --git a/website/docs/zh/guide/distribution/index.mdx b/website/docs/zh/guide/distribution/index.mdx index 2972b67bc..228033a87 100644 --- a/website/docs/zh/guide/distribution/index.mdx +++ b/website/docs/zh/guide/distribution/index.mdx @@ -44,8 +44,9 @@ npx agent-bundle build --root . --output artifact ## 根目录里发布了什么 -宿主清单位于根目录下各自的点目录中(`.claude-plugin/`、`.codex-plugin/`、`.cursor-plugin/`,以及 portable -的 `plugin.json`),各自指向自己的钩子与 MCP 文档;而 `skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 +Amp 目录插件位于 `.amp/plugins//`;其他宿主清单位于根目录下各自的点目录中 +(`.claude-plugin/`、`.codex-plugin/`、`.cursor-plugin/`,以及 portable 的 `plugin.json`),各自指向 +自己的钩子与 MCP 文档;而 `skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 是共享的,只输出一次。完整的目录树与各宿主的文档位置见 [Target 与产物](../../reference/targets-artifacts.mdx)。 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 5729eaa6e..e7cb0c6af 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -1,5 +1,5 @@ --- -description: '把已构建的 agent-bundle 安装进 Claude Code、Codex 或 Cursor:install 命令、宿主作用域、独立的 install.mjs、npm CLI 一致性,以及 Doctor。' +description: '把已构建的 agent-bundle 安装进 Amp、Claude Code、Codex 或 Cursor:宿主作用域、回执归属副本、install.mjs、npm CLI 一致性与 Doctor。' --- # 宿主安装 @@ -14,11 +14,12 @@ description: '把已构建的 agent-bundle 安装进 Claude Code、Codex 或 Cur npx agent-bundle install claude --from artifact --scope user npx agent-bundle install codex --from artifact npx agent-bundle install cursor --from artifact +npx agent-bundle install amp --from artifact --scope user ``` `--from` 指定的是**组合根目录**:存放 `agent-bundle.manifest.json` 的那个目录。安装器只读这一个文件来了解 自己在安装什么——插件名与版本来自 `application`,市场名来自 `projections[host].marketplace`,宿主被指向的 -文件来自 `projections[host].documents.plugin`(见[产物清单](../../reference/artifact-manifest.mdx))。它不需要 +文件来自 `projections[host].documents.plugin`(Amp 使用 `documents.entry`;见[产物清单](../../reference/artifact-manifest.mdx))。它不需要 项目源码,也不存在 `/` 查找:构建不会写出 `artifact/` 子目录,根目录本身就是捆绑包。 以下情形会以 `AB7001` 拒绝:清单缺失或不是规范字节;没有该宿主的投影;投影没有宿主插件清单(或所指文件 不存在);`claude` 或 `codex` 没有声明市场身份;或 `cursor` 的插件名不是安全的本地插件名。 @@ -27,6 +28,7 @@ npx agent-bundle install cursor --from artifact | 宿主 | 机制 | 作用域 | | --- | --- | --- | +| Amp | 只把 `.amp/plugins//` 复制到项目根,或 `$XDG_CONFIG_HOME/amp/plugins/`(回退为 `~/.config/amp/plugins/`)。它不修改设置、信任、禁用状态、个人/工作区仓库或其他插件。`amp plugins list` 检查副本;运行中会话仍需交互式 Ctrl+O → `plugins: reload`。 | `project`、`user`(系统根) | | Claude Code | 委托给 `claude plugin marketplace add` 与 `claude plugin install`。Claude Code 会自行加载插件根目录下的 `hooks/hooks.json` 与 `.mcp.json`,因此 `.claude-plugin/plugin.json` 不带 `hooks` 指针(Claude Code 会把指向该文件的指针报告为重复的 hooks 文件);同一根目录中紧挨着 Codex 与 Cursor 各自清单的文档从不会被 Claude Code 读取。对于 `directory` 类型的市场,Claude Code 会把 `${CLAUDE_PLUGIN_ROOT}` 解析为该目录本身而不是其插件缓存,因此重新构建该目录会改变正在运行的会话所执行的钩子。 | `user`、`project`、`local` | | Codex | 委托给 `codex plugin marketplace add` 与 `codex plugin add`。`.codex-plugin/plugin.json` 指向自己的 `.codex-plugin/hooks.json` 与 `.codex-plugin/mcp.json`,因此 Codex 绝不会回退到发现根目录中另一宿主的文档。 | `user` | | Cursor | 把根目录复制到 `~/.cursor/plugins/local/`(默认的 `--mode local`),因为 Cursor 未发布非交互式安装动词;`--mode marketplace` 则在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步。无论哪种方式,Cursor 都加载 `.cursor-plugin/plugin.json` 所指定的钩子与 MCP 文档——紧挨着清单的 `.cursor-plugin/hooks.json` 与 `.cursor-plugin/mcp.json`——因此插件 hook 会在替换 `${CURSOR_PLUGIN_ROOT}` 后运行,无需 `~/.cursor/hooks.json` 条目。 | `user` | @@ -35,6 +37,10 @@ npx agent-bundle install cursor --from artifact 二进制文件不可用时,安装器会以一条带类型的诊断失败,而不是报告一次它并未完成的成功。宿主安装诊断属于 `AB700x` 家族:捆绑包标识、宿主可用性、作用域、命令失败与冲突检查。 +Amp 没有非交互式 reload 或独立校验器。安装结果只记录交互式 reload 步骤,绝不自动执行。本环境没有 Amp +账号,因此 `amp plugins list` 与 `amp skills list --json` 会要求登录;实时注册、启用状态与工具执行记为 +**未验证**,而不是不支持。 + ## 独立安装器 `targets` 中包含 `cursor` 或 `portable` 的根目录带有一个 `install.mjs`,它把根目录复制到 @@ -113,13 +119,14 @@ export const main = (argv: readonly string[]): Promise => `agent-bundle dev --install-host ` 维护的是一个*标记为开发用*的安装,它跟随成功的重建 epoch, 带有原子的世代切换与一条稳定的 proxy 命令。这部分内容在 [开发者 Workbench](../development/workbench.mdx)中,与 `agent-bundle install` 不是同一个操作。 +Amp 不属于开发期安装宿主;请使用其有归属回执的 `agent-bundle install amp` 副本与交互式 reload。 ## 重建后重新安装 每个输出的安装器——`agent-bundle install ` 与独立的 `install.mjs`——共用同一套替换策略。 内容完全相同的副本是 `already-installed` 空操作。**版本相同但内容哈希不同**的副本会被自动替换,因此不升版本 地重建不再需要卸载加 `rm -rf`。版本不同则以 `AB7005` 拒绝,除非传入 `--replace`(别名 `--force`);外来目录 -——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 副本携带安装回执(`.agent-bundle-install.json`: +——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 与 Amp 副本携带安装回执(`.agent-bundle-install.json`: 插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目, `--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在 `~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置)。`uninstall` @@ -184,6 +191,7 @@ RTORRENT_SSH_HOST=nas.local ```sh # 确切路径,不做改动 npx agent-bundle uninstall cursor --from artifact --plan +npx agent-bundle uninstall amp --from artifact --scope user --plan # 回执归属的文件;保留旧版或就地的 state/ npx agent-bundle uninstall cursor --from artifact # claude plugin uninstall --keep-data + marketplace remove @@ -193,8 +201,9 @@ node artifact/install.mjs --uninstall [--mode marketplace] ``` 每次安装都会写入生命周期回执(`agent-bundle-install-receipt/2`):版本、内容哈希、投递模式、作用域、归属的文件与 -目录、安装器创建的宿主目录、执行过的宿主注册,以及时间戳。Cursor 本地副本以 `.agent-bundle-install.json` 携带它; -Claude、Codex 与 Cursor 市场模式的安装把回执放在 `<宿主根目录>/agent-bundle/receipts/` 下。`uninstall` 只删除回执 +目录、安装器创建的宿主目录、执行过的宿主注册,以及时间戳。Cursor 本地副本与 Amp 项目/系统副本以 +`.agent-bundle-install.json` 携带它;Claude、Codex 与 Cursor 市场模式的安装把回执放在 +`<宿主根目录>/agent-bundle/receipts/` 下。`uninstall` 只删除回执 归属的内容,只撤销它记录的注册——绝不多删;非归属条目会被列为保留。有效框架状态根、推导出的 web-data、旧版 `state/`,以及 Agent Plugins 包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录,除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 副本标为 orphaned 并保留约 14 天;Codex 删除缓存树且没有 keep-data 选项)。缺少回执(`AB7009`)或内容不匹配 diff --git a/website/docs/zh/guide/start/index.mdx b/website/docs/zh/guide/start/index.mdx index aa807d0dc..7df719e37 100644 --- a/website/docs/zh/guide/start/index.mdx +++ b/website/docs/zh/guide/start/index.mdx @@ -1,11 +1,11 @@ --- -description: 'agent-bundle 将一份带类型的配置编译为 Claude Code、Codex、Cursor 与可移植 Agent Plugins 格式都能安装的同一个插件根目录。' +description: 'agent-bundle 将一份带类型的配置编译为 Amp、Claude Code、Codex、Cursor 与可移植 Agent Plugins 格式可安装的产物。' --- # 介绍 agent-bundle 把一个智能体插件——由一份带类型的配置描述的 Skills、钩子、MCP 服务器与脚本——编译为 -一个可安装到 Claude Code、Codex 与 Cursor 的插件根目录,外加可移植的 Agent Plugins 格式。插件只写 +可安装到 Amp、Claude Code、Codex 与 Cursor 的产物,外加可移植的 Agent Plugins 格式。插件只写 一次,编译器负责把每个宿主各自的清单与包装层生成到这同一个根目录中。 需要 Node.js 22.19 或更高版本。 @@ -22,10 +22,12 @@ agent-bundle 把这件事反转过来。宿主专属布局是编译器的职责 npx agent-bundle build --root . ``` -这一条命令会在 `artifact/` 生成一个复合插件根目录:每个所选宿主的清单(`.claude-plugin/`、 -`.codex-plugin/`、`.cursor-plugin/`,以及可移植格式的 `plugin.json`)覆盖在共享的 `skills/`、`hooks/`、 +这一条命令会在 `artifact/` 生成一个复合根目录:Amp 的 `.amp/plugins//` 目录与其他宿主清单 +(`.claude-plugin/`、`.codex-plugin/`、`.cursor-plugin/` +以及可移植格式的 `plugin.json`)覆盖在共享的 `skills/`、`hooks/`、 `mcp/`、`bin/` 与 `scripts/` 目录之上,外加一份 `INSTALL.md`。`targets` 选择根目录承载哪些宿主投影 -——`claude`、`codex`、`cursor`、`portable`;省略时只选择 `portable`——而所有宿主安装的都是同一个目录。 +——`amp`、`claude`、`codex`、`cursor`、`portable`;省略时只选择 `portable`。Amp 安装嵌套的生成目录, +其他宿主安装组合根目录。 ## 配置负责什么 diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index 4fef417db..9d690b2de 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -101,12 +101,13 @@ export default defineConfig({ ### 复合插件根目录 无论 `targets` 选择了什么,产物输出位置都只有一个目录。命令行把该输出默认为 `artifact/`,因此它永远 -不会与下文的包构建冲突;`output.distPath` 或 `--output` 可以移动它。每个所选宿主都把这个目录当作 -自己的插件根目录来读取——不存在 `artifact//` 这样的分区——因此 `targets` 决定的是根目录中出现 -哪些宿主清单,而不是任何东西落在哪里。省略 `targets` 时,根目录只承载 `portable` 投影。 +不会与下文的包构建冲突;`output.distPath` 或 `--output` 可以移动它。Amp 读取嵌套的 +`.amp/plugins//`,其他所选宿主把组合根当作插件根。不存在 `artifact//` 这样的分区。 +省略 `targets` 时,根目录只承载 `portable` 投影。 ```text artifact/ +├── .amp/plugins//index.js # amp 目录插件工厂 ├── .claude-plugin/plugin.json # claude,旁边是 marketplace.json ├── .codex-plugin/plugin.json # codex,旁边是 hooks.json 与 mcp.json ├── .agents/plugins/marketplace.json # codex marketplace diff --git a/website/docs/zh/guide/start/quick-start.mdx b/website/docs/zh/guide/start/quick-start.mdx index ad3ddacef..6b446a28e 100644 --- a/website/docs/zh/guide/start/quick-start.mdx +++ b/website/docs/zh/guide/start/quick-start.mdx @@ -70,7 +70,7 @@ export default defineConfig({ }); ``` -`targets` 选择这一个产物根目录承载哪些宿主投影——`claude`、`codex`、`cursor`、`portable`;省略它时 +`targets` 选择这一个产物根目录承载哪些宿主投影——`amp`、`claude`、`codex`、`cursor`、`portable`;省略它时 根目录只承载 `portable`。发布版本号来自 `package.json`。`plugin.version` 字段仍然可用,但它是已废弃的 兼容轴;取值与 `package.json` 不一致时会报告 `AB4008` 警告。 diff --git a/website/docs/zh/index.mdx b/website/docs/zh/index.mdx index cdd3210cd..33d196a34 100644 --- a/website/docs/zh/index.mdx +++ b/website/docs/zh/index.mdx @@ -1,12 +1,12 @@ --- pageType: home -description: '用一份带类型的配置描述 Skill、钩子、MCP 服务器与脚本,编译为 Claude Code、Codex 与 Cursor 都能直接安装的同一个插件根目录。' -titleSuffix: ' - 面向 Claude Code、Codex 与 Cursor 的 Agent 插件编译器' +description: '用一份带类型的配置描述 Skill、钩子、MCP 服务器与脚本,编译为 Amp、Claude Code、Codex 与 Cursor 可安装的产物。' +titleSuffix: ' - 面向 Amp、Claude Code、Codex 与 Cursor 的 Agent 插件编译器' hero: name: agent-bundle text: 一份带类型的配置,适配所有 Agent 宿主 - tagline: 一次性描述 Skill、钩子、MCP 服务器与脚本,编译出 Claude Code、Codex 与 Cursor 都能直接安装的同一个插件根目录。 + tagline: 一次性描述 Skill、钩子、MCP 服务器与脚本,编译出 Amp、Claude Code、Codex 与 Cursor 可安装的产物。 image: src: /logo.svg alt: agent-bundle 徽标 @@ -188,11 +188,12 @@ CLI 安装的捆绑包——并运行结果为通过、失败或不确定的[评 ## 一份源码,所有宿主 -`targets` 选择这一个根目录承载哪些宿主投影;省略它时根目录只承载 `portable`。下表每一行读取的都是 -同一个目录——不同的只是宿主寻找的清单与安装动词。 +`targets` 选择这一个根目录承载哪些宿主投影;省略它时根目录只承载 `portable`。Amp 读取嵌套目录插件; +其他行读取组合根目录。 | Target | 为根目录添加的内容 | 安装方式 | | --- | --- | --- | +| `amp` | `.amp/plugins//index.js`、显式注册的捆绑 Skill、PluginAPI 事件回调与 Skill 范围 MCP。 | 把生成目录复制到项目/系统根,或运行 `agent-bundle install amp --from artifact`。 | | `claude` | `.claude-plugin/plugin.json` 与本地 `marketplace.json`,以及 `hooks/hooks.json` 与 `.mcp.json`。 | `claude plugin marketplace add` 与 `claude plugin install`,或 `agent-bundle install claude --from artifact`。 | | `codex` | `.codex-plugin/plugin.json` 及其旁边的 `hooks.json` 与 `mcp.json`,以及 `.agents/plugins/marketplace.json`。 | `codex plugin marketplace add` 与 `codex plugin add`,或 `agent-bundle install codex --from artifact`。 | | `cursor` | `.cursor-plugin/plugin.json` 及其旁边的 `hooks.json` 与 `mcp.json`。 | 生成的 `install.mjs`,或 `agent-bundle install cursor --from artifact`。 | diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx index ea161f1ef..07e5f01b2 100644 --- a/website/docs/zh/reference/artifact-manifest.mdx +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -1,5 +1,5 @@ --- -description: 'agent-bundle.manifest.json 契约(manifestVersion 3):build 一次性写出的组合插件根目录索引——每个分节与字段、由谁写入、由谁读取、随包发布的 JSON Schema、保留键,以及版本规则。' +description: 'agent-bundle.manifest.json 契约(manifestVersion 4):build 一次性写出的组合插件根目录索引——每个分节与字段、由谁写入、由谁读取、随包发布的 JSON Schema、保留键,以及版本规则。' --- # 产物清单 @@ -16,11 +16,11 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | 属性 | 规则 | | --- | --- | | 只写一次 | 只有 `agent-bundle build` 在暂存根目录完成之后写出它。没有任何别的东西会创建它;构建之后唯一的编辑是 `files[]` 一节所述对派生变体的重新索引,它只重新测量非编译行,绝不触碰编译行。 | -| `manifestVersion` | `3`。读取方拒绝任何其他值。 | +| `manifestVersion` | `4`。读取方拒绝任何其他值。 | | 规范字节 | 文件就是该文档的规范 JSON 序列化——对象键已排序、没有无意义的空白、末尾恰有一个换行。每个读取方都会把解析结果重新序列化,字节不完全一致就拒绝该文件。手工编辑、重新格式化或重复键都会让读取失败。 | | 封闭键 | 每个对象只接受文档化的那些键。任何层级上的未知键都是解析错误。 | | 有序数组 | 每个数组都有显式的排序键(各分节下方注明),且没有重复项。 | -| 精简读取方 | 上面各行描述的是完整解析器(`parseArtifactManifest`),下表中的消费者除一个之外都使用它:打包进生成 bin 的 ` web` 支持不经解析器读取清单。它只校验自己启动所需的切片并忽略其余所有键,但对这些切片是拒绝而非过滤,所用检查与完整解析器相同(由 `web-host/manifest.ts` 持有,两者都从它导入):`3` 以外的 `manifestVersion` 或重复的 JSON 键;不是对象、未命名 host 或重复 host 的 `projections[]` 行;不是对象、`kind` 未知、按 kind 不应携带却带有 `launch` 记录(或应携带却缺失)、或重复 server `name` 的 `executables.mcpServers[]` 行——同名两行绝不会解析为后者;格式错误的 `launch` 记录,或其 entry 不是其服务器 kind 所启动的那种 `files[]` 行(`compiled` 对应 `bundle`,`prebuilt` 对应 `prebuilt`)、worker 不是 `bundle` 行、或 `artifact` 参数不在根目录之内的记录(参数可以指向根目录下含有行的目录);以及 `server` 并非带 launch 记录之行的 `web.apps[]` 行。 | +| 精简读取方 | 上面各行描述的是完整解析器(`parseArtifactManifest`),下表中的消费者除一个之外都使用它:打包进生成 bin 的 ` web` 支持不经解析器读取清单。它只校验自己启动所需的切片并忽略其余所有键,但对这些切片是拒绝而非过滤,所用检查与完整解析器相同(由 `web-host/manifest.ts` 持有,两者都从它导入):`4` 以外的 `manifestVersion` 或重复的 JSON 键;不是对象、未命名 host 或重复 host 的 `projections[]` 行;不是对象、`kind` 未知、按 kind 不应携带却带有 `launch` 记录(或应携带却缺失)、或重复 server `name` 的 `executables.mcpServers[]` 行——同名两行绝不会解析为后者;格式错误的 `launch` 记录,或其 entry 不是其服务器 kind 所启动的那种 `files[]` 行(`compiled` 对应 `bundle`,`prebuilt` 对应 `prebuilt`)、worker 不是 `bundle` 行、或 `artifact` 参数不在根目录之内的记录(参数可以指向根目录下含有行的目录);以及 `server` 并非带 launch 记录之行的 `web.apps[]` 行。 | | 路径 | 每个路径都是 POSIX 形式、相对根目录且安全:没有前导 `/`、没有反斜杠、没有 `.` 或 `..` 段,也绝不会是构建机上的绝对路径。把整个组合根目录搬到另一个绝对路径后,每个读取方仍然可用:所有路径都保持相对根目录,并在搬迁后的树内解析。任何分节指向的路径——宿主文档、可执行文件、App 视图、安装文件——同时也是一行 `files[]`。 | ## 由谁写入 @@ -28,7 +28,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 `agent-bundle build`,别无他者。文档中的一切都是**派生**的:作者声明 `agent-bundle.config.ts`(元数据、 `targets`、钩子、MCP 服务器、脚本、市场)与约定文件系统(`src/mcp/**`、`src/cli/**`、`src/hooks/**`、 `src/events/**`、`src/scripts/**`),构建则从规范化模型、编译后的路由图、组合计划以及它输出的文件写出清单。 -根目录中的宿主文档——Claude、Codex、Cursor 与 portable 的插件清单,市场、MCP 与钩子文档——和清单在同一次 +Amp 的 `index.js` 入口,以及 Claude、Codex、Cursor 与 portable 的插件清单、市场、MCP 与钩子文档,和清单在同一次 构建中由同一个编译模型序列化而成。每份宿主文档都是带摘要固定的 `files[]` 行,因此手工编辑会使产物摘要校验 失败。文件中没有任何可手工编辑的内容:修改配置或源码,然后重新构建。 @@ -36,7 +36,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | 消费者 | 读取什么 | | --- | --- | -| `install --from `、`uninstall` | `` 下的清单。身份——插件名与版本——来自 `application`;市场名来自 `projections[host].marketplace`;宿主被指向的文件来自 `projections[host].documents.plugin`。`install` 从 `files[]` 加清单自身和操作员 `.env` 覆盖层取得复制集合与产物内容哈希,并直接读取这些固定路径而不遍历产物目录。没有清单、清单不是规范字节、没有该宿主的投影、投影没有宿主插件清单,或某个 `files[]` 条目缺失或摘要不符的根目录,都以 `AB7001` 拒绝。 | +| `install --from `、`uninstall` | `` 下的清单。身份来自 `application`;市场名来自 `projections[host].marketplace`;宿主文件来自 `documents.plugin`,Amp 工厂来自 `documents.entry`。Amp 只安装该 entry 所在目录;其他宿主使用清单拥有的根。缺少清单、投影、所需宿主文件或摘要不符时以 `AB7001` 拒绝。 | | `doctor --from ` | 与 `install` 相同的身份、固定路径文件集合与内容哈希读取,按被检查的宿主逐一进行;它对树运行的静态校验(`AB7319`)继续按投影进行。在已安装的根目录上,`doctor` 与 `uninstall` 通过安装时复制的清单——即 `projections[host].documents.mcp` 指向的宿主 MCP 文档——定位声明的 `AGENT_BUNDLE_STATE_ROOT` 覆盖,绝不探测宿主文档路径;没有清单的根目录不声明文档覆盖,转而回退到继承的 `AGENT_BUNDLE_STATE_ROOT`,再回退到推导出的状态根。 | | `serve-app`、`mcp list` / `invoke` / `run` | 从 `executables.mcpServers[]` 取服务器编译后的 `launch` 记录及其 `apps[]`;省略 `--target` 时从 `projections[]` 选出宿主。 | | `hooks list` / `simulate` | `executables.hooks[]`——可被模拟的编译器 wrapper。 | @@ -58,7 +58,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | 字段 | 内容 | | --- | --- | -| `manifestVersion` | `3`。为消费者读取的公开契约版本化。 | +| `manifestVersion` | `4`。为消费者读取的公开契约版本化。 | | `runtime` | `{ node }`——该根目录据以构建的面向消费者的生成式可执行文件运行时下限,为满足框架下限的规范 `major.minor.patch` 字符串。 | ### `application` @@ -100,16 +100,17 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | 字段 | 必填 | 内容 | | --- | --- | --- | -| `host` | 是 | 项目选择该投影时所用的目标名:`claude`、`codex`、`cursor`、`portable`,或已注册适配器的名字。名字是选择,不是身份。 | -| `builtInHost` | 由内置适配器规划时 | 规划该投影的内置适配器身份:`claude`、`codex`、`cursor` 或 `portable`。高级注册表的适配器缺省此字段——即使它注册在内置名字下。`install`、`doctor` 与已安装宿主测试工具都按此字段选取宿主投影,而不是按 `host`。 | +| `host` | 是 | 项目选择该投影时所用的目标名:`amp`、`claude`、`codex`、`cursor`、`portable`,或已注册适配器的名字。名字是选择,不是身份。 | +| `builtInHost` | 由内置适配器规划时 | 规划该投影的内置适配器身份:`amp`、`claude`、`codex`、`cursor` 或 `portable`。高级注册表的适配器缺省此字段——即使它注册在内置名字下。安装与产物消费者都按此字段选取内置宿主投影,而不是按 `host`。 | | `documents` | 是 | 指向该投影输出的宿主文档的根相对指针;每个存在的指针都是一行 `files[]`。见下文。 | | `marketplace` | 否 | `{ name }`——该投影的市场文档所注册的市场。只与 `documents.marketplace` 同时出现。 | -`documents` 有四个可选键,每个键只在投影输出了对应文件时出现: +`documents` 有五个可选键,每个键只在投影输出了对应文件时出现: | 键 | 内容 | | --- | --- | -| `plugin` | 宿主插件清单(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`.cursor-plugin/plugin.json` 或 portable 的 `plugin.json`)。缺失时,`install` 与 `doctor` 以 `AB7001` 拒绝该宿主。 | +| `entry` | 没有 JSON 清单的宿主可执行插件入口。Amp 在此记录 `.amp/plugins//index.js`。 | +| `plugin` | 宿主插件清单(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`.cursor-plugin/plugin.json` 或 portable 的 `plugin.json`)。宿主既没有它也没有必需的 `entry` 时以 `AB7001` 拒绝。 | | `marketplace` | 市场文档(`.claude-plugin/marketplace.json`、`.agents/plugins/marketplace.json`、`.cursor-plugin/marketplace.json`)。 | | `mcp` | 宿主 MCP 文档(`.mcp.json`、`.codex-plugin/mcp.json`、`.cursor-plugin/mcp.json`、`mcp.json`)。 | | `hooks` | 宿主钩子文档(`hooks/hooks.json`、`.codex-plugin/hooks.json`、`.cursor-plugin/hooks.json`)。 | @@ -320,7 +321,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 { "bytes": 412, "kind": "generated", "path": ".claude-plugin/plugin.json", "sha256": "…" } ], - "manifestVersion": 3, + "manifestVersion": 4, "projections": [ { "builtInHost": "claude", "documents": { "hooks": "hooks/hooks.json", "marketplace": ".claude-plugin/marketplace.json", @@ -372,7 +373,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | `readArtifactManifest(root)` | 打开 `/agent-bundle.manifest.json`,返回 `{ status: 'ok', manifest, path, root }`、`{ status: 'missing', … }` 或 `{ status: 'invalid', detail, … }`。这是框架自身的消费者打开已构建根目录的唯一方式。 | | `parseArtifactManifest(bytes)` | 把规范字节解析并校验为冻结的 `ArtifactManifest`;任何违规——包括重复键或非规范字节——都会抛出。 | | `artifactManifestName` | `'agent-bundle.manifest.json'`。 | -| `artifactManifestVersion` | `3`。 | +| `artifactManifestVersion` | `4`。 | | `artifactCompilerRecordVersion` | `1`。 | 类型(`ArtifactManifest`、`ArtifactManifestCompiler`、`ArtifactManifestCompilerAdapter`、 @@ -381,7 +382,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 ## 保留键 -这些键属于设计的一部分,但**不会输出**,v3 读取方会像对待任何未知键一样拒绝它们。在此列出,是为了让消费者 +这些键属于设计的一部分,但**不会输出**,v4 读取方会像对待任何未知键一样拒绝它们。在此列出,是为了让消费者 不要自创拼写: | 保留键 | 预期内容 | @@ -399,7 +400,8 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 - `manifestVersion` 为消费者读取的**公开契约**版本化(`application`、`files[]`、`runtime`、 `projections[]`、`routes`、`executables`、`distribution`)。旧的封闭读取方会拒绝的**任何**变更 都会使其递增——新增、重命名或删除一个公开契约键(无论是否可选),或更改读取方封闭的枚举值集合。 - 同一版本内键清单冻结。读取方拒绝任何其他 `manifestVersion`。版本 3 标记 npm 根目录保留清单所拥有的 + 同一版本内键清单冻结。读取方拒绝任何其他 `manifestVersion`。版本 4 新增 + `projections[].documents.entry` 与 `amp` 内置宿主。版本 3 标记 npm 根目录保留清单所拥有的 字节与可执行文件这一规范保证;版本 2 已预留同样的 `distribution.install` 指针形状。版本 2 把 `targets[]` 重命名为 `projections[]`,把过去由 `agent-bundle.hooks.json` 承载的钩子行移入 `executables.hooks[]` 并删除了 该文件,并新增了 `application`、`routes`、`executables`、`distribution` 与 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 073cee214..5b387f971 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -37,7 +37,7 @@ npx agent-bundle --version | `--root ` | `process.cwd()` | 项目根目录。 | | `--config ` | —— | 相对 `--root` 的配置文件。 | | `--mode ` | `production` | 配置模式。 | -| `--target ` | 配置中的 `targets`,否则为 `portable` | 要选择的宿主投影:`claude`、`codex`、`cursor` 或 `portable`。可重复;本次运行中该选择替换配置里的 `targets`,无论顺序如何,所有所选宿主都落入同一个组合根目录。其他任何名字(包括 `plugin`)都是 `AB4100`。 | +| `--target ` | 配置中的 `targets`,否则为 `portable` | 要选择的宿主投影:`amp`、`claude`、`codex`、`cursor` 或 `portable`。可重复;本次运行中该选择替换配置里的 `targets`,无论顺序如何,所有所选宿主都落入同一个组合根目录。其他任何名字(包括 `plugin`)都是 `AB4100`。 | | `--json` | 关闭 | 写出一份机器可读的 JSON 文档。 | **产物命令** —— `mcp list`、`mcp invoke`、`mcp run`、`serve-app`、`hooks list`、`hooks simulate`:同样的 @@ -173,9 +173,9 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `` | **必填** | `claude`、`codex` 或 `cursor`。 | -| `--from ` | `process.cwd()` | 组合根目录:存放 `agent-bundle.manifest.json` 的目录。身份(名称、版本、市场)来自清单的 `application` 与 `projections[host]`,宿主被指向 `projections[host].documents.plugin`;不存在 `/` 查找,所有宿主都从同一个目录安装。清单缺失或不是规范字节、没有该宿主的投影、投影没有宿主插件清单或所指文件缺失、`claude`/`codex` 没有市场身份,或 `cursor` 插件名不安全时为 `AB7001`。见[产物清单](./artifact-manifest.mdx)。 | -| `--scope ` | `user` | `user`、`project` 或 `local`。Claude 接受全部三种;Codex 与 Cursor 是 user 作用域。 | +| `` | **必填** | `amp`、`claude`、`codex` 或 `cursor`。 | +| `--from ` | `process.cwd()` | 存放 `agent-bundle.manifest.json` 的组合根。身份来自 `application` 与 `projections[host]`;其他宿主使用 `documents.plugin`,Amp 只复制 `documents.entry` 所在目录。缺少清单、投影、所需宿主文件、不安全本地名称或 Claude/Codex 市场身份时为 `AB7001`。 | +| `--scope ` | `user` | `user`、`project` 或 `local`。Claude 接受全部三种;Amp 接受 `project` 与 `user`(系统根;项目安装应从目标项目根运行);Codex 与 Cursor 是 user 作用域。 | | `--mode ` | `local` | 仅限 Cursor。`local` 把捆绑包复制到 `~/.cursor/plugins/local/`;`marketplace` 在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步,让 Cursor 把该插件当作市场安装来管理。 | | `--replace`(别名 `--force`) | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 | @@ -183,7 +183,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ (`.agent-bundle-install.json`),替换只触碰归属文件,`--replace` 会接管回执出现之前的副本;Claude 的替换 先运行 `claude plugin uninstall --keep-data` 再重新安装,Codex 则先 `codex plugin remove` 再 `add`。 每次安装都会写入生命周期回执(格式 `agent-bundle-install-receipt/2`:版本、内容哈希、模式、作用域、归属路径、 -宿主注册、时间戳)——Cursor 本地副本写在树内,Claude、Codex 与 Cursor 市场模式写在 +宿主注册、时间戳)——Cursor 与 Amp 本地副本写在树内,Claude、Codex 与 Cursor 市场模式写在 `<宿主根目录>/agent-bundle/receipts/` 下——`uninstall` 与 `doctor` 都消费它。 安装期间,尚不存在的显式状态根会由安装器创建,并写入记录安装身份的 `.agent-bundle-state-owner.json`。为到达该根而创建的父目录不会被声明为归属内容,也绝不会被递归删除。 @@ -197,9 +197,9 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `` | **必填** | `claude`、`codex` 或 `cursor`。 | +| `` | **必填** | `amp`、`claude`、`codex` 或 `cursor`。 | | `--from ` | `process.cwd()` | 其 `agent-bundle.manifest.json` 用于识别插件(名称、版本、市场)的组合根目录,读取方式与 `install` 完全相同(相同条件下为 `AB7001`)。 | -| `--scope ` | `user` | 安装时使用的作用域(Claude)。 | +| `--scope ` | `user` | 安装时使用的作用域(Claude 或 Amp)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | | `--keep-data` | 开启 | 保留回执记录的所有框架状态根、推导出的 web-data、旧版 `state/`,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明,并保留归属回执供以后 purge。 | | `--purge-data` | 关闭 | 只删除回执记录且由该安装独占的持久数据根。没有 `--confirm-purge` 时被拒绝(`AB7008`);共享、外部管理、无标记、外来标记及其他无法证明归属的根都会被保留并列出。 | @@ -207,7 +207,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | uninstall 只删除回执归属的内容:记录的文件与安装器创建的目录(Cursor 本地,包括安装本身创建的 -`~/.cursor/plugins[/local]` 目录)、暂存仓库(Cursor 市场模式,在其 `HEAD` 与记录的提交匹配之后),或宿主注册 +`~/.cursor/plugins[/local]` 目录;Amp 项目/系统副本只限 `.amp/plugins/`)、暂存仓库(Cursor 市场模式,在其 `HEAD` 与记录的提交匹配之后),或宿主注册 (`claude plugin uninstall --scope --keep-data` + `claude plugin marketplace remove`, `codex plugin remove` + `codex plugin marketplace remove`——若另一个已安装插件仍在使用该市场,则保留市场, 包括仅记录在 Claude 的 `plugins/installed_plugins.json` 注册表中、位于另一作用域或另一项目的 Claude 安装)。 diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index 20ac2e448..92383267d 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -85,7 +85,7 @@ export default defineConfig({ ## targets -`build` 铺进组合插件根目录的宿主投影:`claude`、`codex`、`cursor` 与 `portable` 的任意组合。顺序无关—— +`build` 铺进组合插件根目录的宿主投影:`amp`、`claude`、`codex`、`cursor` 与 `portable` 的任意组合。顺序无关—— 选择会按名称排序,因此 `['codex', 'claude']` 与 `['claude', 'codex']` 构建出相同的字节。省略该键时只输出 `portable` 投影。命令行上的 `--target` 会在单次运行中替换配置里的列表。其他任何名字(包括 `plugin`)都是 未知 target(`AB4100`)。每个投影在根目录中放置什么,见 [Target 与产物](./targets-artifacts.mdx)。 diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index f375af344..7d85ae32f 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -11,8 +11,9 @@ target 表格——各宿主投影携带什么,以及 portable 标准为何省 ## 组合插件根目录 `agent-bundle build` 在产物输出位置写出**一个目录**——默认是 `artifact/`;`output.distPath` 或 `--output` -可以移动它。`targets` 选择铺进该根目录的**宿主投影**:`claude`、`codex`、`cursor` 与 `portable`,可任意 -组合。每个所选宿主都把同一个目录当作自己的插件根目录来读取;没有需要把宿主指向的逐宿主子目录。 +可以移动它。`targets` 选择铺进该根目录的**宿主投影**:`amp`、`claude`、`codex`、`cursor` 与 `portable`, +可任意组合。Claude Code、Codex、Cursor 与 portable 把根目录当作插件根;Amp 则读取生成在 +`.amp/plugins//` 的那一个目录。 - 省略 `targets`——配置与命令行都省略——时,构建只输出 `portable` 投影。 - 顺序无关。`['codex', 'claude']` 与 `['claude', 'codex']` 产出逐字节相同的输出,规范化模型、产物清单与 @@ -20,10 +21,12 @@ target 表格——各宿主投影携带什么,以及 portable 标准为何省 - `plugin` 不是 target。`targets: ['plugin']` 或 `--target plugin` 是未知 target(`AB4100`):组合根目录 本来就是每次构建的输出。 -以全部四个投影构建时,host-test 示例的根目录如下(组件目录只在项目编写了对应组件时才会出现): +以全部五个投影构建时,host-test 示例的根目录如下(组件目录只在项目编写了对应组件时才会出现): ```text artifact/ +├── .amp/plugins//index.js # Amp PluginAPI 工厂 +├── .amp/plugins//skills/ # 显式注册的 Amp Skill ├── .agents/plugins/marketplace.json # Codex 市场 ├── .claude-plugin/plugin.json # Claude Code 清单 ├── .claude-plugin/marketplace.json @@ -46,7 +49,7 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 只输出一次 ├── INSTALL.md # 选中了任一内置宿主时 ├── install.mjs # 选中了 cursor 或 portable 时 -├── agent-bundle.manifest.json # 产物索引(manifestVersion 3) +├── agent-bundle.manifest.json # 产物索引(manifestVersion 4) └── agent-bundle.compile-evidence.json # 每个已编译文件的编译器记录 ``` @@ -63,12 +66,14 @@ artifact/ | 宿主 | 清单 | 钩子文档 | MCP 文档 | 市场 | | --- | --- | --- | --- | --- | +| Amp | ——;`index.js` 记录为 `documents.entry` | 由 `amp.on` 注册回调 | 已注册 Skill 旁的扁平 `skills//mcp.json` | —— | | Claude Code | `.claude-plugin/plugin.json` | `hooks/hooks.json` | `.mcp.json` | `.claude-plugin/marketplace.json` | | Codex | `.codex-plugin/plugin.json` | `.codex-plugin/hooks.json` | `.codex-plugin/mcp.json` | `.agents/plugins/marketplace.json` | | Cursor | `.cursor-plugin/plugin.json` | `.cursor-plugin/hooks.json` | `.cursor-plugin/mcp.json` | `marketplace: true` 时为 `.cursor-plugin/marketplace.json` | | portable | `plugin.json` | —— | `mcp.json` | —— | -无论选择了哪些宿主,这些路径都是固定的,因此单宿主根目录与四宿主根目录共用同一种布局。Claude Code 与 +无论选择了哪些宿主,这些路径都是固定的,因此单宿主根目录与五宿主根目录共用同一种布局。Amp 的目录隔离在 +`.amp/plugins//` 下;Claude Code 与 portable 的 Agent Plugins 格式从约定的插件根位置加载文档,无法重定向;Codex 与 Cursor 的清单携带显式的 `hooks` 与 MCP 指针,因此它们的文档紧挨着各自的清单。这两个宿主在指针缺失时还会回退到对约定路径的目录 发现,因此当钩子或 MCP 服务器到达另一个所选宿主的约定路径时,没有自己文档的 Codex 或 Cursor 投影仍会 @@ -96,6 +101,9 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 `+` 连接,例如 `claude+codex`——而不是归属于任何单个宿主。`agent-bundle inspect --bundler` 显示的是同一 件事:它的 `output.path` 就是产物输出 ``,其下没有宿主段。 +Amp 的 MCP 是例外:它位于 Skill 范围,而非插件根 MCP 文档。适配器只在恰有一个捆绑 Skill 时接受可搬迁的 +远程服务器与全局可解析命令。Amp 未文档化插件根 token 或执行 `cwd`,所以编译器拥有的本地 MCP 入口会被拒绝。 + ### 一个根目录,一套字节 按路径合并投影,只有在各宿主对字节达成一致时才行得通。三条规则守住根目录的诚实,它们都是错误,且 @@ -105,7 +113,7 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 | --- | --- | | `AB4103` | 两个所选投影为同一路径规划了不同的字节。投影按宿主名顺序比较、路径按路径顺序比较,因此无论 `targets` 怎么写,同一选择报告的都是同一处冲突。常见原因是 frontmatter 带有宿主扩展(`targets: { claude: … }`)的 Skill:它为 Claude Code 降级出的 `skills//SKILL.md` 字节与其他宿主不同。请让该组件对每个所选宿主都一致,或把冲突的宿主构建进单独的产物。 | | `AB4105` | 一个只面向部分所选宿主的组件——frontmatter 带 `targets` 的命令或规则——位于另一个所选宿主会扫描的约定目录中(Claude Code 与 Cursor 的 `commands/`、Cursor 的 `rules/`)。在同一个根目录里无法把该文件对那个宿主隐藏起来,因此构建宁可拒绝也不泄漏它。请把 `targets` 扩展到每个会发现该目录的所选宿主,或把这些宿主分开构建。Skill 从不按宿主限定:每个 skill 都会交付给所有所选宿主,会改变其字节的按宿主 frontmatter 扩展属于 `AB4103` 冲突。 | -| `AB4106` | 所选目标把注册在高级 `TargetRegistry` 上的适配器——即任何其适配器不是随框架发布的 `claude`、`codex`、`cursor`、`portable` 适配器的目标,按适配器身份而非名称判断,因此以这些名字注册的自定义适配器同样算作高级适配器——与另一个目标混在一起。只有内建宿主就各自不共享的文档放在何处、各自扫描哪些目录以及同一套安装面达成了一致,因此第三方适配器要单独构建:`targets: ['']` 输出到自己的 `--output`。只选一个目标时永不触发。 | +| `AB4106` | 所选目标把注册在高级 `TargetRegistry` 上的适配器——即任何其适配器不是随框架发布的 `amp`、`claude`、`codex`、`cursor`、`portable` 适配器的目标,按适配器身份而非名称判断,因此以这些名字注册的自定义适配器同样算作高级适配器——与另一个目标混在一起。只有内建宿主就各自不共享的文档放在何处、各自扫描哪些目录以及同一套安装面达成了一致,因此第三方适配器要单独构建:`targets: ['']` 输出到自己的 `--output`。只选一个目标时永不触发。 | 三者与 `AB4100` 一起列在[诊断参考](./diagnostics.md)中。 @@ -117,6 +125,7 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 | 所选宿主 | 市场清单 | 安装表面 | | --- | --- | --- | +| `amp` | —— | 把 `.amp/plugins//` 复制到项目或系统插件根。 | | `claude` | `.claude-plugin/marketplace.json`。 | `claude plugin marketplace add` + `claude plugin install`。 | | `codex` | `.agents/plugins/marketplace.json`。 | `codex plugin marketplace add` + `codex plugin add`。 | | `cursor` | `marketplace: true` 时为 `.cursor-plugin/marketplace.json`。 | `install.mjs`。 | @@ -128,14 +137,14 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 ## agent-bundle.manifest.json -每个根目录输出一份清单(`manifestVersion: 3`)。它是根目录的**索引**——每个消费者都通过这一份文档了解 +每个根目录输出一份清单(`manifestVersion: 4`)。它是根目录的**索引**——每个消费者都通过这一份文档了解 根目录包含什么——也是之后每一项完整性检查的输入:`validate --artifact`、`prepack`、`install`、 `doctor --from`、`serve-app`、`mcp`、`hooks`,以及 packed 与 installed-host 证明级别。完整的字段参考见 [产物清单](./artifact-manifest.mdx)页;概要如下: | 分节 | 内容 | | --- | --- | -| `manifestVersion`、`runtime` | `3`,以及 `{ node }`,即面向消费者的生成式可执行文件下限。 | +| `manifestVersion`、`runtime` | `4`,以及 `{ node }`,即面向消费者的生成式可执行文件下限。 | | `application` | 身份,只记录一次且与宿主无关:`id`、`name`、`version`,可选的 `description`。这是 `install`、`doctor` 与 `uninstall` 作用的对象。 | | `files[]` | 每个输出文件:`path`、`bytes`、`sha256`、`kind`(`bundle`、`copy`、`generated`、`prebuilt`)、可选的 `mode`。 | | `projections[]` | 每个所选宿主一行,按 `host` 排序:`host`、可选的 `builtInHost`、指向该投影输出的宿主插件、市场、MCP 与钩子文档的 `documents` 指针,以及它的 `marketplace` 名。 |