diff --git a/README.md b/README.md index e353caf4..877c42b6 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ path = "plugins/review-tools" targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] ``` -The canonical portable format is an [Agent Plugins](https://agent-plugins.org/) v1 bundle: required `plugin.json`, optional `skills/`, optional `mcp.json`, and reverse-domain client extensions. dotagents preserves those portable source files under `.agents/plugins//` and generates isolated target harnesses. OpenCode receives portable MCP servers under managed keys such as `plugin..`; `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` are expanded into the installed bundle and persistent `.agents/plugin-data/` paths. Generated JSON uses adjacent ownership sidecars, while component symlinks use markers in reserved `.dotagents-managed/` directories, so client-owned JSON remains unchanged. Legacy generalized and native Claude/Cursor/Codex manifests remain discoverable during migration; native imports preserve their owning manifest and expose only core metadata and Agent Skills to other clients. Standard bundles reject legacy root components so client-specific behavior cannot leak across harnesses. +The canonical portable format is an [Agent Plugins](https://agent-plugins.org/) v1 bundle: required `plugin.json`, optional `skills/`, optional `mcp.json`, and reverse-domain client extensions. dotagents preserves those portable source files under `.agents/plugins//` and generates isolated target harnesses. OpenCode receives portable MCP servers under managed keys such as `plugin..`; `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` are expanded into the installed bundle and persistent `.agents/plugin-data/` paths. Generated JSON uses adjacent ownership sidecars, while component symlinks use markers in reserved `.dotagents-managed/` directories, so client-owned JSON remains unchanged. Legacy generalized and native Claude/Cursor/Codex manifests remain discoverable during migration. A valid standard root may also coexist with authored native manifests as a hybrid compatibility bundle: the portable root remains the source of truth, reproducible native manifests are ignored in favor of portable generation, and manifests with behavior an adapter cannot represent are retained byte-for-byte only as matching-client fallbacks. Generated adapters are disposable output and are never imported back into the portable core. Native commands, agents, hooks, MCP, and other resources never leak into unrelated targets. Invalid standard roots still fail instead of falling back to legacy parsing. Global plugins install canonical bundles under `~/.agents/plugins/`. Claude and Cursor marketplaces are generated under `~/.agents/`, the Codex marketplace is generated at `~/.agents/plugins/marketplace.json`, Grok plugins are copied into `~/.grok/plugins/`, OpenCode skills are linked into `~/.config/opencode/skills/`, portable MCP servers are merged into `~/.config/opencode/opencode.json`, and Pi skills are linked into `~/.agents/skills/`. `--user` remains a compatibility alias for `--global`. diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 2d0a0e1a..25b13fe4 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -291,7 +291,7 @@ Generated files include a dotagents header marker. `install` and `sync` overwrit Each `[[plugins]]` entry requires `name` and `source`. Optional: `ref`, `path`, and `targets`. When `targets` is absent or empty, dotagents targets every agent listed in `agents`. -dotagents installs canonical plugin bundles under `.agents/plugins//`. New bundles use Agent Plugins v1: required `plugin.json`, optional `skills/`, optional `mcp.json`, and reverse-domain client extensions. Portable source files are preserved and target JSON remains client-native. OpenCode receives portable MCP servers under managed `plugin..` keys, with `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` expanded into installed and persistent managed paths. Generated JSON uses adjacent ownership sidecars; component symlinks use marker files in reserved `.dotagents-managed/` directories. Standard bundles reject legacy root components so client-specific behavior cannot leak across harnesses. Native Claude/Cursor/Codex bundles may be imported conservatively: the owning native manifest is preserved, while other clients receive only core metadata and Agent Skills. Native MCP and client-specific components are not guessed or cross-translated. +dotagents installs canonical plugin bundles under `.agents/plugins//`. New bundles use Agent Plugins v1: required `plugin.json`, optional `skills/`, optional `mcp.json`, and reverse-domain client extensions. Portable source files are preserved and target JSON remains client-native. OpenCode receives portable MCP servers under managed `plugin..` keys, with `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` expanded into installed and persistent managed paths. Generated JSON uses adjacent ownership sidecars; component symlinks use marker files in reserved `.dotagents-managed/` directories. Native Claude/Cursor/Codex bundles may be imported conservatively. A valid standard root may coexist with authored Claude, Cursor, or Codex manifests as a hybrid compatibility bundle: the portable root remains the source of truth, reproducible native manifests are ignored in favor of portable generation, and manifests with unrepresentable behavior are retained byte-for-byte only as matching-client fallbacks. Generated adapters are disposable and never become portable or fallback input. Native commands, agents, hooks, MCP, and other resources are not guessed or cross-translated. A malformed fallback fails preflight only when its client is selected; unselected fallbacks remain inert with warnings. Invalid standard roots never downgrade to native or generalized legacy parsing. | Field | Type | Required | Description | |-------|------|----------|-------------| @@ -470,7 +470,7 @@ Show trusted sources with their type. Use `--json` for machine-readable output. npx @sentry/dotagents list [--json] ``` -Show declared skills, plugins, and status. JSON output is an object with `skills` and `plugins` arrays. +Show declared skills, plugins, and status. Hybrid compatibility diagnostics appear as indented warning lines. JSON output is an object with `skills` and `plugins` arrays; plugin entries include a `warnings` array when diagnostics exist. | Status | Meaning | |--------|---------| @@ -486,7 +486,7 @@ Skills from wildcard entries are marked with a wildcard indicator. npx @sentry/dotagents doctor [--fix] ``` -Check selected-scope health: gitignore setup where applicable, installed skills and plugins, plugin runtime projections, symlinks, legacy config fields, and legacy managed project hooks. Use `--fix` to auto-repair issues; project hook repair is `npx @sentry/dotagents --project doctor --fix`. Use `sync` in the same scope to repair generated runtime config drift. +Check selected-scope health: gitignore setup where applicable, installed skills and plugins, hybrid plugin compatibility diagnostics, plugin runtime projections, symlinks, legacy config fields, and legacy managed project hooks. Compatibility is reported as the `plugin compatibility` check. Use `--fix` to auto-repair issues; project hook repair is `npx @sentry/dotagents --project doctor --fix`. Use `sync` in the same scope to repair generated runtime config drift. | Flag | Description | |------|-------------| diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 7bc1b851..1b68ae56 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -313,9 +313,10 @@ dotagents doctor [--fix] ``` Check the selected scope and fix supported issues. Verifies installed skills -and plugins, plugin runtime projections, and legacy config fields. Project scope -also checks gitignore setup, symlinks, and managed hooks. Use `dotagents sync` -in the same scope to repair generated runtime configs. +and plugins, reports hybrid diagnostics in the `plugin compatibility` check, +checks plugin runtime projections, and detects legacy config fields. Project +scope also checks gitignore setup, symlinks, and managed hooks. Use +`dotagents sync` in the same scope to repair generated runtime configs. Options: @@ -336,9 +337,10 @@ dotagents doctor --fix # fix what it can dotagents list [--json] ``` -Show declared skills and plugins with install/lock status. Use `--json` for +Show declared skills and plugins with install/lock status. Hybrid plugin +diagnostics appear as warning lines below the plugin. Use `--json` for machine-readable output. JSON output contains separate `skills` and `plugins` -arrays. +arrays; plugin entries include `warnings` when diagnostics exist. Status output: @@ -448,8 +450,8 @@ agents. Plugin sources support GitHub/GitLab shorthands, git URLs, and `path:` sources; HTTPS well-known sources are not supported for plugins. New plugin bundles follow Agent Plugins v1 with a required `plugin.json` and -optional `skills/` and `mcp.json`. dotagents preserves the raw bundle and -generates isolated target adapters from the portable core. Generated JSON uses +optional `skills/` and `mcp.json`. dotagents preserves the portable core and +generates isolated target adapters from it. Generated JSON uses adjacent ownership sidecars; component symlinks use marker files in reserved `.dotagents-managed/` directories. Client schemas receive no dotagents-only fields. Legacy generalized and native Claude/Cursor/Codex manifests remain supported @@ -457,6 +459,21 @@ during migration. Native imports preserve the owning manifest and expose only core metadata and Agent Skills to other clients; native components are never translated into unrelated clients' formats. +A valid standard root may coexist with authored `.claude-plugin`, +`.cursor-plugin`, or `.codex-plugin` manifests as a hybrid compatibility +bundle. Portable skills, MCP, and metadata remain shared; an authored manifest +is ignored when its behavior can be reproduced from the portable core. A +manifest with commands, hooks, custom fields, or other unrepresentable behavior +is retained byte-for-byte only as a matching-client fallback. Other clients +receive adapters generated solely from the portable core. Managed adapters are +disposable output and are never imported back into the portable bundle. Root +commands, agents, hooks, native MCP, and similar resources remain inert outside +their owning client. Invalid standard roots do not fall back to legacy parsing, +and a malformed native fallback blocks installation only when its client is +selected. `install` warns when redundant native input is normalized away; +`sync`, `list`, and `doctor` report durable retained-fallback state without +reinterpreting generated adapters as source input. + | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Yes | Plugin identifier. Lowercase letters, numbers, hyphens, and dots. | diff --git a/docs/src/content/docs/guide.mdx b/docs/src/content/docs/guide.mdx index 098b3643..82658fa6 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -220,5 +220,11 @@ source = "getsentry/agent-pack" targets = ["claude", "cursor", "codex", "opencode"] ``` +Portable Agent Plugins may coexist with authored Claude, Cursor, or Codex +manifests during migration. The portable root remains the source of truth. +Dotagents ignores reproducible native manifests and retains only behavior it +cannot generate as a matching-client fallback; unrelated targets receive only +portable skills and MCP. + See the [CLI reference](/cli/#configuration-agentstoml) for all fields and options. diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index fccd7130..421f5998 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { runDoctor } from "./doctor.js"; import { resolveScope } from "../../scope.js"; +import { DOTAGENTS_NATIVE_FALLBACKS_MARKER } from "../../plugins/store.js"; describe("runDoctor", () => { let tmpDir: string; @@ -175,6 +176,32 @@ source = "path:external-review-tools" expect(check?.message).toContain("Run 'npx @sentry/dotagents --project install'"); }); + it("reports hybrid compatibility separately from runtime drift", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "hybrid-tools"); + await mkdir(join(pluginDir, ".claude-plugin"), { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ + $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + name: "hybrid-tools", + })); + await writeFile(join(pluginDir, ".claude-plugin", "plugin.json"), '{"name":"hybrid-tools"}'); + await writeFile(join(pluginDir, DOTAGENTS_NATIVE_FALLBACKS_MARKER), "claude\n"); + await writeFile(join(projectRoot, "agents.toml"), `version = 1 +agents = ["claude"] + +[[plugins]] +name = "hybrid-tools" +source = "org/hybrid-tools" +`); + await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); + await writeFile(join(projectRoot, ".agents", ".gitignore"), "# managed\n"); + + const result = await runDoctor({ scope: resolveScope("project", projectRoot) }); + const compatibility = result.checks.find((check) => check.name === "plugin compatibility"); + + expect(compatibility?.status).toBe("warn"); + expect(compatibility?.message).toContain("hybrid compatibility bundle"); + }); + it("detects a missing agent skill symlink", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index 71e6e92d..cda114d6 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -186,6 +186,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { scope.pluginsDir, config.plugins.filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)), `${cmd} install`, + config.agents, ); await writeAgentsGitignore( scope.agentsDir, @@ -266,7 +267,22 @@ export async function runDoctor(opts: DoctorOptions): Promise { } if (config.plugins.length > 0 && pluginErrors.length === 0) { - const installed = await loadInstalledPlugins(scope.pluginsDir, config.plugins, `${cmd} install`); + const installed = await loadInstalledPlugins( + scope.pluginsDir, + config.plugins, + `${cmd} install`, + config.agents, + ); + const compatibilityWarnings = installed.plugins.flatMap( + (plugin) => plugin.compatibilityWarnings ?? [], + ); + if (compatibilityWarnings.length > 0) { + checks.push({ + name: "plugin compatibility", + status: "warn", + message: compatibilityWarnings.join(" "), + }); + } const runtimeIssues = installed.issues.length === 0 ? await verifyPluginOutputs(config.agents, installed.plugins, pluginRuntimeLayout(scope), { reservedMcpNames: config.mcp.map((server) => server.name), diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 6601ec46..bb9bf2f8 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -11,7 +11,10 @@ import { writeLockfile } from "../../lockfile/writer.js"; import type { Lockfile } from "../../lockfile/schema.js"; import { resolveScope } from "../../scope.js"; import { DOTAGENTS_SUBAGENT_MARKER } from "../../subagents/format.js"; -import { DOTAGENTS_MANAGED_PLUGIN_MARKER } from "../../plugins/store.js"; +import { + DOTAGENTS_MANAGED_PLUGIN_MARKER, + DOTAGENTS_NATIVE_FALLBACKS_MARKER, +} from "../../plugins/store.js"; import { AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA } from "../../plugins/schema.js"; const SKILL_MD = (name: string) => `--- @@ -30,6 +33,15 @@ description: Review code for correctness. Review the current diff. `; +const HYBRID_TARGET_CONFIG = (targets: string[]) => `version = 1 +agents = ["claude", "cursor"] + +[[plugins]] +name = "hybrid-tools" +source = "path:plugin-source/hybrid-tools" +targets = [${targets.map((target) => `"${target}"`).join(", ")}] +`; + type HarnessEntry = | { json: SerializedValue } | { text: string } @@ -487,6 +499,280 @@ source = "path:plugin-source/portable-tools" }, ); + it.each([undefined, "."] as const)( + "installs a reported-shape hybrid root with path %s", + async (pluginPath) => { + const sourceDir = join(projectRoot, "hybrid-source"); + const claudeBytes = '{\n "name": "hybrid-tools",\n "description": "Hybrid tools",\n "commands": "./commands",\n "hooks": "./hooks/hooks.json"\n}\n'; + await mkdir(join(sourceDir, ".claude-plugin"), { recursive: true }); + await mkdir(join(sourceDir, ".codex-plugin"), { recursive: true }); + await mkdir(join(sourceDir, "skills", "portable-qa"), { recursive: true }); + await mkdir(join(sourceDir, "commands"), { recursive: true }); + await mkdir(join(sourceDir, "agents"), { recursive: true }); + await mkdir(join(sourceDir, "hooks"), { recursive: true }); + await writeFile(join(sourceDir, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "hybrid-tools", + description: "Hybrid tools", + }, null, 2)); + await writeFile(join(sourceDir, ".claude-plugin", "plugin.json"), claudeBytes); + await writeFile(join(sourceDir, ".codex-plugin", "plugin.json"), JSON.stringify({ + name: "hybrid-tools", + description: "Hybrid tools", + agents: "./agents", + }, null, 2)); + await writeFile(join(sourceDir, "skills", "portable-qa", "SKILL.md"), SKILL_MD("portable-qa")); + await writeFile(join(sourceDir, "commands", "native.md"), "native command"); + await writeFile(join(sourceDir, "agents", "native.md"), "native agent"); + await writeFile(join(sourceDir, "hooks", "hooks.json"), "{}"); + await writeFile(join(sourceDir, "marketplace.json"), JSON.stringify({ + name: "external-catalog", + plugins: [{ + name: "hybrid-tools", + source: { source: "github", repo: "example/hybrid-tools" }, + }], + })); + await writeFile(join(projectRoot, "agents.toml"), `version = 1 +agents = ["claude", "opencode"] + +[[plugins]] +name = "hybrid-tools" +source = "path:hybrid-source" +${pluginPath ? `path = "${pluginPath}"\n` : ""}`); + + const scope = resolveScope("project", projectRoot); + const result = await runInstall({ scope }); + const installedDir = join(projectRoot, ".agents", "plugins", "hybrid-tools"); + + expect(result.installedPlugins).toEqual(["hybrid-tools"]); + expect(result.pluginWarnings).toEqual([{ + agent: "plugin", + name: "hybrid-tools", + message: 'Plugin "hybrid-tools" is a hybrid compatibility bundle: the portable core remains authoritative; authored Claude, Codex interfaces are retained only as matching-client fallbacks.', + }]); + expect(await readFile(join(installedDir, ".claude-plugin", "plugin.json"), "utf-8")).toBe(claudeBytes); + expect(existsSync(join(installedDir, ".claude-plugin", "plugin.json.dotagents-managed"))).toBe(false); + expect(existsSync(join(projectRoot, ".opencode", "skills", "portable-qa"))).toBe(true); + expect(existsSync(join(projectRoot, ".opencode", "agents", "native.md"))).toBe(false); + expect(existsSync(join(projectRoot, ".codex-plugin", "marketplace.json"))).toBe(false); + expect((await loadLockfile(scope.lockPath))!.plugins["hybrid-tools"]).toEqual({ + source: "path:hybrid-source", + }); + + const sync = await runSync({ scope }); + expect(sync.issues.some((issue) => issue.message === result.pluginWarnings[0]!.message)).toBe(true); + expect(await readFile(join(installedDir, ".claude-plugin", "plugin.json"), "utf-8")).toBe(claudeBytes); + }, + ); + + it("replaces a reproducible native interface with a managed portable adapter", async () => { + const sourceDir = join(projectRoot, "plugin-source", "hybrid-tools"); + await mkdir(join(sourceDir, ".claude-plugin"), { recursive: true }); + await mkdir(join(sourceDir, "skills", "portable-qa"), { recursive: true }); + await writeFile(join(sourceDir, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "hybrid-tools", + description: "Portable description", + })); + await writeFile(join(sourceDir, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "hybrid-tools", + description: "Native description", + skills: "./skills", + })); + await writeFile(join(sourceDir, "skills", "portable-qa", "SKILL.md"), SKILL_MD("portable-qa")); + await writeFile(join(projectRoot, "agents.toml"), HYBRID_TARGET_CONFIG(["claude"])); + + const scope = resolveScope("project", projectRoot); + const result = await runInstall({ scope }); + const installedDir = join(scope.pluginsDir, "hybrid-tools"); + const manifestPath = join(installedDir, ".claude-plugin", "plugin.json"); + + expect(JSON.parse(await readFile(manifestPath, "utf-8"))).toMatchObject({ + name: "hybrid-tools", + description: "Portable description", + skills: "./skills", + }); + expect(existsSync(`${manifestPath}.dotagents-managed`)).toBe(true); + expect(existsSync(join(installedDir, DOTAGENTS_NATIVE_FALLBACKS_MARKER))).toBe(false); + expect(result.pluginWarnings.map((warning) => warning.message)).toEqual([ + 'Plugin "hybrid-tools" is a hybrid compatibility bundle: the portable core remains authoritative; redundant authored native interfaces are ignored in favor of portable generation.', + 'Plugin "hybrid-tools" has an authored Claude interface that was ignored because the portable core can generate the Claude adapter.', + ]); + }); + + it("preflights all selected native interfaces before canonical or lockfile mutations", async () => { + const validDir = join(projectRoot, "plugin-source", "valid-tools"); + const invalidDir = join(projectRoot, "plugin-source", "invalid-tools"); + await mkdir(validDir, { recursive: true }); + await mkdir(join(invalidDir, ".claude-plugin"), { recursive: true }); + await writeFile(join(validDir, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "valid-tools", + })); + await writeFile(join(invalidDir, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "invalid-tools", + })); + await writeFile(join(invalidDir, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "invalid-tools", + commands: "../outside", + })); + const originalLock: Lockfile = { + version: 1, + skills: {}, + subagents: {}, + plugins: { previous: { source: "path:previous" } }, + }; + await writeLockfile(join(projectRoot, "agents.lock"), originalLock); + await writeFile(join(projectRoot, "agents.toml"), `version = 1 +agents = ["claude"] + +[[plugins]] +name = "valid-tools" +source = "path:plugin-source/valid-tools" + +[[plugins]] +name = "invalid-tools" +source = "path:plugin-source/invalid-tools" +`); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow("Invalid Claude native fallback"); + + expect(existsSync(join(scope.pluginsDir, "valid-tools"))).toBe(false); + expect(existsSync(join(scope.pluginsDir, "invalid-tools"))).toBe(false); + expect(await loadLockfile(scope.lockPath)).toEqual(originalLock); + expect(existsSync(join(projectRoot, ".claude-plugin", "marketplace.json"))).toBe(false); + }); + + it("preserves and warns about a malformed unselected native interface", async () => { + const sourceDir = join(projectRoot, "plugin-source", "hybrid-tools"); + await mkdir(join(sourceDir, ".claude-plugin"), { recursive: true }); + await writeFile(join(sourceDir, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "hybrid-tools", + })); + const malformedBytes = "{broken\n"; + await writeFile(join(sourceDir, ".claude-plugin", "plugin.json"), malformedBytes); + await writeFile(join(projectRoot, "agents.toml"), `version = 1 +agents = ["codex"] + +[[plugins]] +name = "hybrid-tools" +source = "path:plugin-source/hybrid-tools" +targets = ["claude"] +`); + + const result = await runInstall({ scope: resolveScope("project", projectRoot) }); + const installedDir = join(projectRoot, ".agents", "plugins", "hybrid-tools"); + + expect(result.pluginWarnings.map((warning) => warning.message)).toEqual([ + 'Plugin "hybrid-tools" is a hybrid compatibility bundle: the portable core remains authoritative; authored native interfaces are retained only as matching-client fallbacks.', + expect.stringContaining("malformed Claude native fallback that was ignored"), + 'Plugin "hybrid-tools" targets "claude", but "claude" is not listed in agents.', + ]); + expect(await readFile(join(installedDir, ".claude-plugin", "plugin.json"), "utf-8")).toBe(malformedBytes); + expect(existsSync(join(projectRoot, ".claude-plugin", "marketplace.json"))).toBe(false); + }); + + it("prunes only managed target state when a hybrid target is removed", async () => { + const sourceDir = join(projectRoot, "plugin-source", "hybrid-tools"); + await mkdir(join(sourceDir, ".claude-plugin"), { recursive: true }); + await writeFile(join(sourceDir, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "hybrid-tools", + })); + const claudeBytes = '{ "name": "hybrid-tools", "metadata": {"managedBy": "dotagents"}, "x-authored": true }\n'; + await writeFile(join(sourceDir, ".claude-plugin", "plugin.json"), claudeBytes); + await writeFile(join(projectRoot, "agents.toml"), HYBRID_TARGET_CONFIG(["claude", "cursor"])); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + const installedDir = join(scope.pluginsDir, "hybrid-tools"); + expect(existsSync(join(projectRoot, ".claude-plugin", "marketplace.json"))).toBe(true); + expect(existsSync(join(installedDir, ".cursor-plugin", "plugin.json.dotagents-managed"))).toBe(true); + + await writeFile(join(projectRoot, "agents.toml"), HYBRID_TARGET_CONFIG(["cursor"])); + await runSync({ scope }); + + expect(existsSync(join(projectRoot, ".claude-plugin", "marketplace.json"))).toBe(false); + expect(await readFile(join(installedDir, ".claude-plugin", "plugin.json"), "utf-8")).toBe(claudeBytes); + expect(existsSync(join(installedDir, ".cursor-plugin", "plugin.json.dotagents-managed"))).toBe(true); + }); + + it("handles source upgrades between managed and authored native manifests", async () => { + const sourceDir = join(projectRoot, "plugin-source", "hybrid-tools"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "hybrid-tools", + })); + await writeFile(join(projectRoot, "agents.toml"), `version = 1 +agents = ["claude"] + +[[plugins]] + name = "hybrid-tools" +source = "path:plugin-source/hybrid-tools" +`); + const scope = resolveScope("project", projectRoot); + const installedDir = join(scope.pluginsDir, "hybrid-tools"); + const installedManifest = join(installedDir, ".claude-plugin", "plugin.json"); + + await runInstall({ scope }); + expect(existsSync(`${installedManifest}.dotagents-managed`)).toBe(true); + + const authoredBytes = '{\n "name": "hybrid-tools", "metadata": {"managedBy": "dotagents"}, "x-authored": true\n}\n'; + await mkdir(join(sourceDir, ".claude-plugin"), { recursive: true }); + await writeFile(join(sourceDir, ".claude-plugin", "plugin.json"), authoredBytes); + await writeFile( + join(sourceDir, ".claude-plugin", "plugin.json.dotagents-managed"), + "managedBy=dotagents\n", + ); + await runInstall({ scope }); + expect(await readFile(installedManifest, "utf-8")).toBe(authoredBytes); + expect(existsSync(`${installedManifest}.dotagents-managed`)).toBe(false); + await runSync({ scope }); + expect(await readFile(installedManifest, "utf-8")).toBe(authoredBytes); + + const nativeSourceMarker = join(installedDir, ".dotagents-native-source"); + await writeFile(nativeSourceMarker, "cursor\n"); + const conflictingProvenance = await runSync({ scope }); + expect(conflictingProvenance.issues.some((issue) => + issue.message.includes("conflicting native interface provenance") + )).toBe(true); + await rm(nativeSourceMarker); + + await rm(installedManifest); + const missingAuthored = await runSync({ scope }); + expect(missingAuthored.issues.some((issue) => + issue.message.includes("records a Claude native fallback") && + issue.message.includes("Reinstall the plugin") + )).toBe(true); + expect(existsSync(installedManifest)).toBe(false); + await runInstall({ scope }); + expect(await readFile(installedManifest, "utf-8")).toBe(authoredBytes); + + const installedRootManifest = join(installedDir, "plugin.json"); + await rm(installedRootManifest); + const missingCanonical = await runSync({ scope }); + expect(missingCanonical.issues.some((issue) => + issue.message.includes("missing plugin.json") && issue.message.includes("Reinstall the plugin") + )).toBe(true); + expect(await readFile(installedManifest, "utf-8")).toBe(authoredBytes); + await runInstall({ scope }); + + await rm(join(sourceDir, ".claude-plugin"), { recursive: true }); + await runInstall({ scope }); + expect(JSON.parse(await readFile(installedManifest, "utf-8"))).toMatchObject({ name: "hybrid-tools" }); + expect(existsSync(`${installedManifest}.dotagents-managed`)).toBe(true); + + const unmanagedBytes = '{ "name": "hybrid-tools", "x-unmanaged": true }\n'; + await writeFile(installedManifest, unmanagedBytes); + await rm(`${installedManifest}.dotagents-managed`, { force: true }); + const sync = await runSync({ scope }); + expect(sync.issues.some((issue) => issue.message.includes("manifest exists and is not managed"))).toBe(true); + expect(await readFile(installedManifest, "utf-8")).toBe(unmanagedBytes); + }); + it("imports a native Claude bundle without cross-translating native components", async () => { const sourceDir = join(projectRoot, "plugin-source", "claude-tools"); await mkdir(join(sourceDir, ".claude-plugin"), { recursive: true }); diff --git a/packages/dotagents/src/cli/commands/install/plugins.ts b/packages/dotagents/src/cli/commands/install/plugins.ts index eb767752..aac7012e 100644 --- a/packages/dotagents/src/cli/commands/install/plugins.ts +++ b/packages/dotagents/src/cli/commands/install/plugins.ts @@ -11,6 +11,7 @@ import { isProjectPluginSource, lockEntryForPlugin, pruneInstalledPlugins, + preparePluginForTargets, resolvePlugin, } from "../../../plugins/store.js"; import type { PluginDeclaration } from "../../../plugins/types.js"; @@ -62,9 +63,9 @@ export async function installPlugins( const plugins: PluginDeclaration[] = []; const pruned: string[] = []; const lockEntries: Lockfile["plugins"] = {}; + const resolvedPlugins: Awaited>[] = []; if (config.plugins.length > 0) { - await mkdir(scope.pluginsDir, { recursive: true }); for (const pluginConfig of config.plugins) { let resolved: Awaited>; try { @@ -89,9 +90,18 @@ export async function installPlugins( ); } await assertPluginDestinationIsManaged(scope.pluginsDir, pluginConfig.name); - plugins.push(await installPluginBundle(scope.pluginsDir, resolved)); + resolved = { + ...resolved, + plugin: preparePluginForTargets(resolved.plugin, config.agents), + }; + resolvedPlugins.push(resolved); lockEntries[resolved.plugin.name] = lockEntryForPlugin(resolved); } + + await mkdir(scope.pluginsDir, { recursive: true }); + for (const resolved of resolvedPlugins) { + plugins.push(await installPluginBundle(scope.pluginsDir, resolved)); + } } const staleManaged = staleManagedPluginNames(lockfile, lockEntries); diff --git a/packages/dotagents/src/cli/commands/list.test.ts b/packages/dotagents/src/cli/commands/list.test.ts index 30c6c04b..48cd88cf 100644 --- a/packages/dotagents/src/cli/commands/list.test.ts +++ b/packages/dotagents/src/cli/commands/list.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import list, { runList, runPluginList } from "./list.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { resolveScope } from "../../scope.js"; +import { DOTAGENTS_NATIVE_FALLBACKS_MARKER } from "../../plugins/store.js"; const SKILL_MD = (name: string) => `--- name: ${name} @@ -187,6 +188,8 @@ source = "org/unlocked" ); await mkdir(join(projectRoot, ".agents", "plugins", "ok-tools"), { recursive: true }); await mkdir(join(projectRoot, ".agents", "plugins", "unlocked-tools"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "plugins", "ok-tools", "plugin.json"), '{"name":"ok-tools"}'); + await writeFile(join(projectRoot, ".agents", "plugins", "unlocked-tools", "plugin.json"), '{"name":"unlocked-tools"}'); await writeLockfile(join(projectRoot, "agents.lock"), { version: 1, skills: {}, @@ -209,19 +212,33 @@ source = "org/unlocked" }); it("prints JSON with skill and plugin sections", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "hybrid-tools"); + await mkdir(join(pluginDir, ".claude-plugin"), { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ + $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + name: "hybrid-tools", + })); + await writeFile(join(pluginDir, ".claude-plugin", "plugin.json"), '{"name":"hybrid-tools"}'); + await writeFile(join(pluginDir, DOTAGENTS_NATIVE_FALLBACKS_MARKER), "claude\n"); await writeFile( join(projectRoot, "agents.toml"), `version = 1 +agents = ["claude"] [[skills]] name = "pdf" source = "org/repo" [[plugins]] -name = "review-tools" -source = "org/plugins" +name = "hybrid-tools" +source = "org/hybrid-tools" `, ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + plugins: { "hybrid-tools": { source: "org/hybrid-tools" } }, + }); const log = vi.spyOn(console, "log").mockImplementation(() => {}); process.chdir(projectRoot); @@ -233,7 +250,14 @@ source = "org/plugins" { name: "pdf", source: "org/repo", status: "missing" }, ], plugins: [ - { name: "review-tools", source: "org/plugins", status: "missing" }, + { + name: "hybrid-tools", + source: "org/hybrid-tools", + status: "ok", + warnings: [ + 'Plugin "hybrid-tools" is a hybrid compatibility bundle: the portable core remains authoritative; authored Claude interfaces are retained only as matching-client fallbacks.', + ], + }, ], }); }); diff --git a/packages/dotagents/src/cli/commands/list.ts b/packages/dotagents/src/cli/commands/list.ts index fbad3eb6..810f1da3 100644 --- a/packages/dotagents/src/cli/commands/list.ts +++ b/packages/dotagents/src/cli/commands/list.ts @@ -9,6 +9,7 @@ import { existsSync } from "node:fs"; import type { ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import type { CommandContext } from "../context.js"; +import { loadInstalledPlugins } from "../../plugins/store.js"; export interface SkillStatus { name: string; @@ -22,6 +23,7 @@ export interface PluginStatus { name: string; source: string; status: "ok" | "missing" | "unlocked"; + warnings?: string[]; } export interface ListOptions { @@ -94,6 +96,21 @@ export async function runPluginList(opts: PluginListOptions): Promise existsSync(join(pluginsDir, plugin.name))), + "dotagents install", + config.agents, + ); + const warnings = new Map(); + for (const plugin of loaded.plugins) { + if (plugin.compatibilityWarnings?.length) { + warnings.set(plugin.name, plugin.compatibilityWarnings); + } + } + for (const issue of loaded.issues) { + warnings.set(issue.name, [...(warnings.get(issue.name) ?? []), issue.issue]); + } const results: PluginStatus[] = []; for (const plugin of config.plugins.toSorted((a, b) => a.name.localeCompare(b.name))) { @@ -105,12 +122,14 @@ export async function runPluginList(opts: PluginListOptions): Promise { const pluginSource = join(projectRoot, "plugins", "review-tools"); await mkdir(join(pluginSource, "skills", "review"), { recursive: true }); - await writeFile(join(pluginSource, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await mkdir(join(pluginSource, ".codex-plugin"), { recursive: true }); + await writeFile(join(pluginSource, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "review-tools", + }, null, 2)); + const authoredBytes = '{ "name": "review-tools", "metadata": {"managedBy": "dotagents"}, "x-authored": true }\n'; + await writeFile(join(pluginSource, ".codex-plugin", "plugin.json"), authoredBytes); await writeFile(join(pluginSource, "skills", "review", "SKILL.md"), SKILL_MD("review")); await writeFile( join(projectRoot, "agents.toml"), @@ -519,6 +525,10 @@ source = "path:plugins/review-tools" const config = await loadConfig(join(projectRoot, "agents.toml")); expect(config.plugins.find((plugin) => plugin.name === "review-tools")).toBeUndefined(); expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools"))).toBe(true); + expect(await readFile( + join(projectRoot, ".agents", "plugins", "review-tools", ".codex-plugin", "plugin.json"), + "utf-8", + )).toBe(authoredBytes); expect(existsSync(join(projectRoot, ".agents", "skills", "review"))).toBe(false); }); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index d1cfdab7..8b186d4b 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -276,6 +276,7 @@ async function removePluginArtifacts( scope.pluginsDir, remainingPluginConfigs, `${commandPrefix(scope)} install`, + config.agents, ); if (installedPlugins.issues.length === 0) { const { result } = await reconcilePluginOutputs( @@ -334,6 +335,7 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { scope.pluginsDir, config.plugins.filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)), `${commandPrefix(scope)} install`, + config.agents, ); await writeAgentsGitignore( scope.agentsDir, diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 044f922b..848a2f4d 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -192,6 +192,7 @@ export async function runSync(opts: SyncOptions): Promise { pluginsDir, runtimePluginConfigs.filter((plugin) => existsSync(join(pluginsDir, plugin.name))), `${cmd} install`, + config.agents, ); await writeAgentsGitignore( agentsDir, @@ -334,7 +335,12 @@ export async function runSync(opts: SyncOptions): Promise { // 8. Verify and repair plugin runtime projections let pluginsRepaired = 0; const installedPluginConfigs = runtimePluginConfigs.filter((plugin) => existsSync(join(pluginsDir, plugin.name))); - const installedPluginResult = await loadInstalledPlugins(pluginsDir, installedPluginConfigs, `${cmd} install`); + const installedPluginResult = await loadInstalledPlugins( + pluginsDir, + installedPluginConfigs, + `${cmd} install`, + config.agents, + ); const pluginDecls = installedPluginResult.plugins; const prunedInstalledPlugins = await pruneInstalledPlugins(pluginsDir, staleManagedPluginNames); let pluginIssues: Awaited> = []; diff --git a/packages/dotagents/src/plugins/runtime/manifest-values.ts b/packages/dotagents/src/plugins/runtime/manifest-values.ts index bb7c3686..5d038bd6 100644 --- a/packages/dotagents/src/plugins/runtime/manifest-values.ts +++ b/packages/dotagents/src/plugins/runtime/manifest-values.ts @@ -1,3 +1,4 @@ +import type { SerializedObject } from "@sentry/dotagents-lib"; import { isStandardPluginManifest, type LegacyPluginManifest, type PluginManifest } from "../schema.js"; import { isString } from "../../utils/type-guards.js"; @@ -31,3 +32,15 @@ export function titleCase(value: string): string { .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) .join(" "); } + +export function codexPluginInterface(name: string, manifest: PluginManifest): SerializedObject { + return { + displayName: titleCase(name), + shortDescription: manifestString(manifest, "description") ?? "", + developerName: manifest.author && isString(manifest.author.name) + ? manifest.author.name + : "Unknown", + category: legacyManifestString(manifest, "category") ?? "Coding", + capabilities: ["Interactive", "Write"], + }; +} diff --git a/packages/dotagents/src/plugins/runtime/manifests.ts b/packages/dotagents/src/plugins/runtime/manifests.ts index f55580cd..6aadc738 100644 --- a/packages/dotagents/src/plugins/runtime/manifests.ts +++ b/packages/dotagents/src/plugins/runtime/manifests.ts @@ -3,13 +3,15 @@ import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { isStandardPluginManifest, parsePluginMcpBestEffort, type LegacyPluginManifest, type PluginManifest, type PluginMcpConfig } from "../schema.js"; import type { PluginDeclaration } from "../types.js"; -import { usesLegacyPluginComponents } from "../targets.js"; +import { + generatedNativeMcpPath, + hasAuthoredNativeInterface, + usesLegacyPluginComponents, +} from "../targets.js"; import { isManagedJsonFile, removeManagedJsonFile, stableJson, writeManagedJsonIfChanged } from "../managed-files.js"; import { - manifestString, - legacyManifestString, + codexPluginInterface, runtimePath, - titleCase, } from "./manifest-values.js"; import type { PluginWriteWarning } from "./types.js"; import { isSafeComponentPath } from "./component-paths.js"; @@ -65,7 +67,7 @@ export async function writePluginManifests( for (const spec of NATIVE_PLUGIN_MANIFEST_TARGETS) { if (!agents.includes(spec.agent)) {continue;} const filePath = join(plugin.pluginDir, spec.dir, "plugin.json"); - if (existsSync(filePath) && plugin.nativeSource === spec.agent) {continue;} + if (existsSync(filePath) && hasAuthoredNativeInterface(plugin, spec.agent)) {continue;} if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { warnings.push({ agent: spec.agent, @@ -74,14 +76,14 @@ export async function writePluginManifests( }); continue; } - let mcpPath: string | undefined; + let mcpPath = generatedNativeMcpPath(spec.agent, standardMcp); const adapterMcpPath = join(plugin.pluginDir, spec.dir, "mcp.json"); if (standardMcp.config) { if (standardMcp.issues.length === 0) { - mcpPath = "./mcp.json"; if (await isManagedJsonFile(adapterMcpPath)) {await removeManagedJsonFile(adapterMcpPath);} } else if (Object.keys(standardMcp.config.mcpServers).length > 0) { if (existsSync(adapterMcpPath) && !await isManagedJsonFile(adapterMcpPath)) { + mcpPath = undefined; warnings.push({ agent: spec.agent, name: plugin.name, @@ -89,7 +91,6 @@ export async function writePluginManifests( }); } else { if (await writeManagedJsonIfChanged(adapterMcpPath, stableJson(standardMcp.config))) {written++;} - mcpPath = `./${spec.dir}/mcp.json`; } } } else if (await isManagedJsonFile(adapterMcpPath)) { @@ -235,7 +236,7 @@ function codexRuntimeManifest(plugin: PluginDeclaration, warnings: PluginWriteWa manifest["apps"] = "./.app.json"; } if (!manifest["interface"]) { - manifest["interface"] = codexInterface(plugin); + manifest["interface"] = codexPluginInterface(plugin.name, plugin.manifest); } return manifest; } @@ -276,22 +277,6 @@ async function isDirectory(filePath: string): Promise { } } -function codexInterface(plugin: PluginDeclaration): SerializedObject { - return { - displayName: titleCase(plugin.name), - shortDescription: manifestString(plugin.manifest, "description") ?? "", - developerName: developerName(plugin.manifest), - category: legacyManifestString(plugin.manifest, "category") ?? "Coding", - capabilities: ["Interactive", "Write"], - }; -} - -function developerName(manifest: PluginManifest): string { - const author = manifest.author; - if (author && isString(author.name)) {return author.name;} - return "Unknown"; -} - function copyManifestField(source: PluginManifest, dest: SerializedObject, key: keyof PluginManifest): void { const value = source[key]; if (value !== undefined) {dest[key] = value;} diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index 4618af99..2a47e059 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -5,10 +5,16 @@ import { dirname, join, relative, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { parse as parseJSONC } from "jsonc-parser"; import type { PluginDeclaration } from "../types.js"; -import { AGENT_PLUGIN_SCHEMA, isStandardPluginManifest, type LegacyPluginManifest } from "../schema.js"; +import { + AGENT_PLUGIN_MCP_SCHEMA, + AGENT_PLUGIN_SCHEMA, + isStandardPluginManifest, + type LegacyPluginManifest, +} from "../schema.js"; import { prunePluginOutputs, projectedPiSkillNames, + reconcilePluginOutputs, verifyPluginOutputs, writePluginOutputs, } from "./writer.js"; @@ -94,6 +100,8 @@ describe("plugin writer", () => { source: `path:.agents/plugins/${name}`, pluginDir, manifest, + authoredNativeInterfaces: overrides.authoredNativeInterfaces, + compatibilityWarnings: overrides.compatibilityWarnings, nativeSource: overrides.nativeSource, targets: overrides.targets, }; @@ -251,6 +259,191 @@ describe("plugin writer", () => { } }); + it.each(["claude", "cursor", "codex"] as const)( + "preserves an authored %s manifest byte-for-byte", + async (target) => { + const alpha = await plugin("alpha-tools", { + manifest: { + $schema: AGENT_PLUGIN_SCHEMA, + name: "alpha-tools", + description: "Portable description", + }, + }); + const manifestDir = `.${target}-plugin`; + const manifestPath = join(alpha.pluginDir, manifestDir, "plugin.json"); + const authoredBytes = '{\n "name": "alpha-tools",\n "description": "Native description",\n "x-native": true\n}\n'; + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, authoredBytes); + alpha.authoredNativeInterfaces = { + [target]: { + path: `${manifestDir}/plugin.json`, + fallback: true, + manifest: { + name: "alpha-tools", + description: "Native description", + "x-native": true, + }, + }, + }; + + const result = await writePluginOutputs([target], [alpha], root); + + expect(result.warnings.some((warning) => warning.message.includes("not managed"))).toBe(false); + expect(await readFile(manifestPath, "utf-8")).toBe(authoredBytes); + expect(existsSync(`${manifestPath}.dotagents-managed`)).toBe(false); + }, + ); + + it("keeps native authority target-local when generating another client adapter", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { + $schema: AGENT_PLUGIN_SCHEMA, + name: "alpha-tools", + description: "Portable description", + }, + }); + const claudePath = join(alpha.pluginDir, ".claude-plugin", "plugin.json"); + await mkdir(dirname(claudePath), { recursive: true }); + await writeFile(claudePath, JSON.stringify({ + name: "alpha-tools", + commands: "./commands", + mcpServers: "./native-mcp.json", + "x-claude": true, + })); + alpha.authoredNativeInterfaces = { + claude: { + path: ".claude-plugin/plugin.json", + fallback: true, + manifest: { + name: "alpha-tools", + commands: "./commands", + mcpServers: "./native-mcp.json", + "x-claude": true, + }, + }, + }; + + await writePluginOutputs(["claude", "cursor"], [alpha], root); + + const cursor = parseJsonObject( + await readFile(join(alpha.pluginDir, ".cursor-plugin", "plugin.json"), "utf-8"), + ); + expect(cursor).toMatchObject({ name: "alpha-tools", description: "Portable description" }); + expect(cursor["commands"]).toBeUndefined(); + expect(cursor["mcpServers"]).toBeUndefined(); + expect(cursor["x-claude"]).toBeUndefined(); + }); + + it("rejects a malformed fallback at the selected projection boundary", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { $schema: AGENT_PLUGIN_SCHEMA, name: "alpha-tools" }, + authoredNativeInterfaces: { + claude: { + path: ".claude-plugin/plugin.json", + fallback: true, + error: "invalid native manifest", + }, + }, + }); + + await expect(writePluginOutputs(["claude"], [alpha], root)).rejects.toThrow( + "Invalid Claude native fallback", + ); + }); + + it("projects only portable hybrid components to OpenCode and Pi", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { + $schema: AGENT_PLUGIN_SCHEMA, + name: "alpha-tools", + }, + }); + await writePluginSkill(alpha.pluginDir, "portable-skill"); + await writeFile(join(alpha.pluginDir, "agents", "native-agent.md"), "native agent"); + await writeFile(join(alpha.pluginDir, "commands", "native-command.md"), "native command"); + alpha.authoredNativeInterfaces = { + claude: { + path: ".claude-plugin/plugin.json", + fallback: true, + manifest: { + name: "alpha-tools", + agents: "./agents", + commands: "./commands", + }, + }, + }; + + await writePluginOutputs(["opencode", "pi"], [alpha], root); + + await expectSymlinkTarget( + join(root, ".opencode", "skills", "portable-skill"), + join(alpha.pluginDir, "skills", "portable-skill"), + ); + await expectSymlinkTarget( + join(root, ".agents", "skills", "portable-skill"), + join(alpha.pluginDir, "skills", "portable-skill"), + ); + expect(existsSync(join(root, ".opencode", "agents", "native-agent.md"))).toBe(false); + expect(existsSync(join(root, ".opencode", "commands", "native-command.md"))).toBe(false); + }); + + it("sanitizes hybrid bundles copied to Grok", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { + $schema: AGENT_PLUGIN_SCHEMA, + name: "alpha-tools", + }, + }); + await writePluginSkill(alpha.pluginDir, "portable-skill"); + await writeFile(join(alpha.pluginDir, "plugin.json"), JSON.stringify(alpha.manifest)); + await mkdir(join(alpha.pluginDir, ".claude-plugin"), { recursive: true }); + await mkdir(join(alpha.pluginDir, "hooks"), { recursive: true }); + await writeFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "alpha-tools" })); + await writeFile(join(alpha.pluginDir, "commands", "native.md"), "native command"); + await writeFile(join(alpha.pluginDir, "commands", "real-server"), "portable command"); + await symlink("real-server", join(alpha.pluginDir, "commands", "server")); + await writeFile(join(alpha.pluginDir, "agents", "native.md"), "native agent"); + await writeFile(join(alpha.pluginDir, "hooks", "hooks.json"), "{}"); + await mkdir(join(alpha.pluginDir, "bin"), { recursive: true }); + await writeFile(join(alpha.pluginDir, "bin", "server"), "portable executable"); + await writeFile(join(alpha.pluginDir, "mcp.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_MCP_SCHEMA, + mcpServers: { + portable: { + type: "stdio", + command: "node", + env: { + PATH: "${PLUGIN_ROOT}/bin:/usr/bin", + NATIVE_LIKE: "${PLUGIN_ROOT}/.claude-plugin/tool", + }, + }, + portableCommand: { type: "stdio", command: "./commands/server" }, + }, + })); + await writeFile(join(alpha.pluginDir, "asset.txt"), "ordinary asset"); + alpha.authoredNativeInterfaces = { + claude: { + path: ".claude-plugin/plugin.json", + fallback: true, + manifest: { name: "alpha-tools" }, + }, + }; + + await writePluginOutputs(["grok"], [alpha], root); + + const projected = join(root, ".grok", "plugins", "alpha-tools"); + expect(existsSync(join(projected, "plugin.json"))).toBe(true); + expect(existsSync(join(projected, "skills", "portable-skill", "SKILL.md"))).toBe(true); + expect(await readFile(join(projected, "bin", "server"), "utf-8")).toBe("portable executable"); + expect(await readFile(join(projected, "commands", "server"), "utf-8")).toBe("portable command"); + expect(existsSync(join(projected, "commands", "real-server"))).toBe(true); + expect(existsSync(join(projected, "commands", "native.md"))).toBe(false); + expect(await readFile(join(projected, "asset.txt"), "utf-8")).toBe("ordinary asset"); + for (const path of [".claude-plugin", "agents", "hooks"]) { + expect(existsSync(join(projected, path))).toBe(false); + } + }); + it("isolates invalid Agent Plugins MCP config from other components", async () => { const alpha = await plugin("alpha-tools", { manifest: { @@ -517,12 +710,17 @@ describe("plugin writer", () => { expect(await readFile(manifestPath, "utf-8")).toBe("{ \"name\": \"mine\" }\n"); }); - it("does not generate runtime outputs when no agent targets are selected", async () => { - const alpha = await plugin("alpha-tools"); + it("reports compatibility without generating outputs when no agent targets are selected", async () => { + const alpha = await plugin("alpha-tools", { + compatibilityWarnings: ["hybrid compatibility warning"], + }); const result = await writePluginOutputs([], [alpha], root); - expect(result).toEqual({ warnings: [], written: 0 }); + expect(result).toEqual({ + warnings: [{ agent: "plugin", name: "alpha-tools", message: "hybrid compatibility warning" }], + written: 0, + }); expect(existsSync(join(root, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".claude-plugin", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".cursor-plugin", "marketplace.json"))).toBe(false); @@ -1145,6 +1343,31 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); }); + it("preserves orphan native outputs when fallback provenance is corrupt", async () => { + const pluginDir = join(root, ".agents", "plugins", "orphan-tools"); + const manifestPaths = ["claude", "cursor", "codex"].map( + (target) => join(pluginDir, `.${target}-plugin`, "plugin.json"), + ); + for (const manifestPath of manifestPaths) { + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, JSON.stringify({ name: "orphan-tools" })); + await writeFile(`${manifestPath}.dotagents-managed`, "managedBy=dotagents\n"); + } + await writeFile(join(pluginDir, ".dotagents-native-fallbacks"), "not-a-target\n"); + + const { result, pruned } = await reconcilePluginOutputs([], [], root); + + for (const manifestPath of manifestPaths) { + expect(pruned).not.toContain(manifestPath); + expect(existsSync(manifestPath)).toBe(true); + } + expect(result.warnings).toEqual([{ + agent: "plugin", + name: "orphan-tools", + message: 'Plugin "orphan-tools" native fallback provenance could not be verified, so its native outputs were preserved: Invalid native fallback provenance: not-a-target', + }]); + }); + it("does not prune arbitrary component links into canonical plugin sources", async () => { const localPlugin = join(root, ".agents", "plugins", "local-tools"); await mkdir(join(localPlugin, "skills", "local-qa"), { recursive: true }); diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index 281dc2ac..b56adf29 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -3,8 +3,20 @@ import { cp, lstat, mkdir, readdir, readFile, readlink, realpath, rm, rmdir, sta import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { loadSkillMd, type SerializedObject } from "@sentry/dotagents-lib"; import { AGENT_PLUGIN_SCHEMA, isStandardPluginManifest, parsePluginMcp, type LegacyPluginManifest } from "../schema.js"; +import { + DOTAGENTS_NATIVE_FALLBACKS_MARKER, + hasRecordedNativePluginFallback, + HYBRID_LEGACY_ROOTS, + preparePluginForTargets, +} from "../store.js"; import type { PluginDeclaration } from "../types.js"; -import { selectedAgentIds, selectPlugins, targetWarnings, usesLegacyPluginComponents } from "../targets.js"; +import { + hasAuthoredNativeInterface, + selectedAgentIds, + selectPlugins, + targetWarnings, + usesLegacyPluginComponents, +} from "../targets.js"; import { marketplaceOutputPaths, marketplaceOutputs } from "./marketplace.js"; import { type PluginVerifyIssue, @@ -82,10 +94,19 @@ export async function writePluginOutputs( const layout = normalizePluginRuntimeLayout(root); const warnings: PluginWriteWarning[] = []; let written = 0; - const selected = selectPlugins(agentIds, plugins); + const preparedPlugins = plugins.map((plugin) => plugin.authoredNativeInterfaces === undefined + ? plugin + : preparePluginForTargets(plugin, agentIds)); + const selected = selectPlugins(agentIds, preparedPlugins); const loadedMcp = new Map(); - for (const warning of targetWarnings(agentIds, plugins)) { + for (const plugin of preparedPlugins) { + for (const message of plugin.compatibilityWarnings ?? []) { + warnings.push({ agent: "plugin", name: plugin.name, message }); + } + } + + for (const warning of targetWarnings(agentIds, preparedPlugins)) { warnings.push(warning); } @@ -98,7 +119,12 @@ export async function writePluginOutputs( const standardMcp = await loadStandardMcp(plugin, warnings); loadedMcp.set(plugin.name, standardMcp); written += await writePluginManifests(plugin, agents, warnings, standardMcp); - if (agents.includes("grok") && await writeGrokProjection(layout, plugin, warnings)) { + if (agents.includes("grok") && await writeGrokProjection( + layout, + plugin, + warnings, + standardMcp, + )) { written++; } } @@ -107,7 +133,7 @@ export async function writePluginOutputs( written += await writeComponentProjections("pi", agentIds, selected, layout, warnings); const opencodeMcp = await reconcileOpenCodePluginMcp( agentIds, - plugins, + preparedPlugins, loadedMcp, layout, options.reservedMcpNames ?? [], @@ -126,9 +152,13 @@ export async function reconcilePluginOutputs( root: PluginRuntimeRoot, options: PluginRuntimeOptions = {}, ): Promise<{ result: PluginWriteResult; pruned: string[] }> { - const pruned = await prunePluginOutputs(agentIds, plugins, root); + const pruneWarnings: PluginWriteWarning[] = []; + const pruned = await prunePluginOutputs(agentIds, plugins, root, pruneWarnings); const result = await writePluginOutputs(agentIds, plugins, root, options); - return { result, pruned }; + return { + result: { ...result, warnings: [...pruneWarnings, ...result.warnings] }, + pruned, + }; } /** Verifies that generated plugin runtime artifacts match the current declarations. */ @@ -210,6 +240,7 @@ export async function prunePluginOutputs( agentIds: string[], plugins: PluginDeclaration[], root: PluginRuntimeRoot, + warnings: PluginWriteWarning[] = [], ): Promise { const layout = normalizePluginRuntimeLayout(root); const pruned: string[] = []; @@ -255,6 +286,7 @@ export async function prunePluginOutputs( const canonicalPluginDir = layout.canonicalPluginsDir; if (existsSync(canonicalPluginDir)) { const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); + const unverifiableProvenance = new Set(); for (const target of NATIVE_PLUGIN_MANIFEST_TARGETS) { const desired = new Set( plugins @@ -263,6 +295,26 @@ export async function prunePluginOutputs( ); for (const entry of entries) { if (!entry.isDirectory() || desired.has(entry.name)) {continue;} + const installedPlugin = plugins.find((plugin) => plugin.name === entry.name); + if (installedPlugin && hasAuthoredNativeInterface(installedPlugin, target.agent)) {continue;} + if (!installedPlugin) { + if (unverifiableProvenance.has(entry.name)) {continue;} + try { + if (await hasRecordedNativePluginFallback( + join(canonicalPluginDir, entry.name), + target.agent, + )) {continue;} + } catch (err) { + unverifiableProvenance.add(entry.name); + const message = err instanceof Error ? err.message : String(err); + warnings.push({ + agent: "plugin", + name: entry.name, + message: `Plugin "${entry.name}" native fallback provenance could not be verified, so its native outputs were preserved: ${message}`, + }); + continue; + } + } for (const fileName of ["plugin.json", "mcp.json"]) { const path = join(canonicalPluginDir, entry.name, target.dir, fileName); if (!await isManagedJsonFile(path)) {continue;} @@ -277,11 +329,12 @@ export async function prunePluginOutputs( return pruned; } -/** Mirrors a plugin bundle into Grok's plugin directory with a managed marker. */ +/** Writes a sanitized managed projection into Grok's plugin directory. */ async function writeGrokProjection( layout: PluginRuntimeLayout, plugin: PluginDeclaration, warnings: PluginWriteWarning[], + standardMcp: LoadedStandardMcp, ): Promise { const dest = join(layout.grokPluginsDir, plugin.name); if (existsSync(dest)) { @@ -301,9 +354,19 @@ async function writeGrokProjection( ".claude-plugin", ".cursor-plugin", ".codex-plugin", + DOTAGENTS_NATIVE_FALLBACKS_MARKER, ".dotagents-managed", ".dotagents-native-source", ]); + const alwaysExcluded = new Set(excluded); + for (const path of HYBRID_LEGACY_ROOTS) { + if (path.startsWith(".")) {alwaysExcluded.add(path);} + } + const portableMcpAssets = portableMcpAssetPaths(standardMcp); + await includePortableMcpSymlinkTargets(plugin.pluginDir, portableMcpAssets); + if (isStandardPluginManifest(plugin.manifest)) { + for (const path of HYBRID_LEGACY_ROOTS) {excluded.add(path);} + } let portableSkillsSource: string | undefined; if (plugin.nativeSource) { for (const path of ["agents", "commands", "rules", "hooks", "monitors", ".mcp.json", ".lsp.json", ".app.json"]) { @@ -322,7 +385,9 @@ async function writeGrokProjection( filter: (source) => { const relPath = relative(plugin.pluginDir, source).split("\\").join("/"); const rootEntry = relPath.split("/")[0]; - return !rootEntry || !excluded.has(rootEntry); + if (!rootEntry || !excluded.has(rootEntry)) {return true;} + if (alwaysExcluded.has(rootEntry)) {return false;} + return isPortableMcpAssetPath(relPath, portableMcpAssets); }, }); if (portableSkillsSource) { @@ -338,6 +403,103 @@ async function writeGrokProjection( return true; } +interface PortableMcpAssetPaths { + exact: Set; + subtrees: Set; +} + +function portableMcpAssetPaths(standardMcp: LoadedStandardMcp): PortableMcpAssetPaths { + const paths: PortableMcpAssetPaths = { exact: new Set(), subtrees: new Set() }; + for (const server of Object.values(standardMcp.config?.mcpServers ?? {})) { + if (server.type !== "stdio") {continue;} + addPortableMcpReferences(paths.exact, server.command); + addPortableMcpReferences(paths.subtrees, server.cwd); + for (const value of [...(server.args ?? []), ...Object.values(server.env ?? {})]) { + addPortableMcpReferences(paths.exact, value); + } + } + return paths; +} + +function addPortableMcpReferences(paths: Set, value: string | undefined): void { + if (!value) {return;} + if (value.startsWith("./")) {paths.add(value.slice(2));} + for (const match of value.matchAll(/\$\{PLUGIN_ROOT\}\/([^\s:;,]+)/g)) { + if (match[1]) {paths.add(match[1]);} + } +} + +function isPortableMcpAssetPath( + path: string, + assets: PortableMcpAssetPaths, +): boolean { + for (const asset of assets.exact) { + if (path === asset || asset.startsWith(`${path}/`)) {return true;} + } + for (const subtree of assets.subtrees) { + if ( + path === subtree || path.startsWith(`${subtree}/`) || subtree.startsWith(`${path}/`) + ) {return true;} + } + return false; +} + +async function includePortableMcpSymlinkTargets( + pluginDir: string, + assets: PortableMcpAssetPaths, +): Promise { + const visited = new Set(); + for (const path of assets.exact) { + await visitPortableMcpAsset(pluginDir, path, false, assets, visited); + } + for (const path of assets.subtrees) { + await visitPortableMcpAsset(pluginDir, path, true, assets, visited); + } +} + +async function visitPortableMcpAsset( + pluginDir: string, + path: string, + subtree: boolean, + assets: PortableMcpAssetPaths, + visited: Set, +): Promise { + const visitKey = `${subtree ? "subtree" : "exact"}:${path}`; + if (visited.has(visitKey)) {return;} + visited.add(visitKey); + const filePath = join(pluginDir, path); + let fileStat: Awaited>; + try { + fileStat = await lstat(filePath); + } catch { + return; + } + if (fileStat.isSymbolicLink()) { + const targetPath = resolve(dirname(filePath), await readlink(filePath)); + const target = relative(pluginDir, targetPath).split("\\").join("/"); + if (!target || isOutsideRelativePath(target)) {return;} + (subtree ? assets.subtrees : assets.exact).add(target); + await visitPortableMcpAsset(pluginDir, target, subtree, assets, visited); + return; + } + if (!subtree && fileStat.isDirectory()) { + assets.subtrees.add(path); + await visitPortableMcpAsset(pluginDir, path, true, assets, visited); + return; + } + if (!subtree || !fileStat.isDirectory()) {return;} + for (const entry of await readdir(filePath, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) {continue;} + await visitPortableMcpAsset( + pluginDir, + join(path, entry.name).split("\\").join("/"), + true, + assets, + visited, + ); + } +} + function portableCoreManifest(plugin: PluginDeclaration): SerializedObject { const manifest: SerializedObject = { $schema: AGENT_PLUGIN_SCHEMA, diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 0f823bb7..3186490d 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -9,9 +9,11 @@ import { isSameProjectPluginConfig, loadInstalledPlugins, lockEntryForPlugin, + preparePluginForTargets, resolvePlugin, type ResolvedPlugin, } from "./store.js"; +import { AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA } from "./schema.js"; async function removeWithBackupFailure( path: Parameters[0], @@ -109,7 +111,7 @@ describe("plugin store", () => { const result = await loadInstalledPlugins(pluginsDir, [{ name: "review-tools", source: "path:source/review-tools", - }], "npx @sentry/dotagents install"); + }], "npx @sentry/dotagents install", []); expect(result.plugins).toEqual([]); expect(result.issues[0]?.issue).toContain("Installed plugin resolves outside source"); } finally { @@ -126,9 +128,9 @@ describe("plugin store", () => { const result = await loadInstalledPlugins(pluginsDir, [{ name: "review-tools", source: "path:source/review-tools", - }], "npx @sentry/dotagents install"); + }], "npx @sentry/dotagents install", []); expect(result.plugins).toEqual([]); - expect(result.issues[0]?.issue).toContain("has no plugin.json or supported native manifest"); + expect(result.issues[0]?.issue).toContain("missing plugin.json. Reinstall the plugin"); } finally { await rm(projectRoot, { recursive: true, force: true }); } @@ -302,20 +304,203 @@ describe("plugin store", () => { } }); - it("rejects legacy root components in standard bundles", async () => { + it("accepts standard bundles with inert legacy roots and reports them", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); try { const pluginDir = join(projectRoot, "source", "plugins", "review-tools"); await mkdir(join(pluginDir, "agents"), { recursive: true }); await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ - $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + $schema: AGENT_PLUGIN_SCHEMA, name: "review-tools", })); - await expect(resolvePlugin( + const resolved = await resolvePlugin( { name: "review-tools", source: "path:source", path: "plugins/review-tools" }, { stateDir: join(projectRoot, "state"), projectRoot }, - )).rejects.toThrow(/contains legacy root components: agents/); + ); + expect(resolved.plugin.manifest).toMatchObject({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "review-tools", + }); + expect(resolved.plugin.compatibilityWarnings).toEqual([ + 'Plugin "review-tools" contains ignored legacy root resources: agents. They are preserved but are not portable plugin components.', + ]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("discovers a standard-plus-native bundle as one candidate", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-hybrid-")); + try { + await mkdir(join(sourceRoot, ".claude-plugin"), { recursive: true }); + await mkdir(join(sourceRoot, "commands"), { recursive: true }); + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "review-tools", + })); + await writeFile(join(sourceRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "review-tools", + commands: "./commands", + })); + + const candidates = await discoverPlugins(sourceRoot); + + expect(candidates).toHaveLength(1); + expect(candidates[0]).toMatchObject({ name: "review-tools", path: "" }); + expect(candidates[0]!.nativeSource).toBeUndefined(); + expect(candidates[0]!.authoredNativeInterfaces.claude?.fallback).toBe(true); + expect(candidates[0]!.manifest).toMatchObject({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "review-tools", + }); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it("validates hybrid fallback identity only when its client is selected", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-hybrid-")); + try { + await mkdir(join(sourceRoot, ".claude-plugin"), { recursive: true }); + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "portable-name", + })); + await writeFile(join(sourceRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ + commands: "./commands", + })); + + const resolved = await resolvePlugin( + { name: "portable-name", source: "path:." }, + { stateDir: join(sourceRoot, "state"), projectRoot: sourceRoot }, + ); + expect(preparePluginForTargets(resolved.plugin, ["codex"]).compatibilityWarnings?.some( + (warning) => warning.includes("missing the portable plugin name"), + )).toBe(true); + expect(() => preparePluginForTargets(resolved.plugin, ["claude"])).toThrow( + 'Claude native fallback manifest is missing the portable plugin name "portable-name"', + ); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it("never falls back from an invalid standard root to a valid native manifest", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-hybrid-")); + try { + const sourceRoot = join(projectRoot, "source"); + await mkdir(join(sourceRoot, ".claude-plugin"), { recursive: true }); + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + $schema: "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", + name: "review-tools", + })); + await writeFile(join(sourceRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "review-tools", + })); + await writeFile(join(sourceRoot, "marketplace.json"), JSON.stringify({ + name: "external", + plugins: [{ + name: "review-tools", + source: { source: "github", repo: "example/review-tools" }, + }], + })); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow("unsupported Agent Plugins schema"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("does not let an unrelated root issue mask a valid marketplace candidate", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-hybrid-")); + try { + const sourceRoot = join(projectRoot, "source"); + const pluginDir = join(sourceRoot, "catalog", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + $schema: "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", + name: "unrelated-root", + })); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "review-tools" })); + await writeFile(join(sourceRoot, "marketplace.json"), JSON.stringify({ + name: "local", + plugins: [{ name: "review-tools", source: "./catalog/review-tools" }], + })); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).resolves.toMatchObject({ plugin: { pluginDir } }); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("keeps portable metadata authoritative when the native interface is reproducible", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-hybrid-")); + try { + const sourceRoot = join(projectRoot, "source"); + await mkdir(join(sourceRoot, ".codex-plugin"), { recursive: true }); + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "review-tools", + version: "1.0.0", + author: { name: "Portable author" }, + })); + await writeFile(join(sourceRoot, "mcp.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_MCP_SCHEMA, + mcpServers: { + review: { type: "stdio", command: "node" }, + invalid: { type: "stdio", command: "node server.js" }, + }, + })); + const generatedMcp = { + $schema: AGENT_PLUGIN_MCP_SCHEMA, + mcpServers: { review: { type: "stdio", command: "node" } }, + }; + await writeFile( + join(sourceRoot, ".codex-plugin", "mcp.json"), + JSON.stringify(generatedMcp), + ); + await writeFile(join(sourceRoot, ".codex-plugin", "plugin.json"), JSON.stringify({ + name: "review-tools", + version: "1.0.0", + author: { name: "Native author" }, + mcpServers: "./.codex-plugin/mcp.json", + interface: { + displayName: "Review Tools", + shortDescription: "", + developerName: "Portable author", + category: "Coding", + capabilities: ["Interactive", "Write"], + }, + })); + const resolved = await resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + + expect(resolved.plugin.authoredNativeInterfaces?.codex?.fallback).toBe(false); + expect(preparePluginForTargets(resolved.plugin, ["codex"]).compatibilityWarnings).toEqual([ + 'Plugin "review-tools" is a hybrid compatibility bundle: the portable core remains authoritative; redundant authored native interfaces are ignored in favor of portable generation.', + 'Plugin "review-tools" has an authored Codex interface that was ignored because the portable core can generate the Codex adapter.', + ]); + const installed = await installPluginBundle(join(projectRoot, "installed"), resolved); + expect(existsSync(join(installed.pluginDir, ".codex-plugin", "mcp.json"))).toBe(false); + + await writeFile( + join(sourceRoot, ".codex-plugin", "mcp.json"), + JSON.stringify({ ...generatedMcp, mcpServers: { other: { type: "stdio", command: "node" } } }), + ); + const collided = await resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + expect(collided.plugin.authoredNativeInterfaces?.codex?.fallback).toBe(true); } finally { await rm(projectRoot, { recursive: true, force: true }); } diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts index a9debc90..760c03ce 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -1,10 +1,12 @@ import { existsSync } from "node:fs"; import { lstat, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { applyDefaultRepositorySource, copyDir, ensureCached, + isSerializedObject, isSourceExcluded, parseSource, resolveLocalSource, @@ -14,20 +16,32 @@ import { type TrustPolicy, type CacheReuse, isSerializedValue, + type SerializedObject, type SerializedValue, } from "@sentry/dotagents-lib"; import { PLUGIN_NAME_PATTERN, type PluginConfig } from "../config/schema.js"; import type { LockedPlugin } from "../lockfile/schema.js"; import { parsePluginManifest, + parsePluginMcpBestEffort, parsePluginMarketplace, isStandardPluginManifest, type MarketplacePluginEntry, type LegacyPluginManifest, type PluginManifest, + type PluginMcpParseResult, } from "./schema.js"; -import { isManagedJsonFile } from "./managed-files.js"; -import type { NativePluginSource, PluginDeclaration } from "./types.js"; +import { + generatedNativeMcpPath, + nativeInterfaceNeedsFallback, + selectedAgentIds, +} from "./targets.js"; +import { codexPluginInterface } from "./runtime/manifest-values.js"; +import type { + AuthoredNativePluginInterfaces, + NativePluginSource, + PluginDeclaration, +} from "./types.js"; import { hasErrorCode, isString } from "../utils/type-guards.js"; // Owns plugin source discovery and installation into the canonical project tree. @@ -76,6 +90,8 @@ export interface PluginCandidate { dir: string; path: string; manifest: PluginManifest; + authoredNativeInterfaces: AuthoredNativePluginInterfaces; + legacyRoots: string[]; nativeSource?: NativePluginSource; origin: PluginCandidateOrigin; } @@ -105,15 +121,42 @@ const MARKETPLACE_PATHS = [ ".plugin/marketplace.json", ] as const; -const MANIFEST_PATHS: ReadonlyArray<{ path: string; nativeSource?: NativePluginSource }> = [ - { path: "plugin.json" }, +const FALLBACK_MANIFEST_PATHS: ReadonlyArray<{ path: string; nativeSource?: NativePluginSource }> = [ { path: ".codex-plugin/plugin.json", nativeSource: "codex" }, { path: ".claude-plugin/plugin.json", nativeSource: "claude" }, { path: ".cursor-plugin/plugin.json", nativeSource: "cursor" }, { path: ".plugin/plugin.json" }, ] as const; +const NATIVE_MANIFEST_PATHS: ReadonlyArray<{ + source: NativePluginSource; + path: string; +}> = [ + { source: "claude", path: ".claude-plugin/plugin.json" }, + { source: "cursor", path: ".cursor-plugin/plugin.json" }, + { source: "codex", path: ".codex-plugin/plugin.json" }, +] as const; + +export const HYBRID_LEGACY_ROOTS = [ + ".agents", + ".claude", + ".cursor", + ".codex", + ".opencode", + "agents", + "apps", + "bin", + "commands", + "rules", + "hooks", + "monitors", + ".mcp.json", + ".lsp.json", + ".app.json", +] as const; + export const DOTAGENTS_MANAGED_PLUGIN_MARKER = ".dotagents-managed"; +export const DOTAGENTS_NATIVE_FALLBACKS_MARKER = ".dotagents-native-fallbacks"; const DOTAGENTS_NATIVE_SOURCE_MARKER = ".dotagents-native-source"; let tempInstallCounter = 0; @@ -210,8 +253,10 @@ export async function installPluginBundle( await removeSourceOwnershipMarkers(tempDir); await assertPluginBundleSymlinksContained(tempDir); const staged = { ...resolved.plugin, pluginDir: tempDir }; + await removeRedundantNativeInterfaces(staged); await ensureCanonicalManifest(staged); await writeNativeSourceMarker(staged); + await writeNativeFallbacksMarker(staged); await writeManagedMarker(tempDir); if (existsSync(destDir)) { @@ -240,6 +285,7 @@ export async function loadInstalledPlugins( pluginsDir: string, configs: PluginConfig[], installCommand: string, + agentIds: string[], ): Promise<{ plugins: PluginDeclaration[]; issues: Array<{ name: string; issue: string }> }> { const plugins: PluginDeclaration[] = []; const issues: Array<{ name: string; issue: string }> = []; @@ -256,21 +302,21 @@ export async function loadInstalledPlugins( try { await assertInsideSourceRoot(pluginsDir, pluginDir, "Installed plugin"); await assertPluginBundleSymlinksContained(pluginDir); - const loaded = await loadManifest(pluginDir); + const loaded = await loadPluginInterfaces(pluginDir, true); if (!loaded) { throw new Error("Plugin bundle has no plugin.json or supported native manifest"); } const manifest = loaded.manifest; - await validateStandardBundleLayout(pluginDir, manifest, true); assertPluginName(config.name, manifest, pluginDir); - plugins.push({ + plugins.push(preparePluginForTargets({ name: config.name, source: config.source, pluginDir, manifest: normalizeManifest(config.name, manifest), - nativeSource: await readNativeSourceMarker(pluginDir) ?? loaded?.nativeSource, + authoredNativeInterfaces: loaded.authoredNativeInterfaces, + nativeSource: loaded.nativeSource, targets: config.targets, - }); + }, agentIds)); } catch (err) { const message = err instanceof Error ? err.message : String(err); issues.push({ name: config.name, issue: `Failed to load installed plugin "${config.name}": ${message}` }); @@ -413,11 +459,21 @@ async function resolvePluginCandidate( ): Promise { if (config.path) { const dir = await resolveInside(sourceDir, config.path, "Plugin path", resolveRealpath); - return loadPluginCandidate(sourceDir, dir, { name: config.name }, "Plugin source", "explicit"); + const candidate = await loadPluginCandidate( + sourceDir, + dir, + { name: config.name }, + "Plugin source", + "explicit", + ); + if (candidate) {await assertPluginBundleSymlinksContained(candidate.dir);} + return candidate; } const catalog = await discoverPluginCatalog(sourceDir); - return resolveNamedPluginCandidate(catalog, config); + const candidate = resolveNamedPluginCandidate(catalog, config); + if (candidate) {await assertPluginBundleSymlinksContained(candidate.dir);} + return candidate; } function resolveNamedPluginCandidate( @@ -436,6 +492,11 @@ function resolveNamedPluginCandidate( ); if (canonicalIssue) {throw canonicalIssue.error;} + const namedRootIssue = catalog.issues.find( + (issue) => issue.origin === "root" && issue.name === config.name, + ); + if (namedRootIssue) {throw namedRootIssue.error;} + const marketplaceOutcome = catalog.marketplaceOutcomes.get(config.name)?.[0]; if (marketplaceOutcome && "error" in marketplaceOutcome) { throw marketplaceOutcome.error; @@ -502,6 +563,7 @@ async function discoverPluginCatalog(sourceDir: string): Promise if (root) {candidates.push(root);} } catch (err) { issues.push({ + name: err instanceof NamedPluginManifestError ? err.pluginName : undefined, path: "", origin: "root", error: err instanceof Error ? err : new Error(String(err)), @@ -724,10 +786,9 @@ async function loadPluginCandidate( if (!existsSync(pluginDir)) {return null;} await assertInsideSourceRoot(sourceRoot, pluginDir, label); - const loaded = await loadManifest(pluginDir); + const loaded = await loadPluginInterfaces(pluginDir, false); if (!loaded) {return null;} const manifest = loaded.manifest; - await validateStandardBundleLayout(pluginDir, manifest, false); const name = manifest && isString(manifest["name"]) ? String(manifest["name"]) @@ -749,6 +810,10 @@ async function loadPluginCandidate( dir: pluginDir, path: relativePath(sourceRoot, pluginDir), manifest: combined, + authoredNativeInterfaces: loaded.authoredNativeInterfaces, + legacyRoots: isStandardPluginManifest(combined) + ? HYBRID_LEGACY_ROOTS.filter((path) => existsSync(join(pluginDir, path))) + : [], nativeSource: loaded?.nativeSource, origin, }; @@ -764,56 +829,182 @@ function marketplaceManifestOverlay( return overlay; } -async function loadManifest( +async function loadPluginInterfaces( pluginDir: string, -): Promise<{ manifest: PluginManifest; nativeSource?: NativePluginSource } | null> { - for (const candidate of MANIFEST_PATHS) { + installed: boolean, +): Promise<{ + manifest: PluginManifest; + authoredNativeInterfaces: AuthoredNativePluginInterfaces; + nativeSource?: NativePluginSource; +} | null> { + const provenance = installed + ? await readInstalledPluginProvenance(pluginDir) + : undefined; + const fallbackSources = provenance?.fallbackSources; + const installedNativeSource = provenance?.nativeSource; + const rootPath = join(pluginDir, "plugin.json"); + if (existsSync(rootPath)) { + const value = await readJson(rootPath); + let manifest: PluginManifest; + try { + manifest = parsePluginManifest(value, rootPath); + } catch (err) { + throw pluginManifestError(err, value); + } + return { + manifest, + authoredNativeInterfaces: await loadAuthoredNativeInterfaces( + pluginDir, + fallbackSources, + installedNativeSource, + manifest, + ), + nativeSource: installedNativeSource, + }; + } + if (installed) { + throw new Error("Installed plugin bundle is missing plugin.json. Reinstall the plugin."); + } + + for (const candidate of FALLBACK_MANIFEST_PATHS) { const filePath = join(pluginDir, candidate.path); if (!existsSync(filePath)) {continue;} + const value = await readJson(filePath); + let manifest: PluginManifest; + try { + manifest = parsePluginManifest(value, filePath); + } catch (err) { + throw pluginManifestError(err, value); + } + const authoredNativeInterfaces: AuthoredNativePluginInterfaces = {}; + if (candidate.nativeSource) { + authoredNativeInterfaces[candidate.nativeSource] = { + path: candidate.path, + fallback: true, + manifest, + }; + } return { - manifest: parsePluginManifest(await readJson(filePath), filePath), + manifest, + authoredNativeInterfaces, nativeSource: candidate.nativeSource, }; } return null; } -async function validateStandardBundleLayout( +async function loadAuthoredNativeInterfaces( pluginDir: string, - manifest: PluginManifest, - allowManagedAdapters: boolean, -): Promise { - if (!isStandardPluginManifest(manifest)) {return;} - const legacyRoots = [ - "agents", - "commands", - "rules", - "hooks", - "monitors", - ".mcp.json", - ".lsp.json", - ".app.json", - ]; - const found = legacyRoots.filter((path) => existsSync(join(pluginDir, path))); - for (const dir of [".claude-plugin", ".cursor-plugin", ".codex-plugin"]) { - if (!existsSync(join(pluginDir, dir))) {continue;} - const manifestPath = join(pluginDir, dir, "plugin.json"); - if (allowManagedAdapters && await isManagedJsonFile(manifestPath)) {continue;} - found.push(dir); + fallbackSources: ReadonlySet | null | undefined, + installedNativeSource: NativePluginSource | undefined, + portableManifest: PluginManifest | undefined, +): Promise { + // undefined classifies source input; null ignores installed adapters; a Set reloads recorded fallbacks. + const interfaces: AuthoredNativePluginInterfaces = {}; + const portableMcp = fallbackSources === undefined && portableManifest && + isStandardPluginManifest(portableManifest) + ? await loadPortableMcpForGeneration(pluginDir) + : undefined; + for (const candidate of NATIVE_MANIFEST_PATHS) { + const filePath = join(pluginDir, candidate.path); + if (!existsSync(filePath)) { + if (fallbackSources?.has(candidate.source)) { + throw new Error( + `Installed plugin records a ${nativeDisplayName(candidate.source)} native fallback, but ${candidate.path} is missing. Reinstall the plugin.`, + ); + } + continue; + } + if (fallbackSources && !fallbackSources.has(candidate.source)) {continue;} + if (fallbackSources === null && installedNativeSource !== candidate.source) {continue;} + let fallback = true; + try { + const value = await readJson(filePath); + const manifest = parsePluginManifest(value, filePath); + if (fallbackSources === undefined) { + const reproducibleFields: SerializedObject = {}; + const mcpPath = await reproducibleNativeMcpPath( + pluginDir, + candidate.source, + portableMcp, + ); + if (mcpPath) {reproducibleFields["mcpServers"] = mcpPath;} + if (candidate.source === "codex" && portableManifest && isString(portableManifest.name)) { + reproducibleFields["interface"] = codexPluginInterface( + portableManifest.name, + portableManifest, + ); + } + fallback = nativeInterfaceNeedsFallback(candidate.source, value, reproducibleFields); + } + interfaces[candidate.source] = { + path: candidate.path, + fallback, + manifest, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + interfaces[candidate.source] = { + path: candidate.path, + fallback, + error: message.replaceAll(filePath, candidate.path), + }; + } } - if (found.length > 0) { - throw new Error( - `Agent Plugins v1 bundle contains legacy root components: ${found.join(", ")}. Move client-specific resources into reverse-domain extension directories.`, - ); + return interfaces; +} + +async function reproducibleNativeMcpPath( + pluginDir: string, + source: NativePluginSource, + portableMcp: PluginMcpParseResult | undefined, +): Promise { + const mcpPath = generatedNativeMcpPath(source, portableMcp); + if (!mcpPath || mcpPath === "./mcp.json") {return mcpPath;} + const adapterPath = join(pluginDir, mcpPath); + if (!existsSync(adapterPath)) {return mcpPath;} + try { + return isDeepStrictEqual(await readJson(adapterPath), portableMcp?.config) + ? mcpPath + : undefined; + } catch { + return undefined; } } +async function loadPortableMcpForGeneration( + pluginDir: string, +): Promise { + const filePath = join(pluginDir, "mcp.json"); + if (!existsSync(filePath)) {return undefined;} + try { + return parsePluginMcpBestEffort(await readJson(filePath), filePath); + } catch { + return undefined; + } +} + +class NamedPluginManifestError extends Error { + constructor(message: string, readonly pluginName?: string) { + super(message); + } +} + +function pluginManifestError(err: Thrown, value: SerializedValue): Error { + const message = err instanceof Error ? err.message : String(err); + const name = isSerializedObject(value) && isString(value["name"]) + ? value["name"] + : undefined; + return new NamedPluginManifestError(message, name); +} + async function removeSourceOwnershipMarkers(dir: string): Promise { // Source-controlled markers never establish dotagents ownership or provenance. for (const entry of await readdir(dir, { withFileTypes: true })) { const filePath = join(dir, entry.name); if ( entry.name === DOTAGENTS_MANAGED_PLUGIN_MARKER || + entry.name === DOTAGENTS_NATIVE_FALLBACKS_MARKER || entry.name === DOTAGENTS_NATIVE_SOURCE_MARKER || entry.name.endsWith(".dotagents-managed") ) { @@ -824,6 +1015,19 @@ async function removeSourceOwnershipMarkers(dir: string): Promise { } } +async function removeRedundantNativeInterfaces(plugin: PluginDeclaration): Promise { + for (const candidate of NATIVE_MANIFEST_PATHS) { + const nativeInterface = plugin.authoredNativeInterfaces?.[candidate.source]; + if (!nativeInterface || nativeInterface.fallback) {continue;} + await rm(join(plugin.pluginDir, candidate.path), { force: true }); + if ( + nativeInterface.manifest?.["mcpServers"] === `./.${candidate.source}-plugin/mcp.json` + ) { + await rm(join(plugin.pluginDir, `.${candidate.source}-plugin`, "mcp.json"), { force: true }); + } + } +} + async function ensureCanonicalManifest(plugin: PluginDeclaration): Promise { const filePath = join(plugin.pluginDir, "plugin.json"); if (existsSync(filePath)) {return;} @@ -843,6 +1047,70 @@ async function writeNativeSourceMarker(plugin: PluginDeclaration): Promise } } +async function writeNativeFallbacksMarker(plugin: PluginDeclaration): Promise { + const filePath = join(plugin.pluginDir, DOTAGENTS_NATIVE_FALLBACKS_MARKER); + const sources = nativeSources().filter( + (source) => plugin.authoredNativeInterfaces?.[source]?.fallback, + ); + if (sources.length === 0) { + await rm(filePath, { force: true }); + return; + } + await writeFile(filePath, `${sources.join("\n")}\n`, "utf-8"); +} + +async function readNativeFallbackSources( + pluginDir: string, +): Promise | null> { + let content: string; + try { + content = await readFile(join(pluginDir, DOTAGENTS_NATIVE_FALLBACKS_MARKER), "utf-8"); + } catch (err) { + if (isNotFoundError(err)) {return null;} + throw err; + } + + const values = content.split("\n").filter(Boolean); + const sources = new Set(); + for (const value of values) { + const source = nativeSources().find((candidate) => candidate === value); + if (!source || sources.has(source)) { + throw new Error(`Invalid native fallback provenance: ${value || ""}`); + } + sources.add(source); + } + if (sources.size === 0) { + throw new Error("Invalid native fallback provenance: empty marker"); + } + return sources; +} + +async function readInstalledPluginProvenance(pluginDir: string): Promise<{ + fallbackSources: Set | null; + nativeSource: NativePluginSource | undefined; +}> { + const fallbackSources = await readNativeFallbackSources(pluginDir); + const nativeSource = await readNativeSourceMarker(pluginDir); + if ( + fallbackSources && nativeSource && + (fallbackSources.size !== 1 || !fallbackSources.has(nativeSource)) + ) { + throw new Error("Installed plugin has conflicting native interface provenance. Reinstall the plugin."); + } + return { fallbackSources, nativeSource }; +} + +/** Returns whether installed provenance reserves a matching-client native fallback. */ +export async function hasRecordedNativePluginFallback( + pluginDir: string, + source: NativePluginSource, +): Promise { + const provenance = await readInstalledPluginProvenance(pluginDir); + return provenance.fallbackSources + ? provenance.fallbackSources.has(source) + : provenance.nativeSource === source; +} + async function readNativeSourceMarker(pluginDir: string): Promise { try { const value = (await readFile(join(pluginDir, DOTAGENTS_NATIVE_SOURCE_MARKER), "utf-8")).trim(); @@ -863,11 +1131,171 @@ function toDeclaration( source: config.source, pluginDir: candidate.dir, manifest: normalizeManifest(config.name, candidate.manifest), + authoredNativeInterfaces: candidate.authoredNativeInterfaces, + compatibilityWarnings: compatibilityWarnings( + config.name, + candidate.manifest, + candidate.authoredNativeInterfaces, + candidate.legacyRoots, + [], + ), nativeSource: candidate.nativeSource, targets: config.targets, }; } +/** Validates selected native interfaces and computes deterministic compatibility warnings. */ +export function preparePluginForTargets( + plugin: PluginDeclaration, + agentIds: string[], +): PluginDeclaration { + const interfaces = plugin.authoredNativeInterfaces ?? {}; + const selectedTargets = new Set(selectedAgentIds(agentIds, plugin)); + if (isStandardPluginManifest(plugin.manifest)) { + assertNativeInterfaceNames(plugin.name, interfaces, plugin.pluginDir, selectedTargets); + } + for (const source of nativeSources()) { + const nativeInterface = interfaces[source]; + if (!nativeInterface?.fallback || !nativeInterface.error || !selectedTargets.has(source)) {continue;} + throw new Error( + `Invalid ${nativeDisplayName(source)} native fallback for "${plugin.name}" at ${nativeInterface.path}: ${nativeInterface.error}`, + ); + } + const legacyRoots = isStandardPluginManifest(plugin.manifest) + ? HYBRID_LEGACY_ROOTS.filter((path) => existsSync(join(plugin.pluginDir, path))) + : []; + return { + ...plugin, + compatibilityWarnings: compatibilityWarnings( + plugin.name, + plugin.manifest, + interfaces, + legacyRoots, + selectedTargets, + ), + }; +} + +function assertNativeInterfaceNames( + expected: string, + interfaces: AuthoredNativePluginInterfaces, + context: string, + selectedTargets: ReadonlySet, +): void { + for (const source of nativeSources()) { + const nativeInterface = interfaces[source]; + if (!nativeInterface?.fallback) {continue;} + const manifest = nativeInterface.manifest; + if (!manifest) {continue;} + const issue = nativeInterfaceNameIssue(expected, source, manifest); + if (!issue || !selectedTargets.has(source)) {continue;} + throw new Error(`${issue} in ${context}.`); + } +} + +function nativeInterfaceNameIssue( + expected: string, + source: NativePluginSource, + manifest: PluginManifest, +): string | undefined { + const actual = manifest["name"]; + if (actual === expected) {return undefined;} + if (isString(actual)) { + return `${nativeDisplayName(source)} native fallback manifest name "${actual}" does not match portable plugin name "${expected}"`; + } + return `${nativeDisplayName(source)} native fallback manifest is missing the portable plugin name "${expected}"`; +} + +function compatibilityWarnings( + name: string, + manifest: PluginManifest, + interfaces: AuthoredNativePluginInterfaces, + legacyRoots: string[], + selectedTargets: ReadonlySet | string[], +): string[] { + if (!isStandardPluginManifest(manifest)) {return [];} + const warnings: string[] = []; + const sources = nativeSources().filter((source) => interfaces[source] !== undefined); + const fallbackSources = sources.filter((source) => interfaces[source]?.fallback); + const validFallbackSources = fallbackSources.filter( + (source) => interfaces[source]?.manifest !== undefined, + ); + if (fallbackSources.length > 0) { + warnings.push( + `Plugin "${name}" is a hybrid compatibility bundle: the portable core remains authoritative; authored ${validFallbackSources.map(nativeDisplayName).join(", ") || "native"} interfaces are retained only as matching-client fallbacks.`, + ); + } else if (sources.length > 0) { + warnings.push( + `Plugin "${name}" is a hybrid compatibility bundle: the portable core remains authoritative; redundant authored native interfaces are ignored in favor of portable generation.`, + ); + } else if (legacyRoots.length > 0) { + warnings.push( + `Plugin "${name}" contains ignored legacy root resources: ${legacyRoots.join(", ")}. They are preserved but are not portable plugin components.`, + ); + } + const selected = selectedTargets instanceof Set ? selectedTargets : new Set(selectedTargets); + if ( + legacyRoots.length > 0 && + sources.length > 0 && + !validFallbackSources.some((source) => selected.has(source)) + ) { + warnings.push( + `Plugin "${name}" contains ignored legacy root resources for the selected targets: ${legacyRoots.join(", ")}. They remain available only to a matching native fallback.`, + ); + } + for (const source of sources) { + const nativeInterface = interfaces[source]!; + if (!nativeInterface.fallback) { + warnings.push( + `Plugin "${name}" has an authored ${nativeDisplayName(source)} interface that was ignored because the portable core can generate the ${nativeDisplayName(source)} adapter${nativeInterface.error ? `: ${nativeInterface.error}` : "."}`, + ); + continue; + } + if (nativeInterface.error && !selected.has(source)) { + warnings.push( + `Plugin "${name}" has a malformed ${nativeDisplayName(source)} native fallback that was ignored because ${nativeDisplayName(source)} is not selected: ${nativeInterface.error}`, + ); + continue; + } + const nameIssue = nativeInterface.manifest + ? nativeInterfaceNameIssue(name, source, nativeInterface.manifest) + : undefined; + if (nameIssue && !selected.has(source)) { + warnings.push( + `Plugin "${name}" has an invalid ${nativeDisplayName(source)} native fallback that was ignored because ${nativeDisplayName(source)} is not selected: ${nameIssue}.`, + ); + continue; + } + if (nativeInterface.manifest && metadataDiffers(manifest, nativeInterface.manifest)) { + warnings.push( + `Plugin "${name}" has differing portable and ${nativeDisplayName(source)} fallback metadata; the portable core remains the source of truth while ${nativeDisplayName(source)} receives the preserved fallback.`, + ); + } + } + return warnings; +} + +function metadataDiffers(portable: PluginManifest, native: PluginManifest): boolean { + const keys = [ + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + ] as const; + return keys.some((key) => !isDeepStrictEqual(portable[key], native[key])); +} + +function nativeSources(): NativePluginSource[] { + return ["claude", "cursor", "codex"]; +} + +function nativeDisplayName(source: NativePluginSource): string { + return source === "claude" ? "Claude" : source === "cursor" ? "Cursor" : "Codex"; +} + function assertPluginName( expected: string, manifest: PluginManifest, diff --git a/packages/dotagents/src/plugins/targets.ts b/packages/dotagents/src/plugins/targets.ts index 76da1aaa..253d3f1b 100644 --- a/packages/dotagents/src/plugins/targets.ts +++ b/packages/dotagents/src/plugins/targets.ts @@ -1,10 +1,18 @@ -import type { PluginDeclaration } from "./types.js"; +import { isDeepStrictEqual } from "node:util"; +import type { NativePluginSource, PluginDeclaration } from "./types.js"; import type { PluginWriteWarning } from "./runtime/types.js"; -import { isStandardPluginManifest } from "./schema.js"; +import { isStandardPluginManifest, type PluginMcpConfig } from "./schema.js"; +import { isSerializedObject, type SerializedObject, type SerializedValue } from "@sentry/dotagents-lib"; +import { isString } from "../utils/type-guards.js"; const PLUGIN_ONLY_AGENT_IDS = ["grok", "pi"]; const PLUGIN_AGENT_IDS = ["claude", "cursor", "codex", "grok", "opencode", "pi"]; const SUPPORTED_PLUGIN_AGENT_IDS = new Set(allPluginAgentIds()); +const GENERATED_NATIVE_FIELDS = { + claude: new Set(["$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills"]), + cursor: new Set(["$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills"]), + codex: new Set(["$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills"]), +} satisfies Record>; /** Returns agent IDs accepted in agents.toml only for plugin runtime output. */ export function allPluginOnlyAgentIds(): string[] { @@ -33,6 +41,51 @@ export function selectedAgentIds( .filter((target) => SUPPORTED_PLUGIN_AGENT_IDS.has(target)); } +/** Returns whether a native manifest contains behavior the target adapter cannot generate from the portable core. */ +export function nativeInterfaceNeedsFallback( + source: NativePluginSource, + value: SerializedValue, + reproducibleFields: SerializedObject = {}, +): boolean { + if (!isSerializedObject(value)) {return true;} + for (const [key, field] of Object.entries(value)) { + if (reproducibleFields[key] !== undefined && isDeepStrictEqual(field, reproducibleFields[key])) { + continue; + } + if (!GENERATED_NATIVE_FIELDS[source].has(key)) {return true;} + if (key === "skills" && !isConventionalSkillsReference(field)) {return true;} + } + return false; +} + +/** Returns the native MCP reference generated from a portable MCP parse result. */ +export function generatedNativeMcpPath( + source: NativePluginSource, + portableMcp: { config?: PluginMcpConfig; issues: string[] } | undefined, +): string | undefined { + if (!portableMcp?.config) {return undefined;} + if (portableMcp.issues.length === 0) {return "./mcp.json";} + if (Object.keys(portableMcp.config.mcpServers).length > 0) { + return `./.${source}-plugin/mcp.json`; + } + return undefined; +} + +/** Returns whether the matching-client manifest is authored rather than generated. */ +export function hasAuthoredNativeInterface( + plugin: Pick, + source: NativePluginSource, +): boolean { + return plugin.nativeSource === source || + plugin.authoredNativeInterfaces?.[source]?.fallback === true; +} + +function isConventionalSkillsReference(value: SerializedValue | undefined): boolean { + const values = Array.isArray(value) ? value : [value]; + return values.length === 1 && isString(values[0]) && + values[0].replace(/^\.\//, "").replace(/\/$/, "") === "skills"; +} + /** Keeps native legacy components with their owning client while generalized legacy bundles remain portable. */ export function usesLegacyPluginComponents( plugin: Pick, diff --git a/packages/dotagents/src/plugins/types.ts b/packages/dotagents/src/plugins/types.ts index 5e3dc387..3a56d431 100644 --- a/packages/dotagents/src/plugins/types.ts +++ b/packages/dotagents/src/plugins/types.ts @@ -2,11 +2,21 @@ import type { PluginManifest } from "./schema.js"; export type NativePluginSource = "claude" | "cursor" | "codex"; +export type AuthoredNativePluginInterface = + | { path: string; fallback: boolean; manifest: PluginManifest; error?: never } + | { path: string; fallback: boolean; manifest?: never; error: string }; + +export type AuthoredNativePluginInterfaces = Partial< + Record +>; + export interface PluginDeclaration { name: string; source: string; pluginDir: string; manifest: PluginManifest; + authoredNativeInterfaces?: AuthoredNativePluginInterfaces; + compatibilityWarnings?: string[]; nativeSource?: NativePluginSource; targets?: string[]; } diff --git a/specs/SPEC.md b/specs/SPEC.md index 17f8fcd9..96a65354 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -243,7 +243,7 @@ Generated paths: Plugin dependencies. Each entry selects one plugin bundle from a source. dotagents installs the canonical plugin bundle into `.agents/plugins//` and writes deterministic runtime-specific plugin outputs for the configured agents selected by the plugin's `targets`. -The target canonical plugin input is an [Agent Plugins](https://agent-plugins.org/) bundle under `.agents/plugins//`: required `plugin.json`, optional `skills/`, optional `mcp.json`, and client-specific extension namespaces. dotagents source declarations, lock entries, marketplaces, target selection, and generated runtime files remain management concerns outside the portable bundle. Generated Claude, Cursor, and Codex marketplaces or native manifests are adapters, not source-of-truth plugin metadata. +The preferred canonical plugin input is an [Agent Plugins](https://agent-plugins.org/) bundle under `.agents/plugins//`: required `plugin.json`, optional `skills/`, optional `mcp.json`, and client-specific extension namespaces. During migration, a valid portable root may coexist with authored Claude, Cursor, or Codex manifests as a hybrid compatibility bundle. The portable root remains the source of truth. Dotagents ignores a native manifest when the target adapter can reproduce it from portable data, and retains it byte-for-byte as a matching-client fallback only when it contains behavior the adapter cannot represent. Generated adapters are managed, disposable output and never become input to later normalization. Source declarations, lock entries, marketplaces, target selection, and generated runtime files remain management concerns outside the portable bundle. See [Plugin Support Specification](plugins.md) for the Agent Plugins-aligned bundle contract, legacy migration plan, discovery rules, normalized internal model, downstream target transformations, and implementation gaps. @@ -705,8 +705,9 @@ dotagents doctor [--fix] 8. The selected scope's managed skills directory exists 9. All declared skills are installed 10. All declared plugins are installed -11. Generated plugin runtime artifacts are intact -12. In project scope, symlinks are intact +11. Hybrid plugin compatibility diagnostics are reported +12. Generated plugin runtime artifacts are intact +13. In project scope, symlinks are intact **Flags:** - `--fix`: Auto-fix issues where possible (add gitignore entries, remove legacy fields, create missing `.agents/.gitignore`, and repair legacy managed project hooks) @@ -724,7 +725,7 @@ dotagents list [--json] - `✗` missing — in agents.toml but not installed - `?` unlocked — installed but not in lockfile -**Output:** name, source, status. Human output groups results under `Skills:` and `Plugins:` when both are present. JSON output is an object with `skills` and `plugins` arrays. +**Output:** name, source, status, and optional plugin compatibility warnings. Human output groups results under `Skills:` and `Plugins:` when both are present and prints plugin warning lines below their plugin. JSON output is an object with `skills` and `plugins` arrays; a plugin entry includes `warnings` when diagnostics exist. --- diff --git a/specs/plugins.md b/specs/plugins.md index 6f4eec66..fc322a3b 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -10,7 +10,7 @@ The implementation includes a partial first compatibility stage: it accepts and normalizes Agent Plugins v1 manifests, validates MCP files, preserves portable source files, projects portable skills/MCP into generated native manifests, flattens portable MCP into OpenCode, and continues accepting legacy generalized -manifests and marketplace discovery. +manifests, hybrid compatibility bundles, and marketplace discovery. Client extension adapters and native MCP import remain follow-up work. ## Design Principle @@ -76,9 +76,10 @@ of inventing portable meaning. ### Round-trip invariants 1. Portable source files survive forward compilation unchanged. -2. Native imports preserve the original native manifest and resources. -3. Portable -> target -> portable preserves the portable core, not generated - ownership files or target presentation metadata. +2. Native-only imports and retained hybrid fallbacks preserve the original + native manifest and resources. +3. Generated target output is disposable and one-way: it is never imported as + portable input or reconsidered as a native fallback. 4. Native -> portable -> the same native client preserves the original native files; generated metadata may differ. 5. Cross-client output contains only the portable intersection and the target's @@ -317,10 +318,16 @@ For each `[[plugins]]` declaration: 4. Validate `plugin.json` against the Agent Plugins schema. 5. Require the manifest name to match the configured name. 6. Validate `skills/`, `mcp.json`, and referenced extension paths. -7. Copy the complete bundle into `.agents/plugins//`. +7. Classify authored `.claude-plugin`, `.cursor-plugin`, and `.codex-plugin` + interfaces separately. Retain one only when its target behavior cannot be + generated from the portable core, then validate that fallback fully when its + client is selected and require its name to match the portable name. 8. Reject broken symlinks and symlinks that resolve outside the plugin root. -9. Write the plugin lock entry. -10. Run each selected target adapter. +9. Complete plugin preflight before copying bundles or writing the lockfile. +10. Copy the normalized bundle into `.agents/plugins//`, removing + redundant native manifests that portable generation can replace. +11. Write the plugin lock entry. +12. Run each selected target adapter. The installed canonical bundle should remain a valid Agent Plugin. Generated target artifacts should live in target-specific directories or in clearly @@ -343,6 +350,45 @@ This is a conservative reverse import, not lossless conversion to Agent Plugins. Native MCP conversion and registered extension normalization require explicit client importers and remain future work. +### Hybrid compatibility bundles + +Strict portable authoring still uses only `plugin.json`, `skills/`, `mcp.json`, +and reverse-domain extension directories. For migration, dotagents also accepts +a valid standard root alongside authored `.claude-plugin`, `.cursor-plugin`, or +`.codex-plugin` manifests and legacy root resources. + +This is a compatibility input, not an expanded portable format. The standard +root remains authoritative for identity, portable metadata, skills, MCP, and +generated adapters. An authored native manifest containing only reproducible +metadata and the conventional `skills/` reference is redundant: dotagents +removes it from the normalized canonical install and uses the portable bundle +directly or generates the normal managed adapter. Metadata differences do not +create a second source of truth. + +When an authored native manifest contains commands, hooks, custom fields, or +other matching-client behavior that the adapter cannot reproduce, dotagents +retains it byte-for-byte as a fallback for that client only. Portable fields +are not merged into the fallback, and its native behavior is not translated to +other targets. A retained fallback must use the portable name. A selected +fallback that is malformed or references an unsafe path fails preflight; an +unselected fallback remains contained and inert with a warning. An invalid +standard root never falls back to native or legacy parsing. + +Generated adapters carry adjacent ownership sidecars and never become input to +later normalization. Canonical installs record only retained fallbacks in the +reserved `.dotagents-native-fallbacks` file. Source-supplied provenance and +ownership files are removed before managed markers are written. Installed +rescans use that explicit provenance instead of treating generated or unmarked +native manifests as source input, so a file created later retains the normal +unmanaged-collision behavior. + +OpenCode and Pi receive only supported portable projections; Grok copies omit +recognized native manifest directories and legacy component roots. `install` +reports redundant native input when it normalizes the source. After that input +is removed, `sync`, `list`, and `doctor` report only durable compatibility state +such as retained fallbacks; they do not reconstruct discarded source history +from generated adapters. + ## Internal Normalization Adapters should consume one normalized structure rather than each reparsing @@ -356,6 +402,7 @@ interface NormalizedPlugin { skills: AgentSkill[]; mcpServers: McpDeclaration[]; extensions: Record>; + authoredNativeInterfaces: Partial>; nativeSource?: "claude" | "cursor" | "codex"; targets: string[]; } @@ -386,6 +433,12 @@ Agent Plugin bundle | OpenCode | Project plugin skills and merge normalized MCP servers into OpenCode config when needed | Read only namespaces registered to the OpenCode adapter | Symlink skills into `.opencode/skills/`; generalized legacy bundles may project Markdown agents, while standard extension resources are preserved but not projected yet; do not generate JavaScript or TypeScript plugin modules. | | Pi | Project supported skills | Read only namespaces registered to the Pi adapter | Symlink skills into `.agents/skills/`; ignore unsupported MCP or extension components with warnings. | +For Claude, Cursor, and Codex, a retained matching native fallback replaces the +generated-manifest step for that target only. A reproducible authored manifest +does not. For Grok, hybrid compatibility copies exclude `.claude-plugin`, +`.cursor-plugin`, `.codex-plugin`, and known legacy component roots while +retaining portable files and ordinary assets. + Pi is an explicit isolation exception. Its plugin surface is the shared `.agents/skills/` directory, so targeting Pi makes those plugin skills visible to every configured client that also reads or links that directory. Per-client @@ -502,7 +555,9 @@ Generated state is deterministic and dotagents-managed: 4. Generated JSON ownership uses adjacent `.dotagents-managed` sidecars. Component symlinks use marker files in a reserved sibling `.dotagents-managed/` directory so markers cannot consume a valid component - name. Legacy `metadata.managedBy` files remain recognized for JSON migration. + name. Canonical hybrid bundles use `.dotagents-native-fallbacks` to record + only retained matching-client native fallbacks. Legacy `metadata.managedBy` + files remain recognized for generated JSON migration only. 5. Managed registration, manifests, copies, links, and MCP entries are pruned when a plugin or target is removed. 6. `.agents/.gitignore` lists copied managed bundles and generated links without @@ -566,6 +621,8 @@ Migration should happen in compatibility stages: - Reject legacy portable component fields for newly authored plugins. - Keep an explicit compatibility importer for older third-party repositories until a documented major-version removal. +- Accept hybrid migration repositories without treating native siblings or + legacy roots as portable authoring fields. ## Implementation Gaps