From 95a6bbd6f676e05573a6050c2ba20fa4c11f85c0 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:42:02 -0700 Subject: [PATCH 1/5] feat(plugins): Support hybrid plugin bundles --- README.md | 2 +- docs/public/llms.txt | 6 +- docs/src/content/docs/cli.mdx | 22 +- docs/src/content/docs/guide.mdx | 5 + .../dotagents/src/cli/commands/doctor.test.ts | 27 ++ packages/dotagents/src/cli/commands/doctor.ts | 18 +- .../src/cli/commands/install.test.ts | 248 +++++++++++ .../src/cli/commands/install/plugins.ts | 14 +- .../dotagents/src/cli/commands/list.test.ts | 30 +- packages/dotagents/src/cli/commands/list.ts | 34 +- .../dotagents/src/cli/commands/remove.test.ts | 12 +- packages/dotagents/src/cli/commands/remove.ts | 2 + packages/dotagents/src/cli/commands/sync.ts | 8 +- packages/dotagents/src/cli/index.test.ts | 12 +- .../src/plugins/runtime/manifests.ts | 4 +- .../src/plugins/runtime/writer.test.ts | 155 ++++++- .../dotagents/src/plugins/runtime/writer.ts | 26 +- packages/dotagents/src/plugins/store.test.ts | 158 ++++++- packages/dotagents/src/plugins/store.ts | 403 ++++++++++++++++-- packages/dotagents/src/plugins/types.ts | 10 + specs/SPEC.md | 9 +- specs/plugins.md | 54 ++- 22 files changed, 1176 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index e353caf4..4e0b7942 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: portable data remains authoritative across clients, each authored manifest is preserved byte-for-byte for its matching client, and 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..ba66a3df 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 authoritative for shared data and generated adapters, while each authored manifest remains byte-for-byte authoritative only for its matching selected client. Native commands, agents, hooks, MCP, and other resources are not guessed or cross-translated. Malformed selected native interfaces fail preflight; malformed unselected interfaces 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..a1655162 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: @@ -457,6 +459,16 @@ 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 preserved byte-for-byte and governs only its matching selected client. +Other clients receive adapters generated solely from the portable core. 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 interface blocks installation only when its client is +selected. `install`, `sync`, `list`, and `doctor` report compatibility warnings. + | 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..964d11b4 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -220,5 +220,10 @@ source = "getsentry/agent-pack" targets = ["claude", "cursor", "codex", "opencode"] ``` +Portable Agent Plugins may coexist with authored Claude, Cursor, or Codex +manifests during migration. Dotagents preserves each authored manifest for its +matching client and projects only the portable skills and MCP into unrelated +targets. + 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..a13deecc 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_AUTHORED_INTERFACES_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_AUTHORED_INTERFACES_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 3385af0d..1a9fe46f 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -185,6 +185,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, @@ -265,7 +266,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 86f94823..446bbe4e 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -30,6 +30,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: unknown } | { text: string } @@ -459,6 +468,245 @@ 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: portable components remain shared; authored Claude, Codex interfaces govern only their matching clients.', + }]); + 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("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 authored Claude plugin interface"); + + 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 = '{ "name": "hybrid-tools", "commands": "../outside" }\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: portable components remain shared; authored native interfaces govern only their matching clients.', + expect.stringContaining("malformed authored Claude interface 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 an authored Claude interface") && + 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..d5acec70 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_AUTHORED_INTERFACES_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_AUTHORED_INTERFACES_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: portable components remain shared; authored Claude interfaces govern only their matching clients.', + ], + }, ], }); }); diff --git a/packages/dotagents/src/cli/commands/list.ts b/packages/dotagents/src/cli/commands/list.ts index fbad3eb6..5481a825 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))) { @@ -106,11 +123,21 @@ 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 1646b91b..49283ef2 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/cli/index.test.ts b/packages/dotagents/src/cli/index.test.ts index 6aa1e059..82d39569 100644 --- a/packages/dotagents/src/cli/index.test.ts +++ b/packages/dotagents/src/cli/index.test.ts @@ -39,15 +39,23 @@ const COMMAND_CASES = [ describe("CLI help dispatch", () => { const originalExitCode = process.exitCode; + let dispatchRoot: string; + let originalCwd: string; - beforeEach(() => { + beforeEach(async () => { for (const [, handler] of COMMAND_CASES) {handler.mockReset();} checkForUpdate.mockClear(); process.exitCode = originalExitCode; + originalCwd = process.cwd(); + dispatchRoot = await mkdtemp(join(tmpdir(), "dotagents-cli-dispatch-")); + await writeFile(join(dispatchRoot, "agents.toml"), "version = 1\n"); + process.chdir(dispatchRoot); }); - afterEach(() => { + afterEach(async () => { + process.chdir(originalCwd); process.exitCode = originalExitCode; + await rm(dispatchRoot, { recursive: true, force: true }); }); it("prints command help without running the command", async () => { diff --git a/packages/dotagents/src/plugins/runtime/manifests.ts b/packages/dotagents/src/plugins/runtime/manifests.ts index 671b05b2..74826896 100644 --- a/packages/dotagents/src/plugins/runtime/manifests.ts +++ b/packages/dotagents/src/plugins/runtime/manifests.ts @@ -64,7 +64,9 @@ 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;} + const authored = plugin.nativeSource === spec.agent || + plugin.authoredNativeInterfaces?.[spec.agent]?.manifest !== undefined; + if (existsSync(filePath) && authored) {continue;} if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { warnings.push({ agent: spec.agent, diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index dc4f826f..5f3f3a68 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -48,6 +48,8 @@ describe("plugin writer", () => { source: `path:.agents/plugins/${name}`, pluginDir, manifest, + authoredNativeInterfaces: overrides.authoredNativeInterfaces, + compatibilityWarnings: overrides.compatibilityWarnings, nativeSource: overrides.nativeSource, targets: overrides.targets, }; @@ -222,6 +224,148 @@ 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`, + 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", + manifest: { + name: "alpha-tools", + commands: "./commands", + mcpServers: "./native-mcp.json", + "x-claude": true, + }, + }, + }; + + await writePluginOutputs(["claude", "cursor"], [alpha], root); + + const cursor = JSON.parse( + await readFile(join(alpha.pluginDir, ".cursor-plugin", "plugin.json"), "utf-8"), + ) as SerializedObject; + expect(cursor).toMatchObject({ name: "alpha-tools", description: "Portable description" }); + expect(cursor["commands"]).toBeUndefined(); + expect(cursor["mcpServers"]).toBeUndefined(); + expect(cursor["x-claude"]).toBeUndefined(); + }); + + 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", + 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, "agents", "native.md"), "native agent"); + await writeFile(join(alpha.pluginDir, "hooks", "hooks.json"), "{}"); + await writeFile(join(alpha.pluginDir, "asset.txt"), "ordinary asset"); + alpha.authoredNativeInterfaces = { + claude: { + path: ".claude-plugin/plugin.json", + 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, "asset.txt"), "utf-8")).toBe("ordinary asset"); + for (const path of [".claude-plugin", "commands", "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: { @@ -488,12 +632,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); diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index b5d333f4..b027510a 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -3,6 +3,11 @@ 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_AUTHORED_INTERFACES_MARKER, + hasRecordedAuthoredPluginInterface, + HYBRID_LEGACY_ROOTS, +} from "../store.js"; import type { PluginDeclaration } from "../types.js"; import { selectedAgentIds, selectPlugins, targetWarnings, usesLegacyPluginComponents } from "../targets.js"; import { marketplaceOutputPaths, marketplaceOutputs } from "./marketplace.js"; @@ -84,6 +89,12 @@ export async function writePluginOutputs( const selected = selectPlugins(agentIds, plugins); const loadedMcp = new Map(); + for (const plugin of plugins) { + for (const message of plugin.compatibilityWarnings ?? []) { + warnings.push({ agent: "plugin", name: plugin.name, message }); + } + } + for (const warning of targetWarnings(agentIds, plugins)) { warnings.push(warning); } @@ -262,6 +273,15 @@ export async function prunePluginOutputs( ); for (const entry of entries) { if (!entry.isDirectory() || desired.has(entry.name)) {continue;} + const plugin = plugins.find((plugin) => plugin.name === entry.name); + if ( + plugin?.nativeSource === target.agent || + plugin?.authoredNativeInterfaces?.[target.agent] !== undefined || + !plugin && await hasRecordedAuthoredPluginInterface( + join(canonicalPluginDir, entry.name), + target.agent, + ) + ) {continue;} for (const fileName of ["plugin.json", "mcp.json"]) { const path = join(canonicalPluginDir, entry.name, target.dir, fileName); if (!await isManagedJsonFile(path)) {continue;} @@ -276,7 +296,7 @@ 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, @@ -300,9 +320,13 @@ async function writeGrokProjection( ".claude-plugin", ".cursor-plugin", ".codex-plugin", + DOTAGENTS_AUTHORED_INTERFACES_MARKER, ".dotagents-managed", ".dotagents-native-source", ]); + 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"]) { diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 93e2562a..c785163c 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_SCHEMA } from "./schema.js"; vi.mock("node:fs/promises", async () => { const actual = await vi.importActual("node:fs/promises"); @@ -137,7 +139,7 @@ describe("plugin store", () => { source: "path:source/review-tools", }], "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 }); } @@ -318,20 +320,166 @@ 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.each([ + { shape: "standard-only", root: true, native: false, legacy: false }, + { shape: "native-only", root: false, native: true, legacy: false }, + { shape: "standard-plus-native", root: true, native: true, legacy: false }, + { shape: "standard-plus-inert-legacy", root: true, native: false, legacy: true }, + ])("discovers $shape bundles as one candidate", async ({ root, native, legacy }) => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-hybrid-")); + try { + if (root) { + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "review-tools", + })); + } + if (native) { + await mkdir(join(sourceRoot, ".claude-plugin"), { recursive: true }); + await writeFile(join(sourceRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "review-tools", + commands: "./commands", + })); + } + if (legacy || native) {await mkdir(join(sourceRoot, "commands"), { recursive: true });} + + const candidates = await discoverPlugins(sourceRoot); + + expect(candidates).toHaveLength(1); + expect(candidates[0]).toMatchObject({ name: "review-tools", path: "" }); + expect(candidates[0]!.nativeSource).toBe(root ? undefined : "claude"); + expect(Boolean(candidates[0]!.authoredNativeInterfaces.claude)).toBe(native); + expect(candidates[0]!.manifest).toMatchObject({ name: "review-tools" }); + if (root) {expect(candidates[0]!.manifest).toHaveProperty("$schema", AGENT_PLUGIN_SCHEMA);} + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it("keeps the portable identity authoritative in hybrid bundles", 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({ + name: "native-name", + })); + + await expect(discoverPlugins(sourceRoot)).rejects.toThrow( + 'Authored Claude plugin manifest name "native-name" does not match 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("warns when portable and native metadata differ without changing identity", 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, ".codex-plugin", "plugin.json"), JSON.stringify({ + name: "review-tools", + version: "1.0.0", + author: { name: "Native author" }, + })); + const resolved = await resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + + expect(preparePluginForTargets(resolved.plugin, ["codex"]).compatibilityWarnings).toContain( + 'Plugin "review-tools" has differing portable and Codex metadata; both are preserved and the authored Codex manifest governs Codex.', + ); } 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 a9ff120a..15b1e553 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, @@ -24,8 +26,12 @@ import { type LegacyPluginManifest, type PluginManifest, } from "./schema.js"; -import { isManagedJsonFile } from "./managed-files.js"; -import type { NativePluginSource, PluginDeclaration } from "./types.js"; +import { selectedAgentIds } from "./targets.js"; +import type { + AuthoredNativePluginInterfaces, + NativePluginSource, + PluginDeclaration, +} from "./types.js"; // Owns plugin source discovery and installation into the canonical project tree. // Resolved sources are never allowed to live inside the same project's @@ -64,6 +70,8 @@ export interface PluginCandidate { dir: string; path: string; manifest: PluginManifest; + authoredNativeInterfaces: AuthoredNativePluginInterfaces; + legacyRoots: string[]; nativeSource?: NativePluginSource; origin: PluginCandidateOrigin; } @@ -93,15 +101,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_AUTHORED_INTERFACES_MARKER = ".dotagents-authored-interfaces"; const DOTAGENTS_NATIVE_SOURCE_MARKER = ".dotagents-native-source"; let tempInstallCounter = 0; @@ -197,6 +232,7 @@ export async function installPluginBundle( const staged = { ...resolved.plugin, pluginDir: tempDir }; await ensureCanonicalManifest(staged); await writeNativeSourceMarker(staged); + await writeAuthoredInterfacesMarker(staged); await writeManagedMarker(tempDir); if (existsSync(destDir)) { @@ -225,6 +261,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 }> = []; @@ -241,21 +278,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}` }); @@ -396,11 +433,21 @@ async function resolvePluginCandidate( ): Promise { if (config.path) { const dir = await resolveInside(sourceDir, config.path, "Plugin path"); - 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( @@ -419,6 +466,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; @@ -485,6 +537,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)), @@ -707,10 +760,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 && typeof manifest["name"] === "string" ? String(manifest["name"]) @@ -726,11 +778,23 @@ async function loadPluginCandidate( "Plugin names must be 1-64 lowercase letters, numbers, hyphens, or dots, have alphanumeric ends, and not contain '--' or '..'.", ); } + try { + assertNativeInterfaceNames(name, loaded.authoredNativeInterfaces, relativePath(sourceRoot, pluginDir) || "."); + } catch (err) { + throw new NamedPluginManifestError( + err instanceof Error ? err.message : String(err), + name, + ); + } return { name, 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, }; @@ -746,48 +810,114 @@ 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 authoredSources = provenance?.authoredSources; + 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, + authoredSources, + installedNativeSource, + ), + 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, + 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); - } - 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.`, - ); + authoredSources: ReadonlySet | null | undefined, + installedNativeSource: NativePluginSource | undefined, +): Promise { + const interfaces: AuthoredNativePluginInterfaces = {}; + for (const candidate of NATIVE_MANIFEST_PATHS) { + const filePath = join(pluginDir, candidate.path); + if (!existsSync(filePath)) { + if (authoredSources?.has(candidate.source)) { + throw new Error( + `Installed plugin records an authored ${nativeDisplayName(candidate.source)} interface, but ${candidate.path} is missing. Reinstall the plugin.`, + ); + } + continue; + } + if (authoredSources && !authoredSources.has(candidate.source)) {continue;} + if (authoredSources === null && installedNativeSource !== candidate.source) {continue;} + try { + interfaces[candidate.source] = { + path: candidate.path, + manifest: parsePluginManifest(await readJson(filePath), filePath), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + interfaces[candidate.source] = { + path: candidate.path, + error: message.replaceAll(filePath, candidate.path), + }; + } } + return interfaces; +} + +class NamedPluginManifestError extends Error { + constructor(message: string, readonly pluginName?: string) { + super(message); + } +} + +function pluginManifestError(err: unknown, value: unknown): Error { + const message = err instanceof Error ? err.message : String(err); + const name = isSerializedObject(value) && typeof value["name"] === "string" + ? value["name"] + : undefined; + return new NamedPluginManifestError(message, name); } async function removeSourceOwnershipMarkers(dir: string): Promise { @@ -796,6 +926,7 @@ async function removeSourceOwnershipMarkers(dir: string): Promise { const filePath = join(dir, entry.name); if ( entry.name === DOTAGENTS_MANAGED_PLUGIN_MARKER || + entry.name === DOTAGENTS_AUTHORED_INTERFACES_MARKER || entry.name === DOTAGENTS_NATIVE_SOURCE_MARKER || entry.name.endsWith(".dotagents-managed") ) { @@ -825,6 +956,69 @@ async function writeNativeSourceMarker(plugin: PluginDeclaration): Promise } } +async function writeAuthoredInterfacesMarker(plugin: PluginDeclaration): Promise { + const filePath = join(plugin.pluginDir, DOTAGENTS_AUTHORED_INTERFACES_MARKER); + const sources = nativeSources().filter( + (source) => plugin.authoredNativeInterfaces?.[source] !== undefined, + ); + if (sources.length === 0) { + await rm(filePath, { force: true }); + return; + } + await writeFile(filePath, `${sources.join("\n")}\n`, "utf-8"); +} + +async function readAuthoredInterfaceSources( + pluginDir: string, +): Promise | null> { + let content: string; + try { + content = await readFile(join(pluginDir, DOTAGENTS_AUTHORED_INTERFACES_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) { + if (!nativeSources().includes(value as NativePluginSource) || sources.has(value as NativePluginSource)) { + throw new Error(`Invalid authored plugin interface provenance: ${value || ""}`); + } + sources.add(value as NativePluginSource); + } + if (sources.size === 0) { + throw new Error("Invalid authored plugin interface provenance: empty marker"); + } + return sources; +} + +async function readInstalledPluginProvenance(pluginDir: string): Promise<{ + authoredSources: Set | null; + nativeSource: NativePluginSource | undefined; +}> { + const authoredSources = await readAuthoredInterfaceSources(pluginDir); + const nativeSource = await readNativeSourceMarker(pluginDir); + if ( + authoredSources && nativeSource && + (authoredSources.size !== 1 || !authoredSources.has(nativeSource)) + ) { + throw new Error("Installed plugin has conflicting native interface provenance. Reinstall the plugin."); + } + return { authoredSources, nativeSource }; +} + +/** Returns whether installed provenance reserves a native interface as source-authored. */ +export async function hasRecordedAuthoredPluginInterface( + pluginDir: string, + source: NativePluginSource, +): Promise { + const provenance = await readInstalledPluginProvenance(pluginDir); + return provenance.authoredSources + ? provenance.authoredSources.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(); @@ -845,11 +1039,134 @@ 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 ?? {}; + assertNativeInterfaceNames(plugin.name, interfaces, plugin.pluginDir); + const selectedTargets = new Set(selectedAgentIds(agentIds, plugin)); + for (const source of nativeSources()) { + const nativeInterface = interfaces[source]; + if (!nativeInterface?.error || !selectedTargets.has(source)) {continue;} + throw new Error( + `Invalid authored ${nativeDisplayName(source)} plugin interface 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, +): void { + for (const source of nativeSources()) { + const manifest = interfaces[source]?.manifest; + if (!manifest) {continue;} + const actual = manifest["name"]; + if (typeof actual === "string" && actual !== expected) { + throw new Error( + `Authored ${nativeDisplayName(source)} plugin manifest name "${actual}" does not match portable plugin name "${expected}" in ${context}.`, + ); + } + } +} + +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 validSources = sources.filter((source) => interfaces[source]?.manifest !== undefined); + if (sources.length > 0) { + warnings.push( + `Plugin "${name}" is a hybrid compatibility bundle: portable components remain shared; authored ${validSources.map(nativeDisplayName).join(", ") || "native"} interfaces govern only their matching clients.`, + ); + } 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 && + !validSources.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 authored native interface.`, + ); + } + for (const source of sources) { + const nativeInterface = interfaces[source]!; + if (nativeInterface.error && !selected.has(source)) { + warnings.push( + `Plugin "${name}" has a malformed authored ${nativeDisplayName(source)} interface that was ignored because ${nativeDisplayName(source)} is not selected: ${nativeInterface.error}`, + ); + continue; + } + if (nativeInterface.manifest && metadataDiffers(manifest, nativeInterface.manifest)) { + warnings.push( + `Plugin "${name}" has differing portable and ${nativeDisplayName(source)} metadata; both are preserved and the authored ${nativeDisplayName(source)} manifest governs ${nativeDisplayName(source)}.`, + ); + } + } + 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/types.ts b/packages/dotagents/src/plugins/types.ts index 5e3dc387..79e0c427 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; manifest: PluginManifest; error?: never } + | { path: string; 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..f0223893 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 authoritative for shared data and generated adapters; each authored native manifest remains byte-for-byte authoritative only for its matching selected client. Dotagents generates a native manifest only when that client has no authored interface. 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..da75cb06 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 @@ -317,10 +317,14 @@ 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. Record authored `.claude-plugin`, `.cursor-plugin`, and `.codex-plugin` + interfaces separately; require every parsed interface to use the portable + name and validate a native interface fully when its client is selected. 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 complete bundle into `.agents/plugins//`. +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 +347,36 @@ 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. A selected client's authored native manifest governs only +that client and is preserved byte-for-byte; dotagents neither merges portable +fields into it nor translates its commands, agents, hooks, rules, MCP, or other +native behavior elsewhere. Targets without an authored interface receive the +normal adapter generated from the portable core. + +Parsed root and native manifests must agree on `name`. Metadata differences are +reported without rewriting either source. A malformed selected native interface +fails preflight, while a malformed unselected interface remains contained and +inert with a warning. An invalid standard root never falls back to native or +legacy parsing. OpenCode and Pi receive only supported portable projections; +Grok copies omit recognized native manifest directories and legacy component +roots. `install`, `sync`, `list`, and `doctor` report the same compatibility +diagnostics from the installed bundle. + +Canonical installs record source-authored native interface membership in the +reserved `.dotagents-authored-interfaces` file. Source-supplied ownership files +are removed before this marker is written. Installed rescans use that explicit +provenance instead of treating every unmarked native manifest as authored, so a +file created later retains the normal unmanaged-collision behavior. + ## Internal Normalization Adapters should consume one normalized structure rather than each reparsing @@ -356,6 +390,7 @@ interface NormalizedPlugin { skills: AgentSkill[]; mcpServers: McpDeclaration[]; extensions: Record>; + authoredNativeInterfaces: Partial>; nativeSource?: "claude" | "cursor" | "codex"; targets: string[]; } @@ -386,6 +421,11 @@ 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, an authored matching native manifest replaces the +generated-manifest step for that target only. 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 +542,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-authored-interfaces` to record + source-authored native interface membership. 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 +608,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 From 486d2805fccc3662ab35fa1428dae9970e38ec2c Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:26:15 -0700 Subject: [PATCH 2/5] fix(plugins): Keep portable bundles authoritative --- README.md | 2 +- docs/public/llms.txt | 2 +- docs/src/content/docs/cli.mdx | 17 ++- docs/src/content/docs/guide.mdx | 7 +- .../dotagents/src/cli/commands/doctor.test.ts | 4 +- .../src/cli/commands/install.test.ts | 50 +++++++- .../dotagents/src/cli/commands/list.test.ts | 6 +- .../src/plugins/runtime/manifests.ts | 2 +- .../src/plugins/runtime/writer.test.ts | 4 + .../dotagents/src/plugins/runtime/writer.ts | 10 +- packages/dotagents/src/plugins/store.test.ts | 13 ++- packages/dotagents/src/plugins/store.ts | 110 +++++++++++------- packages/dotagents/src/plugins/targets.ts | 27 ++++- packages/dotagents/src/plugins/types.ts | 4 +- specs/SPEC.md | 2 +- specs/plugins.md | 79 +++++++------ 16 files changed, 230 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 4e0b7942..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. A valid standard root may also coexist with authored native manifests as a hybrid compatibility bundle: portable data remains authoritative across clients, each authored manifest is preserved byte-for-byte for its matching client, and 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. +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 ba66a3df..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. 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 authoritative for shared data and generated adapters, while each authored manifest remains byte-for-byte authoritative only for its matching selected client. Native commands, agents, hooks, MCP, and other resources are not guessed or cross-translated. Malformed selected native interfaces fail preflight; malformed unselected interfaces remain inert with warnings. Invalid standard roots never downgrade to native or generalized legacy parsing. +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 | |-------|------|----------|-------------| diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index a1655162..1b68ae56 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -450,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 @@ -462,12 +462,17 @@ 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 preserved byte-for-byte and governs only its matching selected client. -Other clients receive adapters generated solely from the portable core. Root +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 interface blocks installation only when its client is -selected. `install`, `sync`, `list`, and `doctor` report compatibility warnings. +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 | | --- | --- | --- | --- | diff --git a/docs/src/content/docs/guide.mdx b/docs/src/content/docs/guide.mdx index 964d11b4..82658fa6 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -221,9 +221,10 @@ targets = ["claude", "cursor", "codex", "opencode"] ``` Portable Agent Plugins may coexist with authored Claude, Cursor, or Codex -manifests during migration. Dotagents preserves each authored manifest for its -matching client and projects only the portable skills and MCP into unrelated -targets. +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 a13deecc..421f5998 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { runDoctor } from "./doctor.js"; import { resolveScope } from "../../scope.js"; -import { DOTAGENTS_AUTHORED_INTERFACES_MARKER } from "../../plugins/store.js"; +import { DOTAGENTS_NATIVE_FALLBACKS_MARKER } from "../../plugins/store.js"; describe("runDoctor", () => { let tmpDir: string; @@ -184,7 +184,7 @@ source = "path:external-review-tools" name: "hybrid-tools", })); await writeFile(join(pluginDir, ".claude-plugin", "plugin.json"), '{"name":"hybrid-tools"}'); - await writeFile(join(pluginDir, DOTAGENTS_AUTHORED_INTERFACES_MARKER), "claude\n"); + await writeFile(join(pluginDir, DOTAGENTS_NATIVE_FALLBACKS_MARKER), "claude\n"); await writeFile(join(projectRoot, "agents.toml"), `version = 1 agents = ["claude"] diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 446bbe4e..c6241c65 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) => `--- @@ -517,7 +520,7 @@ ${pluginPath ? `path = "${pluginPath}"\n` : ""}`); expect(result.pluginWarnings).toEqual([{ agent: "plugin", name: "hybrid-tools", - message: 'Plugin "hybrid-tools" is a hybrid compatibility bundle: portable components remain shared; authored Claude, Codex interfaces govern only their matching clients.', + 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); @@ -534,6 +537,41 @@ ${pluginPath ? `path = "${pluginPath}"\n` : ""}`); }, ); + 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"); @@ -571,7 +609,7 @@ source = "path:plugin-source/invalid-tools" `); const scope = resolveScope("project", projectRoot); - await expect(runInstall({ scope })).rejects.toThrow("Invalid authored Claude plugin interface"); + 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); @@ -601,8 +639,8 @@ targets = ["claude"] const installedDir = join(projectRoot, ".agents", "plugins", "hybrid-tools"); expect(result.pluginWarnings.map((warning) => warning.message)).toEqual([ - 'Plugin "hybrid-tools" is a hybrid compatibility bundle: portable components remain shared; authored native interfaces govern only their matching clients.', - expect.stringContaining("malformed authored Claude interface that was ignored"), + '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); @@ -678,7 +716,7 @@ source = "path:plugin-source/hybrid-tools" await rm(installedManifest); const missingAuthored = await runSync({ scope }); expect(missingAuthored.issues.some((issue) => - issue.message.includes("records an authored Claude interface") && + issue.message.includes("records a Claude native fallback") && issue.message.includes("Reinstall the plugin") )).toBe(true); expect(existsSync(installedManifest)).toBe(false); diff --git a/packages/dotagents/src/cli/commands/list.test.ts b/packages/dotagents/src/cli/commands/list.test.ts index d5acec70..48cd88cf 100644 --- a/packages/dotagents/src/cli/commands/list.test.ts +++ b/packages/dotagents/src/cli/commands/list.test.ts @@ -5,7 +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_AUTHORED_INTERFACES_MARKER } from "../../plugins/store.js"; +import { DOTAGENTS_NATIVE_FALLBACKS_MARKER } from "../../plugins/store.js"; const SKILL_MD = (name: string) => `--- name: ${name} @@ -219,7 +219,7 @@ source = "org/unlocked" name: "hybrid-tools", })); await writeFile(join(pluginDir, ".claude-plugin", "plugin.json"), '{"name":"hybrid-tools"}'); - await writeFile(join(pluginDir, DOTAGENTS_AUTHORED_INTERFACES_MARKER), "claude\n"); + await writeFile(join(pluginDir, DOTAGENTS_NATIVE_FALLBACKS_MARKER), "claude\n"); await writeFile( join(projectRoot, "agents.toml"), `version = 1 @@ -255,7 +255,7 @@ source = "org/hybrid-tools" source: "org/hybrid-tools", status: "ok", warnings: [ - 'Plugin "hybrid-tools" is a hybrid compatibility bundle: portable components remain shared; authored Claude interfaces govern only their matching clients.', + '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/plugins/runtime/manifests.ts b/packages/dotagents/src/plugins/runtime/manifests.ts index 74826896..f9917df8 100644 --- a/packages/dotagents/src/plugins/runtime/manifests.ts +++ b/packages/dotagents/src/plugins/runtime/manifests.ts @@ -65,7 +65,7 @@ export async function writePluginManifests( if (!agents.includes(spec.agent)) {continue;} const filePath = join(plugin.pluginDir, spec.dir, "plugin.json"); const authored = plugin.nativeSource === spec.agent || - plugin.authoredNativeInterfaces?.[spec.agent]?.manifest !== undefined; + plugin.authoredNativeInterfaces?.[spec.agent]?.fallback === true; if (existsSync(filePath) && authored) {continue;} if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { warnings.push({ diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index 5f3f3a68..23970f6a 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -242,6 +242,7 @@ describe("plugin writer", () => { alpha.authoredNativeInterfaces = { [target]: { path: `${manifestDir}/plugin.json`, + fallback: true, manifest: { name: "alpha-tools", description: "Native description", @@ -277,6 +278,7 @@ describe("plugin writer", () => { alpha.authoredNativeInterfaces = { claude: { path: ".claude-plugin/plugin.json", + fallback: true, manifest: { name: "alpha-tools", commands: "./commands", @@ -310,6 +312,7 @@ describe("plugin writer", () => { alpha.authoredNativeInterfaces = { claude: { path: ".claude-plugin/plugin.json", + fallback: true, manifest: { name: "alpha-tools", agents: "./agents", @@ -351,6 +354,7 @@ describe("plugin writer", () => { alpha.authoredNativeInterfaces = { claude: { path: ".claude-plugin/plugin.json", + fallback: true, manifest: { name: "alpha-tools" }, }, }; diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index b027510a..5fd79c40 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -4,8 +4,8 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } import { loadSkillMd, type SerializedObject } from "@sentry/dotagents-lib"; import { AGENT_PLUGIN_SCHEMA, isStandardPluginManifest, parsePluginMcp, type LegacyPluginManifest } from "../schema.js"; import { - DOTAGENTS_AUTHORED_INTERFACES_MARKER, - hasRecordedAuthoredPluginInterface, + DOTAGENTS_NATIVE_FALLBACKS_MARKER, + hasRecordedNativePluginFallback, HYBRID_LEGACY_ROOTS, } from "../store.js"; import type { PluginDeclaration } from "../types.js"; @@ -276,8 +276,8 @@ export async function prunePluginOutputs( const plugin = plugins.find((plugin) => plugin.name === entry.name); if ( plugin?.nativeSource === target.agent || - plugin?.authoredNativeInterfaces?.[target.agent] !== undefined || - !plugin && await hasRecordedAuthoredPluginInterface( + plugin?.authoredNativeInterfaces?.[target.agent]?.fallback === true || + !plugin && await hasRecordedNativePluginFallback( join(canonicalPluginDir, entry.name), target.agent, ) @@ -320,7 +320,7 @@ async function writeGrokProjection( ".claude-plugin", ".cursor-plugin", ".codex-plugin", - DOTAGENTS_AUTHORED_INTERFACES_MARKER, + DOTAGENTS_NATIVE_FALLBACKS_MARKER, ".dotagents-managed", ".dotagents-native-source", ]); diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index c785163c..cb27a3d7 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -392,10 +392,11 @@ describe("plugin store", () => { })); await writeFile(join(sourceRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "native-name", + commands: "./commands", })); await expect(discoverPlugins(sourceRoot)).rejects.toThrow( - 'Authored Claude plugin manifest name "native-name" does not match portable plugin name "portable-name"', + 'Claude native fallback manifest name "native-name" does not match portable plugin name "portable-name"', ); } finally { await rm(sourceRoot, { recursive: true, force: true }); @@ -456,7 +457,7 @@ describe("plugin store", () => { } }); - it("warns when portable and native metadata differ without changing identity", async () => { + 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"); @@ -477,9 +478,11 @@ describe("plugin store", () => { { stateDir: join(projectRoot, "state"), projectRoot }, ); - expect(preparePluginForTargets(resolved.plugin, ["codex"]).compatibilityWarnings).toContain( - 'Plugin "review-tools" has differing portable and Codex metadata; both are preserved and the authored Codex manifest governs Codex.', - ); + 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.', + ]); } 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 15b1e553..70eaff26 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -26,7 +26,7 @@ import { type LegacyPluginManifest, type PluginManifest, } from "./schema.js"; -import { selectedAgentIds } from "./targets.js"; +import { nativeInterfaceNeedsFallback, selectedAgentIds } from "./targets.js"; import type { AuthoredNativePluginInterfaces, NativePluginSource, @@ -136,7 +136,7 @@ export const HYBRID_LEGACY_ROOTS = [ ] as const; export const DOTAGENTS_MANAGED_PLUGIN_MARKER = ".dotagents-managed"; -export const DOTAGENTS_AUTHORED_INTERFACES_MARKER = ".dotagents-authored-interfaces"; +export const DOTAGENTS_NATIVE_FALLBACKS_MARKER = ".dotagents-native-fallbacks"; const DOTAGENTS_NATIVE_SOURCE_MARKER = ".dotagents-native-source"; let tempInstallCounter = 0; @@ -230,9 +230,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 writeAuthoredInterfacesMarker(staged); + await writeNativeFallbacksMarker(staged); await writeManagedMarker(tempDir); if (existsSync(destDir)) { @@ -821,7 +822,7 @@ async function loadPluginInterfaces( const provenance = installed ? await readInstalledPluginProvenance(pluginDir) : undefined; - const authoredSources = provenance?.authoredSources; + const fallbackSources = provenance?.fallbackSources; const installedNativeSource = provenance?.nativeSource; const rootPath = join(pluginDir, "plugin.json"); if (existsSync(rootPath)) { @@ -836,7 +837,7 @@ async function loadPluginInterfaces( manifest, authoredNativeInterfaces: await loadAuthoredNativeInterfaces( pluginDir, - authoredSources, + fallbackSources, installedNativeSource, ), nativeSource: installedNativeSource, @@ -860,6 +861,7 @@ async function loadPluginInterfaces( if (candidate.nativeSource) { authoredNativeInterfaces[candidate.nativeSource] = { path: candidate.path, + fallback: true, manifest, }; } @@ -874,31 +876,38 @@ async function loadPluginInterfaces( async function loadAuthoredNativeInterfaces( pluginDir: string, - authoredSources: ReadonlySet | null | undefined, + fallbackSources: ReadonlySet | null | undefined, installedNativeSource: NativePluginSource | undefined, ): Promise { const interfaces: AuthoredNativePluginInterfaces = {}; for (const candidate of NATIVE_MANIFEST_PATHS) { const filePath = join(pluginDir, candidate.path); if (!existsSync(filePath)) { - if (authoredSources?.has(candidate.source)) { + if (fallbackSources?.has(candidate.source)) { throw new Error( - `Installed plugin records an authored ${nativeDisplayName(candidate.source)} interface, but ${candidate.path} is missing. Reinstall the plugin.`, + `Installed plugin records a ${nativeDisplayName(candidate.source)} native fallback, but ${candidate.path} is missing. Reinstall the plugin.`, ); } continue; } - if (authoredSources && !authoredSources.has(candidate.source)) {continue;} - if (authoredSources === null && installedNativeSource !== candidate.source) {continue;} + if (fallbackSources && !fallbackSources.has(candidate.source)) {continue;} + if (fallbackSources === null && installedNativeSource !== candidate.source) {continue;} + let fallback = fallbackSources !== undefined; try { + const value = await readJson(filePath); + if (fallbackSources === undefined) { + fallback = nativeInterfaceNeedsFallback(candidate.source, value); + } interfaces[candidate.source] = { path: candidate.path, - manifest: parsePluginManifest(await readJson(filePath), filePath), + fallback, + manifest: parsePluginManifest(value, filePath), }; } catch (err) { const message = err instanceof Error ? err.message : String(err); interfaces[candidate.source] = { path: candidate.path, + fallback, error: message.replaceAll(filePath, candidate.path), }; } @@ -926,7 +935,7 @@ async function removeSourceOwnershipMarkers(dir: string): Promise { const filePath = join(dir, entry.name); if ( entry.name === DOTAGENTS_MANAGED_PLUGIN_MARKER || - entry.name === DOTAGENTS_AUTHORED_INTERFACES_MARKER || + entry.name === DOTAGENTS_NATIVE_FALLBACKS_MARKER || entry.name === DOTAGENTS_NATIVE_SOURCE_MARKER || entry.name.endsWith(".dotagents-managed") ) { @@ -937,6 +946,14 @@ 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 }); + } +} + async function ensureCanonicalManifest(plugin: PluginDeclaration): Promise { const filePath = join(plugin.pluginDir, "plugin.json"); if (existsSync(filePath)) {return;} @@ -956,10 +973,10 @@ async function writeNativeSourceMarker(plugin: PluginDeclaration): Promise } } -async function writeAuthoredInterfacesMarker(plugin: PluginDeclaration): Promise { - const filePath = join(plugin.pluginDir, DOTAGENTS_AUTHORED_INTERFACES_MARKER); +async function writeNativeFallbacksMarker(plugin: PluginDeclaration): Promise { + const filePath = join(plugin.pluginDir, DOTAGENTS_NATIVE_FALLBACKS_MARKER); const sources = nativeSources().filter( - (source) => plugin.authoredNativeInterfaces?.[source] !== undefined, + (source) => plugin.authoredNativeInterfaces?.[source]?.fallback, ); if (sources.length === 0) { await rm(filePath, { force: true }); @@ -968,12 +985,12 @@ async function writeAuthoredInterfacesMarker(plugin: PluginDeclaration): Promise await writeFile(filePath, `${sources.join("\n")}\n`, "utf-8"); } -async function readAuthoredInterfaceSources( +async function readNativeFallbackSources( pluginDir: string, ): Promise | null> { let content: string; try { - content = await readFile(join(pluginDir, DOTAGENTS_AUTHORED_INTERFACES_MARKER), "utf-8"); + content = await readFile(join(pluginDir, DOTAGENTS_NATIVE_FALLBACKS_MARKER), "utf-8"); } catch (err) { if (isNotFoundError(err)) {return null;} throw err; @@ -983,39 +1000,39 @@ async function readAuthoredInterfaceSources( const sources = new Set(); for (const value of values) { if (!nativeSources().includes(value as NativePluginSource) || sources.has(value as NativePluginSource)) { - throw new Error(`Invalid authored plugin interface provenance: ${value || ""}`); + throw new Error(`Invalid native fallback provenance: ${value || ""}`); } sources.add(value as NativePluginSource); } if (sources.size === 0) { - throw new Error("Invalid authored plugin interface provenance: empty marker"); + throw new Error("Invalid native fallback provenance: empty marker"); } return sources; } async function readInstalledPluginProvenance(pluginDir: string): Promise<{ - authoredSources: Set | null; + fallbackSources: Set | null; nativeSource: NativePluginSource | undefined; }> { - const authoredSources = await readAuthoredInterfaceSources(pluginDir); + const fallbackSources = await readNativeFallbackSources(pluginDir); const nativeSource = await readNativeSourceMarker(pluginDir); if ( - authoredSources && nativeSource && - (authoredSources.size !== 1 || !authoredSources.has(nativeSource)) + fallbackSources && nativeSource && + (fallbackSources.size !== 1 || !fallbackSources.has(nativeSource)) ) { throw new Error("Installed plugin has conflicting native interface provenance. Reinstall the plugin."); } - return { authoredSources, nativeSource }; + return { fallbackSources, nativeSource }; } -/** Returns whether installed provenance reserves a native interface as source-authored. */ -export async function hasRecordedAuthoredPluginInterface( +/** 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.authoredSources - ? provenance.authoredSources.has(source) + return provenance.fallbackSources + ? provenance.fallbackSources.has(source) : provenance.nativeSource === source; } @@ -1062,9 +1079,9 @@ export function preparePluginForTargets( const selectedTargets = new Set(selectedAgentIds(agentIds, plugin)); for (const source of nativeSources()) { const nativeInterface = interfaces[source]; - if (!nativeInterface?.error || !selectedTargets.has(source)) {continue;} + if (!nativeInterface?.fallback || !nativeInterface.error || !selectedTargets.has(source)) {continue;} throw new Error( - `Invalid authored ${nativeDisplayName(source)} plugin interface for "${plugin.name}" at ${nativeInterface.path}: ${nativeInterface.error}`, + `Invalid ${nativeDisplayName(source)} native fallback for "${plugin.name}" at ${nativeInterface.path}: ${nativeInterface.error}`, ); } const legacyRoots = isStandardPluginManifest(plugin.manifest) @@ -1088,12 +1105,14 @@ function assertNativeInterfaceNames( context: string, ): void { for (const source of nativeSources()) { - const manifest = interfaces[source]?.manifest; + const nativeInterface = interfaces[source]; + if (!nativeInterface?.fallback) {continue;} + const manifest = nativeInterface.manifest; if (!manifest) {continue;} const actual = manifest["name"]; if (typeof actual === "string" && actual !== expected) { throw new Error( - `Authored ${nativeDisplayName(source)} plugin manifest name "${actual}" does not match portable plugin name "${expected}" in ${context}.`, + `${nativeDisplayName(source)} native fallback manifest name "${actual}" does not match portable plugin name "${expected}" in ${context}.`, ); } } @@ -1109,10 +1128,17 @@ function compatibilityWarnings( if (!isStandardPluginManifest(manifest)) {return [];} const warnings: string[] = []; const sources = nativeSources().filter((source) => interfaces[source] !== undefined); - const validSources = sources.filter((source) => interfaces[source]?.manifest !== undefined); - if (sources.length > 0) { + 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: portable components remain shared; authored ${validSources.map(nativeDisplayName).join(", ") || "native"} interfaces govern only their matching clients.`, + `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( @@ -1123,23 +1149,29 @@ function compatibilityWarnings( if ( legacyRoots.length > 0 && sources.length > 0 && - !validSources.some((source) => selected.has(source)) + !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 authored native interface.`, + `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 authored ${nativeDisplayName(source)} interface that was ignored because ${nativeDisplayName(source)} is not selected: ${nativeInterface.error}`, + `Plugin "${name}" has a malformed ${nativeDisplayName(source)} native fallback that was ignored because ${nativeDisplayName(source)} is not selected: ${nativeInterface.error}`, ); continue; } if (nativeInterface.manifest && metadataDiffers(manifest, nativeInterface.manifest)) { warnings.push( - `Plugin "${name}" has differing portable and ${nativeDisplayName(source)} metadata; both are preserved and the authored ${nativeDisplayName(source)} manifest governs ${nativeDisplayName(source)}.`, + `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.`, ); } } diff --git a/packages/dotagents/src/plugins/targets.ts b/packages/dotagents/src/plugins/targets.ts index 76da1aaa..e3a7f123 100644 --- a/packages/dotagents/src/plugins/targets.ts +++ b/packages/dotagents/src/plugins/targets.ts @@ -1,10 +1,16 @@ -import type { PluginDeclaration } from "./types.js"; +import type { NativePluginSource, PluginDeclaration } from "./types.js"; import type { PluginWriteWarning } from "./runtime/types.js"; import { isStandardPluginManifest } from "./schema.js"; +import { isSerializedObject } from "@sentry/dotagents-lib"; 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: Record> = { + 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"]), +}; /** Returns agent IDs accepted in agents.toml only for plugin runtime output. */ export function allPluginOnlyAgentIds(): string[] { @@ -33,6 +39,25 @@ 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: unknown, +): boolean { + if (!isSerializedObject(value)) {return false;} + for (const [key, field] of Object.entries(value)) { + if (!GENERATED_NATIVE_FIELDS[source].has(key)) {return true;} + if (key === "skills" && !isConventionalSkillsReference(field)) {return true;} + } + return false; +} + +function isConventionalSkillsReference(value: unknown): boolean { + const values = Array.isArray(value) ? value : [value]; + return values.length === 1 && typeof values[0] === "string" && + 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 79e0c427..3a56d431 100644 --- a/packages/dotagents/src/plugins/types.ts +++ b/packages/dotagents/src/plugins/types.ts @@ -3,8 +3,8 @@ import type { PluginManifest } from "./schema.js"; export type NativePluginSource = "claude" | "cursor" | "codex"; export type AuthoredNativePluginInterface = - | { path: string; manifest: PluginManifest; error?: never } - | { path: string; manifest?: never; error: string }; + | { path: string; fallback: boolean; manifest: PluginManifest; error?: never } + | { path: string; fallback: boolean; manifest?: never; error: string }; export type AuthoredNativePluginInterfaces = Partial< Record diff --git a/specs/SPEC.md b/specs/SPEC.md index f0223893..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 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 authoritative for shared data and generated adapters; each authored native manifest remains byte-for-byte authoritative only for its matching selected client. Dotagents generates a native manifest only when that client has no authored interface. Source declarations, lock entries, marketplaces, target selection, and generated runtime files remain management concerns outside the portable bundle. +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. diff --git a/specs/plugins.md b/specs/plugins.md index da75cb06..fc322a3b 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -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,12 +318,14 @@ 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. Record authored `.claude-plugin`, `.cursor-plugin`, and `.codex-plugin` - interfaces separately; require every parsed interface to use the portable - name and validate a native interface fully when its client is selected. +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. Complete plugin preflight before copying bundles or writing the lockfile. -10. Copy the complete bundle into `.agents/plugins//`. +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. @@ -356,26 +359,35 @@ a valid standard root alongside authored `.claude-plugin`, `.cursor-plugin`, or This is a compatibility input, not an expanded portable format. The standard root remains authoritative for identity, portable metadata, skills, MCP, and -generated adapters. A selected client's authored native manifest governs only -that client and is preserved byte-for-byte; dotagents neither merges portable -fields into it nor translates its commands, agents, hooks, rules, MCP, or other -native behavior elsewhere. Targets without an authored interface receive the -normal adapter generated from the portable core. - -Parsed root and native manifests must agree on `name`. Metadata differences are -reported without rewriting either source. A malformed selected native interface -fails preflight, while a malformed unselected interface remains contained and -inert with a warning. An invalid standard root never falls back to native or -legacy parsing. OpenCode and Pi receive only supported portable projections; -Grok copies omit recognized native manifest directories and legacy component -roots. `install`, `sync`, `list`, and `doctor` report the same compatibility -diagnostics from the installed bundle. - -Canonical installs record source-authored native interface membership in the -reserved `.dotagents-authored-interfaces` file. Source-supplied ownership files -are removed before this marker is written. Installed rescans use that explicit -provenance instead of treating every unmarked native manifest as authored, so a -file created later retains the normal unmanaged-collision behavior. +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 @@ -421,10 +433,11 @@ 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, an authored matching native manifest replaces the -generated-manifest step for that target only. For Grok, hybrid compatibility -copies exclude `.claude-plugin`, `.cursor-plugin`, `.codex-plugin`, and known -legacy component roots while retaining portable files and ordinary assets. +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 @@ -542,8 +555,8 @@ 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. Canonical hybrid bundles use `.dotagents-authored-interfaces` to record - source-authored native interface membership. Legacy `metadata.managedBy` + 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. From 9cd35b7b07e662ec18e30f614bfec191e7aec3e4 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:52:15 -0700 Subject: [PATCH 3/5] fix(plugins): Harden hybrid fallback projection --- .../src/cli/commands/install.test.ts | 2 +- .../src/plugins/runtime/manifest-values.ts | 13 ++ .../src/plugins/runtime/manifests.ts | 37 ++--- .../src/plugins/runtime/writer.test.ts | 48 +++++- .../dotagents/src/plugins/runtime/writer.ts | 137 ++++++++++++++++-- packages/dotagents/src/plugins/store.test.ts | 96 ++++++++---- packages/dotagents/src/plugins/store.ts | 118 ++++++++++++--- packages/dotagents/src/plugins/targets.ts | 33 ++++- 8 files changed, 389 insertions(+), 95 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index b7c04c29..bb9bf2f8 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -652,7 +652,7 @@ source = "path:plugin-source/invalid-tools" $schema: AGENT_PLUGIN_SCHEMA, name: "hybrid-tools", })); - const malformedBytes = '{ "name": "hybrid-tools", "commands": "../outside" }\n'; + const malformedBytes = "{broken\n"; await writeFile(join(sourceDir, ".claude-plugin", "plugin.json"), malformedBytes); await writeFile(join(projectRoot, "agents.toml"), `version = 1 agents = ["codex"] 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 4583b501..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,9 +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"); - const authored = plugin.nativeSource === spec.agent || - plugin.authoredNativeInterfaces?.[spec.agent]?.fallback === true; - if (existsSync(filePath) && authored) {continue;} + if (existsSync(filePath) && hasAuthoredNativeInterface(plugin, spec.agent)) {continue;} if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { warnings.push({ agent: spec.agent, @@ -76,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, @@ -91,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)) { @@ -237,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; } @@ -278,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 3ef156c7..cc93f79d 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -5,7 +5,12 @@ 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, @@ -328,6 +333,23 @@ describe("plugin writer", () => { 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: { @@ -377,8 +399,26 @@ describe("plugin writer", () => { 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: { + TOOL: "${PLUGIN_ROOT}/bin/server", + 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: { @@ -393,8 +433,12 @@ describe("plugin writer", () => { 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", "commands", "agents", "hooks"]) { + for (const path of [".claude-plugin", "agents", "hooks"]) { expect(existsSync(join(projected, path))).toBe(false); } }); diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index 46ba8bf6..bba3a110 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -7,9 +7,16 @@ 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, @@ -87,16 +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 plugin of 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, plugins)) { + for (const warning of targetWarnings(agentIds, preparedPlugins)) { warnings.push(warning); } @@ -109,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++; } } @@ -118,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 ?? [], @@ -276,12 +291,11 @@ export async function prunePluginOutputs( if (!entry.isDirectory() || desired.has(entry.name)) {continue;} const installedPlugin = plugins.find((plugin) => plugin.name === entry.name); if ( - installedPlugin?.nativeSource === target.agent || - installedPlugin?.authoredNativeInterfaces?.[target.agent]?.fallback === true || - !installedPlugin && await hasRecordedNativePluginFallback( + (installedPlugin && hasAuthoredNativeInterface(installedPlugin, target.agent)) || + (!installedPlugin && await hasRecordedNativePluginFallback( join(canonicalPluginDir, entry.name), target.agent, - ) + )) ) {continue;} for (const fileName of ["plugin.json", "mcp.json"]) { const path = join(canonicalPluginDir, entry.name, target.dir, fileName); @@ -302,6 +316,7 @@ async function writeGrokProjection( layout: PluginRuntimeLayout, plugin: PluginDeclaration, warnings: PluginWriteWarning[], + standardMcp: LoadedStandardMcp, ): Promise { const dest = join(layout.grokPluginsDir, plugin.name); if (existsSync(dest)) { @@ -325,6 +340,12 @@ async function writeGrokProjection( ".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);} } @@ -346,7 +367,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) { @@ -362,6 +385,98 @@ 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()) {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 6a36b3be..3186490d 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -13,7 +13,7 @@ import { resolvePlugin, type ResolvedPlugin, } from "./store.js"; -import { AGENT_PLUGIN_SCHEMA } from "./schema.js"; +import { AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA } from "./schema.js"; async function removeWithBackupFailure( path: Parameters[0], @@ -111,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 { @@ -128,7 +128,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("missing plugin.json. Reinstall the plugin"); } finally { @@ -330,43 +330,36 @@ describe("plugin store", () => { } }); - it.each([ - { layout: "standard-only", root: true, native: false, legacy: false }, - { layout: "native-only", root: false, native: true, legacy: false }, - { layout: "standard-plus-native", root: true, native: true, legacy: false }, - { layout: "standard-plus-inert-legacy", root: true, native: false, legacy: true }, - ])("discovers $layout bundles as one candidate", async ({ root, native, legacy }) => { + it("discovers a standard-plus-native bundle as one candidate", async () => { const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-hybrid-")); try { - if (root) { - await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ - $schema: AGENT_PLUGIN_SCHEMA, - name: "review-tools", - })); - } - if (native) { - await mkdir(join(sourceRoot, ".claude-plugin"), { recursive: true }); - await writeFile(join(sourceRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ - name: "review-tools", - commands: "./commands", - })); - } - if (legacy || native) {await mkdir(join(sourceRoot, "commands"), { recursive: true });} + 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).toBe(root ? undefined : "claude"); - expect(Boolean(candidates[0]!.authoredNativeInterfaces.claude)).toBe(native); - expect(candidates[0]!.manifest).toMatchObject({ name: "review-tools" }); - if (root) {expect(candidates[0]!.manifest).toHaveProperty("$schema", AGENT_PLUGIN_SCHEMA);} + 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("keeps the portable identity authoritative in hybrid bundles", async () => { + 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 }); @@ -375,12 +368,18 @@ describe("plugin store", () => { name: "portable-name", })); await writeFile(join(sourceRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ - name: "native-name", commands: "./commands", })); - await expect(discoverPlugins(sourceRoot)).rejects.toThrow( - 'Claude native fallback manifest name "native-name" does not match portable plugin name "portable-name"', + 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 }); @@ -452,10 +451,33 @@ describe("plugin store", () => { 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" }, @@ -467,6 +489,18 @@ describe("plugin store", () => { '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 2f51fc6a..760c03ce 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -16,19 +16,27 @@ 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 { nativeInterfaceNeedsFallback, selectedAgentIds } from "./targets.js"; +import { + generatedNativeMcpPath, + nativeInterfaceNeedsFallback, + selectedAgentIds, +} from "./targets.js"; +import { codexPluginInterface } from "./runtime/manifest-values.js"; import type { AuthoredNativePluginInterfaces, NativePluginSource, @@ -277,7 +285,7 @@ export async function loadInstalledPlugins( pluginsDir: string, configs: PluginConfig[], installCommand: string, - agentIds: string[] = [], + agentIds: string[], ): Promise<{ plugins: PluginDeclaration[]; issues: Array<{ name: string; issue: string }> }> { const plugins: PluginDeclaration[] = []; const issues: Array<{ name: string; issue: string }> = []; @@ -797,14 +805,6 @@ async function loadPluginCandidate( "Plugin names must be 1-64 lowercase letters, numbers, hyphens, or dots, have alphanumeric ends, and not contain '--' or '..'.", ); } - try { - assertNativeInterfaceNames(name, loaded.authoredNativeInterfaces, relativePath(sourceRoot, pluginDir) || "."); - } catch (err) { - throw new NamedPluginManifestError( - err instanceof Error ? err.message : String(err), - name, - ); - } return { name, dir: pluginDir, @@ -857,6 +857,7 @@ async function loadPluginInterfaces( pluginDir, fallbackSources, installedNativeSource, + manifest, ), nativeSource: installedNativeSource, }; @@ -896,8 +897,14 @@ async function loadAuthoredNativeInterfaces( pluginDir: string, 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)) { @@ -910,16 +917,30 @@ async function loadAuthoredNativeInterfaces( } if (fallbackSources && !fallbackSources.has(candidate.source)) {continue;} if (fallbackSources === null && installedNativeSource !== candidate.source) {continue;} - let fallback = fallbackSources !== undefined; + let fallback = true; try { const value = await readJson(filePath); + const manifest = parsePluginManifest(value, filePath); if (fallbackSources === undefined) { - fallback = nativeInterfaceNeedsFallback(candidate.source, value); + 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: parsePluginManifest(value, filePath), + manifest, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -933,6 +954,36 @@ async function loadAuthoredNativeInterfaces( 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); @@ -969,6 +1020,11 @@ async function removeRedundantNativeInterfaces(plugin: PluginDeclaration): Promi 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 }); + } } } @@ -1094,8 +1150,10 @@ export function preparePluginForTargets( agentIds: string[], ): PluginDeclaration { const interfaces = plugin.authoredNativeInterfaces ?? {}; - assertNativeInterfaceNames(plugin.name, interfaces, plugin.pluginDir); 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;} @@ -1122,21 +1180,32 @@ 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 actual = manifest["name"]; - if (isString(actual) && actual !== expected) { - throw new Error( - `${nativeDisplayName(source)} native fallback manifest name "${actual}" does not match portable plugin name "${expected}" in ${context}.`, - ); - } + 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, @@ -1188,6 +1257,15 @@ function compatibilityWarnings( ); 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.`, diff --git a/packages/dotagents/src/plugins/targets.ts b/packages/dotagents/src/plugins/targets.ts index 1e8c34e7..253d3f1b 100644 --- a/packages/dotagents/src/plugins/targets.ts +++ b/packages/dotagents/src/plugins/targets.ts @@ -1,7 +1,8 @@ +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 { isSerializedObject, type SerializedValue } from "@sentry/dotagents-lib"; +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"]; @@ -44,15 +45,41 @@ export function selectedAgentIds( export function nativeInterfaceNeedsFallback( source: NativePluginSource, value: SerializedValue, + reproducibleFields: SerializedObject = {}, ): boolean { - if (!isSerializedObject(value)) {return false;} + 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]) && From 0306bd666ca923f0d464eb9868721a87b2f52a6f Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:59:50 -0700 Subject: [PATCH 4/5] fix(plugins): Preserve outputs with corrupt provenance --- .../src/plugins/runtime/writer.test.ts | 26 ++++++++++++++ .../dotagents/src/plugins/runtime/writer.ts | 36 ++++++++++++++----- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index cc93f79d..365e63ca 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -14,6 +14,7 @@ import { import { prunePluginOutputs, projectedPiSkillNames, + reconcilePluginOutputs, verifyPluginOutputs, writePluginOutputs, } from "./writer.js"; @@ -1342,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 bba3a110..c101e75e 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -152,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. */ @@ -236,6 +240,7 @@ export async function prunePluginOutputs( agentIds: string[], plugins: PluginDeclaration[], root: PluginRuntimeRoot, + warnings: PluginWriteWarning[] = [], ): Promise { const layout = normalizePluginRuntimeLayout(root); const pruned: string[] = []; @@ -281,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 @@ -290,13 +296,25 @@ 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)) || - (!installedPlugin && await hasRecordedNativePluginFallback( - join(canonicalPluginDir, entry.name), - target.agent, - )) - ) {continue;} + 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;} From 0e92d0cb0690350dff3349871366cf380c43c2bd Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:13:02 -0700 Subject: [PATCH 5/5] fix(plugins): Copy Grok MCP directory assets --- packages/dotagents/src/plugins/runtime/writer.test.ts | 2 +- packages/dotagents/src/plugins/runtime/writer.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index 365e63ca..2a47e059 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -413,7 +413,7 @@ describe("plugin writer", () => { type: "stdio", command: "node", env: { - TOOL: "${PLUGIN_ROOT}/bin/server", + PATH: "${PLUGIN_ROOT}/bin:/usr/bin", NATIVE_LIKE: "${PLUGIN_ROOT}/.claude-plugin/tool", }, }, diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index c101e75e..b56adf29 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -482,6 +482,11 @@ async function visitPortableMcpAsset( 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;}