From 39f2ef41dfd46e2621266f9a507dba2f53f104fd Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 30 Jun 2026 08:36:27 -0700 Subject: [PATCH 01/35] feat(plugins): Add universal plugin support Add a canonical .agents/plugins plugin model based on the Codex manifest shape and generate deterministic runtime outputs for supported agent tools. Wire plugin declarations through config loading, lockfiles, install, sync, list, doctor, gitignore handling, and documentation. Reject same-project plugin installs that would overwrite their own source and cover the new behavior with regression tests. Co-Authored-By: Codex # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # interactive rebase in progress; onto 0d50a26 # Last command done (1 command done): # pick 4b509bd feat(plugins): Add universal plugin support # Next commands to do (21 remaining commands): # pick a5875a4 feat(plugins): Add universal plugin support # pick 0874c0a fix(plugins): Keep marketplace metadata out of manifests # You are currently rebasing branch 'codex/add-plugin-support' on '0d50a26'. # # Changes to be committed: # modified: README.md # modified: docs/public/llms.txt # new file: packages/dotagents/src/agents/plugin-schema.test.ts # new file: packages/dotagents/src/agents/plugin-schema.ts # new file: packages/dotagents/src/agents/plugin-store.ts # new file: packages/dotagents/src/agents/plugin-writer.test.ts # new file: packages/dotagents/src/agents/plugin-writer.ts # modified: packages/dotagents/src/cli/commands/doctor.ts # modified: packages/dotagents/src/cli/commands/install.test.ts # modified: packages/dotagents/src/cli/commands/install.ts # modified: packages/dotagents/src/cli/commands/install/agent-runtime.ts # modified: packages/dotagents/src/cli/commands/install/gitignore.ts # new file: packages/dotagents/src/cli/commands/install/plugins.ts # modified: packages/dotagents/src/cli/commands/list.test.ts # modified: packages/dotagents/src/cli/commands/list.ts # modified: packages/dotagents/src/cli/commands/mcp.test.ts # modified: packages/dotagents/src/cli/commands/remove.test.ts # modified: packages/dotagents/src/cli/commands/remove.ts # modified: packages/dotagents/src/cli/commands/sync.test.ts # modified: packages/dotagents/src/cli/commands/sync.ts # modified: packages/dotagents/src/cli/commands/trust.test.ts # modified: packages/dotagents/src/cli/ensure-user-scope.test.ts # modified: packages/dotagents/src/cli/index.ts # modified: packages/dotagents/src/config/loader.test.ts # modified: packages/dotagents/src/config/loader.ts # modified: packages/dotagents/src/config/schema.test.ts # modified: packages/dotagents/src/config/schema.ts # modified: packages/dotagents/src/gitignore/writer.test.ts # modified: packages/dotagents/src/gitignore/writer.ts # modified: packages/dotagents/src/lockfile/schema.test.ts # modified: packages/dotagents/src/lockfile/schema.ts # modified: packages/dotagents/src/lockfile/writer.test.ts # modified: packages/dotagents/src/lockfile/writer.ts # modified: packages/dotagents/src/scope.ts # modified: packages/dotagents/src/targets/registry.ts # modified: specs/SPEC.md # new file: specs/plugins.md # --- README.md | 27 +- docs/public/llms.txt | 73 +- .../src/agents/plugin-schema.test.ts | 115 +++ .../dotagents/src/agents/plugin-schema.ts | 127 ++++ packages/dotagents/src/agents/plugin-store.ts | 482 +++++++++++++ .../src/agents/plugin-writer.test.ts | 236 +++++++ .../dotagents/src/agents/plugin-writer.ts | 663 ++++++++++++++++++ packages/dotagents/src/cli/commands/doctor.ts | 40 +- .../src/cli/commands/install.test.ts | 243 +++++++ .../dotagents/src/cli/commands/install.ts | 21 +- .../src/cli/commands/install/agent-runtime.ts | 17 + .../src/cli/commands/install/gitignore.ts | 17 + .../src/cli/commands/install/plugins.ts | 113 +++ .../dotagents/src/cli/commands/list.test.ts | 79 ++- packages/dotagents/src/cli/commands/list.ts | 75 +- .../dotagents/src/cli/commands/mcp.test.ts | 1 + .../dotagents/src/cli/commands/remove.test.ts | 39 ++ packages/dotagents/src/cli/commands/remove.ts | 14 + .../dotagents/src/cli/commands/sync.test.ts | 145 ++++ packages/dotagents/src/cli/commands/sync.ts | 127 +++- .../dotagents/src/cli/commands/trust.test.ts | 1 + .../src/cli/ensure-user-scope.test.ts | 2 + packages/dotagents/src/cli/index.ts | 2 +- packages/dotagents/src/config/loader.test.ts | 70 ++ packages/dotagents/src/config/loader.ts | 45 +- packages/dotagents/src/config/schema.test.ts | 56 ++ packages/dotagents/src/config/schema.ts | 22 + .../dotagents/src/gitignore/writer.test.ts | 9 + packages/dotagents/src/gitignore/writer.ts | 7 + .../dotagents/src/lockfile/schema.test.ts | 22 + packages/dotagents/src/lockfile/schema.ts | 6 + .../dotagents/src/lockfile/writer.test.ts | 37 + packages/dotagents/src/lockfile/writer.ts | 11 +- packages/dotagents/src/scope.ts | 4 + packages/dotagents/src/targets/registry.ts | 10 + specs/SPEC.md | 116 ++- specs/plugins.md | 313 +++++++++ 37 files changed, 3316 insertions(+), 71 deletions(-) create mode 100644 packages/dotagents/src/agents/plugin-schema.test.ts create mode 100644 packages/dotagents/src/agents/plugin-schema.ts create mode 100644 packages/dotagents/src/agents/plugin-store.ts create mode 100644 packages/dotagents/src/agents/plugin-writer.test.ts create mode 100644 packages/dotagents/src/agents/plugin-writer.ts create mode 100644 packages/dotagents/src/cli/commands/install/plugins.ts create mode 100644 specs/plugins.md diff --git a/README.md b/README.md index 770456e..f0efaad 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # dotagents -Shared tooling for coding agents. Declare skills, MCP servers, hooks, and subagents in `agents.toml` — dotagents wires them into every agent tool on your team. +Shared tooling for coding agents. Declare skills, MCP servers, hooks, subagents, and plugins in `agents.toml` — dotagents wires them into every agent tool on your team. ## Why dotagents? **One source of truth.** Skills live in `.agents/skills/` and symlink into `.claude/skills/` or wherever your tools expect them. Cursor shares Claude-compatible skills. No copy-pasting between directories. -**One command to install.** `agents.toml` is committed, managed skills and canonical installed subagents under `.agents/` are gitignored. Collaborators run `dotagents install` to fetch or refresh local agent state. +**One command to install.** `agents.toml` is committed, managed skills, canonical installed subagents, and managed plugin bundles under `.agents/` are gitignored. Collaborators run `dotagents install` to fetch or refresh local agent state. **Shareable.** Skills are directories with a `SKILL.md`. Host them in any git repo, discover them automatically, install with one command. -**Multi-agent.** Configure Claude, Cursor, Codex, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, hooks, and subagents where supported. Pi reads `.agents/skills/` directly. +**Multi-agent.** Configure Claude, Cursor, Codex, Grok, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, hooks, subagents, and plugins where supported. Pi reads `.agents/skills/` directly. ## Quick Start @@ -31,9 +31,9 @@ npx @sentry/dotagents add getsentry/skills find-bugs code-review commit npx @sentry/dotagents add getsentry/skills --all ``` -This creates an `agents.toml` at your project root and an `agents.lock` tracking installed skills and subagents. +This creates an `agents.toml` at your project root and an `agents.lock` tracking installed skills, subagents, and plugins. -After cloning a project that already has `agents.toml`, run `install` to fetch skills and subagents. Run it again to refresh managed local state: +After cloning a project that already has `agents.toml`, run `install` to fetch skills, subagents, and plugins. Run it again to refresh managed local state: ```bash npx @sentry/dotagents install @@ -47,7 +47,7 @@ npx @sentry/dotagents install | `add [skills...]` | Add skill dependencies | | `remove [-y]` | Remove a skill or all skills from a source | | `install` | Install all dependencies from `agents.toml` | -| `list` | Show installed skills and their status | +| `list` | Show declared skills, plugins, and their status | | `sync` | Reconcile state offline: adopt local skills, prune stale managed ones, repair configs | | `mcp` | Manage MCP server declarations | | `trust` | Manage trusted sources | @@ -100,6 +100,7 @@ agents = ["claude", "cursor", "codex", "opencode"] | `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` | `.claude/agents/*.md` | | `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` | `.cursor/agents/*.md` | | `codex` | `.codex` | `.codex/config.toml` | -- | `.codex/agents/*.toml` | +| `grok` | `.grok` | -- | -- | -- | | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | -- | | `opencode` | `.opencode` | `opencode.json` | -- | `.opencode/agents/*.md` | @@ -127,11 +128,23 @@ Review the current diff and return findings with file references. dotagents can also import native runtime subagent files from `.claude/agents/`, `.cursor/agents/`, `.codex/agents/*.toml`, and `.opencode/agents/`. Input and matching-runtime output use the same native format: Markdown with YAML frontmatter for Claude, Cursor, and OpenCode; TOML for Codex. Claude and Codex identify agents by `name`, Cursor can derive `name` from the filename when omitted, and OpenCode uses the filename as the agent name. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Subagent declarations intentionally cover only dependency source and runtime targets, not universal model/tool/permission behavior. +Plugins are declared with `[[plugins]]` entries. dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.agents/plugins/marketplace.json`, `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.grok/plugins//`, and `.opencode/plugins/.js|ts` where supported: + +```toml +[[plugins]] +name = "review-tools" +source = "getsentry/agent-plugins" +path = "plugins/review-tools" +targets = ["claude", "cursor", "codex", "grok", "opencode"] +``` + +The canonical plugin format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a Codex-compatible marketplace baseline. Known input fields are validated, unknown manifest and marketplace extension fields are preserved, `targets` are limited to configured agents, and generated outputs are deterministic. dotagents rejects plugin sources that resolve to the same project's `.agents/plugins//` install destination, so same-repo plugins are never installed onto themselves. + [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively and needs no configuration. ## Documentation -For the full guide -- including MCP servers, hooks, subagents, trust policies, wildcard skills, user scope, and CI setup -- see the [documentation site](https://dotagents.sentry.dev). +For the full guide -- including MCP servers, hooks, subagents, plugins, trust policies, wildcard skills, user scope, and CI setup -- see the [documentation site](https://dotagents.sentry.dev). ## Contributing diff --git a/docs/public/llms.txt b/docs/public/llms.txt index de9261b..1eec063 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -2,7 +2,7 @@ > Shared tooling for coding agents -dotagents manages agent skills, MCP servers, hooks, and subagents declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code, Cursor, Codex, VS Code, and OpenCode are configured from a single source of truth. +dotagents manages agent skills, MCP servers, hooks, subagents, and plugins declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code, Cursor, Codex, Grok, VS Code, and OpenCode are configured from a single source of truth. Install: `npm install -g @sentry/dotagents` Run without installing: `npx @sentry/dotagents ` @@ -37,16 +37,16 @@ name = "find-bugs" source = "getsentry/skills" ``` -And a lockfile (`agents.lock`) tracking which skills and subagents are managed. Both `agents.lock` and `.agents/.gitignore` are automatically gitignored. +And a lockfile (`agents.lock`) tracking which skills, subagents, and plugins are managed. Both `agents.lock` and `.agents/.gitignore` are automatically gitignored. ## How It Works 1. Declare skill dependencies in `agents.toml` at the project root (or `~/.agents/agents.toml` for user scope) 2. `install` clones or refreshes sources, discovers skills by convention, and copies them into `.agents/skills/` -3. `agents.lock` tracks which skills and subagents are managed (gitignored automatically) -4. Managed skills and canonical installed subagents under `.agents/` are gitignored. Collaborators run `npx @sentry/dotagents install` after cloning. Custom skills in `.agents/skills/` are tracked by git normally. +3. `agents.lock` tracks which skills, subagents, and plugins are managed (gitignored automatically) +4. Managed skills, canonical installed subagents, and managed plugin bundles under `.agents/` are gitignored. Collaborators run `npx @sentry/dotagents install` after cloning. Custom skills in `.agents/skills/` and project-authored plugin source directories in `.agents/plugins/` are tracked by git normally when they are not installed dependencies. 5. Symlinks connect `.agents/skills/` to each agent's expected location (`.claude/skills/` for Claude and Cursor) -6. MCP, hook, and subagent configs are generated for each declared agent where supported +6. MCP, hook, subagent, and plugin configs are generated for each declared agent where supported ## Configuration (agents.toml) @@ -54,7 +54,7 @@ Full example with all sections: ```toml version = 1 -agents = ["claude", "cursor", "codex", "opencode"] +agents = ["claude", "cursor", "codex", "grok", "opencode"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -131,6 +131,13 @@ command = "notify-done" name = "code-reviewer" source = "getsentry/agent-pack" targets = ["claude", "codex", "opencode"] + +# Plugin bundle +[[plugins]] +name = "review-tools" +source = "getsentry/agent-plugins" +path = "plugins/review-tools" +targets = ["claude", "cursor", "codex", "grok", "opencode"] ``` ### Top-level Fields @@ -139,9 +146,10 @@ targets = ["claude", "codex", "opencode"] |-------|------|----------|---------|-------------| | `version` | integer | Yes | -- | Schema version. Always `1`. | | `defaultRepositorySource` | string | No | `github` | Host used for shorthand `owner/repo` skill sources. Valid values: `github`, `gitlab`. | -| `agents` | string[] | No | `[]` | Agent tool IDs: `claude`, `cursor`, `codex`, `vscode`, `opencode`. Creates symlinks and config files for each. | +| `agents` | string[] | No | `[]` | Agent tool IDs: `claude`, `cursor`, `codex`, `grok`, `vscode`, `opencode`. Creates symlinks and config files for each where supported. | | `subagents` | table[] | No | `[]` | Custom subagent declarations. Generates runtime-specific files for Claude, Cursor, Codex, and OpenCode. | -| `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill can install. | +| `plugins` | table[] | No | `[]` | Plugin declarations. Installs canonical bundles into `.agents/plugins/` and generates runtime plugin outputs for Claude, Cursor, Codex, Grok, and OpenCode where supported. | +| `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install. | | `minimum_release_age_exclude` | string[] | No | `[]` | Sources that bypass the minimum release age gate. Supports org names, `org/repo`, and `org/*`. | ### Skills @@ -266,9 +274,32 @@ Generated subagent files: Generated files include a dotagents header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` loads subagents from existing installed files, preserves managed subagent files and lock entries instead of pruning removed subagents, and does not resolve subagent sources. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. +### Plugins + +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//`. The canonical plugin input format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a generalized Codex-compatible marketplace and manifest shape. Known fields are validated, unknown manifest and marketplace extension fields are preserved, and component paths must be relative without `..`. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Lowercase plugin identifier. Must start with lowercase `a-z`, end with lowercase `a-z` or `0-9`, and contain only lowercase letters, numbers, hyphens, and dots. | +| `source` | string | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources; HTTPS well-known skill indexes are not supported for plugins. | +| `ref` | string | No | Optional git ref override. | +| `path` | string | No | Optional explicit plugin directory path inside the source. | +| `targets` | string[] | No | Optional subset of configured agent IDs. When absent or empty, defaults to every configured agent in `agents`; targets not listed in top-level `agents` are skipped with a warning. | + +Generated project-scope plugin outputs: +- Claude: `.claude-plugin/marketplace.json` +- Cursor: `.cursor-plugin/marketplace.json` +- Codex: `.agents/plugins/marketplace.json` and `.agents/plugins//.codex-plugin/plugin.json` +- Grok: `.grok/plugins//` managed copy +- OpenCode: `.opencode/plugins/.js|ts` re-export module when the plugin declares or contains one OpenCode module + +Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated runtime marketplaces and generated Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. + ### Trust -Optional `[trust]` section to restrict allowed skill and subagent sources. +Optional `[trust]` section to restrict allowed skill, subagent, and plugin sources. | Field | Type | Description | |-------|------|-------------| @@ -282,7 +313,7 @@ Rules: - `allow_all = true` = all sources allowed (explicit intent) - `[trust]` present without `allow_all` = allowlist mode (source must match at least one rule) - Local `path:` sources are always allowed -- Trust is validated before any network operations in `add` for skills and `install` for configured skills and subagents +- Trust is validated before any network operations in `add` for skills and `install` for configured skills, subagents, and plugins ## CLI Commands @@ -310,7 +341,7 @@ Create `agents.toml` and `.agents/skills/` directory. Automatically includes the npx @sentry/dotagents install ``` -Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, hook, and subagent configs. There is no separate update command. With `--frozen`, declared skills and subagents must already exist in `agents.lock`, subagents are loaded from existing installed files without resolving sources, the lockfile is not updated, and existing managed subagent files are not pruned. +Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, installs subagents and plugins, writes the lockfile, creates symlinks, and generates MCP, hook, subagent, and plugin configs. There is no separate update command. With `--frozen`, declared skills, subagents, and plugins must already exist in `agents.lock`; subagents and plugins are loaded from existing installed files without resolving sources; the lockfile is not updated; and existing managed subagent/plugin files are not pruned. ### add @@ -347,7 +378,7 @@ When the argument is a source specifier (e.g. `owner/repo`, a URL) instead of a npx @sentry/dotagents sync ``` -Reconcile project state without network access: adopt truly local orphaned skills, prune stale managed skills and subagents removed from config, regenerate `.agents/.gitignore`, check for missing skills, repair symlinks, and verify/repair MCP, hook, and subagent configs. Reports issues as warnings or errors. +Reconcile project state without network access: adopt truly local orphaned skills, prune stale managed skills/subagents/plugins removed from config, regenerate `.agents/.gitignore`, check for missing skills and plugins, repair symlinks, and verify/repair MCP, hook, subagent, and plugin configs. Reports issues as warnings or errors. ### mcp add @@ -413,7 +444,7 @@ Show trusted sources with their type. Use `--json` for machine-readable output. npx @sentry/dotagents list [--json] ``` -Show installed skills and status. +Show declared skills, plugins, and status. JSON output is an object with `skills` and `plugins` arrays. | Status | Meaning | |--------|---------| @@ -519,6 +550,12 @@ source = "getsentry/agent-pack" resolved_url = "https://github.com/getsentry/agent-pack.git" resolved_path = "agents/code-reviewer.md" resolved_commit = "fedcba9876543210fedcba9876543210fedcba98" + +[plugins.review-tools] +source = "getsentry/agent-plugins" +resolved_url = "https://github.com/getsentry/agent-plugins.git" +resolved_path = "plugins/review-tools" +resolved_commit = "0123456789abcdef0123456789abcdef01234567" ``` | Field | Present For | Description | @@ -529,7 +566,7 @@ resolved_commit = "fedcba9876543210fedcba9876543210fedcba98" | `resolved_ref` | Git sources (optional) | Resolved ref name (omitted for default branch) | | `resolved_commit` | Git sources (optional) | Full commit SHA that was installed. Informational only; install does not use it for locking. | -Local `path:` skills and subagents have `source` only. Subagent entries use the same fields under `[subagents.]`; `resolved_path` points to the subagent file inside a git source. +Local `path:` skills, subagents, and plugins have `source` only. Subagent entries use the same fields under `[subagents.]`; `resolved_path` points to the subagent file inside a git source. Plugin entries use the same fields under `[plugins.]`; `resolved_path` points to the plugin directory inside a git source. ## Caching @@ -549,18 +586,18 @@ Location: `~/.local/dotagents/` (override: `DOTAGENTS_STATE_DIR`) ## Gitignore dotagents always manages gitignore. Two files are gitignored automatically: -- `agents.lock` -- tracks managed skills and subagents -- `.agents/.gitignore` -- excludes managed skill directories and canonical installed subagent files from git +- `agents.lock` -- tracks managed skills, subagents, and plugins +- `.agents/.gitignore` -- excludes managed skill directories, canonical installed subagent files, and managed plugin bundles from git `npx @sentry/dotagents init` adds both to the root `.gitignore`. If they're missing, `install` and `sync` warn. Run `npx @sentry/dotagents doctor --fix` to add them. -Custom skills created directly in `.agents/skills/` are not gitignored. They're tracked by git normally. +Custom skills created directly in `.agents/skills/` and project-authored plugin source directories in `.agents/plugins/` are not gitignored unless they are managed installed dependencies. They're tracked by git normally. `.agents/.gitignore` is regenerated on every `install`, `add`, `remove`, and `sync`. ## Refresh Strategy -Run `npx @sentry/dotagents install` after cloning or pulling changes. It fetches or refreshes managed skills and subagents unless a ref is pinned. There is no separate update command. +Run `npx @sentry/dotagents install` after cloning or pulling changes. It fetches or refreshes managed skills, subagents, and plugins unless a ref is pinned. There is no separate update command. ## Links diff --git a/packages/dotagents/src/agents/plugin-schema.test.ts b/packages/dotagents/src/agents/plugin-schema.test.ts new file mode 100644 index 0000000..64cbfe2 --- /dev/null +++ b/packages/dotagents/src/agents/plugin-schema.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + parsePluginManifest, + parsePluginMarketplace, + pluginManifestSchema, + pluginMarketplaceSchema, +} from "./plugin-schema.js"; + +describe("plugin manifest schema", () => { + it("accepts known fields and preserves extension fields", () => { + const manifest = parsePluginManifest( + { + name: "review-tools", + version: "1.2.3", + skills: "./skills", + commands: ["commands/review.md"], + opencode: { + plugins: ["opencode/plugin.ts"], + runtime: "bun", + }, + "x-dotagents": { + stable: true, + }, + }, + "plugin.json", + ); + + expect(manifest.name).toBe("review-tools"); + expect(manifest.opencode?.plugins).toEqual(["opencode/plugin.ts"]); + expect(manifest.opencode?.["runtime"]).toBe("bun"); + expect(manifest["x-dotagents"]).toEqual({ stable: true }); + }); + + it("rejects absolute and traversing component paths", () => { + expect(pluginManifestSchema.safeParse({ skills: "/tmp/skills" }).success).toBe(false); + expect(pluginManifestSchema.safeParse({ commands: ["../commands"] }).success).toBe(false); + expect(pluginManifestSchema.safeParse({ opencode: { plugins: ["opencode/../../plugin.ts"] } }).success).toBe(false); + }); + + it("rejects multiple OpenCode plugin modules", () => { + expect(pluginManifestSchema.safeParse({ + opencode: { + plugins: ["opencode/first.ts", "opencode/second.ts"], + }, + }).success).toBe(false); + }); + + it("rejects OpenCode plugin modules without a JavaScript or TypeScript extension", () => { + expect(pluginManifestSchema.safeParse({ + opencode: { + plugins: ["opencode/plugin.md"], + }, + }).success).toBe(false); + }); +}); + +describe("plugin marketplace schema", () => { + it("accepts codex-compatible local entries and preserves extension fields", () => { + const marketplace = parsePluginMarketplace( + { + name: "dotagents", + metadata: { + pluginRoot: ".agents/plugins", + managedBy: "dotagents", + }, + plugins: [ + { + name: "review-tools", + source: { + source: "local", + path: "./review-tools", + }, + policy: { + installation: "AVAILABLE", + authentication: "ON_INSTALL", + }, + extra: "kept", + }, + ], + }, + "marketplace.json", + ); + + expect(marketplace.metadata?.pluginRoot).toBe(".agents/plugins"); + expect(marketplace.metadata?.["managedBy"]).toBe("dotagents"); + expect(marketplace.plugins[0]!.source).toEqual({ + source: "local", + path: "./review-tools", + }); + expect(marketplace.plugins[0]!["extra"]).toBe("kept"); + }); + + it("rejects unsafe marketplace paths", () => { + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: "../bad" }], + }).success).toBe(false); + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + metadata: { pluginRoot: "../plugins" }, + plugins: [{ name: "bad", source: "./bad" }], + }).success).toBe(false); + }); + + it("rejects marketplace source objects without a path selector", () => { + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: { source: "local" } }], + }).success).toBe(false); + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: { url: "https://example.com/plugin.git" } }], + }).success).toBe(false); + }); +}); diff --git a/packages/dotagents/src/agents/plugin-schema.ts b/packages/dotagents/src/agents/plugin-schema.ts new file mode 100644 index 0000000..8c3387f --- /dev/null +++ b/packages/dotagents/src/agents/plugin-schema.ts @@ -0,0 +1,127 @@ +import { z } from "zod/v4"; + +export const pluginPathSchema = z.string().check( + z.refine((value) => { + if (value.length === 0) {return false;} + if (value.startsWith("/") || value.startsWith("\\")) {return false;} + if (/^[a-zA-Z]:[\\/]/.test(value)) {return false;} + const parts = value.replaceAll("\\", "/").split("/"); + return !parts.includes(".."); + }, "Plugin paths must be relative and must not contain '..'"), +); + +const pluginAuthorSchema = z.object({ + name: z.string().optional(), + email: z.string().optional(), + url: z.string().optional(), +}).passthrough(); + +const pluginPathOrPathsSchema = z.union([ + pluginPathSchema, + z.array(pluginPathSchema), +]); + +const pluginModulePathSchema = pluginPathSchema.check( + z.refine( + (value) => value.endsWith(".js") || value.endsWith(".ts"), + "Plugin module paths must end with .js or .ts", + ), +); + +export const pluginManifestSchema = z.object({ + name: z.string().optional(), + version: z.string().optional(), + description: z.string().optional(), + author: pluginAuthorSchema.optional(), + homepage: z.string().optional(), + repository: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(), + license: z.string().optional(), + keywords: z.array(z.string()).optional(), + category: z.string().optional(), + skills: pluginPathOrPathsSchema.optional(), + agents: pluginPathOrPathsSchema.optional(), + commands: pluginPathOrPathsSchema.optional(), + rules: pluginPathOrPathsSchema.optional(), + hooks: pluginPathSchema.optional(), + mcpServers: pluginPathSchema.optional(), + lspServers: pluginPathSchema.optional(), + apps: pluginPathSchema.optional(), + monitors: pluginPathSchema.optional(), + bin: pluginPathOrPathsSchema.optional(), + opencode: z.object({ + plugins: z.array(pluginModulePathSchema).max(1).optional(), + }).passthrough().optional(), +}).passthrough(); + +export type PluginManifest = z.infer; + +const localMarketplaceSourceSchema = z.object({ + source: z.literal("local"), + path: pluginPathSchema, +}).passthrough(); + +const extensionMarketplaceSourceSchema = z.object({ + source: z.string().optional(), + path: pluginPathSchema, +}).passthrough(); + +export const marketplaceSourceSchema = z.union([ + pluginPathSchema, + localMarketplaceSourceSchema, + extensionMarketplaceSourceSchema, +]); + +export const marketplacePluginEntrySchema = z.object({ + name: z.string(), + source: marketplaceSourceSchema, + description: z.string().optional(), + version: z.string().optional(), + category: z.string().optional(), + policy: z.object({ + installation: z.string().optional(), + authentication: z.string().optional(), + }).passthrough().optional(), + skills: z.array(pluginPathSchema).optional(), +}).passthrough(); + +export type MarketplacePluginEntry = z.infer; + +export const pluginMarketplaceSchema = z.object({ + name: z.string(), + interface: z.object({ + displayName: z.string().optional(), + }).passthrough().optional(), + owner: z.object({ + name: z.string().optional(), + }).passthrough().optional(), + metadata: z.object({ + pluginRoot: pluginPathSchema.optional(), + }).passthrough().optional(), + plugins: z.array(marketplacePluginEntrySchema), +}).passthrough(); + +export type PluginMarketplace = z.infer; + +/** Parses an external plugin manifest and annotates schema errors with its file path. */ +export function parsePluginManifest( + value: unknown, + filePath: string, +): PluginManifest { + const parsed = pluginManifestSchema.safeParse(value); + if (!parsed.success) { + throw new Error(`Invalid plugin manifest ${filePath}: ${parsed.error.message}`); + } + return parsed.data; +} + +/** Parses an external plugin marketplace and annotates schema errors with its file path. */ +export function parsePluginMarketplace( + value: unknown, + filePath: string, +): PluginMarketplace { + const parsed = pluginMarketplaceSchema.safeParse(value); + if (!parsed.success) { + throw new Error(`Invalid plugin marketplace ${filePath}: ${parsed.error.message}`); + } + return parsed.data; +} diff --git a/packages/dotagents/src/agents/plugin-store.ts b/packages/dotagents/src/agents/plugin-store.ts new file mode 100644 index 0000000..0c05aef --- /dev/null +++ b/packages/dotagents/src/agents/plugin-store.ts @@ -0,0 +1,482 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { basename, isAbsolute, join, posix, relative, resolve } from "node:path"; +import { + applyDefaultRepositorySource, + copyDir, + ensureCached, + isSourceExcluded, + parseSource, + resolveLocalSource, + sanitizeCacheKey, + validateTrustedSource, + type RepositorySource, + type TrustPolicy, +} from "@sentry/dotagents-lib"; +import type { PluginConfig } from "../config/schema.js"; +import type { LockedPlugin } from "../lockfile/schema.js"; +import { + parsePluginManifest, + parsePluginMarketplace, + type MarketplacePluginEntry, + type PluginManifest, +} from "./plugin-schema.js"; + +// Owns plugin source discovery and installation into the project cache. +// Resolved sources are never allowed to live inside the same project's +// `.agents/plugins` tree because installs replace managed destination dirs. +export interface PluginResolveOptions { + stateDir: string; + projectRoot: string; + defaultRepositorySource?: RepositorySource; + minimumReleaseAge?: number; + minimumReleaseAgeExclude?: string[]; + trust?: TrustPolicy; +} + +export type ResolvedPluginType = "git" | "local"; + +export interface PluginDeclaration { + name: string; + source: string; + pluginDir: string; + manifest: PluginManifest; + targets?: string[]; +} + +export interface ResolvedPlugin { + type: ResolvedPluginType; + source: string; + resolvedUrl?: string; + resolvedPath?: string; + resolvedRef?: string; + commit?: string; + plugin: PluginDeclaration; +} + +interface PluginCandidate { + dir: string; + path: string; + manifest: PluginManifest; +} + +const MARKETPLACE_PATHS = [ + ".agents/plugins/marketplace.json", + "marketplace.json", + ".claude-plugin/marketplace.json", + ".cursor-plugin/marketplace.json", + ".codex-plugin/marketplace.json", + ".plugin/marketplace.json", +] as const; + +const NATIVE_MANIFEST_PATHS = [ + "plugin.json", + ".codex-plugin/plugin.json", + ".claude-plugin/plugin.json", + ".cursor-plugin/plugin.json", + ".plugin/plugin.json", +] as const; + +/** Resolves a plugin declaration to a trusted local or cached git source bundle. */ +export async function resolvePlugin( + config: PluginConfig, + opts: PluginResolveOptions, +): Promise { + const sourceForResolve = applyDefaultRepositorySource( + config.source, + opts.defaultRepositorySource, + ); + if (opts.trust) {validateTrustedSource(sourceForResolve, opts.trust);} + + const parsed = parseSource(sourceForResolve); + + if (parsed.type === "local") { + const sourceDir = await resolveLocalSource(opts.projectRoot, parsed.path!); + const discovered = await discoverPlugin(sourceDir, config); + if (!discovered) { + throw new Error(`Plugin "${config.name}" not found in ${config.source}.`); + } + return { + type: "local", + source: config.source, + plugin: toDeclaration(config, discovered), + }; + } + + if (parsed.type === "well-known") { + throw new Error( + `HTTPS well-known sources are not supported for plugins. Use a git: URL, GitHub/GitLab repository, or path: source for "${config.name}".`, + ); + } + + const url = parsed.url!; + const cloneUrl = parsed.cloneUrl ?? url; + const ref = config.ref ?? parsed.ref; + const cacheKey = parsed.type === "github" + ? `${parsed.owner}/${parsed.repo}` + : sanitizeCacheKey(url); + const excluded = isSourceExcluded(config.source, opts.minimumReleaseAgeExclude); + const cached = await ensureCached({ + stateDir: opts.stateDir, + url: cloneUrl, + cacheKey, + ref, + minimumReleaseAge: excluded ? undefined : opts.minimumReleaseAge, + }); + + const discovered = await discoverPlugin(cached.repoDir, config); + if (!discovered) { + throw new Error(`Plugin "${config.name}" not found in ${config.source}.`); + } + + return { + type: "git", + source: config.source, + resolvedUrl: cloneUrl, + resolvedPath: discovered.path, + resolvedRef: ref, + commit: cached.commit, + plugin: toDeclaration(config, discovered), + }; +} + +/** Copies a resolved plugin bundle into `.agents/plugins//` safely. */ +export async function installPluginBundle( + pluginsDir: string, + resolved: ResolvedPlugin, +): Promise { + const destDir = join(pluginsDir, resolved.plugin.name); + if (isProjectPluginSource(resolved.plugin.pluginDir, pluginsDir)) { + throw new Error( + `Plugin "${resolved.plugin.name}" source resolves inside this project's .agents/plugins/ tree. Same-project plugins in .agents/plugins/ cannot be installed into the same project.`, + ); + } + const installed = { ...resolved.plugin, pluginDir: destDir }; + + await copyDir(resolved.plugin.pluginDir, destDir); + + await ensureCanonicalManifest(installed); + return installed; +} + +/** Loads installed plugin bundles declared in config and reports recoverable issues. */ +export async function loadInstalledPlugins( + pluginsDir: string, + configs: PluginConfig[], +): Promise<{ plugins: PluginDeclaration[]; issues: string[] }> { + const plugins: PluginDeclaration[] = []; + const issues: string[] = []; + + for (const config of configs) { + const pluginDir = join(pluginsDir, config.name); + if (!existsSync(pluginDir)) { + issues.push(`Plugin "${config.name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`); + continue; + } + try { + const manifest = await loadManifest(pluginDir) ?? { name: config.name }; + assertPluginName(config.name, manifest, pluginDir); + plugins.push({ + name: config.name, + source: config.source, + pluginDir, + manifest: normalizeManifest(config.name, manifest), + targets: config.targets, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + issues.push(`Failed to load installed plugin "${config.name}": ${message}`); + } + } + + return { plugins, issues }; +} + +/** Removes installed plugin bundles by lockfile name using path-safe names only. */ +export async function pruneInstalledPlugins( + pluginsDir: string, + names: Iterable, +): Promise { + if (!existsSync(pluginsDir)) {return [];} + + const pruned: string[] = []; + for (const name of names) { + const pluginPath = managedPluginPath(pluginsDir, name); + if (!pluginPath || !existsSync(pluginPath)) {continue;} + await rm(pluginPath, { recursive: true, force: true }); + pruned.push(name); + } + return pruned; +} + +/** Converts a resolved plugin to its lockfile entry. */ +export function lockEntryForPlugin(resolved: ResolvedPlugin): LockedPlugin { + return { + source: resolved.source, + ...(resolved.resolvedUrl ? { resolved_url: resolved.resolvedUrl } : {}), + ...(resolved.resolvedPath ? { resolved_path: resolved.resolvedPath } : {}), + ...(resolved.resolvedRef ? { resolved_ref: resolved.resolvedRef } : {}), + ...(resolved.commit ? { resolved_commit: resolved.commit } : {}), + }; +} + +/** Returns true for direct `path:.agents/plugins/...` plugin sources. */ +export function isInPlacePluginSource(source: string): boolean { + let parsed: ReturnType; + try { + parsed = parseSource(source); + } catch { + return false; + } + if (parsed.type !== "local" || !parsed.path) {return false;} + + const normalized = posix.normalize(parsed.path.replaceAll("\\", "/")).replace(/^\.\//, ""); + return normalized.startsWith(".agents/plugins/"); +} + +/** Returns true when a resolved plugin source lives inside the project plugin tree. */ +export function isProjectPluginSource( + pluginDir: string, + pluginsDir: string, +): boolean { + const rootPath = resolve(pluginsDir); + const relPath = relative(rootPath, resolve(pluginDir)); + return relPath === "" || (!relPath.startsWith("..") && !isAbsolute(relPath)); +} + +async function discoverPlugin( + sourceDir: string, + config: PluginConfig, +): Promise { + if (config.path) { + const dir = resolveInside(sourceDir, config.path, "Plugin path"); + return loadPluginCandidate(sourceDir, dir); + } + + const matches: PluginCandidate[] = []; + const canonical = await loadPluginCandidate(sourceDir, join(sourceDir, ".agents", "plugins", config.name)); + if (canonical && candidateMatches(config.name, canonical)) { + return canonical; + } + + const fromMarketplace = await discoverFromMarketplaces(sourceDir, config.name); + if (fromMarketplace) {return fromMarketplace;} + + for (const dir of [sourceDir, join(sourceDir, "plugins", config.name)]) { + const candidate = await loadPluginCandidate(sourceDir, dir); + if (candidate && candidateMatches(config.name, candidate)) {matches.push(candidate);} + } + + const recursive = await scanPluginDirectories(sourceDir, join(sourceDir, "plugins"), config.name); + matches.push(...recursive); + + const unique = dedupeCandidates(matches); + if (unique.length > 1) { + throw new Error( + `Plugin "${config.name}" is ambiguous in ${config.source}: ${unique.map((m) => m.path).join(", ")}`, + ); + } + return unique[0] ?? null; +} + +async function discoverFromMarketplaces( + sourceDir: string, + name: string, +): Promise { + for (const marketplacePath of MARKETPLACE_PATHS) { + const filePath = join(sourceDir, marketplacePath); + if (!existsSync(filePath)) {continue;} + + const marketplace = parsePluginMarketplace(await readJson(filePath), filePath); + const root = typeof marketplace.metadata?.pluginRoot === "string" + ? marketplace.metadata.pluginRoot + : "."; + for (const entry of marketplace.plugins) { + if (entry.name !== name) {continue;} + + const path = localMarketplacePath(entry.source); + if (!path) {continue;} + + const pluginDir = resolveInside(sourceDir, join(root, path), "Marketplace plugin source"); + const candidate = await loadPluginCandidate(sourceDir, pluginDir, entry); + if (candidate) {return candidate;} + } + } + + return null; +} + +async function scanPluginDirectories( + sourceRoot: string, + dir: string, + name: string, + depth = 2, +): Promise { + if (depth <= 0 || !existsSync(dir)) {return [];} + + const matches: PluginCandidate[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) {continue;} + const childDir = join(dir, entry.name); + const candidate = await loadPluginCandidate(sourceRoot, childDir); + if (candidate && candidateMatches(name, candidate)) { + matches.push(candidate); + continue; + } + matches.push(...await scanPluginDirectories(sourceRoot, childDir, name, depth - 1)); + } + return matches; +} + +async function loadPluginCandidate( + sourceRoot: string, + pluginDir: string, + overlay: Partial = {}, +): Promise { + if (!existsSync(pluginDir)) {return null;} + + const manifest = await loadManifest(pluginDir); + if (!manifest && !await hasPluginComponents(pluginDir)) {return null;} + + const name = typeof overlay["name"] === "string" + ? String(overlay["name"]) + : manifest && typeof manifest["name"] === "string" + ? String(manifest["name"]) + : basename(pluginDir); + const combined = normalizeManifest(name, { ...overlay, ...manifest }); + return { + dir: pluginDir, + path: relativePath(sourceRoot, pluginDir), + manifest: combined, + }; +} + +async function loadManifest(pluginDir: string): Promise { + for (const manifestPath of NATIVE_MANIFEST_PATHS) { + const filePath = join(pluginDir, manifestPath); + if (!existsSync(filePath)) {continue;} + return parsePluginManifest(await readJson(filePath), filePath); + } + return null; +} + +async function hasPluginComponents(pluginDir: string): Promise { + const paths = [ + "skills", + "agents", + "commands", + "rules", + "hooks/hooks.json", + ".mcp.json", + "mcp.json", + ".lsp.json", + ".app.json", + "monitors/monitors.json", + "bin", + "SKILL.md", + ]; + return paths.some((path) => existsSync(join(pluginDir, path))); +} + +async function ensureCanonicalManifest(plugin: PluginDeclaration): Promise { + const filePath = join(plugin.pluginDir, "plugin.json"); + if (existsSync(filePath)) {return;} + await writeFile(filePath, `${JSON.stringify(plugin.manifest, null, 2)}\n`, "utf-8"); +} + +function toDeclaration( + config: PluginConfig, + candidate: PluginCandidate, +): PluginDeclaration { + assertPluginName(config.name, candidate.manifest, candidate.path); + return { + name: config.name, + source: config.source, + pluginDir: candidate.dir, + manifest: normalizeManifest(config.name, candidate.manifest), + targets: config.targets, + }; +} + +function assertPluginName( + expected: string, + manifest: PluginManifest, + context: string, +): void { + const actual = manifest["name"]; + if (typeof actual === "string" && actual !== expected) { + throw new Error(`Plugin manifest name "${actual}" does not match configured name "${expected}" in ${context}.`); + } +} + +function normalizeManifest( + name: string, + manifest: PluginManifest, +): PluginManifest { + return { ...manifest, name }; +} + +function candidateMatches(name: string, candidate: PluginCandidate): boolean { + return basename(candidate.dir) === name || candidate.manifest["name"] === name; +} + +function dedupeCandidates(candidates: PluginCandidate[]): PluginCandidate[] { + const seen = new Set(); + const result: PluginCandidate[] = []; + for (const candidate of candidates) { + const key = resolve(candidate.dir); + if (seen.has(key)) {continue;} + seen.add(key); + result.push(candidate); + } + return result; +} + +function localMarketplacePath(source: MarketplacePluginEntry["source"]): string | null { + if (typeof source === "string") {return stripDotSlash(source);} + + if (source.source === "local" && typeof source.path === "string") { + return stripDotSlash(source.path); + } + if (typeof source.path === "string" && !("url" in source) && !("repo" in source)) { + return stripDotSlash(source.path); + } + return null; +} + +function stripDotSlash(path: string): string { + return path.replace(/^\.\//, ""); +} + +function resolveInside(root: string, childPath: string, label: string): string { + const rootPath = resolve(root); + const filePath = resolve(rootPath, childPath); + const relPath = relative(rootPath, filePath); + if (relPath.startsWith("..") || isAbsolute(relPath)) { + throw new Error(`${label} resolves outside source: ${childPath}`); + } + return filePath; +} + +function managedPluginPath(pluginsDir: string, name: string): string | null { + const rootPath = resolve(pluginsDir); + const pluginPath = resolve(rootPath, name); + const relPath = relative(rootPath, pluginPath); + if (!relPath || relPath.startsWith("..") || isAbsolute(relPath)) {return null;} + if (relPath.includes("/") || relPath.includes("\\")) {return null;} + return pluginPath; +} + +function relativePath(root: string, filePath: string): string { + return relative(root, filePath).split("\\").join("/"); +} + +async function readJson(filePath: string): Promise> { + const raw = await readFile(filePath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Expected JSON object in ${filePath}`); + } + return parsed as Record; +} diff --git a/packages/dotagents/src/agents/plugin-writer.test.ts b/packages/dotagents/src/agents/plugin-writer.test.ts new file mode 100644 index 0000000..2ee9ae6 --- /dev/null +++ b/packages/dotagents/src/agents/plugin-writer.test.ts @@ -0,0 +1,236 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { PluginDeclaration } from "./plugin-store.js"; +import { + prunePluginOutputs, + verifyPluginOutputs, + writePluginOutputs, +} from "./plugin-writer.js"; + +describe("plugin writer", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "dotagents-plugin-writer-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true }); + }); + + async function plugin( + name: string, + overrides: Partial = {}, + ): Promise { + const pluginDir = join(root, ".agents", "plugins", name); + await mkdir(join(pluginDir, "skills"), { recursive: true }); + await mkdir(join(pluginDir, "commands"), { recursive: true }); + return { + name, + source: `path:.agents/plugins/${name}`, + pluginDir, + manifest: { + name, + version: "1.0.0", + description: `Tools for ${name}`, + category: "Coding", + author: { name: "Sentry" }, + ...overrides.manifest, + }, + targets: overrides.targets, + }; + } + + it("writes deterministic marketplace outputs for supported runtimes", async () => { + const alpha = await plugin("alpha-tools"); + const beta = await plugin("beta-tools"); + + const result = await writePluginOutputs( + ["cursor", "codex", "claude"], + [beta, alpha], + root, + ); + + expect(result.warnings).toEqual([]); + expect(result.written).toBe(5); + expect(await readFile(join(root, ".agents", "plugins", "marketplace.json"), "utf-8")).toBe(`{ + "interface": { + "displayName": "Dotagents Plugins" + }, + "metadata": { + "managedBy": "dotagents" + }, + "name": "dotagents", + "owner": { + "name": "dotagents" + }, + "plugins": [ + { + "category": "Coding", + "description": "Tools for alpha-tools", + "name": "alpha-tools", + "policy": { + "authentication": "ON_INSTALL", + "installation": "AVAILABLE" + }, + "source": { + "path": ".agents/plugins/alpha-tools", + "source": "local" + }, + "version": "1.0.0" + }, + { + "category": "Coding", + "description": "Tools for beta-tools", + "name": "beta-tools", + "policy": { + "authentication": "ON_INSTALL", + "installation": "AVAILABLE" + }, + "source": { + "path": ".agents/plugins/beta-tools", + "source": "local" + }, + "version": "1.0.0" + } + ] +} +`); + expect(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ + "metadata": { + "managedBy": "dotagents" + }, + "name": "dotagents", + "owner": { + "name": "dotagents" + }, + "plugins": [ + { + "description": "Tools for alpha-tools", + "name": "alpha-tools", + "source": ".agents/plugins/alpha-tools", + "version": "1.0.0" + }, + { + "description": "Tools for beta-tools", + "name": "beta-tools", + "source": ".agents/plugins/beta-tools", + "version": "1.0.0" + } + ] +} +`); + expect(await readFile(join(root, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")); + + const codexManifest = JSON.parse(await readFile(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"), "utf-8")) as Record; + expect(codexManifest["skills"]).toBe("./skills"); + expect(codexManifest["commands"]).toBe("./commands"); + expect(codexManifest["interface"]).toEqual({ + capabilities: ["Interactive", "Write"], + category: "Coding", + developerName: "Sentry", + displayName: "Alpha Tools", + shortDescription: "Tools for alpha-tools", + }); + + expect(await verifyPluginOutputs(["cursor", "codex", "claude"], [beta, alpha], root)).toEqual([]); + }); + + it("does not overwrite unmanaged marketplace files", async () => { + const alpha = await plugin("alpha-tools"); + await mkdir(join(root, ".claude-plugin"), { recursive: true }); + await writeFile(join(root, ".claude-plugin", "marketplace.json"), "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs(["claude"], [alpha], root); + + expect(result.written).toBe(0); + expect(result.warnings).toEqual([ + { + agent: "claude", + name: "marketplace", + message: `Plugin marketplace exists and is not managed by dotagents: ${join(root, ".claude-plugin", "marketplace.json")}`, + }, + ]); + expect(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + }); + + it("does not overwrite unmanaged Codex plugin manifests", async () => { + const alpha = await plugin("alpha-tools"); + await mkdir(join(alpha.pluginDir, ".codex-plugin"), { recursive: true }); + await writeFile(join(alpha.pluginDir, ".codex-plugin", "plugin.json"), "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs(["codex"], [alpha], root); + + expect(result.warnings).toEqual([ + { + agent: "codex", + name: "alpha-tools", + message: `Codex plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json")}`, + }, + ]); + expect(await readFile(join(alpha.pluginDir, ".codex-plugin", "plugin.json"), "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"); + + const result = await writePluginOutputs([], [alpha], root); + + expect(result).toEqual({ warnings: [], 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); + }); + + it("warns and skips plugin targets that are not configured agents", async () => { + const alpha = await plugin("alpha-tools", { targets: ["codex"] }); + + const result = await writePluginOutputs(["claude"], [alpha], root); + + expect(result.written).toBe(0); + expect(result.warnings).toEqual([ + { + agent: "codex", + name: "alpha-tools", + message: 'Plugin "alpha-tools" targets "codex", but "codex" is not listed in agents.', + }, + ]); + expect(existsSync(join(root, ".agents", "plugins", "marketplace.json"))).toBe(false); + expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); + }); + + it("prunes stale managed runtime plugin outputs", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { opencode: { plugins: ["opencode/plugin.ts"] } }, + }); + await mkdir(join(alpha.pluginDir, "opencode"), { recursive: true }); + await writeFile(join(alpha.pluginDir, "opencode", "plugin.ts"), "export default {}\n", "utf-8"); + await writePluginOutputs(["codex", "grok", "opencode"], [alpha], root); + + const pruned = await prunePluginOutputs([], [alpha], root); + + expect(pruned).toEqual([ + join(root, ".agents", "plugins", "marketplace.json"), + join(root, ".grok", "plugins", "alpha-tools"), + join(root, ".opencode", "plugins", "alpha-tools.ts"), + join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"), + ]); + expect(existsSync(join(root, ".agents", "plugins", "marketplace.json"))).toBe(false); + expect(existsSync(join(root, ".grok", "plugins", "alpha-tools"))).toBe(false); + expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(false); + expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); + }); + + it("does not rewrite unchanged managed Grok projections", async () => { + const alpha = await plugin("alpha-tools"); + + const first = await writePluginOutputs(["grok"], [alpha], root); + const second = await writePluginOutputs(["grok"], [alpha], root); + + expect(first.written).toBe(1); + expect(second.written).toBe(0); + }); +}); diff --git a/packages/dotagents/src/agents/plugin-writer.ts b/packages/dotagents/src/agents/plugin-writer.ts new file mode 100644 index 0000000..df19254 --- /dev/null +++ b/packages/dotagents/src/agents/plugin-writer.ts @@ -0,0 +1,663 @@ +import { existsSync } from "node:fs"; +import { cp, lstat, mkdir, readdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises"; +import { dirname, extname, join, relative } from "node:path"; +import type { PluginDeclaration } from "./plugin-store.js"; +import type { PluginManifest, PluginMarketplace } from "./plugin-schema.js"; +import { allPluginAgentIds } from "./registry.js"; + +// Owns deterministic runtime plugin projections. Existing runtime artifacts are +// overwritten only when they carry dotagents managed metadata or a managed marker. +const DOTAGENTS_METADATA = { managedBy: "dotagents" }; +const SUPPORTED_PLUGIN_AGENT_IDS = new Set(allPluginAgentIds()); + +export interface PluginWriteWarning { + agent: string; + name: string; + message: string; +} + +export interface PluginWriteResult { + warnings: PluginWriteWarning[]; + written: number; +} + +export interface PluginVerifyIssue { + agent: string; + name: string; + issue: string; +} + +interface RuntimeOutput { + agent: string; + filePath: string; + content: string; +} + +/** Writes deterministic project-scope plugin runtime artifacts for selected agents. */ +export async function writePluginOutputs( + agentIds: string[], + plugins: PluginDeclaration[], + projectRoot: string, +): Promise { + const warnings: PluginWriteWarning[] = []; + let written = 0; + const selected = selectPlugins(agentIds, plugins); + + for (const warning of targetWarnings(agentIds, plugins)) { + warnings.push(warning); + } + + for (const output of marketplaceOutputs(agentIds, projectRoot, selected)) { + if (await writeManagedJsonOutput(output, warnings)) {written++;} + } + + for (const plugin of selected) { + const agents = selectedAgentIds(agentIds, plugin); + if (agents.includes("codex") && await writeCodexManifest(plugin, warnings)) { + written++; + } + if (agents.includes("grok") && await writeGrokProjection(projectRoot, plugin, warnings)) { + written++; + } + if (agents.includes("opencode")) { + written += await writeOpenCodeProjection(projectRoot, plugin, warnings); + } + } + + return { warnings, written }; +} + +/** Verifies that generated plugin runtime artifacts match the current declarations. */ +export async function verifyPluginOutputs( + agentIds: string[], + plugins: PluginDeclaration[], + projectRoot: string, +): Promise { + const issues: PluginVerifyIssue[] = []; + const selected = selectPlugins(agentIds, plugins); + + for (const output of marketplaceOutputs(agentIds, projectRoot, selected)) { + if (!existsSync(output.filePath)) { + issues.push({ agent: output.agent, name: "marketplace", issue: `Plugin marketplace missing: ${output.filePath}` }); + continue; + } + try { + const existing = await readFile(output.filePath, "utf-8"); + if (existing !== output.content) { + issues.push({ agent: output.agent, name: "marketplace", issue: `Plugin marketplace out of date: ${output.filePath}` }); + } + } catch { + issues.push({ agent: output.agent, name: "marketplace", issue: `Failed to read plugin marketplace: ${output.filePath}` }); + } + } + + for (const plugin of selected) { + const agents = selectedAgentIds(agentIds, plugin); + if (agents.includes("codex")) { + const filePath = join(plugin.pluginDir, ".codex-plugin", "plugin.json"); + if (!existsSync(filePath)) { + issues.push({ agent: "codex", name: plugin.name, issue: `Codex plugin manifest missing: ${filePath}` }); + } + } + if (agents.includes("grok")) { + const filePath = join(projectRoot, ".grok", "plugins", plugin.name); + if (!existsSync(filePath)) { + issues.push({ agent: "grok", name: plugin.name, issue: `Grok plugin projection missing: ${filePath}` }); + } + } + } + + return issues; +} + +/** Removes stale dotagents-managed plugin runtime artifacts. */ +export async function prunePluginOutputs( + agentIds: string[], + plugins: PluginDeclaration[], + projectRoot: string, +): Promise { + const pruned: string[] = []; + const desiredMarketplacePaths = new Set( + marketplaceOutputsForTargets(agentIds, projectRoot, plugins).map((output) => output.filePath), + ); + for (const filePath of marketplaceOutputPaths(projectRoot)) { + if (desiredMarketplacePaths.has(filePath)) {continue;} + if (!existsSync(filePath)) {continue;} + if (!await isManagedJsonFile(filePath)) {continue;} + await rm(filePath, { force: true }); + pruned.push(filePath); + } + + const desiredGrok = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("grok")) + .map((plugin) => plugin.name), + ); + const grokDir = join(projectRoot, ".grok", "plugins"); + if (existsSync(grokDir)) { + const entries = await readdir(grokDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) {continue;} + if (desiredGrok.has(entry.name)) {continue;} + const path = join(grokDir, entry.name); + if (!await isManagedProjection(path)) {continue;} + await rm(path, { recursive: true, force: true }); + pruned.push(path); + } + } + + const desiredOpenCode = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("opencode")) + .flatMap((plugin) => opencodeModules(plugin).map((modulePath) => `${plugin.name}${extname(modulePath)}`)), + ); + const opencodeDir = join(projectRoot, ".opencode", "plugins"); + if (existsSync(opencodeDir)) { + const entries = await readdir(opencodeDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() && !entry.isSymbolicLink()) {continue;} + if (desiredOpenCode.has(entry.name)) {continue;} + const path = join(opencodeDir, entry.name); + if (!await isManagedOpenCodeModule(path)) {continue;} + await rm(path, { force: true }); + pruned.push(path); + } + } + + const desiredCodex = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")) + .map((plugin) => plugin.name), + ); + const canonicalPluginDir = join(projectRoot, ".agents", "plugins"); + if (existsSync(canonicalPluginDir)) { + const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) {continue;} + if (desiredCodex.has(entry.name)) {continue;} + const path = join(canonicalPluginDir, entry.name, ".codex-plugin", "plugin.json"); + if (!existsSync(path) || !await isManagedJsonFile(path)) {continue;} + await rm(path, { force: true }); + await rmdirIfEmpty(dirname(path)); + pruned.push(path); + } + } + + return pruned; +} + +function marketplaceOutputPaths(projectRoot: string): string[] { + return [ + join(projectRoot, ".agents", "plugins", "marketplace.json"), + join(projectRoot, ".claude-plugin", "marketplace.json"), + join(projectRoot, ".cursor-plugin", "marketplace.json"), + ]; +} + +function marketplaceOutputs( + agentIds: string[], + projectRoot: string, + plugins: PluginDeclaration[], +): RuntimeOutput[] { + if (plugins.length === 0) {return [];} + + const outputs: RuntimeOutput[] = []; + const codexPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); + const claudePlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); + const cursorPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); + + if (codexPlugins.length > 0) { + outputs.push({ + agent: "codex", + filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), + content: stableJson(codexMarketplace(projectRoot, codexPlugins)), + }); + } + if (claudePlugins.length > 0) { + outputs.push({ + agent: "claude", + filePath: join(projectRoot, ".claude-plugin", "marketplace.json"), + content: stableJson(pathMarketplace(projectRoot, "dotagents", claudePlugins)), + }); + } + if (cursorPlugins.length > 0) { + outputs.push({ + agent: "cursor", + filePath: join(projectRoot, ".cursor-plugin", "marketplace.json"), + content: stableJson(pathMarketplace(projectRoot, "dotagents", cursorPlugins)), + }); + } + + return outputs; +} + +function marketplaceOutputsForTargets( + agentIds: string[], + projectRoot: string, + plugins: Array>, +): RuntimeOutput[] { + if (plugins.length === 0) {return [];} + + const outputs: RuntimeOutput[] = []; + const hasCodex = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); + const hasClaude = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); + const hasCursor = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); + + if (hasCodex) { + outputs.push({ agent: "codex", filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), content: "" }); + } + if (hasClaude) { + outputs.push({ agent: "claude", filePath: join(projectRoot, ".claude-plugin", "marketplace.json"), content: "" }); + } + if (hasCursor) { + outputs.push({ agent: "cursor", filePath: join(projectRoot, ".cursor-plugin", "marketplace.json"), content: "" }); + } + + return outputs; +} + +function codexMarketplace( + projectRoot: string, + plugins: PluginDeclaration[], +): PluginMarketplace { + return { + name: "dotagents", + interface: { + displayName: "Dotagents Plugins", + }, + owner: { + name: "dotagents", + }, + metadata: DOTAGENTS_METADATA, + plugins: plugins + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((plugin) => codexMarketplaceEntry(projectRoot, plugin)), + }; +} + +function pathMarketplace( + projectRoot: string, + name: string, + plugins: PluginDeclaration[], +): Record { + return { + name, + owner: { + name: "dotagents", + }, + metadata: DOTAGENTS_METADATA, + plugins: plugins + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((plugin) => pathMarketplaceEntry(projectRoot, plugin)), + }; +} + +function codexMarketplaceEntry( + projectRoot: string, + plugin: PluginDeclaration, +): PluginMarketplace["plugins"][number] { + const entry: PluginMarketplace["plugins"][number] = { + name: plugin.name, + source: { + source: "local" as const, + path: relativePath(projectRoot, plugin.pluginDir), + }, + policy: { + installation: "AVAILABLE", + authentication: "ON_INSTALL", + }, + category: manifestString(plugin.manifest, "category") ?? "Coding", + }; + const description = manifestString(plugin.manifest, "description"); + if (description) {entry.description = description;} + const version = manifestString(plugin.manifest, "version"); + if (version) {entry.version = version;} + return entry; +} + +function pathMarketplaceEntry( + projectRoot: string, + plugin: PluginDeclaration, +): Record { + const entry: Record = { + name: plugin.name, + source: relativePath(projectRoot, plugin.pluginDir), + }; + const description = manifestString(plugin.manifest, "description"); + if (description) {entry["description"] = description;} + const version = manifestString(plugin.manifest, "version"); + if (version) {entry["version"] = version;} + return entry; +} + +async function writeCodexManifest( + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const filePath = join(plugin.pluginDir, ".codex-plugin", "plugin.json"); + if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { + warnings.push({ + agent: "codex", + name: plugin.name, + message: `Codex plugin manifest exists and is not managed by dotagents: ${filePath}`, + }); + return false; + } + const manifest = codexRuntimeManifest(plugin); + return writeJsonIfChanged(filePath, stableJson(manifest)); +} + +/** Mirrors a plugin bundle into Grok's plugin directory with a managed marker. */ +async function writeGrokProjection( + projectRoot: string, + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const dest = join(projectRoot, ".grok", "plugins", plugin.name); + if (existsSync(dest)) { + if (await isManagedProjection(dest)) { + if (await directoriesMatch(plugin.pluginDir, dest, new Set([".dotagents-managed"]))) { + return false; + } + await rm(dest, { recursive: true, force: true }); + } else { + warnings.push({ + agent: "grok", + name: plugin.name, + message: `Grok plugin projection exists and is not managed by dotagents: ${dest}`, + }); + return false; + } + } + + await mkdir(dirname(dest), { recursive: true }); + await cp(plugin.pluginDir, dest, { recursive: true }); + await writeFile(join(dest, ".dotagents-managed"), "Generated by dotagents. Do not edit.\n", "utf-8"); + return true; +} + +/** Writes OpenCode re-export modules for explicit or conventional plugin modules. */ +async function writeOpenCodeProjection( + projectRoot: string, + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const modules = opencodeModules(plugin); + let written = 0; + for (const modulePath of modules) { + const ext = extname(modulePath); + const dest = join(projectRoot, ".opencode", "plugins", `${plugin.name}${ext}`); + if (existsSync(dest) && !await isManagedOpenCodeModule(dest)) { + warnings.push({ + agent: "opencode", + name: plugin.name, + message: `OpenCode plugin module exists and is not managed by dotagents: ${dest}`, + }); + continue; + } + + await mkdir(dirname(dest), { recursive: true }); + const content = `// Generated by dotagents. Do not edit.\nexport { default } from "${relativePath(dirname(dest), join(plugin.pluginDir, modulePath))}";\n`; + if (await writeTextIfChanged(dest, content)) {written++;} + } + return written; +} + +function codexRuntimeManifest(plugin: PluginDeclaration): Record { + const manifest: Record = { + ...plugin.manifest, + name: plugin.name, + }; + + if (!manifest["skills"] && existsSync(join(plugin.pluginDir, "skills"))) { + manifest["skills"] = "./skills"; + } + if (!manifest["agents"] && existsSync(join(plugin.pluginDir, "agents"))) { + manifest["agents"] = "./agents"; + } + if (!manifest["commands"] && existsSync(join(plugin.pluginDir, "commands"))) { + manifest["commands"] = "./commands"; + } + if (!manifest["hooks"] && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { + manifest["hooks"] = "./hooks/hooks.json"; + } + if (!manifest["mcpServers"] && existsSync(join(plugin.pluginDir, ".mcp.json"))) { + manifest["mcpServers"] = "./.mcp.json"; + } + if (!manifest["lspServers"] && existsSync(join(plugin.pluginDir, ".lsp.json"))) { + manifest["lspServers"] = "./.lsp.json"; + } + if (!manifest["apps"] && existsSync(join(plugin.pluginDir, ".app.json"))) { + manifest["apps"] = "./.app.json"; + } + if (!manifest["interface"]) { + manifest["interface"] = codexInterface(plugin); + } + const metadata = manifest["metadata"]; + manifest["metadata"] = { + ...(metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {}), + ...DOTAGENTS_METADATA, + }; + return manifest; +} + +function codexInterface(plugin: PluginDeclaration): Record { + return { + displayName: titleCase(plugin.name), + shortDescription: manifestString(plugin.manifest, "description") ?? "", + developerName: developerName(plugin.manifest), + category: manifestString(plugin.manifest, "category") ?? "Coding", + capabilities: ["Interactive", "Write"], + }; +} + +function developerName(manifest: PluginManifest): string { + const author = manifest.author; + if (author && typeof author.name === "string") {return author.name;} + return "Unknown"; +} + +function opencodeModules(plugin: PluginDeclaration): string[] { + const opencode = plugin.manifest.opencode; + if (opencode?.plugins) {return opencode.plugins;} + const candidates = ["opencode/plugin.ts", "opencode/plugin.js"]; + return candidates.filter((path) => existsSync(join(plugin.pluginDir, path))); +} + +function selectPlugins(agentIds: string[], plugins: PluginDeclaration[]): PluginDeclaration[] { + return plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).length > 0); +} + +function selectedAgentIds( + agentIds: string[], + plugin: Pick, +): string[] { + const targets = plugin.targets && plugin.targets.length > 0 + ? plugin.targets + : agentIds; + const configured = new Set(agentIds); + return [...new Set(targets)] + .filter((target) => configured.has(target)) + .filter((target) => SUPPORTED_PLUGIN_AGENT_IDS.has(target)); +} + +function targetWarnings( + agentIds: string[], + plugins: PluginDeclaration[], +): PluginWriteWarning[] { + const configured = new Set(agentIds); + const warnings: PluginWriteWarning[] = []; + for (const plugin of plugins) { + const targets = plugin.targets && plugin.targets.length > 0 + ? plugin.targets + : agentIds; + for (const target of new Set(targets)) { + if (!configured.has(target)) { + warnings.push({ + agent: target, + name: plugin.name, + message: `Plugin "${plugin.name}" targets "${target}", but "${target}" is not listed in agents.`, + }); + continue; + } + if (!SUPPORTED_PLUGIN_AGENT_IDS.has(target)) { + warnings.push({ + agent: target, + name: plugin.name, + message: `Plugin "${plugin.name}" targets "${target}", but "${target}" does not support plugin outputs.`, + }); + } + } + } + return warnings; +} + +async function writeManagedJsonOutput( + output: RuntimeOutput, + warnings: PluginWriteWarning[], +): Promise { + if (existsSync(output.filePath) && !await isManagedJsonFile(output.filePath)) { + warnings.push({ + agent: output.agent, + name: "marketplace", + message: `Plugin marketplace exists and is not managed by dotagents: ${output.filePath}`, + }); + return false; + } + return writeJsonIfChanged(output.filePath, output.content); +} + +async function writeJsonIfChanged(filePath: string, content: string): Promise { + await mkdir(dirname(filePath), { recursive: true }); + return writeTextIfChanged(filePath, content); +} + +async function writeTextIfChanged(filePath: string, content: string): Promise { + try { + if (await readFile(filePath, "utf-8") === content) {return false;} + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + } + await writeFile(filePath, content, "utf-8"); + return true; +} + +/** Checks the JSON ownership marker used for marketplaces and Codex manifests. */ +async function isManagedJsonFile(filePath: string): Promise { + try { + const parsed = JSON.parse(await readFile(filePath, "utf-8")) as Record; + const metadata = parsed["metadata"]; + return !!metadata && typeof metadata === "object" && (metadata as Record)["managedBy"] === "dotagents"; + } catch { + return false; + } +} + +async function isManagedProjection(path: string): Promise { + return existsSync(join(path, ".dotagents-managed")); +} + +async function isManagedOpenCodeModule(filePath: string): Promise { + try { + return (await readFile(filePath, "utf-8")).startsWith("// Generated by dotagents."); + } catch { + return false; + } +} + +async function directoriesMatch(source: string, dest: string, ignoredNames = new Set()): Promise { + if (!existsSync(source) || !existsSync(dest)) {return false;} + + const sourceEntries = await comparableEntries(source, ignoredNames); + const destEntries = await comparableEntries(dest, ignoredNames); + if (sourceEntries.length !== destEntries.length) {return false;} + + for (const entry of sourceEntries) { + const destEntry = destEntries.find((item) => item.name === entry.name); + if (!destEntry) {return false;} + + const sourcePath = join(source, entry.name); + const destPath = join(dest, destEntry.name); + if (entry.kind !== destEntry.kind) {return false;} + if (entry.kind === "directory") { + if (!await directoriesMatch(sourcePath, destPath, ignoredNames)) {return false;} + continue; + } + if (entry.kind === "symlink") { + if (await readlink(sourcePath) !== await readlink(destPath)) {return false;} + continue; + } + if (await readFile(sourcePath, "utf-8") !== await readFile(destPath, "utf-8")) { + return false; + } + } + return true; +} + +async function comparableEntries( + dir: string, + ignoredNames: Set, +): Promise> { + const entries = await readdir(dir, { withFileTypes: true }); + const result: Array<{ name: string; kind: "directory" | "file" | "symlink" }> = []; + for (const entry of entries) { + if (ignoredNames.has(entry.name)) {continue;} + const path = join(dir, entry.name); + const stat = await lstat(path); + const kind = stat.isSymbolicLink() + ? "symlink" + : stat.isDirectory() + ? "directory" + : "file"; + result.push({ name: entry.name, kind }); + } + return result.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +async function rmdirIfEmpty(dir: string): Promise { + try { + await rmdir(dir); + } catch (err) { + if (!isNotFoundError(err) && !(err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOTEMPTY")) { + throw err; + } + } +} + +function stableJson(value: unknown): string { + return `${JSON.stringify(sortJson(value), null, 2)}\n`; +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) {return value.map(sortJson);} + if (!value || typeof value !== "object") {return value;} + + const result: Record = {}; + const record = value as Record; + for (const key of Object.keys(record).toSorted()) { + result[key] = sortJson(record[key]); + } + return result; +} + +function manifestString(manifest: PluginManifest, key: string): string | undefined { + const value = manifest[key]; + return typeof value === "string" ? value : undefined; +} + +function titleCase(value: string): string { + return value + .split(/[-.]/) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function relativePath(from: string, to: string): string { + const path = relative(from, to).split("\\").join("/"); + return path.startsWith(".") ? path : `./${path}`; +} + +function isNotFoundError(err: unknown): boolean { + return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; +} diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index 0b00479..c160b05 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -14,6 +14,7 @@ import { getAgent } from "../../targets/registry.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { exec } from "@sentry/dotagents-lib"; import { isInPlaceSkill } from "../../utils/fs.js"; +import { isInPlacePluginSource } from "../../agents/plugin-store.js"; export interface DoctorCheck { name: string; @@ -151,6 +152,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { } else { const managedNames = getManagedSkillNames(config, lockfile); const managedSubagentNames = getManagedSubagentNames(config, lockfile); + const managedPluginNames = getManagedPluginNames(config, lockfile); checks.push({ name: ".agents/.gitignore", status: "warn", @@ -160,6 +162,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { scope.agentsDir, managedNames, managedSubagentNames, + managedPluginNames, ); }, }); @@ -192,7 +195,23 @@ export async function runDoctor(opts: DoctorOptions): Promise { checks.push({ name: "installed skills", status: "ok", message: "No skills declared." }); } - // 9. Symlinks (project scope only) + // 9. Declared plugins are installed + const missingPlugins = config.plugins + .filter((plugin) => !existsSync(`${scope.pluginsDir}/${plugin.name}`)) + .map((plugin) => plugin.name); + if (missingPlugins.length > 0) { + checks.push({ + name: "installed plugins", + status: "error", + message: `${missingPlugins.length} plugin(s) not installed: ${missingPlugins.join(", ")}. Run 'npx @sentry/dotagents install'.`, + }); + } else if (config.plugins.length > 0) { + checks.push({ name: "installed plugins", status: "ok", message: `All ${config.plugins.length} declared plugin(s) installed.` }); + } else { + checks.push({ name: "installed plugins", status: "ok", message: "No plugins declared." }); + } + + // 10. Symlinks (project scope only) if (scope.scope === "project" && existsSync(scope.agentsDir)) { const targets: string[] = []; const seenDirs = new Set(); @@ -295,6 +314,25 @@ function getManagedSubagentNames( return [...names]; } +function getManagedPluginNames( + config: Awaited>, + lockfile: Awaited>, +): string[] { + const names = new Set( + config.plugins + .filter((plugin) => !isInPlacePluginSource(plugin.source)) + .map((plugin) => plugin.name), + ); + if (lockfile) { + for (const [name, locked] of Object.entries(lockfile.plugins)) { + if (!isInPlacePluginSource(locked.source)) { + names.add(name); + } + } + } + return [...names]; +} + export default async function doctor(args: string[], flags?: { user?: boolean }): Promise { const { values } = parseArgs({ args, diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index a73552d..e6d4450 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -120,6 +120,248 @@ describe("runInstall", () => { expect("integrity" in lockfile!.skills["pdf"]!).toBe(false); }); + it("installs a local plugin and writes deterministic runtime artifacts", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await mkdir(join(sourceDir, "commands"), { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + version: "1.0.0", + description: "Review workflow helpers", + category: "Coding", + author: { name: "Sentry" }, + "x-extra": { kept: true }, + }, null, 2), + ); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile(join(sourceDir, "commands", "review.md"), "Review command"); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex", "claude", "cursor"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", "plugin.json"))).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", "skills", "review", "SKILL.md"))).toBe(true); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins["review-tools"]).toEqual({ + source: "path:plugin-source/review-tools", + }); + + expect(await readFile(join(projectRoot, ".agents", "plugins", "marketplace.json"), "utf-8")).toBe(`{ + "interface": { + "displayName": "Dotagents Plugins" + }, + "metadata": { + "managedBy": "dotagents" + }, + "name": "dotagents", + "owner": { + "name": "dotagents" + }, + "plugins": [ + { + "category": "Coding", + "description": "Review workflow helpers", + "name": "review-tools", + "policy": { + "authentication": "ON_INSTALL", + "installation": "AVAILABLE" + }, + "source": { + "path": ".agents/plugins/review-tools", + "source": "local" + }, + "version": "1.0.0" + } + ] +} +`); + expect(await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ + "metadata": { + "managedBy": "dotagents" + }, + "name": "dotagents", + "owner": { + "name": "dotagents" + }, + "plugins": [ + { + "description": "Review workflow helpers", + "name": "review-tools", + "source": ".agents/plugins/review-tools", + "version": "1.0.0" + } + ] +} +`); + expect(await readFile(join(projectRoot, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8")); + + const codexManifest = JSON.parse(await readFile(join(projectRoot, ".agents", "plugins", "review-tools", ".codex-plugin", "plugin.json"), "utf-8")) as Record; + expect(codexManifest["name"]).toBe("review-tools"); + expect(codexManifest["skills"]).toBe("./skills"); + expect(codexManifest["commands"]).toBe("./commands"); + expect(codexManifest["x-extra"]).toEqual({ kept: true }); + + const agentsGitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(agentsGitignore).toContain("/plugins/review-tools/"); + }); + + it("rejects same-project plugins that would install onto themselves", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ + name: "local-tools", + description: "Local plugin", + }, null, 2), + ); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "local-tools" +source = "path:./.agents/plugins/local-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow(/Same-project plugins cannot be installed into the same project/); + expect(existsSync(pluginDir)).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); + }); + + it("rejects same-project plugin sources nested under their install destination", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + const sourceDir = join(pluginDir, "source"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ + name: "local-tools", + description: "Local plugin", + }, null, 2), + ); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "local-tools" +source = "path:./.agents/plugins/local-tools/source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow(/Same-project plugins cannot be installed into the same project/); + expect(existsSync(sourceDir)).toBe(true); + }); + + it("prefers canonical plugin directories before marketplace entries", async () => { + const sourceRoot = join(projectRoot, "plugin-source"); + const canonicalDir = join(sourceRoot, ".agents", "plugins", "review-tools"); + const marketplaceDir = join(sourceRoot, "plugins", "review-tools-alt"); + await mkdir(canonicalDir, { recursive: true }); + await mkdir(marketplaceDir, { recursive: true }); + await writeFile( + join(canonicalDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Canonical plugin" }, null, 2), + ); + await writeFile( + join(marketplaceDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Marketplace plugin" }, null, 2), + ); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "source", + plugins: [ + { + name: "review-tools", + source: { + source: "local", + path: "plugins/review-tools-alt", + }, + }, + ], + }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installed = JSON.parse( + await readFile(join(projectRoot, ".agents", "plugins", "review-tools", "plugin.json"), "utf-8"), + ) as Record; + expect(installed["description"]).toBe("Canonical plugin"); + }); + + it("generates plugin runtime outputs in frozen mode from installed bundles", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Review workflow helpers", + }, null, 2), + ); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:external-source" +`, + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "review-tools": { + source: "path:external-source", + }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope, frozen: true }); + + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", ".codex-plugin", "plugin.json"))).toBe(true); + }); + it("installs multiple skills", async () => { await writeFile( join(projectRoot, "agents.toml"), @@ -453,6 +695,7 @@ path = "code-reviewer.md" source: "path:old-agents", }, }, + plugins: {}, }; await writeLockfile(join(projectRoot, "agents.lock"), originalLockfile); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index a83dc7b..67b7409 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -12,10 +12,12 @@ import { formatGitError, formatTrustError } from "../errors.js"; import { InstallError } from "./install/errors.js"; import { installSkills } from "./install/skills.js"; import { installSubagents, writeCanonicalSubagents } from "./install/subagents.js"; +import { installPlugins } from "./install/plugins.js"; import { writeInstallGitignore } from "./install/gitignore.js"; import { writeHookRuntime, writeMcpRuntime, + writePluginRuntime, writeSkillSymlinks, writeSubagentRuntime, } from "./install/agent-runtime.js"; @@ -34,8 +36,10 @@ export interface InstallResult { installed: string[]; skipped: string[]; pruned: string[]; + prunedPlugins: string[]; hookWarnings: { agent: string; message: string }[]; subagentWarnings: { agent: string; name: string; message: string }[]; + pluginWarnings: { agent: string; name: string; message: string }[]; } export async function runInstall(opts: InstallOptions): Promise { @@ -44,17 +48,20 @@ export async function runInstall(opts: InstallOptions): Promise { const lockfile = await loadLockfile(scope.lockPath); const skills = await installSkills(config, lockfile, scope, frozen); const subagents = await installSubagents(config, lockfile, scope, frozen); + const plugins = await installPlugins(config, lockfile, scope, frozen); const newLock: Lockfile = { version: 1, skills: skills.lockEntries, subagents: subagents.lockEntries, + plugins: plugins.lockEntries, }; await writeCanonicalSubagents(config, scope, subagents.subagents, frozen); const writeLock = !frozen && ( !!lockfile || config.skills.length > 0 || - config.subagents.length > 0 + config.subagents.length > 0 || + config.plugins.length > 0 ); if (writeLock) { await writeLockfile(scope.lockPath, newLock); @@ -63,18 +70,22 @@ export async function runInstall(opts: InstallOptions): Promise { await writeInstallGitignore(config, lockfile, scope, { installedSkillNames: skills.installed, subagents: subagents.subagents, + plugins: plugins.plugins, }, frozen); await writeSkillSymlinks(config, scope); await writeMcpRuntime(config, scope); const hookWarnings = await writeHookRuntime(config, scope); const subagentWarnings = await writeSubagentRuntime(config, scope, subagents.subagents, frozen); + const pluginWarnings = await writePluginRuntime(config, scope, plugins.plugins, frozen); return { installed: skills.installed, skipped: [], pruned: skills.pruned, + prunedPlugins: plugins.pruned, hookWarnings, subagentWarnings, + pluginWarnings, }; } @@ -109,12 +120,20 @@ export default async function install(args: string[], flags?: { user?: boolean } chalk.yellow(`Pruned ${result.pruned.length} stale skill(s): ${result.pruned.join(", ")}`), ); } + if (result.prunedPlugins.length > 0) { + console.log( + chalk.yellow(`Pruned ${result.prunedPlugins.length} stale plugin(s): ${result.prunedPlugins.join(", ")}`), + ); + } for (const w of result.hookWarnings) { console.log(chalk.yellow(` warn: ${w.message}`)); } for (const w of result.subagentWarnings) { console.log(chalk.yellow(` warn: ${w.message}`)); } + for (const w of result.pluginWarnings) { + console.log(chalk.yellow(` warn: ${w.message}`)); + } } catch (err) { if (err instanceof TrustError) { console.error(chalk.red(formatTrustError(err))); diff --git a/packages/dotagents/src/cli/commands/install/agent-runtime.ts b/packages/dotagents/src/cli/commands/install/agent-runtime.ts index 9506661..c0b1847 100644 --- a/packages/dotagents/src/cli/commands/install/agent-runtime.ts +++ b/packages/dotagents/src/cli/commands/install/agent-runtime.ts @@ -12,6 +12,8 @@ import { userSubagentResolver, writeSubagentConfigs, } from "../../../subagents/writer.js"; +import { prunePluginOutputs, writePluginOutputs } from "../../../agents/plugin-writer.js"; +import type { PluginDeclaration } from "../../../agents/plugin-store.js"; import type { SubagentDeclaration } from "../../../subagents/types.js"; /** Writes agent skill symlinks after canonical install artifacts are ready. */ @@ -88,3 +90,18 @@ export async function writeSubagentRuntime( } return result.warnings; } + +/** Writes project-scoped plugin runtime projections. */ +export async function writePluginRuntime( + config: AgentsConfig, + scope: ScopeRoot, + plugins: PluginDeclaration[], + frozen?: boolean, +): Promise<{ agent: string; name: string; message: string }[]> { + if (scope.scope !== "project") {return [];} + const result = await writePluginOutputs(config.agents, plugins, scope.root); + if (!frozen) { + await prunePluginOutputs(config.agents, plugins, scope.root); + } + return result.warnings; +} diff --git a/packages/dotagents/src/cli/commands/install/gitignore.ts b/packages/dotagents/src/cli/commands/install/gitignore.ts index 9cf8bc4..4f039ec 100644 --- a/packages/dotagents/src/cli/commands/install/gitignore.ts +++ b/packages/dotagents/src/cli/commands/install/gitignore.ts @@ -4,11 +4,13 @@ import type { Lockfile } from "../../../lockfile/schema.js"; import type { ScopeRoot } from "../../../scope.js"; import { checkRootGitignoreEntries, writeAgentsGitignore } from "../../../gitignore/writer.js"; import { isInPlaceSkill } from "../../../utils/fs.js"; +import { isInPlacePluginSource, type PluginDeclaration } from "../../../agents/plugin-store.js"; import type { SubagentDeclaration } from "../../../subagents/types.js"; export interface InstallGitignoreArtifacts { installedSkillNames: string[]; subagents: SubagentDeclaration[]; + plugins: PluginDeclaration[]; } function managedSkillNames( @@ -32,6 +34,20 @@ function managedSubagentNames( : subagents.map((subagent) => subagent.name); } +function managedPluginNames( + lockfile: Lockfile | null, + plugins: PluginDeclaration[], + frozen?: boolean, +): string[] { + return frozen + ? Object.entries(lockfile?.plugins ?? {}) + .filter(([, locked]) => !isInPlacePluginSource(locked.source)) + .map(([name]) => name) + : plugins + .filter((plugin) => !isInPlacePluginSource(plugin.source)) + .map((plugin) => plugin.name); +} + /** Regenerates project `.agents/.gitignore` from install results and lockfile state. */ export async function writeInstallGitignore( config: AgentsConfig, @@ -46,6 +62,7 @@ export async function writeInstallGitignore( scope.agentsDir, managedSkillNames(config, artifacts.installedSkillNames), managedSubagentNames(lockfile, artifacts.subagents, frozen), + managedPluginNames(lockfile, artifacts.plugins, frozen), ); const missing = await checkRootGitignoreEntries(scope.root); diff --git a/packages/dotagents/src/cli/commands/install/plugins.ts b/packages/dotagents/src/cli/commands/install/plugins.ts new file mode 100644 index 0000000..2583cc6 --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/plugins.ts @@ -0,0 +1,113 @@ +import { mkdir } from "node:fs/promises"; +import type { AgentsConfig, PluginConfig } from "../../../config/schema.js"; +import type { Lockfile } from "../../../lockfile/schema.js"; +import type { ScopeRoot } from "../../../scope.js"; +import { + installPluginBundle, + isInPlacePluginSource, + isProjectPluginSource, + loadInstalledPlugins, + lockEntryForPlugin, + type PluginDeclaration, + pruneInstalledPlugins, + resolvePlugin, +} from "../../../agents/plugin-store.js"; +import { GitError, TrustError } from "@sentry/dotagents-lib"; +import { getCacheStateDir } from "../../cache.js"; +import { InstallError } from "./errors.js"; + +export interface InstallPluginsResult { + plugins: PluginDeclaration[]; + pruned: string[]; + lockEntries: Lockfile["plugins"]; +} + +function validateFrozenPlugins( + plugins: PluginConfig[], + lockfile: Lockfile | null, +): void { + if (plugins.length === 0) {return;} + if (!lockfile) { + throw new InstallError("--frozen requires agents.lock to exist."); + } + + for (const plugin of plugins) { + if (!lockfile.plugins[plugin.name]) { + throw new InstallError( + `--frozen: plugin "${plugin.name}" is in agents.toml but missing from agents.lock.`, + ); + } + } +} + +function staleManagedPluginNames( + current: Lockfile | null, + nextPlugins: Lockfile["plugins"], +): string[] { + if (!current) {return [];} + return Object.entries(current.plugins) + .filter(([name, locked]) => !nextPlugins[name] && !isInPlacePluginSource(locked.source)) + .map(([name]) => name); +} + +/** Resolves, installs, and prunes canonical plugin bundles for install. */ +export async function installPlugins( + config: AgentsConfig, + lockfile: Lockfile | null, + scope: ScopeRoot, + frozen?: boolean, +): Promise { + const plugins: PluginDeclaration[] = []; + const pruned: string[] = []; + const lockEntries: Lockfile["plugins"] = {}; + + if (frozen) { + validateFrozenPlugins(config.plugins, lockfile); + if (config.plugins.length > 0) { + const loaded = await loadInstalledPlugins(scope.pluginsDir, config.plugins); + if (loaded.issues.length > 0) { + throw new InstallError(loaded.issues.join("\n")); + } + plugins.push(...loaded.plugins); + } + return { plugins, pruned, lockEntries }; + } + + if (config.plugins.length > 0) { + await mkdir(scope.pluginsDir, { recursive: true }); + for (const pluginConfig of config.plugins) { + let resolved: Awaited>; + try { + resolved = await resolvePlugin(pluginConfig, { + stateDir: getCacheStateDir(), + projectRoot: scope.root, + defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge: config.minimum_release_age, + minimumReleaseAgeExclude: config.minimum_release_age_exclude, + trust: config.trust, + }); + } catch (err) { + if (err instanceof GitError || err instanceof TrustError) {throw err;} + const msg = err instanceof Error ? err.message : String(err); + throw new InstallError(`Failed to resolve plugin "${pluginConfig.name}": ${msg}`); + } + if (isProjectPluginSource(resolved.plugin.pluginDir, scope.pluginsDir)) { + throw new InstallError( + `Plugin "${resolved.plugin.name}" source resolves inside this project's .agents/plugins/ tree. ` + + "Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.", + ); + } + plugins.push(await installPluginBundle(scope.pluginsDir, resolved)); + lockEntries[resolved.plugin.name] = lockEntryForPlugin(resolved); + } + } + + if (lockfile) { + pruned.push(...await pruneInstalledPlugins( + scope.pluginsDir, + staleManagedPluginNames(lockfile, lockEntries), + )); + } + + return { plugins, pruned, lockEntries }; +} diff --git a/packages/dotagents/src/cli/commands/list.test.ts b/packages/dotagents/src/cli/commands/list.test.ts index d2e0252..304009a 100644 --- a/packages/dotagents/src/cli/commands/list.test.ts +++ b/packages/dotagents/src/cli/commands/list.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { runList } from "./list.js"; +import list, { runList, runPluginList } from "./list.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { resolveScope } from "../../scope.js"; @@ -15,14 +15,18 @@ description: Test skill ${name} describe("runList", () => { let tmpDir: string; let projectRoot: string; + let cwd: string; beforeEach(async () => { + cwd = process.cwd(); tmpDir = await mkdtemp(join(tmpdir(), "dotagents-list-")); projectRoot = join(tmpDir, "project"); await mkdir(join(projectRoot, ".agents", "skills"), { recursive: true }); }); afterEach(async () => { + process.chdir(cwd); + vi.restoreAllMocks(); await rm(tmpDir, { recursive: true }); }); @@ -162,4 +166,75 @@ describe("runList", () => { expect(results).toHaveLength(1); expect(results[0]!.name).toBe("pdf"); }); + + it("reports plugin status from installed bundles and lockfile entries", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "missing-tools" +source = "org/missing" + +[[plugins]] +name = "ok-tools" +source = "org/ok" + +[[plugins]] +name = "unlocked-tools" +source = "org/unlocked" +`, + ); + await mkdir(join(projectRoot, ".agents", "plugins", "ok-tools"), { recursive: true }); + await mkdir(join(projectRoot, ".agents", "plugins", "unlocked-tools"), { recursive: true }); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + plugins: { + "ok-tools": { + source: "org/ok", + resolved_url: "https://github.com/org/ok.git", + resolved_path: ".", + }, + }, + }); + + const results = await runPluginList({ scope: resolveScope("project", projectRoot) }); + + expect(results).toEqual([ + { name: "missing-tools", source: "org/missing", status: "missing" }, + { name: "ok-tools", source: "org/ok", status: "ok" }, + { name: "unlocked-tools", source: "org/unlocked", status: "unlocked" }, + ]); + }); + + it("prints JSON with skill and plugin sections", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[skills]] +name = "pdf" +source = "org/repo" + +[[plugins]] +name = "review-tools" +source = "org/plugins" +`, + ); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + process.chdir(projectRoot); + + await list(["--json"]); + + const printed = log.mock.calls[0]?.[0]; + expect(JSON.parse(String(printed))).toEqual({ + skills: [ + { name: "pdf", source: "org/repo", status: "missing" }, + ], + plugins: [ + { name: "review-tools", source: "org/plugins", status: "missing" }, + ], + }); + }); }); diff --git a/packages/dotagents/src/cli/commands/list.ts b/packages/dotagents/src/cli/commands/list.ts index 3fa91fa..abe7a0e 100644 --- a/packages/dotagents/src/cli/commands/list.ts +++ b/packages/dotagents/src/cli/commands/list.ts @@ -17,11 +17,21 @@ export interface SkillStatus { wildcard?: string; } +export interface PluginStatus { + name: string; + source: string; + status: "ok" | "missing" | "unlocked"; +} + export interface ListOptions { scope: ScopeRoot; json?: boolean; } +export interface PluginListOptions { + scope: ScopeRoot; +} + export async function runList(opts: ListOptions): Promise { const { scope } = opts; const { configPath, lockPath, skillsDir } = scope; @@ -78,6 +88,34 @@ export async function runList(opts: ListOptions): Promise { return results; } +export async function runPluginList(opts: PluginListOptions): Promise { + const { scope } = opts; + const { configPath, lockPath, pluginsDir } = scope; + + const config = await loadConfig(configPath); + const lockfile = await loadLockfile(lockPath); + + const results: PluginStatus[] = []; + for (const plugin of config.plugins.toSorted((a, b) => a.name.localeCompare(b.name))) { + const locked = lockfile?.plugins[plugin.name]; + const installed = join(pluginsDir, plugin.name); + + if (!existsSync(installed)) { + results.push({ name: plugin.name, source: plugin.source, status: "missing" }); + continue; + } + + if (!locked) { + results.push({ name: plugin.name, source: plugin.source, status: "unlocked" }); + continue; + } + + results.push({ name: plugin.name, source: plugin.source, status: "ok" }); + } + + return results; +} + function formatStatus(s: SkillStatus): string { const source = chalk.dim(s.source); const wildcard = s.wildcard ? chalk.dim(" (* wildcard)") : ""; @@ -92,6 +130,19 @@ function formatStatus(s: SkillStatus): string { } } +function formatPluginStatus(s: PluginStatus): string { + const source = chalk.dim(s.source); + + switch (s.status) { + case "ok": + return ` ${chalk.green("✓")} ${s.name} ${source}`; + case "missing": + return ` ${chalk.red("✗")} ${s.name} ${source} ${chalk.red("not installed")}`; + case "unlocked": + return ` ${chalk.yellow("?")} ${s.name} ${source} ${chalk.yellow("not in lockfile")}`; + } +} + export default async function list(args: string[], flags?: { user?: boolean }): Promise { const { values } = parseArgs({ args, @@ -117,19 +168,31 @@ export default async function list(args: string[], flags?: { user?: boolean }): scope, json: values["json"], }); + const pluginResults = await runPluginList({ + scope, + }); - if (results.length === 0) { - console.log(chalk.dim("No skills declared in agents.toml.")); + if (results.length === 0 && pluginResults.length === 0) { + console.log(chalk.dim("No skills or plugins declared in agents.toml.")); return; } if (values["json"]) { - console.log(JSON.stringify(results, null, 2)); + console.log(JSON.stringify({ skills: results, plugins: pluginResults }, null, 2)); return; } - console.log(chalk.bold("Skills:")); - for (const s of results) { - console.log(formatStatus(s)); + if (results.length > 0) { + console.log(chalk.bold("Skills:")); + for (const s of results) { + console.log(formatStatus(s)); + } + } + if (pluginResults.length > 0) { + if (results.length > 0) {console.log("");} + console.log(chalk.bold("Plugins:")); + for (const p of pluginResults) { + console.log(formatPluginStatus(p)); + } } } diff --git a/packages/dotagents/src/cli/commands/mcp.test.ts b/packages/dotagents/src/cli/commands/mcp.test.ts index 9780a41..0229a37 100644 --- a/packages/dotagents/src/cli/commands/mcp.test.ts +++ b/packages/dotagents/src/cli/commands/mcp.test.ts @@ -27,6 +27,7 @@ describe("mcp", () => { root: projectRoot, agentsDir: join(projectRoot, ".agents"), skillsDir: join(projectRoot, ".agents", "skills"), + pluginsDir: join(projectRoot, ".agents", "plugins"), configPath: join(projectRoot, "agents.toml"), lockPath: join(projectRoot, "agents.lock"), }; diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index 9a7332b..30c85dd 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -102,6 +102,45 @@ describe("runRemove", () => { expect(gitignore).toContain("/agents/old-reviewer.md"); }); + it("keeps managed plugins in .agents/.gitignore after removing a skill", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[skills]] +name = "pdf" +source = "path:local-skills/pdf" + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + await mkdir(join(projectRoot, ".agents", "skills", "pdf"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"), SKILL_MD("pdf")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + pdf: { + source: "path:local-skills/pdf", + }, + }, + plugins: { + "review-tools": { + source: "path:plugins/review-tools", + }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await runRemove({ scope, skillName: "pdf" }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/skills/pdf/"); + expect(gitignore).toContain("/plugins/review-tools/"); + expect(gitignore).toContain("/plugins/marketplace.json"); + }); + it("throws RemoveError for skill not in config", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); const scope = resolveScope("project", projectRoot); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index ec3dd93..36563eb 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -13,6 +13,7 @@ import { sourcesMatch, parseOwnerRepoShorthand, isExplicitSourceSpecifier } from import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { isInPlaceSkill } from "../../utils/fs.js"; +import { isInPlacePluginSource } from "../../agents/plugin-store.js"; export class RemoveError extends Error { constructor(message: string) { @@ -162,10 +163,23 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { managedSubagentNames.add(name); } } + const managedPluginNames = new Set( + config.plugins + .filter((plugin) => !isInPlacePluginSource(plugin.source)) + .map((plugin) => plugin.name), + ); + if (lockfile) { + for (const [name, locked] of Object.entries(lockfile.plugins)) { + if (!isInPlacePluginSource(locked.source)) { + managedPluginNames.add(name); + } + } + } await writeAgentsGitignore( scope.agentsDir, managedNames, [...managedSubagentNames], + [...managedPluginNames], ); } diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 54da37d..f72f02d 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -621,6 +621,151 @@ agents = ["claude"] expect(gitignore).not.toContain("/agents/old-reviewer.md"); }); + it("repairs plugin runtime artifacts from installed plugin bundles", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + version: "1.0.0", + description: "Review workflow helpers", + }, null, 2), + ); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex", "claude", "cursor"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.pluginsRepaired).toBeGreaterThan(0); + expect(result.issues).toEqual([ + { + type: "plugins", + name: "marketplace", + message: `Plugin marketplace missing: ${join(projectRoot, ".agents", "plugins", "marketplace.json")}`, + }, + { + type: "plugins", + name: "marketplace", + message: `Plugin marketplace missing: ${join(projectRoot, ".claude-plugin", "marketplace.json")}`, + }, + { + type: "plugins", + name: "marketplace", + message: `Plugin marketplace missing: ${join(projectRoot, ".cursor-plugin", "marketplace.json")}`, + }, + { + type: "plugins", + name: "review-tools", + message: `Codex plugin manifest missing: ${join(pluginDir, ".codex-plugin", "plugin.json")}`, + }, + ]); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(true); + expect(existsSync(join(projectRoot, ".claude-plugin", "marketplace.json"))).toBe(true); + expect(existsSync(join(projectRoot, ".cursor-plugin", "marketplace.json"))).toBe(true); + }); + + it("reports same-project plugins without generating runtime outputs", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ + name: "local-tools", + description: "Local plugin", + }, null, 2), + ); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "local-tools" +source = "path:./.agents/plugins/local-tools" +`, + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.issues).toContainEqual({ + type: "plugins", + name: "local-tools", + message: 'Plugin "local-tools" resolves to .agents/plugins/local-tools. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.', + }); + expect(existsSync(pluginDir)).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); + expect(existsSync(join(pluginDir, ".codex-plugin", "plugin.json"))).toBe(false); + }); + + it("reports same-project plugins resolved through path aliases", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" }, null, 2)); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "local-tools" +source = "path:." +path = ".agents/plugins/local-tools" +`, + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.issues).toContainEqual({ + type: "plugins", + name: "local-tools", + message: 'Plugin "local-tools" resolves to .agents/plugins/local-tools. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.', + }); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); + expect(existsSync(join(pluginDir, ".codex-plugin", "plugin.json"))).toBe(false); + }); + + it("only prunes stale managed plugin directories from the lockfile", async () => { + await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); + await mkdir(join(projectRoot, ".agents", "plugins", "stale-managed"), { recursive: true }); + await mkdir(join(projectRoot, ".agents", "plugins", "local-unmanaged"), { recursive: true }); + await writeFile( + join(projectRoot, ".agents", "plugins", "local-unmanaged", "plugin.json"), + JSON.stringify({ name: "local-unmanaged" }, null, 2), + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "stale-managed": { + source: "getsentry/plugins", + resolved_url: "https://github.com/getsentry/plugins.git", + resolved_path: "stale-managed", + }, + }, + }); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.pluginsRepaired).toBe(1); + expect(existsSync(join(projectRoot, ".agents", "plugins", "stale-managed"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "plugins", "local-unmanaged"))).toBe(true); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins).toEqual({}); + }); + it("does not auto-create root .gitignore", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 21c8ce1..6cbc313 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -1,10 +1,10 @@ -import { join, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import { existsSync } from "node:fs"; import { readdir, rm } from "node:fs/promises"; import chalk from "chalk"; import { loadConfig } from "../../config/loader.js"; import { isWildcardDep } from "../../config/schema.js"; -import { normalizeSource } from "@sentry/dotagents-lib"; +import { normalizeSource, parseSource } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { addSkillToConfig } from "../../config/writer.js"; @@ -15,13 +15,15 @@ import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolve import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../targets/hook-writer.js"; import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../subagents/writer.js"; import { loadInstalledSubagents, pruneInstalledSubagents } from "../../subagents/store.js"; +import { isInPlacePluginSource, isProjectPluginSource, loadInstalledPlugins, pruneInstalledPlugins } from "../../agents/plugin-store.js"; +import { prunePluginOutputs, verifyPluginOutputs, writePluginOutputs } from "../../agents/plugin-writer.js"; import { userMcpResolver } from "../../targets/paths.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { isInPlaceSkill, managedSkillPath } from "../../utils/fs.js"; export interface SyncIssue { - type: "symlink" | "missing" | "mcp" | "hooks" | "subagents"; + type: "symlink" | "missing" | "mcp" | "hooks" | "subagents" | "plugins"; name: string; message: string; } @@ -39,11 +41,12 @@ export interface SyncResult { mcpRepaired: number; hooksRepaired: number; subagentsRepaired: number; + pluginsRepaired: number; } export async function runSync(opts: SyncOptions): Promise { const { scope } = opts; - const { configPath, lockPath, agentsDir, skillsDir } = scope; + const { configPath, lockPath, agentsDir, skillsDir, pluginsDir } = scope; const subagentsDir = join(agentsDir, "agents"); let config = await loadConfig(configPath); @@ -67,6 +70,22 @@ export async function runSync(opts: SyncOptions): Promise { const issues: SyncIssue[] = []; const adopted: string[] = []; const pruned: string[] = []; + const selfInstalledPluginNames = new Set( + scope.scope === "project" + ? config.plugins + .filter((plugin) => isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) + .map((plugin) => plugin.name) + : [], + ); + const runtimePluginConfigs = config.plugins.filter((plugin) => !selfInstalledPluginNames.has(plugin.name)); + + for (const name of selfInstalledPluginNames) { + issues.push({ + type: "plugins", + name, + message: `Plugin "${name}" resolves to .agents/plugins/${name}. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.`, + }); + } // 1. Adopt orphaned skills (installed but not in agents.toml) if (existsSync(skillsDir)) { @@ -100,6 +119,7 @@ export async function runSync(opts: SyncOptions): Promise { version: 1, skills: { ...lockfile?.skills, ...adoptedLockEntries }, subagents: lockfile?.subagents ?? {}, + plugins: lockfile?.plugins ?? {}, }; await writeLockfile(lockPath, lockfile); } @@ -116,6 +136,20 @@ export async function runSync(opts: SyncOptions): Promise { lockfile = { ...lockfile, subagents }; await writeLockfile(lockPath, lockfile); } + const declaredPluginNames = new Set(config.plugins.map((plugin) => plugin.name)); + const staleManagedPluginNames: string[] = []; + if (lockfile && Object.keys(lockfile.plugins).some((name) => !declaredPluginNames.has(name))) { + for (const [name, locked] of Object.entries(lockfile.plugins)) { + if (declaredPluginNames.has(name)) {continue;} + if (isInPlacePluginSource(locked.source)) {continue;} + staleManagedPluginNames.push(name); + } + const plugins = Object.fromEntries( + Object.entries(lockfile.plugins).filter(([name]) => declaredPluginNames.has(name)), + ); + lockfile = { ...lockfile, plugins }; + await writeLockfile(lockPath, lockfile); + } // 2. Regenerate .agents/.gitignore (skip for user scope) let gitignoreUpdated = false; @@ -137,10 +171,21 @@ export async function runSync(opts: SyncOptions): Promise { managedSubagentNames.add(name); } } + const managedPluginNames = new Set(config.plugins + .filter((plugin) => !isInPlacePluginSource(plugin.source)) + .map((plugin) => plugin.name)); + if (lockNow) { + for (const [name, locked] of Object.entries(lockNow.plugins)) { + if (!isInPlacePluginSource(locked.source)) { + managedPluginNames.add(name); + } + } + } await writeAgentsGitignore( agentsDir, managedNames, [...managedSubagentNames], + [...managedPluginNames], ); gitignoreUpdated = true; @@ -161,6 +206,15 @@ export async function runSync(opts: SyncOptions): Promise { }); } } + for (const plugin of runtimePluginConfigs) { + if (!existsSync(join(pluginsDir, plugin.name))) { + issues.push({ + type: "plugins", + name: plugin.name, + message: `Plugin "${plugin.name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, + }); + } + } // 4. Verify and repair symlinks let symlinksRepaired = 0; @@ -302,6 +356,44 @@ export async function runSync(opts: SyncOptions): Promise { } } + // 8. Verify and repair plugin runtime projections + let pluginsRepaired = 0; + const installedPluginResult = await loadInstalledPlugins(pluginsDir, runtimePluginConfigs); + const pluginDecls = installedPluginResult.plugins; + const prunedInstalledPlugins = await pruneInstalledPlugins(pluginsDir, staleManagedPluginNames); + const pluginIssues = scope.scope === "project" + ? await verifyPluginOutputs(config.agents, pluginDecls, scope.root) + : []; + + if (scope.scope === "project") { + const pluginResult = await writePluginOutputs(config.agents, pluginDecls, scope.root); + const prunedPluginOutputs = await prunePluginOutputs(config.agents, pluginDecls, scope.root); + pluginsRepaired = pluginResult.written + prunedPluginOutputs.length + prunedInstalledPlugins.length; + + for (const warning of pluginResult.warnings) { + issues.push({ + type: "plugins", + name: warning.name, + message: warning.message, + }); + } + } + + for (const issue of installedPluginResult.issues) { + issues.push({ + type: "plugins", + name: "plugin", + message: issue, + }); + } + for (const issue of pluginIssues) { + issues.push({ + type: "plugins", + name: issue.name, + message: issue.issue, + }); + } + return { issues, adopted, @@ -311,9 +403,31 @@ export async function runSync(opts: SyncOptions): Promise { mcpRepaired, hooksRepaired, subagentsRepaired, + pluginsRepaired, }; } +function isSameProjectPluginConfig( + plugin: { source: string; path?: string }, + pluginsDir: string, + projectRoot: string, +): boolean { + if (isInPlacePluginSource(plugin.source)) {return true;} + if (!plugin.path) {return false;} + + try { + const parsed = parseSource(plugin.source); + if (parsed.type !== "local" || !parsed.path) {return false;} + const sourceDir = resolve(projectRoot, parsed.path); + const pluginDir = resolve(sourceDir, plugin.path); + const relPath = relative(sourceDir, pluginDir); + if (relPath.startsWith("..") || isAbsolute(relPath)) {return false;} + return isProjectPluginSource(pluginDir, pluginsDir); + } catch { + return false; + } +} + async function removeStaleManagedSkill(skillsDir: string, name: string): Promise { const skillPath = managedSkillPath(skillsDir, name); if (!skillPath) {return false;} @@ -364,6 +478,10 @@ export default async function sync(_args: string[], flags?: { user?: boolean }): console.log(chalk.green(`Repaired ${result.subagentsRepaired} subagent config(s)`)); } + if (result.pluginsRepaired > 0) { + console.log(chalk.green(`Repaired ${result.pluginsRepaired} plugin artifact(s)`)); + } + if (result.issues.length === 0) { console.log(chalk.green("Everything in sync.")); return; @@ -374,6 +492,7 @@ export default async function sync(_args: string[], flags?: { user?: boolean }): case "mcp": case "hooks": case "subagents": + case "plugins": console.log(chalk.yellow(` warn: ${issue.message}`)); break; case "missing": diff --git a/packages/dotagents/src/cli/commands/trust.test.ts b/packages/dotagents/src/cli/commands/trust.test.ts index 6ee0aad..bd20dd7 100644 --- a/packages/dotagents/src/cli/commands/trust.test.ts +++ b/packages/dotagents/src/cli/commands/trust.test.ts @@ -33,6 +33,7 @@ describe("trust", () => { root: projectRoot, agentsDir: join(projectRoot, ".agents"), skillsDir: join(projectRoot, ".agents", "skills"), + pluginsDir: join(projectRoot, ".agents", "plugins"), configPath: join(projectRoot, "agents.toml"), lockPath: join(projectRoot, "agents.lock"), }; diff --git a/packages/dotagents/src/cli/ensure-user-scope.test.ts b/packages/dotagents/src/cli/ensure-user-scope.test.ts index fc0ffcb..769bf29 100644 --- a/packages/dotagents/src/cli/ensure-user-scope.test.ts +++ b/packages/dotagents/src/cli/ensure-user-scope.test.ts @@ -28,6 +28,7 @@ describe("ensureUserScopeBootstrapped", () => { configPath: join(home, "agents.toml"), lockPath: join(home, "agents.lock"), skillsDir: join(home, "skills"), + pluginsDir: join(home, "plugins"), }; } @@ -82,6 +83,7 @@ describe("ensureUserScopeBootstrapped", () => { configPath: join(tmpDir, "agents.toml"), lockPath: join(tmpDir, "agents.lock"), skillsDir: join(tmpDir, ".agents", "skills"), + pluginsDir: join(tmpDir, ".agents", "plugins"), }; await ensureUserScopeBootstrapped(scope); diff --git a/packages/dotagents/src/cli/index.ts b/packages/dotagents/src/cli/index.ts index 84cb7ee..a61bdfb 100644 --- a/packages/dotagents/src/cli/index.ts +++ b/packages/dotagents/src/cli/index.ts @@ -32,7 +32,7 @@ Commands: add Add a skill dependency remove Remove a skill or all skills from a source sync Reconcile state offline and repair generated config - list Show installed skills + list Show installed skills and plugins mcp Manage MCP server declarations trust Manage trusted sources doctor Check project health and fix issues diff --git a/packages/dotagents/src/config/loader.test.ts b/packages/dotagents/src/config/loader.test.ts index 8ab52e0..bec44d2 100644 --- a/packages/dotagents/src/config/loader.test.ts +++ b/packages/dotagents/src/config/loader.test.ts @@ -246,4 +246,74 @@ source = "https://agents.example.com" await expect(loadConfig(configPath)).rejects.toThrow(/unsupported HTTPS well-known source/); }); + + it("loads plugin entries", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 +agents = ["claude", "codex", "cursor", "grok", "opencode"] + +[[plugins]] +name = "review-tools" +source = "getsentry/plugins" +targets = ["claude", "codex", "cursor", "grok", "opencode"] +`, + ); + + const config = await loadConfig(configPath); + expect(config.plugins).toHaveLength(1); + expect(config.plugins[0]!.targets).toEqual(["claude", "codex", "cursor", "grok", "opencode"]); + expect(config.plugins[0]!.source).toBe("getsentry/plugins"); + }); + + it("rejects unknown plugin targets", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[plugins]] +name = "review-tools" +source = "getsentry/plugins" +targets = ["emacs"] +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/Unknown plugin target/); + }); + + it("rejects duplicate plugin names", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[plugins]] +name = "review-tools" +source = "getsentry/plugins" + +[[plugins]] +name = "review-tools" +source = "getsentry/plugins" +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/Duplicate plugin/); + }); + + it("rejects HTTPS well-known plugin sources", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[plugins]] +name = "review-tools" +source = "https://plugins.example.com" +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/unsupported HTTPS well-known source/); + }); }); diff --git a/packages/dotagents/src/config/loader.ts b/packages/dotagents/src/config/loader.ts index 8385721..4da4cbb 100644 --- a/packages/dotagents/src/config/loader.ts +++ b/packages/dotagents/src/config/loader.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { parse as parseTOML } from "smol-toml"; import { agentsConfigSchema, isWildcardDep, type AgentsConfig } from "./schema.js"; -import { allAgentIds } from "../targets/registry.js"; +import { allAgentIds, allConfigAgentIds } from "../targets/registry.js"; import { applyDefaultRepositorySource, parseSource } from "@sentry/dotagents-lib"; export class ConfigError extends Error { @@ -36,7 +36,8 @@ export async function loadConfig(filePath: string): Promise { } // Post-parse validation: reject unknown agent IDs - const validIds = allAgentIds(); + const validIds = allConfigAgentIds(); + const registryAgentIds = allAgentIds(); const unknown = result.data.agents.filter((id) => !validIds.includes(id)); if (unknown.length > 0) { throw new ConfigError( @@ -47,13 +48,26 @@ export async function loadConfig(filePath: string): Promise { const unknownSubagentTargets = [ ...new Set( result.data.subagents.flatMap((subagent) => - (subagent.targets ?? []).filter((id) => !validIds.includes(id)) + (subagent.targets ?? []).filter((id) => !registryAgentIds.includes(id)) ), ), ]; if (unknownSubagentTargets.length > 0) { throw new ConfigError( - `Unknown subagent target(s) in ${filePath}: ${unknownSubagentTargets.join(", ")}. Valid agents: ${validIds.join(", ")}`, + `Unknown subagent target(s) in ${filePath}: ${unknownSubagentTargets.join(", ")}. Valid agents: ${registryAgentIds.join(", ")}`, + ); + } + + const unknownPluginTargets = [ + ...new Set( + result.data.plugins.flatMap((plugin) => + (plugin.targets ?? []).filter((id) => !validIds.includes(id)) + ), + ), + ]; + if (unknownPluginTargets.length > 0) { + throw new ConfigError( + `Unknown plugin target(s) in ${filePath}: ${unknownPluginTargets.join(", ")}. Valid agents: ${validIds.join(", ")}`, ); } @@ -69,6 +83,18 @@ export async function loadConfig(filePath: string): Promise { } } + for (const plugin of result.data.plugins) { + const sourceForResolve = applyDefaultRepositorySource( + plugin.source, + result.data.defaultRepositorySource, + ); + if (parseSource(sourceForResolve).type === "well-known") { + throw new ConfigError( + `Plugin "${plugin.name}" uses an unsupported HTTPS well-known source in ${filePath}. Use a git: URL, GitHub/GitLab repository, or path: source.`, + ); + } + } + // Post-parse validation: no two wildcard entries may share the same source const wildcardSources = new Set(); for (const dep of result.data.skills) { @@ -93,5 +119,16 @@ export async function loadConfig(filePath: string): Promise { subagentNames.add(subagent.name); } + // Post-parse validation: no two plugin entries may share the same name. + const pluginNames = new Set(); + for (const plugin of result.data.plugins) { + if (pluginNames.has(plugin.name)) { + throw new ConfigError( + `Duplicate plugin in ${filePath}: "${plugin.name}". Plugin names must be unique.`, + ); + } + pluginNames.add(plugin.name); + } + return result.data; } diff --git a/packages/dotagents/src/config/schema.test.ts b/packages/dotagents/src/config/schema.test.ts index ddfcf73..95f6272 100644 --- a/packages/dotagents/src/config/schema.test.ts +++ b/packages/dotagents/src/config/schema.test.ts @@ -8,6 +8,7 @@ describe("agentsConfigSchema", () => { if (result.success) { expect(result.data.version).toBe(1); expect(result.data.skills).toEqual([]); + expect(result.data.plugins).toEqual([]); } }); @@ -306,6 +307,61 @@ describe("agentsConfigSchema", () => { }); }); + describe("plugins field", () => { + it("defaults to empty array when absent", () => { + const result = agentsConfigSchema.safeParse({ version: 1 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.plugins).toEqual([]); + } + }); + + it("accepts a portable plugin declaration", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + agents: ["claude", "codex", "cursor", "grok", "opencode"], + plugins: [ + { + name: "review-tools", + source: "getsentry/plugins", + targets: ["claude", "codex", "cursor", "grok", "opencode"], + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.plugins[0]!.name).toBe("review-tools"); + } + }); + + it("rejects runtime-specific plugin declaration options", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + plugins: [ + { + name: "review-tools", + source: "getsentry/plugins", + codex: { enabled: true }, + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid plugin names", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + plugins: [ + { + name: "ReviewTools", + source: "getsentry/plugins", + }, + ], + }); + expect(result.success).toBe(false); + }); + }); + describe("mcp field", () => { it("defaults to empty array when absent", () => { const result = agentsConfigSchema.safeParse({ version: 1 }); diff --git a/packages/dotagents/src/config/schema.ts b/packages/dotagents/src/config/schema.ts index 933deec..3a772f0 100644 --- a/packages/dotagents/src/config/schema.ts +++ b/packages/dotagents/src/config/schema.ts @@ -169,6 +169,27 @@ const subagentSchema = z.object({ export type SubagentConfig = z.infer; +export const PLUGIN_NAME_PATTERN = /^[a-z][a-z0-9.-]*[a-z0-9]$|^[a-z]$/; + +const pluginNameSchema = z + .string() + .regex( + PLUGIN_NAME_PATTERN, + "Plugin names must start with lowercase a-z, end with lowercase a-z or 0-9, and contain only lowercase letters, numbers, hyphens, and dots", + ); + +const pluginTargetSchema = z.string().min(1); + +const pluginSchema = z.object({ + name: pluginNameSchema, + source: skillSourceSchema, + ref: z.string().optional(), + path: z.string().optional(), + targets: z.array(pluginTargetSchema).optional(), +}).strict(); + +export type PluginConfig = z.infer; + const trustConfigSchema = z.object({ allow_all: z.boolean().default(false), github_orgs: z.array(z.string()).default([]), @@ -190,6 +211,7 @@ export const agentsConfigSchema = z.object({ mcp: z.array(mcpSchema).default([]), hooks: z.array(hookSchema).default([]), subagents: z.array(subagentSchema).default([]), + plugins: z.array(pluginSchema).default([]), trust: trustConfigSchema.optional(), minimum_release_age: z.number().int().min(0).optional(), minimum_release_age_exclude: z.array(z.string()).default([]), diff --git a/packages/dotagents/src/gitignore/writer.test.ts b/packages/dotagents/src/gitignore/writer.test.ts index 153e56b..93c8e99 100644 --- a/packages/dotagents/src/gitignore/writer.test.ts +++ b/packages/dotagents/src/gitignore/writer.test.ts @@ -44,6 +44,15 @@ describe("writeAgentsGitignore", () => { expect(content).toContain("/agents/test-runner.md"); }); + it("lists managed plugin directories and generated marketplace", async () => { + const agentsDir = join(dir, ".agents"); + await writeAgentsGitignore(agentsDir, [], [], ["review-tools"]); + + const content = await readFile(join(agentsDir, ".gitignore"), "utf-8"); + expect(content).toContain("/plugins/review-tools/"); + expect(content).toContain("/plugins/marketplace.json"); + }); + it("sorts skill names alphabetically", async () => { const agentsDir = join(dir, ".agents"); await writeAgentsGitignore(agentsDir, ["zebra", "alpha", "middle"]); diff --git a/packages/dotagents/src/gitignore/writer.ts b/packages/dotagents/src/gitignore/writer.ts index 8323902..e0e0d92 100644 --- a/packages/dotagents/src/gitignore/writer.ts +++ b/packages/dotagents/src/gitignore/writer.ts @@ -13,6 +13,7 @@ export async function writeAgentsGitignore( agentsDir: string, managedSkillNames: string[], managedSubagentNames: string[] = [], + managedPluginNames: string[] = [], ): Promise { const lines = [HEADER]; for (const name of managedSkillNames.toSorted()) { @@ -21,6 +22,12 @@ export async function writeAgentsGitignore( for (const name of managedSubagentNames.toSorted()) { lines.push(`/agents/${name}.md`); } + for (const name of managedPluginNames.toSorted()) { + lines.push(`/plugins/${name}/`); + } + if (managedPluginNames.length > 0) { + lines.push("/plugins/marketplace.json"); + } lines.push(""); // trailing newline await writeFile(join(agentsDir, ".gitignore"), lines.join("\n"), "utf-8"); diff --git a/packages/dotagents/src/lockfile/schema.test.ts b/packages/dotagents/src/lockfile/schema.test.ts index 8fa14ef..4c840c0 100644 --- a/packages/dotagents/src/lockfile/schema.test.ts +++ b/packages/dotagents/src/lockfile/schema.test.ts @@ -38,4 +38,26 @@ describe("lockfileSchema", () => { }); expect(result.success).toBe(false); }); + + it("accepts plugin lock entries", () => { + const result = lockfileSchema.safeParse({ + version: 1, + skills: {}, + subagents: {}, + plugins: { + "review-tools": { + source: "getsentry/plugins", + resolved_url: "https://github.com/getsentry/plugins.git", + resolved_path: "review-tools", + resolved_ref: "v1.0.0", + resolved_commit: "abc123", + }, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.plugins["review-tools"]!.source).toBe("getsentry/plugins"); + } + }); }); diff --git a/packages/dotagents/src/lockfile/schema.ts b/packages/dotagents/src/lockfile/schema.ts index fc47205..e5af7be 100644 --- a/packages/dotagents/src/lockfile/schema.ts +++ b/packages/dotagents/src/lockfile/schema.ts @@ -23,14 +23,20 @@ const lockedLocalSubagentSchema = z.object({ source: z.string(), }).strict(); const lockedSubagentSchema = z.union([lockedGitSkillSchema, lockedLocalSubagentSchema]); +const lockedLocalPluginSchema = z.object({ + source: z.string(), +}).strict(); +const lockedPluginSchema = z.union([lockedGitSkillSchema, lockedLocalPluginSchema]); export type LockedSkill = z.infer; export type LockedSubagent = z.infer; +export type LockedPlugin = z.infer; export const lockfileSchema = z.object({ version: z.literal(1), skills: z.record(z.string(), lockedSkillSchema).default({}), subagents: z.record(z.string(), lockedSubagentSchema).default({}), + plugins: z.record(z.string(), lockedPluginSchema).default({}), }); export type Lockfile = z.infer; diff --git a/packages/dotagents/src/lockfile/writer.test.ts b/packages/dotagents/src/lockfile/writer.test.ts index b9f13cb..ea2bfa7 100644 --- a/packages/dotagents/src/lockfile/writer.test.ts +++ b/packages/dotagents/src/lockfile/writer.test.ts @@ -90,6 +90,27 @@ describe("writeLockfile + loadLockfile", () => { expect(keys).toEqual(["a-reviewer", "z-reviewer"]); }); + it("sorts plugins alphabetically", async () => { + const lockPath = join(dir, "agents.lock"); + await writeLockfile(lockPath, { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "z-plugin": { + source: "org/z-repo", + }, + "a-plugin": { + source: "org/a-repo", + }, + }, + }); + + const loaded = await loadLockfile(lockPath); + const keys = Object.keys(loaded!.plugins); + expect(keys).toEqual(["a-plugin", "z-plugin"]); + }); + it("omits empty subagents from the serialized lockfile", async () => { const lockPath = join(dir, "agents.lock"); await writeLockfile(lockPath, { @@ -105,6 +126,22 @@ describe("writeLockfile + loadLockfile", () => { expect(loaded!.subagents).toEqual({}); }); + it("omits empty plugins from the serialized lockfile", async () => { + const lockPath = join(dir, "agents.lock"); + await writeLockfile(lockPath, { + version: 1, + skills: {}, + subagents: {}, + plugins: {}, + }); + + const content = await readFile(lockPath, "utf-8"); + expect(content).not.toContain("[plugins]"); + + const loaded = await loadLockfile(lockPath); + expect(loaded!.plugins).toEqual({}); + }); + it("ends with exactly one trailing newline", async () => { const lockPath = join(dir, "agents.lock"); await writeLockfile(lockPath, { diff --git a/packages/dotagents/src/lockfile/writer.ts b/packages/dotagents/src/lockfile/writer.ts index 0a5b057..19fb72c 100644 --- a/packages/dotagents/src/lockfile/writer.ts +++ b/packages/dotagents/src/lockfile/writer.ts @@ -4,8 +4,9 @@ import type { Lockfile } from "./schema.js"; const HEADER = "# Auto-generated by dotagents. Do not edit.\n"; -type WritableLockfile = Omit & { +type WritableLockfile = Omit & { subagents?: Lockfile["subagents"]; + plugins?: Lockfile["plugins"]; }; /** @@ -26,6 +27,11 @@ export async function writeLockfile( for (const name of Object.keys(subagents).toSorted()) { sortedSubagents[name] = subagents[name]; } + const sortedPlugins: Record = {}; + const plugins = lockfile.plugins ?? {}; + for (const name of Object.keys(plugins).toSorted()) { + sortedPlugins[name] = plugins[name]; + } const doc: Record = { version: lockfile.version, @@ -34,6 +40,9 @@ export async function writeLockfile( if (Object.keys(sortedSubagents).length > 0) { doc["subagents"] = sortedSubagents; } + if (Object.keys(sortedPlugins).length > 0) { + doc["plugins"] = sortedPlugins; + } const toml = stringify(doc); await writeFile(filePath, `${(HEADER + toml).trimEnd()}\n`, "utf-8"); diff --git a/packages/dotagents/src/scope.ts b/packages/dotagents/src/scope.ts index afd2086..055a2f8 100644 --- a/packages/dotagents/src/scope.ts +++ b/packages/dotagents/src/scope.ts @@ -16,6 +16,8 @@ export interface ScopeRoot { lockPath: string; /** skills/ directory */ skillsDir: string; + /** plugins/ directory */ + pluginsDir: string; } /** @@ -34,6 +36,7 @@ export function resolveScope(scope: Scope, projectRoot?: string): ScopeRoot { configPath: join(home, "agents.toml"), lockPath: join(home, "agents.lock"), skillsDir: join(home, "skills"), + pluginsDir: join(home, "plugins"), }; } @@ -46,6 +49,7 @@ export function resolveScope(scope: Scope, projectRoot?: string): ScopeRoot { configPath: join(root, "agents.toml"), lockPath: join(root, "agents.lock"), skillsDir: join(agentsDir, "skills"), + pluginsDir: join(agentsDir, "plugins"), }; } diff --git a/packages/dotagents/src/targets/registry.ts b/packages/dotagents/src/targets/registry.ts index 8bffe05..aa1c6be 100644 --- a/packages/dotagents/src/targets/registry.ts +++ b/packages/dotagents/src/targets/registry.ts @@ -6,6 +6,8 @@ import vscode from "./definitions/vscode.js"; import opencode from "./definitions/opencode.js"; const ALL_AGENTS: AgentDefinition[] = [claude, cursor, codex, vscode, opencode]; +const PLUGIN_ONLY_AGENT_IDS = ["grok"]; +const PLUGIN_AGENT_IDS = ["claude", "cursor", "codex", "grok", "opencode"]; const AGENT_REGISTRY = new Map( ALL_AGENTS.map((a) => [a.id, a]), @@ -19,6 +21,14 @@ export function allAgentIds(): string[] { return [...AGENT_REGISTRY.keys()]; } +export function allConfigAgentIds(): string[] { + return [...new Set([...allAgentIds(), ...PLUGIN_ONLY_AGENT_IDS])]; +} + +export function allPluginAgentIds(): string[] { + return PLUGIN_AGENT_IDS; +} + export function allAgents(): AgentDefinition[] { return ALL_AGENTS; } diff --git a/specs/SPEC.md b/specs/SPEC.md index f44ff7b..8a7b659 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -2,9 +2,9 @@ ## Overview -dotagents is shared tooling for coding agents. It manages agent skill dependencies using the [agentskills.io](https://agentskills.io) standard, and handles MCP servers, hooks, subagents, and symlinks so that multiple agent tools (Claude Code, Cursor, Codex, etc.) can be configured from a single `agents.toml`. +dotagents is shared tooling for coding agents. It manages agent skill dependencies using the [agentskills.io](https://agentskills.io) standard, and handles MCP servers, hooks, subagents, plugins, and symlinks so that multiple agent tools (Claude Code, Cursor, Codex, etc.) can be configured from a single `agents.toml`. -Declare what you need, run `dotagents install`, and skills appear in `.agents/skills/` with symlinks into each tool's expected directory. MCP, hook, and subagent configs are generated per agent. +Declare what you need, run `dotagents install`, and skills appear in `.agents/skills/` with symlinks into each tool's expected directory. Plugins install into `.agents/plugins/`. MCP, hook, subagent, and plugin configs are generated per agent. > **Implementation note.** The skill-loading, source-fetching, and trust-validation primitives that drive the CLI are factored into a separate npm package, [`@sentry/dotagents-lib`](../packages/dotagents-lib/), versioned in lock-step with `@sentry/dotagents`. The `agents.toml` grammar and the `.agents/` convention described below remain entirely the host's responsibility — the lib only knows about source strings, SKILL.md, and the cache. @@ -15,8 +15,9 @@ Agent skills, MCP servers, hooks, and subagents are configured differently for e ### Key Principles - **`.agents/skills/` is the canonical home** for all skills (managed and custom) +- **`.agents/plugins/` is the canonical home** for all plugins (managed and custom) - **`agents.toml`** declares what you want; **`agents.lock`** tracks what's managed -- **Selective gitignore**: managed skills and canonical installed subagents are gitignored, custom skills are tracked +- **Selective gitignore**: managed skills, canonical installed subagents, and managed plugin bundles are gitignored; custom skills and project-authored plugin source directories are tracked - **Subdirectory symlinks**: `.claude/skills/ -> .agents/skills/`, not full directory symlinks - **agentskills.io format**: skills are folders with a `SKILL.md` file containing YAML frontmatter @@ -76,6 +77,12 @@ headers = { X-Api-Key = "${API_KEY}" } name = "code-reviewer" source = "getsentry/agent-pack" targets = ["claude", "codex", "opencode"] + +[[plugins]] +name = "review-tools" +source = "getsentry/agent-plugins" +path = "plugins/review-tools" +targets = ["claude", "cursor", "codex", "grok", "opencode"] ``` ### Fields @@ -86,27 +93,28 @@ targets = ["claude", "codex", "opencode"] |-------|----------|-------------| | `version` | Yes | Schema version. Always `1`. | | `defaultRepositorySource` | No | Host used for shorthand `owner/repo` skill sources. Valid values: `github`, `gitlab`. Defaults to `github`. | -| `agents` | No | Array of agent tool IDs. Valid: `claude`, `cursor`, `codex`, `vscode`, `opencode`. Defaults to `[]`. When set, dotagents creates skills symlinks and MCP config files for each agent. | +| `agents` | No | Array of agent tool IDs. Valid: `claude`, `cursor`, `codex`, `vscode`, `grok`, `opencode`. Defaults to `[]`. When set, dotagents creates skills symlinks and runtime config files for each agent where supported. | | `project` | No | Project metadata. | | `symlinks` | No | Symlink configuration (legacy — prefer `agents` for new projects). | | `skills` | No | Skill dependencies (array of tables). | | `mcp` | No | MCP server declarations (array of tables). Generates agent-specific config files during install/sync. | | `hooks` | No | Hook declarations (array of tables). Generates agent-specific hook config files during install/sync for agents that support hooks. | | `subagents` | No | Custom subagent declarations (array of tables). Generates runtime-specific subagent files during install/sync for Claude, Cursor, Codex, and OpenCode. | +| `plugins` | No | Plugin declarations (array of tables). Installs canonical bundles into `.agents/plugins/` and generates runtime-specific plugin outputs during install/sync for Claude, Cursor, Codex, Grok, and OpenCode. | | `trust` | No | Trusted source restrictions. When absent, all sources allowed. See `[trust]` below. | -| `minimum_release_age` | No | Minimum age in **minutes** a commit must have before it's eligible for install. Applies to all git skills (pinned and unpinned). For unpinned skills, resolves to the newest qualifying commit. For pinned skills (`ref`), rejects if the pinned commit is too new. Install fails with an error if no qualifying commit exists. When absent, always uses HEAD. | +| `minimum_release_age` | No | Minimum age in **minutes** a commit must have before it's eligible for install. Applies to all git skills, subagents, and plugins (pinned and unpinned). For unpinned sources, resolves to the newest qualifying commit. For pinned sources (`ref`), rejects if the pinned commit is too new. Install fails with an error if no qualifying commit exists. When absent, always uses HEAD. | | `minimum_release_age_exclude` | No | Sources excluded from the age gate. Accepts org names (`"myorg"` matches all repos), org/repo (`"myorg/skills"` exact match), or org wildcards (`"myorg/*"`). Defaults to `[]`. | **Age gate semantics:** - `minimum_release_age` absent → no age gating (default behavior) -- `minimum_release_age` set → for git skills, enforce commit age. Unpinned skills resolve to the newest qualifying commit; pinned skills error if the ref is too new +- `minimum_release_age` set → for git skills, subagents, and plugins, enforce commit age. Unpinned sources resolve to the newest qualifying commit; pinned sources error if the ref is too new - `minimum_release_age_exclude` → listed sources bypass the age gate entirely (useful for internal/trusted repos) -- Local skills (`path:`) and well-known skills are unaffected +- Local `path:` sources and well-known skills are unaffected - The age check uses the git committer date, which reflects when code landed on the branch #### `[trust]` -Optional section to restrict which skill and subagent sources are allowed. Useful for teams that want to lock down agent dependency provenance. +Optional section to restrict which skill, subagent, and plugin sources are allowed. Useful for teams that want to lock down agent dependency provenance. ```toml # Restrictive: only allow specific sources @@ -135,7 +143,7 @@ allow_all = true - `git_domains` entries match by prefix: `gitlab.com` matches all repos on GitLab, `gitlab.com/myorg` matches repos under that org, `gitlab.com/myorg/repo` matches only that repo - Local `path:` sources are always allowed (already sandboxed to project root) -Trust is checked before any network work in `dotagents add` for skills and `dotagents install` for configured skills and subagents. +Trust is checked before any network work in `dotagents add` for skills and `dotagents install` for configured skills, subagents, and plugins. #### `[project]` @@ -231,6 +239,34 @@ Generated paths: | Codex | `.codex/agents/.toml` | `~/.codex/agents/.toml` | TOML | | OpenCode | `.opencode/agents/.md` | `~/.config/opencode/agents/.md` | Markdown with YAML frontmatter | +#### `[[plugins]]` + +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 canonical plugin input format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a generalized Codex-compatible marketplace and manifest shape. Canonical plugin manifests and marketplaces validate known fields tightly but allow unknown extension fields so native runtime metadata and future dotagents fields can be preserved. + +See [Plugin Support Specification](plugins.md) for the canonical layout, exact input/output contract, native docs captured for each runtime, discovery rules, generated runtime outputs, and non-goals. + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Plugin name to discover. Must start with lowercase `a-z`, end with lowercase `a-z` or `0-9`, and contain only lowercase letters, numbers, hyphens, and dots. | +| `source` | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources; HTTPS well-known skill indexes are not supported for plugins. | +| `ref` | No | Optional git ref override. | +| `path` | No | Optional explicit plugin directory path inside the source. | +| `targets` | No | Optional subset of configured agent IDs. When absent or empty, defaults to every configured agent in `agents`; targets not listed in top-level `agents` are skipped with a warning. | + +Generated project-scope plugin outputs: + +| Agent | Project Scope Output | +|-------|----------------------| +| Claude Code | `.claude-plugin/marketplace.json` | +| Cursor | `.cursor-plugin/marketplace.json` | +| Codex | `.agents/plugins/marketplace.json` and `.agents/plugins//.codex-plugin/plugin.json` | +| Grok Build | `.grok/plugins//` managed copy | +| OpenCode | `.opencode/plugins/.js|ts` re-export module when the plugin declares or contains one OpenCode module | + +Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. + #### Supported Agents | ID | Tool | Config Dir | MCP File | MCP Format | Subagents | @@ -238,10 +274,11 @@ Generated paths: | `claude` | Claude Code | `.claude` | `.mcp.json` | JSON | `.claude/agents/*.md` | | `cursor` | Cursor | `.cursor` | `.cursor/mcp.json` | JSON | `.cursor/agents/*.md` | | `codex` | Codex | `.codex` | `.codex/config.toml` | TOML (shared) | `.codex/agents/*.toml` | +| `grok` | Grok Build | `.grok` | Not generated | Not generated | Not generated | | `vscode` | VS Code Copilot | `.vscode` | `.vscode/mcp.json` | JSON | Not supported | | `opencode` | OpenCode | `.opencode` | `opencode.json` | JSON (shared) | `.opencode/agents/*.md` | -Each agent has its own MCP config format. dotagents translates the universal `[[mcp]]` declarations into the format each tool expects during `install` and `sync`. +Each agent has its own MCP config format. dotagents translates the universal `[[mcp]]` declarations into the format each tool expects during `install` and `sync`. Grok is currently supported for plugin projections only. ### Source Types @@ -379,6 +416,15 @@ resolved_commit = "fedcba9876543210fedcba9876543210fedcba98" [subagents.local-reviewer] source = "path:../shared-agents" + +[plugins.review-tools] +source = "getsentry/agent-plugins" +resolved_url = "https://github.com/getsentry/agent-plugins.git" +resolved_path = "plugins/review-tools" +resolved_commit = "0123456789abcdef0123456789abcdef01234567" + +[plugins.local-tools] +source = "path:../shared-plugins/local-tools" ``` ### Fields per skill @@ -403,6 +449,18 @@ Subagent lock entries use the same source-resolution fields under `[subagents.]`. Git plugins record the resolved clone URL, discovered or explicit plugin directory path, optional ref, and installed commit. Local `path:` plugins record `source` only. + +| Field | Present For | Description | +|-------|-------------|-------------| +| `source` | All | Original source specifier from agents.toml. | +| `resolved_url` | Git sources | Resolved clone URL. | +| `resolved_path` | Git sources | Directory path within the repo where the plugin was discovered or loaded from. | +| `resolved_ref` | Git sources (optional) | The ref that was resolved (tag/branch name). Omitted when using default branch. | +| `resolved_commit` | Git sources (optional) | Full 40-char commit SHA that was installed. Informational only. | + --- ## CLI Commands @@ -445,15 +503,18 @@ dotagents install a. Resolve source (check cache with TTL-based refresh, clone/fetch if needed) b. Discover skill within the repo c. Copy skill directory into `.agents/skills//` -3. Write `agents.lock` with the current configured skills and subagents - - In `--frozen` mode, require configured dependencies to already be present in `agents.lock`, load subagents from installed files, do not update the lockfile, and do not prune existing managed subagent files -4. Regenerate `.agents/.gitignore` -5. Warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` -6. Create/verify symlinks (legacy `[symlinks]` and agent-specific) -7. Write MCP config files for each declared agent -8. Write hook config files for each declared agent that supports hooks -9. Write generated subagent files for each declared agent that supports custom subagents -10. Print summary +3. Resolve and install configured subagents into `.agents/agents/` +4. Resolve and install configured plugins into `.agents/plugins//` +5. Write `agents.lock` with the current configured skills, subagents, and plugins + - In `--frozen` mode, require configured dependencies to already be present in `agents.lock`, load subagents and plugins from installed files, do not update the lockfile, and do not prune existing managed subagent or plugin files +6. Regenerate `.agents/.gitignore` +7. Warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` +8. Create/verify symlinks (legacy `[symlinks]` and agent-specific) +9. Write MCP config files for each declared agent +10. Write hook config files for each declared agent that supports hooks +11. Write generated subagent files for each declared agent that supports custom subagents +12. Write generated plugin runtime projections for each declared agent that supports plugins +13. Print summary ### `dotagents add ` @@ -538,6 +599,7 @@ dotagents sync 7. Verify and repair MCP config files for declared agents 8. Verify and repair hook config files for declared agents 9. Verify and repair generated subagent files for declared agents +10. Verify and repair generated plugin runtime projections for declared agents ### `dotagents mcp` @@ -593,14 +655,15 @@ dotagents doctor [--fix] 5. `.agents/.gitignore` exists 6. `.agents/skills/` directory exists 7. All declared skills are installed -8. Symlinks are intact +8. All declared plugins are installed +9. Symlinks are intact **Flags:** - `--fix`: Auto-fix issues where possible (add gitignore entries, remove legacy fields, create missing `.agents/.gitignore`) ### `dotagents list` -Show installed skills and status. +Show declared skills, plugins, and status. ``` dotagents list [--json] @@ -611,7 +674,7 @@ dotagents list [--json] - `✗` missing — in agents.toml but not installed - `?` unlocked — installed but not in lockfile -**Output:** name, source, status +**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. --- @@ -667,12 +730,12 @@ The YAML frontmatter is parsed with the `yaml` package. `allowed-tools` can be a ## Gitignore Strategy dotagents always manages gitignore. Two files are added to the root `.gitignore` during `init`: -- `agents.lock` — tracks managed skills and subagents +- `agents.lock` — tracks managed skills, subagents, and plugins - `.agents/.gitignore` — excludes managed skill directories and canonical installed subagent files from git ### How It Works -Managed (external) skills and canonical installed subagent files are gitignored. Custom (local) skills are tracked. dotagents generates `.agents/.gitignore` listing every managed skill and installed subagent: +Managed (external) skills, canonical installed subagent files, and managed copied plugin bundles are gitignored. Custom local skills and project-authored plugin source directories in `.agents/plugins//` are tracked when they are not installed dependencies. dotagents generates `.agents/.gitignore` listing every managed skill, installed subagent, and managed plugin bundle: ```gitignore # Auto-generated by dotagents. Do not edit. @@ -680,9 +743,10 @@ Managed (external) skills and canonical installed subagent files are gitignored. /skills/find-bugs/ /skills/warden-skill/ /agents/code-reviewer.md +/plugins/review-tools/ ``` -Custom skills in `.agents/skills/my-local-skill/` are NOT listed, so git tracks them normally. +Custom skills in `.agents/skills/my-local-skill/` and canonical local plugins in `.agents/plugins/my-plugin/` are NOT listed, so git tracks them normally. ### Regeneration @@ -749,7 +813,7 @@ dotagents/ AGENTS.md # Agent instructions CLAUDE.md -> AGENTS.md # Symlink agents.toml # Self-dogfooding - agents.lock # Tracks managed skills and subagents (gitignored) + agents.lock # Tracks managed skills, subagents, and plugins (gitignored) warden.toml # Warden config for code analysis package.json # pnpm workspace root pnpm-workspace.yaml diff --git a/specs/plugins.md b/specs/plugins.md new file mode 100644 index 0000000..ffe13a3 --- /dev/null +++ b/specs/plugins.md @@ -0,0 +1,313 @@ +# Plugin Support Specification + +Plugin support lets teams declare reusable agent extensions once, preserve native plugin artifacts where they already exist, and generate deterministic runtime outputs for supported tools. + +This is not a universal plugin behavior schema. Runtime-specific behavior such as app authentication, hook trust prompts, LSP lifecycle, JavaScript plugin APIs, marketplace review, managed policy, UI presentation, and telemetry belongs in each runtime's native plugin format. + +## Core Model + +dotagents has one canonical plugin source of truth: + +```text +.agents/plugins/ +|-- marketplace.json +`-- / + |-- plugin.json + `-- ... +``` + +The canonical catalog and plugin manifests should use a generalized Codex-compatible format. Codex compatibility is the baseline because Codex already reads `.agents/plugins/marketplace.json` for repo-scoped marketplaces, but dotagents treats the schema as portable project metadata rather than Codex-only configuration. + +Every other runtime output is generated from `.agents/plugins/` when that runtime does not directly consume the canonical path or schema. Generated artifacts may include `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.codex-plugin/plugin.json`, `.grok/` plugin files, `.opencode/plugins/` modules, or runtime settings/config entries. These generated artifacts are runtime projections, not the source of truth. + +## Input and Output Contract + +The canonical inputs are tightly defined but forward-compatible: + +1. `agents.toml` `[[plugins]]` declarations are strict operational config. Unknown fields are rejected. +2. `.agents/plugins/marketplace.json` must be a JSON object with `name` and `plugins[]`. Each plugin entry must have `name` and `source`. `source` may be a relative path string, `{ "source": "local", "path": "" }`, or a runtime extension object with a safe relative `path`. +3. `.agents/plugins//plugin.json` must be a JSON object. Known component path fields are validated when present. Unknown fields are allowed and preserved so native runtimes and future dotagents versions can add metadata without breaking older installs. +4. All component paths in canonical manifests and marketplaces must be relative and must not contain `..`, absolute POSIX paths, absolute Windows paths, or backslash-rooted paths. +5. The portable plugin `name` in `agents.toml` is authoritative. If a discovered manifest also declares `name`, it must match the configured name. + +Generated outputs are deterministic: + +1. JSON output is pretty-printed with two-space indentation, sorted object keys, sorted plugin entries by name, and exactly one trailing newline. +2. Runtime marketplace outputs are overwritten only when they carry `metadata.managedBy = "dotagents"`. Hand-written runtime marketplaces are left untouched and reported as warnings. +3. Managed generated marketplace files are pruned when no configured plugin targets that runtime anymore. +4. Remote or copied plugin bundles under `.agents/plugins//` are treated as dotagents-managed and are listed in `.agents/.gitignore`. +5. Plugin sources that resolve to the same project's `.agents/plugins//` install destination are rejected. dotagents must not install a same-repo plugin onto itself. + +## Documentation Sources + +This design is based on the public plugin documentation current as of 2026-06-12: + +| Runtime | Documentation | +|---------|---------------| +| Claude Code | https://code.claude.com/docs/en/plugins, https://code.claude.com/docs/en/plugins-reference.md, and https://code.claude.com/docs/en/plugin-marketplaces | +| Cursor | https://cursor.com/docs/plugins.md and https://cursor.com/docs/reference/plugins.md | +| Codex | https://developers.openai.com/codex/plugins and https://developers.openai.com/codex/plugins/build | +| Grok Build | https://docs.x.ai/build/features/skills-plugins-marketplaces and https://docs.x.ai/build/overview | +| OpenCode | https://opencode.ai/docs/plugins | +| `plugins` npm package | https://www.npmjs.com/package/plugins and https://github.com/vercel-labs/plugins | + +The npm `plugins` package is an interoperability reference, not a proposed dotagents dependency. Its public metadata describes an "open-plugin format" installer for agent tools, and its current package scans `.plugin/`, `.claude-plugin/`, `.cursor-plugin/`, and `.codex-plugin/` manifests. `.plugin/` is not a native directory for the runtimes dotagents targets, so dotagents should support it only as an import compatibility format, not as the canonical authoring or install location. + +## Configuration + +Plugins should be declared in `agents.toml`: + +```toml +[[plugins]] +name = "sentry-tools" +source = "getsentry/agent-plugins" +ref = "v1.2.0" +path = "plugins/sentry-tools" +targets = ["claude", "cursor", "codex", "grok"] +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Portable dotagents ID. Must start with lowercase `a-z` and contain only lowercase letters, numbers, hyphens, and dots. | +| `source` | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources. HTTPS well-known skill indexes are not supported for plugins. | +| `ref` | No | Optional git ref override. | +| `path` | No | Optional explicit plugin directory path inside the source. | +| `targets` | No | Optional subset of configured agent IDs. When absent or empty, defaults to every configured agent in `agents`; targets not listed in top-level `agents` and unsupported configured agents produce warnings. | + +Wildcard plugin installs can be added later if needed: + +```toml +[[plugins]] +name = "*" +source = "getsentry/agent-plugins" +exclude = ["deprecated-plugin"] +``` + +## Portable Projection + +Every imported plugin should produce this portable shape: + +| Field | Meaning | +|-------|---------| +| `name` | dotagents portable ID from `agents.toml`, a marketplace entry, a manifest, or the plugin directory name | +| `version` | optional version from native or portable manifest metadata | +| `description` | optional short description | +| `metadata` | portable package metadata: author, homepage, repository, license, keywords, category, and logo paths | +| `components` | discovered component paths for skills, agents, commands, rules, hooks, MCP servers, LSP servers, apps/connectors, monitors, binaries, settings, and OpenCode plugins | +| `native` | optional raw native plugin metadata keyed by runtime | + +Only metadata and component locations are portable. Component semantics remain native unless they are already modeled by dotagents elsewhere, such as skills, MCP servers, hooks, and subagents. + +## Dotagents Plugin Layout + +The canonical dotagents plugin layout should live under `.agents/plugins/`, matching `.agents/skills/` and `.agents/agents/`. The canonical marketplace catalog is `.agents/plugins/marketplace.json`, using the generalized Codex-compatible marketplace shape described in Core Model. + +Local project-authored plugins may be committed under `.agents/plugins//` as source files, but they must not be declared as same-project `[[plugins]]` dependencies because installing them would overwrite the source with itself. To consume a local plugin, reference a path outside this project's `.agents/plugins/` directory or consume it from a separate repository. + +```toml +[[plugins]] +name = "my-plugin" +source = "path:../shared-plugins/my-plugin" +``` + +Remote plugins should install into the same canonical directory during `install`: + +```text +.agents/plugins/my-plugin/ +|-- plugin.json +|-- skills/ +| `-- code-review/ +| `-- SKILL.md +|-- agents/ +| `-- verifier.md +|-- commands/ +| `-- release.md +|-- rules/ +| `-- typescript.mdc +|-- hooks/ +| `-- hooks.json +|-- .mcp.json +|-- .lsp.json +|-- bin/ +`-- opencode/ + `-- plugin.ts +``` + +Example canonical marketplace: + +```json +{ + "name": "dotagents-local", + "interface": { + "displayName": "Dotagents Plugins" + }, + "owner": { + "name": "dotagents" + }, + "plugins": [ + { + "name": "my-plugin", + "source": { + "source": "local", + "path": "./.agents/plugins/my-plugin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} +``` + +The canonical catalog may include vendor-specific extension fields, but dotagents should keep the required core small: marketplace `name`, optional display metadata, and `plugins[]` entries with `name` and `source`. Writers may emit simpler relative string sources for runtimes that prefer them, such as Claude and Cursor. + +Example dotagents manifest: + +```json +{ + "name": "my-plugin", + "version": "1.0.0", + "description": "Shared agent workflows", + "author": { "name": "Sentry" }, + "skills": "./skills", + "agents": "./agents", + "commands": "./commands", + "rules": "./rules", + "hooks": "./hooks/hooks.json", + "mcpServers": "./.mcp.json", + "lspServers": "./.lsp.json", + "opencode": { + "plugins": ["./opencode/plugin.ts"] + } +} +``` + +Path fields must be relative to the plugin root and must not contain `..`. OpenCode plugin manifests may declare at most one JS/TS module because dotagents projects it to one deterministic `.opencode/plugins/.js|ts` file. Generated native manifests may add a leading `./` when the target runtime requires it. + +dotagents may also import plugin sources that already use native runtime manifests such as `.claude-plugin/plugin.json`, `.cursor-plugin/plugin.json`, or `.codex-plugin/plugin.json`. It may import `.plugin/plugin.json` for compatibility with the npm `plugins` package, but it should normalize that input into `.agents/plugins//plugin.json` on install. + +## Native Formats + +Input and matching-runtime output should use the same native format where possible. dotagents should preserve raw native manifests and component files for matching runtimes, adding only generated metadata needed to make the runtime discover the plugin. + +| Runtime | Native Manifest | Native Plugin Roots | Components from Docs | Notes | +|---------|-----------------|---------------------|----------------------|-------| +| Claude Code | `.claude-plugin/plugin.json` | marketplace installs, `--plugin-dir`, and skills-directory plugins | skills, commands, agents, hooks, `.mcp.json`, `.lsp.json`, monitors, `bin/`, `settings.json` | Plugin skills are namespaced as `/plugin-name:skill-name`; components live at plugin root, not under `.claude-plugin/`. | +| Cursor | `.cursor-plugin/plugin.json` | marketplace installs and `~/.cursor/plugins/local/` for local testing | rules, skills, agents, commands, hooks, `mcp.json`, assets, scripts | Manifest component paths replace default discovery for that component. Multi-plugin repos use `.cursor-plugin/marketplace.json`. | +| Codex | `.codex-plugin/plugin.json` | repo/user marketplaces under `.agents/plugins/marketplace.json` and plugin cache installs | skills, hooks, `.app.json`, `.mcp.json`, assets | Published plugins commonly need rich `interface` metadata. Codex sets `PLUGIN_ROOT` and `PLUGIN_DATA`, plus Claude-compatible plugin env vars. | +| Grok Build | Claude-compatible plugin directories plus `.grok/plugins/` and marketplaces | `./.grok/plugins/`, `~/.grok/plugins/`, marketplace installs, configured plugin paths, `--plugin-dir` | skills, agents, hooks, MCP servers, LSP servers | Docs state Grok automatically reads Claude Code marketplaces, plugins, skills, MCPs, agents, hooks, and `.claude/rules/` alongside `.grok/`. | +| OpenCode | JavaScript or TypeScript plugin modules | `.opencode/plugins/`, `~/.config/opencode/plugins/`, npm package names in `opencode.json` | JS/TS plugin functions returning hooks and custom tools | OpenCode plugins are executable modules, not bundle manifests. dotagents should only install OpenCode-native plugin modules or npm plugin entries for the plugin portion. | + +## Discovery + +Without an explicit `path`, dotagents should scan source directories in this order: + +1. dotagents plugin directories at `.agents/plugins//plugin.json` +2. Marketplace manifests at `.agents/plugins/marketplace.json`, `marketplace.json`, `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, and `.codex-plugin/marketplace.json` +3. Root native plugin manifests at `plugin.json`, `.claude-plugin/plugin.json`, `.cursor-plugin/plugin.json`, and `.codex-plugin/plugin.json` +4. Root plugin component directories such as `skills/`, `commands/`, `agents/`, `rules/`, `hooks/`, `.mcp.json`, `.lsp.json`, `.app.json`, `monitors/`, `bin/`, or root `SKILL.md` +5. Recursive plugin scan under common collection directories such as `plugins/*`, limited to two levels by default +6. Compatibility manifests at `.plugin/marketplace.json` and `.plugin/plugin.json` + +Directory-name matches for `agents.toml` `name` take precedence over manifest-name matches. Multiple matches in one source are rejected as ambiguous unless a marketplace manifest explicitly selects a path. + +Marketplace entries should be treated as plugin selectors. If a marketplace entry points at a local path, dotagents resolves it relative to the marketplace root, optionally applying the marketplace's `metadata.pluginRoot` prefix when present. If a marketplace entry points at a remote source object that dotagents cannot resolve yet, it should produce an unsupported-source warning rather than guessing. + +## Component Handling + +dotagents should handle plugin components in three buckets: + +| Bucket | Components | Behavior | +|--------|------------|----------| +| Portable existing dotagents concepts | skills, MCP servers, hooks, subagents/agents | Load through existing parsers where possible and generate runtime configs using existing agent writers. Preserve native plugin copies for matching runtimes. | +| Runtime-specific files | Cursor rules, Claude/Codex/Grok LSP servers, Codex apps, Claude monitors, Claude settings, binaries, OpenCode JS/TS plugins | Copy or expose only for runtimes that natively understand them. Do not attempt cross-runtime conversion. | +| Metadata and marketplace files | plugin manifests, marketplace manifests, icons, screenshots, README | Preserve and regenerate native manifests/marketplaces from normalized metadata when needed. | + +Plugin skills should not be flattened into `.agents/skills/` by default. Native plugin systems namespace plugin skills and avoid conflicts. Flattening may be offered later as an explicit compatibility mode for runtimes without bundle support. + +Plugin subagents should use the subagent importer only when the runtime stores them in a compatible file format. The plugin spec should not expand subagent behavior beyond the rules in `subagents.md`. + +## Plugin Root Variables + +Portable plugin-authored config should use `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` when referring to files or writable state inside the plugin. Runtime writers translate these where needed: + +| Runtime | Root Variable | Data Variable | +|---------|---------------|---------------| +| Claude Code | `${CLAUDE_PLUGIN_ROOT}` | `${CLAUDE_PLUGIN_DATA}` | +| Codex | `${PLUGIN_ROOT}` | `${PLUGIN_DATA}` | +| Grok Build | `${GROK_PLUGIN_ROOT}` | `${GROK_PLUGIN_DATA}` | +| Cursor | Target-specific support to verify before implementation | Target-specific support to verify before implementation | +| OpenCode | Not applicable for local JS/TS modules unless the module reads environment variables set by dotagents | Not applicable | + +Codex also sets Claude-compatible plugin variables for compatibility. For generated Codex output, dotagents can leave `${PLUGIN_ROOT}` intact. For generated Claude or Grok output, dotagents should rewrite known portable variables in hook, MCP, and LSP config files. + +## Install and Sync + +Install should write two layers: + +1. Canonical installed plugin bundle in `.agents/plugins//` +2. Runtime-specific generated plugin registrations for each configured target + +Generated project-scope outputs should be: + +| Agent | Project Scope Output | User Scope Output | Notes | +|-------|----------------------|-------------------|-------| +| Claude Code | `.claude-plugin/marketplace.json` | Not generated yet | Generated marketplace uses deterministic relative string sources into `.agents/plugins//`. | +| Cursor | `.cursor-plugin/marketplace.json` | Not generated yet | Generated marketplace uses deterministic relative string sources into `.agents/plugins//`. | +| Codex | `.agents/plugins/marketplace.json` plus generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | Codex is the baseline marketplace format; `source.path` resolves relative to marketplace root. | +| Grok Build | `.grok/plugins/` for targeted plugins | Not generated yet | The projection is a managed copy of the canonical plugin bundle with a `.dotagents-managed` marker. | +| OpenCode | `.opencode/plugins/.js|ts` re-export module for an explicit OpenCode module | Not generated yet | dotagents only exposes the module declared in `manifest.opencode.plugins` or discovered at `opencode/plugin.ts|js`; it does not synthesize OpenCode JS/TS code from other runtime hooks. | + +Installed and generated files are dotagents-managed. `install` and `sync` may overwrite stale managed files and prune removed managed files, but they must not overwrite hand-written plugin files without a generated marker or a canonical installed bundle path owned by dotagents. Generated Codex manifests carry `metadata.managedBy = "dotagents"` so target removal can prune them without deleting user-authored native Codex plugin manifests. + +## Lockfile + +Plugin lock entries should use the same source-resolution fields as subagents: + +```toml +[plugins.sentry-tools] +source = "getsentry/agent-plugins" +resolved_url = "https://github.com/getsentry/agent-plugins.git" +resolved_path = "plugins/sentry-tools" +resolved_commit = "0123456789abcdef0123456789abcdef01234567" +``` + +| Field | Present For | Description | +|-------|-------------|-------------| +| `source` | All | Original source specifier from `agents.toml`. | +| `resolved_url` | Git sources | Resolved clone URL. | +| `resolved_path` | Git sources | Directory path within the repo where the plugin was discovered or loaded from. | +| `resolved_ref` | Git sources (optional) | Ref that was resolved. Omitted when using the default branch. | +| `resolved_commit` | Git sources (optional) | Full 40-char commit SHA that was installed. Informational only. | + +## Security and Trust + +Plugins are a higher-risk dependency class than plain skills because they may bundle executable hooks, MCP servers, LSP servers, binaries, or OpenCode JS/TS modules. + +dotagents should: + +1. Apply existing trust policy before network access. +2. Surface warnings when a plugin contains executable components. +3. Preserve runtime-native trust flows instead of bypassing them. For example, Codex plugin hooks still require the user's runtime trust review. +4. Avoid executing plugin code during install except for normal git/source resolution. +5. Treat local `path:` plugins as allowed by source trust policy but still warn for executable components. + +## Non-goals + +dotagents should not: + +1. Standardize a universal hook event model across all runtimes. +2. Convert OpenCode JavaScript/TypeScript plugin code from declarative hook files. +3. Convert Cursor rules into Claude, Codex, Grok, or OpenCode instructions by default. +4. Install app integrations or perform OAuth/authentication for users. +5. Bypass native marketplace review, policy, or trust prompts. +6. Promise identical plugin behavior across runtimes. + +## Open Questions + +1. Whether Claude and Cursor should gain additional native install/config outputs beyond the deterministic marketplace projections dotagents writes today. +2. Grok's exact native manifest shape is not fully documented publicly; current support uses native `.grok/plugins/` placement with the canonical bundle. +3. Whether `[[plugins]]` should allow remote marketplace source objects directly, or only concrete plugin directories resolved from repositories. +4. Whether plugin-contained skills should optionally expose short aliases in `.agents/skills/` for runtimes without native plugin namespaces. From d0386f5485c85441fa0d9281745090f44a8afd3e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 30 Jun 2026 08:37:45 -0700 Subject: [PATCH 02/35] feat(plugins): Add universal plugin support # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # interactive rebase in progress; onto 0d50a26 # Last commands done (2 commands done): # pick 4b509bd feat(plugins): Add universal plugin support # pick a5875a4 feat(plugins): Add universal plugin support # Next commands to do (20 remaining commands): # pick 0874c0a fix(plugins): Keep marketplace metadata out of manifests # pick c3324b1 fix(plugins): Tighten plugin sync diagnostics # You are currently rebasing branch 'codex/add-plugin-support' on '0d50a26'. # # Changes to be committed: # new file: packages/dotagents/src/agents/plugin-store.test.ts # modified: packages/dotagents/src/agents/plugin-store.ts # modified: packages/dotagents/src/agents/plugin-writer.test.ts # modified: packages/dotagents/src/agents/plugin-writer.ts # modified: packages/dotagents/src/cli/commands/doctor.test.ts # modified: packages/dotagents/src/cli/commands/doctor.ts # modified: packages/dotagents/src/cli/commands/install.test.ts # modified: packages/dotagents/src/cli/commands/sync.ts # --- .../dotagents/src/agents/plugin-store.test.ts | 27 ++++++++++++ packages/dotagents/src/agents/plugin-store.ts | 44 ++++++++++++++++--- .../src/agents/plugin-writer.test.ts | 34 +++++++++++++- .../dotagents/src/agents/plugin-writer.ts | 6 ++- .../dotagents/src/cli/commands/doctor.test.ts | 22 ++++++++++ packages/dotagents/src/cli/commands/doctor.ts | 16 ++++++- .../src/cli/commands/install.test.ts | 30 +++++++++++++ packages/dotagents/src/cli/commands/sync.ts | 27 ++---------- 8 files changed, 170 insertions(+), 36 deletions(-) create mode 100644 packages/dotagents/src/agents/plugin-store.test.ts diff --git a/packages/dotagents/src/agents/plugin-store.test.ts b/packages/dotagents/src/agents/plugin-store.test.ts new file mode 100644 index 0000000..1da6681 --- /dev/null +++ b/packages/dotagents/src/agents/plugin-store.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { lockEntryForPlugin, type ResolvedPlugin } from "./plugin-store.js"; + +describe("plugin store", () => { + it("preserves an empty resolved path for root git plugins", () => { + const resolved = { + type: "git", + source: "org/review-tools", + resolvedUrl: "https://github.com/org/review-tools.git", + resolvedPath: "", + commit: "abc123", + plugin: { + name: "review-tools", + source: "org/review-tools", + pluginDir: "/tmp/review-tools", + manifest: { name: "review-tools" }, + }, + } satisfies ResolvedPlugin; + + expect(lockEntryForPlugin(resolved)).toEqual({ + source: "org/review-tools", + resolved_url: "https://github.com/org/review-tools.git", + resolved_path: "", + resolved_commit: "abc123", + }); + }); +}); diff --git a/packages/dotagents/src/agents/plugin-store.ts b/packages/dotagents/src/agents/plugin-store.ts index 0c05aef..90de533 100644 --- a/packages/dotagents/src/agents/plugin-store.ts +++ b/packages/dotagents/src/agents/plugin-store.ts @@ -211,13 +211,21 @@ export async function pruneInstalledPlugins( /** Converts a resolved plugin to its lockfile entry. */ export function lockEntryForPlugin(resolved: ResolvedPlugin): LockedPlugin { - return { - source: resolved.source, - ...(resolved.resolvedUrl ? { resolved_url: resolved.resolvedUrl } : {}), - ...(resolved.resolvedPath ? { resolved_path: resolved.resolvedPath } : {}), - ...(resolved.resolvedRef ? { resolved_ref: resolved.resolvedRef } : {}), - ...(resolved.commit ? { resolved_commit: resolved.commit } : {}), - }; + const entry: Record = { source: resolved.source }; + setIfDefined(entry, "resolved_url", resolved.resolvedUrl); + setIfDefined(entry, "resolved_path", resolved.resolvedPath); + setIfDefined(entry, "resolved_ref", resolved.resolvedRef); + setIfDefined(entry, "resolved_commit", resolved.commit); + return entry as LockedPlugin; +} + +function setIfDefined( + entry: Record, + key: string, + value: string | undefined, +): void { + if (value === undefined) {return;} + entry[key] = value; } /** Returns true for direct `path:.agents/plugins/...` plugin sources. */ @@ -244,6 +252,28 @@ export function isProjectPluginSource( return relPath === "" || (!relPath.startsWith("..") && !isAbsolute(relPath)); } +/** Returns true when a plugin config resolves back into this project's managed plugin tree. */ +export function isSameProjectPluginConfig( + plugin: Pick, + pluginsDir: string, + projectRoot: string, +): boolean { + if (isInPlacePluginSource(plugin.source)) {return true;} + if (!plugin.path) {return false;} + + try { + const parsed = parseSource(plugin.source); + if (parsed.type !== "local" || !parsed.path) {return false;} + const sourceDir = resolve(projectRoot, parsed.path); + const pluginDir = resolve(sourceDir, plugin.path); + const relPath = relative(sourceDir, pluginDir); + if (relPath.startsWith("..") || isAbsolute(relPath)) {return false;} + return isProjectPluginSource(pluginDir, pluginsDir); + } catch { + return false; + } +} + async function discoverPlugin( sourceDir: string, config: PluginConfig, diff --git a/packages/dotagents/src/agents/plugin-writer.test.ts b/packages/dotagents/src/agents/plugin-writer.test.ts index 2ee9ae6..d89fb26 100644 --- a/packages/dotagents/src/agents/plugin-writer.test.ts +++ b/packages/dotagents/src/agents/plugin-writer.test.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, relative } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { PluginDeclaration } from "./plugin-store.js"; import { @@ -202,6 +202,38 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); }); + it("uses one conventional OpenCode module when both TypeScript and JavaScript modules exist", async () => { + const alpha = await plugin("alpha-tools"); + await mkdir(join(alpha.pluginDir, "opencode"), { recursive: true }); + await writeFile(join(alpha.pluginDir, "opencode", "plugin.ts"), "export default {}\n", "utf-8"); + await writeFile(join(alpha.pluginDir, "opencode", "plugin.js"), "export default {}\n", "utf-8"); + + const result = await writePluginOutputs(["opencode"], [alpha], root); + + expect(result.warnings).toEqual([]); + expect(result.written).toBe(1); + expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(true); + expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.js"))).toBe(false); + }); + + it("escapes OpenCode module specifiers in generated modules", async () => { + const modulePath = `opencode/plugin";globalThis.injected=true;.ts`; + const alpha = await plugin("alpha-tools", { + manifest: { opencode: { plugins: [modulePath] } }, + }); + await mkdir(join(alpha.pluginDir, "opencode"), { recursive: true }); + await writeFile(join(alpha.pluginDir, modulePath), "export default {}\n", "utf-8"); + + const result = await writePluginOutputs(["opencode"], [alpha], root); + + const dest = join(root, ".opencode", "plugins", "alpha-tools.ts"); + const specifier = relative(dirname(dest), join(alpha.pluginDir, modulePath)).split("\\").join("/"); + expect(result.warnings).toEqual([]); + expect(await readFile(dest, "utf-8")).toBe( + `// Generated by dotagents. Do not edit.\nexport { default } from ${JSON.stringify(specifier)};\n`, + ); + }); + it("prunes stale managed runtime plugin outputs", async () => { const alpha = await plugin("alpha-tools", { manifest: { opencode: { plugins: ["opencode/plugin.ts"] } }, diff --git a/packages/dotagents/src/agents/plugin-writer.ts b/packages/dotagents/src/agents/plugin-writer.ts index df19254..6ab84b7 100644 --- a/packages/dotagents/src/agents/plugin-writer.ts +++ b/packages/dotagents/src/agents/plugin-writer.ts @@ -397,7 +397,8 @@ async function writeOpenCodeProjection( } await mkdir(dirname(dest), { recursive: true }); - const content = `// Generated by dotagents. Do not edit.\nexport { default } from "${relativePath(dirname(dest), join(plugin.pluginDir, modulePath))}";\n`; + const moduleSpecifier = JSON.stringify(relativePath(dirname(dest), join(plugin.pluginDir, modulePath))); + const content = `// Generated by dotagents. Do not edit.\nexport { default } from ${moduleSpecifier};\n`; if (await writeTextIfChanged(dest, content)) {written++;} } return written; @@ -461,7 +462,8 @@ function opencodeModules(plugin: PluginDeclaration): string[] { const opencode = plugin.manifest.opencode; if (opencode?.plugins) {return opencode.plugins;} const candidates = ["opencode/plugin.ts", "opencode/plugin.js"]; - return candidates.filter((path) => existsSync(join(plugin.pluginDir, path))); + const candidate = candidates.find((path) => existsSync(join(plugin.pluginDir, path))); + return candidate ? [candidate] : []; } function selectPlugins(agentIds: string[], plugins: PluginDeclaration[]): PluginDeclaration[] { diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 4084996..979efdb 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -102,6 +102,28 @@ describe("runDoctor", () => { expect(check?.message).toContain("pdf"); }); + it("detects same-project plugins that cannot be installed", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" })); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "local-tools" +source = "path:.agents/plugins/local-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 check = result.checks.find((c) => c.name === "installed plugins"); + expect(check?.status).toBe("error"); + expect(check?.message).toContain("Same-project plugins cannot be installed into the same project"); + }); + it("detects generated files tracked by git", async () => { // Initialize a git repo so git ls-files works const { execSync } = await import("node:child_process"); diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index c160b05..dfbd886 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -14,7 +14,7 @@ import { getAgent } from "../../targets/registry.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { exec } from "@sentry/dotagents-lib"; import { isInPlaceSkill } from "../../utils/fs.js"; -import { isInPlacePluginSource } from "../../agents/plugin-store.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig } from "../../agents/plugin-store.js"; export interface DoctorCheck { name: string; @@ -196,10 +196,22 @@ export async function runDoctor(opts: DoctorOptions): Promise { } // 9. Declared plugins are installed + const sameProjectPlugins = scope.scope === "project" + ? config.plugins + .filter((plugin) => isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) + .map((plugin) => plugin.name) + : []; const missingPlugins = config.plugins + .filter((plugin) => !sameProjectPlugins.includes(plugin.name)) .filter((plugin) => !existsSync(`${scope.pluginsDir}/${plugin.name}`)) .map((plugin) => plugin.name); - if (missingPlugins.length > 0) { + if (sameProjectPlugins.length > 0) { + checks.push({ + name: "installed plugins", + status: "error", + message: `${sameProjectPlugins.length} plugin(s) resolve inside this project's .agents/plugins/ tree: ${sameProjectPlugins.join(", ")}. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.`, + }); + } else if (missingPlugins.length > 0) { checks.push({ name: "installed plugins", status: "error", diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index e6d4450..3c1bee0 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -218,6 +218,36 @@ source = "path:plugin-source/review-tools" expect(agentsGitignore).toContain("/plugins/review-tools/"); }); + it("keeps plugin lock entries when runtime projection fails after installing the bundle", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Review workflow helpers" }, null, 2), + ); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile(join(sourceDir, ".codex-plugin"), "not a directory\n", "utf-8"); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow(); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins["review-tools"]).toEqual({ + source: "path:plugin-source/review-tools", + }); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", "plugin.json"))).toBe(true); + }); + it("rejects same-project plugins that would install onto themselves", async () => { const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 6cbc313..9b989ea 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -1,10 +1,10 @@ -import { isAbsolute, join, relative, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { existsSync } from "node:fs"; import { readdir, rm } from "node:fs/promises"; import chalk from "chalk"; import { loadConfig } from "../../config/loader.js"; import { isWildcardDep } from "../../config/schema.js"; -import { normalizeSource, parseSource } from "@sentry/dotagents-lib"; +import { normalizeSource } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { addSkillToConfig } from "../../config/writer.js"; @@ -15,7 +15,7 @@ import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolve import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../targets/hook-writer.js"; import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../subagents/writer.js"; import { loadInstalledSubagents, pruneInstalledSubagents } from "../../subagents/store.js"; -import { isInPlacePluginSource, isProjectPluginSource, loadInstalledPlugins, pruneInstalledPlugins } from "../../agents/plugin-store.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins, pruneInstalledPlugins } from "../../agents/plugin-store.js"; import { prunePluginOutputs, verifyPluginOutputs, writePluginOutputs } from "../../agents/plugin-writer.js"; import { userMcpResolver } from "../../targets/paths.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; @@ -407,27 +407,6 @@ export async function runSync(opts: SyncOptions): Promise { }; } -function isSameProjectPluginConfig( - plugin: { source: string; path?: string }, - pluginsDir: string, - projectRoot: string, -): boolean { - if (isInPlacePluginSource(plugin.source)) {return true;} - if (!plugin.path) {return false;} - - try { - const parsed = parseSource(plugin.source); - if (parsed.type !== "local" || !parsed.path) {return false;} - const sourceDir = resolve(projectRoot, parsed.path); - const pluginDir = resolve(sourceDir, plugin.path); - const relPath = relative(sourceDir, pluginDir); - if (relPath.startsWith("..") || isAbsolute(relPath)) {return false;} - return isProjectPluginSource(pluginDir, pluginsDir); - } catch { - return false; - } -} - async function removeStaleManagedSkill(skillsDir: string, name: string): Promise { const skillPath = managedSkillPath(skillsDir, name); if (!skillPath) {return false;} From 31658d05bec274233bb1a9184b53d601811fbcd8 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 12 Jun 2026 15:21:43 -0700 Subject: [PATCH 03/35] fix(plugins): Keep marketplace metadata out of manifests --- packages/dotagents/src/agents/plugin-store.ts | 14 +++- .../src/cli/commands/install.test.ts | 84 +++++++++++++++++++ .../dotagents/src/cli/commands/install.ts | 9 +- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/packages/dotagents/src/agents/plugin-store.ts b/packages/dotagents/src/agents/plugin-store.ts index 90de533..b244229 100644 --- a/packages/dotagents/src/agents/plugin-store.ts +++ b/packages/dotagents/src/agents/plugin-store.ts @@ -328,7 +328,7 @@ async function discoverFromMarketplaces( if (!path) {continue;} const pluginDir = resolveInside(sourceDir, join(root, path), "Marketplace plugin source"); - const candidate = await loadPluginCandidate(sourceDir, pluginDir, entry); + const candidate = await loadPluginCandidate(sourceDir, pluginDir, marketplaceManifestOverlay(entry)); if (candidate) {return candidate;} } } @@ -362,7 +362,7 @@ async function scanPluginDirectories( async function loadPluginCandidate( sourceRoot: string, pluginDir: string, - overlay: Partial = {}, + overlay: Partial = {}, ): Promise { if (!existsSync(pluginDir)) {return null;} @@ -382,6 +382,16 @@ async function loadPluginCandidate( }; } +function marketplaceManifestOverlay( + entry: MarketplacePluginEntry, +): Partial { + const overlay: Partial = { name: entry.name }; + if (entry.description) {overlay.description = entry.description;} + if (entry.version) {overlay.version = entry.version;} + if (entry.category) {overlay.category = entry.category;} + return overlay; +} + async function loadManifest(pluginDir: string): Promise { for (const manifestPath of NATIVE_MANIFEST_PATHS) { const filePath = join(pluginDir, manifestPath); diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 3c1bee0..0eb48b9 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -304,6 +304,33 @@ source = "path:./.agents/plugins/local-tools/source" expect(existsSync(sourceDir)).toBe(true); }); + it("rejects same-project plugins in frozen mode", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" })); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "local-tools" +source = "path:.agents/plugins/local-tools" +`, + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "local-tools": { source: "path:.agents/plugins/local-tools" }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope, frozen: true })).rejects.toThrow(/Same-project plugins cannot be installed into the same project/); + }); + it("prefers canonical plugin directories before marketplace entries", async () => { const sourceRoot = join(projectRoot, "plugin-source"); const canonicalDir = join(sourceRoot, ".agents", "plugins", "review-tools"); @@ -353,6 +380,63 @@ source = "path:plugin-source" expect(installed["description"]).toBe("Canonical plugin"); }); + it("does not copy marketplace-only fields into manifest outputs", async () => { + const sourceRoot = join(projectRoot, "plugin-source"); + const pluginDir = join(sourceRoot, "plugins", "review-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + plugins: [ + { + name: "review-tools", + source: { source: "local", path: "plugins/review-tools" }, + description: "Marketplace description", + version: "1.0.0", + category: "Coding", + policy: { installation: "AVAILABLE" }, + "x-marketplace": true, + }, + ], + }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse( + await readFile(join(projectRoot, ".agents", "plugins", "review-tools", "plugin.json"), "utf-8"), + ) as Record; + const codexManifest = JSON.parse( + await readFile(join(projectRoot, ".agents", "plugins", "review-tools", ".codex-plugin", "plugin.json"), "utf-8"), + ) as Record; + + expect(installedManifest).toMatchObject({ + name: "review-tools", + description: "Marketplace description", + version: "1.0.0", + category: "Coding", + }); + expect(installedManifest["source"]).toBeUndefined(); + expect(installedManifest["policy"]).toBeUndefined(); + expect(installedManifest["x-marketplace"]).toBeUndefined(); + expect(codexManifest["source"]).toBeUndefined(); + expect(codexManifest["policy"]).toBeUndefined(); + expect(codexManifest["x-marketplace"]).toBeUndefined(); + }); + it("generates plugin runtime outputs in frozen mode from installed bundles", async () => { const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 67b7409..5ea277f 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -34,7 +34,6 @@ export interface InstallOptions { export interface InstallResult { installed: string[]; - skipped: string[]; pruned: string[]; prunedPlugins: string[]; hookWarnings: { agent: string; message: string }[]; @@ -45,6 +44,13 @@ export interface InstallResult { export async function runInstall(opts: InstallOptions): Promise { const { scope, frozen } = opts; const config = await loadConfig(scope.configPath); + if (scope.scope === "user" && config.plugins.length > 0) { + throw new InstallError( + "User-scope plugins are not supported yet because plugin runtime projections are project-scoped. " + + "Declare plugins in a project agents.toml instead.", + ); + } + const lockfile = await loadLockfile(scope.lockPath); const skills = await installSkills(config, lockfile, scope, frozen); const subagents = await installSubagents(config, lockfile, scope, frozen); @@ -80,7 +86,6 @@ export async function runInstall(opts: InstallOptions): Promise { return { installed: skills.installed, - skipped: [], pruned: skills.pruned, prunedPlugins: plugins.pruned, hookWarnings, From 05da4c1670e48f49dc082702cecefce997fce1e1 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 12 Jun 2026 15:43:24 -0700 Subject: [PATCH 04/35] fix(plugins): Tighten plugin sync diagnostics --- README.md | 2 ++ docs/public/llms.txt | 4 ++- .../src/agents/plugin-writer.test.ts | 20 +++++++++++++ .../dotagents/src/agents/plugin-writer.ts | 19 +++++++++++-- .../src/cli/commands/install.test.ts | 28 +++++++++++++++++++ .../dotagents/src/cli/commands/sync.test.ts | 21 ++++++++++++++ packages/dotagents/src/cli/commands/sync.ts | 19 +++++++++++-- specs/SPEC.md | 4 ++- specs/plugins.md | 2 ++ 9 files changed, 111 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f0efaad..c8bfe0a 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ targets = ["claude", "cursor", "codex", "grok", "opencode"] The canonical plugin format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a Codex-compatible marketplace baseline. Known input fields are validated, unknown manifest and marketplace extension fields are preserved, `targets` are limited to configured agents, and generated outputs are deterministic. dotagents rejects plugin sources that resolve to the same project's `.agents/plugins//` install destination, so same-repo plugins are never installed onto themselves. +Plugin declarations are project-scope only for now. `dotagents --user install` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. + [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively and needs no configuration. ## Documentation diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 1eec063..dea641c 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -297,6 +297,8 @@ Generated project-scope plugin outputs: Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated runtime marketplaces and generated Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. +Plugin declarations are project-scope only for now. `install --user` rejects `[[plugins]]` entries and `sync --user` reports them as unsupported because user-scope runtime plugin projections are not generated yet. + ### Trust Optional `[trust]` section to restrict allowed skill, subagent, and plugin sources. @@ -341,7 +343,7 @@ Create `agents.toml` and `.agents/skills/` directory. Automatically includes the npx @sentry/dotagents install ``` -Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, installs subagents and plugins, writes the lockfile, creates symlinks, and generates MCP, hook, subagent, and plugin configs. There is no separate update command. With `--frozen`, declared skills, subagents, and plugins must already exist in `agents.lock`; subagents and plugins are loaded from existing installed files without resolving sources; the lockfile is not updated; and existing managed subagent/plugin files are not pruned. +Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, installs subagents and project-scope plugins, writes the lockfile, creates symlinks, and generates MCP, hook, subagent, and plugin configs. There is no separate update command. With `--frozen`, declared skills, subagents, and project-scope plugins must already exist in `agents.lock`; subagents and plugins are loaded from existing installed files without resolving sources; the lockfile is not updated; and existing managed subagent/plugin files are not pruned. `install --user` rejects plugin declarations because user-scope plugin projections are not generated yet. ### add diff --git a/packages/dotagents/src/agents/plugin-writer.test.ts b/packages/dotagents/src/agents/plugin-writer.test.ts index d89fb26..2a3eac1 100644 --- a/packages/dotagents/src/agents/plugin-writer.test.ts +++ b/packages/dotagents/src/agents/plugin-writer.test.ts @@ -234,6 +234,26 @@ describe("plugin writer", () => { ); }); + it("warns without writing OpenCode re-exports for missing manifest modules", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { opencode: { plugins: ["opencode/missing.ts"] } }, + }); + + const result = await writePluginOutputs(["opencode"], [alpha], root); + + expect(result).toEqual({ + written: 0, + warnings: [ + { + agent: "opencode", + name: "alpha-tools", + message: `OpenCode plugin module missing: ${join(alpha.pluginDir, "opencode", "missing.ts")}`, + }, + ], + }); + expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(false); + }); + it("prunes stale managed runtime plugin outputs", async () => { const alpha = await plugin("alpha-tools", { manifest: { opencode: { plugins: ["opencode/plugin.ts"] } }, diff --git a/packages/dotagents/src/agents/plugin-writer.ts b/packages/dotagents/src/agents/plugin-writer.ts index 6ab84b7..c146855 100644 --- a/packages/dotagents/src/agents/plugin-writer.ts +++ b/packages/dotagents/src/agents/plugin-writer.ts @@ -382,7 +382,7 @@ async function writeOpenCodeProjection( plugin: PluginDeclaration, warnings: PluginWriteWarning[], ): Promise { - const modules = opencodeModules(plugin); + const modules = opencodeModules(plugin, warnings); let written = 0; for (const modulePath of modules) { const ext = extname(modulePath); @@ -458,9 +458,22 @@ function developerName(manifest: PluginManifest): string { return "Unknown"; } -function opencodeModules(plugin: PluginDeclaration): string[] { +function opencodeModules( + plugin: PluginDeclaration, + warnings: PluginWriteWarning[] = [], +): string[] { const opencode = plugin.manifest.opencode; - if (opencode?.plugins) {return opencode.plugins;} + if (opencode?.plugins) { + return opencode.plugins.filter((path) => { + if (existsSync(join(plugin.pluginDir, path))) {return true;} + warnings.push({ + agent: "opencode", + name: plugin.name, + message: `OpenCode plugin module missing: ${join(plugin.pluginDir, path)}`, + }); + return false; + }); + } const candidates = ["opencode/plugin.ts", "opencode/plugin.js"]; const candidate = candidates.find((path) => existsSync(join(plugin.pluginDir, path))); return candidate ? [candidate] : []; diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 0eb48b9..75b180f 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -331,6 +331,34 @@ source = "path:.agents/plugins/local-tools" await expect(runInstall({ scope, frozen: true })).rejects.toThrow(/Same-project plugins cannot be installed into the same project/); }); + it("rejects user-scope plugin declarations", async () => { + const previousHome = process.env["DOTAGENTS_HOME"]; + const dotagentsHome = join(tmpDir, "user-agents"); + process.env["DOTAGENTS_HOME"] = dotagentsHome; + try { + const scope = resolveScope("user"); + await mkdir(scope.root, { recursive: true }); + await writeFile( + scope.configPath, + `version = 1 + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + await expect(runInstall({ scope })).rejects.toThrow(/User-scope plugins are not supported yet/); + expect(existsSync(scope.pluginsDir)).toBe(false); + } finally { + if (previousHome === undefined) { + delete process.env["DOTAGENTS_HOME"]; + } else { + process.env["DOTAGENTS_HOME"] = previousHome; + } + } + }); + it("prefers canonical plugin directories before marketplace entries", async () => { const sourceRoot = join(projectRoot, "plugin-source"); const canonicalDir = join(sourceRoot, ".agents", "plugins", "review-tools"); diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index f72f02d..85ecdc7 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -285,6 +285,27 @@ describe("runSync", () => { expect(missingIssues[0]!.name).toBe("pdf"); }); + it("detects missing plugins as errors", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + expect(result.issues).toEqual([ + { + type: "missing", + name: "review-tools", + message: `Plugin "review-tools" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, + }, + ]); + }); + it("reports no issues when everything is in sync", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 9b989ea..0f510d5 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -77,7 +77,12 @@ export async function runSync(opts: SyncOptions): Promise { .map((plugin) => plugin.name) : [], ); - const runtimePluginConfigs = config.plugins.filter((plugin) => !selfInstalledPluginNames.has(plugin.name)); + const userScopePluginNames = scope.scope === "user" + ? config.plugins.map((plugin) => plugin.name) + : []; + const runtimePluginConfigs = scope.scope === "user" + ? [] + : config.plugins.filter((plugin) => !selfInstalledPluginNames.has(plugin.name)); for (const name of selfInstalledPluginNames) { issues.push({ @@ -86,6 +91,13 @@ export async function runSync(opts: SyncOptions): Promise { message: `Plugin "${name}" resolves to .agents/plugins/${name}. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.`, }); } + for (const name of userScopePluginNames) { + issues.push({ + type: "plugins", + name, + message: `Plugin "${name}" is declared in user scope, but user-scope plugins are not supported yet. Declare plugins in a project agents.toml instead.`, + }); + } // 1. Adopt orphaned skills (installed but not in agents.toml) if (existsSync(skillsDir)) { @@ -209,7 +221,7 @@ export async function runSync(opts: SyncOptions): Promise { for (const plugin of runtimePluginConfigs) { if (!existsSync(join(pluginsDir, plugin.name))) { issues.push({ - type: "plugins", + type: "missing", name: plugin.name, message: `Plugin "${plugin.name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, }); @@ -358,7 +370,8 @@ export async function runSync(opts: SyncOptions): Promise { // 8. Verify and repair plugin runtime projections let pluginsRepaired = 0; - const installedPluginResult = await loadInstalledPlugins(pluginsDir, runtimePluginConfigs); + const installedPluginConfigs = runtimePluginConfigs.filter((plugin) => existsSync(join(pluginsDir, plugin.name))); + const installedPluginResult = await loadInstalledPlugins(pluginsDir, installedPluginConfigs); const pluginDecls = installedPluginResult.plugins; const prunedInstalledPlugins = await pruneInstalledPlugins(pluginsDir, staleManagedPluginNames); const pluginIssues = scope.scope === "project" diff --git a/specs/SPEC.md b/specs/SPEC.md index 8a7b659..95aa023 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -267,6 +267,8 @@ Generated project-scope plugin outputs: Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. +Plugins are currently project-scope only. `install --user` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. + #### Supported Agents | ID | Tool | Config Dir | MCP File | MCP Format | Subagents | @@ -504,7 +506,7 @@ dotagents install b. Discover skill within the repo c. Copy skill directory into `.agents/skills//` 3. Resolve and install configured subagents into `.agents/agents/` -4. Resolve and install configured plugins into `.agents/plugins//` +4. Resolve and install configured project-scope plugins into `.agents/plugins//`; reject user-scope plugin declarations 5. Write `agents.lock` with the current configured skills, subagents, and plugins - In `--frozen` mode, require configured dependencies to already be present in `agents.lock`, load subagents and plugins from installed files, do not update the lockfile, and do not prune existing managed subagent or plugin files 6. Regenerate `.agents/.gitignore` diff --git a/specs/plugins.md b/specs/plugins.md index ffe13a3..b8e3063 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -262,6 +262,8 @@ Generated project-scope outputs should be: Installed and generated files are dotagents-managed. `install` and `sync` may overwrite stale managed files and prune removed managed files, but they must not overwrite hand-written plugin files without a generated marker or a canonical installed bundle path owned by dotagents. Generated Codex manifests carry `metadata.managedBy = "dotagents"` so target removal can prune them without deleting user-authored native Codex plugin manifests. +User-scope plugin declarations are not supported yet. `install --user` rejects `[[plugins]]` entries, and `sync --user` reports them as unsupported, because the current runtime projections are defined only for project scope. + ## Lockfile Plugin lock entries should use the same source-resolution fields as subagents: From eaa6646329f7752b460a26f926fa0c021e81eda0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 29 Jun 2026 18:26:06 -0700 Subject: [PATCH 05/35] fix(plugins): Separate canonical marketplaces Keep .agents/plugins/marketplace.json as authored canonical input instead of a generated or pruned runtime artifact. Tighten marketplace selector resolution and avoid treating unsupported source objects or URL-like paths as local filesystem paths. Preserve manifest-declared plugin names through validation, reject explicit path name mismatches, prefer directory-name plugin matches over root manifest matches, and keep same-project plugin aliases out of managed .agents/.gitignore entries. Report plugin sync issues from the repaired state, surface user-scope plugins as unsupported in doctor, and export public plugin config and lockfile types from the host package barrels. Extend the checked-in smoke example with a portable plugin fixture that exercises Claude, Cursor, Codex, Grok, and OpenCode plugin output generation and sync repair. Add regression coverage for git plugin lock metadata, explicit plugin path installs, manifest mismatches, plugin discovery precedence, public plugin type exports, and the reviewed sync and doctor plugin edge cases. Co-Authored-By: GPT-5 Codex --- README.md | 4 +- docs/public/llms.txt | 4 +- examples/full/agents.toml | 6 +- .../qa-tools/agents/plugin-reviewer.md | 5 + .../qa-tools/commands/plugin-qa.md | 6 + .../local-plugins/qa-tools/opencode/plugin.ts | 3 + .../full/local-plugins/qa-tools/plugin.json | 13 + .../qa-tools/skills/plugin-qa/SKILL.md | 6 + .../src/agents/plugin-schema.test.ts | 5 + .../dotagents/src/agents/plugin-schema.ts | 5 +- packages/dotagents/src/agents/plugin-store.ts | 96 ++++--- .../src/agents/plugin-writer.test.ts | 50 +--- .../dotagents/src/agents/plugin-writer.ts | 64 +---- .../dotagents/src/cli/commands/doctor.test.ts | 53 ++++ packages/dotagents/src/cli/commands/doctor.ts | 11 +- .../src/cli/commands/install.test.ts | 261 ++++++++++++++++-- .../dotagents/src/cli/commands/remove.test.ts | 2 +- .../dotagents/src/cli/commands/sync.test.ts | 55 ++-- packages/dotagents/src/cli/commands/sync.ts | 8 +- packages/dotagents/src/config/index.ts | 1 + .../dotagents/src/gitignore/writer.test.ts | 4 +- packages/dotagents/src/gitignore/writer.ts | 3 - packages/dotagents/src/index.test.ts | 9 +- packages/dotagents/src/index.ts | 3 +- packages/dotagents/src/lockfile/index.ts | 2 +- packages/dotagents/src/targets/registry.ts | 2 + skills/dotagents-qa/SKILL.md | 19 ++ skills/dotagents-qa/scripts/qa-example.mjs | 123 +++++++++ specs/SPEC.md | 4 +- specs/plugins.md | 18 +- 30 files changed, 620 insertions(+), 225 deletions(-) create mode 100644 examples/full/local-plugins/qa-tools/agents/plugin-reviewer.md create mode 100644 examples/full/local-plugins/qa-tools/commands/plugin-qa.md create mode 100644 examples/full/local-plugins/qa-tools/opencode/plugin.ts create mode 100644 examples/full/local-plugins/qa-tools/plugin.json create mode 100644 examples/full/local-plugins/qa-tools/skills/plugin-qa/SKILL.md diff --git a/README.md b/README.md index c8bfe0a..4064df0 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Review the current diff and return findings with file references. dotagents can also import native runtime subagent files from `.claude/agents/`, `.cursor/agents/`, `.codex/agents/*.toml`, and `.opencode/agents/`. Input and matching-runtime output use the same native format: Markdown with YAML frontmatter for Claude, Cursor, and OpenCode; TOML for Codex. Claude and Codex identify agents by `name`, Cursor can derive `name` from the filename when omitted, and OpenCode uses the filename as the agent name. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Subagent declarations intentionally cover only dependency source and runtime targets, not universal model/tool/permission behavior. -Plugins are declared with `[[plugins]]` entries. dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.agents/plugins/marketplace.json`, `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.grok/plugins//`, and `.opencode/plugins/.js|ts` where supported: +Plugins are declared with `[[plugins]]` entries. dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/plugins//`, and `.opencode/plugins/.js|ts` where supported: ```toml [[plugins]] @@ -138,7 +138,7 @@ path = "plugins/review-tools" targets = ["claude", "cursor", "codex", "grok", "opencode"] ``` -The canonical plugin format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a Codex-compatible marketplace baseline. Known input fields are validated, unknown manifest and marketplace extension fields are preserved, `targets` are limited to configured agents, and generated outputs are deterministic. dotagents rejects plugin sources that resolve to the same project's `.agents/plugins//` install destination, so same-repo plugins are never installed onto themselves. +The canonical plugin format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a Codex-compatible marketplace baseline. Known input fields are validated, component paths must be relative filesystem paths, unknown manifest extension fields are preserved in installed bundles, marketplace extension fields are accepted but not projected, `targets` are limited to configured agents, and generated outputs are deterministic. dotagents rejects plugin sources that resolve to the same project's `.agents/plugins//` install destination, so same-repo plugins are never installed onto themselves. Plugin declarations are project-scope only for now. `dotagents --user install` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. diff --git a/docs/public/llms.txt b/docs/public/llms.txt index dea641c..efefc70 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -278,7 +278,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//`. The canonical plugin input format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a generalized Codex-compatible marketplace and manifest shape. Known fields are validated, unknown manifest and marketplace extension fields are preserved, and component paths must be relative without `..`. +dotagents installs canonical plugin bundles under `.agents/plugins//`. The canonical plugin input format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a generalized Codex-compatible marketplace and manifest shape. Known fields are validated, unknown manifest extension fields are preserved in installed bundles, marketplace extension fields are accepted but not projected, and component paths must be relative filesystem paths without `..` or URL/scheme prefixes. | Field | Type | Required | Description | |-------|------|----------|-------------| @@ -291,7 +291,7 @@ dotagents installs canonical plugin bundles under `.agents/plugins//`. The Generated project-scope plugin outputs: - Claude: `.claude-plugin/marketplace.json` - Cursor: `.cursor-plugin/marketplace.json` -- Codex: `.agents/plugins/marketplace.json` and `.agents/plugins//.codex-plugin/plugin.json` +- Codex: `.agents/plugins//.codex-plugin/plugin.json` - Grok: `.grok/plugins//` managed copy - OpenCode: `.opencode/plugins/.js|ts` re-export module when the plugin declares or contains one OpenCode module diff --git a/examples/full/agents.toml b/examples/full/agents.toml index 9932e37..9607744 100644 --- a/examples/full/agents.toml +++ b/examples/full/agents.toml @@ -1,5 +1,5 @@ version = 1 -agents = ["claude", "cursor", "codex", "opencode"] +agents = ["claude", "cursor", "codex", "grok", "opencode"] [[skills]] name = "review" @@ -21,3 +21,7 @@ command = "echo fixture" [[subagents]] name = "code-reviewer" source = "path:./local-agents" + +[[plugins]] +name = "qa-tools" +source = "path:./local-plugins/qa-tools" diff --git a/examples/full/local-plugins/qa-tools/agents/plugin-reviewer.md b/examples/full/local-plugins/qa-tools/agents/plugin-reviewer.md new file mode 100644 index 0000000..a1d053d --- /dev/null +++ b/examples/full/local-plugins/qa-tools/agents/plugin-reviewer.md @@ -0,0 +1,5 @@ +--- +description: Proves plugin agent projection in dotagents smoke tests. +--- + +Return `DOTAGENTS_PLUGIN_QA_FIXTURE` and mention the plugin agent was loaded. diff --git a/examples/full/local-plugins/qa-tools/commands/plugin-qa.md b/examples/full/local-plugins/qa-tools/commands/plugin-qa.md new file mode 100644 index 0000000..e7158f6 --- /dev/null +++ b/examples/full/local-plugins/qa-tools/commands/plugin-qa.md @@ -0,0 +1,6 @@ +--- +name: plugin-qa +description: Prove plugin command projection in dotagents smoke tests. +--- + +Return `DOTAGENTS_PLUGIN_QA_FIXTURE` and mention the plugin command was loaded. diff --git a/examples/full/local-plugins/qa-tools/opencode/plugin.ts b/examples/full/local-plugins/qa-tools/opencode/plugin.ts new file mode 100644 index 0000000..f3f2845 --- /dev/null +++ b/examples/full/local-plugins/qa-tools/opencode/plugin.ts @@ -0,0 +1,3 @@ +export default { + name: "qa-tools", +}; diff --git a/examples/full/local-plugins/qa-tools/plugin.json b/examples/full/local-plugins/qa-tools/plugin.json new file mode 100644 index 0000000..4b64353 --- /dev/null +++ b/examples/full/local-plugins/qa-tools/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "qa-tools", + "version": "1.0.0", + "description": "Portable plugin fixture for dotagents QA.", + "category": "Testing", + "author": { + "name": "dotagents" + }, + "keywords": ["qa", "plugins"], + "opencode": { + "plugins": ["opencode/plugin.ts"] + } +} diff --git a/examples/full/local-plugins/qa-tools/skills/plugin-qa/SKILL.md b/examples/full/local-plugins/qa-tools/skills/plugin-qa/SKILL.md new file mode 100644 index 0000000..8060bae --- /dev/null +++ b/examples/full/local-plugins/qa-tools/skills/plugin-qa/SKILL.md @@ -0,0 +1,6 @@ +--- +name: plugin-qa +description: Use this fixture skill to prove plugin skill projection in dotagents smoke tests. +--- + +Return `DOTAGENTS_PLUGIN_QA_FIXTURE` when asked to prove plugin skill loading. diff --git a/packages/dotagents/src/agents/plugin-schema.test.ts b/packages/dotagents/src/agents/plugin-schema.test.ts index 64cbfe2..9726e14 100644 --- a/packages/dotagents/src/agents/plugin-schema.test.ts +++ b/packages/dotagents/src/agents/plugin-schema.test.ts @@ -34,6 +34,7 @@ describe("plugin manifest schema", () => { it("rejects absolute and traversing component paths", () => { expect(pluginManifestSchema.safeParse({ skills: "/tmp/skills" }).success).toBe(false); expect(pluginManifestSchema.safeParse({ commands: ["../commands"] }).success).toBe(false); + expect(pluginManifestSchema.safeParse({ skills: "https://example.com/skills" }).success).toBe(false); expect(pluginManifestSchema.safeParse({ opencode: { plugins: ["opencode/../../plugin.ts"] } }).success).toBe(false); }); @@ -95,6 +96,10 @@ describe("plugin marketplace schema", () => { name: "dotagents", plugins: [{ name: "bad", source: "../bad" }], }).success).toBe(false); + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: "https://example.com/plugin.git" }], + }).success).toBe(false); expect(pluginMarketplaceSchema.safeParse({ name: "dotagents", metadata: { pluginRoot: "../plugins" }, diff --git a/packages/dotagents/src/agents/plugin-schema.ts b/packages/dotagents/src/agents/plugin-schema.ts index 8c3387f..3140474 100644 --- a/packages/dotagents/src/agents/plugin-schema.ts +++ b/packages/dotagents/src/agents/plugin-schema.ts @@ -1,13 +1,16 @@ import { z } from "zod/v4"; +// Canonical plugin wire schemas. Known fields are validated for path safety, +// while passthrough preserves native runtime and future dotagents extensions. export const pluginPathSchema = z.string().check( z.refine((value) => { if (value.length === 0) {return false;} if (value.startsWith("/") || value.startsWith("\\")) {return false;} if (/^[a-zA-Z]:[\\/]/.test(value)) {return false;} + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) {return false;} const parts = value.replaceAll("\\", "/").split("/"); return !parts.includes(".."); - }, "Plugin paths must be relative and must not contain '..'"), + }, "Plugin paths must be relative filesystem paths and must not contain '..'"), ); const pluginAuthorSchema = z.object({ diff --git a/packages/dotagents/src/agents/plugin-store.ts b/packages/dotagents/src/agents/plugin-store.ts index b244229..4ca99a5 100644 --- a/packages/dotagents/src/agents/plugin-store.ts +++ b/packages/dotagents/src/agents/plugin-store.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { basename, isAbsolute, join, posix, relative, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, posix, relative, resolve } from "node:path"; import { applyDefaultRepositorySource, copyDir, @@ -34,8 +34,6 @@ export interface PluginResolveOptions { trust?: TrustPolicy; } -export type ResolvedPluginType = "git" | "local"; - export interface PluginDeclaration { name: string; source: string; @@ -44,16 +42,24 @@ export interface PluginDeclaration { targets?: string[]; } -export interface ResolvedPlugin { - type: ResolvedPluginType; +interface ResolvedLocalPlugin { + type: "local"; + source: string; + plugin: PluginDeclaration; +} + +interface ResolvedGitPlugin { + type: "git"; source: string; - resolvedUrl?: string; - resolvedPath?: string; + resolvedUrl: string; + resolvedPath: string; resolvedRef?: string; - commit?: string; + commit: string; plugin: PluginDeclaration; } +export type ResolvedPlugin = ResolvedLocalPlugin | ResolvedGitPlugin; + interface PluginCandidate { dir: string; path: string; @@ -211,21 +217,16 @@ export async function pruneInstalledPlugins( /** Converts a resolved plugin to its lockfile entry. */ export function lockEntryForPlugin(resolved: ResolvedPlugin): LockedPlugin { - const entry: Record = { source: resolved.source }; - setIfDefined(entry, "resolved_url", resolved.resolvedUrl); - setIfDefined(entry, "resolved_path", resolved.resolvedPath); - setIfDefined(entry, "resolved_ref", resolved.resolvedRef); - setIfDefined(entry, "resolved_commit", resolved.commit); - return entry as LockedPlugin; -} - -function setIfDefined( - entry: Record, - key: string, - value: string | undefined, -): void { - if (value === undefined) {return;} - entry[key] = value; + if (resolved.type === "local") { + return { source: resolved.source }; + } + return { + source: resolved.source, + resolved_url: resolved.resolvedUrl, + resolved_path: resolved.resolvedPath, + ...(resolved.resolvedRef === undefined ? {} : { resolved_ref: resolved.resolvedRef }), + resolved_commit: resolved.commit, + }; } /** Returns true for direct `path:.agents/plugins/...` plugin sources. */ @@ -254,18 +255,20 @@ export function isProjectPluginSource( /** Returns true when a plugin config resolves back into this project's managed plugin tree. */ export function isSameProjectPluginConfig( - plugin: Pick, + plugin: Pick, pluginsDir: string, projectRoot: string, ): boolean { if (isInPlacePluginSource(plugin.source)) {return true;} - if (!plugin.path) {return false;} try { const parsed = parseSource(plugin.source); if (parsed.type !== "local" || !parsed.path) {return false;} const sourceDir = resolve(projectRoot, parsed.path); - const pluginDir = resolve(sourceDir, plugin.path); + const pluginDir = plugin.path + ? resolve(sourceDir, plugin.path) + : resolve(sourceDir, ".agents", "plugins", plugin.name); + if (!plugin.path && !existsSync(pluginDir)) {return false;} const relPath = relative(sourceDir, pluginDir); if (relPath.startsWith("..") || isAbsolute(relPath)) {return false;} return isProjectPluginSource(pluginDir, pluginsDir); @@ -280,7 +283,7 @@ async function discoverPlugin( ): Promise { if (config.path) { const dir = resolveInside(sourceDir, config.path, "Plugin path"); - return loadPluginCandidate(sourceDir, dir); + return loadPluginCandidate(sourceDir, dir, { name: config.name }); } const matches: PluginCandidate[] = []; @@ -300,7 +303,7 @@ async function discoverPlugin( const recursive = await scanPluginDirectories(sourceDir, join(sourceDir, "plugins"), config.name); matches.push(...recursive); - const unique = dedupeCandidates(matches); + const unique = rankedCandidates(config.name, matches); if (unique.length > 1) { throw new Error( `Plugin "${config.name}" is ambiguous in ${config.source}: ${unique.map((m) => m.path).join(", ")}`, @@ -309,6 +312,11 @@ async function discoverPlugin( return unique[0] ?? null; } +/** + * Reads marketplace selector files and resolves only explicit local sources. + * Relative paths are anchored at the marketplace file directory, plus any + * marketplace-level `metadata.pluginRoot` prefix. + */ async function discoverFromMarketplaces( sourceDir: string, name: string, @@ -325,9 +333,12 @@ async function discoverFromMarketplaces( if (entry.name !== name) {continue;} const path = localMarketplacePath(entry.source); - if (!path) {continue;} + if (!path) { + throw new Error(`Marketplace source for plugin "${name}" is not a supported local source: ${marketplaceSourceLabel(entry.source)}`); + } - const pluginDir = resolveInside(sourceDir, join(root, path), "Marketplace plugin source"); + const marketplaceRoot = dirname(filePath); + const pluginDir = resolveInside(marketplaceRoot, join(root, path), "Marketplace plugin source"); const candidate = await loadPluginCandidate(sourceDir, pluginDir, marketplaceManifestOverlay(entry)); if (candidate) {return candidate;} } @@ -369,10 +380,10 @@ async function loadPluginCandidate( const manifest = await loadManifest(pluginDir); if (!manifest && !await hasPluginComponents(pluginDir)) {return null;} - const name = typeof overlay["name"] === "string" - ? String(overlay["name"]) - : manifest && typeof manifest["name"] === "string" - ? String(manifest["name"]) + const name = manifest && typeof manifest["name"] === "string" + ? String(manifest["name"]) + : typeof overlay["name"] === "string" + ? String(overlay["name"]) : basename(pluginDir); const combined = normalizeManifest(name, { ...overlay, ...manifest }); return { @@ -461,6 +472,15 @@ function candidateMatches(name: string, candidate: PluginCandidate): boolean { return basename(candidate.dir) === name || candidate.manifest["name"] === name; } +/** Applies discovery precedence: directory-name matches win before manifest-name fallback. */ +function rankedCandidates(name: string, candidates: PluginCandidate[]): PluginCandidate[] { + const unique = dedupeCandidates(candidates); + const directoryMatches = unique.filter((candidate) => basename(candidate.dir) === name); + return directoryMatches.length > 0 + ? directoryMatches + : unique.filter((candidate) => candidate.manifest["name"] === name); +} + function dedupeCandidates(candidates: PluginCandidate[]): PluginCandidate[] { const seen = new Set(); const result: PluginCandidate[] = []; @@ -479,16 +499,18 @@ function localMarketplacePath(source: MarketplacePluginEntry["source"]): string if (source.source === "local" && typeof source.path === "string") { return stripDotSlash(source.path); } - if (typeof source.path === "string" && !("url" in source) && !("repo" in source)) { - return stripDotSlash(source.path); - } return null; } +function marketplaceSourceLabel(source: MarketplacePluginEntry["source"]): string { + return typeof source === "string" ? source : JSON.stringify(source); +} + function stripDotSlash(path: string): string { return path.replace(/^\.\//, ""); } +/** Resolves a selector path while preserving the source-root containment boundary. */ function resolveInside(root: string, childPath: string, label: string): string { const rootPath = resolve(root); const filePath = resolve(rootPath, childPath); diff --git a/packages/dotagents/src/agents/plugin-writer.test.ts b/packages/dotagents/src/agents/plugin-writer.test.ts index 2a3eac1..9d46ff9 100644 --- a/packages/dotagents/src/agents/plugin-writer.test.ts +++ b/packages/dotagents/src/agents/plugin-writer.test.ts @@ -44,7 +44,7 @@ describe("plugin writer", () => { }; } - it("writes deterministic marketplace outputs for supported runtimes", async () => { + it("writes deterministic marketplace outputs for runtimes that need projections", async () => { const alpha = await plugin("alpha-tools"); const beta = await plugin("beta-tools"); @@ -55,50 +55,8 @@ describe("plugin writer", () => { ); expect(result.warnings).toEqual([]); - expect(result.written).toBe(5); - expect(await readFile(join(root, ".agents", "plugins", "marketplace.json"), "utf-8")).toBe(`{ - "interface": { - "displayName": "Dotagents Plugins" - }, - "metadata": { - "managedBy": "dotagents" - }, - "name": "dotagents", - "owner": { - "name": "dotagents" - }, - "plugins": [ - { - "category": "Coding", - "description": "Tools for alpha-tools", - "name": "alpha-tools", - "policy": { - "authentication": "ON_INSTALL", - "installation": "AVAILABLE" - }, - "source": { - "path": ".agents/plugins/alpha-tools", - "source": "local" - }, - "version": "1.0.0" - }, - { - "category": "Coding", - "description": "Tools for beta-tools", - "name": "beta-tools", - "policy": { - "authentication": "ON_INSTALL", - "installation": "AVAILABLE" - }, - "source": { - "path": ".agents/plugins/beta-tools", - "source": "local" - }, - "version": "1.0.0" - } - ] -} -`); + expect(result.written).toBe(4); + expect(existsSync(join(root, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ "metadata": { "managedBy": "dotagents" @@ -265,12 +223,10 @@ describe("plugin writer", () => { const pruned = await prunePluginOutputs([], [alpha], root); expect(pruned).toEqual([ - join(root, ".agents", "plugins", "marketplace.json"), join(root, ".grok", "plugins", "alpha-tools"), join(root, ".opencode", "plugins", "alpha-tools.ts"), join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"), ]); - expect(existsSync(join(root, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".grok", "plugins", "alpha-tools"))).toBe(false); expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(false); expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); diff --git a/packages/dotagents/src/agents/plugin-writer.ts b/packages/dotagents/src/agents/plugin-writer.ts index c146855..e323116 100644 --- a/packages/dotagents/src/agents/plugin-writer.ts +++ b/packages/dotagents/src/agents/plugin-writer.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import { cp, lstat, mkdir, readdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises"; import { dirname, extname, join, relative } from "node:path"; import type { PluginDeclaration } from "./plugin-store.js"; -import type { PluginManifest, PluginMarketplace } from "./plugin-schema.js"; +import type { PluginManifest } from "./plugin-schema.js"; import { allPluginAgentIds } from "./registry.js"; // Owns deterministic runtime plugin projections. Existing runtime artifacts are @@ -188,7 +188,6 @@ export async function prunePluginOutputs( function marketplaceOutputPaths(projectRoot: string): string[] { return [ - join(projectRoot, ".agents", "plugins", "marketplace.json"), join(projectRoot, ".claude-plugin", "marketplace.json"), join(projectRoot, ".cursor-plugin", "marketplace.json"), ]; @@ -202,17 +201,9 @@ function marketplaceOutputs( if (plugins.length === 0) {return [];} const outputs: RuntimeOutput[] = []; - const codexPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); const claudePlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); const cursorPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); - if (codexPlugins.length > 0) { - outputs.push({ - agent: "codex", - filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), - content: stableJson(codexMarketplace(projectRoot, codexPlugins)), - }); - } if (claudePlugins.length > 0) { outputs.push({ agent: "claude", @@ -239,13 +230,9 @@ function marketplaceOutputsForTargets( if (plugins.length === 0) {return [];} const outputs: RuntimeOutput[] = []; - const hasCodex = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); const hasClaude = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); const hasCursor = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); - if (hasCodex) { - outputs.push({ agent: "codex", filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), content: "" }); - } if (hasClaude) { outputs.push({ agent: "claude", filePath: join(projectRoot, ".claude-plugin", "marketplace.json"), content: "" }); } @@ -256,25 +243,6 @@ function marketplaceOutputsForTargets( return outputs; } -function codexMarketplace( - projectRoot: string, - plugins: PluginDeclaration[], -): PluginMarketplace { - return { - name: "dotagents", - interface: { - displayName: "Dotagents Plugins", - }, - owner: { - name: "dotagents", - }, - metadata: DOTAGENTS_METADATA, - plugins: plugins - .toSorted((a, b) => a.name.localeCompare(b.name)) - .map((plugin) => codexMarketplaceEntry(projectRoot, plugin)), - }; -} - function pathMarketplace( projectRoot: string, name: string, @@ -292,29 +260,10 @@ function pathMarketplace( }; } -function codexMarketplaceEntry( - projectRoot: string, - plugin: PluginDeclaration, -): PluginMarketplace["plugins"][number] { - const entry: PluginMarketplace["plugins"][number] = { - name: plugin.name, - source: { - source: "local" as const, - path: relativePath(projectRoot, plugin.pluginDir), - }, - policy: { - installation: "AVAILABLE", - authentication: "ON_INSTALL", - }, - category: manifestString(plugin.manifest, "category") ?? "Coding", - }; - const description = manifestString(plugin.manifest, "description"); - if (description) {entry.description = description;} - const version = manifestString(plugin.manifest, "version"); - if (version) {entry.version = version;} - return entry; -} - +/** + * Claude and Cursor marketplace projections use path strings instead of Codex's + * structured local source objects, so keep this projection format separate. + */ function pathMarketplaceEntry( projectRoot: string, plugin: PluginDeclaration, @@ -404,6 +353,7 @@ async function writeOpenCodeProjection( return written; } +/** Builds the managed Codex manifest projection and stamps dotagents ownership metadata. */ function codexRuntimeManifest(plugin: PluginDeclaration): Record { const manifest: Record = { ...plugin.manifest, @@ -568,10 +518,12 @@ async function isManagedJsonFile(filePath: string): Promise { } } +/** Checks Grok directory projections using the non-JSON marker file. */ async function isManagedProjection(path: string): Promise { return existsSync(join(path, ".dotagents-managed")); } +/** Checks OpenCode module projections using the generated-file header marker. */ async function isManagedOpenCodeModule(filePath: string): Promise { try { return (await readFile(filePath, "utf-8")).startsWith("// Generated by dotagents."); diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 979efdb..0a37971 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -124,6 +124,59 @@ source = "path:.agents/plugins/local-tools" expect(check?.message).toContain("Same-project plugins cannot be installed into the same project"); }); + it("detects same-project plugins resolved through canonical discovery", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" })); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "local-tools" +source = "path:." +`, + ); + 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 check = result.checks.find((c) => c.name === "installed plugins"); + expect(check?.status).toBe("error"); + expect(check?.message).toContain("Same-project plugins cannot be installed into the same project"); + }); + + it("reports user-scope plugins as unsupported", async () => { + const previousHome = process.env["DOTAGENTS_HOME"]; + const userRoot = join(tmpDir, "user-agents"); + process.env["DOTAGENTS_HOME"] = userRoot; + const scope = resolveScope("user"); + await mkdir(scope.agentsDir, { recursive: true }); + await writeFile( + scope.configPath, + `version = 1 + +[[plugins]] +name = "review-tools" +source = "getsentry/plugins" +`, + ); + + try { + const result = await runDoctor({ scope }); + const check = result.checks.find((c) => c.name === "installed plugins"); + expect(check?.status).toBe("error"); + expect(check?.message).toContain("User-scope plugins are not supported yet"); + expect(check?.message).not.toContain("Run 'npx @sentry/dotagents install'"); + } finally { + if (previousHome === undefined) { + delete process.env["DOTAGENTS_HOME"]; + } else { + process.env["DOTAGENTS_HOME"] = previousHome; + } + } + }); + it("detects generated files tracked by git", async () => { // Initialize a git repo so git ls-files works const { execSync } = await import("node:child_process"); diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index dfbd886..264b969 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -196,6 +196,9 @@ export async function runDoctor(opts: DoctorOptions): Promise { } // 9. Declared plugins are installed + const userScopePlugins = scope.scope === "user" + ? config.plugins.map((plugin) => plugin.name) + : []; const sameProjectPlugins = scope.scope === "project" ? config.plugins .filter((plugin) => isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) @@ -205,7 +208,13 @@ export async function runDoctor(opts: DoctorOptions): Promise { .filter((plugin) => !sameProjectPlugins.includes(plugin.name)) .filter((plugin) => !existsSync(`${scope.pluginsDir}/${plugin.name}`)) .map((plugin) => plugin.name); - if (sameProjectPlugins.length > 0) { + if (userScopePlugins.length > 0) { + checks.push({ + name: "installed plugins", + status: "error", + message: `${userScopePlugins.length} plugin(s) declared in user scope: ${userScopePlugins.join(", ")}. User-scope plugins are not supported yet; declare plugins in a project agents.toml instead.`, + }); + } else if (sameProjectPlugins.length > 0) { checks.push({ name: "installed plugins", status: "error", diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 75b180f..7e9d67c 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -159,35 +159,7 @@ source = "path:plugin-source/review-tools" source: "path:plugin-source/review-tools", }); - expect(await readFile(join(projectRoot, ".agents", "plugins", "marketplace.json"), "utf-8")).toBe(`{ - "interface": { - "displayName": "Dotagents Plugins" - }, - "metadata": { - "managedBy": "dotagents" - }, - "name": "dotagents", - "owner": { - "name": "dotagents" - }, - "plugins": [ - { - "category": "Coding", - "description": "Review workflow helpers", - "name": "review-tools", - "policy": { - "authentication": "ON_INSTALL", - "installation": "AVAILABLE" - }, - "source": { - "path": ".agents/plugins/review-tools", - "source": "local" - }, - "version": "1.0.0" - } - ] -} -`); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ "metadata": { "managedBy": "dotagents" @@ -218,6 +190,131 @@ source = "path:plugin-source/review-tools" expect(agentsGitignore).toContain("/plugins/review-tools/"); }); + it("installs a plugin from a git source and records resolved lock metadata", async () => { + await ensureGitRepo(); + const pluginDir = join(repoDir, "plugins", "review-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Review workflow helpers", + }, null, 2), + ); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await exec("git", ["add", "."], { cwd: repoDir }); + await exec("git", ["commit", "-m", "add review plugin"], { cwd: repoDir }); + const { stdout: commit } = await exec("git", ["rev-parse", "HEAD"], { cwd: repoDir }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "git:${repoDir}" +path = "plugins/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", "skills", "review", "SKILL.md"))).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", ".codex-plugin", "plugin.json"))).toBe(true); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins["review-tools"]).toEqual({ + source: `git:${repoDir}`, + resolved_url: repoDir, + resolved_path: "plugins/review-tools", + resolved_commit: commit.trim(), + }); + }); + + it("installs an explicit plugin path without a manifest using the configured name", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools-v2"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:." +path = "plugin-source/review-tools-v2" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const manifest = JSON.parse( + await readFile(join(projectRoot, ".agents", "plugins", "review-tools", "plugin.json"), "utf-8"), + ) as Record; + expect(manifest["name"]).toBe("review-tools"); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", "skills", "review", "SKILL.md"))).toBe(true); + }); + + it("rejects an explicit plugin path when its manifest name differs", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools-v2"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ name: "other-tools", description: "Wrong plugin" }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:." +path = "plugin-source/review-tools-v2" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow( + 'Plugin manifest name "other-tools" does not match configured name "review-tools"', + ); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools"))).toBe(false); + }); + + it("prefers plugin directory-name matches over root manifest-name matches", async () => { + const sourceRoot = join(projectRoot, "plugin-source"); + const pluginDir = join(sourceRoot, "plugins", "review-tools"); + await mkdir(join(sourceRoot, "skills", "root-review"), { recursive: true }); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile( + join(sourceRoot, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Root plugin should not win" }, null, 2), + ); + await writeFile(join(sourceRoot, "skills", "root-review", "SKILL.md"), SKILL_MD("root-review")); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", "skills", "review", "SKILL.md"))).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", "skills", "root-review", "SKILL.md"))).toBe(false); + }); + it("keeps plugin lock entries when runtime projection fails after installing the bundle", async () => { const sourceDir = join(projectRoot, "plugin-source", "review-tools"); await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); @@ -331,6 +428,33 @@ source = "path:.agents/plugins/local-tools" await expect(runInstall({ scope, frozen: true })).rejects.toThrow(/Same-project plugins cannot be installed into the same project/); }); + it("rejects same-project canonical plugin discovery in frozen mode", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" })); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "local-tools" +source = "path:." +`, + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "local-tools": { source: "path:." }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope, frozen: true })).rejects.toThrow(/Same-project plugins cannot be installed into the same project/); + }); + it("rejects user-scope plugin declarations", async () => { const previousHome = process.env["DOTAGENTS_HOME"]; const dotagentsHome = join(tmpDir, "user-agents"); @@ -465,6 +589,83 @@ source = "path:plugin-source" expect(codexManifest["x-marketplace"]).toBeUndefined(); }); + it("resolves canonical marketplace sources relative to the marketplace directory", async () => { + const sourceRoot = join(projectRoot, "plugin-source"); + const pluginDir = join(sourceRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Canonical marketplace plugin" }, null, 2), + ); + await writeFile( + join(sourceRoot, ".agents", "plugins", "marketplace.json"), + JSON.stringify({ + name: "source-marketplace", + plugins: [ + { + name: "review-tools", + source: { source: "local", path: "./review-tools" }, + }, + ], + }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse( + await readFile(join(projectRoot, ".agents", "plugins", "review-tools", "plugin.json"), "utf-8"), + ) as Record; + expect(installedManifest["description"]).toBe("Canonical marketplace plugin"); + }); + + it("rejects unsupported marketplace source objects instead of guessing local paths", async () => { + const sourceRoot = join(projectRoot, "plugin-source"); + await mkdir(sourceRoot, { recursive: true }); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "source-marketplace", + plugins: [ + { + name: "review-tools", + source: { + source: "github", + path: "plugins/review-tools", + repo: "org/review-tools", + }, + }, + ], + }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow( + /Marketplace source for plugin "review-tools" is not a supported local source/, + ); + }); + it("generates plugin runtime outputs in frozen mode from installed bundles", async () => { const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); @@ -500,7 +701,7 @@ source = "path:external-source" const scope = resolveScope("project", projectRoot); await runInstall({ scope, frozen: true }); - expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", ".codex-plugin", "plugin.json"))).toBe(true); }); diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index 30c85dd..99ac475 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -138,7 +138,7 @@ source = "path:plugins/review-tools" const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); expect(gitignore).not.toContain("/skills/pdf/"); expect(gitignore).toContain("/plugins/review-tools/"); - expect(gitignore).toContain("/plugins/marketplace.json"); + expect(gitignore).not.toContain("/plugins/marketplace.json"); }); it("throws RemoveError for skill not in config", async () => { diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 85ecdc7..6dd812e 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -668,29 +668,8 @@ source = "path:plugin-source/review-tools" const result = await runSync({ scope: resolveScope("project", projectRoot) }); expect(result.pluginsRepaired).toBeGreaterThan(0); - expect(result.issues).toEqual([ - { - type: "plugins", - name: "marketplace", - message: `Plugin marketplace missing: ${join(projectRoot, ".agents", "plugins", "marketplace.json")}`, - }, - { - type: "plugins", - name: "marketplace", - message: `Plugin marketplace missing: ${join(projectRoot, ".claude-plugin", "marketplace.json")}`, - }, - { - type: "plugins", - name: "marketplace", - message: `Plugin marketplace missing: ${join(projectRoot, ".cursor-plugin", "marketplace.json")}`, - }, - { - type: "plugins", - name: "review-tools", - message: `Codex plugin manifest missing: ${join(pluginDir, ".codex-plugin", "plugin.json")}`, - }, - ]); - expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(true); + expect(result.issues).toEqual([]); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(existsSync(join(projectRoot, ".claude-plugin", "marketplace.json"))).toBe(true); expect(existsSync(join(projectRoot, ".cursor-plugin", "marketplace.json"))).toBe(true); }); @@ -753,10 +732,40 @@ path = ".agents/plugins/local-tools" name: "local-tools", message: 'Plugin "local-tools" resolves to .agents/plugins/local-tools. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.', }); + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/plugins/local-tools/"); expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(existsSync(join(pluginDir, ".codex-plugin", "plugin.json"))).toBe(false); }); + it("reports same-project plugins resolved through canonical discovery", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" }, null, 2)); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "local-tools" +source = "path:." +`, + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.issues).toContainEqual({ + type: "plugins", + name: "local-tools", + message: 'Plugin "local-tools" resolves to .agents/plugins/local-tools. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.', + }); + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/plugins/local-tools/"); + expect(existsSync(join(pluginDir, ".codex-plugin", "plugin.json"))).toBe(false); + }); + it("only prunes stale managed plugin directories from the lockfile", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); await mkdir(join(projectRoot, ".agents", "plugins", "stale-managed"), { recursive: true }); diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 0f510d5..c8f5ddf 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -184,10 +184,11 @@ export async function runSync(opts: SyncOptions): Promise { } } const managedPluginNames = new Set(config.plugins - .filter((plugin) => !isInPlacePluginSource(plugin.source)) + .filter((plugin) => !selfInstalledPluginNames.has(plugin.name) && !isInPlacePluginSource(plugin.source)) .map((plugin) => plugin.name)); if (lockNow) { for (const [name, locked] of Object.entries(lockNow.plugins)) { + if (selfInstalledPluginNames.has(name)) {continue;} if (!isInPlacePluginSource(locked.source)) { managedPluginNames.add(name); } @@ -374,14 +375,13 @@ export async function runSync(opts: SyncOptions): Promise { const installedPluginResult = await loadInstalledPlugins(pluginsDir, installedPluginConfigs); const pluginDecls = installedPluginResult.plugins; const prunedInstalledPlugins = await pruneInstalledPlugins(pluginsDir, staleManagedPluginNames); - const pluginIssues = scope.scope === "project" - ? await verifyPluginOutputs(config.agents, pluginDecls, scope.root) - : []; + let pluginIssues: Awaited> = []; if (scope.scope === "project") { const pluginResult = await writePluginOutputs(config.agents, pluginDecls, scope.root); const prunedPluginOutputs = await prunePluginOutputs(config.agents, pluginDecls, scope.root); pluginsRepaired = pluginResult.written + prunedPluginOutputs.length + prunedInstalledPlugins.length; + pluginIssues = await verifyPluginOutputs(config.agents, pluginDecls, scope.root); for (const warning of pluginResult.warnings) { issues.push({ diff --git a/packages/dotagents/src/config/index.ts b/packages/dotagents/src/config/index.ts index eb6e015..4879ce3 100644 --- a/packages/dotagents/src/config/index.ts +++ b/packages/dotagents/src/config/index.ts @@ -17,5 +17,6 @@ export type { SkillSource, McpConfig, SubagentConfig, + PluginConfig, TrustConfig, } from "./schema.js"; diff --git a/packages/dotagents/src/gitignore/writer.test.ts b/packages/dotagents/src/gitignore/writer.test.ts index 93c8e99..2ce8114 100644 --- a/packages/dotagents/src/gitignore/writer.test.ts +++ b/packages/dotagents/src/gitignore/writer.test.ts @@ -44,13 +44,13 @@ describe("writeAgentsGitignore", () => { expect(content).toContain("/agents/test-runner.md"); }); - it("lists managed plugin directories and generated marketplace", async () => { + it("lists managed plugin directories", async () => { const agentsDir = join(dir, ".agents"); await writeAgentsGitignore(agentsDir, [], [], ["review-tools"]); const content = await readFile(join(agentsDir, ".gitignore"), "utf-8"); expect(content).toContain("/plugins/review-tools/"); - expect(content).toContain("/plugins/marketplace.json"); + expect(content).not.toContain("/plugins/marketplace.json"); }); it("sorts skill names alphabetically", async () => { diff --git a/packages/dotagents/src/gitignore/writer.ts b/packages/dotagents/src/gitignore/writer.ts index e0e0d92..510c1f7 100644 --- a/packages/dotagents/src/gitignore/writer.ts +++ b/packages/dotagents/src/gitignore/writer.ts @@ -25,9 +25,6 @@ export async function writeAgentsGitignore( for (const name of managedPluginNames.toSorted()) { lines.push(`/plugins/${name}/`); } - if (managedPluginNames.length > 0) { - lines.push("/plugins/marketplace.json"); - } lines.push(""); // trailing newline await writeFile(join(agentsDir, ".gitignore"), lines.join("\n"), "utf-8"); diff --git a/packages/dotagents/src/index.test.ts b/packages/dotagents/src/index.test.ts index ca0c551..b83f633 100644 --- a/packages/dotagents/src/index.test.ts +++ b/packages/dotagents/src/index.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, expectTypeOf, beforeEach, afterEach } from "vitest"; import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { discoverSkill, discoverAllSkills } from "./index.js"; +import { discoverSkill, discoverAllSkills, type PluginConfig, type LockedPlugin } from "./index.js"; const SKILL_MD = (name: string) => `---\nname: ${name}\ndescription: ${name} skill\n---\n`; @@ -56,4 +56,9 @@ describe("host re-exports preserve dotagents scan dirs", () => { const names = results.map((r) => r.meta.name).toSorted(); expect(names).toEqual(["commit", "lint"]); }); + + it("exports public plugin types", () => { + expectTypeOf().toMatchTypeOf<{ name: string; source: string }>(); + expectTypeOf().toMatchTypeOf<{ source: string }>(); + }); }); diff --git a/packages/dotagents/src/index.ts b/packages/dotagents/src/index.ts index b32c1d4..c31fd8f 100644 --- a/packages/dotagents/src/index.ts +++ b/packages/dotagents/src/index.ts @@ -7,6 +7,7 @@ export type { SkillSource, McpConfig, SubagentConfig, + PluginConfig, TrustConfig, } from "./config/index.js"; @@ -34,7 +35,7 @@ export { writeAgentsGitignore, ensureRootGitignoreEntries } from "./gitignore/in export { ensureSkillsSymlink, verifySymlinks } from "./symlinks/index.js"; export { lockfileSchema, loadLockfile, LockfileError, writeLockfile } from "./lockfile/index.js"; -export type { Lockfile, LockedSkill, LockedSubagent } from "./lockfile/index.js"; +export type { Lockfile, LockedSkill, LockedSubagent, LockedPlugin } from "./lockfile/index.js"; // --------------------------------------------------------------------------- // Re-exports from @sentry/dotagents-lib. diff --git a/packages/dotagents/src/lockfile/index.ts b/packages/dotagents/src/lockfile/index.ts index cd4d865..6ed43f7 100644 --- a/packages/dotagents/src/lockfile/index.ts +++ b/packages/dotagents/src/lockfile/index.ts @@ -1,4 +1,4 @@ export { lockfileSchema } from "./schema.js"; -export type { Lockfile, LockedSkill, LockedSubagent } from "./schema.js"; +export type { Lockfile, LockedSkill, LockedSubagent, LockedPlugin } from "./schema.js"; export { loadLockfile, LockfileError } from "./loader.js"; export { writeLockfile } from "./writer.js"; diff --git a/packages/dotagents/src/targets/registry.ts b/packages/dotagents/src/targets/registry.ts index aa1c6be..754ef66 100644 --- a/packages/dotagents/src/targets/registry.ts +++ b/packages/dotagents/src/targets/registry.ts @@ -21,10 +21,12 @@ export function allAgentIds(): string[] { return [...AGENT_REGISTRY.keys()]; } +/** Returns agent IDs accepted in agents.toml, including plugin-only targets. */ export function allConfigAgentIds(): string[] { return [...new Set([...allAgentIds(), ...PLUGIN_ONLY_AGENT_IDS])]; } +/** Returns runtime IDs that plugin declarations may target. */ export function allPluginAgentIds(): string[] { return PLUGIN_AGENT_IDS; } diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index 4e24dcc..e26fb14 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -145,6 +145,8 @@ asserts: - hook files for Claude and Cursor - canonical installed subagent under `.agents/agents/` - generated subagent runtime files for Claude, Cursor, Codex, and OpenCode +- canonical installed plugin bundle under `.agents/plugins/` +- generated plugin runtime files for Claude, Cursor, Codex, Grok, and OpenCode - `sync` repair after deleting representative generated files Use `node skills/dotagents-qa/scripts/qa-example.mjs all --keep` when you need @@ -224,6 +226,9 @@ Useful fixture changes: - Subagents: include a portable Markdown fixture under `agents/`, assert the installed canonical file in `.agents/agents/`, assert generated runtime files for Claude/Cursor/Codex/OpenCode, and inspect `agents.lock`. +- Plugins: include a local plugin fixture with representative components, + assert the installed canonical bundle in `.agents/plugins/`, assert generated + runtime files for Claude/Cursor/Codex/Grok/OpenCode, and inspect `agents.lock`. - Sync and doctor: pre-create broken or legacy state, then prove repair and diagnostics. - User scope: set both `HOME` and `DOTAGENTS_HOME`, pass `--user`, and inspect @@ -268,7 +273,14 @@ test -f .claude/agents/code-reviewer.md test -f .cursor/agents/code-reviewer.md test -f .codex/agents/code-reviewer.toml test -f .opencode/agents/code-reviewer.md +test -f .agents/plugins/qa-tools/plugin.json +test -f .claude-plugin/marketplace.json +test -f .cursor-plugin/marketplace.json +test -f .agents/plugins/qa-tools/.codex-plugin/plugin.json +test -f .grok/plugins/qa-tools/.dotagents-managed +test -f .opencode/plugins/qa-tools.ts grep -q "code-reviewer" agents.lock +grep -q "qa-tools" agents.lock grep -q "Generated by dotagents" .claude/agents/code-reviewer.md grep -q "Generated by dotagents" .codex/agents/code-reviewer.toml ``` @@ -278,11 +290,18 @@ diff claims to repair, then verify the repair: ```bash rm .mcp.json .claude/skills .claude/agents/code-reviewer.md .codex/agents/code-reviewer.toml +rm .claude-plugin/marketplace.json .agents/plugins/qa-tools/.codex-plugin/plugin.json +rm -rf .grok/plugins/qa-tools +rm .opencode/plugins/qa-tools.ts "${cli[@]}" sync | tee /qa-out/sync.out test -f .mcp.json test -L .claude/skills test -f .claude/agents/code-reviewer.md test -f .codex/agents/code-reviewer.toml +test -f .claude-plugin/marketplace.json +test -f .agents/plugins/qa-tools/.codex-plugin/plugin.json +test -f .grok/plugins/qa-tools/.dotagents-managed +test -f .opencode/plugins/qa-tools.ts ``` For user-scope changes: diff --git a/skills/dotagents-qa/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs index 37bda4d..409a979 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -36,6 +36,9 @@ const taskGroups = { const tasks = { "install-files": runInstallFiles, "sync-repair": runSyncRepair, + "plugin-claude": runClaudePluginProof, + "plugin-codex": runCodexPluginProof, + "plugin-opencode": runOpenCodePluginProof, "codex-runtime": runCodexRuntimeProof, }; @@ -110,6 +113,9 @@ Tasks: all Run install-files and sync-repair install-files Install the full example and assert generated files sync-repair Delete representative generated files and assert sync repairs them + plugin-claude Validate generated Claude plugin and marketplace with Claude Code + plugin-codex Add/list/install generated Codex marketplace with Codex CLI + plugin-opencode Assert generated OpenCode module and CLI surface codex-runtime Paid proof that Codex can spawn the generated custom agent Compatibility: @@ -127,11 +133,69 @@ async function runSyncRepair() { rmSync(join(projectDir, ".claude", "skills"), { force: true, recursive: true }); rmSync(join(projectDir, ".claude", "agents", "code-reviewer.md"), { force: true }); rmSync(join(projectDir, ".codex", "agents", "code-reviewer.toml"), { force: true }); + rmSync(join(projectDir, ".claude-plugin", "marketplace.json"), { force: true }); + rmSync(join(projectDir, ".agents", "plugins", "qa-tools", ".codex-plugin", "plugin.json"), { force: true }); + rmSync(join(projectDir, ".grok", "plugins", "qa-tools"), { force: true, recursive: true }); + rmSync(join(projectDir, ".opencode", "plugins", "qa-tools.ts"), { force: true }); runCli(["sync"]); assertFile(".mcp.json"); assertSymlink(".claude/skills"); assertFile(".claude/agents/code-reviewer.md"); assertFile(".codex/agents/code-reviewer.toml"); + assertPluginOutputs(); +} + +async function runClaudePluginProof() { + await installAndAssert(); + execFileSync("claude", ["plugin", "validate", join(projectDir, ".agents", "plugins", "qa-tools")], { + cwd: projectDir, + env: fixtureEnv, + stdio: "inherit", + }); + execFileSync("claude", ["plugin", "validate", join(projectDir, ".claude-plugin", "marketplace.json")], { + cwd: projectDir, + env: fixtureEnv, + stdio: "inherit", + }); +} + +async function runCodexPluginProof() { + await installAndAssert(); + rmSync(codexHomeDir, { recursive: true, force: true }); + mkdirSync(codexHomeDir, { recursive: true }); + const env = { ...fixtureEnv, CODEX_HOME: codexHomeDir }; + + const add = execJson("codex", ["plugin", "marketplace", "add", projectDir, "--json"], env); + if (add.marketplaceName !== "dotagents-local") { + throw new Error("Codex marketplace add did not return dotagents-local"); + } + + const available = execJson("codex", ["plugin", "list", "--available", "--json"], env); + if (!available.available?.some((plugin) => plugin.pluginId === "qa-tools@dotagents-local")) { + throw new Error("Codex available plugin list did not include qa-tools@dotagents-local"); + } + + const installed = execJson("codex", ["plugin", "add", "qa-tools@dotagents-local", "--json"], env); + if (installed.pluginId !== "qa-tools@dotagents-local") { + throw new Error("Codex plugin add did not install qa-tools@dotagents-local"); + } + + const list = execJson("codex", ["plugin", "list", "--json"], env); + if (!list.installed?.some((plugin) => plugin.pluginId === "qa-tools@dotagents-local" && plugin.enabled === true)) { + throw new Error("Codex installed plugin list did not include enabled qa-tools@dotagents-local"); + } +} + +async function runOpenCodePluginProof() { + await installAndAssert(); + execFileSync("opencode", ["plugin", "--help"], { + cwd: projectDir, + env: fixtureEnv, + stdio: "inherit", + }); + assertFile(".opencode/plugins/qa-tools.ts"); + assertFileIncludes(".opencode/plugins/qa-tools.ts", "Generated by dotagents"); + assertFileIncludes(".opencode/plugins/qa-tools.ts", "../.agents/plugins/qa-tools/opencode/plugin.ts"); } async function runCodexRuntimeProof() { @@ -163,6 +227,7 @@ async function installAndAssert() { assertFile(".claude/settings.json"); assertFile(".cursor/hooks.json"); assertSubagentOutputs(); + assertPluginOutputs(); } function runCli(cliArgs) { @@ -174,6 +239,16 @@ function runCli(cliArgs) { }); } +function execJson(command, args, env) { + const output = execFileSync(command, args, { + cwd: projectDir, + env, + encoding: "utf-8", + stdio: ["ignore", "pipe", "inherit"], + }); + return JSON.parse(output); +} + async function listSkills() { const [{ runList }, { resolveScope }] = await Promise.all([ import(pathToFileURL(join(repoRoot, "packages", "dotagents", "dist", "cli", "commands", "list.js")).href), @@ -274,6 +349,37 @@ function assertSubagentOutputs() { assertFileIncludes(".opencode/agents/code-reviewer.md", sentinel); } +function assertPluginOutputs() { + assertFile(".agents/plugins/qa-tools/plugin.json"); + assertFile(".agents/plugins/qa-tools/skills/plugin-qa/SKILL.md"); + assertFile(".agents/plugins/qa-tools/commands/plugin-qa.md"); + assertFile(".agents/plugins/qa-tools/agents/plugin-reviewer.md"); + assertFile(".agents/plugins/qa-tools/opencode/plugin.ts"); + assertFileIncludes("agents.lock", "qa-tools"); + assertFileDoesNotExist(".agents/plugins/marketplace.json"); + + assertFile(".claude-plugin/marketplace.json"); + assertFileIncludes(".claude-plugin/marketplace.json", '"managedBy": "dotagents"'); + assertFileIncludes(".claude-plugin/marketplace.json", '"name": "qa-tools"'); + assertFileIncludes(".claude-plugin/marketplace.json", '"source": ".agents/plugins/qa-tools"'); + assertFile(".cursor-plugin/marketplace.json"); + assertSameFile(".cursor-plugin/marketplace.json", ".claude-plugin/marketplace.json"); + + assertFile(".agents/plugins/qa-tools/.codex-plugin/plugin.json"); + assertFileIncludes(".agents/plugins/qa-tools/.codex-plugin/plugin.json", '"managedBy": "dotagents"'); + assertFileIncludes(".agents/plugins/qa-tools/.codex-plugin/plugin.json", '"skills": "./skills"'); + assertFileIncludes(".agents/plugins/qa-tools/.codex-plugin/plugin.json", '"commands": "./commands"'); + assertFileIncludes(".agents/plugins/qa-tools/.codex-plugin/plugin.json", '"agents": "./agents"'); + + assertFile(".grok/plugins/qa-tools/.dotagents-managed"); + assertFile(".grok/plugins/qa-tools/plugin.json"); + assertFileIncludes(".grok/plugins/qa-tools/skills/plugin-qa/SKILL.md", "DOTAGENTS_PLUGIN_QA_FIXTURE"); + + assertFile(".opencode/plugins/qa-tools.ts"); + assertFileIncludes(".opencode/plugins/qa-tools.ts", "Generated by dotagents"); + assertFileIncludes(".opencode/plugins/qa-tools.ts", "../.agents/plugins/qa-tools/opencode/plugin.ts"); +} + function assertFile(relativePath) { const path = join(projectDir, relativePath); if (!existsSync(path)) { @@ -281,6 +387,13 @@ function assertFile(relativePath) { } } +function assertFileDoesNotExist(relativePath) { + const path = join(projectDir, relativePath); + if (existsSync(path)) { + throw new Error(`expected file not to exist: ${relativePath}`); + } +} + function assertSymlink(relativePath) { const path = join(projectDir, relativePath); if (!existsSync(path) || !lstatSync(path).isSymbolicLink()) { @@ -303,6 +416,16 @@ function assertSkillStatus(statuses, name) { } } +function assertSameFile(actualPath, expectedPath) { + assertFile(actualPath); + assertFile(expectedPath); + const actual = readFileSync(join(projectDir, actualPath), "utf-8"); + const expected = readFileSync(join(projectDir, expectedPath), "utf-8"); + if (actual !== expected) { + throw new Error(`${actualPath} should match ${expectedPath}`); + } +} + function assertIncludes(value, expected, message) { if (!value.includes(expected)) { throw new Error(message); diff --git a/specs/SPEC.md b/specs/SPEC.md index 95aa023..0c48745 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 canonical plugin input format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a generalized Codex-compatible marketplace and manifest shape. Canonical plugin manifests and marketplaces validate known fields tightly but allow unknown extension fields so native runtime metadata and future dotagents fields can be preserved. +The canonical plugin input format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a generalized Codex-compatible marketplace and manifest shape. Canonical plugin manifests and marketplaces validate known fields tightly while allowing unknown extension fields. Manifest extensions are preserved in installed bundles and generated native manifests; marketplace extensions are accepted as input metadata but are not projected into generated marketplaces. See [Plugin Support Specification](plugins.md) for the canonical layout, exact input/output contract, native docs captured for each runtime, discovery rules, generated runtime outputs, and non-goals. @@ -261,7 +261,7 @@ Generated project-scope plugin outputs: |-------|----------------------| | Claude Code | `.claude-plugin/marketplace.json` | | Cursor | `.cursor-plugin/marketplace.json` | -| Codex | `.agents/plugins/marketplace.json` and `.agents/plugins//.codex-plugin/plugin.json` | +| Codex | `.agents/plugins//.codex-plugin/plugin.json` | | Grok Build | `.grok/plugins//` managed copy | | OpenCode | `.opencode/plugins/.js|ts` re-export module when the plugin declares or contains one OpenCode module | diff --git a/specs/plugins.md b/specs/plugins.md index b8e3063..1ef221f 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -18,16 +18,16 @@ dotagents has one canonical plugin source of truth: The canonical catalog and plugin manifests should use a generalized Codex-compatible format. Codex compatibility is the baseline because Codex already reads `.agents/plugins/marketplace.json` for repo-scoped marketplaces, but dotagents treats the schema as portable project metadata rather than Codex-only configuration. -Every other runtime output is generated from `.agents/plugins/` when that runtime does not directly consume the canonical path or schema. Generated artifacts may include `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.codex-plugin/plugin.json`, `.grok/` plugin files, `.opencode/plugins/` modules, or runtime settings/config entries. These generated artifacts are runtime projections, not the source of truth. +Every other runtime output is generated from `.agents/plugins/` when that runtime does not directly consume the canonical path or schema. Generated artifacts may include `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/` plugin files, `.opencode/plugins/` modules, or runtime settings/config entries. These generated artifacts are runtime projections, not the source of truth. ## Input and Output Contract The canonical inputs are tightly defined but forward-compatible: 1. `agents.toml` `[[plugins]]` declarations are strict operational config. Unknown fields are rejected. -2. `.agents/plugins/marketplace.json` must be a JSON object with `name` and `plugins[]`. Each plugin entry must have `name` and `source`. `source` may be a relative path string, `{ "source": "local", "path": "" }`, or a runtime extension object with a safe relative `path`. -3. `.agents/plugins//plugin.json` must be a JSON object. Known component path fields are validated when present. Unknown fields are allowed and preserved so native runtimes and future dotagents versions can add metadata without breaking older installs. -4. All component paths in canonical manifests and marketplaces must be relative and must not contain `..`, absolute POSIX paths, absolute Windows paths, or backslash-rooted paths. +2. `.agents/plugins/marketplace.json` must be a JSON object with `name` and `plugins[]`. Each plugin entry must have `name` and `source`. dotagents resolves local plugin selectors only when `source` is a relative path string or `{ "source": "local", "path": "" }`. Runtime extension source objects may be accepted when their known path fields are safe, but dotagents must report them as unsupported instead of guessing how to resolve them. +3. `.agents/plugins//plugin.json` must be a JSON object. Known component path fields are validated as relative filesystem paths without `..` or URL/scheme prefixes when present. Unknown fields are allowed and preserved so native runtimes and future dotagents versions can add metadata without breaking older installs. +4. All component paths in canonical manifests and marketplaces must be relative filesystem paths and must not contain `..`, URL/scheme prefixes, absolute POSIX paths, absolute Windows paths, or backslash-rooted paths. 5. The portable plugin `name` in `agents.toml` is authoritative. If a discovered manifest also declares `name`, it must match the configured name. Generated outputs are deterministic: @@ -68,7 +68,7 @@ targets = ["claude", "cursor", "codex", "grok"] | Field | Required | Description | |-------|----------|-------------| -| `name` | Yes | Portable dotagents ID. Must start with lowercase `a-z` and contain only lowercase letters, numbers, hyphens, and dots. | +| `name` | Yes | Portable dotagents ID. Must start with lowercase `a-z`, end with lowercase `a-z` or `0-9`, and contain only lowercase letters, numbers, hyphens, and dots. | | `source` | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources. HTTPS well-known skill indexes are not supported for plugins. | | `ref` | No | Optional git ref override. | | `path` | No | Optional explicit plugin directory path inside the source. | @@ -241,7 +241,7 @@ Portable plugin-authored config should use `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` | Cursor | Target-specific support to verify before implementation | Target-specific support to verify before implementation | | OpenCode | Not applicable for local JS/TS modules unless the module reads environment variables set by dotagents | Not applicable | -Codex also sets Claude-compatible plugin variables for compatibility. For generated Codex output, dotagents can leave `${PLUGIN_ROOT}` intact. For generated Claude or Grok output, dotagents should rewrite known portable variables in hook, MCP, and LSP config files. +Codex also sets Claude-compatible plugin variables for compatibility. For generated Codex output, dotagents can leave `${PLUGIN_ROOT}` intact. The current implementation does not rewrite Claude or Grok hook, MCP, or LSP config files; portable variable rewriting is reserved for a later projection pass. ## Install and Sync @@ -256,7 +256,7 @@ Generated project-scope outputs should be: |-------|----------------------|-------------------|-------| | Claude Code | `.claude-plugin/marketplace.json` | Not generated yet | Generated marketplace uses deterministic relative string sources into `.agents/plugins//`. | | Cursor | `.cursor-plugin/marketplace.json` | Not generated yet | Generated marketplace uses deterministic relative string sources into `.agents/plugins//`. | -| Codex | `.agents/plugins/marketplace.json` plus generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | Codex is the baseline marketplace format; `source.path` resolves relative to marketplace root. | +| Codex | Generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | `.agents/plugins/marketplace.json` is canonical input/discovery metadata, not a generated output. | | Grok Build | `.grok/plugins/` for targeted plugins | Not generated yet | The projection is a managed copy of the canonical plugin bundle with a `.dotagents-managed` marker. | | OpenCode | `.opencode/plugins/.js|ts` re-export module for an explicit OpenCode module | Not generated yet | dotagents only exposes the module declared in `manifest.opencode.plugins` or discovered at `opencode/plugin.ts|js`; it does not synthesize OpenCode JS/TS code from other runtime hooks. | @@ -291,10 +291,10 @@ Plugins are a higher-risk dependency class than plain skills because they may bu dotagents should: 1. Apply existing trust policy before network access. -2. Surface warnings when a plugin contains executable components. +2. Reserve executable-component warnings for a later policy pass; current installs rely on source trust validation plus runtime-native trust prompts. 3. Preserve runtime-native trust flows instead of bypassing them. For example, Codex plugin hooks still require the user's runtime trust review. 4. Avoid executing plugin code during install except for normal git/source resolution. -5. Treat local `path:` plugins as allowed by source trust policy but still warn for executable components. +5. Treat local `path:` plugins as allowed by source trust policy without bypassing runtime-native trust prompts. ## Non-goals From 3c1e617e5f073870e8d73077b1326cce37246dfb Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 29 Jun 2026 18:31:05 -0700 Subject: [PATCH 06/35] fix(plugins): Generate Claude-native plugin manifests Docker QA showed Claude Code rejects a marketplace entry unless the referenced plugin has a Claude-native .claude-plugin/plugin.json manifest. Generate that managed manifest inside the canonical .agents/plugins bundle, point marketplace sources at ./.agents/plugins/, and omit fields current Claude validation rejects. Co-Authored-By: Codex --- .../src/agents/plugin-writer.test.ts | 39 +++++++-- .../dotagents/src/agents/plugin-writer.ts | 87 ++++++++++++++++++- .../src/cli/commands/install.test.ts | 10 ++- skills/dotagents-qa/SKILL.md | 5 +- skills/dotagents-qa/scripts/qa-example.mjs | 6 +- specs/SPEC.md | 4 +- specs/plugins.md | 6 +- 7 files changed, 142 insertions(+), 15 deletions(-) diff --git a/packages/dotagents/src/agents/plugin-writer.test.ts b/packages/dotagents/src/agents/plugin-writer.test.ts index 9d46ff9..4101426 100644 --- a/packages/dotagents/src/agents/plugin-writer.test.ts +++ b/packages/dotagents/src/agents/plugin-writer.test.ts @@ -55,7 +55,7 @@ describe("plugin writer", () => { ); expect(result.warnings).toEqual([]); - expect(result.written).toBe(4); + expect(result.written).toBe(6); expect(existsSync(join(root, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ "metadata": { @@ -69,13 +69,13 @@ describe("plugin writer", () => { { "description": "Tools for alpha-tools", "name": "alpha-tools", - "source": ".agents/plugins/alpha-tools", + "source": "./.agents/plugins/alpha-tools", "version": "1.0.0" }, { "description": "Tools for beta-tools", "name": "beta-tools", - "source": ".agents/plugins/beta-tools", + "source": "./.agents/plugins/beta-tools", "version": "1.0.0" } ] @@ -83,6 +83,13 @@ describe("plugin writer", () => { `); expect(await readFile(join(root, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")); + const claudeManifest = JSON.parse(await readFile(join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"), "utf-8")) as Record; + expect(claudeManifest["skills"]).toBe("./skills"); + expect(claudeManifest["commands"]).toBe("./commands"); + expect(claudeManifest["agents"]).toBeUndefined(); + expect(claudeManifest["category"]).toBeUndefined(); + expect(claudeManifest["metadata"]).toEqual({ managedBy: "dotagents" }); + const codexManifest = JSON.parse(await readFile(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"), "utf-8")) as Record; expect(codexManifest["skills"]).toBe("./skills"); expect(codexManifest["commands"]).toBe("./commands"); @@ -104,7 +111,7 @@ describe("plugin writer", () => { const result = await writePluginOutputs(["claude"], [alpha], root); - expect(result.written).toBe(0); + expect(result.written).toBe(1); expect(result.warnings).toEqual([ { agent: "claude", @@ -113,6 +120,7 @@ describe("plugin writer", () => { }, ]); expect(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"))).toBe(true); }); it("does not overwrite unmanaged Codex plugin manifests", async () => { @@ -132,6 +140,23 @@ describe("plugin writer", () => { expect(await readFile(join(alpha.pluginDir, ".codex-plugin", "plugin.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); }); + it("does not overwrite unmanaged Claude plugin manifests", async () => { + const alpha = await plugin("alpha-tools"); + await mkdir(join(alpha.pluginDir, ".claude-plugin"), { recursive: true }); + await writeFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs(["claude"], [alpha], root); + + expect(result.warnings).toEqual([ + { + agent: "claude", + name: "alpha-tools", + message: `Claude plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json")}`, + }, + ]); + expect(await readFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), "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"); @@ -218,17 +243,21 @@ describe("plugin writer", () => { }); await mkdir(join(alpha.pluginDir, "opencode"), { recursive: true }); await writeFile(join(alpha.pluginDir, "opencode", "plugin.ts"), "export default {}\n", "utf-8"); - await writePluginOutputs(["codex", "grok", "opencode"], [alpha], root); + await writePluginOutputs(["claude", "codex", "grok", "opencode"], [alpha], root); const pruned = await prunePluginOutputs([], [alpha], root); expect(pruned).toEqual([ + join(root, ".claude-plugin", "marketplace.json"), join(root, ".grok", "plugins", "alpha-tools"), join(root, ".opencode", "plugins", "alpha-tools.ts"), + join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"), join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"), ]); + expect(existsSync(join(root, ".claude-plugin", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".grok", "plugins", "alpha-tools"))).toBe(false); expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(false); + expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"))).toBe(false); expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); }); diff --git a/packages/dotagents/src/agents/plugin-writer.ts b/packages/dotagents/src/agents/plugin-writer.ts index e323116..719bb35 100644 --- a/packages/dotagents/src/agents/plugin-writer.ts +++ b/packages/dotagents/src/agents/plugin-writer.ts @@ -53,6 +53,9 @@ export async function writePluginOutputs( for (const plugin of selected) { const agents = selectedAgentIds(agentIds, plugin); + if (agents.includes("claude") && await writeClaudeManifest(plugin, warnings)) { + written++; + } if (agents.includes("codex") && await writeCodexManifest(plugin, warnings)) { written++; } @@ -93,6 +96,12 @@ export async function verifyPluginOutputs( for (const plugin of selected) { const agents = selectedAgentIds(agentIds, plugin); + if (agents.includes("claude")) { + const filePath = join(plugin.pluginDir, ".claude-plugin", "plugin.json"); + if (!existsSync(filePath)) { + issues.push({ agent: "claude", name: plugin.name, issue: `Claude plugin manifest missing: ${filePath}` }); + } + } if (agents.includes("codex")) { const filePath = join(plugin.pluginDir, ".codex-plugin", "plugin.json"); if (!existsSync(filePath)) { @@ -164,12 +173,30 @@ export async function prunePluginOutputs( } } + const canonicalPluginDir = join(projectRoot, ".agents", "plugins"); + const desiredClaude = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")) + .map((plugin) => plugin.name), + ); + if (existsSync(canonicalPluginDir)) { + const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) {continue;} + if (desiredClaude.has(entry.name)) {continue;} + const path = join(canonicalPluginDir, entry.name, ".claude-plugin", "plugin.json"); + if (!existsSync(path) || !await isManagedJsonFile(path)) {continue;} + await rm(path, { force: true }); + await rmdirIfEmpty(dirname(path)); + pruned.push(path); + } + } + const desiredCodex = new Set( plugins .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")) .map((plugin) => plugin.name), ); - const canonicalPluginDir = join(projectRoot, ".agents", "plugins"); if (existsSync(canonicalPluginDir)) { const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); for (const entry of entries) { @@ -270,7 +297,7 @@ function pathMarketplaceEntry( ): Record { const entry: Record = { name: plugin.name, - source: relativePath(projectRoot, plugin.pluginDir), + source: `./${relativePath(projectRoot, plugin.pluginDir)}`, }; const description = manifestString(plugin.manifest, "description"); if (description) {entry["description"] = description;} @@ -279,6 +306,23 @@ function pathMarketplaceEntry( return entry; } +async function writeClaudeManifest( + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const filePath = join(plugin.pluginDir, ".claude-plugin", "plugin.json"); + if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { + warnings.push({ + agent: "claude", + name: plugin.name, + message: `Claude plugin manifest exists and is not managed by dotagents: ${filePath}`, + }); + return false; + } + const manifest = claudeRuntimeManifest(plugin); + return writeJsonIfChanged(filePath, stableJson(manifest)); +} + async function writeCodexManifest( plugin: PluginDeclaration, warnings: PluginWriteWarning[], @@ -296,6 +340,39 @@ async function writeCodexManifest( return writeJsonIfChanged(filePath, stableJson(manifest)); } +/** Builds the managed Claude manifest projection using Claude-native paths. */ +function claudeRuntimeManifest(plugin: PluginDeclaration): Record { + const manifest: Record = { + name: plugin.name, + }; + copyManifestField(plugin.manifest, manifest, "version"); + copyManifestField(plugin.manifest, manifest, "description"); + copyManifestField(plugin.manifest, manifest, "author"); + copyManifestField(plugin.manifest, manifest, "homepage"); + copyManifestField(plugin.manifest, manifest, "repository"); + copyManifestField(plugin.manifest, manifest, "license"); + copyManifestField(plugin.manifest, manifest, "keywords"); + + if (existsSync(join(plugin.pluginDir, "skills"))) { + manifest["skills"] = "./skills"; + } + if (existsSync(join(plugin.pluginDir, "commands"))) { + manifest["commands"] = "./commands"; + } + if (existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { + manifest["hooks"] = "./hooks/hooks.json"; + } + if (existsSync(join(plugin.pluginDir, ".mcp.json"))) { + manifest["mcpServers"] = "./.mcp.json"; + } + const metadata = plugin.manifest["metadata"]; + manifest["metadata"] = { + ...(metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {}), + ...DOTAGENTS_METADATA, + }; + return manifest; +} + /** Mirrors a plugin bundle into Grok's plugin directory with a managed marker. */ async function writeGrokProjection( projectRoot: string, @@ -408,6 +485,12 @@ function developerName(manifest: PluginManifest): string { return "Unknown"; } +function copyManifestField(source: PluginManifest, dest: Record, key: keyof PluginManifest): void { + if (source[key] !== undefined) { + dest[key] = source[key]; + } +} + function opencodeModules( plugin: PluginDeclaration, warnings: PluginWriteWarning[] = [], diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 7e9d67c..b2647e7 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -172,7 +172,7 @@ source = "path:plugin-source/review-tools" { "description": "Review workflow helpers", "name": "review-tools", - "source": ".agents/plugins/review-tools", + "source": "./.agents/plugins/review-tools", "version": "1.0.0" } ] @@ -180,6 +180,14 @@ source = "path:plugin-source/review-tools" `); expect(await readFile(join(projectRoot, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8")); + const claudeManifest = JSON.parse(await readFile(join(projectRoot, ".agents", "plugins", "review-tools", ".claude-plugin", "plugin.json"), "utf-8")) as Record; + expect(claudeManifest["name"]).toBe("review-tools"); + expect(claudeManifest["skills"]).toBe("./skills"); + expect(claudeManifest["commands"]).toBe("./commands"); + expect(claudeManifest["agents"]).toBeUndefined(); + expect(claudeManifest["category"]).toBeUndefined(); + expect(claudeManifest["metadata"]).toEqual({ managedBy: "dotagents" }); + const codexManifest = JSON.parse(await readFile(join(projectRoot, ".agents", "plugins", "review-tools", ".codex-plugin", "plugin.json"), "utf-8")) as Record; expect(codexManifest["name"]).toBe("review-tools"); expect(codexManifest["skills"]).toBe("./skills"); diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index e26fb14..ae8efb6 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -276,6 +276,7 @@ test -f .opencode/agents/code-reviewer.md test -f .agents/plugins/qa-tools/plugin.json test -f .claude-plugin/marketplace.json test -f .cursor-plugin/marketplace.json +test -f .agents/plugins/qa-tools/.claude-plugin/plugin.json test -f .agents/plugins/qa-tools/.codex-plugin/plugin.json test -f .grok/plugins/qa-tools/.dotagents-managed test -f .opencode/plugins/qa-tools.ts @@ -290,7 +291,8 @@ diff claims to repair, then verify the repair: ```bash rm .mcp.json .claude/skills .claude/agents/code-reviewer.md .codex/agents/code-reviewer.toml -rm .claude-plugin/marketplace.json .agents/plugins/qa-tools/.codex-plugin/plugin.json +rm .claude-plugin/marketplace.json .agents/plugins/qa-tools/.claude-plugin/plugin.json +rm .agents/plugins/qa-tools/.codex-plugin/plugin.json rm -rf .grok/plugins/qa-tools rm .opencode/plugins/qa-tools.ts "${cli[@]}" sync | tee /qa-out/sync.out @@ -299,6 +301,7 @@ test -L .claude/skills test -f .claude/agents/code-reviewer.md test -f .codex/agents/code-reviewer.toml test -f .claude-plugin/marketplace.json +test -f .agents/plugins/qa-tools/.claude-plugin/plugin.json test -f .agents/plugins/qa-tools/.codex-plugin/plugin.json test -f .grok/plugins/qa-tools/.dotagents-managed test -f .opencode/plugins/qa-tools.ts diff --git a/skills/dotagents-qa/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs index 409a979..de92f4b 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -355,16 +355,20 @@ function assertPluginOutputs() { assertFile(".agents/plugins/qa-tools/commands/plugin-qa.md"); assertFile(".agents/plugins/qa-tools/agents/plugin-reviewer.md"); assertFile(".agents/plugins/qa-tools/opencode/plugin.ts"); + assertFile(".agents/plugins/qa-tools/.claude-plugin/plugin.json"); assertFileIncludes("agents.lock", "qa-tools"); assertFileDoesNotExist(".agents/plugins/marketplace.json"); assertFile(".claude-plugin/marketplace.json"); assertFileIncludes(".claude-plugin/marketplace.json", '"managedBy": "dotagents"'); assertFileIncludes(".claude-plugin/marketplace.json", '"name": "qa-tools"'); - assertFileIncludes(".claude-plugin/marketplace.json", '"source": ".agents/plugins/qa-tools"'); + assertFileIncludes(".claude-plugin/marketplace.json", '"source": "./.agents/plugins/qa-tools"'); assertFile(".cursor-plugin/marketplace.json"); assertSameFile(".cursor-plugin/marketplace.json", ".claude-plugin/marketplace.json"); + assertFileIncludes(".agents/plugins/qa-tools/.claude-plugin/plugin.json", '"managedBy": "dotagents"'); + assertFileIncludes(".agents/plugins/qa-tools/.claude-plugin/plugin.json", '"skills": "./skills"'); + assertFileIncludes(".agents/plugins/qa-tools/.claude-plugin/plugin.json", '"commands": "./commands"'); assertFile(".agents/plugins/qa-tools/.codex-plugin/plugin.json"); assertFileIncludes(".agents/plugins/qa-tools/.codex-plugin/plugin.json", '"managedBy": "dotagents"'); assertFileIncludes(".agents/plugins/qa-tools/.codex-plugin/plugin.json", '"skills": "./skills"'); diff --git a/specs/SPEC.md b/specs/SPEC.md index 0c48745..b96bc58 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -259,13 +259,13 @@ Generated project-scope plugin outputs: | Agent | Project Scope Output | |-------|----------------------| -| Claude Code | `.claude-plugin/marketplace.json` | +| Claude Code | `.claude-plugin/marketplace.json`; `.agents/plugins//.claude-plugin/plugin.json` | | Cursor | `.cursor-plugin/marketplace.json` | | Codex | `.agents/plugins//.codex-plugin/plugin.json` | | Grok Build | `.grok/plugins//` managed copy | | OpenCode | `.opencode/plugins/.js|ts` re-export module when the plugin declares or contains one OpenCode module | -Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. +Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Plugins are currently project-scope only. `install --user` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. diff --git a/specs/plugins.md b/specs/plugins.md index 1ef221f..e9432fa 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -254,13 +254,13 @@ Generated project-scope outputs should be: | Agent | Project Scope Output | User Scope Output | Notes | |-------|----------------------|-------------------|-------| -| Claude Code | `.claude-plugin/marketplace.json` | Not generated yet | Generated marketplace uses deterministic relative string sources into `.agents/plugins//`. | -| Cursor | `.cursor-plugin/marketplace.json` | Not generated yet | Generated marketplace uses deterministic relative string sources into `.agents/plugins//`. | +| Claude Code | `.claude-plugin/marketplace.json` and `.agents/plugins//.claude-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources and each targeted plugin gets a Claude-native manifest. | +| Cursor | `.cursor-plugin/marketplace.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources. | | Codex | Generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | `.agents/plugins/marketplace.json` is canonical input/discovery metadata, not a generated output. | | Grok Build | `.grok/plugins/` for targeted plugins | Not generated yet | The projection is a managed copy of the canonical plugin bundle with a `.dotagents-managed` marker. | | OpenCode | `.opencode/plugins/.js|ts` re-export module for an explicit OpenCode module | Not generated yet | dotagents only exposes the module declared in `manifest.opencode.plugins` or discovered at `opencode/plugin.ts|js`; it does not synthesize OpenCode JS/TS code from other runtime hooks. | -Installed and generated files are dotagents-managed. `install` and `sync` may overwrite stale managed files and prune removed managed files, but they must not overwrite hand-written plugin files without a generated marker or a canonical installed bundle path owned by dotagents. Generated Codex manifests carry `metadata.managedBy = "dotagents"` so target removal can prune them without deleting user-authored native Codex plugin manifests. +Installed and generated files are dotagents-managed. `install` and `sync` may overwrite stale managed files and prune removed managed files, but they must not overwrite hand-written plugin files without a generated marker or a canonical installed bundle path owned by dotagents. Generated Claude and Codex manifests carry `metadata.managedBy = "dotagents"` so target removal can prune them without deleting user-authored native plugin manifests. User-scope plugin declarations are not supported yet. `install --user` rejects `[[plugins]]` entries, and `sync --user` reports them as unsupported, because the current runtime projections are defined only for project scope. From d5873a80a015ed61c83e4aa9465fd90cab07d8c4 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 29 Jun 2026 18:32:43 -0700 Subject: [PATCH 07/35] fix(plugins): Generate Codex marketplace catalogs Codex requires a repo-scoped .agents/plugins/marketplace.json before plugin add/list can discover a local plugin. Generate that managed catalog from installed plugin declarations and keep the per-plugin .codex-plugin manifest as the install target. Co-Authored-By: Codex --- .../src/agents/plugin-writer.test.ts | 57 ++++++++++++++++++- .../dotagents/src/agents/plugin-writer.ts | 52 +++++++++++++++++ .../src/cli/commands/install.test.ts | 28 ++++++++- .../dotagents/src/cli/commands/sync.test.ts | 2 +- skills/dotagents-qa/SKILL.md | 5 +- skills/dotagents-qa/scripts/qa-example.mjs | 7 ++- specs/SPEC.md | 2 +- specs/plugins.md | 4 +- 8 files changed, 147 insertions(+), 10 deletions(-) diff --git a/packages/dotagents/src/agents/plugin-writer.test.ts b/packages/dotagents/src/agents/plugin-writer.test.ts index 4101426..06f18e0 100644 --- a/packages/dotagents/src/agents/plugin-writer.test.ts +++ b/packages/dotagents/src/agents/plugin-writer.test.ts @@ -55,8 +55,42 @@ describe("plugin writer", () => { ); expect(result.warnings).toEqual([]); - expect(result.written).toBe(6); - expect(existsSync(join(root, ".agents", "plugins", "marketplace.json"))).toBe(false); + expect(result.written).toBe(7); + const codexMarketplace = JSON.parse(await readFile(join(root, ".agents", "plugins", "marketplace.json"), "utf-8")) as Record; + expect(codexMarketplace).toEqual({ + interface: { + displayName: "Dotagents Plugins", + }, + metadata: { + managedBy: "dotagents", + }, + name: "dotagents-local", + owner: { + name: "dotagents", + }, + plugins: [ + { + category: "Coding", + description: "Tools for alpha-tools", + name: "alpha-tools", + source: { + path: "./.agents/plugins/alpha-tools", + source: "local", + }, + version: "1.0.0", + }, + { + category: "Coding", + description: "Tools for beta-tools", + name: "beta-tools", + source: { + path: "./.agents/plugins/beta-tools", + source: "local", + }, + version: "1.0.0", + }, + ], + }); expect(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ "metadata": { "managedBy": "dotagents" @@ -123,6 +157,23 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"))).toBe(true); }); + it("does not overwrite unmanaged Codex marketplace files", async () => { + const alpha = await plugin("alpha-tools"); + await writeFile(join(root, ".agents", "plugins", "marketplace.json"), "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs(["codex"], [alpha], root); + + expect(result.warnings).toEqual([ + { + agent: "codex", + name: "marketplace", + message: `Plugin marketplace exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "marketplace.json")}`, + }, + ]); + expect(await readFile(join(root, ".agents", "plugins", "marketplace.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(true); + }); + it("does not overwrite unmanaged Codex plugin manifests", async () => { const alpha = await plugin("alpha-tools"); await mkdir(join(alpha.pluginDir, ".codex-plugin"), { recursive: true }); @@ -248,12 +299,14 @@ describe("plugin writer", () => { const pruned = await prunePluginOutputs([], [alpha], root); expect(pruned).toEqual([ + join(root, ".agents", "plugins", "marketplace.json"), join(root, ".claude-plugin", "marketplace.json"), join(root, ".grok", "plugins", "alpha-tools"), join(root, ".opencode", "plugins", "alpha-tools.ts"), join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"), join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"), ]); + expect(existsSync(join(root, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".claude-plugin", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".grok", "plugins", "alpha-tools"))).toBe(false); expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(false); diff --git a/packages/dotagents/src/agents/plugin-writer.ts b/packages/dotagents/src/agents/plugin-writer.ts index 719bb35..53c1f6f 100644 --- a/packages/dotagents/src/agents/plugin-writer.ts +++ b/packages/dotagents/src/agents/plugin-writer.ts @@ -215,6 +215,7 @@ export async function prunePluginOutputs( function marketplaceOutputPaths(projectRoot: string): string[] { return [ + join(projectRoot, ".agents", "plugins", "marketplace.json"), join(projectRoot, ".claude-plugin", "marketplace.json"), join(projectRoot, ".cursor-plugin", "marketplace.json"), ]; @@ -230,6 +231,7 @@ function marketplaceOutputs( const outputs: RuntimeOutput[] = []; const claudePlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); const cursorPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); + const codexPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); if (claudePlugins.length > 0) { outputs.push({ @@ -245,6 +247,13 @@ function marketplaceOutputs( content: stableJson(pathMarketplace(projectRoot, "dotagents", cursorPlugins)), }); } + if (codexPlugins.length > 0) { + outputs.push({ + agent: "codex", + filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), + content: stableJson(codexMarketplace(projectRoot, "dotagents-local", codexPlugins)), + }); + } return outputs; } @@ -259,6 +268,7 @@ function marketplaceOutputsForTargets( const outputs: RuntimeOutput[] = []; const hasClaude = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); const hasCursor = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); + const hasCodex = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); if (hasClaude) { outputs.push({ agent: "claude", filePath: join(projectRoot, ".claude-plugin", "marketplace.json"), content: "" }); @@ -266,6 +276,9 @@ function marketplaceOutputsForTargets( if (hasCursor) { outputs.push({ agent: "cursor", filePath: join(projectRoot, ".cursor-plugin", "marketplace.json"), content: "" }); } + if (hasCodex) { + outputs.push({ agent: "codex", filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), content: "" }); + } return outputs; } @@ -306,6 +319,45 @@ function pathMarketplaceEntry( return entry; } +function codexMarketplace( + projectRoot: string, + name: string, + plugins: PluginDeclaration[], +): Record { + return { + interface: { + displayName: "Dotagents Plugins", + }, + metadata: DOTAGENTS_METADATA, + name, + owner: { + name: "dotagents", + }, + plugins: plugins + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((plugin) => codexMarketplaceEntry(projectRoot, plugin)), + }; +} + +function codexMarketplaceEntry( + projectRoot: string, + plugin: PluginDeclaration, +): Record { + const entry: Record = { + category: manifestString(plugin.manifest, "category") ?? "Productivity", + name: plugin.name, + source: { + path: `./${relativePath(projectRoot, plugin.pluginDir)}`, + source: "local", + }, + }; + const description = manifestString(plugin.manifest, "description"); + if (description) {entry["description"] = description;} + const version = manifestString(plugin.manifest, "version"); + if (version) {entry["version"] = version;} + return entry; +} + async function writeClaudeManifest( plugin: PluginDeclaration, warnings: PluginWriteWarning[], diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index b2647e7..d5ef435 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -159,7 +159,31 @@ source = "path:plugin-source/review-tools" source: "path:plugin-source/review-tools", }); - expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); + const codexMarketplace = JSON.parse(await readFile(join(projectRoot, ".agents", "plugins", "marketplace.json"), "utf-8")) as Record; + expect(codexMarketplace).toEqual({ + interface: { + displayName: "Dotagents Plugins", + }, + metadata: { + managedBy: "dotagents", + }, + name: "dotagents-local", + owner: { + name: "dotagents", + }, + plugins: [ + { + category: "Coding", + description: "Review workflow helpers", + name: "review-tools", + source: { + path: "./.agents/plugins/review-tools", + source: "local", + }, + version: "1.0.0", + }, + ], + }); expect(await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ "metadata": { "managedBy": "dotagents" @@ -709,7 +733,7 @@ source = "path:external-source" const scope = resolveScope("project", projectRoot); await runInstall({ scope, frozen: true }); - expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(true); expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", ".codex-plugin", "plugin.json"))).toBe(true); }); diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 6dd812e..51c0f44 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -669,7 +669,7 @@ source = "path:plugin-source/review-tools" expect(result.pluginsRepaired).toBeGreaterThan(0); expect(result.issues).toEqual([]); - expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(true); expect(existsSync(join(projectRoot, ".claude-plugin", "marketplace.json"))).toBe(true); expect(existsSync(join(projectRoot, ".cursor-plugin", "marketplace.json"))).toBe(true); }); diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index ae8efb6..ee1946e 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -146,6 +146,7 @@ asserts: - canonical installed subagent under `.agents/agents/` - generated subagent runtime files for Claude, Cursor, Codex, and OpenCode - canonical installed plugin bundle under `.agents/plugins/` +- Codex repo-scoped plugin marketplace under `.agents/plugins/marketplace.json` - generated plugin runtime files for Claude, Cursor, Codex, Grok, and OpenCode - `sync` repair after deleting representative generated files @@ -274,6 +275,7 @@ test -f .cursor/agents/code-reviewer.md test -f .codex/agents/code-reviewer.toml test -f .opencode/agents/code-reviewer.md test -f .agents/plugins/qa-tools/plugin.json +test -f .agents/plugins/marketplace.json test -f .claude-plugin/marketplace.json test -f .cursor-plugin/marketplace.json test -f .agents/plugins/qa-tools/.claude-plugin/plugin.json @@ -291,7 +293,7 @@ diff claims to repair, then verify the repair: ```bash rm .mcp.json .claude/skills .claude/agents/code-reviewer.md .codex/agents/code-reviewer.toml -rm .claude-plugin/marketplace.json .agents/plugins/qa-tools/.claude-plugin/plugin.json +rm .agents/plugins/marketplace.json .claude-plugin/marketplace.json .agents/plugins/qa-tools/.claude-plugin/plugin.json rm .agents/plugins/qa-tools/.codex-plugin/plugin.json rm -rf .grok/plugins/qa-tools rm .opencode/plugins/qa-tools.ts @@ -300,6 +302,7 @@ test -f .mcp.json test -L .claude/skills test -f .claude/agents/code-reviewer.md test -f .codex/agents/code-reviewer.toml +test -f .agents/plugins/marketplace.json test -f .claude-plugin/marketplace.json test -f .agents/plugins/qa-tools/.claude-plugin/plugin.json test -f .agents/plugins/qa-tools/.codex-plugin/plugin.json diff --git a/skills/dotagents-qa/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs index de92f4b..a7d22e9 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -133,6 +133,7 @@ async function runSyncRepair() { rmSync(join(projectDir, ".claude", "skills"), { force: true, recursive: true }); rmSync(join(projectDir, ".claude", "agents", "code-reviewer.md"), { force: true }); rmSync(join(projectDir, ".codex", "agents", "code-reviewer.toml"), { force: true }); + rmSync(join(projectDir, ".agents", "plugins", "marketplace.json"), { force: true }); rmSync(join(projectDir, ".claude-plugin", "marketplace.json"), { force: true }); rmSync(join(projectDir, ".agents", "plugins", "qa-tools", ".codex-plugin", "plugin.json"), { force: true }); rmSync(join(projectDir, ".grok", "plugins", "qa-tools"), { force: true, recursive: true }); @@ -357,7 +358,11 @@ function assertPluginOutputs() { assertFile(".agents/plugins/qa-tools/opencode/plugin.ts"); assertFile(".agents/plugins/qa-tools/.claude-plugin/plugin.json"); assertFileIncludes("agents.lock", "qa-tools"); - assertFileDoesNotExist(".agents/plugins/marketplace.json"); + assertFile(".agents/plugins/marketplace.json"); + assertFileIncludes(".agents/plugins/marketplace.json", '"name": "dotagents-local"'); + assertFileIncludes(".agents/plugins/marketplace.json", '"managedBy": "dotagents"'); + assertFileIncludes(".agents/plugins/marketplace.json", '"path": "./.agents/plugins/qa-tools"'); + assertFileIncludes(".agents/plugins/marketplace.json", '"source": "local"'); assertFile(".claude-plugin/marketplace.json"); assertFileIncludes(".claude-plugin/marketplace.json", '"managedBy": "dotagents"'); diff --git a/specs/SPEC.md b/specs/SPEC.md index b96bc58..f342c90 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -261,7 +261,7 @@ Generated project-scope plugin outputs: |-------|----------------------| | Claude Code | `.claude-plugin/marketplace.json`; `.agents/plugins//.claude-plugin/plugin.json` | | Cursor | `.cursor-plugin/marketplace.json` | -| Codex | `.agents/plugins//.codex-plugin/plugin.json` | +| Codex | `.agents/plugins/marketplace.json`; `.agents/plugins//.codex-plugin/plugin.json` | | Grok Build | `.grok/plugins//` managed copy | | OpenCode | `.opencode/plugins/.js|ts` re-export module when the plugin declares or contains one OpenCode module | diff --git a/specs/plugins.md b/specs/plugins.md index e9432fa..334bf1f 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -18,7 +18,7 @@ dotagents has one canonical plugin source of truth: The canonical catalog and plugin manifests should use a generalized Codex-compatible format. Codex compatibility is the baseline because Codex already reads `.agents/plugins/marketplace.json` for repo-scoped marketplaces, but dotagents treats the schema as portable project metadata rather than Codex-only configuration. -Every other runtime output is generated from `.agents/plugins/` when that runtime does not directly consume the canonical path or schema. Generated artifacts may include `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/` plugin files, `.opencode/plugins/` modules, or runtime settings/config entries. These generated artifacts are runtime projections, not the source of truth. +Every other runtime output is generated from `.agents/plugins/` when that runtime does not directly consume the canonical path or schema. Generated artifacts may include `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/` plugin files, `.opencode/plugins/` modules, or runtime settings/config entries. These generated artifacts are runtime projections, not the source of truth, except that `.agents/plugins/marketplace.json` is also Codex's documented repo-scoped marketplace location. ## Input and Output Contract @@ -256,7 +256,7 @@ Generated project-scope outputs should be: |-------|----------------------|-------------------|-------| | Claude Code | `.claude-plugin/marketplace.json` and `.agents/plugins//.claude-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources and each targeted plugin gets a Claude-native manifest. | | Cursor | `.cursor-plugin/marketplace.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources. | -| Codex | Generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | `.agents/plugins/marketplace.json` is canonical input/discovery metadata, not a generated output. | +| Codex | `.agents/plugins/marketplace.json` and generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | Generated marketplace uses deterministic `{ "source": "local", "path": "./.agents/plugins/" }` entries relative to the project root. | | Grok Build | `.grok/plugins/` for targeted plugins | Not generated yet | The projection is a managed copy of the canonical plugin bundle with a `.dotagents-managed` marker. | | OpenCode | `.opencode/plugins/.js|ts` re-export module for an explicit OpenCode module | Not generated yet | dotagents only exposes the module declared in `manifest.opencode.plugins` or discovered at `opencode/plugin.ts|js`; it does not synthesize OpenCode JS/TS code from other runtime hooks. | From 4b1420a0d2b05d70b7208d51009b2bf9a89f0db6 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 29 Jun 2026 18:34:50 -0700 Subject: [PATCH 08/35] docs(qa): Document runtime gateway QA Document how to forward local QA credentials into Docker without copying secrets into fixtures, and spell out runtime-specific OpenRouter or custom-provider paths for Claude Code, Codex, Grok Build, OpenCode, and Cursor. Co-Authored-By: Codex --- .gitignore | 3 + skills/dotagents-qa/SKILL.md | 24 ++- skills/dotagents-qa/SOURCES.md | 9 + skills/dotagents-qa/SPEC.md | 4 + skills/dotagents-qa/references/claude.md | 5 + skills/dotagents-qa/references/codex.md | 6 + skills/dotagents-qa/references/cursor.md | 6 + skills/dotagents-qa/references/grok.md | 38 ++++ skills/dotagents-qa/references/opencode.md | 6 + .../dotagents-qa/references/runtime-auth.md | 199 ++++++++++++++++++ 10 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 skills/dotagents-qa/references/grok.md create mode 100644 skills/dotagents-qa/references/runtime-auth.md diff --git a/.gitignore b/.gitignore index 845dc88..11318ec 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ docs/.astro/ agents.lock .agents/.gitignore +# Local QA credentials +.env.qa.local + # openspec local install artifacts (per-machine, not project source) openspec/ .agents/skills/openspec-*/ diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index ee1946e..cc7a37e 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -35,9 +35,11 @@ Write down the QA target before running commands: Read the targeted reference before running runtime-specific QA: - Core install/sync example: [references/core-agentic-qa.md](references/core-agentic-qa.md) -- Codex custom agents and runtime caveats: [references/codex.md](references/codex.md) -- Claude Code files and runtime caveats: [references/claude.md](references/claude.md) +- Runtime auth, gateway, and `.env.qa.local` handling: [references/runtime-auth.md](references/runtime-auth.md) +- Codex custom agents and plugin marketplace/install proof: [references/codex.md](references/codex.md) +- Claude Code files, plugin validation, and runtime caveats: [references/claude.md](references/claude.md) - Cursor files/runtime caveats: [references/cursor.md](references/cursor.md) +- Grok Build files/runtime caveats: [references/grok.md](references/grok.md) - OpenCode files/runtime caveats: [references/opencode.md](references/opencode.md) Run focused Vitest coverage for logic bugs. Use this skill for end-to-end QA @@ -58,8 +60,15 @@ docker build \ The image installs the latest npm-published Codex, Claude Code, and OpenCode CLIs (`codex`, `claude`, `opencode`). Use them for version checks, help-output checks, and optional isolated runtime probes. Their presence does not prove -runtime discovery by itself; authenticated model-backed checks are still -explicit opt-ins. +runtime discovery by itself; for plugins, prefer native dry-run/management +commands where available: `claude plugin validate` for Claude plugin and +marketplace shape, and `codex plugin marketplace add/list` plus +`codex plugin add/list` for Codex marketplace installation. Authenticated +model-backed checks are still explicit opt-ins. When a model-backed check needs +secrets, put them in host `.env.qa.local`, keep that file out of git, and pass +only the specific variables required for the check into Docker; do not copy the +env file into the fixture or retained `/qa-out` artifacts. See +[references/runtime-auth.md](references/runtime-auth.md). Use an interactive container so the QA steps stay change-specific: @@ -354,6 +363,13 @@ Claude has no cheap dry-run skill list. If auth/network/model cost is acceptable, run a minimal non-interactive prompt from the temp project; otherwise report it as skipped. +Provider and gateway checks are runtime-specific. OpenRouter can be used where +the runtime supports an Anthropic-compatible or OpenAI-compatible custom +provider, but Cursor's documented runtime auth is Cursor login/API-key based. +Before claiming runtime proof through a gateway, read +[references/runtime-auth.md](references/runtime-auth.md), run the check inside +Docker, and report the exact provider config used with secret values redacted. + ## 7. Report Evidence Report: diff --git a/skills/dotagents-qa/SOURCES.md b/skills/dotagents-qa/SOURCES.md index d862603..a23a422 100644 --- a/skills/dotagents-qa/SOURCES.md +++ b/skills/dotagents-qa/SOURCES.md @@ -17,6 +17,12 @@ | `skills/dotagents/SKILL.md` | sibling skill layout | | `/Users/dcramer/src/sentry-mcp/.agents/skills/mcp-qa/SKILL.md` | Numbered QA flow, primary path guidance, optional client checks, and pass criteria structure | | local `codex debug prompt-input` probe | verifies Codex can expose `.agents/skills` metadata without a model call | +| Claude Code LLM gateway docs | Anthropic-compatible gateway env vars for authenticated runtime QA | +| Codex manual | Custom model provider and `CODEX_HOME` auth/config isolation | +| Cursor CLI auth and BYOK docs | Cursor-specific auth limits and OpenRouter caveat | +| Grok Build docs | Custom model provider config shape | +| OpenCode provider docs | OpenAI-compatible provider config shape | +| OpenRouter API docs | OpenAI-compatible and Anthropic Messages endpoint shapes | ## Decisions @@ -31,6 +37,9 @@ - Require inspection of generated files and command output that demonstrate the changed behavior, not only exit codes. - Expose the skill through `.agents/skills/dotagents-qa` so existing Claude/Cursor symlinks discover it. - Keep agent CLI registration and remote-source checks optional because they add auth, network, or tool-version variance. +- Keep authenticated runtime QA opt-in, pass only explicit env vars into Docker, + and document runtime-specific gateway support instead of assuming one API key + works across every client. ## Trigger Notes diff --git a/skills/dotagents-qa/SPEC.md b/skills/dotagents-qa/SPEC.md index de18814..41756ca 100644 --- a/skills/dotagents-qa/SPEC.md +++ b/skills/dotagents-qa/SPEC.md @@ -14,6 +14,8 @@ In scope: - optionally checking agent CLI registration when discovery paths changed - optionally using `getsentry/skills` for remote source behavior - isolating home and cache state from the host +- optionally forwarding specific runtime API keys into Docker for model-backed + proof, with secrets kept out of fixtures and retained artifacts Out of scope: - release publishing @@ -31,6 +33,8 @@ Out of scope: - Run the built CLI from inside the container fixture project so project scope resolves correctly. - Inspect generated files and command output that demonstrate the changed behavior, not just exit codes. - Keep `HOME`, `DOTAGENTS_STATE_DIR`, and `DOTAGENTS_HOME` inside Docker for user-scope checks. +- Keep runtime credentials in host `.env.qa.local`, pass only explicit variables + into Docker, and isolate runtime config such as `CODEX_HOME`. - Report fixture shape, commands, assertions, skipped checks, and residual risk. ## Maintenance diff --git a/skills/dotagents-qa/references/claude.md b/skills/dotagents-qa/references/claude.md index 37f9796..b6283fd 100644 --- a/skills/dotagents-qa/references/claude.md +++ b/skills/dotagents-qa/references/claude.md @@ -26,6 +26,11 @@ Then assert Claude runtime files under `$HOME/.claude/agents/` and user skills u There is no cheap dry-run in this QA skill that proves Claude Code loads custom agents. Do not claim Claude runtime discovery from file-level checks alone. +For authenticated or OpenRouter-backed runtime proof, read +[runtime-auth.md](runtime-auth.md) first. Keep credentials in `.env.qa.local`, +pass only `OPENROUTER_API_KEY` or `ANTHROPIC_*` variables into Docker, and keep +`HOME` isolated to the temp container home. + If a branch specifically requires Claude runtime proof, run an explicit Claude Code invocation only when auth/model cost is acceptable, keep it isolated to a temp project, and report: - exact command diff --git a/skills/dotagents-qa/references/codex.md b/skills/dotagents-qa/references/codex.md index 311f069..8964e28 100644 --- a/skills/dotagents-qa/references/codex.md +++ b/skills/dotagents-qa/references/codex.md @@ -40,6 +40,12 @@ With `--keep`, inspect: - `codex-runtime.jsonl` for `spawn_agent`, `wait`, and child-agent response events - `project/.codex/agents/code-reviewer.toml` for the generated file Codex loaded +For OpenRouter or other gateway-backed Codex proof, read +[runtime-auth.md](runtime-auth.md) first. Put provider config in the isolated +`CODEX_HOME/config.toml`; Codex ignores provider redirects in project +`.codex/config.toml`. Pass `OPENROUTER_API_KEY` into Docker only for the +runtime invocation. + ## Important Caveats Project-scoped `.codex/` layers load only when Codex trusts the project. A one-off `-c projects."".trust_level="trusted"` override did not prove sufficient in local testing; the reliable path is a temp `CODEX_HOME/config.toml` with the canonical real project path marked trusted. diff --git a/skills/dotagents-qa/references/cursor.md b/skills/dotagents-qa/references/cursor.md index 11ad2d5..c45d27c 100644 --- a/skills/dotagents-qa/references/cursor.md +++ b/skills/dotagents-qa/references/cursor.md @@ -20,4 +20,10 @@ For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated Curso This QA skill does not currently include an automated Cursor desktop/runtime proof. Do not claim Cursor runtime discovery from file-level checks alone. +Cursor runtime proof uses Cursor auth, not the generic OpenRouter path. Read +[runtime-auth.md](runtime-auth.md) before using secrets. Use `CURSOR_API_KEY` +or browser login for Cursor CLI/headless checks. Do not claim OpenRouter proof +for Cursor unless current Cursor docs or observed local behavior proves an +OpenRouter-compatible endpoint. + If a branch specifically requires Cursor runtime proof, use a real Cursor session or a documented headless path if one exists in the local environment. Report the exact interaction and evidence. Otherwise report file-level Cursor wiring only. diff --git a/skills/dotagents-qa/references/grok.md b/skills/dotagents-qa/references/grok.md new file mode 100644 index 0000000..266c802 --- /dev/null +++ b/skills/dotagents-qa/references/grok.md @@ -0,0 +1,38 @@ +# Grok Build QA + +Use this reference when changes affect Grok plugin projections, `.grok/` +runtime files, or claims that generated dotagents plugins load in Grok Build. + +## File-Level Checks + +The core agentic QA asserts: + +- `.grok/plugins//.dotagents-managed` exists for each targeted plugin +- the projected plugin bundle contains the canonical plugin files +- `sync` repairs deleted `.grok/plugins/` projections + +These checks prove dotagents wrote the expected Grok plugin files. They do not +prove Grok loaded the plugin. + +## Runtime Proof + +This QA skill does not currently include an automated Grok runtime proof, and +the QA Docker image does not install `grok` yet. Do not claim Grok runtime +discovery from file-level checks alone. + +For authenticated or OpenRouter-backed runtime proof, read +[runtime-auth.md](runtime-auth.md) first. Use a temp Grok config with a custom +model provider and `env_key = "OPENROUTER_API_KEY"` or `env_key = +"XAI_API_KEY"`, then run `grok inspect` before any paid prompt to prove Grok +discovered the temp project config, skills, plugins, hooks, and MCP servers. + +If a branch specifically requires Grok runtime proof, install or make `grok` +available inside the Docker container and report: + +- exact command or interaction +- temp project path +- generated `.grok/plugins/` path +- `grok inspect` evidence for discovered plugin paths +- evidence that Grok loaded or invoked the expected plugin/skill + +Otherwise report file-level Grok wiring only. diff --git a/skills/dotagents-qa/references/opencode.md b/skills/dotagents-qa/references/opencode.md index d061308..67e850d 100644 --- a/skills/dotagents-qa/references/opencode.md +++ b/skills/dotagents-qa/references/opencode.md @@ -18,6 +18,12 @@ For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated OpenC This QA skill does not currently include an automated OpenCode runtime proof. Do not claim OpenCode runtime discovery from file-level checks alone. +For authenticated or OpenRouter-backed runtime proof, read +[runtime-auth.md](runtime-auth.md) first. Use a temp `opencode.json` provider +configuration with `@ai-sdk/openai-compatible`, `options.baseURL`, and +`options.apiKey` referencing `{env:OPENROUTER_API_KEY}`. Keep credentials in +the environment, not in retained fixture files. + If a branch specifically requires OpenCode runtime proof, use the installed OpenCode CLI/app and report: - exact command or interaction diff --git a/skills/dotagents-qa/references/runtime-auth.md b/skills/dotagents-qa/references/runtime-auth.md new file mode 100644 index 0000000..31fd81a --- /dev/null +++ b/skills/dotagents-qa/references/runtime-auth.md @@ -0,0 +1,199 @@ +# Runtime Auth And Gateway QA + +Use this reference when a dotagents change needs model-backed proof from a real +agent client, or when forwarding API keys into the Docker QA sandbox. + +## Secret Handling + +Keep runtime credentials in host `.env.qa.local`. The file is intentionally +gitignored and must stay out of copied fixtures, retained temp projects, and +`/qa-out` evidence. + +Load secrets on the host, then pass only the specific variables needed for the +check into Docker: + +```bash +set -a +source .env.qa.local +set +a + +docker run --rm -i \ + -e OPENROUTER_API_KEY \ + -e ANTHROPIC_API_KEY \ + -e XAI_API_KEY \ + -e CURSOR_API_KEY \ + -v "$REPO:/host-repo:ro" \ + -v "$OUT:/qa-out" \ + dotagents-qa:local +``` + +Inside Docker, keep client state isolated with temp homes such as `HOME`, +`CODEX_HOME`, `DOTAGENTS_HOME`, and any runtime-specific config directory the +client supports. Do not use or mount host runtime homes for ordinary QA. + +Report the provider config shape and model IDs, but redact tokens. If a check +requires network or paid model calls and is skipped, say exactly which runtime +was skipped and what file-level or dry-run proof was completed instead. + +## Runtime Matrix + +| Runtime | OpenRouter or custom gateway path | QA status | +| --- | --- | --- | +| Claude Code | Supported through Anthropic Messages gateways. Use `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, and an explicit model override. | Good candidate for model-backed plugin/skill discovery proof. | +| Codex | Supported through user-level custom model providers in `CODEX_HOME/config.toml`. Use `base_url`, `env_key`, and a model that the gateway supports. | Good candidate, but provider config must live in isolated `CODEX_HOME`, not project `.codex/config.toml`. | +| Grok Build | Supported through custom model config with `model`, `base_url`, `name`, and `env_key`. | Candidate once the Grok CLI is available in the QA image or installed in the container. | +| OpenCode | Supported through custom providers using `@ai-sdk/openai-compatible`, `options.baseURL`, and `options.apiKey`. | Candidate once provider config is written to isolated `opencode.json`. | +| Cursor | Cursor CLI documents browser login, `CURSOR_API_KEY`, and `--endpoint`. Cursor BYOK docs cover named providers such as OpenAI, Anthropic, Google, Azure, and Bedrock, not arbitrary OpenRouter-compatible provider config. | Do not claim OpenRouter proof for Cursor unless a documented endpoint or local runtime behavior is verified. Use Cursor API-key/login proof separately. | + +## OpenRouter Baseline + +OpenRouter exposes an OpenAI-compatible chat endpoint at: + +```text +https://openrouter.ai/api/v1/chat/completions +``` + +It also exposes an Anthropic Messages endpoint at: + +```text +https://openrouter.ai/api/v1/messages +``` + +Use `OPENROUTER_API_KEY` as a bearer token. Model IDs include provider prefixes, +for example `anthropic/claude-sonnet-4` or `openai/gpt-5.2`. Pick a cheap, +tool-capable model for QA unless the runtime requires a particular family. + +## Claude Code With OpenRouter + +Claude Code supports LLM gateways that expose Anthropic Messages APIs and sends +`ANTHROPIC_AUTH_TOKEN` as a bearer token. A minimal OpenRouter probe should set: + +```bash +export ANTHROPIC_BASE_URL="https://openrouter.ai/api" +export ANTHROPIC_AUTH_TOKEN="$OPENROUTER_API_KEY" +export ANTHROPIC_MODEL="anthropic/claude-sonnet-4" +``` + +If the model is not available in the picker or aliases resolve incorrectly, add: + +```bash +export ANTHROPIC_CUSTOM_MODEL_OPTION="anthropic/claude-sonnet-4" +export ANTHROPIC_DEFAULT_SONNET_MODEL="anthropic/claude-sonnet-4" +``` + +Keep `HOME` pointed at the container temp home. For plugin QA, install the +example, validate generated plugin files first, then run the smallest prompt +that can prove the desired skill/plugin behavior. + +## Codex With OpenRouter + +Codex custom provider settings must be in user-level `CODEX_HOME/config.toml`. +Project `.codex/config.toml` cannot set provider redirects such as +`model_provider`, `model_providers`, or `openai_base_url`. + +For OpenRouter, use an isolated `CODEX_HOME` and write a provider config like: + +```toml +model = "openai/gpt-5.2" +model_provider = "openrouter" + +[model_providers.openrouter] +name = "OpenRouter" +base_url = "https://openrouter.ai/api/v1" +env_key = "OPENROUTER_API_KEY" +``` + +If the selected model requires the Responses API and the gateway supports it, +add: + +```toml +wire_api = "responses" +``` + +Then run `codex exec` inside Docker with `CODEX_HOME` and +`OPENROUTER_API_KEY` set. Keep the existing `codex-runtime` task as the +reference pattern for trusting only the temp project and removing copied auth. + +## Grok Build With OpenRouter + +Grok Build documents custom models with `model`, `base_url`, `name`, and +`env_key`. An OpenRouter candidate config is: + +```toml +[model.openrouter-claude] +model = "anthropic/claude-sonnet-4" +base_url = "https://openrouter.ai/api/v1" +name = "Claude via OpenRouter" +env_key = "OPENROUTER_API_KEY" + +[models] +default = "openrouter-claude" +``` + +Run `grok inspect` before any paid prompt to confirm the config, skills, +plugins, hooks, and MCP servers discovered in the temp project. Use +`grok -p "..." -m openrouter-claude` for a model-backed proof. If `grok` is not +installed in the QA image, report Grok file-level plugin output plus the +missing CLI runtime proof. + +## OpenCode With OpenRouter + +OpenCode supports custom OpenAI-compatible providers through +`@ai-sdk/openai-compatible`, `options.baseURL`, model definitions, and env +interpolation in `options.apiKey`. + +Use a temp project `opencode.json` or isolated config that includes: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openrouter": { + "npm": "@ai-sdk/openai-compatible", + "name": "OpenRouter", + "options": { + "baseURL": "https://openrouter.ai/api/v1", + "apiKey": "{env:OPENROUTER_API_KEY}" + }, + "models": { + "anthropic/claude-sonnet-4": { + "name": "Claude Sonnet via OpenRouter" + } + } + } + } +} +``` + +Run `opencode auth list` or a cheap prompt from the temp project only after the +file-level plugin projection passes. Report whether OpenCode loaded the +generated `.opencode/plugins/.ts|js` module separately from whether the +model call succeeded. + +## Cursor + +Cursor runtime QA is not the same as gateway QA. The CLI documents +`CURSOR_API_KEY` or browser login, and exposes `--endpoint` for endpoint issues. +The Cursor BYOK UI documents provider-specific API keys, but not arbitrary +OpenRouter-compatible provider configuration. + +For now: + +- Use file-level checks for `.cursor/mcp.json`, `.cursor/hooks.json`, and + `.cursor/agents/*.md`. +- Use `CURSOR_API_KEY` only for Cursor CLI/headless proof when the test target + is Cursor runtime behavior. +- Do not represent `OPENROUTER_API_KEY` as sufficient Cursor proof unless a + current Cursor doc or an observed local command proves the endpoint shape. + +## Sources + +- Claude Code LLM gateway configuration: https://code.claude.com/docs/en/llm-gateway +- Claude Code model configuration: https://code.claude.com/docs/en/model-config +- Codex custom model providers and environment variables: https://developers.openai.com/codex/codex-manual.md +- Cursor CLI authentication: https://cursor.com/docs/cli/reference/authentication.md +- Cursor BYOK: https://cursor.com/help/models-and-usage/api-keys.md +- Grok Build getting started and custom models: https://docs.x.ai/build/overview +- OpenCode providers: https://opencode.ai/docs/providers/ +- OpenRouter API overview: https://openrouter.ai/docs/api/reference/overview +- OpenRouter Anthropic Messages endpoint: https://openrouter.ai/docs/api/api-reference/anthropic-messages/create-messages From 0f24c97c3114f3d8a3263bcc6028300a85ed83ce Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 11:11:09 -0700 Subject: [PATCH 09/35] docs(qa): Document OpenCode runtime proof Capture the manual Docker proof path for OpenCode credentials, generated subagent discovery, and delegation through the task tool. Also document the local QA env template for provider-specific keys. Co-Authored-By: Codex --- skills/dotagents-qa/references/opencode.md | 42 ++++++++++++++++++- .../dotagents-qa/references/runtime-auth.md | 31 ++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/skills/dotagents-qa/references/opencode.md b/skills/dotagents-qa/references/opencode.md index 67e850d..fa54f5e 100644 --- a/skills/dotagents-qa/references/opencode.md +++ b/skills/dotagents-qa/references/opencode.md @@ -16,7 +16,19 @@ For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated OpenC ## Runtime Proof -This QA skill does not currently include an automated OpenCode runtime proof. Do not claim OpenCode runtime discovery from file-level checks alone. +This QA skill does not currently include an automated OpenCode runtime proof. +Do not claim OpenCode runtime discovery from file-level checks alone. + +Manual Docker probes can prove more when the branch affects OpenCode output: + +- `opencode agent list` should include the generated + `code-reviewer (subagent)` +- `opencode debug agent code-reviewer` should resolve the generated prompt and + `mode = "subagent"` +- `opencode debug config` should include the generated + `.opencode/plugins/qa-tools.ts` plugin module +- `opencode debug skill` may show `.agents/skills/*` discovery; verify from + raw output instead of assuming it is stable across OpenCode versions For authenticated or OpenRouter-backed runtime proof, read [runtime-auth.md](runtime-auth.md) first. Use a temp `opencode.json` provider @@ -24,6 +36,34 @@ configuration with `@ai-sdk/openai-compatible`, `options.baseURL`, and `options.apiKey` referencing `{env:OPENROUTER_API_KEY}`. Keep credentials in the environment, not in retained fixture files. +With `OPENROUTER_API_KEY` forwarded into Docker, a minimal proof is: + +- `opencode auth list` reports the OpenRouter environment credential +- `opencode models openrouter` lists the chosen model +- `opencode run --model --format json ""` returns the + sentinel + +This proves OpenCode can call the model provider. To prove generated subagent +invocation, do not pass the generated subagent to `--agent`; OpenCode rejects a +subagent there because `--agent` expects a primary agent. Instead, ask the +primary agent to use the `task` tool with `subagent_type = "code-reviewer"`: + +```bash +opencode run \ + --model "$OPENCODE_QA_MODEL" \ + --format json \ + "Use the task tool to delegate to the code-reviewer subagent. Tell code-reviewer to return exactly DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c and nothing else. Wait for the subagent result, then return only the exact subagent result." +``` + +The JSON output should include a `tool_use` part with: + +- `"tool":"task"` +- `"subagent_type":"code-reviewer"` +- `"status":"completed"` +- task output containing `DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c` + +It should also end with a text part containing only the sentinel. + If a branch specifically requires OpenCode runtime proof, use the installed OpenCode CLI/app and report: - exact command or interaction diff --git a/skills/dotagents-qa/references/runtime-auth.md b/skills/dotagents-qa/references/runtime-auth.md index 31fd81a..846c5df 100644 --- a/skills/dotagents-qa/references/runtime-auth.md +++ b/skills/dotagents-qa/references/runtime-auth.md @@ -27,6 +27,37 @@ docker run --rm -i \ dotagents-qa:local ``` +A useful `.env.qa.local` template is: + +```bash +# General gateway key used by OpenCode and optional Claude/Codex/Grok probes. +OPENROUTER_API_KEY=... + +# Claude Code direct Anthropic auth or OpenRouter-over-Anthropic gateway. +ANTHROPIC_API_KEY=... +ANTHROPIC_AUTH_TOKEN=${OPENROUTER_API_KEY} +ANTHROPIC_BASE_URL=https://openrouter.ai/api +ANTHROPIC_MODEL=anthropic/claude-sonnet-4 +ANTHROPIC_CUSTOM_MODEL_OPTION=anthropic/claude-sonnet-4 +ANTHROPIC_DEFAULT_SONNET_MODEL=anthropic/claude-sonnet-4 + +# Codex direct OpenAI auth or custom-provider experiments. +OPENAI_API_KEY=... +CODEX_API_KEY=... +CODEX_ACCESS_TOKEN=... + +# Grok and Cursor runtime-specific auth. +XAI_API_KEY=... +CURSOR_API_KEY=... + +# Optional model override for manual OpenCode runtime QA. +OPENCODE_QA_MODEL=openrouter/anthropic/claude-haiku-4.5 +``` + +Only set aliases like `ANTHROPIC_AUTH_TOKEN=${OPENROUTER_API_KEY}` when you +intend that runtime to use the gateway. For first-party provider proof, set the +provider's real key instead and leave the gateway overrides unset. + Inside Docker, keep client state isolated with temp homes such as `HOME`, `CODEX_HOME`, `DOTAGENTS_HOME`, and any runtime-specific config directory the client supports. Do not use or mount host runtime homes for ordinary QA. From 26e00b8bb27efcc5394ef684bdeb6c1c18166baa Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 11:26:03 -0700 Subject: [PATCH 10/35] fix(plugins): Generate Cursor plugin manifests Add managed Cursor native plugin manifests alongside the generated marketplace so local Cursor plugin roots have the expected .cursor-plugin/plugin.json file. Cover write, verify, prune, and unmanaged manifest protection. Document plugin runtime verification steps for Claude, Codex, OpenCode, Cursor, and Grok so manual QA has a deterministic checklist where runtime clients or model auth are required. Co-Authored-By: Codex --- docs/public/llms.txt | 8 +- .../src/agents/plugin-writer.test.ts | 30 ++- .../dotagents/src/agents/plugin-writer.ts | 85 ++++++++ skills/dotagents-qa/SKILL.md | 5 + .../dotagents-qa/references/plugin-runtime.md | 186 ++++++++++++++++++ skills/dotagents-qa/scripts/qa-example.mjs | 5 + specs/SPEC.md | 4 +- specs/plugins.md | 8 +- 8 files changed, 319 insertions(+), 12 deletions(-) create mode 100644 skills/dotagents-qa/references/plugin-runtime.md diff --git a/docs/public/llms.txt b/docs/public/llms.txt index efefc70..78fc42e 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -289,13 +289,13 @@ dotagents installs canonical plugin bundles under `.agents/plugins//`. The | `targets` | string[] | No | Optional subset of configured agent IDs. When absent or empty, defaults to every configured agent in `agents`; targets not listed in top-level `agents` are skipped with a warning. | Generated project-scope plugin outputs: -- Claude: `.claude-plugin/marketplace.json` -- Cursor: `.cursor-plugin/marketplace.json` -- Codex: `.agents/plugins//.codex-plugin/plugin.json` +- Claude: `.claude-plugin/marketplace.json` and `.agents/plugins//.claude-plugin/plugin.json` +- Cursor: `.cursor-plugin/marketplace.json` and `.agents/plugins//.cursor-plugin/plugin.json` +- Codex: `.agents/plugins/marketplace.json` and `.agents/plugins//.codex-plugin/plugin.json` - Grok: `.grok/plugins//` managed copy - OpenCode: `.opencode/plugins/.js|ts` re-export module when the plugin declares or contains one OpenCode module -Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated runtime marketplaces and generated Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. +Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Plugin declarations are project-scope only for now. `install --user` rejects `[[plugins]]` entries and `sync --user` reports them as unsupported because user-scope runtime plugin projections are not generated yet. diff --git a/packages/dotagents/src/agents/plugin-writer.test.ts b/packages/dotagents/src/agents/plugin-writer.test.ts index 06f18e0..574414a 100644 --- a/packages/dotagents/src/agents/plugin-writer.test.ts +++ b/packages/dotagents/src/agents/plugin-writer.test.ts @@ -55,7 +55,7 @@ describe("plugin writer", () => { ); expect(result.warnings).toEqual([]); - expect(result.written).toBe(7); + expect(result.written).toBe(9); const codexMarketplace = JSON.parse(await readFile(join(root, ".agents", "plugins", "marketplace.json"), "utf-8")) as Record; expect(codexMarketplace).toEqual({ interface: { @@ -124,6 +124,11 @@ describe("plugin writer", () => { expect(claudeManifest["category"]).toBeUndefined(); expect(claudeManifest["metadata"]).toEqual({ managedBy: "dotagents" }); + const cursorManifest = JSON.parse(await readFile(join(root, ".agents", "plugins", "alpha-tools", ".cursor-plugin", "plugin.json"), "utf-8")) as Record; + expect(cursorManifest["skills"]).toBe("./skills"); + expect(cursorManifest["commands"]).toBe("./commands"); + expect(cursorManifest["metadata"]).toEqual({ managedBy: "dotagents" }); + const codexManifest = JSON.parse(await readFile(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"), "utf-8")) as Record; expect(codexManifest["skills"]).toBe("./skills"); expect(codexManifest["commands"]).toBe("./commands"); @@ -208,6 +213,23 @@ describe("plugin writer", () => { expect(await readFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); }); + it("does not overwrite unmanaged Cursor plugin manifests", async () => { + const alpha = await plugin("alpha-tools"); + await mkdir(join(alpha.pluginDir, ".cursor-plugin"), { recursive: true }); + await writeFile(join(alpha.pluginDir, ".cursor-plugin", "plugin.json"), "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs(["cursor"], [alpha], root); + + expect(result.warnings).toEqual([ + { + agent: "cursor", + name: "alpha-tools", + message: `Cursor plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", ".cursor-plugin", "plugin.json")}`, + }, + ]); + expect(await readFile(join(alpha.pluginDir, ".cursor-plugin", "plugin.json"), "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"); @@ -294,23 +316,27 @@ describe("plugin writer", () => { }); await mkdir(join(alpha.pluginDir, "opencode"), { recursive: true }); await writeFile(join(alpha.pluginDir, "opencode", "plugin.ts"), "export default {}\n", "utf-8"); - await writePluginOutputs(["claude", "codex", "grok", "opencode"], [alpha], root); + await writePluginOutputs(["claude", "cursor", "codex", "grok", "opencode"], [alpha], root); const pruned = await prunePluginOutputs([], [alpha], root); expect(pruned).toEqual([ join(root, ".agents", "plugins", "marketplace.json"), join(root, ".claude-plugin", "marketplace.json"), + join(root, ".cursor-plugin", "marketplace.json"), join(root, ".grok", "plugins", "alpha-tools"), join(root, ".opencode", "plugins", "alpha-tools.ts"), join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"), + join(root, ".agents", "plugins", "alpha-tools", ".cursor-plugin", "plugin.json"), join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"), ]); 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); expect(existsSync(join(root, ".grok", "plugins", "alpha-tools"))).toBe(false); expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(false); expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"))).toBe(false); + expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".cursor-plugin", "plugin.json"))).toBe(false); expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); }); diff --git a/packages/dotagents/src/agents/plugin-writer.ts b/packages/dotagents/src/agents/plugin-writer.ts index 53c1f6f..7078d98 100644 --- a/packages/dotagents/src/agents/plugin-writer.ts +++ b/packages/dotagents/src/agents/plugin-writer.ts @@ -56,6 +56,9 @@ export async function writePluginOutputs( if (agents.includes("claude") && await writeClaudeManifest(plugin, warnings)) { written++; } + if (agents.includes("cursor") && await writeCursorManifest(plugin, warnings)) { + written++; + } if (agents.includes("codex") && await writeCodexManifest(plugin, warnings)) { written++; } @@ -102,6 +105,12 @@ export async function verifyPluginOutputs( issues.push({ agent: "claude", name: plugin.name, issue: `Claude plugin manifest missing: ${filePath}` }); } } + if (agents.includes("cursor")) { + const filePath = join(plugin.pluginDir, ".cursor-plugin", "plugin.json"); + if (!existsSync(filePath)) { + issues.push({ agent: "cursor", name: plugin.name, issue: `Cursor plugin manifest missing: ${filePath}` }); + } + } if (agents.includes("codex")) { const filePath = join(plugin.pluginDir, ".codex-plugin", "plugin.json"); if (!existsSync(filePath)) { @@ -192,6 +201,24 @@ export async function prunePluginOutputs( } } + const desiredCursor = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")) + .map((plugin) => plugin.name), + ); + if (existsSync(canonicalPluginDir)) { + const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) {continue;} + if (desiredCursor.has(entry.name)) {continue;} + const path = join(canonicalPluginDir, entry.name, ".cursor-plugin", "plugin.json"); + if (!existsSync(path) || !await isManagedJsonFile(path)) {continue;} + await rm(path, { force: true }); + await rmdirIfEmpty(dirname(path)); + pruned.push(path); + } + } + const desiredCodex = new Set( plugins .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")) @@ -375,6 +402,23 @@ async function writeClaudeManifest( return writeJsonIfChanged(filePath, stableJson(manifest)); } +async function writeCursorManifest( + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const filePath = join(plugin.pluginDir, ".cursor-plugin", "plugin.json"); + if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { + warnings.push({ + agent: "cursor", + name: plugin.name, + message: `Cursor plugin manifest exists and is not managed by dotagents: ${filePath}`, + }); + return false; + } + const manifest = cursorRuntimeManifest(plugin); + return writeJsonIfChanged(filePath, stableJson(manifest)); +} + async function writeCodexManifest( plugin: PluginDeclaration, warnings: PluginWriteWarning[], @@ -425,6 +469,47 @@ function claudeRuntimeManifest(plugin: PluginDeclaration): Record { + const manifest: Record = { + name: plugin.name, + }; + copyManifestField(plugin.manifest, manifest, "version"); + copyManifestField(plugin.manifest, manifest, "description"); + copyManifestField(plugin.manifest, manifest, "author"); + copyManifestField(plugin.manifest, manifest, "homepage"); + copyManifestField(plugin.manifest, manifest, "repository"); + copyManifestField(plugin.manifest, manifest, "license"); + copyManifestField(plugin.manifest, manifest, "keywords"); + + if (existsSync(join(plugin.pluginDir, "skills"))) { + manifest["skills"] = "./skills"; + } + if (existsSync(join(plugin.pluginDir, "agents"))) { + manifest["agents"] = "./agents"; + } + if (existsSync(join(plugin.pluginDir, "commands"))) { + manifest["commands"] = "./commands"; + } + if (existsSync(join(plugin.pluginDir, "rules"))) { + manifest["rules"] = "./rules"; + } + if (existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { + manifest["hooks"] = "./hooks/hooks.json"; + } + if (existsSync(join(plugin.pluginDir, ".mcp.json"))) { + manifest["mcpServers"] = "./.mcp.json"; + } else if (existsSync(join(plugin.pluginDir, "mcp.json"))) { + manifest["mcpServers"] = "./mcp.json"; + } + const metadata = plugin.manifest["metadata"]; + manifest["metadata"] = { + ...(metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {}), + ...DOTAGENTS_METADATA, + }; + return manifest; +} + /** Mirrors a plugin bundle into Grok's plugin directory with a managed marker. */ async function writeGrokProjection( projectRoot: string, diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index cc7a37e..05b76a9 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -36,6 +36,7 @@ Write down the QA target before running commands: Read the targeted reference before running runtime-specific QA: - Core install/sync example: [references/core-agentic-qa.md](references/core-agentic-qa.md) - Runtime auth, gateway, and `.env.qa.local` handling: [references/runtime-auth.md](references/runtime-auth.md) +- Plugin runtime verification matrix: [references/plugin-runtime.md](references/plugin-runtime.md) - Codex custom agents and plugin marketplace/install proof: [references/codex.md](references/codex.md) - Claude Code files, plugin validation, and runtime caveats: [references/claude.md](references/claude.md) - Cursor files/runtime caveats: [references/cursor.md](references/cursor.md) @@ -288,6 +289,7 @@ test -f .agents/plugins/marketplace.json test -f .claude-plugin/marketplace.json test -f .cursor-plugin/marketplace.json test -f .agents/plugins/qa-tools/.claude-plugin/plugin.json +test -f .agents/plugins/qa-tools/.cursor-plugin/plugin.json test -f .agents/plugins/qa-tools/.codex-plugin/plugin.json test -f .grok/plugins/qa-tools/.dotagents-managed test -f .opencode/plugins/qa-tools.ts @@ -303,6 +305,7 @@ diff claims to repair, then verify the repair: ```bash rm .mcp.json .claude/skills .claude/agents/code-reviewer.md .codex/agents/code-reviewer.toml rm .agents/plugins/marketplace.json .claude-plugin/marketplace.json .agents/plugins/qa-tools/.claude-plugin/plugin.json +rm .cursor-plugin/marketplace.json .agents/plugins/qa-tools/.cursor-plugin/plugin.json rm .agents/plugins/qa-tools/.codex-plugin/plugin.json rm -rf .grok/plugins/qa-tools rm .opencode/plugins/qa-tools.ts @@ -313,7 +316,9 @@ test -f .claude/agents/code-reviewer.md test -f .codex/agents/code-reviewer.toml test -f .agents/plugins/marketplace.json test -f .claude-plugin/marketplace.json +test -f .cursor-plugin/marketplace.json test -f .agents/plugins/qa-tools/.claude-plugin/plugin.json +test -f .agents/plugins/qa-tools/.cursor-plugin/plugin.json test -f .agents/plugins/qa-tools/.codex-plugin/plugin.json test -f .grok/plugins/qa-tools/.dotagents-managed test -f .opencode/plugins/qa-tools.ts diff --git a/skills/dotagents-qa/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md new file mode 100644 index 0000000..d06e1d7 --- /dev/null +++ b/skills/dotagents-qa/references/plugin-runtime.md @@ -0,0 +1,186 @@ +# Plugin Runtime Verification + +Use this reference when a branch changes plugin output and the goal is to make +manual QA easy. Keep automated checks narrow: prove deterministic files and +native management commands first, then leave UI/model-backed confirmation as a +small checklist for the user. + +## Baseline Automated Proof + +Inside Docker, always start with: + +```bash +pnpm install --frozen-lockfile +pnpm build +pnpm check +pnpm qa:example +node skills/dotagents-qa/scripts/qa-example.mjs plugin-claude +node skills/dotagents-qa/scripts/qa-example.mjs plugin-codex +node skills/dotagents-qa/scripts/qa-example.mjs plugin-opencode +``` + +This proves install/sync/list/doctor behavior, generated plugin files, Claude +validation, Codex marketplace install, and OpenCode projection surface. It does +not prove every runtime loaded and invoked every plugin component. + +## Claude Code + +Best automated proof in Docker: + +```bash +claude plugin validate .agents/plugins/qa-tools +claude plugin validate .claude-plugin/marketplace.json +claude plugin marketplace add "$PROJECT" --scope local +claude plugin list --available --json +claude plugin install qa-tools@dotagents --scope local +claude plugin list --json +claude plugin details qa-tools +``` + +Expected evidence: + +- `plugin list --available --json` includes `qa-tools@dotagents` +- `plugin install` succeeds at local scope +- `plugin list --json` shows `enabled: true` +- `plugin details qa-tools` lists plugin skills, agents, hooks, MCP servers, and + LSP servers from the generated bundle + +Manual final check when authenticated: + +- Start Claude Code in the retained temp project +- Run `/reload-plugins` +- Check `/help` or plugin UI for `/qa-tools:plugin-qa` +- Invoke the plugin skill and confirm it returns `DOTAGENTS_PLUGIN_QA_FIXTURE` +- Check `/agents` for `plugin-reviewer` when plugin agents are present + +## Codex + +Best automated proof in Docker: + +```bash +export CODEX_HOME="$TMP/codex-home" +mkdir -p "$CODEX_HOME" +codex plugin marketplace add "$PROJECT" --json +codex plugin list --available --json +codex plugin add qa-tools@dotagents-local --json +codex plugin list --json +``` + +Expected evidence: + +- Marketplace add returns `dotagents-local` +- Available list includes `qa-tools@dotagents-local` +- Install returns `qa-tools@dotagents-local` +- Installed list shows the plugin enabled + +Manual final check with model auth: + +- Run a Codex prompt in the retained project after installing the plugin +- Ask Codex to use or summarize the installed plugin skill/component +- Inspect JSON/session events or final answer for `DOTAGENTS_PLUGIN_QA_FIXTURE` + +Codex plugin install proof is strong; plugin component invocation still needs a +model-backed prompt because the plugin management CLI does not execute skills. + +## OpenCode + +Best automated proof in Docker: + +1. Install the example with dotagents. +2. Confirm `.opencode/plugins/qa-tools.ts` re-exports the canonical plugin + module. +3. For a deterministic plugin-execution proof, make the fixture plugin register + an observable config hook: + +```ts +export default async () => ({ + config: (cfg) => { + cfg.command = cfg.command ?? {}; + cfg.command["dotagents-plugin-proof"] = { + description: "Proof command injected by generated OpenCode plugin projection.", + prompt: "DOTAGENTS_OPENCODE_PLUGIN_EXECUTION_PROOF", + }; + }, +}); +``` + +Then run: + +```bash +opencode debug config > /tmp/opencode-config.json +rg "dotagents-plugin-proof|DOTAGENTS_OPENCODE_PLUGIN_EXECUTION_PROOF|qa-tools.ts" /tmp/opencode-config.json +``` + +Expected evidence: + +- `debug config` includes the generated plugin module path +- `debug config` includes the command injected by the plugin hook + +Manual final check with model auth: + +- Use `OPENROUTER_API_KEY` or another configured provider key +- Run `opencode run --model "$OPENCODE_QA_MODEL" --format json ""` +- For generated subagents, ask the primary agent to call the `task` tool with + `subagent_type = "code-reviewer"`; do not pass the subagent to `--agent` + +## Cursor + +Best current proof: + +- File-level generated marketplace exists at `.cursor-plugin/marketplace.json` +- The marketplace points at `./.agents/plugins/` +- Cursor-native manifest exists at + `.agents/plugins//.cursor-plugin/plugin.json` +- Generated manifest carries `metadata.managedBy = "dotagents"` and lists the + expected component paths such as `skills`, `commands`, `agents`, `rules`, + `hooks`, or `mcpServers` when those files exist + +Manual final check with the Cursor client: + +```bash +mkdir -p ~/.cursor/plugins/local +ln -s "$PROJECT/.agents/plugins/qa-tools" ~/.cursor/plugins/local/qa-tools +``` + +Then restart Cursor or run **Developer: Reload Window**. Verify: + +- plugin appears in Cursor settings/marketplace UI +- plugin rules/skills/agents/commands/MCP servers appear in their respective + Cursor settings surfaces +- invoking the plugin skill or command yields `DOTAGENTS_PLUGIN_QA_FIXTURE` + +## Grok Build + +Best automated proof depends on the `grok` CLI, which is not installed in the +QA Docker image today. + +With `grok` available in Docker or locally isolated: + +```bash +grok inspect +grok -p "Use the qa-tools plugin skill and return DOTAGENTS_PLUGIN_QA_FIXTURE" \ + -m "$GROK_QA_MODEL" \ + --output-format streaming-json +``` + +Expected evidence: + +- `grok inspect` lists `.grok/plugins/qa-tools` +- `grok inspect` or the TUI extensions modal lists plugin skills/components +- model-backed output returns `DOTAGENTS_PLUGIN_QA_FIXTURE` + +Manual final check: + +- Open the retained project in Grok +- Open the extensions modal with `/plugins`, `/hooks`, `/skills`, or `/mcps` +- Verify the generated plugin and its components are listed +- Invoke the plugin skill or command and check for `DOTAGENTS_PLUGIN_QA_FIXTURE` + +## What Counts As Verified + +- File generated: dotagents writer works, not runtime load. +- Native validate/list/install/details command passes: runtime can parse and + register the plugin. +- Runtime debug config shows a plugin-injected value: plugin code loaded and + executed. +- Model/UI invocation returns the sentinel: end-user behavior is verified. diff --git a/skills/dotagents-qa/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs index a7d22e9..30b1d0c 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -374,6 +374,11 @@ function assertPluginOutputs() { assertFileIncludes(".agents/plugins/qa-tools/.claude-plugin/plugin.json", '"managedBy": "dotagents"'); assertFileIncludes(".agents/plugins/qa-tools/.claude-plugin/plugin.json", '"skills": "./skills"'); assertFileIncludes(".agents/plugins/qa-tools/.claude-plugin/plugin.json", '"commands": "./commands"'); + assertFile(".agents/plugins/qa-tools/.cursor-plugin/plugin.json"); + assertFileIncludes(".agents/plugins/qa-tools/.cursor-plugin/plugin.json", '"managedBy": "dotagents"'); + assertFileIncludes(".agents/plugins/qa-tools/.cursor-plugin/plugin.json", '"skills": "./skills"'); + assertFileIncludes(".agents/plugins/qa-tools/.cursor-plugin/plugin.json", '"commands": "./commands"'); + assertFileIncludes(".agents/plugins/qa-tools/.cursor-plugin/plugin.json", '"agents": "./agents"'); assertFile(".agents/plugins/qa-tools/.codex-plugin/plugin.json"); assertFileIncludes(".agents/plugins/qa-tools/.codex-plugin/plugin.json", '"managedBy": "dotagents"'); assertFileIncludes(".agents/plugins/qa-tools/.codex-plugin/plugin.json", '"skills": "./skills"'); diff --git a/specs/SPEC.md b/specs/SPEC.md index f342c90..01e97ee 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -260,12 +260,12 @@ Generated project-scope plugin outputs: | Agent | Project Scope Output | |-------|----------------------| | Claude Code | `.claude-plugin/marketplace.json`; `.agents/plugins//.claude-plugin/plugin.json` | -| Cursor | `.cursor-plugin/marketplace.json` | +| Cursor | `.cursor-plugin/marketplace.json`; `.agents/plugins//.cursor-plugin/plugin.json` | | Codex | `.agents/plugins/marketplace.json`; `.agents/plugins//.codex-plugin/plugin.json` | | Grok Build | `.grok/plugins//` managed copy | | OpenCode | `.opencode/plugins/.js|ts` re-export module when the plugin declares or contains one OpenCode module | -Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. +Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Plugins are currently project-scope only. `install --user` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. diff --git a/specs/plugins.md b/specs/plugins.md index 334bf1f..97e50ba 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -18,7 +18,7 @@ dotagents has one canonical plugin source of truth: The canonical catalog and plugin manifests should use a generalized Codex-compatible format. Codex compatibility is the baseline because Codex already reads `.agents/plugins/marketplace.json` for repo-scoped marketplaces, but dotagents treats the schema as portable project metadata rather than Codex-only configuration. -Every other runtime output is generated from `.agents/plugins/` when that runtime does not directly consume the canonical path or schema. Generated artifacts may include `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/` plugin files, `.opencode/plugins/` modules, or runtime settings/config entries. These generated artifacts are runtime projections, not the source of truth, except that `.agents/plugins/marketplace.json` is also Codex's documented repo-scoped marketplace location. +Every other runtime output is generated from `.agents/plugins/` when that runtime does not directly consume the canonical path or schema. Generated artifacts may include `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.claude-plugin/plugin.json`, `.agents/plugins//.cursor-plugin/plugin.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/` plugin files, `.opencode/plugins/` modules, or runtime settings/config entries. These generated artifacts are runtime projections, not the source of truth, except that `.agents/plugins/marketplace.json` is also Codex's documented repo-scoped marketplace location. ## Input and Output Contract @@ -255,12 +255,12 @@ Generated project-scope outputs should be: | Agent | Project Scope Output | User Scope Output | Notes | |-------|----------------------|-------------------|-------| | Claude Code | `.claude-plugin/marketplace.json` and `.agents/plugins//.claude-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources and each targeted plugin gets a Claude-native manifest. | -| Cursor | `.cursor-plugin/marketplace.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources. | +| Cursor | `.cursor-plugin/marketplace.json` and `.agents/plugins//.cursor-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources and each targeted plugin gets a Cursor-native manifest. | | Codex | `.agents/plugins/marketplace.json` and generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | Generated marketplace uses deterministic `{ "source": "local", "path": "./.agents/plugins/" }` entries relative to the project root. | | Grok Build | `.grok/plugins/` for targeted plugins | Not generated yet | The projection is a managed copy of the canonical plugin bundle with a `.dotagents-managed` marker. | | OpenCode | `.opencode/plugins/.js|ts` re-export module for an explicit OpenCode module | Not generated yet | dotagents only exposes the module declared in `manifest.opencode.plugins` or discovered at `opencode/plugin.ts|js`; it does not synthesize OpenCode JS/TS code from other runtime hooks. | -Installed and generated files are dotagents-managed. `install` and `sync` may overwrite stale managed files and prune removed managed files, but they must not overwrite hand-written plugin files without a generated marker or a canonical installed bundle path owned by dotagents. Generated Claude and Codex manifests carry `metadata.managedBy = "dotagents"` so target removal can prune them without deleting user-authored native plugin manifests. +Installed and generated files are dotagents-managed. `install` and `sync` may overwrite stale managed files and prune removed managed files, but they must not overwrite hand-written plugin files without a generated marker or a canonical installed bundle path owned by dotagents. Generated Claude, Cursor, and Codex manifests carry `metadata.managedBy = "dotagents"` so target removal can prune them without deleting user-authored native plugin manifests. User-scope plugin declarations are not supported yet. `install --user` rejects `[[plugins]]` entries, and `sync --user` reports them as unsupported, because the current runtime projections are defined only for project scope. @@ -309,7 +309,7 @@ dotagents should not: ## Open Questions -1. Whether Claude and Cursor should gain additional native install/config outputs beyond the deterministic marketplace projections dotagents writes today. +1. Whether Claude and Cursor should gain additional native install/config outputs beyond the deterministic marketplace and plugin manifest projections dotagents writes today. 2. Grok's exact native manifest shape is not fully documented publicly; current support uses native `.grok/plugins/` placement with the canonical bundle. 3. Whether `[[plugins]]` should allow remote marketplace source objects directly, or only concrete plugin directories resolved from repositories. 4. Whether plugin-contained skills should optionally expose short aliases in `.agents/skills/` for runtimes without native plugin namespaces. From cbed6b99c7bc9d7d9858d861dba713efb4cb5075 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 29 Jun 2026 18:36:19 -0700 Subject: [PATCH 11/35] fix(plugins): Tighten runtime plugin projections Preserve explicit Claude and Cursor component paths before falling back to conventional discovery, and compare managed Grok projection files as bytes so binary plugin assets update correctly. Keep same-project canonical plugins out of regenerated .agents/.gitignore, strengthen OpenCode plugin QA with debug config evidence, and align docs with the current native manifest projection contract. Co-Authored-By: Codex --- README.md | 2 +- docs/public/llms.txt | 2 +- .../local-plugins/qa-tools/opencode/plugin.ts | 12 ++- .../dotagents/src/agents/plugin-store.test.ts | 1 - packages/dotagents/src/agents/plugin-store.ts | 8 +- .../src/agents/plugin-writer.test.ts | 46 +++++++++++ .../dotagents/src/agents/plugin-writer.ts | 76 +++++++++---------- .../dotagents/src/cli/commands/doctor.test.ts | 21 +++++ packages/dotagents/src/cli/commands/doctor.ts | 5 +- packages/dotagents/src/cli/commands/list.ts | 1 + .../dotagents-qa/references/plugin-runtime.md | 19 +++-- skills/dotagents-qa/scripts/qa-example.mjs | 12 ++- specs/SPEC.md | 2 +- specs/plugins.md | 2 +- 14 files changed, 144 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 4064df0..3e1705b 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Review the current diff and return findings with file references. dotagents can also import native runtime subagent files from `.claude/agents/`, `.cursor/agents/`, `.codex/agents/*.toml`, and `.opencode/agents/`. Input and matching-runtime output use the same native format: Markdown with YAML frontmatter for Claude, Cursor, and OpenCode; TOML for Codex. Claude and Codex identify agents by `name`, Cursor can derive `name` from the filename when omitted, and OpenCode uses the filename as the agent name. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Subagent declarations intentionally cover only dependency source and runtime targets, not universal model/tool/permission behavior. -Plugins are declared with `[[plugins]]` entries. dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/plugins//`, and `.opencode/plugins/.js|ts` where supported: +Plugins are declared with `[[plugins]]` entries. dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.claude-plugin/marketplace.json`, `.agents/plugins//.claude-plugin/plugin.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.cursor-plugin/plugin.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/plugins//`, and `.opencode/plugins/.js|ts` where supported: ```toml [[plugins]] diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 78fc42e..dff94a2 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -278,7 +278,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//`. The canonical plugin input format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a generalized Codex-compatible marketplace and manifest shape. Known fields are validated, unknown manifest extension fields are preserved in installed bundles, marketplace extension fields are accepted but not projected, and component paths must be relative filesystem paths without `..` or URL/scheme prefixes. +dotagents installs canonical plugin bundles under `.agents/plugins//`. The canonical plugin input format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a generalized Codex-compatible marketplace and manifest shape. Known fields are validated, component paths must be relative filesystem paths without `..` or URL/scheme prefixes, unknown manifest extension fields are preserved in installed bundles and the generated Codex manifest, and Claude/Cursor manifests project known supported fields plus managed metadata. Marketplace extension fields are accepted but not projected. | Field | Type | Required | Description | |-------|------|----------|-------------| diff --git a/examples/full/local-plugins/qa-tools/opencode/plugin.ts b/examples/full/local-plugins/qa-tools/opencode/plugin.ts index f3f2845..5777b24 100644 --- a/examples/full/local-plugins/qa-tools/opencode/plugin.ts +++ b/examples/full/local-plugins/qa-tools/opencode/plugin.ts @@ -1,3 +1,9 @@ -export default { - name: "qa-tools", -}; +export default async () => ({ + config: (cfg) => { + cfg.command = cfg.command ?? {}; + cfg.command["dotagents-plugin-proof"] = { + description: "Proof command injected by generated OpenCode plugin projection.", + prompt: "DOTAGENTS_OPENCODE_PLUGIN_EXECUTION_PROOF", + }; + }, +}); diff --git a/packages/dotagents/src/agents/plugin-store.test.ts b/packages/dotagents/src/agents/plugin-store.test.ts index 1da6681..9c3510a 100644 --- a/packages/dotagents/src/agents/plugin-store.test.ts +++ b/packages/dotagents/src/agents/plugin-store.test.ts @@ -5,7 +5,6 @@ describe("plugin store", () => { it("preserves an empty resolved path for root git plugins", () => { const resolved = { type: "git", - source: "org/review-tools", resolvedUrl: "https://github.com/org/review-tools.git", resolvedPath: "", commit: "abc123", diff --git a/packages/dotagents/src/agents/plugin-store.ts b/packages/dotagents/src/agents/plugin-store.ts index 4ca99a5..13dcea2 100644 --- a/packages/dotagents/src/agents/plugin-store.ts +++ b/packages/dotagents/src/agents/plugin-store.ts @@ -44,13 +44,11 @@ export interface PluginDeclaration { interface ResolvedLocalPlugin { type: "local"; - source: string; plugin: PluginDeclaration; } interface ResolvedGitPlugin { type: "git"; - source: string; resolvedUrl: string; resolvedPath: string; resolvedRef?: string; @@ -104,7 +102,6 @@ export async function resolvePlugin( } return { type: "local", - source: config.source, plugin: toDeclaration(config, discovered), }; } @@ -137,7 +134,6 @@ export async function resolvePlugin( return { type: "git", - source: config.source, resolvedUrl: cloneUrl, resolvedPath: discovered.path, resolvedRef: ref, @@ -218,10 +214,10 @@ export async function pruneInstalledPlugins( /** Converts a resolved plugin to its lockfile entry. */ export function lockEntryForPlugin(resolved: ResolvedPlugin): LockedPlugin { if (resolved.type === "local") { - return { source: resolved.source }; + return { source: resolved.plugin.source }; } return { - source: resolved.source, + source: resolved.plugin.source, resolved_url: resolved.resolvedUrl, resolved_path: resolved.resolvedPath, ...(resolved.resolvedRef === undefined ? {} : { resolved_ref: resolved.resolvedRef }), diff --git a/packages/dotagents/src/agents/plugin-writer.test.ts b/packages/dotagents/src/agents/plugin-writer.test.ts index 574414a..f558bef 100644 --- a/packages/dotagents/src/agents/plugin-writer.test.ts +++ b/packages/dotagents/src/agents/plugin-writer.test.ts @@ -143,6 +143,38 @@ describe("plugin writer", () => { expect(await verifyPluginOutputs(["cursor", "codex", "claude"], [beta, alpha], root)).toEqual([]); }); + it("projects explicit Claude and Cursor component paths before conventional discovery", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { + agents: "custom-agents", + commands: ["cmds/review.md"], + hooks: "config/hooks.json", + mcpServers: "config/mcp.json", + rules: "cursor-rules", + skills: "plugin-skills", + }, + }); + + const result = await writePluginOutputs(["claude", "cursor"], [alpha], root); + + expect(result.warnings).toEqual([]); + expect(result.written).toBe(4); + const claudeManifest = JSON.parse(await readFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), "utf-8")) as Record; + expect(claudeManifest["agents"]).toBe("./custom-agents"); + expect(claudeManifest["commands"]).toEqual(["./cmds/review.md"]); + expect(claudeManifest["hooks"]).toBe("./config/hooks.json"); + expect(claudeManifest["mcpServers"]).toBe("./config/mcp.json"); + expect(claudeManifest["skills"]).toBe("./plugin-skills"); + + const cursorManifest = JSON.parse(await readFile(join(alpha.pluginDir, ".cursor-plugin", "plugin.json"), "utf-8")) as Record; + expect(cursorManifest["agents"]).toBe("./custom-agents"); + expect(cursorManifest["commands"]).toEqual(["./cmds/review.md"]); + expect(cursorManifest["hooks"]).toBe("./config/hooks.json"); + expect(cursorManifest["mcpServers"]).toBe("./config/mcp.json"); + expect(cursorManifest["rules"]).toBe("./cursor-rules"); + expect(cursorManifest["skills"]).toBe("./plugin-skills"); + }); + it("does not overwrite unmanaged marketplace files", async () => { const alpha = await plugin("alpha-tools"); await mkdir(join(root, ".claude-plugin"), { recursive: true }); @@ -349,4 +381,18 @@ describe("plugin writer", () => { expect(first.written).toBe(1); expect(second.written).toBe(0); }); + + it("compares managed Grok projection files as bytes", async () => { + const alpha = await plugin("alpha-tools"); + await mkdir(join(alpha.pluginDir, "bin"), { recursive: true }); + await writeFile(join(alpha.pluginDir, "bin", "blob"), Buffer.from([0xff])); + + const first = await writePluginOutputs(["grok"], [alpha], root); + await writeFile(join(alpha.pluginDir, "bin", "blob"), Buffer.from([0xef, 0xbf, 0xbd])); + const second = await writePluginOutputs(["grok"], [alpha], root); + + expect(first.written).toBe(1); + expect(second.written).toBe(1); + expect(await readFile(join(root, ".grok", "plugins", "alpha-tools", "bin", "blob"))).toEqual(Buffer.from([0xef, 0xbf, 0xbd])); + }); }); diff --git a/packages/dotagents/src/agents/plugin-writer.ts b/packages/dotagents/src/agents/plugin-writer.ts index 7078d98..7385bc8 100644 --- a/packages/dotagents/src/agents/plugin-writer.ts +++ b/packages/dotagents/src/agents/plugin-writer.ts @@ -136,7 +136,7 @@ export async function prunePluginOutputs( ): Promise { const pruned: string[] = []; const desiredMarketplacePaths = new Set( - marketplaceOutputsForTargets(agentIds, projectRoot, plugins).map((output) => output.filePath), + marketplaceOutputs(agentIds, projectRoot, plugins).map((output) => output.filePath), ); for (const filePath of marketplaceOutputPaths(projectRoot)) { if (desiredMarketplacePaths.has(filePath)) {continue;} @@ -285,31 +285,6 @@ function marketplaceOutputs( return outputs; } -function marketplaceOutputsForTargets( - agentIds: string[], - projectRoot: string, - plugins: Array>, -): RuntimeOutput[] { - if (plugins.length === 0) {return [];} - - const outputs: RuntimeOutput[] = []; - const hasClaude = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); - const hasCursor = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); - const hasCodex = plugins.some((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); - - if (hasClaude) { - outputs.push({ agent: "claude", filePath: join(projectRoot, ".claude-plugin", "marketplace.json"), content: "" }); - } - if (hasCursor) { - outputs.push({ agent: "cursor", filePath: join(projectRoot, ".cursor-plugin", "marketplace.json"), content: "" }); - } - if (hasCodex) { - outputs.push({ agent: "codex", filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), content: "" }); - } - - return outputs; -} - function pathMarketplace( projectRoot: string, name: string, @@ -449,18 +424,24 @@ function claudeRuntimeManifest(plugin: PluginDeclaration): Record } } +function copyRuntimeComponentField(source: PluginManifest, dest: Record, key: keyof PluginManifest): boolean { + const value = source[key]; + if (typeof value === "string") { + dest[key] = runtimePath(value); + return true; + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + dest[key] = value.map(runtimePath); + return true; + } + return false; +} + +function runtimePath(value: string): string { + return value.startsWith(".") ? value : `./${value}`; +} + function opencodeModules( plugin: PluginDeclaration, warnings: PluginWriteWarning[] = [], @@ -774,7 +774,7 @@ async function directoriesMatch(source: string, dest: string, ignoredNames = new if (await readlink(sourcePath) !== await readlink(destPath)) {return false;} continue; } - if (await readFile(sourcePath, "utf-8") !== await readFile(destPath, "utf-8")) { + if (!(await readFile(sourcePath)).equals(await readFile(destPath))) { return false; } } diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 0a37971..38b2097 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -277,6 +277,27 @@ source = "getsentry/plugins" expect(existsSync(join(projectRoot, ".agents", ".gitignore"))).toBe(true); }); + it("does not gitignore same-project canonical plugins when recreating .agents/.gitignore", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" })); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "local-tools" +source = "path:." +`, + ); + await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); + + await runDoctor({ scope: resolveScope("project", projectRoot), fix: true }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/plugins/local-tools/"); + }); + it("includes lockfile subagents when recreating .agents/.gitignore", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index 264b969..ceae800 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -152,7 +152,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { } else { const managedNames = getManagedSkillNames(config, lockfile); const managedSubagentNames = getManagedSubagentNames(config, lockfile); - const managedPluginNames = getManagedPluginNames(config, lockfile); + const managedPluginNames = getManagedPluginNames(config, lockfile, scope); checks.push({ name: ".agents/.gitignore", status: "warn", @@ -338,10 +338,11 @@ function getManagedSubagentNames( function getManagedPluginNames( config: Awaited>, lockfile: Awaited>, + scope: ScopeRoot, ): string[] { const names = new Set( config.plugins - .filter((plugin) => !isInPlacePluginSource(plugin.source)) + .filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) .map((plugin) => plugin.name), ); if (lockfile) { diff --git a/packages/dotagents/src/cli/commands/list.ts b/packages/dotagents/src/cli/commands/list.ts index abe7a0e..620f474 100644 --- a/packages/dotagents/src/cli/commands/list.ts +++ b/packages/dotagents/src/cli/commands/list.ts @@ -88,6 +88,7 @@ export async function runList(opts: ListOptions): Promise { return results; } +/** Returns configured plugin install and lock status for the selected scope. */ export async function runPluginList(opts: PluginListOptions): Promise { const { scope } = opts; const { configPath, lockPath, pluginsDir } = scope; diff --git a/skills/dotagents-qa/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md index d06e1d7..e996ab7 100644 --- a/skills/dotagents-qa/references/plugin-runtime.md +++ b/skills/dotagents-qa/references/plugin-runtime.md @@ -23,6 +23,11 @@ This proves install/sync/list/doctor behavior, generated plugin files, Claude validation, Codex marketplace install, and OpenCode projection surface. It does not prove every runtime loaded and invoked every plugin component. +`pnpm qa:example` is the install/sync repair proof. It intentionally runs only +the `install-files` and `sync-repair` tasks. Run the separate `plugin-claude`, +`plugin-codex`, and `plugin-opencode` tasks when the branch needs native +plugin-management or runtime-projection evidence. + ## Claude Code Best automated proof in Docker: @@ -42,8 +47,8 @@ Expected evidence: - `plugin list --available --json` includes `qa-tools@dotagents` - `plugin install` succeeds at local scope - `plugin list --json` shows `enabled: true` -- `plugin details qa-tools` lists plugin skills, agents, hooks, MCP servers, and - LSP servers from the generated bundle +- `plugin details qa-tools` lists the generated bundle's available components, + such as plugin skills, commands, and agents in the checked-in fixture Manual final check when authenticated: @@ -86,11 +91,7 @@ model-backed prompt because the plugin management CLI does not execute skills. Best automated proof in Docker: -1. Install the example with dotagents. -2. Confirm `.opencode/plugins/qa-tools.ts` re-exports the canonical plugin - module. -3. For a deterministic plugin-execution proof, make the fixture plugin register - an observable config hook: +The checked-in `qa-tools` fixture registers an observable config hook: ```ts export default async () => ({ @@ -104,7 +105,9 @@ export default async () => ({ }); ``` -Then run: +`node skills/dotagents-qa/scripts/qa-example.mjs plugin-opencode` installs the +fixture, confirms `.opencode/plugins/qa-tools.ts` re-exports the canonical +module, then runs: ```bash opencode debug config > /tmp/opencode-config.json diff --git a/skills/dotagents-qa/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs index 30b1d0c..1698398 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -115,7 +115,7 @@ Tasks: sync-repair Delete representative generated files and assert sync repairs them plugin-claude Validate generated Claude plugin and marketplace with Claude Code plugin-codex Add/list/install generated Codex marketplace with Codex CLI - plugin-opencode Assert generated OpenCode module and CLI surface + plugin-opencode Assert generated OpenCode module and debug config load codex-runtime Paid proof that Codex can spawn the generated custom agent Compatibility: @@ -135,6 +135,9 @@ async function runSyncRepair() { rmSync(join(projectDir, ".codex", "agents", "code-reviewer.toml"), { force: true }); rmSync(join(projectDir, ".agents", "plugins", "marketplace.json"), { force: true }); rmSync(join(projectDir, ".claude-plugin", "marketplace.json"), { force: true }); + rmSync(join(projectDir, ".cursor-plugin", "marketplace.json"), { force: true }); + rmSync(join(projectDir, ".agents", "plugins", "qa-tools", ".claude-plugin", "plugin.json"), { force: true }); + rmSync(join(projectDir, ".agents", "plugins", "qa-tools", ".cursor-plugin", "plugin.json"), { force: true }); rmSync(join(projectDir, ".agents", "plugins", "qa-tools", ".codex-plugin", "plugin.json"), { force: true }); rmSync(join(projectDir, ".grok", "plugins", "qa-tools"), { force: true, recursive: true }); rmSync(join(projectDir, ".opencode", "plugins", "qa-tools.ts"), { force: true }); @@ -189,14 +192,17 @@ async function runCodexPluginProof() { async function runOpenCodePluginProof() { await installAndAssert(); - execFileSync("opencode", ["plugin", "--help"], { + const config = execFileSync("opencode", ["debug", "config"], { cwd: projectDir, env: fixtureEnv, - stdio: "inherit", + encoding: "utf-8", }); assertFile(".opencode/plugins/qa-tools.ts"); assertFileIncludes(".opencode/plugins/qa-tools.ts", "Generated by dotagents"); assertFileIncludes(".opencode/plugins/qa-tools.ts", "../.agents/plugins/qa-tools/opencode/plugin.ts"); + if (!config.includes("qa-tools.ts") || !config.includes("dotagents-plugin-proof") || !config.includes("DOTAGENTS_OPENCODE_PLUGIN_EXECUTION_PROOF")) { + throw new Error("OpenCode debug config did not include generated plugin proof command"); + } } async function runCodexRuntimeProof() { diff --git a/specs/SPEC.md b/specs/SPEC.md index 01e97ee..69bbac8 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 canonical plugin input format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a generalized Codex-compatible marketplace and manifest shape. Canonical plugin manifests and marketplaces validate known fields tightly while allowing unknown extension fields. Manifest extensions are preserved in installed bundles and generated native manifests; marketplace extensions are accepted as input metadata but are not projected into generated marketplaces. +The canonical plugin input format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a generalized Codex-compatible marketplace and manifest shape. Canonical plugin manifests and marketplaces validate known fields tightly while allowing unknown extension fields. Manifest extensions are preserved in installed bundles and the generated Codex manifest; Claude and Cursor manifests project known supported fields plus managed metadata. Marketplace extensions are accepted as input metadata but are not projected into generated marketplaces. See [Plugin Support Specification](plugins.md) for the canonical layout, exact input/output contract, native docs captured for each runtime, discovery rules, generated runtime outputs, and non-goals. diff --git a/specs/plugins.md b/specs/plugins.md index 97e50ba..6961a3a 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -190,7 +190,7 @@ dotagents may also import plugin sources that already use native runtime manifes ## Native Formats -Input and matching-runtime output should use the same native format where possible. dotagents should preserve raw native manifests and component files for matching runtimes, adding only generated metadata needed to make the runtime discover the plugin. +Input and matching-runtime output should use the same native format where possible. dotagents should preserve component files for matching runtimes. Generated native manifests project the portable fields each runtime needs to discover the plugin; the Codex manifest additionally preserves unknown manifest extensions from the canonical bundle. | Runtime | Native Manifest | Native Plugin Roots | Components from Docs | Notes | |---------|-----------------|---------------------|----------------------|-------| From 22cbc2847a6a5107c7f0abe142a1c2d7f16fb2f4 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 11:53:18 -0700 Subject: [PATCH 12/35] docs(qa): Clarify OpenCode plugin proof Document that plugin-opencode now proves generated OpenCode plugin module loading through debug config while model-backed invocation remains explicit runtime QA. Co-Authored-By: Codex --- skills/dotagents-qa/references/opencode.md | 26 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/skills/dotagents-qa/references/opencode.md b/skills/dotagents-qa/references/opencode.md index fa54f5e..9dcb62d 100644 --- a/skills/dotagents-qa/references/opencode.md +++ b/skills/dotagents-qa/references/opencode.md @@ -1,6 +1,8 @@ # OpenCode QA -Use this reference when changes affect OpenCode config generation, `opencode.json`, `.opencode/agents/*.md`, or OpenCode user-scope paths. +Use this reference when changes affect OpenCode config generation, +`opencode.json`, `.opencode/agents/*.md`, `.opencode/plugins/*.ts`, or +OpenCode user-scope paths. ## File-Level Checks @@ -14,10 +16,25 @@ OpenCode does not support dotagents hooks in the current agent definition, so ho For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated OpenCode subagents under `$HOME/.config/opencode/agents/`. +## Plugin Proof + +For generated plugin modules, the QA skill has a cheap Docker proof: + +```bash +node skills/dotagents-qa/scripts/qa-example.mjs plugin-opencode --keep +``` + +This installs the checked-in example, asserts the generated +`.opencode/plugins/qa-tools.ts` re-export, runs `opencode debug config`, and +checks for the generated module path plus the fixture's +`dotagents-plugin-proof` command. That proves OpenCode loaded and executed the +generated plugin module's config hook. It does not prove model-backed +invocation. + ## Runtime Proof -This QA skill does not currently include an automated OpenCode runtime proof. -Do not claim OpenCode runtime discovery from file-level checks alone. +OpenCode subagent invocation and model-backed skill/plugin use still require an +authenticated runtime proof. Do not claim those from file-level checks alone. Manual Docker probes can prove more when the branch affects OpenCode output: @@ -26,7 +43,8 @@ Manual Docker probes can prove more when the branch affects OpenCode output: - `opencode debug agent code-reviewer` should resolve the generated prompt and `mode = "subagent"` - `opencode debug config` should include the generated - `.opencode/plugins/qa-tools.ts` plugin module + `.opencode/plugins/qa-tools.ts` plugin module and any observable fixture + config it injects - `opencode debug skill` may show `.agents/skills/*` discovery; verify from raw output instead of assuming it is stable across OpenCode versions From f51b252518fd0af15cf8c1367ab7fe9dcc6f4550 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 14:43:48 -0700 Subject: [PATCH 13/35] test(plugins): Cover same-project plugin detection Document the boundary for same-project plugin config checks. Missing canonical plugin directories should not be treated as same-project plugins, while explicit same-project source paths remain blocked before the path exists. Co-Authored-By: Codex --- .../dotagents/src/agents/plugin-store.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/dotagents/src/agents/plugin-store.test.ts b/packages/dotagents/src/agents/plugin-store.test.ts index 9c3510a..54f8710 100644 --- a/packages/dotagents/src/agents/plugin-store.test.ts +++ b/packages/dotagents/src/agents/plugin-store.test.ts @@ -1,5 +1,8 @@ +import { mkdtemp, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; -import { lockEntryForPlugin, type ResolvedPlugin } from "./plugin-store.js"; +import { isSameProjectPluginConfig, lockEntryForPlugin, type ResolvedPlugin } from "./plugin-store.js"; describe("plugin store", () => { it("preserves an empty resolved path for root git plugins", () => { @@ -23,4 +26,33 @@ describe("plugin store", () => { resolved_commit: "abc123", }); }); + + it("does not treat missing canonical plugin dirs as same-project plugins", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + const pluginsDir = join(projectRoot, ".agents", "plugins"); + await mkdir(pluginsDir, { recursive: true }); + + expect(isSameProjectPluginConfig( + { name: "review-tools", source: "path:." }, + pluginsDir, + projectRoot, + )).toBe(false); + }); + + it("detects same-project plugins without requiring the explicit source path to exist", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + const pluginsDir = join(projectRoot, ".agents", "plugins"); + await mkdir(pluginsDir, { recursive: true }); + + expect(isSameProjectPluginConfig( + { name: "review-tools", source: "path:.agents/plugins/review-tools" }, + pluginsDir, + projectRoot, + )).toBe(true); + expect(isSameProjectPluginConfig( + { name: "review-tools", source: "path:.", path: ".agents/plugins/review-tools" }, + pluginsDir, + projectRoot, + )).toBe(true); + }); }); From 691e1ebbfb1616b03007cc4ad68ac05ace122ed3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 30 Jun 2026 08:47:32 -0700 Subject: [PATCH 14/35] ref(dotagents): Split agent runtime concerns Move target definitions, subagent handling, and plugin runtime projection into separate modules. Keep the agents barrel as a compatibility layer while reducing the plugin writer surface area. Harden git-backed tests against local commit signing and update internal architecture docs to match the new layout. Co-Authored-By: Codex --- .../dotagents/src/agents/plugin-writer.ts | 850 ------------------ packages/dotagents/src/cli/commands/doctor.ts | 2 +- .../src/cli/commands/install/agent-runtime.ts | 4 +- .../src/cli/commands/install/gitignore.ts | 2 +- .../src/cli/commands/install/plugins.ts | 2 +- packages/dotagents/src/cli/commands/remove.ts | 2 +- .../dotagents/src/cli/commands/sync.test.ts | 4 +- packages/dotagents/src/cli/commands/sync.ts | 4 +- packages/dotagents/src/config/loader.ts | 5 +- .../dotagents/src/plugins/runtime/files.ts | 53 ++ .../src/plugins/runtime/manifest-values.ts | 28 + .../src/plugins/runtime/manifests.ts | 221 +++++ .../src/plugins/runtime/marketplace.ts | 128 +++ .../dotagents/src/plugins/runtime/types.ts | 22 + .../runtime/writer.test.ts} | 4 +- .../dotagents/src/plugins/runtime/writer.ts | 397 ++++++++ .../schema.test.ts} | 2 +- .../plugin-schema.ts => plugins/schema.ts} | 0 .../store.test.ts} | 2 +- .../plugin-store.ts => plugins/store.ts} | 2 +- packages/dotagents/src/plugins/targets.ts | 67 ++ packages/dotagents/src/subagents/writer.ts | 7 + packages/dotagents/src/targets/registry.ts | 12 - specs/SPEC.md | 5 + 24 files changed, 946 insertions(+), 879 deletions(-) delete mode 100644 packages/dotagents/src/agents/plugin-writer.ts create mode 100644 packages/dotagents/src/plugins/runtime/files.ts create mode 100644 packages/dotagents/src/plugins/runtime/manifest-values.ts create mode 100644 packages/dotagents/src/plugins/runtime/manifests.ts create mode 100644 packages/dotagents/src/plugins/runtime/marketplace.ts create mode 100644 packages/dotagents/src/plugins/runtime/types.ts rename packages/dotagents/src/{agents/plugin-writer.test.ts => plugins/runtime/writer.test.ts} (99%) create mode 100644 packages/dotagents/src/plugins/runtime/writer.ts rename packages/dotagents/src/{agents/plugin-schema.test.ts => plugins/schema.test.ts} (99%) rename packages/dotagents/src/{agents/plugin-schema.ts => plugins/schema.ts} (100%) rename packages/dotagents/src/{agents/plugin-store.test.ts => plugins/store.test.ts} (98%) rename packages/dotagents/src/{agents/plugin-store.ts => plugins/store.ts} (99%) create mode 100644 packages/dotagents/src/plugins/targets.ts diff --git a/packages/dotagents/src/agents/plugin-writer.ts b/packages/dotagents/src/agents/plugin-writer.ts deleted file mode 100644 index 7385bc8..0000000 --- a/packages/dotagents/src/agents/plugin-writer.ts +++ /dev/null @@ -1,850 +0,0 @@ -import { existsSync } from "node:fs"; -import { cp, lstat, mkdir, readdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises"; -import { dirname, extname, join, relative } from "node:path"; -import type { PluginDeclaration } from "./plugin-store.js"; -import type { PluginManifest } from "./plugin-schema.js"; -import { allPluginAgentIds } from "./registry.js"; - -// Owns deterministic runtime plugin projections. Existing runtime artifacts are -// overwritten only when they carry dotagents managed metadata or a managed marker. -const DOTAGENTS_METADATA = { managedBy: "dotagents" }; -const SUPPORTED_PLUGIN_AGENT_IDS = new Set(allPluginAgentIds()); - -export interface PluginWriteWarning { - agent: string; - name: string; - message: string; -} - -export interface PluginWriteResult { - warnings: PluginWriteWarning[]; - written: number; -} - -export interface PluginVerifyIssue { - agent: string; - name: string; - issue: string; -} - -interface RuntimeOutput { - agent: string; - filePath: string; - content: string; -} - -/** Writes deterministic project-scope plugin runtime artifacts for selected agents. */ -export async function writePluginOutputs( - agentIds: string[], - plugins: PluginDeclaration[], - projectRoot: string, -): Promise { - const warnings: PluginWriteWarning[] = []; - let written = 0; - const selected = selectPlugins(agentIds, plugins); - - for (const warning of targetWarnings(agentIds, plugins)) { - warnings.push(warning); - } - - for (const output of marketplaceOutputs(agentIds, projectRoot, selected)) { - if (await writeManagedJsonOutput(output, warnings)) {written++;} - } - - for (const plugin of selected) { - const agents = selectedAgentIds(agentIds, plugin); - if (agents.includes("claude") && await writeClaudeManifest(plugin, warnings)) { - written++; - } - if (agents.includes("cursor") && await writeCursorManifest(plugin, warnings)) { - written++; - } - if (agents.includes("codex") && await writeCodexManifest(plugin, warnings)) { - written++; - } - if (agents.includes("grok") && await writeGrokProjection(projectRoot, plugin, warnings)) { - written++; - } - if (agents.includes("opencode")) { - written += await writeOpenCodeProjection(projectRoot, plugin, warnings); - } - } - - return { warnings, written }; -} - -/** Verifies that generated plugin runtime artifacts match the current declarations. */ -export async function verifyPluginOutputs( - agentIds: string[], - plugins: PluginDeclaration[], - projectRoot: string, -): Promise { - const issues: PluginVerifyIssue[] = []; - const selected = selectPlugins(agentIds, plugins); - - for (const output of marketplaceOutputs(agentIds, projectRoot, selected)) { - if (!existsSync(output.filePath)) { - issues.push({ agent: output.agent, name: "marketplace", issue: `Plugin marketplace missing: ${output.filePath}` }); - continue; - } - try { - const existing = await readFile(output.filePath, "utf-8"); - if (existing !== output.content) { - issues.push({ agent: output.agent, name: "marketplace", issue: `Plugin marketplace out of date: ${output.filePath}` }); - } - } catch { - issues.push({ agent: output.agent, name: "marketplace", issue: `Failed to read plugin marketplace: ${output.filePath}` }); - } - } - - for (const plugin of selected) { - const agents = selectedAgentIds(agentIds, plugin); - if (agents.includes("claude")) { - const filePath = join(plugin.pluginDir, ".claude-plugin", "plugin.json"); - if (!existsSync(filePath)) { - issues.push({ agent: "claude", name: plugin.name, issue: `Claude plugin manifest missing: ${filePath}` }); - } - } - if (agents.includes("cursor")) { - const filePath = join(plugin.pluginDir, ".cursor-plugin", "plugin.json"); - if (!existsSync(filePath)) { - issues.push({ agent: "cursor", name: plugin.name, issue: `Cursor plugin manifest missing: ${filePath}` }); - } - } - if (agents.includes("codex")) { - const filePath = join(plugin.pluginDir, ".codex-plugin", "plugin.json"); - if (!existsSync(filePath)) { - issues.push({ agent: "codex", name: plugin.name, issue: `Codex plugin manifest missing: ${filePath}` }); - } - } - if (agents.includes("grok")) { - const filePath = join(projectRoot, ".grok", "plugins", plugin.name); - if (!existsSync(filePath)) { - issues.push({ agent: "grok", name: plugin.name, issue: `Grok plugin projection missing: ${filePath}` }); - } - } - } - - return issues; -} - -/** Removes stale dotagents-managed plugin runtime artifacts. */ -export async function prunePluginOutputs( - agentIds: string[], - plugins: PluginDeclaration[], - projectRoot: string, -): Promise { - const pruned: string[] = []; - const desiredMarketplacePaths = new Set( - marketplaceOutputs(agentIds, projectRoot, plugins).map((output) => output.filePath), - ); - for (const filePath of marketplaceOutputPaths(projectRoot)) { - if (desiredMarketplacePaths.has(filePath)) {continue;} - if (!existsSync(filePath)) {continue;} - if (!await isManagedJsonFile(filePath)) {continue;} - await rm(filePath, { force: true }); - pruned.push(filePath); - } - - const desiredGrok = new Set( - plugins - .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("grok")) - .map((plugin) => plugin.name), - ); - const grokDir = join(projectRoot, ".grok", "plugins"); - if (existsSync(grokDir)) { - const entries = await readdir(grokDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) {continue;} - if (desiredGrok.has(entry.name)) {continue;} - const path = join(grokDir, entry.name); - if (!await isManagedProjection(path)) {continue;} - await rm(path, { recursive: true, force: true }); - pruned.push(path); - } - } - - const desiredOpenCode = new Set( - plugins - .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("opencode")) - .flatMap((plugin) => opencodeModules(plugin).map((modulePath) => `${plugin.name}${extname(modulePath)}`)), - ); - const opencodeDir = join(projectRoot, ".opencode", "plugins"); - if (existsSync(opencodeDir)) { - const entries = await readdir(opencodeDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile() && !entry.isSymbolicLink()) {continue;} - if (desiredOpenCode.has(entry.name)) {continue;} - const path = join(opencodeDir, entry.name); - if (!await isManagedOpenCodeModule(path)) {continue;} - await rm(path, { force: true }); - pruned.push(path); - } - } - - const canonicalPluginDir = join(projectRoot, ".agents", "plugins"); - const desiredClaude = new Set( - plugins - .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")) - .map((plugin) => plugin.name), - ); - if (existsSync(canonicalPluginDir)) { - const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) {continue;} - if (desiredClaude.has(entry.name)) {continue;} - const path = join(canonicalPluginDir, entry.name, ".claude-plugin", "plugin.json"); - if (!existsSync(path) || !await isManagedJsonFile(path)) {continue;} - await rm(path, { force: true }); - await rmdirIfEmpty(dirname(path)); - pruned.push(path); - } - } - - const desiredCursor = new Set( - plugins - .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")) - .map((plugin) => plugin.name), - ); - if (existsSync(canonicalPluginDir)) { - const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) {continue;} - if (desiredCursor.has(entry.name)) {continue;} - const path = join(canonicalPluginDir, entry.name, ".cursor-plugin", "plugin.json"); - if (!existsSync(path) || !await isManagedJsonFile(path)) {continue;} - await rm(path, { force: true }); - await rmdirIfEmpty(dirname(path)); - pruned.push(path); - } - } - - const desiredCodex = new Set( - plugins - .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")) - .map((plugin) => plugin.name), - ); - if (existsSync(canonicalPluginDir)) { - const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) {continue;} - if (desiredCodex.has(entry.name)) {continue;} - const path = join(canonicalPluginDir, entry.name, ".codex-plugin", "plugin.json"); - if (!existsSync(path) || !await isManagedJsonFile(path)) {continue;} - await rm(path, { force: true }); - await rmdirIfEmpty(dirname(path)); - pruned.push(path); - } - } - - return pruned; -} - -function marketplaceOutputPaths(projectRoot: string): string[] { - return [ - join(projectRoot, ".agents", "plugins", "marketplace.json"), - join(projectRoot, ".claude-plugin", "marketplace.json"), - join(projectRoot, ".cursor-plugin", "marketplace.json"), - ]; -} - -function marketplaceOutputs( - agentIds: string[], - projectRoot: string, - plugins: PluginDeclaration[], -): RuntimeOutput[] { - if (plugins.length === 0) {return [];} - - const outputs: RuntimeOutput[] = []; - const claudePlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); - const cursorPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); - const codexPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); - - if (claudePlugins.length > 0) { - outputs.push({ - agent: "claude", - filePath: join(projectRoot, ".claude-plugin", "marketplace.json"), - content: stableJson(pathMarketplace(projectRoot, "dotagents", claudePlugins)), - }); - } - if (cursorPlugins.length > 0) { - outputs.push({ - agent: "cursor", - filePath: join(projectRoot, ".cursor-plugin", "marketplace.json"), - content: stableJson(pathMarketplace(projectRoot, "dotagents", cursorPlugins)), - }); - } - if (codexPlugins.length > 0) { - outputs.push({ - agent: "codex", - filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), - content: stableJson(codexMarketplace(projectRoot, "dotagents-local", codexPlugins)), - }); - } - - return outputs; -} - -function pathMarketplace( - projectRoot: string, - name: string, - plugins: PluginDeclaration[], -): Record { - return { - name, - owner: { - name: "dotagents", - }, - metadata: DOTAGENTS_METADATA, - plugins: plugins - .toSorted((a, b) => a.name.localeCompare(b.name)) - .map((plugin) => pathMarketplaceEntry(projectRoot, plugin)), - }; -} - -/** - * Claude and Cursor marketplace projections use path strings instead of Codex's - * structured local source objects, so keep this projection format separate. - */ -function pathMarketplaceEntry( - projectRoot: string, - plugin: PluginDeclaration, -): Record { - const entry: Record = { - name: plugin.name, - source: `./${relativePath(projectRoot, plugin.pluginDir)}`, - }; - const description = manifestString(plugin.manifest, "description"); - if (description) {entry["description"] = description;} - const version = manifestString(plugin.manifest, "version"); - if (version) {entry["version"] = version;} - return entry; -} - -function codexMarketplace( - projectRoot: string, - name: string, - plugins: PluginDeclaration[], -): Record { - return { - interface: { - displayName: "Dotagents Plugins", - }, - metadata: DOTAGENTS_METADATA, - name, - owner: { - name: "dotagents", - }, - plugins: plugins - .toSorted((a, b) => a.name.localeCompare(b.name)) - .map((plugin) => codexMarketplaceEntry(projectRoot, plugin)), - }; -} - -function codexMarketplaceEntry( - projectRoot: string, - plugin: PluginDeclaration, -): Record { - const entry: Record = { - category: manifestString(plugin.manifest, "category") ?? "Productivity", - name: plugin.name, - source: { - path: `./${relativePath(projectRoot, plugin.pluginDir)}`, - source: "local", - }, - }; - const description = manifestString(plugin.manifest, "description"); - if (description) {entry["description"] = description;} - const version = manifestString(plugin.manifest, "version"); - if (version) {entry["version"] = version;} - return entry; -} - -async function writeClaudeManifest( - plugin: PluginDeclaration, - warnings: PluginWriteWarning[], -): Promise { - const filePath = join(plugin.pluginDir, ".claude-plugin", "plugin.json"); - if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { - warnings.push({ - agent: "claude", - name: plugin.name, - message: `Claude plugin manifest exists and is not managed by dotagents: ${filePath}`, - }); - return false; - } - const manifest = claudeRuntimeManifest(plugin); - return writeJsonIfChanged(filePath, stableJson(manifest)); -} - -async function writeCursorManifest( - plugin: PluginDeclaration, - warnings: PluginWriteWarning[], -): Promise { - const filePath = join(plugin.pluginDir, ".cursor-plugin", "plugin.json"); - if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { - warnings.push({ - agent: "cursor", - name: plugin.name, - message: `Cursor plugin manifest exists and is not managed by dotagents: ${filePath}`, - }); - return false; - } - const manifest = cursorRuntimeManifest(plugin); - return writeJsonIfChanged(filePath, stableJson(manifest)); -} - -async function writeCodexManifest( - plugin: PluginDeclaration, - warnings: PluginWriteWarning[], -): Promise { - const filePath = join(plugin.pluginDir, ".codex-plugin", "plugin.json"); - if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { - warnings.push({ - agent: "codex", - name: plugin.name, - message: `Codex plugin manifest exists and is not managed by dotagents: ${filePath}`, - }); - return false; - } - const manifest = codexRuntimeManifest(plugin); - return writeJsonIfChanged(filePath, stableJson(manifest)); -} - -/** Builds the managed Claude manifest projection using Claude-native paths. */ -function claudeRuntimeManifest(plugin: PluginDeclaration): Record { - const manifest: Record = { - name: plugin.name, - }; - copyManifestField(plugin.manifest, manifest, "version"); - copyManifestField(plugin.manifest, manifest, "description"); - copyManifestField(plugin.manifest, manifest, "author"); - copyManifestField(plugin.manifest, manifest, "homepage"); - copyManifestField(plugin.manifest, manifest, "repository"); - copyManifestField(plugin.manifest, manifest, "license"); - copyManifestField(plugin.manifest, manifest, "keywords"); - - if (!copyRuntimeComponentField(plugin.manifest, manifest, "skills") && existsSync(join(plugin.pluginDir, "skills"))) { - manifest["skills"] = "./skills"; - } - if (!copyRuntimeComponentField(plugin.manifest, manifest, "agents") && existsSync(join(plugin.pluginDir, "agents"))) { - manifest["agents"] = "./agents"; - } - if (!copyRuntimeComponentField(plugin.manifest, manifest, "commands") && existsSync(join(plugin.pluginDir, "commands"))) { - manifest["commands"] = "./commands"; - } - if (!copyRuntimeComponentField(plugin.manifest, manifest, "hooks") && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { - manifest["hooks"] = "./hooks/hooks.json"; - } - if (!copyRuntimeComponentField(plugin.manifest, manifest, "mcpServers") && existsSync(join(plugin.pluginDir, ".mcp.json"))) { - manifest["mcpServers"] = "./.mcp.json"; - } - copyRuntimeComponentField(plugin.manifest, manifest, "lspServers"); - copyRuntimeComponentField(plugin.manifest, manifest, "monitors"); - copyRuntimeComponentField(plugin.manifest, manifest, "bin"); - const metadata = plugin.manifest["metadata"]; - manifest["metadata"] = { - ...(metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {}), - ...DOTAGENTS_METADATA, - }; - return manifest; -} - -/** Builds the managed Cursor manifest projection using Cursor-native paths. */ -function cursorRuntimeManifest(plugin: PluginDeclaration): Record { - const manifest: Record = { - name: plugin.name, - }; - copyManifestField(plugin.manifest, manifest, "version"); - copyManifestField(plugin.manifest, manifest, "description"); - copyManifestField(plugin.manifest, manifest, "author"); - copyManifestField(plugin.manifest, manifest, "homepage"); - copyManifestField(plugin.manifest, manifest, "repository"); - copyManifestField(plugin.manifest, manifest, "license"); - copyManifestField(plugin.manifest, manifest, "keywords"); - - if (!copyRuntimeComponentField(plugin.manifest, manifest, "skills") && existsSync(join(plugin.pluginDir, "skills"))) { - manifest["skills"] = "./skills"; - } - if (!copyRuntimeComponentField(plugin.manifest, manifest, "agents") && existsSync(join(plugin.pluginDir, "agents"))) { - manifest["agents"] = "./agents"; - } - if (!copyRuntimeComponentField(plugin.manifest, manifest, "commands") && existsSync(join(plugin.pluginDir, "commands"))) { - manifest["commands"] = "./commands"; - } - if (!copyRuntimeComponentField(plugin.manifest, manifest, "rules") && existsSync(join(plugin.pluginDir, "rules"))) { - manifest["rules"] = "./rules"; - } - if (!copyRuntimeComponentField(plugin.manifest, manifest, "hooks") && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { - manifest["hooks"] = "./hooks/hooks.json"; - } - const hasExplicitMcpServers = copyRuntimeComponentField(plugin.manifest, manifest, "mcpServers"); - if (!hasExplicitMcpServers && existsSync(join(plugin.pluginDir, ".mcp.json"))) { - manifest["mcpServers"] = "./.mcp.json"; - } else if (!hasExplicitMcpServers && existsSync(join(plugin.pluginDir, "mcp.json"))) { - manifest["mcpServers"] = "./mcp.json"; - } - copyRuntimeComponentField(plugin.manifest, manifest, "bin"); - const metadata = plugin.manifest["metadata"]; - manifest["metadata"] = { - ...(metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {}), - ...DOTAGENTS_METADATA, - }; - return manifest; -} - -/** Mirrors a plugin bundle into Grok's plugin directory with a managed marker. */ -async function writeGrokProjection( - projectRoot: string, - plugin: PluginDeclaration, - warnings: PluginWriteWarning[], -): Promise { - const dest = join(projectRoot, ".grok", "plugins", plugin.name); - if (existsSync(dest)) { - if (await isManagedProjection(dest)) { - if (await directoriesMatch(plugin.pluginDir, dest, new Set([".dotagents-managed"]))) { - return false; - } - await rm(dest, { recursive: true, force: true }); - } else { - warnings.push({ - agent: "grok", - name: plugin.name, - message: `Grok plugin projection exists and is not managed by dotagents: ${dest}`, - }); - return false; - } - } - - await mkdir(dirname(dest), { recursive: true }); - await cp(plugin.pluginDir, dest, { recursive: true }); - await writeFile(join(dest, ".dotagents-managed"), "Generated by dotagents. Do not edit.\n", "utf-8"); - return true; -} - -/** Writes OpenCode re-export modules for explicit or conventional plugin modules. */ -async function writeOpenCodeProjection( - projectRoot: string, - plugin: PluginDeclaration, - warnings: PluginWriteWarning[], -): Promise { - const modules = opencodeModules(plugin, warnings); - let written = 0; - for (const modulePath of modules) { - const ext = extname(modulePath); - const dest = join(projectRoot, ".opencode", "plugins", `${plugin.name}${ext}`); - if (existsSync(dest) && !await isManagedOpenCodeModule(dest)) { - warnings.push({ - agent: "opencode", - name: plugin.name, - message: `OpenCode plugin module exists and is not managed by dotagents: ${dest}`, - }); - continue; - } - - await mkdir(dirname(dest), { recursive: true }); - const moduleSpecifier = JSON.stringify(relativePath(dirname(dest), join(plugin.pluginDir, modulePath))); - const content = `// Generated by dotagents. Do not edit.\nexport { default } from ${moduleSpecifier};\n`; - if (await writeTextIfChanged(dest, content)) {written++;} - } - return written; -} - -/** Builds the managed Codex manifest projection and stamps dotagents ownership metadata. */ -function codexRuntimeManifest(plugin: PluginDeclaration): Record { - const manifest: Record = { - ...plugin.manifest, - name: plugin.name, - }; - - if (!manifest["skills"] && existsSync(join(plugin.pluginDir, "skills"))) { - manifest["skills"] = "./skills"; - } - if (!manifest["agents"] && existsSync(join(plugin.pluginDir, "agents"))) { - manifest["agents"] = "./agents"; - } - if (!manifest["commands"] && existsSync(join(plugin.pluginDir, "commands"))) { - manifest["commands"] = "./commands"; - } - if (!manifest["hooks"] && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { - manifest["hooks"] = "./hooks/hooks.json"; - } - if (!manifest["mcpServers"] && existsSync(join(plugin.pluginDir, ".mcp.json"))) { - manifest["mcpServers"] = "./.mcp.json"; - } - if (!manifest["lspServers"] && existsSync(join(plugin.pluginDir, ".lsp.json"))) { - manifest["lspServers"] = "./.lsp.json"; - } - if (!manifest["apps"] && existsSync(join(plugin.pluginDir, ".app.json"))) { - manifest["apps"] = "./.app.json"; - } - if (!manifest["interface"]) { - manifest["interface"] = codexInterface(plugin); - } - const metadata = manifest["metadata"]; - manifest["metadata"] = { - ...(metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {}), - ...DOTAGENTS_METADATA, - }; - return manifest; -} - -function codexInterface(plugin: PluginDeclaration): Record { - return { - displayName: titleCase(plugin.name), - shortDescription: manifestString(plugin.manifest, "description") ?? "", - developerName: developerName(plugin.manifest), - category: manifestString(plugin.manifest, "category") ?? "Coding", - capabilities: ["Interactive", "Write"], - }; -} - -function developerName(manifest: PluginManifest): string { - const author = manifest.author; - if (author && typeof author.name === "string") {return author.name;} - return "Unknown"; -} - -function copyManifestField(source: PluginManifest, dest: Record, key: keyof PluginManifest): void { - if (source[key] !== undefined) { - dest[key] = source[key]; - } -} - -function copyRuntimeComponentField(source: PluginManifest, dest: Record, key: keyof PluginManifest): boolean { - const value = source[key]; - if (typeof value === "string") { - dest[key] = runtimePath(value); - return true; - } - if (Array.isArray(value) && value.every((item) => typeof item === "string")) { - dest[key] = value.map(runtimePath); - return true; - } - return false; -} - -function runtimePath(value: string): string { - return value.startsWith(".") ? value : `./${value}`; -} - -function opencodeModules( - plugin: PluginDeclaration, - warnings: PluginWriteWarning[] = [], -): string[] { - const opencode = plugin.manifest.opencode; - if (opencode?.plugins) { - return opencode.plugins.filter((path) => { - if (existsSync(join(plugin.pluginDir, path))) {return true;} - warnings.push({ - agent: "opencode", - name: plugin.name, - message: `OpenCode plugin module missing: ${join(plugin.pluginDir, path)}`, - }); - return false; - }); - } - const candidates = ["opencode/plugin.ts", "opencode/plugin.js"]; - const candidate = candidates.find((path) => existsSync(join(plugin.pluginDir, path))); - return candidate ? [candidate] : []; -} - -function selectPlugins(agentIds: string[], plugins: PluginDeclaration[]): PluginDeclaration[] { - return plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).length > 0); -} - -function selectedAgentIds( - agentIds: string[], - plugin: Pick, -): string[] { - const targets = plugin.targets && plugin.targets.length > 0 - ? plugin.targets - : agentIds; - const configured = new Set(agentIds); - return [...new Set(targets)] - .filter((target) => configured.has(target)) - .filter((target) => SUPPORTED_PLUGIN_AGENT_IDS.has(target)); -} - -function targetWarnings( - agentIds: string[], - plugins: PluginDeclaration[], -): PluginWriteWarning[] { - const configured = new Set(agentIds); - const warnings: PluginWriteWarning[] = []; - for (const plugin of plugins) { - const targets = plugin.targets && plugin.targets.length > 0 - ? plugin.targets - : agentIds; - for (const target of new Set(targets)) { - if (!configured.has(target)) { - warnings.push({ - agent: target, - name: plugin.name, - message: `Plugin "${plugin.name}" targets "${target}", but "${target}" is not listed in agents.`, - }); - continue; - } - if (!SUPPORTED_PLUGIN_AGENT_IDS.has(target)) { - warnings.push({ - agent: target, - name: plugin.name, - message: `Plugin "${plugin.name}" targets "${target}", but "${target}" does not support plugin outputs.`, - }); - } - } - } - return warnings; -} - -async function writeManagedJsonOutput( - output: RuntimeOutput, - warnings: PluginWriteWarning[], -): Promise { - if (existsSync(output.filePath) && !await isManagedJsonFile(output.filePath)) { - warnings.push({ - agent: output.agent, - name: "marketplace", - message: `Plugin marketplace exists and is not managed by dotagents: ${output.filePath}`, - }); - return false; - } - return writeJsonIfChanged(output.filePath, output.content); -} - -async function writeJsonIfChanged(filePath: string, content: string): Promise { - await mkdir(dirname(filePath), { recursive: true }); - return writeTextIfChanged(filePath, content); -} - -async function writeTextIfChanged(filePath: string, content: string): Promise { - try { - if (await readFile(filePath, "utf-8") === content) {return false;} - } catch (err) { - if (!isNotFoundError(err)) {throw err;} - } - await writeFile(filePath, content, "utf-8"); - return true; -} - -/** Checks the JSON ownership marker used for marketplaces and Codex manifests. */ -async function isManagedJsonFile(filePath: string): Promise { - try { - const parsed = JSON.parse(await readFile(filePath, "utf-8")) as Record; - const metadata = parsed["metadata"]; - return !!metadata && typeof metadata === "object" && (metadata as Record)["managedBy"] === "dotagents"; - } catch { - return false; - } -} - -/** Checks Grok directory projections using the non-JSON marker file. */ -async function isManagedProjection(path: string): Promise { - return existsSync(join(path, ".dotagents-managed")); -} - -/** Checks OpenCode module projections using the generated-file header marker. */ -async function isManagedOpenCodeModule(filePath: string): Promise { - try { - return (await readFile(filePath, "utf-8")).startsWith("// Generated by dotagents."); - } catch { - return false; - } -} - -async function directoriesMatch(source: string, dest: string, ignoredNames = new Set()): Promise { - if (!existsSync(source) || !existsSync(dest)) {return false;} - - const sourceEntries = await comparableEntries(source, ignoredNames); - const destEntries = await comparableEntries(dest, ignoredNames); - if (sourceEntries.length !== destEntries.length) {return false;} - - for (const entry of sourceEntries) { - const destEntry = destEntries.find((item) => item.name === entry.name); - if (!destEntry) {return false;} - - const sourcePath = join(source, entry.name); - const destPath = join(dest, destEntry.name); - if (entry.kind !== destEntry.kind) {return false;} - if (entry.kind === "directory") { - if (!await directoriesMatch(sourcePath, destPath, ignoredNames)) {return false;} - continue; - } - if (entry.kind === "symlink") { - if (await readlink(sourcePath) !== await readlink(destPath)) {return false;} - continue; - } - if (!(await readFile(sourcePath)).equals(await readFile(destPath))) { - return false; - } - } - return true; -} - -async function comparableEntries( - dir: string, - ignoredNames: Set, -): Promise> { - const entries = await readdir(dir, { withFileTypes: true }); - const result: Array<{ name: string; kind: "directory" | "file" | "symlink" }> = []; - for (const entry of entries) { - if (ignoredNames.has(entry.name)) {continue;} - const path = join(dir, entry.name); - const stat = await lstat(path); - const kind = stat.isSymbolicLink() - ? "symlink" - : stat.isDirectory() - ? "directory" - : "file"; - result.push({ name: entry.name, kind }); - } - return result.toSorted((a, b) => a.name.localeCompare(b.name)); -} - -async function rmdirIfEmpty(dir: string): Promise { - try { - await rmdir(dir); - } catch (err) { - if (!isNotFoundError(err) && !(err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOTEMPTY")) { - throw err; - } - } -} - -function stableJson(value: unknown): string { - return `${JSON.stringify(sortJson(value), null, 2)}\n`; -} - -function sortJson(value: unknown): unknown { - if (Array.isArray(value)) {return value.map(sortJson);} - if (!value || typeof value !== "object") {return value;} - - const result: Record = {}; - const record = value as Record; - for (const key of Object.keys(record).toSorted()) { - result[key] = sortJson(record[key]); - } - return result; -} - -function manifestString(manifest: PluginManifest, key: string): string | undefined { - const value = manifest[key]; - return typeof value === "string" ? value : undefined; -} - -function titleCase(value: string): string { - return value - .split(/[-.]/) - .filter(Boolean) - .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) - .join(" "); -} - -function relativePath(from: string, to: string): string { - const path = relative(from, to).split("\\").join("/"); - return path.startsWith(".") ? path : `./${path}`; -} - -function isNotFoundError(err: unknown): boolean { - return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; -} diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index ceae800..2b3feae 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -14,7 +14,7 @@ import { getAgent } from "../../targets/registry.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { exec } from "@sentry/dotagents-lib"; import { isInPlaceSkill } from "../../utils/fs.js"; -import { isInPlacePluginSource, isSameProjectPluginConfig } from "../../agents/plugin-store.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig } from "../../plugins/store.js"; export interface DoctorCheck { name: string; diff --git a/packages/dotagents/src/cli/commands/install/agent-runtime.ts b/packages/dotagents/src/cli/commands/install/agent-runtime.ts index c0b1847..a231d3d 100644 --- a/packages/dotagents/src/cli/commands/install/agent-runtime.ts +++ b/packages/dotagents/src/cli/commands/install/agent-runtime.ts @@ -12,8 +12,8 @@ import { userSubagentResolver, writeSubagentConfigs, } from "../../../subagents/writer.js"; -import { prunePluginOutputs, writePluginOutputs } from "../../../agents/plugin-writer.js"; -import type { PluginDeclaration } from "../../../agents/plugin-store.js"; +import { prunePluginOutputs, writePluginOutputs } from "../../../plugins/runtime/writer.js"; +import type { PluginDeclaration } from "../../../plugins/store.js"; import type { SubagentDeclaration } from "../../../subagents/types.js"; /** Writes agent skill symlinks after canonical install artifacts are ready. */ diff --git a/packages/dotagents/src/cli/commands/install/gitignore.ts b/packages/dotagents/src/cli/commands/install/gitignore.ts index 4f039ec..6663f73 100644 --- a/packages/dotagents/src/cli/commands/install/gitignore.ts +++ b/packages/dotagents/src/cli/commands/install/gitignore.ts @@ -4,7 +4,7 @@ import type { Lockfile } from "../../../lockfile/schema.js"; import type { ScopeRoot } from "../../../scope.js"; import { checkRootGitignoreEntries, writeAgentsGitignore } from "../../../gitignore/writer.js"; import { isInPlaceSkill } from "../../../utils/fs.js"; -import { isInPlacePluginSource, type PluginDeclaration } from "../../../agents/plugin-store.js"; +import { isInPlacePluginSource, type PluginDeclaration } from "../../../plugins/store.js"; import type { SubagentDeclaration } from "../../../subagents/types.js"; export interface InstallGitignoreArtifacts { diff --git a/packages/dotagents/src/cli/commands/install/plugins.ts b/packages/dotagents/src/cli/commands/install/plugins.ts index 2583cc6..aea2f12 100644 --- a/packages/dotagents/src/cli/commands/install/plugins.ts +++ b/packages/dotagents/src/cli/commands/install/plugins.ts @@ -11,7 +11,7 @@ import { type PluginDeclaration, pruneInstalledPlugins, resolvePlugin, -} from "../../../agents/plugin-store.js"; +} from "../../../plugins/store.js"; import { GitError, TrustError } from "@sentry/dotagents-lib"; import { getCacheStateDir } from "../../cache.js"; import { InstallError } from "./errors.js"; diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index 36563eb..41510ef 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -13,7 +13,7 @@ import { sourcesMatch, parseOwnerRepoShorthand, isExplicitSourceSpecifier } from import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { isInPlaceSkill } from "../../utils/fs.js"; -import { isInPlacePluginSource } from "../../agents/plugin-store.js"; +import { isInPlacePluginSource } from "../../plugins/store.js"; export class RemoveError extends Error { constructor(message: string) { diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 51c0f44..d4ed196 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -224,9 +224,9 @@ describe("runSync", () => { await exec("git", ["clone", "--branch", "main", projectOrigin, bobRepo], { cwd: tmpDir }); await exec("git", ["config", "user.email", "alice@example.com"], { cwd: aliceRepo }); await exec("git", ["config", "user.name", "Alice"], { cwd: aliceRepo }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: aliceRepo }); await exec("git", ["config", "user.email", "bob@example.com"], { cwd: bobRepo }); await exec("git", ["config", "user.name", "Bob"], { cwd: bobRepo }); - await exec("git", ["config", "commit.gpgsign", "false"], { cwd: aliceRepo }); await exec("git", ["config", "commit.gpgsign", "false"], { cwd: bobRepo }); try { @@ -271,7 +271,7 @@ describe("runSync", () => { process.env["DOTAGENTS_STATE_DIR"] = previousStateDir; } } - }, 30_000); + }, 90_000); it("detects missing skills", async () => { await writeFile( diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index c8f5ddf..3a12633 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -15,8 +15,8 @@ import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolve import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../targets/hook-writer.js"; import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../subagents/writer.js"; import { loadInstalledSubagents, pruneInstalledSubagents } from "../../subagents/store.js"; -import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins, pruneInstalledPlugins } from "../../agents/plugin-store.js"; -import { prunePluginOutputs, verifyPluginOutputs, writePluginOutputs } from "../../agents/plugin-writer.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins, pruneInstalledPlugins } from "../../plugins/store.js"; +import { prunePluginOutputs, verifyPluginOutputs, writePluginOutputs } from "../../plugins/runtime/writer.js"; import { userMcpResolver } from "../../targets/paths.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; diff --git a/packages/dotagents/src/config/loader.ts b/packages/dotagents/src/config/loader.ts index 4da4cbb..6d63bfc 100644 --- a/packages/dotagents/src/config/loader.ts +++ b/packages/dotagents/src/config/loader.ts @@ -1,7 +1,8 @@ import { readFile } from "node:fs/promises"; import { parse as parseTOML } from "smol-toml"; import { agentsConfigSchema, isWildcardDep, type AgentsConfig } from "./schema.js"; -import { allAgentIds, allConfigAgentIds } from "../targets/registry.js"; +import { allAgentIds } from "../targets/registry.js"; +import { allPluginOnlyAgentIds } from "../plugins/targets.js"; import { applyDefaultRepositorySource, parseSource } from "@sentry/dotagents-lib"; export class ConfigError extends Error { @@ -36,8 +37,8 @@ export async function loadConfig(filePath: string): Promise { } // Post-parse validation: reject unknown agent IDs - const validIds = allConfigAgentIds(); const registryAgentIds = allAgentIds(); + const validIds = [...new Set([...registryAgentIds, ...allPluginOnlyAgentIds()])]; const unknown = result.data.agents.filter((id) => !validIds.includes(id)); if (unknown.length > 0) { throw new ConfigError( diff --git a/packages/dotagents/src/plugins/runtime/files.ts b/packages/dotagents/src/plugins/runtime/files.ts new file mode 100644 index 0000000..c840e2f --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/files.ts @@ -0,0 +1,53 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +export const DOTAGENTS_METADATA = { managedBy: "dotagents" }; + +/** Serializes generated plugin output with stable key ordering. */ +export function stableJson(value: unknown): string { + return `${JSON.stringify(sortJson(value), null, 2)}\n`; +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) {return value.map(sortJson);} + if (!value || typeof value !== "object") {return value;} + + const result: Record = {}; + const record = value as Record; + for (const key of Object.keys(record).toSorted()) { + result[key] = sortJson(record[key]); + } + return result; +} + +/** Writes generated JSON only when the serialized content changed. */ +export async function writeJsonIfChanged(filePath: string, content: string): Promise { + await mkdir(dirname(filePath), { recursive: true }); + return writeTextIfChanged(filePath, content); +} + +/** Writes generated text only when the content changed. */ +export async function writeTextIfChanged(filePath: string, content: string): Promise { + try { + if (await readFile(filePath, "utf-8") === content) {return false;} + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + } + await writeFile(filePath, content, "utf-8"); + return true; +} + +/** Checks the JSON ownership marker used for managed plugin outputs. */ +export async function isManagedJsonFile(filePath: string): Promise { + try { + const parsed = JSON.parse(await readFile(filePath, "utf-8")) as Record; + const metadata = parsed["metadata"]; + return !!metadata && typeof metadata === "object" && (metadata as Record)["managedBy"] === "dotagents"; + } catch { + return false; + } +} + +export function isNotFoundError(err: unknown): boolean { + return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; +} diff --git a/packages/dotagents/src/plugins/runtime/manifest-values.ts b/packages/dotagents/src/plugins/runtime/manifest-values.ts new file mode 100644 index 0000000..45d1862 --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/manifest-values.ts @@ -0,0 +1,28 @@ +import { relative } from "node:path"; +import type { PluginManifest } from "../schema.js"; + +/** Reads string-valued manifest fields for generated plugin projections. */ +export function manifestString(manifest: PluginManifest, key: string): string | undefined { + const value = manifest[key]; + return typeof value === "string" ? value : undefined; +} + +/** Normalizes manifest component paths to runtime-relative paths. */ +export function runtimePath(value: string): string { + return value.startsWith(".") ? value : `./${value}`; +} + +/** Builds a human-readable display name from a plugin package name. */ +export function titleCase(value: string): string { + return value + .split(/[-.]/) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +/** Formats generated plugin paths with POSIX separators. */ +export function relativePath(from: string, to: string): string { + const path = relative(from, to).split("\\").join("/"); + return path.startsWith(".") ? path : `./${path}`; +} diff --git a/packages/dotagents/src/plugins/runtime/manifests.ts b/packages/dotagents/src/plugins/runtime/manifests.ts new file mode 100644 index 0000000..74e1a35 --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/manifests.ts @@ -0,0 +1,221 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { PluginManifest } from "../schema.js"; +import type { PluginDeclaration } from "../store.js"; +import { DOTAGENTS_METADATA, isManagedJsonFile, stableJson, writeJsonIfChanged } from "./files.js"; +import { + manifestString, + runtimePath, + titleCase, +} from "./manifest-values.js"; +import type { PluginWriteWarning } from "./types.js"; + +/** Writes the managed Claude plugin manifest projection when safe to overwrite. */ +export async function writeClaudeManifest( + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const filePath = join(plugin.pluginDir, ".claude-plugin", "plugin.json"); + if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { + warnings.push({ + agent: "claude", + name: plugin.name, + message: `Claude plugin manifest exists and is not managed by dotagents: ${filePath}`, + }); + return false; + } + const manifest = claudeRuntimeManifest(plugin); + return writeJsonIfChanged(filePath, stableJson(manifest)); +} + +/** Writes the managed Cursor plugin manifest projection when safe to overwrite. */ +export async function writeCursorManifest( + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const filePath = join(plugin.pluginDir, ".cursor-plugin", "plugin.json"); + if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { + warnings.push({ + agent: "cursor", + name: plugin.name, + message: `Cursor plugin manifest exists and is not managed by dotagents: ${filePath}`, + }); + return false; + } + const manifest = cursorRuntimeManifest(plugin); + return writeJsonIfChanged(filePath, stableJson(manifest)); +} + +/** Writes the managed Codex plugin manifest projection when safe to overwrite. */ +export async function writeCodexManifest( + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const filePath = join(plugin.pluginDir, ".codex-plugin", "plugin.json"); + if (existsSync(filePath) && !await isManagedJsonFile(filePath)) { + warnings.push({ + agent: "codex", + name: plugin.name, + message: `Codex plugin manifest exists and is not managed by dotagents: ${filePath}`, + }); + return false; + } + const manifest = codexRuntimeManifest(plugin); + return writeJsonIfChanged(filePath, stableJson(manifest)); +} + +/** Builds the managed Claude manifest projection using Claude-native paths. */ +function claudeRuntimeManifest(plugin: PluginDeclaration): Record { + const manifest: Record = { + name: plugin.name, + }; + copyManifestField(plugin.manifest, manifest, "version"); + copyManifestField(plugin.manifest, manifest, "description"); + copyManifestField(plugin.manifest, manifest, "author"); + copyManifestField(plugin.manifest, manifest, "homepage"); + copyManifestField(plugin.manifest, manifest, "repository"); + copyManifestField(plugin.manifest, manifest, "license"); + copyManifestField(plugin.manifest, manifest, "keywords"); + + if (!copyRuntimeComponentField(plugin.manifest, manifest, "skills") && existsSync(join(plugin.pluginDir, "skills"))) { + manifest["skills"] = "./skills"; + } + if (!copyRuntimeComponentField(plugin.manifest, manifest, "agents") && existsSync(join(plugin.pluginDir, "agents"))) { + manifest["agents"] = "./agents"; + } + if (!copyRuntimeComponentField(plugin.manifest, manifest, "commands") && existsSync(join(plugin.pluginDir, "commands"))) { + manifest["commands"] = "./commands"; + } + if (!copyRuntimeComponentField(plugin.manifest, manifest, "hooks") && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { + manifest["hooks"] = "./hooks/hooks.json"; + } + if (!copyRuntimeComponentField(plugin.manifest, manifest, "mcpServers") && existsSync(join(plugin.pluginDir, ".mcp.json"))) { + manifest["mcpServers"] = "./.mcp.json"; + } + copyRuntimeComponentField(plugin.manifest, manifest, "lspServers"); + copyRuntimeComponentField(plugin.manifest, manifest, "monitors"); + copyRuntimeComponentField(plugin.manifest, manifest, "bin"); + const metadata = plugin.manifest["metadata"]; + manifest["metadata"] = { + ...(metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {}), + ...DOTAGENTS_METADATA, + }; + return manifest; +} + +/** Builds the managed Cursor manifest projection using Cursor-native paths. */ +function cursorRuntimeManifest(plugin: PluginDeclaration): Record { + const manifest: Record = { + name: plugin.name, + }; + copyManifestField(plugin.manifest, manifest, "version"); + copyManifestField(plugin.manifest, manifest, "description"); + copyManifestField(plugin.manifest, manifest, "author"); + copyManifestField(plugin.manifest, manifest, "homepage"); + copyManifestField(plugin.manifest, manifest, "repository"); + copyManifestField(plugin.manifest, manifest, "license"); + copyManifestField(plugin.manifest, manifest, "keywords"); + + if (!copyRuntimeComponentField(plugin.manifest, manifest, "skills") && existsSync(join(plugin.pluginDir, "skills"))) { + manifest["skills"] = "./skills"; + } + if (!copyRuntimeComponentField(plugin.manifest, manifest, "agents") && existsSync(join(plugin.pluginDir, "agents"))) { + manifest["agents"] = "./agents"; + } + if (!copyRuntimeComponentField(plugin.manifest, manifest, "commands") && existsSync(join(plugin.pluginDir, "commands"))) { + manifest["commands"] = "./commands"; + } + if (!copyRuntimeComponentField(plugin.manifest, manifest, "rules") && existsSync(join(plugin.pluginDir, "rules"))) { + manifest["rules"] = "./rules"; + } + if (!copyRuntimeComponentField(plugin.manifest, manifest, "hooks") && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { + manifest["hooks"] = "./hooks/hooks.json"; + } + const hasExplicitMcpServers = copyRuntimeComponentField(plugin.manifest, manifest, "mcpServers"); + if (!hasExplicitMcpServers && existsSync(join(plugin.pluginDir, ".mcp.json"))) { + manifest["mcpServers"] = "./.mcp.json"; + } else if (!hasExplicitMcpServers && existsSync(join(plugin.pluginDir, "mcp.json"))) { + manifest["mcpServers"] = "./mcp.json"; + } + copyRuntimeComponentField(plugin.manifest, manifest, "bin"); + const metadata = plugin.manifest["metadata"]; + manifest["metadata"] = { + ...(metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {}), + ...DOTAGENTS_METADATA, + }; + return manifest; +} + +/** Builds the managed Codex manifest projection and stamps dotagents ownership metadata. */ +function codexRuntimeManifest(plugin: PluginDeclaration): Record { + const manifest: Record = { + ...plugin.manifest, + name: plugin.name, + }; + + if (!manifest["skills"] && existsSync(join(plugin.pluginDir, "skills"))) { + manifest["skills"] = "./skills"; + } + if (!manifest["agents"] && existsSync(join(plugin.pluginDir, "agents"))) { + manifest["agents"] = "./agents"; + } + if (!manifest["commands"] && existsSync(join(plugin.pluginDir, "commands"))) { + manifest["commands"] = "./commands"; + } + if (!manifest["hooks"] && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { + manifest["hooks"] = "./hooks/hooks.json"; + } + if (!manifest["mcpServers"] && existsSync(join(plugin.pluginDir, ".mcp.json"))) { + manifest["mcpServers"] = "./.mcp.json"; + } + if (!manifest["lspServers"] && existsSync(join(plugin.pluginDir, ".lsp.json"))) { + manifest["lspServers"] = "./.lsp.json"; + } + if (!manifest["apps"] && existsSync(join(plugin.pluginDir, ".app.json"))) { + manifest["apps"] = "./.app.json"; + } + if (!manifest["interface"]) { + manifest["interface"] = codexInterface(plugin); + } + const metadata = manifest["metadata"]; + manifest["metadata"] = { + ...(metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {}), + ...DOTAGENTS_METADATA, + }; + return manifest; +} + +function codexInterface(plugin: PluginDeclaration): Record { + return { + displayName: titleCase(plugin.name), + shortDescription: manifestString(plugin.manifest, "description") ?? "", + developerName: developerName(plugin.manifest), + category: manifestString(plugin.manifest, "category") ?? "Coding", + capabilities: ["Interactive", "Write"], + }; +} + +function developerName(manifest: PluginManifest): string { + const author = manifest.author; + if (author && typeof author.name === "string") {return author.name;} + return "Unknown"; +} + +function copyManifestField(source: PluginManifest, dest: Record, key: keyof PluginManifest): void { + if (source[key] !== undefined) { + dest[key] = source[key]; + } +} + +function copyRuntimeComponentField(source: PluginManifest, dest: Record, key: keyof PluginManifest): boolean { + const value = source[key]; + if (typeof value === "string") { + dest[key] = runtimePath(value); + return true; + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + dest[key] = value.map(runtimePath); + return true; + } + return false; +} diff --git a/packages/dotagents/src/plugins/runtime/marketplace.ts b/packages/dotagents/src/plugins/runtime/marketplace.ts new file mode 100644 index 0000000..b20691b --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/marketplace.ts @@ -0,0 +1,128 @@ +import { join } from "node:path"; +import type { PluginDeclaration } from "../store.js"; +import { selectedAgentIds } from "../targets.js"; +import { DOTAGENTS_METADATA, stableJson } from "./files.js"; +import { manifestString, relativePath } from "./manifest-values.js"; +import type { RuntimeOutput } from "./types.js"; + +/** Lists managed plugin marketplace files that may be generated or pruned. */ +export function marketplaceOutputPaths(projectRoot: string): string[] { + return [ + join(projectRoot, ".agents", "plugins", "marketplace.json"), + join(projectRoot, ".claude-plugin", "marketplace.json"), + join(projectRoot, ".cursor-plugin", "marketplace.json"), + ]; +} + +/** Builds target-specific marketplace JSON outputs for selected plugins. */ +export function marketplaceOutputs( + agentIds: string[], + projectRoot: string, + plugins: PluginDeclaration[], +): RuntimeOutput[] { + if (plugins.length === 0) {return [];} + + const outputs: RuntimeOutput[] = []; + const claudePlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); + const cursorPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); + const codexPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); + + if (claudePlugins.length > 0) { + outputs.push({ + agent: "claude", + filePath: join(projectRoot, ".claude-plugin", "marketplace.json"), + content: stableJson(pathMarketplace(projectRoot, "dotagents", claudePlugins)), + }); + } + if (cursorPlugins.length > 0) { + outputs.push({ + agent: "cursor", + filePath: join(projectRoot, ".cursor-plugin", "marketplace.json"), + content: stableJson(pathMarketplace(projectRoot, "dotagents", cursorPlugins)), + }); + } + if (codexPlugins.length > 0) { + outputs.push({ + agent: "codex", + filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), + content: stableJson(codexMarketplace(projectRoot, "dotagents-local", codexPlugins)), + }); + } + + return outputs; +} + +function pathMarketplace( + projectRoot: string, + name: string, + plugins: PluginDeclaration[], +): Record { + return { + name, + owner: { + name: "dotagents", + }, + metadata: DOTAGENTS_METADATA, + plugins: plugins + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((plugin) => pathMarketplaceEntry(projectRoot, plugin)), + }; +} + +/** + * Claude and Cursor marketplace projections use path strings instead of Codex's + * structured local source objects, so keep this projection format separate. + */ +function pathMarketplaceEntry( + projectRoot: string, + plugin: PluginDeclaration, +): Record { + const entry: Record = { + name: plugin.name, + source: `./${relativePath(projectRoot, plugin.pluginDir)}`, + }; + const description = manifestString(plugin.manifest, "description"); + if (description) {entry["description"] = description;} + const version = manifestString(plugin.manifest, "version"); + if (version) {entry["version"] = version;} + return entry; +} + +function codexMarketplace( + projectRoot: string, + name: string, + plugins: PluginDeclaration[], +): Record { + return { + interface: { + displayName: "Dotagents Plugins", + }, + metadata: DOTAGENTS_METADATA, + name, + owner: { + name: "dotagents", + }, + plugins: plugins + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((plugin) => codexMarketplaceEntry(projectRoot, plugin)), + }; +} + +function codexMarketplaceEntry( + projectRoot: string, + plugin: PluginDeclaration, +): Record { + const entry: Record = { + category: manifestString(plugin.manifest, "category") ?? "Productivity", + name: plugin.name, + source: { + path: `./${relativePath(projectRoot, plugin.pluginDir)}`, + source: "local", + }, + }; + const description = manifestString(plugin.manifest, "description"); + if (description) {entry["description"] = description;} + const version = manifestString(plugin.manifest, "version"); + if (version) {entry["version"] = version;} + return entry; +} diff --git a/packages/dotagents/src/plugins/runtime/types.ts b/packages/dotagents/src/plugins/runtime/types.ts new file mode 100644 index 0000000..b4a2e9b --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/types.ts @@ -0,0 +1,22 @@ +export interface PluginWriteWarning { + agent: string; + name: string; + message: string; +} + +export interface PluginWriteResult { + warnings: PluginWriteWarning[]; + written: number; +} + +export interface PluginVerifyIssue { + agent: string; + name: string; + issue: string; +} + +export interface RuntimeOutput { + agent: string; + filePath: string; + content: string; +} diff --git a/packages/dotagents/src/agents/plugin-writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts similarity index 99% rename from packages/dotagents/src/agents/plugin-writer.test.ts rename to packages/dotagents/src/plugins/runtime/writer.test.ts index f558bef..ee38fa5 100644 --- a/packages/dotagents/src/agents/plugin-writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -3,12 +3,12 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { PluginDeclaration } from "./plugin-store.js"; +import type { PluginDeclaration } from "../store.js"; import { prunePluginOutputs, verifyPluginOutputs, writePluginOutputs, -} from "./plugin-writer.js"; +} from "./writer.js"; describe("plugin writer", () => { let root: string; diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts new file mode 100644 index 0000000..a1bef39 --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -0,0 +1,397 @@ +import { existsSync } from "node:fs"; +import { cp, lstat, mkdir, readdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises"; +import { dirname, extname, join } from "node:path"; +import type { PluginDeclaration } from "../store.js"; +import { selectedAgentIds, selectPlugins, targetWarnings } from "../targets.js"; +import { marketplaceOutputPaths, marketplaceOutputs } from "./marketplace.js"; +import { + type PluginVerifyIssue, + type PluginWriteResult, + type PluginWriteWarning, + type RuntimeOutput, +} from "./types.js"; +import { + isManagedJsonFile, + isNotFoundError, + writeJsonIfChanged, + writeTextIfChanged, +} from "./files.js"; +import { relativePath } from "./manifest-values.js"; +import { writeClaudeManifest, writeCodexManifest, writeCursorManifest } from "./manifests.js"; + +// Owns deterministic runtime plugin projections. Existing runtime artifacts are +// overwritten only when they carry dotagents managed metadata or a managed marker. +export type { PluginVerifyIssue, PluginWriteResult, PluginWriteWarning } from "./types.js"; + +/** Writes deterministic project-scope plugin runtime artifacts for selected agents. */ +export async function writePluginOutputs( + agentIds: string[], + plugins: PluginDeclaration[], + projectRoot: string, +): Promise { + const warnings: PluginWriteWarning[] = []; + let written = 0; + const selected = selectPlugins(agentIds, plugins); + + for (const warning of targetWarnings(agentIds, plugins)) { + warnings.push(warning); + } + + for (const output of marketplaceOutputs(agentIds, projectRoot, selected)) { + if (await writeManagedJsonOutput(output, warnings)) {written++;} + } + + for (const plugin of selected) { + const agents = selectedAgentIds(agentIds, plugin); + if (agents.includes("claude") && await writeClaudeManifest(plugin, warnings)) { + written++; + } + if (agents.includes("cursor") && await writeCursorManifest(plugin, warnings)) { + written++; + } + if (agents.includes("codex") && await writeCodexManifest(plugin, warnings)) { + written++; + } + if (agents.includes("grok") && await writeGrokProjection(projectRoot, plugin, warnings)) { + written++; + } + if (agents.includes("opencode")) { + written += await writeOpenCodeProjection(projectRoot, plugin, warnings); + } + } + + return { warnings, written }; +} + +/** Verifies that generated plugin runtime artifacts match the current declarations. */ +export async function verifyPluginOutputs( + agentIds: string[], + plugins: PluginDeclaration[], + projectRoot: string, +): Promise { + const issues: PluginVerifyIssue[] = []; + const selected = selectPlugins(agentIds, plugins); + + for (const output of marketplaceOutputs(agentIds, projectRoot, selected)) { + if (!existsSync(output.filePath)) { + issues.push({ agent: output.agent, name: "marketplace", issue: `Plugin marketplace missing: ${output.filePath}` }); + continue; + } + try { + const existing = await readFile(output.filePath, "utf-8"); + if (existing !== output.content) { + issues.push({ agent: output.agent, name: "marketplace", issue: `Plugin marketplace out of date: ${output.filePath}` }); + } + } catch { + issues.push({ agent: output.agent, name: "marketplace", issue: `Failed to read plugin marketplace: ${output.filePath}` }); + } + } + + for (const plugin of selected) { + const agents = selectedAgentIds(agentIds, plugin); + if (agents.includes("claude")) { + const filePath = join(plugin.pluginDir, ".claude-plugin", "plugin.json"); + if (!existsSync(filePath)) { + issues.push({ agent: "claude", name: plugin.name, issue: `Claude plugin manifest missing: ${filePath}` }); + } + } + if (agents.includes("cursor")) { + const filePath = join(plugin.pluginDir, ".cursor-plugin", "plugin.json"); + if (!existsSync(filePath)) { + issues.push({ agent: "cursor", name: plugin.name, issue: `Cursor plugin manifest missing: ${filePath}` }); + } + } + if (agents.includes("codex")) { + const filePath = join(plugin.pluginDir, ".codex-plugin", "plugin.json"); + if (!existsSync(filePath)) { + issues.push({ agent: "codex", name: plugin.name, issue: `Codex plugin manifest missing: ${filePath}` }); + } + } + if (agents.includes("grok")) { + const filePath = join(projectRoot, ".grok", "plugins", plugin.name); + if (!existsSync(filePath)) { + issues.push({ agent: "grok", name: plugin.name, issue: `Grok plugin projection missing: ${filePath}` }); + } + } + } + + return issues; +} + +/** Removes stale dotagents-managed plugin runtime artifacts. */ +export async function prunePluginOutputs( + agentIds: string[], + plugins: PluginDeclaration[], + projectRoot: string, +): Promise { + const pruned: string[] = []; + const desiredMarketplacePaths = new Set( + marketplaceOutputs(agentIds, projectRoot, plugins).map((output) => output.filePath), + ); + for (const filePath of marketplaceOutputPaths(projectRoot)) { + if (desiredMarketplacePaths.has(filePath)) {continue;} + if (!existsSync(filePath)) {continue;} + if (!await isManagedJsonFile(filePath)) {continue;} + await rm(filePath, { force: true }); + pruned.push(filePath); + } + + const desiredGrok = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("grok")) + .map((plugin) => plugin.name), + ); + const grokDir = join(projectRoot, ".grok", "plugins"); + if (existsSync(grokDir)) { + const entries = await readdir(grokDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) {continue;} + if (desiredGrok.has(entry.name)) {continue;} + const path = join(grokDir, entry.name); + if (!await isManagedProjection(path)) {continue;} + await rm(path, { recursive: true, force: true }); + pruned.push(path); + } + } + + const desiredOpenCode = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("opencode")) + .flatMap((plugin) => opencodeModules(plugin).map((modulePath) => `${plugin.name}${extname(modulePath)}`)), + ); + const opencodeDir = join(projectRoot, ".opencode", "plugins"); + if (existsSync(opencodeDir)) { + const entries = await readdir(opencodeDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() && !entry.isSymbolicLink()) {continue;} + if (desiredOpenCode.has(entry.name)) {continue;} + const path = join(opencodeDir, entry.name); + if (!await isManagedOpenCodeModule(path)) {continue;} + await rm(path, { force: true }); + pruned.push(path); + } + } + + const canonicalPluginDir = join(projectRoot, ".agents", "plugins"); + const desiredClaude = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")) + .map((plugin) => plugin.name), + ); + if (existsSync(canonicalPluginDir)) { + const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) {continue;} + if (desiredClaude.has(entry.name)) {continue;} + const path = join(canonicalPluginDir, entry.name, ".claude-plugin", "plugin.json"); + if (!existsSync(path) || !await isManagedJsonFile(path)) {continue;} + await rm(path, { force: true }); + await rmdirIfEmpty(dirname(path)); + pruned.push(path); + } + } + + const desiredCursor = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")) + .map((plugin) => plugin.name), + ); + if (existsSync(canonicalPluginDir)) { + const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) {continue;} + if (desiredCursor.has(entry.name)) {continue;} + const path = join(canonicalPluginDir, entry.name, ".cursor-plugin", "plugin.json"); + if (!existsSync(path) || !await isManagedJsonFile(path)) {continue;} + await rm(path, { force: true }); + await rmdirIfEmpty(dirname(path)); + pruned.push(path); + } + } + + const desiredCodex = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")) + .map((plugin) => plugin.name), + ); + if (existsSync(canonicalPluginDir)) { + const entries = await readdir(canonicalPluginDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) {continue;} + if (desiredCodex.has(entry.name)) {continue;} + const path = join(canonicalPluginDir, entry.name, ".codex-plugin", "plugin.json"); + if (!existsSync(path) || !await isManagedJsonFile(path)) {continue;} + await rm(path, { force: true }); + await rmdirIfEmpty(dirname(path)); + pruned.push(path); + } + } + + return pruned; +} + +/** Mirrors a plugin bundle into Grok's plugin directory with a managed marker. */ +async function writeGrokProjection( + projectRoot: string, + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const dest = join(projectRoot, ".grok", "plugins", plugin.name); + if (existsSync(dest)) { + if (await isManagedProjection(dest)) { + if (await directoriesMatch(plugin.pluginDir, dest, new Set([".dotagents-managed"]))) { + return false; + } + await rm(dest, { recursive: true, force: true }); + } else { + warnings.push({ + agent: "grok", + name: plugin.name, + message: `Grok plugin projection exists and is not managed by dotagents: ${dest}`, + }); + return false; + } + } + + await mkdir(dirname(dest), { recursive: true }); + await cp(plugin.pluginDir, dest, { recursive: true }); + await writeFile(join(dest, ".dotagents-managed"), "Generated by dotagents. Do not edit.\n", "utf-8"); + return true; +} + +/** Writes OpenCode re-export modules for explicit or conventional plugin modules. */ +async function writeOpenCodeProjection( + projectRoot: string, + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const modules = opencodeModules(plugin, warnings); + let written = 0; + for (const modulePath of modules) { + const ext = extname(modulePath); + const dest = join(projectRoot, ".opencode", "plugins", `${plugin.name}${ext}`); + if (existsSync(dest) && !await isManagedOpenCodeModule(dest)) { + warnings.push({ + agent: "opencode", + name: plugin.name, + message: `OpenCode plugin module exists and is not managed by dotagents: ${dest}`, + }); + continue; + } + + await mkdir(dirname(dest), { recursive: true }); + const moduleSpecifier = JSON.stringify(relativePath(dirname(dest), join(plugin.pluginDir, modulePath))); + const content = `// Generated by dotagents. Do not edit.\nexport { default } from ${moduleSpecifier};\n`; + if (await writeTextIfChanged(dest, content)) {written++;} + } + return written; +} + +function opencodeModules( + plugin: PluginDeclaration, + warnings: PluginWriteWarning[] = [], +): string[] { + const opencode = plugin.manifest.opencode; + if (opencode?.plugins) { + return opencode.plugins.filter((path) => { + if (existsSync(join(plugin.pluginDir, path))) {return true;} + warnings.push({ + agent: "opencode", + name: plugin.name, + message: `OpenCode plugin module missing: ${join(plugin.pluginDir, path)}`, + }); + return false; + }); + } + const candidates = ["opencode/plugin.ts", "opencode/plugin.js"]; + const candidate = candidates.find((path) => existsSync(join(plugin.pluginDir, path))); + return candidate ? [candidate] : []; +} + +async function writeManagedJsonOutput( + output: RuntimeOutput, + warnings: PluginWriteWarning[], +): Promise { + if (existsSync(output.filePath) && !await isManagedJsonFile(output.filePath)) { + warnings.push({ + agent: output.agent, + name: "marketplace", + message: `Plugin marketplace exists and is not managed by dotagents: ${output.filePath}`, + }); + return false; + } + return writeJsonIfChanged(output.filePath, output.content); +} + +/** Checks Grok directory projections using the non-JSON marker file. */ +async function isManagedProjection(path: string): Promise { + return existsSync(join(path, ".dotagents-managed")); +} + +/** Checks OpenCode module projections using the generated-file header marker. */ +async function isManagedOpenCodeModule(filePath: string): Promise { + try { + return (await readFile(filePath, "utf-8")).startsWith("// Generated by dotagents."); + } catch { + return false; + } +} + +async function directoriesMatch(source: string, dest: string, ignoredNames = new Set()): Promise { + if (!existsSync(source) || !existsSync(dest)) {return false;} + + const sourceEntries = await comparableEntries(source, ignoredNames); + const destEntries = await comparableEntries(dest, ignoredNames); + if (sourceEntries.length !== destEntries.length) {return false;} + + for (const entry of sourceEntries) { + const destEntry = destEntries.find((item) => item.name === entry.name); + if (!destEntry) {return false;} + + const sourcePath = join(source, entry.name); + const destPath = join(dest, destEntry.name); + if (entry.kind !== destEntry.kind) {return false;} + if (entry.kind === "directory") { + if (!await directoriesMatch(sourcePath, destPath, ignoredNames)) {return false;} + continue; + } + if (entry.kind === "symlink") { + if (await readlink(sourcePath) !== await readlink(destPath)) {return false;} + continue; + } + if (!(await readFile(sourcePath)).equals(await readFile(destPath))) { + return false; + } + } + return true; +} + +async function comparableEntries( + dir: string, + ignoredNames: Set, +): Promise> { + const entries = await readdir(dir, { withFileTypes: true }); + const result: Array<{ name: string; kind: "directory" | "file" | "symlink" }> = []; + for (const entry of entries) { + if (ignoredNames.has(entry.name)) {continue;} + const path = join(dir, entry.name); + const stat = await lstat(path); + const kind = stat.isSymbolicLink() + ? "symlink" + : stat.isDirectory() + ? "directory" + : "file"; + result.push({ name: entry.name, kind }); + } + return result.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +async function rmdirIfEmpty(dir: string): Promise { + try { + await rmdir(dir); + } catch (err) { + if (!isNotFoundError(err) && !(err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOTEMPTY")) { + throw err; + } + } +} diff --git a/packages/dotagents/src/agents/plugin-schema.test.ts b/packages/dotagents/src/plugins/schema.test.ts similarity index 99% rename from packages/dotagents/src/agents/plugin-schema.test.ts rename to packages/dotagents/src/plugins/schema.test.ts index 9726e14..b482399 100644 --- a/packages/dotagents/src/agents/plugin-schema.test.ts +++ b/packages/dotagents/src/plugins/schema.test.ts @@ -4,7 +4,7 @@ import { parsePluginMarketplace, pluginManifestSchema, pluginMarketplaceSchema, -} from "./plugin-schema.js"; +} from "./schema.js"; describe("plugin manifest schema", () => { it("accepts known fields and preserves extension fields", () => { diff --git a/packages/dotagents/src/agents/plugin-schema.ts b/packages/dotagents/src/plugins/schema.ts similarity index 100% rename from packages/dotagents/src/agents/plugin-schema.ts rename to packages/dotagents/src/plugins/schema.ts diff --git a/packages/dotagents/src/agents/plugin-store.test.ts b/packages/dotagents/src/plugins/store.test.ts similarity index 98% rename from packages/dotagents/src/agents/plugin-store.test.ts rename to packages/dotagents/src/plugins/store.test.ts index 54f8710..68f5942 100644 --- a/packages/dotagents/src/agents/plugin-store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; -import { isSameProjectPluginConfig, lockEntryForPlugin, type ResolvedPlugin } from "./plugin-store.js"; +import { isSameProjectPluginConfig, lockEntryForPlugin, type ResolvedPlugin } from "./store.js"; describe("plugin store", () => { it("preserves an empty resolved path for root git plugins", () => { diff --git a/packages/dotagents/src/agents/plugin-store.ts b/packages/dotagents/src/plugins/store.ts similarity index 99% rename from packages/dotagents/src/agents/plugin-store.ts rename to packages/dotagents/src/plugins/store.ts index 13dcea2..1de9acd 100644 --- a/packages/dotagents/src/agents/plugin-store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -20,7 +20,7 @@ import { parsePluginMarketplace, type MarketplacePluginEntry, type PluginManifest, -} from "./plugin-schema.js"; +} from "./schema.js"; // Owns plugin source discovery and installation into the project cache. // Resolved sources are never allowed to live inside the same project's diff --git a/packages/dotagents/src/plugins/targets.ts b/packages/dotagents/src/plugins/targets.ts new file mode 100644 index 0000000..99c5f3e --- /dev/null +++ b/packages/dotagents/src/plugins/targets.ts @@ -0,0 +1,67 @@ +import type { PluginDeclaration } from "./store.js"; +import type { PluginWriteWarning } from "./runtime/types.js"; + +const PLUGIN_ONLY_AGENT_IDS = ["grok"]; +const PLUGIN_AGENT_IDS = ["claude", "cursor", "codex", "grok", "opencode"]; +const SUPPORTED_PLUGIN_AGENT_IDS = new Set(allPluginAgentIds()); + +/** Returns agent IDs accepted in agents.toml only for plugin runtime output. */ +export function allPluginOnlyAgentIds(): string[] { + return PLUGIN_ONLY_AGENT_IDS; +} + +/** Returns runtime IDs that plugin declarations may target. */ +export function allPluginAgentIds(): string[] { + return PLUGIN_AGENT_IDS; +} + +/** Filters plugins to those selected by configured agents and plugin targets. */ +export function selectPlugins(agentIds: string[], plugins: PluginDeclaration[]): PluginDeclaration[] { + return plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).length > 0); +} + +/** Resolves configured agent ids that should receive outputs for a plugin. */ +export function selectedAgentIds( + agentIds: string[], + plugin: Pick, +): string[] { + const targets = plugin.targets && plugin.targets.length > 0 + ? plugin.targets + : agentIds; + const configured = new Set(agentIds); + return [...new Set(targets)] + .filter((target) => configured.has(target)) + .filter((target) => SUPPORTED_PLUGIN_AGENT_IDS.has(target)); +} + +/** Reports plugin target declarations that cannot produce runtime outputs. */ +export function targetWarnings( + agentIds: string[], + plugins: PluginDeclaration[], +): PluginWriteWarning[] { + const configured = new Set(agentIds); + const warnings: PluginWriteWarning[] = []; + for (const plugin of plugins) { + const targets = plugin.targets && plugin.targets.length > 0 + ? plugin.targets + : agentIds; + for (const target of new Set(targets)) { + if (!configured.has(target)) { + warnings.push({ + agent: target, + name: plugin.name, + message: `Plugin "${plugin.name}" targets "${target}", but "${target}" is not listed in agents.`, + }); + continue; + } + if (!SUPPORTED_PLUGIN_AGENT_IDS.has(target)) { + warnings.push({ + agent: target, + name: plugin.name, + message: `Plugin "${plugin.name}" targets "${target}", but "${target}" does not support plugin outputs.`, + }); + } + } + } + return warnings; +} diff --git a/packages/dotagents/src/subagents/writer.ts b/packages/dotagents/src/subagents/writer.ts index c69ff8d..6f77edc 100644 --- a/packages/dotagents/src/subagents/writer.ts +++ b/packages/dotagents/src/subagents/writer.ts @@ -6,6 +6,8 @@ import { hasDotagentsMarkdownSubagentMarker, hasDotagentsTomlSubagentMarker } fr import { generatedSubagentIdentity, readSubagentFileIdentity } from "./identity.js"; import type { SubagentConfigSpec, SubagentDeclaration } from "./types.js"; +// Owns runtime-specific subagent projection. Managed markers protect generated +// files, while identity checks prevent overwriting user-authored subagents. export interface SubagentResolvedTarget { dirPath: string; } @@ -38,18 +40,21 @@ interface DesiredDir { files: Set; } +/** Resolves project-scope runtime subagent directories relative to a project root. */ export function projectSubagentResolver(projectRoot: string): SubagentTargetResolver { return (_id: string, spec: SubagentConfigSpec) => ({ dirPath: join(projectRoot, spec.projectDir), }); } +/** Resolves user-scope runtime subagent directories from each target definition. */ export function userSubagentResolver(): SubagentTargetResolver { return (_id: string, spec: SubagentConfigSpec) => ({ dirPath: spec.userDir, }); } +/** Writes managed runtime subagent configs for all configured target agents. */ export async function writeSubagentConfigs( agentIds: string[], subagents: SubagentDeclaration[], @@ -122,6 +127,7 @@ export async function writeSubagentConfigs( return { warnings, written }; } +/** Prunes managed runtime subagent files that are no longer desired. */ export async function pruneSubagentConfigs( agentIds: string[], desiredSubagents: Pick[], @@ -130,6 +136,7 @@ export async function pruneSubagentConfigs( return pruneManagedFiles(initDesiredDirs(agentIds, desiredSubagents, resolveTarget)); } +/** Verifies that managed runtime subagent files exist and still match desired output. */ export async function verifySubagentConfigs( agentIds: string[], subagents: SubagentDeclaration[], diff --git a/packages/dotagents/src/targets/registry.ts b/packages/dotagents/src/targets/registry.ts index 754ef66..8bffe05 100644 --- a/packages/dotagents/src/targets/registry.ts +++ b/packages/dotagents/src/targets/registry.ts @@ -6,8 +6,6 @@ import vscode from "./definitions/vscode.js"; import opencode from "./definitions/opencode.js"; const ALL_AGENTS: AgentDefinition[] = [claude, cursor, codex, vscode, opencode]; -const PLUGIN_ONLY_AGENT_IDS = ["grok"]; -const PLUGIN_AGENT_IDS = ["claude", "cursor", "codex", "grok", "opencode"]; const AGENT_REGISTRY = new Map( ALL_AGENTS.map((a) => [a.id, a]), @@ -21,16 +19,6 @@ export function allAgentIds(): string[] { return [...AGENT_REGISTRY.keys()]; } -/** Returns agent IDs accepted in agents.toml, including plugin-only targets. */ -export function allConfigAgentIds(): string[] { - return [...new Set([...allAgentIds(), ...PLUGIN_ONLY_AGENT_IDS])]; -} - -/** Returns runtime IDs that plugin declarations may target. */ -export function allPluginAgentIds(): string[] { - return PLUGIN_AGENT_IDS; -} - export function allAgents(): AgentDefinition[] { return ALL_AGENTS; } diff --git a/specs/SPEC.md b/specs/SPEC.md index 69bbac8..bc5cec4 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -854,6 +854,11 @@ dotagents/ store.ts # Canonical installed subagent loading/writing writer.ts # Runtime subagent config generation format.ts # Subagent serialization and managed markers + plugins/ + schema.ts # Plugin manifest schema + store.ts # Canonical installed plugin loading/writing + targets.ts # Plugin target selection and validation + runtime/ # Target-specific plugin runtime projection agents/ index.ts # Compatibility re-export barrel config/ # agents.toml schema, loader, writer From af170f56388bc76b40bbdd6e6ebc836b73b4fc85 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 11:13:29 -0700 Subject: [PATCH 15/35] fix(dotagents): Address plugin review feedback Skip unsupported marketplace extension sources during plugin discovery while preserving fallback discovery paths. Validate frozen wildcard skill names before repository resolution and keep declared OpenCode projections during prune. Co-Authored-By: Codex --- .../src/cli/commands/install.test.ts | 60 +++++++++++++++++-- .../src/cli/commands/install/skills.ts | 8 ++- .../src/plugins/runtime/writer.test.ts | 17 ++++++ .../dotagents/src/plugins/runtime/writer.ts | 25 ++++---- packages/dotagents/src/plugins/store.test.ts | 47 ++++++++++++++- packages/dotagents/src/plugins/store.ts | 6 +- 6 files changed, 140 insertions(+), 23 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index d5ef435..d0b5aff 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -662,7 +662,7 @@ source = "path:plugin-source" expect(installedManifest["description"]).toBe("Canonical marketplace plugin"); }); - it("rejects unsupported marketplace source objects instead of guessing local paths", async () => { + it("skips unsupported marketplace source objects during plugin discovery", async () => { const sourceRoot = join(projectRoot, "plugin-source"); await mkdir(sourceRoot, { recursive: true }); await writeFile( @@ -674,13 +674,31 @@ source = "path:plugin-source" name: "review-tools", source: { source: "github", - path: "plugins/review-tools", + path: "plugins/marketplace-review-tools", repo: "org/review-tools", }, }, ], }, null, 2), ); + const marketplaceOnlyDir = join(sourceRoot, "plugins", "marketplace-review-tools"); + await mkdir(marketplaceOnlyDir, { recursive: true }); + await writeFile( + join(marketplaceOnlyDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Marketplace-only plugin", + }, null, 2), + ); + const pluginDir = join(sourceRoot, "plugins", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Fallback local plugin", + }, null, 2), + ); await writeFile( join(projectRoot, "agents.toml"), `version = 1 @@ -693,8 +711,13 @@ source = "path:plugin-source" ); const scope = resolveScope("project", projectRoot); - await expect(runInstall({ scope })).rejects.toThrow( - /Marketplace source for plugin "review-tools" is not a supported local source/, + await runInstall({ scope }); + + const installedManifest = JSON.parse( + await readFile(join(projectRoot, ".agents", "plugins", "review-tools", "plugin.json"), "utf-8"), + ) as Record; + expect(installedManifest["description"]).toBe( + "Fallback local plugin", ); }); @@ -1557,6 +1580,35 @@ path = "reviewer.md" expect(result.installed).toContain("review"); }); + it("rejects unsafe skill names from frozen wildcard lockfiles", async () => { + await mkdir(repoDir, { recursive: true }); + await initTestGitRepo(repoDir); + await mkdir(join(repoDir, "evil"), { recursive: true }); + await writeFile(join(repoDir, "evil", "SKILL.md"), SKILL_MD("../outside")); + await exec("git", ["add", "."], { cwd: repoDir }); + await exec("git", ["commit", "-m", "initial"], { cwd: repoDir }); + repoInitialized = true; + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1\n\n[[skills]]\nname = "*"\nsource = "git:${repoDir}"\n`, + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + "../outside": { + source: `git:${repoDir}`, + }, + }, + subagents: {}, + plugins: {}, + }); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope, frozen: true })).rejects.toThrow(/Invalid skill name/); + expect(existsSync(join(projectRoot, ".agents", "outside"))).toBe(false); + }); + it("wildcard-expanded skills are gitignored", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/install/skills.ts b/packages/dotagents/src/cli/commands/install/skills.ts index cca1e75..694a774 100644 --- a/packages/dotagents/src/cli/commands/install/skills.ts +++ b/packages/dotagents/src/cli/commands/install/skills.ts @@ -1,4 +1,4 @@ -import { join, resolve } from "node:path"; +import { resolve } from "node:path"; import { mkdir, rm } from "node:fs/promises"; import { isWildcardDep, type AgentsConfig, type RepositorySource, type SkillDependency } from "../../../config/schema.js"; import type { Lockfile, LockedSkill } from "../../../lockfile/schema.js"; @@ -176,6 +176,11 @@ export async function installSkills( ); validateTrustedSource(sourceForTrust, config.trust); + const destDir = managedSkillPath(scope.skillsDir, name); + if (!destDir) { + throw new InstallError(`Invalid skill name "${name}" in install plan.`); + } + let resolved: ResolvedSkill; if (item.resolved) { resolved = item.resolved; @@ -196,7 +201,6 @@ export async function installSkills( } } - const destDir = join(scope.skillsDir, name); if (resolve(resolved.skillDir) !== resolve(destDir)) { await copyDir(resolved.skillDir, destDir); } diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index ee38fa5..2cbc51c 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -342,6 +342,23 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(false); }); + it("keeps declared OpenCode modules during prune even when the source is missing", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { opencode: { plugins: ["opencode/missing.ts"] } }, + }); + await mkdir(join(root, ".opencode", "plugins"), { recursive: true }); + await writeFile( + join(root, ".opencode", "plugins", "alpha-tools.ts"), + `// Generated by dotagents. Do not edit.\nexport { default } from "../missing";\n`, + "utf-8", + ); + + const pruned = await prunePluginOutputs(["opencode"], [alpha], root); + + expect(pruned).toEqual([]); + expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(true); + }); + it("prunes stale managed runtime plugin outputs", async () => { const alpha = await plugin("alpha-tools", { manifest: { opencode: { plugins: ["opencode/plugin.ts"] } }, diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index a1bef39..8984fb3 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -157,7 +157,7 @@ export async function prunePluginOutputs( const desiredOpenCode = new Set( plugins .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("opencode")) - .flatMap((plugin) => opencodeModules(plugin).map((modulePath) => `${plugin.name}${extname(modulePath)}`)), + .flatMap((plugin) => desiredOpenCodeModules(plugin).map((modulePath) => `${plugin.name}${extname(modulePath)}`)), ); const opencodeDir = join(projectRoot, ".opencode", "plugins"); if (existsSync(opencodeDir)) { @@ -291,17 +291,22 @@ function opencodeModules( plugin: PluginDeclaration, warnings: PluginWriteWarning[] = [], ): string[] { + return desiredOpenCodeModules(plugin).filter((path) => { + if (existsSync(join(plugin.pluginDir, path))) {return true;} + warnings.push({ + agent: "opencode", + name: plugin.name, + message: `OpenCode plugin module missing: ${join(plugin.pluginDir, path)}`, + }); + return false; + }); +} + +/** Returns declared OpenCode modules without existence checks so prune keeps desired managed outputs. */ +function desiredOpenCodeModules(plugin: PluginDeclaration): string[] { const opencode = plugin.manifest.opencode; if (opencode?.plugins) { - return opencode.plugins.filter((path) => { - if (existsSync(join(plugin.pluginDir, path))) {return true;} - warnings.push({ - agent: "opencode", - name: plugin.name, - message: `OpenCode plugin module missing: ${join(plugin.pluginDir, path)}`, - }); - return false; - }); + return opencode.plugins; } const candidates = ["opencode/plugin.ts", "opencode/plugin.js"]; const candidate = candidates.find((path) => existsSync(join(plugin.pluginDir, path))); diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 68f5942..56b4345 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -1,8 +1,8 @@ -import { mkdtemp, mkdir } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; -import { isSameProjectPluginConfig, lockEntryForPlugin, type ResolvedPlugin } from "./store.js"; +import { isSameProjectPluginConfig, lockEntryForPlugin, resolvePlugin, type ResolvedPlugin } from "./store.js"; describe("plugin store", () => { it("preserves an empty resolved path for root git plugins", () => { @@ -55,4 +55,47 @@ describe("plugin store", () => { projectRoot, )).toBe(true); }); + + it("skips unsupported marketplace sources during discovery", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const pluginDir = join(projectRoot, "plugins", "review-tools"); + const marketplaceOnlyDir = join(projectRoot, "plugins", "marketplace-review-tools"); + await mkdir(pluginDir, { recursive: true }); + await mkdir(marketplaceOnlyDir, { recursive: true }); + await writeFile( + join(projectRoot, "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + plugins: [ + { + name: "review-tools", + source: { source: "github", path: "plugins/marketplace-review-tools" }, + }, + ], + }), + "utf-8", + ); + await writeFile( + join(marketplaceOnlyDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Marketplace-only plugin" }), + "utf-8", + ); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Fallback local plugin" }), + "utf-8", + ); + + const resolved = await resolvePlugin( + { name: "review-tools", source: "path:." }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + + expect(resolved.plugin.pluginDir).toBe(pluginDir); + expect(resolved.plugin.manifest.description).toBe("Fallback local plugin"); + } 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 1de9acd..e277251 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -330,7 +330,7 @@ async function discoverFromMarketplaces( const path = localMarketplacePath(entry.source); if (!path) { - throw new Error(`Marketplace source for plugin "${name}" is not a supported local source: ${marketplaceSourceLabel(entry.source)}`); + continue; } const marketplaceRoot = dirname(filePath); @@ -498,10 +498,6 @@ function localMarketplacePath(source: MarketplacePluginEntry["source"]): string return null; } -function marketplaceSourceLabel(source: MarketplacePluginEntry["source"]): string { - return typeof source === "string" ? source : JSON.stringify(source); -} - function stripDotSlash(path: string): string { return path.replace(/^\.\//, ""); } From 1da2f0d3a838d2849604066300ddc0adb7114365 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 29 Jun 2026 18:37:57 -0700 Subject: [PATCH 16/35] fix(dotagents): Omit Claude plugin agents Claude Code validation rejects the agents field in plugin manifests. Keep canonical plugin agents available for runtimes that support them, but omit the field from generated Claude plugin manifests. Add regression coverage with a conventional agents directory and document the current Claude projection limit. Co-Authored-By: OpenAI Codex --- .../src/plugins/runtime/manifests.ts | 3 -- .../src/plugins/runtime/writer.test.ts | 7 +++-- skills/dotagents-qa/references/claude.md | 28 +++++++++++++++++++ .../dotagents-qa/references/plugin-runtime.md | 5 ++-- specs/plugins.md | 2 +- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/dotagents/src/plugins/runtime/manifests.ts b/packages/dotagents/src/plugins/runtime/manifests.ts index 74e1a35..b412e78 100644 --- a/packages/dotagents/src/plugins/runtime/manifests.ts +++ b/packages/dotagents/src/plugins/runtime/manifests.ts @@ -80,9 +80,6 @@ function claudeRuntimeManifest(plugin: PluginDeclaration): Record { const pluginDir = join(root, ".agents", "plugins", name); await mkdir(join(pluginDir, "skills"), { recursive: true }); await mkdir(join(pluginDir, "commands"), { recursive: true }); + await mkdir(join(pluginDir, "agents"), { recursive: true }); return { name, source: `path:.agents/plugins/${name}`, @@ -127,11 +128,13 @@ describe("plugin writer", () => { const cursorManifest = JSON.parse(await readFile(join(root, ".agents", "plugins", "alpha-tools", ".cursor-plugin", "plugin.json"), "utf-8")) as Record; expect(cursorManifest["skills"]).toBe("./skills"); expect(cursorManifest["commands"]).toBe("./commands"); + expect(cursorManifest["agents"]).toBe("./agents"); expect(cursorManifest["metadata"]).toEqual({ managedBy: "dotagents" }); const codexManifest = JSON.parse(await readFile(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"), "utf-8")) as Record; expect(codexManifest["skills"]).toBe("./skills"); expect(codexManifest["commands"]).toBe("./commands"); + expect(codexManifest["agents"]).toBe("./agents"); expect(codexManifest["interface"]).toEqual({ capabilities: ["Interactive", "Write"], category: "Coding", @@ -143,7 +146,7 @@ describe("plugin writer", () => { expect(await verifyPluginOutputs(["cursor", "codex", "claude"], [beta, alpha], root)).toEqual([]); }); - it("projects explicit Claude and Cursor component paths before conventional discovery", async () => { + it("projects explicit runtime component paths before conventional discovery", async () => { const alpha = await plugin("alpha-tools", { manifest: { agents: "custom-agents", @@ -160,7 +163,7 @@ describe("plugin writer", () => { expect(result.warnings).toEqual([]); expect(result.written).toBe(4); const claudeManifest = JSON.parse(await readFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), "utf-8")) as Record; - expect(claudeManifest["agents"]).toBe("./custom-agents"); + expect(claudeManifest["agents"]).toBeUndefined(); expect(claudeManifest["commands"]).toEqual(["./cmds/review.md"]); expect(claudeManifest["hooks"]).toBe("./config/hooks.json"); expect(claudeManifest["mcpServers"]).toBe("./config/mcp.json"); diff --git a/skills/dotagents-qa/references/claude.md b/skills/dotagents-qa/references/claude.md index b6283fd..bfbba6f 100644 --- a/skills/dotagents-qa/references/claude.md +++ b/skills/dotagents-qa/references/claude.md @@ -22,6 +22,34 @@ DOTAGENTS_HOME="$TMP/user-home" Then assert Claude runtime files under `$HOME/.claude/agents/` and user skills under `$DOTAGENTS_HOME/skills/`. +## Plugin Checks + +Use these checks when a branch affects plugin output for Claude. Run them inside +the Docker QA container, against a retained QA project: + +```bash +node skills/dotagents-qa/scripts/qa-example.mjs plugin-claude --keep +``` + +The task installs the full example, asserts the generated files, then runs +`claude plugin validate` against the generated plugin bundle and marketplace. +The first validation proves the generated native plugin manifest is acceptable +to Claude Code. The second proves the generated marketplace points at a valid +plugin bundle. + +Claude Code 2.1.x rejects an `agents` field in plugin manifests. dotagents +therefore omits plugin agents from the generated Claude manifest even when the +canonical bundle contains an `agents/` directory for other runtimes. + +Expected warning today: Claude Code accepts the files but warns that +`metadata.managedBy` is unknown. That warning is acceptable because dotagents +uses the marker for overwrite protection and Claude ignores unknown metadata. + +If Claude reports `No manifest found in directory`, verify the plugin bundle +contains `.claude-plugin/plugin.json`; the marketplace alone is not enough. If +Claude reports `plugins.0.source: Invalid input`, verify marketplace entries +use relative string sources like `"./.agents/plugins/qa-tools"`. + ## Runtime Proof There is no cheap dry-run in this QA skill that proves Claude Code loads custom agents. Do not claim Claude runtime discovery from file-level checks alone. diff --git a/skills/dotagents-qa/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md index e996ab7..397f441 100644 --- a/skills/dotagents-qa/references/plugin-runtime.md +++ b/skills/dotagents-qa/references/plugin-runtime.md @@ -48,7 +48,9 @@ Expected evidence: - `plugin install` succeeds at local scope - `plugin list --json` shows `enabled: true` - `plugin details qa-tools` lists the generated bundle's available components, - such as plugin skills, commands, and agents in the checked-in fixture + such as plugin skills and commands in the checked-in fixture. Claude Code + 2.1.x rejects `agents` in plugin manifests, so dotagents does not project + plugin agents into the Claude native manifest. Manual final check when authenticated: @@ -56,7 +58,6 @@ Manual final check when authenticated: - Run `/reload-plugins` - Check `/help` or plugin UI for `/qa-tools:plugin-qa` - Invoke the plugin skill and confirm it returns `DOTAGENTS_PLUGIN_QA_FIXTURE` -- Check `/agents` for `plugin-reviewer` when plugin agents are present ## Codex diff --git a/specs/plugins.md b/specs/plugins.md index 6961a3a..415ae44 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -194,7 +194,7 @@ Input and matching-runtime output should use the same native format where possib | Runtime | Native Manifest | Native Plugin Roots | Components from Docs | Notes | |---------|-----------------|---------------------|----------------------|-------| -| Claude Code | `.claude-plugin/plugin.json` | marketplace installs, `--plugin-dir`, and skills-directory plugins | skills, commands, agents, hooks, `.mcp.json`, `.lsp.json`, monitors, `bin/`, `settings.json` | Plugin skills are namespaced as `/plugin-name:skill-name`; components live at plugin root, not under `.claude-plugin/`. | +| Claude Code | `.claude-plugin/plugin.json` | marketplace installs, `--plugin-dir`, and skills-directory plugins | skills, commands, hooks, `.mcp.json`, `.lsp.json`, monitors, `bin/`, `settings.json` | Plugin skills are namespaced as `/plugin-name:skill-name`; components live at plugin root, not under `.claude-plugin/`. The current Claude Code validator rejects `agents` in plugin manifests, so dotagents does not project plugin agents into Claude manifests. | | Cursor | `.cursor-plugin/plugin.json` | marketplace installs and `~/.cursor/plugins/local/` for local testing | rules, skills, agents, commands, hooks, `mcp.json`, assets, scripts | Manifest component paths replace default discovery for that component. Multi-plugin repos use `.cursor-plugin/marketplace.json`. | | Codex | `.codex-plugin/plugin.json` | repo/user marketplaces under `.agents/plugins/marketplace.json` and plugin cache installs | skills, hooks, `.app.json`, `.mcp.json`, assets | Published plugins commonly need rich `interface` metadata. Codex sets `PLUGIN_ROOT` and `PLUGIN_DATA`, plus Claude-compatible plugin env vars. | | Grok Build | Claude-compatible plugin directories plus `.grok/plugins/` and marketplaces | `./.grok/plugins/`, `~/.grok/plugins/`, marketplace installs, configured plugin paths, `--plugin-dir` | skills, agents, hooks, MCP servers, LSP servers | Docs state Grok automatically reads Claude Code marketplaces, plugins, skills, MCPs, agents, hooks, and `.claude/rules/` alongside `.grok/`. | From 9e1fff0e100463e8427cbd8353429f5c5e1b4f4d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 29 Jun 2026 18:38:45 -0700 Subject: [PATCH 17/35] fix(dotagents): Project plugin components for OpenCode and Pi Expose dotagents plugin bundle skills and Markdown agents through OpenCode native component directories instead of generating JavaScript plugin modules. Project Pi-supported plugin skills into .agents/skills so Pi can consume the same bundles through its supported surface. Update gitignore generation, QA fixtures, docs, and regression tests for the corrected runtime behavior. Co-Authored-By: GPT-5 Codex --- README.md | 8 +- docs/public/llms.txt | 13 +- examples/full/agents.toml | 2 +- .../local-plugins/qa-tools/opencode/plugin.ts | 9 - .../full/local-plugins/qa-tools/plugin.json | 5 +- packages/dotagents/src/cli/commands/doctor.ts | 9 +- .../src/cli/commands/install.test.ts | 10 +- .../src/cli/commands/install/gitignore.ts | 8 +- .../dotagents/src/cli/commands/remove.test.ts | 4 +- packages/dotagents/src/cli/commands/remove.ts | 9 +- .../dotagents/src/cli/commands/sync.test.ts | 2 +- packages/dotagents/src/cli/commands/sync.ts | 8 +- packages/dotagents/src/config/loader.test.ts | 6 +- packages/dotagents/src/config/schema.test.ts | 4 +- .../dotagents/src/gitignore/writer.test.ts | 12 +- packages/dotagents/src/gitignore/writer.ts | 2 +- .../src/plugins/runtime/writer.test.ts | 184 +++++++-- .../dotagents/src/plugins/runtime/writer.ts | 363 +++++++++++++++--- packages/dotagents/src/plugins/schema.test.ts | 27 +- packages/dotagents/src/plugins/schema.ts | 10 - packages/dotagents/src/plugins/targets.ts | 4 +- skills/dotagents-qa/SKILL.md | 10 +- skills/dotagents-qa/references/opencode.md | 25 +- .../dotagents-qa/references/plugin-runtime.md | 31 +- .../dotagents-qa/references/runtime-auth.md | 6 +- skills/dotagents-qa/scripts/qa-example.mjs | 33 +- specs/SPEC.md | 13 +- specs/plugins.md | 28 +- 28 files changed, 587 insertions(+), 258 deletions(-) delete mode 100644 examples/full/local-plugins/qa-tools/opencode/plugin.ts diff --git a/README.md b/README.md index 3e1705b..19cadbb 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Shorthand (`owner/repo`) resolves to GitHub by default. Set `defaultRepositorySo The `agents` field tells dotagents which tools to configure: ```toml -agents = ["claude", "cursor", "codex", "opencode"] +agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] ``` | Agent | Config Dir | MCP Config | Hooks | Subagents | @@ -128,21 +128,21 @@ Review the current diff and return findings with file references. dotagents can also import native runtime subagent files from `.claude/agents/`, `.cursor/agents/`, `.codex/agents/*.toml`, and `.opencode/agents/`. Input and matching-runtime output use the same native format: Markdown with YAML frontmatter for Claude, Cursor, and OpenCode; TOML for Codex. Claude and Codex identify agents by `name`, Cursor can derive `name` from the filename when omitted, and OpenCode uses the filename as the agent name. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Subagent declarations intentionally cover only dependency source and runtime targets, not universal model/tool/permission behavior. -Plugins are declared with `[[plugins]]` entries. dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.claude-plugin/marketplace.json`, `.agents/plugins//.claude-plugin/plugin.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.cursor-plugin/plugin.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/plugins//`, and `.opencode/plugins/.js|ts` where supported: +Plugins are declared with `[[plugins]]` entries. dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.claude-plugin/marketplace.json`, `.agents/plugins//.claude-plugin/plugin.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.cursor-plugin/plugin.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/plugins//`, `.opencode/skills//`, `.opencode/agents/.md`, and Pi skill links under `.agents/skills//` where supported: ```toml [[plugins]] name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" -targets = ["claude", "cursor", "codex", "grok", "opencode"] +targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] ``` The canonical plugin format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a Codex-compatible marketplace baseline. Known input fields are validated, component paths must be relative filesystem paths, unknown manifest extension fields are preserved in installed bundles, marketplace extension fields are accepted but not projected, `targets` are limited to configured agents, and generated outputs are deterministic. dotagents rejects plugin sources that resolve to the same project's `.agents/plugins//` install destination, so same-repo plugins are never installed onto themselves. Plugin declarations are project-scope only for now. `dotagents --user install` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. -[Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively and needs no configuration. +[Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. Normal skills need no Pi-specific configuration; plugin bundles can target `pi` when their `skills/` components should be exposed there. ## Documentation diff --git a/docs/public/llms.txt b/docs/public/llms.txt index dff94a2..f25f7fd 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -54,7 +54,7 @@ Full example with all sections: ```toml version = 1 -agents = ["claude", "cursor", "codex", "grok", "opencode"] +agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -137,7 +137,7 @@ targets = ["claude", "codex", "opencode"] name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" -targets = ["claude", "cursor", "codex", "grok", "opencode"] +targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] ``` ### Top-level Fields @@ -146,9 +146,9 @@ targets = ["claude", "cursor", "codex", "grok", "opencode"] |-------|------|----------|---------|-------------| | `version` | integer | Yes | -- | Schema version. Always `1`. | | `defaultRepositorySource` | string | No | `github` | Host used for shorthand `owner/repo` skill sources. Valid values: `github`, `gitlab`. | -| `agents` | string[] | No | `[]` | Agent tool IDs: `claude`, `cursor`, `codex`, `grok`, `vscode`, `opencode`. Creates symlinks and config files for each where supported. | +| `agents` | string[] | No | `[]` | Agent tool IDs: `claude`, `cursor`, `codex`, `grok`, `vscode`, `opencode`, `pi`. Creates symlinks and config files for each where supported. `grok` and `pi` are plugin-only targets. | | `subagents` | table[] | No | `[]` | Custom subagent declarations. Generates runtime-specific files for Claude, Cursor, Codex, and OpenCode. | -| `plugins` | table[] | No | `[]` | Plugin declarations. Installs canonical bundles into `.agents/plugins/` and generates runtime plugin outputs for Claude, Cursor, Codex, Grok, and OpenCode where supported. | +| `plugins` | table[] | No | `[]` | Plugin declarations. Installs canonical bundles into `.agents/plugins/` and generates runtime plugin outputs for Claude, Cursor, Codex, Grok, OpenCode, and Pi skill projection where supported. | | `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install. | | `minimum_release_age_exclude` | string[] | No | `[]` | Sources that bypass the minimum release age gate. Supports org names, `org/repo`, and `org/*`. | @@ -293,9 +293,10 @@ Generated project-scope plugin outputs: - Cursor: `.cursor-plugin/marketplace.json` and `.agents/plugins//.cursor-plugin/plugin.json` - Codex: `.agents/plugins/marketplace.json` and `.agents/plugins//.codex-plugin/plugin.json` - Grok: `.grok/plugins//` managed copy -- OpenCode: `.opencode/plugins/.js|ts` re-export module when the plugin declares or contains one OpenCode module +- OpenCode: plugin `skills/` symlinked into `.opencode/skills/`; plugin Markdown `agents/` symlinked into `.opencode/agents/` +- Pi: plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target -Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. +Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok copies and OpenCode/Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Plugin declarations are project-scope only for now. `install --user` rejects `[[plugins]]` entries and `sync --user` reports them as unsupported because user-scope runtime plugin projections are not generated yet. diff --git a/examples/full/agents.toml b/examples/full/agents.toml index 9607744..a5d5067 100644 --- a/examples/full/agents.toml +++ b/examples/full/agents.toml @@ -1,5 +1,5 @@ version = 1 -agents = ["claude", "cursor", "codex", "grok", "opencode"] +agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] [[skills]] name = "review" diff --git a/examples/full/local-plugins/qa-tools/opencode/plugin.ts b/examples/full/local-plugins/qa-tools/opencode/plugin.ts deleted file mode 100644 index 5777b24..0000000 --- a/examples/full/local-plugins/qa-tools/opencode/plugin.ts +++ /dev/null @@ -1,9 +0,0 @@ -export default async () => ({ - config: (cfg) => { - cfg.command = cfg.command ?? {}; - cfg.command["dotagents-plugin-proof"] = { - description: "Proof command injected by generated OpenCode plugin projection.", - prompt: "DOTAGENTS_OPENCODE_PLUGIN_EXECUTION_PROOF", - }; - }, -}); diff --git a/examples/full/local-plugins/qa-tools/plugin.json b/examples/full/local-plugins/qa-tools/plugin.json index 4b64353..f5a3029 100644 --- a/examples/full/local-plugins/qa-tools/plugin.json +++ b/examples/full/local-plugins/qa-tools/plugin.json @@ -6,8 +6,5 @@ "author": { "name": "dotagents" }, - "keywords": ["qa", "plugins"], - "opencode": { - "plugins": ["opencode/plugin.ts"] - } + "keywords": ["qa", "plugins"] } diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index 2b3feae..ec55f3f 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -14,7 +14,8 @@ import { getAgent } from "../../targets/registry.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { exec } from "@sentry/dotagents-lib"; import { isInPlaceSkill } from "../../utils/fs.js"; -import { isInPlacePluginSource, isSameProjectPluginConfig } from "../../plugins/store.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins } from "../../plugins/store.js"; +import { projectedPiSkillNames } from "../../plugins/runtime/writer.js"; export interface DoctorCheck { name: string; @@ -158,9 +159,13 @@ export async function runDoctor(opts: DoctorOptions): Promise { status: "warn", message: ".agents/.gitignore is missing. Run 'npx @sentry/dotagents install' or 'npx @sentry/dotagents sync' to regenerate.", fix: async () => { + const installedPlugins = await loadInstalledPlugins( + scope.pluginsDir, + config.plugins.filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)), + ); await writeAgentsGitignore( scope.agentsDir, - managedNames, + [...managedNames, ...await projectedPiSkillNames(config.agents, installedPlugins.plugins)], managedSubagentNames, managedPluginNames, ); diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index d0b5aff..0e1f12a 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -786,7 +786,7 @@ source = "path:external-source" join(projectRoot, ".agents", ".gitignore"), "utf-8", ); - expect(gitignore).toContain("/skills/pdf/"); + expect(gitignore).toContain("/skills/pdf"); }); it("handles empty skills list", async () => { @@ -1502,9 +1502,9 @@ path = "reviewer.md" const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); // Sourced skill should be gitignored - expect(gitignore).toContain("/skills/pdf/"); + expect(gitignore).toContain("/skills/pdf"); // In-place skill should NOT be gitignored - expect(gitignore).not.toContain("/skills/local-skill/"); + expect(gitignore).not.toContain("/skills/local-skill"); }); it("installs all skills from a wildcard entry", async () => { @@ -1619,8 +1619,8 @@ path = "reviewer.md" await runInstall({ scope }); const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); - expect(gitignore).toContain("/skills/pdf/"); - expect(gitignore).toContain("/skills/review/"); + expect(gitignore).toContain("/skills/pdf"); + expect(gitignore).toContain("/skills/review"); }); it("errors on name conflict between two wildcard sources", async () => { diff --git a/packages/dotagents/src/cli/commands/install/gitignore.ts b/packages/dotagents/src/cli/commands/install/gitignore.ts index 6663f73..b25ea41 100644 --- a/packages/dotagents/src/cli/commands/install/gitignore.ts +++ b/packages/dotagents/src/cli/commands/install/gitignore.ts @@ -5,6 +5,7 @@ import type { ScopeRoot } from "../../../scope.js"; import { checkRootGitignoreEntries, writeAgentsGitignore } from "../../../gitignore/writer.js"; import { isInPlaceSkill } from "../../../utils/fs.js"; import { isInPlacePluginSource, type PluginDeclaration } from "../../../plugins/store.js"; +import { projectedPiSkillNames } from "../../../plugins/runtime/writer.js"; import type { SubagentDeclaration } from "../../../subagents/types.js"; export interface InstallGitignoreArtifacts { @@ -58,9 +59,14 @@ export async function writeInstallGitignore( ): Promise { if (scope.scope !== "project") {return;} + const managedSkills = [ + ...managedSkillNames(config, artifacts.installedSkillNames), + ...await projectedPiSkillNames(config.agents, artifacts.plugins), + ]; + await writeAgentsGitignore( scope.agentsDir, - managedSkillNames(config, artifacts.installedSkillNames), + managedSkills, managedSubagentNames(lockfile, artifacts.subagents, frozen), managedPluginNames(lockfile, artifacts.plugins, frozen), ); diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index 99ac475..e8b1856 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -98,7 +98,7 @@ describe("runRemove", () => { await runRemove({ scope, skillName: "pdf" }); const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); - expect(gitignore).not.toContain("/skills/pdf/"); + expect(gitignore).not.toContain("/skills/pdf"); expect(gitignore).toContain("/agents/old-reviewer.md"); }); @@ -136,7 +136,7 @@ source = "path:plugins/review-tools" await runRemove({ scope, skillName: "pdf" }); const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); - expect(gitignore).not.toContain("/skills/pdf/"); + expect(gitignore).not.toContain("/skills/pdf"); expect(gitignore).toContain("/plugins/review-tools/"); expect(gitignore).not.toContain("/plugins/marketplace.json"); }); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index 41510ef..98b7a76 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -13,7 +13,8 @@ import { sourcesMatch, parseOwnerRepoShorthand, isExplicitSourceSpecifier } from import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { isInPlaceSkill } from "../../utils/fs.js"; -import { isInPlacePluginSource } from "../../plugins/store.js"; +import { isInPlacePluginSource, loadInstalledPlugins } from "../../plugins/store.js"; +import { projectedPiSkillNames } from "../../plugins/runtime/writer.js"; export class RemoveError extends Error { constructor(message: string) { @@ -175,9 +176,13 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { } } } + const installedPlugins = await loadInstalledPlugins( + scope.pluginsDir, + config.plugins.filter((plugin) => !isInPlacePluginSource(plugin.source)), + ); await writeAgentsGitignore( scope.agentsDir, - managedNames, + [...managedNames, ...await projectedPiSkillNames(config.agents, installedPlugins.plugins)], [...managedSubagentNames], [...managedPluginNames], ); diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index d4ed196..c723a7e 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -380,7 +380,7 @@ source = "path:plugin-source/review-tools" join(projectRoot, ".agents", ".gitignore"), "utf-8", ); - expect(gitignore).toContain("/skills/pdf/"); + expect(gitignore).toContain("/skills/pdf"); }); it("repairs missing MCP configs", async () => { diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 3a12633..1bda18a 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -16,7 +16,7 @@ import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookRes import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../subagents/writer.js"; import { loadInstalledSubagents, pruneInstalledSubagents } from "../../subagents/store.js"; import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins, pruneInstalledPlugins } from "../../plugins/store.js"; -import { prunePluginOutputs, verifyPluginOutputs, writePluginOutputs } from "../../plugins/runtime/writer.js"; +import { projectedPiSkillNames, prunePluginOutputs, verifyPluginOutputs, writePluginOutputs } from "../../plugins/runtime/writer.js"; import { userMcpResolver } from "../../targets/paths.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; @@ -194,9 +194,13 @@ export async function runSync(opts: SyncOptions): Promise { } } } + const installedPluginsForGitignore = await loadInstalledPlugins( + pluginsDir, + runtimePluginConfigs.filter((plugin) => existsSync(join(pluginsDir, plugin.name))), + ); await writeAgentsGitignore( agentsDir, - managedNames, + [...managedNames, ...await projectedPiSkillNames(config.agents, installedPluginsForGitignore.plugins)], [...managedSubagentNames], [...managedPluginNames], ); diff --git a/packages/dotagents/src/config/loader.test.ts b/packages/dotagents/src/config/loader.test.ts index bec44d2..a6ea1fd 100644 --- a/packages/dotagents/src/config/loader.test.ts +++ b/packages/dotagents/src/config/loader.test.ts @@ -252,18 +252,18 @@ source = "https://agents.example.com" await writeFile( configPath, `version = 1 -agents = ["claude", "codex", "cursor", "grok", "opencode"] +agents = ["claude", "codex", "cursor", "grok", "opencode", "pi"] [[plugins]] name = "review-tools" source = "getsentry/plugins" -targets = ["claude", "codex", "cursor", "grok", "opencode"] +targets = ["claude", "codex", "cursor", "grok", "opencode", "pi"] `, ); const config = await loadConfig(configPath); expect(config.plugins).toHaveLength(1); - expect(config.plugins[0]!.targets).toEqual(["claude", "codex", "cursor", "grok", "opencode"]); + expect(config.plugins[0]!.targets).toEqual(["claude", "codex", "cursor", "grok", "opencode", "pi"]); expect(config.plugins[0]!.source).toBe("getsentry/plugins"); }); diff --git a/packages/dotagents/src/config/schema.test.ts b/packages/dotagents/src/config/schema.test.ts index 95f6272..07b9d63 100644 --- a/packages/dotagents/src/config/schema.test.ts +++ b/packages/dotagents/src/config/schema.test.ts @@ -319,12 +319,12 @@ describe("agentsConfigSchema", () => { it("accepts a portable plugin declaration", () => { const result = agentsConfigSchema.safeParse({ version: 1, - agents: ["claude", "codex", "cursor", "grok", "opencode"], + agents: ["claude", "codex", "cursor", "grok", "opencode", "pi"], plugins: [ { name: "review-tools", source: "getsentry/plugins", - targets: ["claude", "codex", "cursor", "grok", "opencode"], + targets: ["claude", "codex", "cursor", "grok", "opencode", "pi"], }, ], }); diff --git a/packages/dotagents/src/gitignore/writer.test.ts b/packages/dotagents/src/gitignore/writer.test.ts index 2ce8114..3ab649a 100644 --- a/packages/dotagents/src/gitignore/writer.test.ts +++ b/packages/dotagents/src/gitignore/writer.test.ts @@ -30,9 +30,9 @@ describe("writeAgentsGitignore", () => { await writeAgentsGitignore(agentsDir, ["pdf", "find-bugs", "code-review"]); const content = await readFile(join(agentsDir, ".gitignore"), "utf-8"); - expect(content).toContain("/skills/code-review/"); - expect(content).toContain("/skills/find-bugs/"); - expect(content).toContain("/skills/pdf/"); + expect(content).toContain("/skills/code-review"); + expect(content).toContain("/skills/find-bugs"); + expect(content).toContain("/skills/pdf"); }); it("lists managed subagent files", async () => { @@ -60,9 +60,9 @@ describe("writeAgentsGitignore", () => { const content = await readFile(join(agentsDir, ".gitignore"), "utf-8"); const lines = content.split("\n").filter((l) => l.startsWith("/skills/")); expect(lines).toEqual([ - "/skills/alpha/", - "/skills/middle/", - "/skills/zebra/", + "/skills/alpha", + "/skills/middle", + "/skills/zebra", ]); }); diff --git a/packages/dotagents/src/gitignore/writer.ts b/packages/dotagents/src/gitignore/writer.ts index 510c1f7..0d46283 100644 --- a/packages/dotagents/src/gitignore/writer.ts +++ b/packages/dotagents/src/gitignore/writer.ts @@ -17,7 +17,7 @@ export async function writeAgentsGitignore( ): Promise { const lines = [HEADER]; for (const name of managedSkillNames.toSorted()) { - lines.push(`/skills/${name}/`); + lines.push(`/skills/${name}`); } for (const name of managedSubagentNames.toSorted()) { lines.push(`/agents/${name}.md`); diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index 65408d8..a3ee03c 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { lstat, mkdtemp, mkdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, relative } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { PluginDeclaration } from "../store.js"; import { @@ -45,6 +45,20 @@ describe("plugin writer", () => { }; } + async function expectSymlinkTarget(linkPath: string, expectedTarget: string): Promise { + expect((await lstat(linkPath)).isSymbolicLink()).toBe(true); + expect(resolve(dirname(linkPath), await readlink(linkPath))).toBe(resolve(expectedTarget)); + } + + async function writePluginSkill(pluginDir: string, name: string): Promise { + await mkdir(join(pluginDir, "skills", name), { recursive: true }); + await writeFile( + join(pluginDir, "skills", name, "SKILL.md"), + `---\nname: ${name}\ndescription: Plugin QA\n---\n`, + "utf-8", + ); + } + it("writes deterministic marketplace outputs for runtimes that need projections", async () => { const alpha = await plugin("alpha-tools"); const beta = await plugin("beta-tools"); @@ -293,42 +307,96 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); }); - it("uses one conventional OpenCode module when both TypeScript and JavaScript modules exist", async () => { + it("projects plugin skills and agents into OpenCode native locations", async () => { const alpha = await plugin("alpha-tools"); - await mkdir(join(alpha.pluginDir, "opencode"), { recursive: true }); - await writeFile(join(alpha.pluginDir, "opencode", "plugin.ts"), "export default {}\n", "utf-8"); - await writeFile(join(alpha.pluginDir, "opencode", "plugin.js"), "export default {}\n", "utf-8"); + await writePluginSkill(alpha.pluginDir, "plugin-qa"); + await writeFile( + join(alpha.pluginDir, "agents", "plugin-reviewer.md"), + "---\ndescription: Plugin reviewer\n---\nReview plugin output.\n", + "utf-8", + ); const result = await writePluginOutputs(["opencode"], [alpha], root); expect(result.warnings).toEqual([]); - expect(result.written).toBe(1); - expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(true); - expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.js"))).toBe(false); + expect(result.written).toBe(2); + await expectSymlinkTarget( + join(root, ".opencode", "skills", "plugin-qa"), + join(alpha.pluginDir, "skills", "plugin-qa"), + ); + await expectSymlinkTarget( + join(root, ".opencode", "agents", "plugin-reviewer.md"), + join(alpha.pluginDir, "agents", "plugin-reviewer.md"), + ); + expect(await verifyPluginOutputs(["opencode"], [alpha], root)).toEqual([]); }); - it("escapes OpenCode module specifiers in generated modules", async () => { - const modulePath = `opencode/plugin";globalThis.injected=true;.ts`; + it("projects explicit plugin component paths into OpenCode native locations", async () => { const alpha = await plugin("alpha-tools", { - manifest: { opencode: { plugins: [modulePath] } }, + manifest: { skills: "components/skills", agents: "components/agents" }, }); - await mkdir(join(alpha.pluginDir, "opencode"), { recursive: true }); - await writeFile(join(alpha.pluginDir, modulePath), "export default {}\n", "utf-8"); + await writePluginSkill(join(alpha.pluginDir, "components"), "plugin-qa"); + await mkdir(join(alpha.pluginDir, "components", "agents"), { recursive: true }); + await writeFile( + join(alpha.pluginDir, "components", "agents", "plugin-reviewer.md"), + "---\ndescription: Plugin reviewer\n---\nReview plugin output.\n", + "utf-8", + ); const result = await writePluginOutputs(["opencode"], [alpha], root); - const dest = join(root, ".opencode", "plugins", "alpha-tools.ts"); - const specifier = relative(dirname(dest), join(alpha.pluginDir, modulePath)).split("\\").join("/"); expect(result.warnings).toEqual([]); - expect(await readFile(dest, "utf-8")).toBe( - `// Generated by dotagents. Do not edit.\nexport { default } from ${JSON.stringify(specifier)};\n`, + expect(result.written).toBe(2); + await expectSymlinkTarget( + join(root, ".opencode", "skills", "plugin-qa"), + join(alpha.pluginDir, "components", "skills", "plugin-qa"), + ); + await expectSymlinkTarget( + join(root, ".opencode", "agents", "plugin-reviewer.md"), + join(alpha.pluginDir, "components", "agents", "plugin-reviewer.md"), ); }); - it("warns without writing OpenCode re-exports for missing manifest modules", async () => { - const alpha = await plugin("alpha-tools", { - manifest: { opencode: { plugins: ["opencode/missing.ts"] } }, + it("projects plugin skills into Pi's native agentskills location", async () => { + const alpha = await plugin("alpha-tools"); + await writePluginSkill(alpha.pluginDir, "plugin-qa"); + + const result = await writePluginOutputs(["pi"], [alpha], root); + + expect(result.warnings).toEqual([]); + expect(result.written).toBe(1); + await expectSymlinkTarget( + join(root, ".agents", "skills", "plugin-qa"), + join(alpha.pluginDir, "skills", "plugin-qa"), + ); + }); + + it("does not overwrite unmanaged Pi plugin skill projections", async () => { + const alpha = await plugin("alpha-tools"); + await writePluginSkill(alpha.pluginDir, "plugin-qa"); + await mkdir(join(root, ".agents", "skills", "plugin-qa"), { recursive: true }); + await writeFile(join(root, ".agents", "skills", "plugin-qa", "SKILL.md"), "---\nname: plugin-qa\ndescription: Mine\n---\n", "utf-8"); + + const result = await writePluginOutputs(["pi"], [alpha], root); + + expect(result).toEqual({ + written: 0, + warnings: [ + { + agent: "pi", + name: "alpha-tools", + message: `Pi plugin skill projection exists and is not managed by dotagents: ${join(root, ".agents", "skills", "plugin-qa")}`, + }, + ], }); + expect((await lstat(join(root, ".agents", "skills", "plugin-qa"))).isDirectory()).toBe(true); + }); + + it("does not overwrite unmanaged OpenCode plugin component projections", async () => { + const alpha = await plugin("alpha-tools"); + await writePluginSkill(alpha.pluginDir, "plugin-qa"); + await mkdir(join(root, ".opencode", "skills", "plugin-qa"), { recursive: true }); + await writeFile(join(root, ".opencode", "skills", "plugin-qa", "SKILL.md"), "---\nname: plugin-qa\ndescription: Mine\n---\n", "utf-8"); const result = await writePluginOutputs(["opencode"], [alpha], root); @@ -338,37 +406,65 @@ describe("plugin writer", () => { { agent: "opencode", name: "alpha-tools", - message: `OpenCode plugin module missing: ${join(alpha.pluginDir, "opencode", "missing.ts")}`, + message: `OpenCode plugin skill projection exists and is not managed by dotagents: ${join(root, ".opencode", "skills", "plugin-qa")}`, }, ], }); - expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(false); }); - it("keeps declared OpenCode modules during prune even when the source is missing", async () => { - const alpha = await plugin("alpha-tools", { - manifest: { opencode: { plugins: ["opencode/missing.ts"] } }, - }); - await mkdir(join(root, ".opencode", "plugins"), { recursive: true }); - await writeFile( - join(root, ".opencode", "plugins", "alpha-tools.ts"), - `// Generated by dotagents. Do not edit.\nexport { default } from "../missing";\n`, - "utf-8", + it("repairs dangling managed plugin component links", async () => { + const alpha = await plugin("alpha-tools"); + await writePluginSkill(alpha.pluginDir, "plugin-qa"); + await mkdir(join(root, ".opencode", "skills"), { recursive: true }); + await symlink( + relative(join(root, ".opencode", "skills"), join(root, ".agents", "plugins", "alpha-tools", "missing", "plugin-qa")), + join(root, ".opencode", "skills", "plugin-qa"), ); - const pruned = await prunePluginOutputs(["opencode"], [alpha], root); + const result = await writePluginOutputs(["opencode"], [alpha], root); - expect(pruned).toEqual([]); - expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(true); + expect(result.warnings).toEqual([]); + expect(result.written).toBe(1); + await expectSymlinkTarget( + join(root, ".opencode", "skills", "plugin-qa"), + join(alpha.pluginDir, "skills", "plugin-qa"), + ); }); - it("prunes stale managed runtime plugin outputs", async () => { - const alpha = await plugin("alpha-tools", { - manifest: { opencode: { plugins: ["opencode/plugin.ts"] } }, + it("warns and skips invalid OpenCode plugin skill names", async () => { + const alpha = await plugin("alpha-tools"); + await writePluginSkill(alpha.pluginDir, "plugin_qa"); + + const result = await writePluginOutputs(["opencode"], [alpha], root); + + expect(result).toEqual({ + written: 0, + warnings: [ + { + agent: "opencode", + name: "alpha-tools", + message: 'Plugin skill "plugin_qa" cannot be projected to OpenCode because OpenCode skill names must be lowercase alphanumeric with single hyphen separators.', + }, + ], }); - await mkdir(join(alpha.pluginDir, "opencode"), { recursive: true }); - await writeFile(join(alpha.pluginDir, "opencode", "plugin.ts"), "export default {}\n", "utf-8"); - await writePluginOutputs(["claude", "cursor", "codex", "grok", "opencode"], [alpha], root); + expect(existsSync(join(root, ".opencode", "skills", "plugin_qa"))).toBe(false); + }); + + it("prunes stale managed runtime plugin outputs", async () => { + const alpha = await plugin("alpha-tools"); + await writePluginSkill(alpha.pluginDir, "plugin-qa"); + await writeFile( + join(alpha.pluginDir, "agents", "plugin-reviewer.md"), + "---\ndescription: Plugin reviewer\n---\nReview plugin output.\n", + "utf-8", + ); + await mkdir(join(root, ".opencode", "plugins"), { recursive: true }); + await writeFile( + join(root, ".opencode", "plugins", "alpha-tools.ts"), + `// Generated by dotagents. Do not edit.\nexport { default } from "../.agents/plugins/alpha-tools/opencode/plugin.ts";\n`, + "utf-8", + ); + await writePluginOutputs(["claude", "cursor", "codex", "grok", "opencode", "pi"], [alpha], root); const pruned = await prunePluginOutputs([], [alpha], root); @@ -378,6 +474,9 @@ describe("plugin writer", () => { join(root, ".cursor-plugin", "marketplace.json"), join(root, ".grok", "plugins", "alpha-tools"), join(root, ".opencode", "plugins", "alpha-tools.ts"), + join(root, ".opencode", "skills", "plugin-qa"), + join(root, ".opencode", "agents", "plugin-reviewer.md"), + join(root, ".agents", "skills", "plugin-qa"), join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"), join(root, ".agents", "plugins", "alpha-tools", ".cursor-plugin", "plugin.json"), join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"), @@ -387,6 +486,9 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".cursor-plugin", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".grok", "plugins", "alpha-tools"))).toBe(false); expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(false); + expect(existsSync(join(root, ".opencode", "skills", "plugin-qa"))).toBe(false); + expect(existsSync(join(root, ".opencode", "agents", "plugin-reviewer.md"))).toBe(false); + expect(existsSync(join(root, ".agents", "skills", "plugin-qa"))).toBe(false); expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"))).toBe(false); expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".cursor-plugin", "plugin.json"))).toBe(false); expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index 8984fb3..37e2188 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; -import { cp, lstat, mkdir, readdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises"; -import { dirname, extname, join } from "node:path"; +import { cp, lstat, mkdir, readdir, readFile, readlink, rm, rmdir, symlink, writeFile } from "node:fs/promises"; +import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { loadSkillMd } from "@sentry/dotagents-lib"; +import type { PluginManifest } from "../schema.js"; import type { PluginDeclaration } from "../store.js"; import { selectedAgentIds, selectPlugins, targetWarnings } from "../targets.js"; import { marketplaceOutputPaths, marketplaceOutputs } from "./marketplace.js"; @@ -14,15 +16,42 @@ import { isManagedJsonFile, isNotFoundError, writeJsonIfChanged, - writeTextIfChanged, } from "./files.js"; -import { relativePath } from "./manifest-values.js"; import { writeClaudeManifest, writeCodexManifest, writeCursorManifest } from "./manifests.js"; // Owns deterministic runtime plugin projections. Existing runtime artifacts are // overwritten only when they carry dotagents managed metadata or a managed marker. export type { PluginVerifyIssue, PluginWriteResult, PluginWriteWarning } from "./types.js"; +type ComponentProjectionAgent = "opencode" | "pi"; + +interface ComponentLink { + agent: ComponentProjectionAgent; + kind: "skill" | "agent"; + name: string; + sourcePath: string; + destPath: string; +} + +const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +/** Returns plugin skill names projected into `.agents/skills/` for Pi. */ +export async function projectedPiSkillNames( + agentIds: string[], + plugins: PluginDeclaration[], +): Promise { + const names = new Set(); + const selected = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("pi")); + for (const plugin of selected) { + for (const skillsDir of componentDirs(plugin, "skills", "skills")) { + for (const name of await skillNamesInDir(skillsDir)) { + names.add(name); + } + } + } + return [...names]; +} + /** Writes deterministic project-scope plugin runtime artifacts for selected agents. */ export async function writePluginOutputs( agentIds: string[], @@ -55,11 +84,11 @@ export async function writePluginOutputs( if (agents.includes("grok") && await writeGrokProjection(projectRoot, plugin, warnings)) { written++; } - if (agents.includes("opencode")) { - written += await writeOpenCodeProjection(projectRoot, plugin, warnings); - } } + written += await writeComponentProjections("opencode", agentIds, selected, projectRoot, warnings); + written += await writeComponentProjections("pi", agentIds, selected, projectRoot, warnings); + return { warnings, written }; } @@ -115,6 +144,17 @@ export async function verifyPluginOutputs( } } + for (const link of await desiredComponentLinks("opencode", agentIds, selected, projectRoot, [])) { + if (!await symlinkPointsTo(link.destPath, link.sourcePath)) { + issues.push({ agent: link.agent, name: link.name, issue: `OpenCode plugin ${link.kind} projection missing: ${link.destPath}` }); + } + } + for (const link of await desiredComponentLinks("pi", agentIds, selected, projectRoot, [])) { + if (!await symlinkPointsTo(link.destPath, link.sourcePath)) { + issues.push({ agent: link.agent, name: link.name, issue: `Pi plugin ${link.kind} projection missing: ${link.destPath}` }); + } + } + return issues; } @@ -154,23 +194,18 @@ export async function prunePluginOutputs( } } - const desiredOpenCode = new Set( - plugins - .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("opencode")) - .flatMap((plugin) => desiredOpenCodeModules(plugin).map((modulePath) => `${plugin.name}${extname(modulePath)}`)), + pruned.push(...await pruneLegacyOpenCodeModules(projectRoot)); + + const desiredOpenCodeLinks = new Set( + (await desiredComponentLinks("opencode", agentIds, plugins, projectRoot, [])).map((link) => link.destPath), ); - const opencodeDir = join(projectRoot, ".opencode", "plugins"); - if (existsSync(opencodeDir)) { - const entries = await readdir(opencodeDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile() && !entry.isSymbolicLink()) {continue;} - if (desiredOpenCode.has(entry.name)) {continue;} - const path = join(opencodeDir, entry.name); - if (!await isManagedOpenCodeModule(path)) {continue;} - await rm(path, { force: true }); - pruned.push(path); - } - } + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".opencode", "skills"), desiredOpenCodeLinks, projectRoot)); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".opencode", "agents"), desiredOpenCodeLinks, projectRoot)); + + const desiredPiLinks = new Set( + (await desiredComponentLinks("pi", agentIds, plugins, projectRoot, [])).map((link) => link.destPath), + ); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".agents", "skills"), desiredPiLinks, projectRoot)); const canonicalPluginDir = join(projectRoot, ".agents", "plugins"); const desiredClaude = new Set( @@ -259,58 +294,196 @@ async function writeGrokProjection( return true; } -/** Writes OpenCode re-export modules for explicit or conventional plugin modules. */ -async function writeOpenCodeProjection( +async function writeComponentProjections( + agent: ComponentProjectionAgent, + agentIds: string[], + plugins: PluginDeclaration[], projectRoot: string, - plugin: PluginDeclaration, warnings: PluginWriteWarning[], ): Promise { - const modules = opencodeModules(plugin, warnings); let written = 0; - for (const modulePath of modules) { - const ext = extname(modulePath); - const dest = join(projectRoot, ".opencode", "plugins", `${plugin.name}${ext}`); - if (existsSync(dest) && !await isManagedOpenCodeModule(dest)) { + for (const link of await desiredComponentLinks(agent, agentIds, plugins, projectRoot, warnings)) { + if (await writeManagedComponentLink(link, projectRoot, warnings)) {written++;} + } + return written; +} + +async function desiredComponentLinks( + agent: ComponentProjectionAgent, + agentIds: string[], + plugins: PluginDeclaration[], + projectRoot: string, + warnings: PluginWriteWarning[], +): Promise { + const links = new Map(); + const selected = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes(agent)); + for (const plugin of selected) { + for (const link of await componentLinks(agent, projectRoot, plugin, warnings)) { + const existing = links.get(link.destPath); + if (existing && existing.sourcePath !== link.sourcePath) { + warnings.push({ + agent, + name: plugin.name, + message: `${displayName(agent)} plugin ${link.kind} projection conflicts with ${existing.sourcePath}: ${link.destPath}`, + }); + continue; + } + links.set(link.destPath, link); + } + } + return [...links.values()]; +} + +async function componentLinks( + agent: ComponentProjectionAgent, + projectRoot: string, + plugin: PluginDeclaration, + warnings: PluginWriteWarning[], +): Promise { + const links: ComponentLink[] = []; + for (const skillsDir of componentDirs(plugin, "skills", "skills")) { + const skillDestRoot = agent === "opencode" + ? join(projectRoot, ".opencode", "skills") + : join(projectRoot, ".agents", "skills"); + links.push(...await skillComponentLinks(agent, plugin, skillsDir, skillDestRoot, warnings)); + } + + if (agent === "opencode") { + for (const agentsDir of componentDirs(plugin, "agents", "agents")) { + links.push(...await markdownComponentLinks(agent, plugin, agentsDir, join(projectRoot, ".opencode", "agents"))); + } + } + + return links; +} + +async function skillComponentLinks( + agent: ComponentProjectionAgent, + plugin: PluginDeclaration, + skillsDir: string, + destRoot: string, + warnings: PluginWriteWarning[], +): Promise { + if (!existsSync(skillsDir)) {return [];} + + const links: ComponentLink[] = []; + for (const entry of await readdir(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) {continue;} + const sourcePath = join(skillsDir, entry.name); + const skillMd = join(sourcePath, "SKILL.md"); + if (!existsSync(skillMd)) {continue;} + + let skillName: string; + try { + skillName = (await loadSkillMd(skillMd)).name; + } catch (err) { warnings.push({ - agent: "opencode", + agent, name: plugin.name, - message: `OpenCode plugin module exists and is not managed by dotagents: ${dest}`, + message: `Plugin skill is invalid for ${displayName(agent)} projection: ${err instanceof Error ? err.message : String(err)}`, }); continue; } - await mkdir(dirname(dest), { recursive: true }); - const moduleSpecifier = JSON.stringify(relativePath(dirname(dest), join(plugin.pluginDir, modulePath))); - const content = `// Generated by dotagents. Do not edit.\nexport { default } from ${moduleSpecifier};\n`; - if (await writeTextIfChanged(dest, content)) {written++;} + if (agent === "opencode" && !OPENCODE_SKILL_NAME_PATTERN.test(skillName)) { + warnings.push({ + agent, + name: plugin.name, + message: `Plugin skill "${skillName}" cannot be projected to OpenCode because OpenCode skill names must be lowercase alphanumeric with single hyphen separators.`, + }); + continue; + } + + links.push({ + agent, + kind: "skill", + name: plugin.name, + sourcePath, + destPath: join(destRoot, skillName), + }); + } + return links; +} + +async function skillNamesInDir(skillsDir: string): Promise { + if (!existsSync(skillsDir)) {return [];} + + const names: string[] = []; + for (const entry of await readdir(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) {continue;} + const skillMd = join(skillsDir, entry.name, "SKILL.md"); + if (!existsSync(skillMd)) {continue;} + try { + names.push((await loadSkillMd(skillMd)).name); + } catch { + // Invalid skills are skipped by the projection writer too. + } } - return written; + return names; } -function opencodeModules( +async function markdownComponentLinks( + agent: ComponentProjectionAgent, plugin: PluginDeclaration, - warnings: PluginWriteWarning[] = [], -): string[] { - return desiredOpenCodeModules(plugin).filter((path) => { - if (existsSync(join(plugin.pluginDir, path))) {return true;} - warnings.push({ - agent: "opencode", + agentsDir: string, + destRoot: string, +): Promise { + if (!existsSync(agentsDir)) {return [];} + + const links: ComponentLink[] = []; + for (const entry of await readdir(agentsDir, { withFileTypes: true })) { + if ((!entry.isFile() && !entry.isSymbolicLink()) || extname(entry.name) !== ".md") {continue;} + links.push({ + agent, + kind: "agent", name: plugin.name, - message: `OpenCode plugin module missing: ${join(plugin.pluginDir, path)}`, + sourcePath: join(agentsDir, entry.name), + destPath: join(destRoot, entry.name), }); - return false; - }); + } + + return links; } -/** Returns declared OpenCode modules without existence checks so prune keeps desired managed outputs. */ -function desiredOpenCodeModules(plugin: PluginDeclaration): string[] { - const opencode = plugin.manifest.opencode; - if (opencode?.plugins) { - return opencode.plugins; +async function writeManagedComponentLink( + link: ComponentLink, + projectRoot: string, + warnings: PluginWriteWarning[], +): Promise { + if (await pathExists(link.destPath)) { + if (await symlinkPointsTo(link.destPath, link.sourcePath)) {return false;} + if (!await isManagedComponentLink(link.destPath, projectRoot)) { + warnings.push({ + agent: link.agent, + name: link.name, + message: `${displayName(link.agent)} plugin ${link.kind} projection exists and is not managed by dotagents: ${link.destPath}`, + }); + return false; + } + await rm(link.destPath, { force: true }); } - const candidates = ["opencode/plugin.ts", "opencode/plugin.js"]; - const candidate = candidates.find((path) => existsSync(join(plugin.pluginDir, path))); - return candidate ? [candidate] : []; + + await mkdir(dirname(link.destPath), { recursive: true }); + await symlink(relative(dirname(link.destPath), link.sourcePath), link.destPath); + return true; +} + +function componentDirs( + plugin: PluginDeclaration, + manifestKey: keyof Pick, + defaultDir: string, +): string[] { + const explicit = manifestPaths(plugin.manifest[manifestKey]); + const paths = explicit.length > 0 ? explicit : [defaultDir]; + return paths.map((path) => join(plugin.pluginDir, path)); +} + +function manifestPaths(value: unknown): string[] { + if (typeof value === "string") {return [value];} + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return value; + } + return []; } async function writeManagedJsonOutput( @@ -333,7 +506,7 @@ async function isManagedProjection(path: string): Promise { return existsSync(join(path, ".dotagents-managed")); } -/** Checks OpenCode module projections using the generated-file header marker. */ +/** Checks legacy OpenCode JS projections from earlier dotagents plugin support. */ async function isManagedOpenCodeModule(filePath: string): Promise { try { return (await readFile(filePath, "utf-8")).startsWith("// Generated by dotagents."); @@ -342,6 +515,82 @@ async function isManagedOpenCodeModule(filePath: string): Promise { } } +async function pruneLegacyOpenCodeModules(projectRoot: string): Promise { + const opencodeDir = join(projectRoot, ".opencode", "plugins"); + if (!existsSync(opencodeDir)) {return [];} + + const pruned: string[] = []; + const entries = await readdir(opencodeDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() && !entry.isSymbolicLink()) {continue;} + const path = join(opencodeDir, entry.name); + if (!await isManagedOpenCodeModule(path)) {continue;} + await rm(path, { force: true }); + pruned.push(path); + } + return pruned; +} + +async function pruneManagedComponentLinks( + dir: string, + desiredPaths: Set, + projectRoot: string, +): Promise { + if (!existsSync(dir)) {return [];} + + const pruned: string[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (desiredPaths.has(path)) {continue;} + if (!await isManagedComponentLink(path, projectRoot)) {continue;} + await rm(path, { force: true }); + pruned.push(path); + } + return pruned; +} + +async function symlinkPointsTo(filePath: string, expectedTarget: string): Promise { + try { + const stat = await lstat(filePath); + if (!stat.isSymbolicLink()) {return false;} + const target = await readlink(filePath); + return resolve(dirname(filePath), target) === resolve(expectedTarget); + } catch { + return false; + } +} + +async function pathExists(filePath: string): Promise { + try { + await lstat(filePath); + return true; + } catch (err) { + if (isNotFoundError(err)) {return false;} + throw err; + } +} + +async function isManagedComponentLink(filePath: string, projectRoot: string): Promise { + try { + const stat = await lstat(filePath); + if (!stat.isSymbolicLink()) {return false;} + const target = await readlink(filePath); + return isInside(resolve(dirname(filePath), target), join(projectRoot, ".agents", "plugins")); + } catch { + return false; + } +} + +function isInside(path: string, root: string): boolean { + const relPath = relative(resolve(root), resolve(path)); + return relPath === "" || (!relPath.startsWith("..") && !isAbsolute(relPath)); +} + +function displayName(agent: ComponentProjectionAgent): string { + return agent === "opencode" ? "OpenCode" : "Pi"; +} + async function directoriesMatch(source: string, dest: string, ignoredNames = new Set()): Promise { if (!existsSync(source) || !existsSync(dest)) {return false;} diff --git a/packages/dotagents/src/plugins/schema.test.ts b/packages/dotagents/src/plugins/schema.test.ts index b482399..18b367c 100644 --- a/packages/dotagents/src/plugins/schema.test.ts +++ b/packages/dotagents/src/plugins/schema.test.ts @@ -14,8 +14,8 @@ describe("plugin manifest schema", () => { version: "1.2.3", skills: "./skills", commands: ["commands/review.md"], - opencode: { - plugins: ["opencode/plugin.ts"], + "x-runtime": { + plugins: ["runtime/plugin.ts"], runtime: "bun", }, "x-dotagents": { @@ -26,8 +26,10 @@ describe("plugin manifest schema", () => { ); expect(manifest.name).toBe("review-tools"); - expect(manifest.opencode?.plugins).toEqual(["opencode/plugin.ts"]); - expect(manifest.opencode?.["runtime"]).toBe("bun"); + expect(manifest["x-runtime"]).toEqual({ + plugins: ["runtime/plugin.ts"], + runtime: "bun", + }); expect(manifest["x-dotagents"]).toEqual({ stable: true }); }); @@ -35,23 +37,6 @@ describe("plugin manifest schema", () => { expect(pluginManifestSchema.safeParse({ skills: "/tmp/skills" }).success).toBe(false); expect(pluginManifestSchema.safeParse({ commands: ["../commands"] }).success).toBe(false); expect(pluginManifestSchema.safeParse({ skills: "https://example.com/skills" }).success).toBe(false); - expect(pluginManifestSchema.safeParse({ opencode: { plugins: ["opencode/../../plugin.ts"] } }).success).toBe(false); - }); - - it("rejects multiple OpenCode plugin modules", () => { - expect(pluginManifestSchema.safeParse({ - opencode: { - plugins: ["opencode/first.ts", "opencode/second.ts"], - }, - }).success).toBe(false); - }); - - it("rejects OpenCode plugin modules without a JavaScript or TypeScript extension", () => { - expect(pluginManifestSchema.safeParse({ - opencode: { - plugins: ["opencode/plugin.md"], - }, - }).success).toBe(false); }); }); diff --git a/packages/dotagents/src/plugins/schema.ts b/packages/dotagents/src/plugins/schema.ts index 3140474..0c06e9c 100644 --- a/packages/dotagents/src/plugins/schema.ts +++ b/packages/dotagents/src/plugins/schema.ts @@ -24,13 +24,6 @@ const pluginPathOrPathsSchema = z.union([ z.array(pluginPathSchema), ]); -const pluginModulePathSchema = pluginPathSchema.check( - z.refine( - (value) => value.endsWith(".js") || value.endsWith(".ts"), - "Plugin module paths must end with .js or .ts", - ), -); - export const pluginManifestSchema = z.object({ name: z.string().optional(), version: z.string().optional(), @@ -51,9 +44,6 @@ export const pluginManifestSchema = z.object({ apps: pluginPathSchema.optional(), monitors: pluginPathSchema.optional(), bin: pluginPathOrPathsSchema.optional(), - opencode: z.object({ - plugins: z.array(pluginModulePathSchema).max(1).optional(), - }).passthrough().optional(), }).passthrough(); export type PluginManifest = z.infer; diff --git a/packages/dotagents/src/plugins/targets.ts b/packages/dotagents/src/plugins/targets.ts index 99c5f3e..e104458 100644 --- a/packages/dotagents/src/plugins/targets.ts +++ b/packages/dotagents/src/plugins/targets.ts @@ -1,8 +1,8 @@ import type { PluginDeclaration } from "./store.js"; import type { PluginWriteWarning } from "./runtime/types.js"; -const PLUGIN_ONLY_AGENT_IDS = ["grok"]; -const PLUGIN_AGENT_IDS = ["claude", "cursor", "codex", "grok", "opencode"]; +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()); /** Returns agent IDs accepted in agents.toml only for plugin runtime output. */ diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index 05b76a9..dd3d2be 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -157,7 +157,7 @@ asserts: - generated subagent runtime files for Claude, Cursor, Codex, and OpenCode - canonical installed plugin bundle under `.agents/plugins/` - Codex repo-scoped plugin marketplace under `.agents/plugins/marketplace.json` -- generated plugin runtime files for Claude, Cursor, Codex, Grok, and OpenCode +- generated plugin runtime files for Claude, Cursor, Codex, Grok, and OpenCode component projections - `sync` repair after deleting representative generated files Use `node skills/dotagents-qa/scripts/qa-example.mjs all --keep` when you need @@ -292,7 +292,8 @@ test -f .agents/plugins/qa-tools/.claude-plugin/plugin.json test -f .agents/plugins/qa-tools/.cursor-plugin/plugin.json test -f .agents/plugins/qa-tools/.codex-plugin/plugin.json test -f .grok/plugins/qa-tools/.dotagents-managed -test -f .opencode/plugins/qa-tools.ts +test -L .opencode/skills/plugin-qa +test -L .opencode/agents/plugin-reviewer.md grep -q "code-reviewer" agents.lock grep -q "qa-tools" agents.lock grep -q "Generated by dotagents" .claude/agents/code-reviewer.md @@ -308,7 +309,7 @@ rm .agents/plugins/marketplace.json .claude-plugin/marketplace.json .agents/plug rm .cursor-plugin/marketplace.json .agents/plugins/qa-tools/.cursor-plugin/plugin.json rm .agents/plugins/qa-tools/.codex-plugin/plugin.json rm -rf .grok/plugins/qa-tools -rm .opencode/plugins/qa-tools.ts +rm .opencode/skills/plugin-qa .opencode/agents/plugin-reviewer.md "${cli[@]}" sync | tee /qa-out/sync.out test -f .mcp.json test -L .claude/skills @@ -321,7 +322,8 @@ test -f .agents/plugins/qa-tools/.claude-plugin/plugin.json test -f .agents/plugins/qa-tools/.cursor-plugin/plugin.json test -f .agents/plugins/qa-tools/.codex-plugin/plugin.json test -f .grok/plugins/qa-tools/.dotagents-managed -test -f .opencode/plugins/qa-tools.ts +test -L .opencode/skills/plugin-qa +test -L .opencode/agents/plugin-reviewer.md ``` For user-scope changes: diff --git a/skills/dotagents-qa/references/opencode.md b/skills/dotagents-qa/references/opencode.md index 9dcb62d..32c50d6 100644 --- a/skills/dotagents-qa/references/opencode.md +++ b/skills/dotagents-qa/references/opencode.md @@ -1,7 +1,7 @@ # OpenCode QA Use this reference when changes affect OpenCode config generation, -`opencode.json`, `.opencode/agents/*.md`, `.opencode/plugins/*.ts`, or +`opencode.json`, `.opencode/agents/*.md`, `.opencode/skills/*`, or OpenCode user-scope paths. ## File-Level Checks @@ -11,25 +11,26 @@ The core agentic QA asserts: - `opencode.json` exists for OpenCode MCP config - `.opencode/agents/code-reviewer.md` exists - generated Markdown contains the dotagents managed marker +- plugin skills selected for OpenCode are symlinked into `.opencode/skills/` +- plugin Markdown agents selected for OpenCode are symlinked into `.opencode/agents/` OpenCode does not support dotagents hooks in the current agent definition, so hook warnings for OpenCode are expected when the fixture includes hooks. For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated OpenCode subagents under `$HOME/.config/opencode/agents/`. -## Plugin Proof +## Plugin Component Proof -For generated plugin modules, the QA skill has a cheap Docker proof: +For generated plugin component projections, the QA skill has a cheap Docker proof: ```bash node skills/dotagents-qa/scripts/qa-example.mjs plugin-opencode --keep ``` This installs the checked-in example, asserts the generated -`.opencode/plugins/qa-tools.ts` re-export, runs `opencode debug config`, and -checks for the generated module path plus the fixture's -`dotagents-plugin-proof` command. That proves OpenCode loaded and executed the -generated plugin module's config hook. It does not prove model-backed -invocation. +`.opencode/skills/plugin-qa` and `.opencode/agents/plugin-reviewer.md` +symlinks, runs `opencode debug skill`, and checks for the fixture skill +sentinel. It also runs `opencode agent list` and checks for the projected +plugin agent. It does not prove model-backed invocation. ## Runtime Proof @@ -42,11 +43,9 @@ Manual Docker probes can prove more when the branch affects OpenCode output: `code-reviewer (subagent)` - `opencode debug agent code-reviewer` should resolve the generated prompt and `mode = "subagent"` -- `opencode debug config` should include the generated - `.opencode/plugins/qa-tools.ts` plugin module and any observable fixture - config it injects -- `opencode debug skill` may show `.agents/skills/*` discovery; verify from - raw output instead of assuming it is stable across OpenCode versions +- `opencode debug skill` should include projected plugin skills under + `.opencode/skills/*`; verify from raw output instead of assuming it is + stable across OpenCode versions For authenticated or OpenRouter-backed runtime proof, read [runtime-auth.md](runtime-auth.md) first. Use a temp `opencode.json` provider diff --git a/skills/dotagents-qa/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md index 397f441..318cca4 100644 --- a/skills/dotagents-qa/references/plugin-runtime.md +++ b/skills/dotagents-qa/references/plugin-runtime.md @@ -20,8 +20,8 @@ node skills/dotagents-qa/scripts/qa-example.mjs plugin-opencode ``` This proves install/sync/list/doctor behavior, generated plugin files, Claude -validation, Codex marketplace install, and OpenCode projection surface. It does -not prove every runtime loaded and invoked every plugin component. +validation, Codex marketplace install, and OpenCode component projection. It +does not prove every runtime loaded and invoked every plugin component. `pnpm qa:example` is the install/sync repair proof. It intentionally runs only the `install-files` and `sync-repair` tasks. Run the separate `plugin-claude`, @@ -92,33 +92,20 @@ model-backed prompt because the plugin management CLI does not execute skills. Best automated proof in Docker: -The checked-in `qa-tools` fixture registers an observable config hook: - -```ts -export default async () => ({ - config: (cfg) => { - cfg.command = cfg.command ?? {}; - cfg.command["dotagents-plugin-proof"] = { - description: "Proof command injected by generated OpenCode plugin projection.", - prompt: "DOTAGENTS_OPENCODE_PLUGIN_EXECUTION_PROOF", - }; - }, -}); -``` - `node skills/dotagents-qa/scripts/qa-example.mjs plugin-opencode` installs the -fixture, confirms `.opencode/plugins/qa-tools.ts` re-exports the canonical -module, then runs: +fixture, confirms `.opencode/skills/plugin-qa` and +`.opencode/agents/plugin-reviewer.md` are symlinks into the installed plugin +bundle, then runs: ```bash -opencode debug config > /tmp/opencode-config.json -rg "dotagents-plugin-proof|DOTAGENTS_OPENCODE_PLUGIN_EXECUTION_PROOF|qa-tools.ts" /tmp/opencode-config.json +opencode debug skill +opencode agent list ``` Expected evidence: -- `debug config` includes the generated plugin module path -- `debug config` includes the command injected by the plugin hook +- `debug skill` includes `plugin-qa` and `DOTAGENTS_PLUGIN_QA_FIXTURE` +- `agent list` includes `plugin-reviewer` Manual final check with model auth: diff --git a/skills/dotagents-qa/references/runtime-auth.md b/skills/dotagents-qa/references/runtime-auth.md index 846c5df..d698251 100644 --- a/skills/dotagents-qa/references/runtime-auth.md +++ b/skills/dotagents-qa/references/runtime-auth.md @@ -197,9 +197,9 @@ Use a temp project `opencode.json` or isolated config that includes: ``` Run `opencode auth list` or a cheap prompt from the temp project only after the -file-level plugin projection passes. Report whether OpenCode loaded the -generated `.opencode/plugins/.ts|js` module separately from whether the -model call succeeded. +file-level plugin component projection passes. Report whether OpenCode sees +the generated `.opencode/skills/*` or `.opencode/agents/*` component separately +from whether the model call succeeded. ## Cursor diff --git a/skills/dotagents-qa/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs index 1698398..779bc32 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -115,7 +115,7 @@ Tasks: sync-repair Delete representative generated files and assert sync repairs them plugin-claude Validate generated Claude plugin and marketplace with Claude Code plugin-codex Add/list/install generated Codex marketplace with Codex CLI - plugin-opencode Assert generated OpenCode module and debug config load + plugin-opencode Assert generated OpenCode plugin skill and agent projections codex-runtime Paid proof that Codex can spawn the generated custom agent Compatibility: @@ -140,7 +140,9 @@ async function runSyncRepair() { rmSync(join(projectDir, ".agents", "plugins", "qa-tools", ".cursor-plugin", "plugin.json"), { force: true }); rmSync(join(projectDir, ".agents", "plugins", "qa-tools", ".codex-plugin", "plugin.json"), { force: true }); rmSync(join(projectDir, ".grok", "plugins", "qa-tools"), { force: true, recursive: true }); - rmSync(join(projectDir, ".opencode", "plugins", "qa-tools.ts"), { force: true }); + rmSync(join(projectDir, ".opencode", "skills", "plugin-qa"), { force: true, recursive: true }); + rmSync(join(projectDir, ".opencode", "agents", "plugin-reviewer.md"), { force: true }); + rmSync(join(projectDir, ".agents", "skills", "plugin-qa"), { force: true, recursive: true }); runCli(["sync"]); assertFile(".mcp.json"); assertSymlink(".claude/skills"); @@ -192,16 +194,23 @@ async function runCodexPluginProof() { async function runOpenCodePluginProof() { await installAndAssert(); - const config = execFileSync("opencode", ["debug", "config"], { + const skills = execFileSync("opencode", ["debug", "skill"], { cwd: projectDir, env: fixtureEnv, encoding: "utf-8", }); - assertFile(".opencode/plugins/qa-tools.ts"); - assertFileIncludes(".opencode/plugins/qa-tools.ts", "Generated by dotagents"); - assertFileIncludes(".opencode/plugins/qa-tools.ts", "../.agents/plugins/qa-tools/opencode/plugin.ts"); - if (!config.includes("qa-tools.ts") || !config.includes("dotagents-plugin-proof") || !config.includes("DOTAGENTS_OPENCODE_PLUGIN_EXECUTION_PROOF")) { - throw new Error("OpenCode debug config did not include generated plugin proof command"); + const agents = execFileSync("opencode", ["agent", "list"], { + cwd: projectDir, + env: fixtureEnv, + encoding: "utf-8", + }); + assertSymlink(".opencode/skills/plugin-qa"); + assertSymlink(".opencode/agents/plugin-reviewer.md"); + if (!skills.includes("plugin-qa") || !skills.includes("DOTAGENTS_PLUGIN_QA_FIXTURE")) { + throw new Error("OpenCode debug skill did not include projected plugin skill"); + } + if (!agents.includes("plugin-reviewer")) { + throw new Error("OpenCode agent list did not include projected plugin agent"); } } @@ -361,8 +370,9 @@ function assertPluginOutputs() { assertFile(".agents/plugins/qa-tools/skills/plugin-qa/SKILL.md"); assertFile(".agents/plugins/qa-tools/commands/plugin-qa.md"); assertFile(".agents/plugins/qa-tools/agents/plugin-reviewer.md"); - assertFile(".agents/plugins/qa-tools/opencode/plugin.ts"); assertFile(".agents/plugins/qa-tools/.claude-plugin/plugin.json"); + assertSymlink(".agents/skills/plugin-qa"); + assertFileIncludes(".agents/.gitignore", "/skills/plugin-qa"); assertFileIncludes("agents.lock", "qa-tools"); assertFile(".agents/plugins/marketplace.json"); assertFileIncludes(".agents/plugins/marketplace.json", '"name": "dotagents-local"'); @@ -395,9 +405,8 @@ function assertPluginOutputs() { assertFile(".grok/plugins/qa-tools/plugin.json"); assertFileIncludes(".grok/plugins/qa-tools/skills/plugin-qa/SKILL.md", "DOTAGENTS_PLUGIN_QA_FIXTURE"); - assertFile(".opencode/plugins/qa-tools.ts"); - assertFileIncludes(".opencode/plugins/qa-tools.ts", "Generated by dotagents"); - assertFileIncludes(".opencode/plugins/qa-tools.ts", "../.agents/plugins/qa-tools/opencode/plugin.ts"); + assertSymlink(".opencode/skills/plugin-qa"); + assertSymlink(".opencode/agents/plugin-reviewer.md"); } function assertFile(relativePath) { diff --git a/specs/SPEC.md b/specs/SPEC.md index bc5cec4..b74a63e 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -31,7 +31,7 @@ The manifest file. Lives at the project root. ```toml version = 1 -agents = ["claude", "cursor", "codex", "opencode"] +agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] [project] name = "my-project" # Optional. For display purposes. @@ -82,7 +82,7 @@ targets = ["claude", "codex", "opencode"] name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" -targets = ["claude", "cursor", "codex", "grok", "opencode"] +targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] ``` ### Fields @@ -93,14 +93,14 @@ targets = ["claude", "cursor", "codex", "grok", "opencode"] |-------|----------|-------------| | `version` | Yes | Schema version. Always `1`. | | `defaultRepositorySource` | No | Host used for shorthand `owner/repo` skill sources. Valid values: `github`, `gitlab`. Defaults to `github`. | -| `agents` | No | Array of agent tool IDs. Valid: `claude`, `cursor`, `codex`, `vscode`, `grok`, `opencode`. Defaults to `[]`. When set, dotagents creates skills symlinks and runtime config files for each agent where supported. | +| `agents` | No | Array of agent tool IDs. Valid: `claude`, `cursor`, `codex`, `vscode`, `grok`, `opencode`, `pi`. Defaults to `[]`. When set, dotagents creates skills symlinks and runtime config files for each agent where supported. `grok` and `pi` are plugin-only targets. | | `project` | No | Project metadata. | | `symlinks` | No | Symlink configuration (legacy — prefer `agents` for new projects). | | `skills` | No | Skill dependencies (array of tables). | | `mcp` | No | MCP server declarations (array of tables). Generates agent-specific config files during install/sync. | | `hooks` | No | Hook declarations (array of tables). Generates agent-specific hook config files during install/sync for agents that support hooks. | | `subagents` | No | Custom subagent declarations (array of tables). Generates runtime-specific subagent files during install/sync for Claude, Cursor, Codex, and OpenCode. | -| `plugins` | No | Plugin declarations (array of tables). Installs canonical bundles into `.agents/plugins/` and generates runtime-specific plugin outputs during install/sync for Claude, Cursor, Codex, Grok, and OpenCode. | +| `plugins` | No | Plugin declarations (array of tables). Installs canonical bundles into `.agents/plugins/` and generates runtime-specific plugin outputs during install/sync for Claude, Cursor, Codex, Grok, OpenCode, and Pi skill projection. | | `trust` | No | Trusted source restrictions. When absent, all sources allowed. See `[trust]` below. | | `minimum_release_age` | No | Minimum age in **minutes** a commit must have before it's eligible for install. Applies to all git skills, subagents, and plugins (pinned and unpinned). For unpinned sources, resolves to the newest qualifying commit. For pinned sources (`ref`), rejects if the pinned commit is too new. Install fails with an error if no qualifying commit exists. When absent, always uses HEAD. | | `minimum_release_age_exclude` | No | Sources excluded from the age gate. Accepts org names (`"myorg"` matches all repos), org/repo (`"myorg/skills"` exact match), or org wildcards (`"myorg/*"`). Defaults to `[]`. | @@ -263,9 +263,10 @@ Generated project-scope plugin outputs: | Cursor | `.cursor-plugin/marketplace.json`; `.agents/plugins//.cursor-plugin/plugin.json` | | Codex | `.agents/plugins/marketplace.json`; `.agents/plugins//.codex-plugin/plugin.json` | | Grok Build | `.grok/plugins//` managed copy | -| OpenCode | `.opencode/plugins/.js|ts` re-export module when the plugin declares or contains one OpenCode module | +| OpenCode | Plugin `skills/` symlinked into `.opencode/skills/`; plugin Markdown `agents/` symlinked into `.opencode/agents/` | +| Pi | Plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target | -Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok and OpenCode projections are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. +Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok copies and OpenCode/Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Plugins are currently project-scope only. `install --user` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. diff --git a/specs/plugins.md b/specs/plugins.md index 415ae44..777f5a9 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -18,7 +18,7 @@ dotagents has one canonical plugin source of truth: The canonical catalog and plugin manifests should use a generalized Codex-compatible format. Codex compatibility is the baseline because Codex already reads `.agents/plugins/marketplace.json` for repo-scoped marketplaces, but dotagents treats the schema as portable project metadata rather than Codex-only configuration. -Every other runtime output is generated from `.agents/plugins/` when that runtime does not directly consume the canonical path or schema. Generated artifacts may include `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.claude-plugin/plugin.json`, `.agents/plugins//.cursor-plugin/plugin.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/` plugin files, `.opencode/plugins/` modules, or runtime settings/config entries. These generated artifacts are runtime projections, not the source of truth, except that `.agents/plugins/marketplace.json` is also Codex's documented repo-scoped marketplace location. +Every other runtime output is generated from `.agents/plugins/` when that runtime does not directly consume the canonical path or schema. Generated artifacts may include `.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.claude-plugin/plugin.json`, `.agents/plugins//.cursor-plugin/plugin.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/` plugin files, `.opencode/skills/` links, `.opencode/agents/` links, or Pi skill links in `.agents/skills/`. These generated artifacts are runtime projections, not the source of truth, except that `.agents/plugins/marketplace.json` is also Codex's documented repo-scoped marketplace location. ## Input and Output Contract @@ -93,7 +93,7 @@ Every imported plugin should produce this portable shape: | `version` | optional version from native or portable manifest metadata | | `description` | optional short description | | `metadata` | portable package metadata: author, homepage, repository, license, keywords, category, and logo paths | -| `components` | discovered component paths for skills, agents, commands, rules, hooks, MCP servers, LSP servers, apps/connectors, monitors, binaries, settings, and OpenCode plugins | +| `components` | discovered component paths for skills, agents, commands, rules, hooks, MCP servers, LSP servers, apps/connectors, monitors, binaries, and settings | | `native` | optional raw native plugin metadata keyed by runtime | Only metadata and component locations are portable. Component semantics remain native unless they are already modeled by dotagents elsewhere, such as skills, MCP servers, hooks, and subagents. @@ -128,9 +128,7 @@ Remote plugins should install into the same canonical directory during `install` | `-- hooks.json |-- .mcp.json |-- .lsp.json -|-- bin/ -`-- opencode/ - `-- plugin.ts +`-- bin/ ``` Example canonical marketplace: @@ -177,14 +175,11 @@ Example dotagents manifest: "rules": "./rules", "hooks": "./hooks/hooks.json", "mcpServers": "./.mcp.json", - "lspServers": "./.lsp.json", - "opencode": { - "plugins": ["./opencode/plugin.ts"] - } + "lspServers": "./.lsp.json" } ``` -Path fields must be relative to the plugin root and must not contain `..`. OpenCode plugin manifests may declare at most one JS/TS module because dotagents projects it to one deterministic `.opencode/plugins/.js|ts` file. Generated native manifests may add a leading `./` when the target runtime requires it. +Path fields must be relative to the plugin root and must not contain `..`. Generated native manifests may add a leading `./` when the target runtime requires it. dotagents may also import plugin sources that already use native runtime manifests such as `.claude-plugin/plugin.json`, `.cursor-plugin/plugin.json`, or `.codex-plugin/plugin.json`. It may import `.plugin/plugin.json` for compatibility with the npm `plugins` package, but it should normalize that input into `.agents/plugins//plugin.json` on install. @@ -198,7 +193,7 @@ Input and matching-runtime output should use the same native format where possib | Cursor | `.cursor-plugin/plugin.json` | marketplace installs and `~/.cursor/plugins/local/` for local testing | rules, skills, agents, commands, hooks, `mcp.json`, assets, scripts | Manifest component paths replace default discovery for that component. Multi-plugin repos use `.cursor-plugin/marketplace.json`. | | Codex | `.codex-plugin/plugin.json` | repo/user marketplaces under `.agents/plugins/marketplace.json` and plugin cache installs | skills, hooks, `.app.json`, `.mcp.json`, assets | Published plugins commonly need rich `interface` metadata. Codex sets `PLUGIN_ROOT` and `PLUGIN_DATA`, plus Claude-compatible plugin env vars. | | Grok Build | Claude-compatible plugin directories plus `.grok/plugins/` and marketplaces | `./.grok/plugins/`, `~/.grok/plugins/`, marketplace installs, configured plugin paths, `--plugin-dir` | skills, agents, hooks, MCP servers, LSP servers | Docs state Grok automatically reads Claude Code marketplaces, plugins, skills, MCPs, agents, hooks, and `.claude/rules/` alongside `.grok/`. | -| OpenCode | JavaScript or TypeScript plugin modules | `.opencode/plugins/`, `~/.config/opencode/plugins/`, npm package names in `opencode.json` | JS/TS plugin functions returning hooks and custom tools | OpenCode plugins are executable modules, not bundle manifests. dotagents should only install OpenCode-native plugin modules or npm plugin entries for the plugin portion. | +| OpenCode | No bundle manifest | `.opencode/skills/`, `.agents/skills/`, `.opencode/agents/`, `opencode.json` | skills, agents, MCP servers; JS/TS plugin modules separately support hooks/tools | dotagents projects plugin `skills/` and Markdown `agents/` into OpenCode's native component directories. It does not turn dotagents plugin bundles into OpenCode JS plugins. | ## Discovery @@ -222,7 +217,7 @@ dotagents should handle plugin components in three buckets: | Bucket | Components | Behavior | |--------|------------|----------| | Portable existing dotagents concepts | skills, MCP servers, hooks, subagents/agents | Load through existing parsers where possible and generate runtime configs using existing agent writers. Preserve native plugin copies for matching runtimes. | -| Runtime-specific files | Cursor rules, Claude/Codex/Grok LSP servers, Codex apps, Claude monitors, Claude settings, binaries, OpenCode JS/TS plugins | Copy or expose only for runtimes that natively understand them. Do not attempt cross-runtime conversion. | +| Runtime-specific files | Cursor rules, Claude/Codex/Grok LSP servers, Codex apps, Claude monitors, Claude settings, binaries | Copy or expose only for runtimes that natively understand them. Do not attempt cross-runtime conversion. | | Metadata and marketplace files | plugin manifests, marketplace manifests, icons, screenshots, README | Preserve and regenerate native manifests/marketplaces from normalized metadata when needed. | Plugin skills should not be flattened into `.agents/skills/` by default. Native plugin systems namespace plugin skills and avoid conflicts. Flattening may be offered later as an explicit compatibility mode for runtimes without bundle support. @@ -239,7 +234,7 @@ Portable plugin-authored config should use `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` | Codex | `${PLUGIN_ROOT}` | `${PLUGIN_DATA}` | | Grok Build | `${GROK_PLUGIN_ROOT}` | `${GROK_PLUGIN_DATA}` | | Cursor | Target-specific support to verify before implementation | Target-specific support to verify before implementation | -| OpenCode | Not applicable for local JS/TS modules unless the module reads environment variables set by dotagents | Not applicable | +| OpenCode | Not applicable to projected skills and agents | Not applicable | Codex also sets Claude-compatible plugin variables for compatibility. For generated Codex output, dotagents can leave `${PLUGIN_ROOT}` intact. The current implementation does not rewrite Claude or Grok hook, MCP, or LSP config files; portable variable rewriting is reserved for a later projection pass. @@ -258,7 +253,8 @@ Generated project-scope outputs should be: | Cursor | `.cursor-plugin/marketplace.json` and `.agents/plugins//.cursor-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources and each targeted plugin gets a Cursor-native manifest. | | Codex | `.agents/plugins/marketplace.json` and generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | Generated marketplace uses deterministic `{ "source": "local", "path": "./.agents/plugins/" }` entries relative to the project root. | | Grok Build | `.grok/plugins/` for targeted plugins | Not generated yet | The projection is a managed copy of the canonical plugin bundle with a `.dotagents-managed` marker. | -| OpenCode | `.opencode/plugins/.js|ts` re-export module for an explicit OpenCode module | Not generated yet | dotagents only exposes the module declared in `manifest.opencode.plugins` or discovered at `opencode/plugin.ts|js`; it does not synthesize OpenCode JS/TS code from other runtime hooks. | +| OpenCode | Plugin `skills/` symlinked into `.opencode/skills/`; plugin Markdown `agents/` symlinked into `.opencode/agents/` | Not generated yet | dotagents exposes bundle components through OpenCode's native resource directories and skips collisions with user-authored files. | +| Pi | Plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target | Not generated yet | Pi reads agentskills from `.agents/skills/`; only plugin skills are projected. | Installed and generated files are dotagents-managed. `install` and `sync` may overwrite stale managed files and prune removed managed files, but they must not overwrite hand-written plugin files without a generated marker or a canonical installed bundle path owned by dotagents. Generated Claude, Cursor, and Codex manifests carry `metadata.managedBy = "dotagents"` so target removal can prune them without deleting user-authored native plugin manifests. @@ -286,7 +282,7 @@ resolved_commit = "0123456789abcdef0123456789abcdef01234567" ## Security and Trust -Plugins are a higher-risk dependency class than plain skills because they may bundle executable hooks, MCP servers, LSP servers, binaries, or OpenCode JS/TS modules. +Plugins are a higher-risk dependency class than plain skills because they may bundle executable hooks, MCP servers, LSP servers, or binaries. dotagents should: @@ -301,7 +297,7 @@ dotagents should: dotagents should not: 1. Standardize a universal hook event model across all runtimes. -2. Convert OpenCode JavaScript/TypeScript plugin code from declarative hook files. +2. Generate or install OpenCode JavaScript/TypeScript plugins from dotagents plugin bundles. 3. Convert Cursor rules into Claude, Codex, Grok, or OpenCode instructions by default. 4. Install app integrations or perform OAuth/authentication for users. 5. Bypass native marketplace review, policy, or trust prompts. From 7bc2ed1a3cd83a8edff11cde95ff86633d26ea6d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 15:40:41 -0700 Subject: [PATCH 18/35] fix(dotagents): Preserve plugin skill gitignore boundaries Keep Pi plugin skill projections from adding .agents/.gitignore entries when the target skill path is user-authored or otherwise unmanaged. Persist resolved install lock entries before canonical subagent file writes so recovery state matches copied artifacts if those writes fail. Cover install, sync, remove, and doctor repair paths for the Pi collision behavior. Co-Authored-By: GPT-5 Codex --- .../dotagents/src/cli/commands/doctor.test.ts | 26 ++++ packages/dotagents/src/cli/commands/doctor.ts | 11 +- .../src/cli/commands/install.test.ts | 132 ++++++++++++------ .../dotagents/src/cli/commands/install.ts | 3 +- .../src/cli/commands/install/gitignore.ts | 8 +- .../dotagents/src/cli/commands/remove.test.ts | 45 ++++++ packages/dotagents/src/cli/commands/remove.ts | 11 +- .../dotagents/src/cli/commands/sync.test.ts | 25 ++++ packages/dotagents/src/cli/commands/sync.ts | 11 +- packages/dotagents/src/gitignore/skills.ts | 56 ++++++++ specs/SPEC.md | 19 +-- 11 files changed, 287 insertions(+), 60 deletions(-) create mode 100644 packages/dotagents/src/gitignore/skills.ts diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 38b2097..b3debf2 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -298,6 +298,32 @@ source = "path:." expect(gitignore).not.toContain("/plugins/local-tools/"); }); + it("does not gitignore orphan skills that collide with Pi plugin projections when recreating .agents/.gitignore", async () => { + await mkdir(join(projectRoot, ".agents", "skills", "review"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "review", "SKILL.md"), "---\nname: review\ndescription: Review\n---\n"); + const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), "---\nname: review\ndescription: Review\n---\n"); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["pi"] + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); + + await runDoctor({ scope: resolveScope("project", projectRoot), fix: true }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/skills/review"); + expect(gitignore).toContain("/plugins/review-tools/"); + }); + it("includes lockfile subagents when recreating .agents/.gitignore", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index ec55f3f..583f5b4 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -4,6 +4,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { parse as parseTOML } from "smol-toml"; import { parseArgs } from "node:util"; import chalk from "chalk"; +import { filterManagedPluginSkillNames } from "../../gitignore/skills.js"; import { checkRootGitignoreEntries, ensureRootGitignoreEntries, writeAgentsGitignore } from "../../gitignore/writer.js"; import { loadConfig } from "../../config/loader.js"; import { isWildcardDep } from "../../config/schema.js"; @@ -165,7 +166,15 @@ export async function runDoctor(opts: DoctorOptions): Promise { ); await writeAgentsGitignore( scope.agentsDir, - [...managedNames, ...await projectedPiSkillNames(config.agents, installedPlugins.plugins)], + [ + ...managedNames, + ...await filterManagedPluginSkillNames( + await projectedPiSkillNames(config.agents, installedPlugins.plugins), + config, + scope.skillsDir, + scope.pluginsDir, + ), + ], managedSubagentNames, managedPluginNames, ); diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 0e1f12a..040362d 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access, chmod } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -222,6 +222,78 @@ source = "path:plugin-source/review-tools" expect(agentsGitignore).toContain("/plugins/review-tools/"); }); + it("does not gitignore in-place skills that collide with Pi plugin projections", async () => { + await mkdir(join(projectRoot, ".agents", "skills", "review"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "review", "SKILL.md"), SKILL_MD("review")); + + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile(join(sourceDir, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["pi"] + +[[skills]] +name = "review" +source = "path:.agents/skills/review" + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + const result = await runInstall({ scope }); + + expect(result.pluginWarnings).toEqual([ + { + agent: "pi", + name: "review-tools", + message: `Pi plugin skill projection exists and is not managed by dotagents: ${join(projectRoot, ".agents", "skills", "review")}`, + }, + ]); + const agentsGitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(agentsGitignore).not.toContain("/skills/review"); + expect(agentsGitignore).toContain("/plugins/review-tools/"); + }); + + it("does not gitignore orphan skills that collide with Pi plugin projections", async () => { + await mkdir(join(projectRoot, ".agents", "skills", "review"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "review", "SKILL.md"), SKILL_MD("review")); + + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile(join(sourceDir, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["pi"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + const result = await runInstall({ scope }); + + expect(result.pluginWarnings).toEqual([ + { + agent: "pi", + name: "review-tools", + message: `Pi plugin skill projection exists and is not managed by dotagents: ${join(projectRoot, ".agents", "skills", "review")}`, + }, + ]); + const agentsGitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(agentsGitignore).not.toContain("/skills/review"); + expect(agentsGitignore).toContain("/plugins/review-tools/"); + }); + it("installs a plugin from a git source and records resolved lock metadata", async () => { await ensureGitRepo(); const pluginDir = join(repoDir, "plugins", "review-tools"); @@ -1064,7 +1136,7 @@ path = "code-reviewer.md" expect(syncResult.adopted).toEqual([]); }); - it("does not update the lockfile when installed subagent writes fail", async () => { + it("keeps resolved lock entries when installed subagent writes fail", async () => { const skillSourceDir = join(projectRoot, "local-skills", "pdf"); await mkdir(skillSourceDir, { recursive: true }); await writeFile(join(skillSourceDir, "SKILL.md"), SKILL_MD("pdf")); @@ -1118,53 +1190,21 @@ path = "code-reviewer.md" ); const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); - expect(lockfile).toEqual(originalLockfile); + expect(lockfile).toEqual({ + version: 1, + skills: { + pdf: { source: "path:local-skills/pdf" }, + }, + subagents: { + "code-reviewer": { + source: "path:agents", + }, + }, + plugins: {}, + }); expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true); }); - it("does not write the lockfile when stale subagent pruning fails", async () => { - const sourceDir = join(projectRoot, "agents"); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); - - const installedDir = join(projectRoot, ".agents", "agents"); - await mkdir(installedDir, { recursive: true }); - const stalePath = join(installedDir, "old-reviewer.md"); - await writeFile( - stalePath, - `--- -# ${DOTAGENTS_SUBAGENT_MARKER} -name: old-reviewer -description: Review old code. ---- - -Review old code. -`, - "utf-8", - ); - await chmod(stalePath, 0o000); - - await writeFile( - join(projectRoot, "agents.toml"), - `version = 1 -[[subagents]] -name = "code-reviewer" -source = "path:agents" -path = "code-reviewer.md" -`, - ); - - const scope = resolveScope("project", projectRoot); - try { - await expect(runInstall({ scope })).rejects.toThrow(); - } finally { - await chmod(stalePath, 0o600).catch(() => {}); - } - - expect(await loadLockfile(join(projectRoot, "agents.lock"))).toBeNull(); - expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(true); - }); - it("keeps lock entries when runtime subagent writes fail", async () => { const skillSourceDir = join(projectRoot, "local-skills", "pdf"); await mkdir(skillSourceDir, { recursive: true }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 5ea277f..ff023cd 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -62,7 +62,6 @@ export async function runInstall(opts: InstallOptions): Promise { plugins: plugins.lockEntries, }; - await writeCanonicalSubagents(config, scope, subagents.subagents, frozen); const writeLock = !frozen && ( !!lockfile || config.skills.length > 0 || @@ -70,8 +69,10 @@ export async function runInstall(opts: InstallOptions): Promise { config.plugins.length > 0 ); if (writeLock) { + // Preserve resolved dependency state even if later canonical file writes fail. await writeLockfile(scope.lockPath, newLock); } + await writeCanonicalSubagents(config, scope, subagents.subagents, frozen); await writeInstallGitignore(config, lockfile, scope, { installedSkillNames: skills.installed, diff --git a/packages/dotagents/src/cli/commands/install/gitignore.ts b/packages/dotagents/src/cli/commands/install/gitignore.ts index b25ea41..267c3de 100644 --- a/packages/dotagents/src/cli/commands/install/gitignore.ts +++ b/packages/dotagents/src/cli/commands/install/gitignore.ts @@ -2,6 +2,7 @@ import chalk from "chalk"; import { isWildcardDep, type AgentsConfig } from "../../../config/schema.js"; import type { Lockfile } from "../../../lockfile/schema.js"; import type { ScopeRoot } from "../../../scope.js"; +import { filterManagedPluginSkillNames } from "../../../gitignore/skills.js"; import { checkRootGitignoreEntries, writeAgentsGitignore } from "../../../gitignore/writer.js"; import { isInPlaceSkill } from "../../../utils/fs.js"; import { isInPlacePluginSource, type PluginDeclaration } from "../../../plugins/store.js"; @@ -61,7 +62,12 @@ export async function writeInstallGitignore( const managedSkills = [ ...managedSkillNames(config, artifacts.installedSkillNames), - ...await projectedPiSkillNames(config.agents, artifacts.plugins), + ...await filterManagedPluginSkillNames( + await projectedPiSkillNames(config.agents, artifacts.plugins), + config, + scope.skillsDir, + scope.pluginsDir, + ), ]; await writeAgentsGitignore( diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index e8b1856..a7571ef 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -141,6 +141,51 @@ source = "path:plugins/review-tools" expect(gitignore).not.toContain("/plugins/marketplace.json"); }); + it("does not gitignore orphan skills that collide with Pi plugin projections after removing a skill", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["pi"] + +[[skills]] +name = "pdf" +source = "path:local-skills/pdf" + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + await mkdir(join(projectRoot, ".agents", "skills", "pdf"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"), SKILL_MD("pdf")); + await mkdir(join(projectRoot, ".agents", "skills", "review"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "review", "SKILL.md"), SKILL_MD("review")); + const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + pdf: { + source: "path:local-skills/pdf", + }, + }, + plugins: { + "review-tools": { + source: "path:plugins/review-tools", + }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await runRemove({ scope, skillName: "pdf" }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/skills/review"); + expect(gitignore).toContain("/plugins/review-tools/"); + }); + it("throws RemoveError for skill not in config", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); const scope = resolveScope("project", projectRoot); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index 98b7a76..1fb0ebb 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -8,6 +8,7 @@ import { isWildcardDep } from "../../config/schema.js"; import { removeSkillFromConfig, removeSkillBlocksBySource, addExcludeToWildcard } from "../../config/writer.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; +import { filterManagedPluginSkillNames } from "../../gitignore/skills.js"; import { writeAgentsGitignore } from "../../gitignore/writer.js"; import { sourcesMatch, parseOwnerRepoShorthand, isExplicitSourceSpecifier } from "@sentry/dotagents-lib"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; @@ -182,7 +183,15 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { ); await writeAgentsGitignore( scope.agentsDir, - [...managedNames, ...await projectedPiSkillNames(config.agents, installedPlugins.plugins)], + [ + ...managedNames, + ...await filterManagedPluginSkillNames( + await projectedPiSkillNames(config.agents, installedPlugins.plugins), + config, + scope.skillsDir, + scope.pluginsDir, + ), + ], [...managedSubagentNames], [...managedPluginNames], ); diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index c723a7e..e3fe2b1 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -383,6 +383,31 @@ source = "path:plugin-source/review-tools" expect(gitignore).toContain("/skills/pdf"); }); + it("does not gitignore orphan skills that collide with Pi plugin projections", async () => { + await mkdir(join(projectRoot, ".agents", "skills", "review"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "review", "SKILL.md"), SKILL_MD("review")); + const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["pi"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + await runSync({ scope: resolveScope("project", projectRoot) }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/skills/review"); + expect(gitignore).toContain("/plugins/review-tools/"); + }); + it("repairs missing MCP configs", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 1bda18a..ee6c5b8 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -8,6 +8,7 @@ import { normalizeSource } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { addSkillToConfig } from "../../config/writer.js"; +import { filterManagedPluginSkillNames } from "../../gitignore/skills.js"; import { writeAgentsGitignore, checkRootGitignoreEntries } from "../../gitignore/writer.js"; import { ensureSkillsSymlink, verifySymlinks } from "../../symlinks/manager.js"; import { getAgent } from "../../targets/registry.js"; @@ -200,7 +201,15 @@ export async function runSync(opts: SyncOptions): Promise { ); await writeAgentsGitignore( agentsDir, - [...managedNames, ...await projectedPiSkillNames(config.agents, installedPluginsForGitignore.plugins)], + [ + ...managedNames, + ...await filterManagedPluginSkillNames( + await projectedPiSkillNames(config.agents, installedPluginsForGitignore.plugins), + config, + skillsDir, + pluginsDir, + ), + ], [...managedSubagentNames], [...managedPluginNames], ); diff --git a/packages/dotagents/src/gitignore/skills.ts b/packages/dotagents/src/gitignore/skills.ts new file mode 100644 index 0000000..88f2583 --- /dev/null +++ b/packages/dotagents/src/gitignore/skills.ts @@ -0,0 +1,56 @@ +import { lstat, readlink } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { isWildcardDep, type AgentsConfig } from "../config/schema.js"; +import { isInPlaceSkill } from "../utils/fs.js"; + +/** Returns skill names declared as user-authored in-place `.agents/skills/` content. */ +function declaredInPlaceSkillNames(config: AgentsConfig): Set { + return new Set( + config.skills + .filter((skill) => !isWildcardDep(skill) && isInPlaceSkill(skill.source)) + .map((skill) => skill.name), + ); +} + +/** Keeps generated plugin skill projections from gitignoring user-authored skills. */ +export async function filterManagedPluginSkillNames( + skillNames: string[], + config: AgentsConfig, + skillsDir: string, + pluginsDir: string, +): Promise { + const inPlaceNames = declaredInPlaceSkillNames(config); + const managedNames: string[] = []; + for (const name of skillNames) { + if (inPlaceNames.has(name)) {continue;} + if (await hasUnmanagedExistingSkill(skillsDir, pluginsDir, name)) {continue;} + managedNames.push(name); + } + return managedNames; +} + +async function hasUnmanagedExistingSkill( + skillsDir: string, + pluginsDir: string, + name: string, +): Promise { + const skillPath = join(skillsDir, name); + try { + const stat = await lstat(skillPath); + if (!stat.isSymbolicLink()) {return true;} + const target = await readlink(skillPath); + return !isInside(resolve(skillsDir, target), pluginsDir); + } catch (err) { + if (isNotFoundError(err)) {return false;} + throw err; + } +} + +function isInside(path: string, root: string): boolean { + const relPath = relative(resolve(root), resolve(path)); + return relPath === "" || (!relPath.startsWith("..") && !isAbsolute(relPath)); +} + +function isNotFoundError(err: unknown): boolean { + return err instanceof Error && "code" in err && err.code === "ENOENT"; +} diff --git a/specs/SPEC.md b/specs/SPEC.md index b74a63e..7a4c2eb 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -506,18 +506,19 @@ dotagents install a. Resolve source (check cache with TTL-based refresh, clone/fetch if needed) b. Discover skill within the repo c. Copy skill directory into `.agents/skills//` -3. Resolve and install configured subagents into `.agents/agents/` +3. Resolve configured subagents 4. Resolve and install configured project-scope plugins into `.agents/plugins//`; reject user-scope plugin declarations 5. Write `agents.lock` with the current configured skills, subagents, and plugins - In `--frozen` mode, require configured dependencies to already be present in `agents.lock`, load subagents and plugins from installed files, do not update the lockfile, and do not prune existing managed subagent or plugin files -6. Regenerate `.agents/.gitignore` -7. Warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` -8. Create/verify symlinks (legacy `[symlinks]` and agent-specific) -9. Write MCP config files for each declared agent -10. Write hook config files for each declared agent that supports hooks -11. Write generated subagent files for each declared agent that supports custom subagents -12. Write generated plugin runtime projections for each declared agent that supports plugins -13. Print summary +6. Install configured subagents into `.agents/agents/` +7. Regenerate `.agents/.gitignore` +8. Warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` +9. Create/verify symlinks (legacy `[symlinks]` and agent-specific) +10. Write MCP config files for each declared agent +11. Write hook config files for each declared agent that supports hooks +12. Write generated subagent files for each declared agent that supports custom subagents +13. Write generated plugin runtime projections for each declared agent that supports plugins +14. Print summary ### `dotagents add ` From 8b62f87b4fc0427fdc20ce83e9ee57846fdb05d9 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 15:51:16 -0700 Subject: [PATCH 19/35] fix(dotagents): Constrain plugin projection paths Reject plugin discovery symlinks that resolve outside the source root and skip unsafe manifest component paths before generating runtime projections. Validate Pi-projected skill names before using them as filesystem paths so plugin bundles cannot create links outside managed skill directories. Co-Authored-By: Codex --- .../src/plugins/runtime/component-paths.ts | 11 ++ .../src/plugins/runtime/manifest-values.ts | 6 + .../src/plugins/runtime/manifests.ts | 110 +++++++++++++----- .../src/plugins/runtime/writer.test.ts | 83 +++++++++++++ .../dotagents/src/plugins/runtime/writer.ts | 68 +++++++++-- packages/dotagents/src/plugins/store.test.ts | 61 +++++++++- packages/dotagents/src/plugins/store.ts | 19 ++- 7 files changed, 311 insertions(+), 47 deletions(-) create mode 100644 packages/dotagents/src/plugins/runtime/component-paths.ts diff --git a/packages/dotagents/src/plugins/runtime/component-paths.ts b/packages/dotagents/src/plugins/runtime/component-paths.ts new file mode 100644 index 0000000..fb121f7 --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/component-paths.ts @@ -0,0 +1,11 @@ +import { isAbsolute } from "node:path"; + +/** Manifest component paths are always relative to the plugin directory. */ +export function isSafeComponentPath(value: string): boolean { + if (value.length === 0) {return false;} + if (isAbsolute(value)) {return false;} + if (/^[a-zA-Z]:[\\/]/.test(value)) {return false;} + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) {return false;} + return !value.replaceAll("\\", "/").split("/").includes(".."); +} + diff --git a/packages/dotagents/src/plugins/runtime/manifest-values.ts b/packages/dotagents/src/plugins/runtime/manifest-values.ts index 45d1862..c0b544e 100644 --- a/packages/dotagents/src/plugins/runtime/manifest-values.ts +++ b/packages/dotagents/src/plugins/runtime/manifest-values.ts @@ -1,5 +1,6 @@ import { relative } from "node:path"; import type { PluginManifest } from "../schema.js"; +import { isSafeComponentPath } from "./component-paths.js"; /** Reads string-valued manifest fields for generated plugin projections. */ export function manifestString(manifest: PluginManifest, key: string): string | undefined { @@ -12,6 +13,11 @@ export function runtimePath(value: string): string { return value.startsWith(".") ? value : `./${value}`; } +/** Normalizes only path-safe manifest component paths. */ +export function safeRuntimePath(value: string): string | null { + return isSafeComponentPath(value) ? runtimePath(value) : null; +} + /** Builds a human-readable display name from a plugin package name. */ export function titleCase(value: string): string { return value diff --git a/packages/dotagents/src/plugins/runtime/manifests.ts b/packages/dotagents/src/plugins/runtime/manifests.ts index b412e78..7b72c0a 100644 --- a/packages/dotagents/src/plugins/runtime/manifests.ts +++ b/packages/dotagents/src/plugins/runtime/manifests.ts @@ -5,11 +5,24 @@ import type { PluginDeclaration } from "../store.js"; import { DOTAGENTS_METADATA, isManagedJsonFile, stableJson, writeJsonIfChanged } from "./files.js"; import { manifestString, - runtimePath, + safeRuntimePath, titleCase, } from "./manifest-values.js"; import type { PluginWriteWarning } from "./types.js"; +const COMPONENT_KEYS: Array = [ + "skills", + "agents", + "commands", + "rules", + "hooks", + "mcpServers", + "lspServers", + "apps", + "monitors", + "bin", +]; + /** Writes the managed Claude plugin manifest projection when safe to overwrite. */ export async function writeClaudeManifest( plugin: PluginDeclaration, @@ -24,7 +37,7 @@ export async function writeClaudeManifest( }); return false; } - const manifest = claudeRuntimeManifest(plugin); + const manifest = claudeRuntimeManifest(plugin, warnings); return writeJsonIfChanged(filePath, stableJson(manifest)); } @@ -42,7 +55,7 @@ export async function writeCursorManifest( }); return false; } - const manifest = cursorRuntimeManifest(plugin); + const manifest = cursorRuntimeManifest(plugin, warnings); return writeJsonIfChanged(filePath, stableJson(manifest)); } @@ -60,12 +73,12 @@ export async function writeCodexManifest( }); return false; } - const manifest = codexRuntimeManifest(plugin); + const manifest = codexRuntimeManifest(plugin, warnings); return writeJsonIfChanged(filePath, stableJson(manifest)); } /** Builds the managed Claude manifest projection using Claude-native paths. */ -function claudeRuntimeManifest(plugin: PluginDeclaration): Record { +function claudeRuntimeManifest(plugin: PluginDeclaration, warnings: PluginWriteWarning[]): Record { const manifest: Record = { name: plugin.name, }; @@ -77,21 +90,21 @@ function claudeRuntimeManifest(plugin: PluginDeclaration): Record { +function cursorRuntimeManifest(plugin: PluginDeclaration, warnings: PluginWriteWarning[]): Record { const manifest: Record = { name: plugin.name, }; @@ -113,28 +126,28 @@ function cursorRuntimeManifest(plugin: PluginDeclaration): Record { +function codexRuntimeManifest(plugin: PluginDeclaration, warnings: PluginWriteWarning[]): Record { const manifest: Record = { ...plugin.manifest, name: plugin.name, }; + for (const key of COMPONENT_KEYS) { + delete manifest[key]; + copyRuntimeComponentField(plugin, manifest, key, warnings); + } - if (!manifest["skills"] && existsSync(join(plugin.pluginDir, "skills"))) { + if (plugin.manifest["skills"] === undefined && !manifest["skills"] && existsSync(join(plugin.pluginDir, "skills"))) { manifest["skills"] = "./skills"; } - if (!manifest["agents"] && existsSync(join(plugin.pluginDir, "agents"))) { + if (plugin.manifest["agents"] === undefined && !manifest["agents"] && existsSync(join(plugin.pluginDir, "agents"))) { manifest["agents"] = "./agents"; } - if (!manifest["commands"] && existsSync(join(plugin.pluginDir, "commands"))) { + if (plugin.manifest["commands"] === undefined && !manifest["commands"] && existsSync(join(plugin.pluginDir, "commands"))) { manifest["commands"] = "./commands"; } - if (!manifest["hooks"] && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { + if (plugin.manifest["hooks"] === undefined && !manifest["hooks"] && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { manifest["hooks"] = "./hooks/hooks.json"; } - if (!manifest["mcpServers"] && existsSync(join(plugin.pluginDir, ".mcp.json"))) { + if (plugin.manifest["mcpServers"] === undefined && !manifest["mcpServers"] && existsSync(join(plugin.pluginDir, ".mcp.json"))) { manifest["mcpServers"] = "./.mcp.json"; } - if (!manifest["lspServers"] && existsSync(join(plugin.pluginDir, ".lsp.json"))) { + if (plugin.manifest["lspServers"] === undefined && !manifest["lspServers"] && existsSync(join(plugin.pluginDir, ".lsp.json"))) { manifest["lspServers"] = "./.lsp.json"; } - if (!manifest["apps"] && existsSync(join(plugin.pluginDir, ".app.json"))) { + if (plugin.manifest["apps"] === undefined && !manifest["apps"] && existsSync(join(plugin.pluginDir, ".app.json"))) { manifest["apps"] = "./.app.json"; } if (!manifest["interface"]) { @@ -204,15 +221,44 @@ function copyManifestField(source: PluginManifest, dest: Record } } -function copyRuntimeComponentField(source: PluginManifest, dest: Record, key: keyof PluginManifest): boolean { - const value = source[key]; +function copyRuntimeComponentField( + plugin: PluginDeclaration, + dest: Record, + key: keyof PluginManifest, + warnings: PluginWriteWarning[], +): boolean { + const value = plugin.manifest[key]; if (typeof value === "string") { - dest[key] = runtimePath(value); + const path = safeRuntimePath(value); + if (path) { + dest[key] = path; + } else { + warnUnsafeComponentPath(plugin, key, value, warnings); + } return true; } if (Array.isArray(value) && value.every((item) => typeof item === "string")) { - dest[key] = value.map(runtimePath); + const paths = value.flatMap((item) => { + const path = safeRuntimePath(item); + if (path) {return [path];} + warnUnsafeComponentPath(plugin, key, item, warnings); + return []; + }); + if (paths.length > 0) {dest[key] = paths;} return true; } return false; } + +function warnUnsafeComponentPath( + plugin: PluginDeclaration, + key: keyof PluginManifest, + value: string, + warnings: PluginWriteWarning[], +): void { + warnings.push({ + agent: "plugin", + name: plugin.name, + message: `Plugin component path "${value}" for "${String(key)}" is not a safe relative path and was skipped.`, + }); +} diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index a3ee03c..1a8f473 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { PluginDeclaration } from "../store.js"; import { prunePluginOutputs, + projectedPiSkillNames, verifyPluginOutputs, writePluginOutputs, } from "./writer.js"; @@ -192,6 +193,41 @@ describe("plugin writer", () => { expect(cursorManifest["skills"]).toBe("./plugin-skills"); }); + it("skips unsafe runtime component paths in generated manifests", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { + skills: "../outside", + }, + }); + + const result = await writePluginOutputs(["claude", "cursor", "codex"], [alpha], root); + + expect(result.written).toBe(6); + expect(result.warnings).toEqual([ + { + agent: "plugin", + name: "alpha-tools", + message: 'Plugin component path "../outside" for "skills" is not a safe relative path and was skipped.', + }, + { + agent: "plugin", + name: "alpha-tools", + message: 'Plugin component path "../outside" for "skills" is not a safe relative path and was skipped.', + }, + { + agent: "plugin", + name: "alpha-tools", + message: 'Plugin component path "../outside" for "skills" is not a safe relative path and was skipped.', + }, + ]); + const claudeManifest = JSON.parse(await readFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), "utf-8")) as Record; + const cursorManifest = JSON.parse(await readFile(join(alpha.pluginDir, ".cursor-plugin", "plugin.json"), "utf-8")) as Record; + const codexManifest = JSON.parse(await readFile(join(alpha.pluginDir, ".codex-plugin", "plugin.json"), "utf-8")) as Record; + expect(claudeManifest["skills"]).toBeUndefined(); + expect(cursorManifest["skills"]).toBeUndefined(); + expect(codexManifest["skills"]).toBeUndefined(); + }); + it("does not overwrite unmanaged marketplace files", async () => { const alpha = await plugin("alpha-tools"); await mkdir(join(root, ".claude-plugin"), { recursive: true }); @@ -371,6 +407,53 @@ describe("plugin writer", () => { ); }); + it("warns and skips invalid Pi plugin skill names", async () => { + const alpha = await plugin("alpha-tools"); + await mkdir(join(alpha.pluginDir, "skills", "bad"), { recursive: true }); + await writeFile( + join(alpha.pluginDir, "skills", "bad", "SKILL.md"), + `---\nname: ../../outside\ndescription: Bad skill\n---\n`, + "utf-8", + ); + + const result = await writePluginOutputs(["pi"], [alpha], root); + + expect(result).toEqual({ + written: 0, + warnings: [ + { + agent: "pi", + name: "alpha-tools", + message: 'Plugin skill "../../outside" cannot be projected to Pi because skill names must start with alphanumeric and contain only [a-zA-Z0-9._-].', + }, + ], + }); + expect(existsSync(join(root, "outside"))).toBe(false); + await expect(projectedPiSkillNames(["pi"], [alpha])).resolves.toEqual([]); + }); + + it("warns and skips unsafe plugin component paths for skill projections", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { + skills: "../outside", + }, + }); + + const result = await writePluginOutputs(["pi"], [alpha], root); + + expect(result).toEqual({ + written: 0, + warnings: [ + { + agent: "pi", + name: "alpha-tools", + message: 'Plugin component path "../outside" for "skills" is not a safe relative path and was skipped.', + }, + ], + }); + expect(existsSync(join(root, ".agents", "skills"))).toBe(false); + }); + it("does not overwrite unmanaged Pi plugin skill projections", async () => { const alpha = await plugin("alpha-tools"); await writePluginSkill(alpha.pluginDir, "plugin-qa"); diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index 37e2188..c196835 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -18,6 +18,7 @@ import { writeJsonIfChanged, } from "./files.js"; import { writeClaudeManifest, writeCodexManifest, writeCursorManifest } from "./manifests.js"; +import { isSafeComponentPath } from "./component-paths.js"; // Owns deterministic runtime plugin projections. Existing runtime artifacts are // overwritten only when they carry dotagents managed metadata or a managed marker. @@ -34,6 +35,7 @@ interface ComponentLink { } const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const SKILL_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; /** Returns plugin skill names projected into `.agents/skills/` for Pi. */ export async function projectedPiSkillNames( @@ -45,7 +47,7 @@ export async function projectedPiSkillNames( for (const plugin of selected) { for (const skillsDir of componentDirs(plugin, "skills", "skills")) { for (const name of await skillNamesInDir(skillsDir)) { - names.add(name); + if (SKILL_NAME_PATTERN.test(name)) {names.add(name);} } } } @@ -341,7 +343,7 @@ async function componentLinks( warnings: PluginWriteWarning[], ): Promise { const links: ComponentLink[] = []; - for (const skillsDir of componentDirs(plugin, "skills", "skills")) { + for (const skillsDir of componentDirs(plugin, "skills", "skills", agent, warnings)) { const skillDestRoot = agent === "opencode" ? join(projectRoot, ".opencode", "skills") : join(projectRoot, ".agents", "skills"); @@ -349,7 +351,7 @@ async function componentLinks( } if (agent === "opencode") { - for (const agentsDir of componentDirs(plugin, "agents", "agents")) { + for (const agentsDir of componentDirs(plugin, "agents", "agents", agent, warnings)) { links.push(...await markdownComponentLinks(agent, plugin, agentsDir, join(projectRoot, ".opencode", "agents"))); } } @@ -393,6 +395,14 @@ async function skillComponentLinks( }); continue; } + if (agent === "pi" && !SKILL_NAME_PATTERN.test(skillName)) { + warnings.push({ + agent, + name: plugin.name, + message: `Plugin skill "${skillName}" cannot be projected to Pi because skill names must start with alphanumeric and contain only [a-zA-Z0-9._-].`, + }); + continue; + } links.push({ agent, @@ -472,18 +482,58 @@ function componentDirs( plugin: PluginDeclaration, manifestKey: keyof Pick, defaultDir: string, + agent?: ComponentProjectionAgent, + warnings: PluginWriteWarning[] = [], ): string[] { - const explicit = manifestPaths(plugin.manifest[manifestKey]); - const paths = explicit.length > 0 ? explicit : [defaultDir]; + const explicit = manifestPaths(plugin.manifest[manifestKey], plugin, manifestKey, agent, warnings); + const paths = explicit.present ? explicit.paths : [defaultDir]; return paths.map((path) => join(plugin.pluginDir, path)); } -function manifestPaths(value: unknown): string[] { - if (typeof value === "string") {return [value];} +function manifestPaths( + value: unknown, + plugin: PluginDeclaration, + manifestKey: keyof Pick, + agent?: ComponentProjectionAgent, + warnings: PluginWriteWarning[] = [], +): { present: boolean; paths: string[] } { + if (typeof value === "string") { + return { + present: true, + paths: safeComponentPaths([value], plugin, manifestKey, agent, warnings), + }; + } if (Array.isArray(value) && value.every((item) => typeof item === "string")) { - return value; + return { + present: true, + paths: safeComponentPaths(value, plugin, manifestKey, agent, warnings), + }; + } + return { present: false, paths: [] }; +} + +function safeComponentPaths( + values: string[], + plugin: PluginDeclaration, + manifestKey: keyof Pick, + agent?: ComponentProjectionAgent, + warnings: PluginWriteWarning[] = [], +): string[] { + const paths: string[] = []; + for (const value of values) { + if (isSafeComponentPath(value)) { + paths.push(value); + continue; + } + if (agent) { + warnings.push({ + agent, + name: plugin.name, + message: `Plugin component path "${value}" for "${String(manifestKey)}" is not a safe relative path and was skipped.`, + }); + } } - return []; + return paths; } async function writeManagedJsonOutput( diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 56b4345..6305ed1 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; @@ -98,4 +98,63 @@ describe("plugin store", () => { await rm(projectRoot, { recursive: true, force: true }); } }); + + it("rejects canonical plugin discovery symlinks that escape the source root", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const outsideDir = join(projectRoot, "outside", "review-tools"); + await mkdir(join(sourceRoot, ".agents", "plugins"), { recursive: true }); + await mkdir(outsideDir, { recursive: true }); + await writeFile( + join(outsideDir, "plugin.json"), + JSON.stringify({ name: "review-tools" }), + "utf-8", + ); + await symlink(outsideDir, join(sourceRoot, ".agents", "plugins", "review-tools")); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow(/Canonical plugin source resolves outside source/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("rejects marketplace plugin source symlinks that escape the source root", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const outsideDir = join(projectRoot, "outside", "review-tools"); + await mkdir(join(sourceRoot, "plugins"), { recursive: true }); + await mkdir(outsideDir, { recursive: true }); + await writeFile( + join(outsideDir, "plugin.json"), + JSON.stringify({ name: "review-tools" }), + "utf-8", + ); + await symlink(outsideDir, join(sourceRoot, "plugins", "review-tools")); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + plugins: [ + { + name: "review-tools", + source: { source: "local", path: "plugins/review-tools" }, + }, + ], + }), + "utf-8", + ); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow(/Marketplace plugin source resolves outside source/); + } 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 e277251..2dcd3d6 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, posix, relative, resolve } from "node:path"; import { applyDefaultRepositorySource, @@ -278,12 +278,13 @@ async function discoverPlugin( config: PluginConfig, ): Promise { if (config.path) { - const dir = resolveInside(sourceDir, config.path, "Plugin path"); + const dir = await resolveInside(sourceDir, config.path, "Plugin path"); return loadPluginCandidate(sourceDir, dir, { name: config.name }); } const matches: PluginCandidate[] = []; - const canonical = await loadPluginCandidate(sourceDir, join(sourceDir, ".agents", "plugins", config.name)); + const canonicalDir = await resolveInside(sourceDir, join(".agents", "plugins", config.name), "Canonical plugin source"); + const canonical = await loadPluginCandidate(sourceDir, canonicalDir); if (canonical && candidateMatches(config.name, canonical)) { return canonical; } @@ -334,7 +335,7 @@ async function discoverFromMarketplaces( } const marketplaceRoot = dirname(filePath); - const pluginDir = resolveInside(marketplaceRoot, join(root, path), "Marketplace plugin source"); + const pluginDir = await resolveInside(marketplaceRoot, join(root, path), "Marketplace plugin source"); const candidate = await loadPluginCandidate(sourceDir, pluginDir, marketplaceManifestOverlay(entry)); if (candidate) {return candidate;} } @@ -503,13 +504,21 @@ function stripDotSlash(path: string): string { } /** Resolves a selector path while preserving the source-root containment boundary. */ -function resolveInside(root: string, childPath: string, label: string): string { +async function resolveInside(root: string, childPath: string, label: string): Promise { const rootPath = resolve(root); const filePath = resolve(rootPath, childPath); const relPath = relative(rootPath, filePath); if (relPath.startsWith("..") || isAbsolute(relPath)) { throw new Error(`${label} resolves outside source: ${childPath}`); } + if (existsSync(filePath)) { + const rootRealPath = await realpath(rootPath); + const fileRealPath = await realpath(filePath); + const realRelPath = relative(rootRealPath, fileRealPath); + if (realRelPath.startsWith("..") || isAbsolute(realRelPath)) { + throw new Error(`${label} resolves outside source: ${childPath}`); + } + } return filePath; } From 2b376dafd9804e7a48ae55231f4adfb357738400 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 16:03:47 -0700 Subject: [PATCH 20/35] fix(dotagents): Cover plugin path boundary cases Reject backslash-rooted plugin component paths and add regressions for both backslash component paths and explicit plugin path symlink escapes. Simplify the runtime component path helpers while keeping component path ownership local to plugin runtime code. Co-Authored-By: Codex --- .../src/plugins/runtime/component-paths.ts | 2 +- .../src/plugins/runtime/manifest-values.ts | 6 --- .../src/plugins/runtime/manifests.ts | 29 ++++++++---- .../src/plugins/runtime/writer.test.ts | 20 ++++++++ .../dotagents/src/plugins/runtime/writer.ts | 46 ++++++++----------- packages/dotagents/src/plugins/store.test.ts | 23 ++++++++++ 6 files changed, 84 insertions(+), 42 deletions(-) diff --git a/packages/dotagents/src/plugins/runtime/component-paths.ts b/packages/dotagents/src/plugins/runtime/component-paths.ts index fb121f7..ef56c11 100644 --- a/packages/dotagents/src/plugins/runtime/component-paths.ts +++ b/packages/dotagents/src/plugins/runtime/component-paths.ts @@ -4,8 +4,8 @@ import { isAbsolute } from "node:path"; export function isSafeComponentPath(value: string): boolean { if (value.length === 0) {return false;} if (isAbsolute(value)) {return false;} + if (value.startsWith("\\")) {return false;} if (/^[a-zA-Z]:[\\/]/.test(value)) {return false;} if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) {return false;} return !value.replaceAll("\\", "/").split("/").includes(".."); } - diff --git a/packages/dotagents/src/plugins/runtime/manifest-values.ts b/packages/dotagents/src/plugins/runtime/manifest-values.ts index c0b544e..45d1862 100644 --- a/packages/dotagents/src/plugins/runtime/manifest-values.ts +++ b/packages/dotagents/src/plugins/runtime/manifest-values.ts @@ -1,6 +1,5 @@ import { relative } from "node:path"; import type { PluginManifest } from "../schema.js"; -import { isSafeComponentPath } from "./component-paths.js"; /** Reads string-valued manifest fields for generated plugin projections. */ export function manifestString(manifest: PluginManifest, key: string): string | undefined { @@ -13,11 +12,6 @@ export function runtimePath(value: string): string { return value.startsWith(".") ? value : `./${value}`; } -/** Normalizes only path-safe manifest component paths. */ -export function safeRuntimePath(value: string): string | null { - return isSafeComponentPath(value) ? runtimePath(value) : null; -} - /** Builds a human-readable display name from a plugin package name. */ export function titleCase(value: string): string { return value diff --git a/packages/dotagents/src/plugins/runtime/manifests.ts b/packages/dotagents/src/plugins/runtime/manifests.ts index 7b72c0a..9c5b18d 100644 --- a/packages/dotagents/src/plugins/runtime/manifests.ts +++ b/packages/dotagents/src/plugins/runtime/manifests.ts @@ -5,12 +5,25 @@ import type { PluginDeclaration } from "../store.js"; import { DOTAGENTS_METADATA, isManagedJsonFile, stableJson, writeJsonIfChanged } from "./files.js"; import { manifestString, - safeRuntimePath, + runtimePath, titleCase, } from "./manifest-values.js"; import type { PluginWriteWarning } from "./types.js"; +import { isSafeComponentPath } from "./component-paths.js"; -const COMPONENT_KEYS: Array = [ +type ComponentManifestKey = + | "skills" + | "agents" + | "commands" + | "rules" + | "hooks" + | "mcpServers" + | "lspServers" + | "apps" + | "monitors" + | "bin"; + +const COMPONENT_KEYS: ComponentManifestKey[] = [ "skills", "agents", "commands", @@ -224,14 +237,13 @@ function copyManifestField(source: PluginManifest, dest: Record function copyRuntimeComponentField( plugin: PluginDeclaration, dest: Record, - key: keyof PluginManifest, + key: ComponentManifestKey, warnings: PluginWriteWarning[], ): boolean { const value = plugin.manifest[key]; if (typeof value === "string") { - const path = safeRuntimePath(value); - if (path) { - dest[key] = path; + if (isSafeComponentPath(value)) { + dest[key] = runtimePath(value); } else { warnUnsafeComponentPath(plugin, key, value, warnings); } @@ -239,8 +251,7 @@ function copyRuntimeComponentField( } if (Array.isArray(value) && value.every((item) => typeof item === "string")) { const paths = value.flatMap((item) => { - const path = safeRuntimePath(item); - if (path) {return [path];} + if (isSafeComponentPath(item)) {return [runtimePath(item)];} warnUnsafeComponentPath(plugin, key, item, warnings); return []; }); @@ -252,7 +263,7 @@ function copyRuntimeComponentField( function warnUnsafeComponentPath( plugin: PluginDeclaration, - key: keyof PluginManifest, + key: ComponentManifestKey, value: string, warnings: PluginWriteWarning[], ): void { diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index 1a8f473..b6c175d 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -228,6 +228,26 @@ describe("plugin writer", () => { expect(codexManifest["skills"]).toBeUndefined(); }); + it("skips backslash-rooted runtime component paths in generated manifests", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { + skills: "\\outside", + }, + }); + + const result = await writePluginOutputs(["codex"], [alpha], root); + + expect(result.warnings).toEqual([ + { + agent: "plugin", + name: "alpha-tools", + message: 'Plugin component path "\\outside" for "skills" is not a safe relative path and was skipped.', + }, + ]); + const codexManifest = JSON.parse(await readFile(join(alpha.pluginDir, ".codex-plugin", "plugin.json"), "utf-8")) as Record; + expect(codexManifest["skills"]).toBeUndefined(); + }); + it("does not overwrite unmanaged marketplace files", async () => { const alpha = await plugin("alpha-tools"); await mkdir(join(root, ".claude-plugin"), { recursive: true }); diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index c196835..b04f348 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -497,45 +497,39 @@ function manifestPaths( agent?: ComponentProjectionAgent, warnings: PluginWriteWarning[] = [], ): { present: boolean; paths: string[] } { + const collect = (values: string[]): string[] => { + const paths: string[] = []; + for (const item of values) { + if (isSafeComponentPath(item)) { + paths.push(item); + continue; + } + if (agent) { + warnings.push({ + agent, + name: plugin.name, + message: `Plugin component path "${item}" for "${String(manifestKey)}" is not a safe relative path and was skipped.`, + }); + } + } + return paths; + }; + if (typeof value === "string") { return { present: true, - paths: safeComponentPaths([value], plugin, manifestKey, agent, warnings), + paths: collect([value]), }; } if (Array.isArray(value) && value.every((item) => typeof item === "string")) { return { present: true, - paths: safeComponentPaths(value, plugin, manifestKey, agent, warnings), + paths: collect(value), }; } return { present: false, paths: [] }; } -function safeComponentPaths( - values: string[], - plugin: PluginDeclaration, - manifestKey: keyof Pick, - agent?: ComponentProjectionAgent, - warnings: PluginWriteWarning[] = [], -): string[] { - const paths: string[] = []; - for (const value of values) { - if (isSafeComponentPath(value)) { - paths.push(value); - continue; - } - if (agent) { - warnings.push({ - agent, - name: plugin.name, - message: `Plugin component path "${value}" for "${String(manifestKey)}" is not a safe relative path and was skipped.`, - }); - } - } - return paths; -} - async function writeManagedJsonOutput( output: RuntimeOutput, warnings: PluginWriteWarning[], diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 6305ed1..9c46407 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -122,6 +122,29 @@ describe("plugin store", () => { } }); + it("rejects explicit plugin path symlinks that escape the source root", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const outsideDir = join(projectRoot, "outside", "review-tools"); + await mkdir(join(sourceRoot, "plugins"), { recursive: true }); + await mkdir(outsideDir, { recursive: true }); + await writeFile( + join(outsideDir, "plugin.json"), + JSON.stringify({ name: "review-tools" }), + "utf-8", + ); + await symlink(outsideDir, join(sourceRoot, "plugins", "review-tools")); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source", path: "plugins/review-tools" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow(/Plugin path resolves outside source/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + it("rejects marketplace plugin source symlinks that escape the source root", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); try { From f35c72bed4b4ea04b11732aff5657b3c8d605952 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 16:08:28 -0700 Subject: [PATCH 21/35] fix(dotagents): Preserve same-project plugin gitignore state Keep remove and doctor from treating same-project canonical plugin declarations as managed gitignore entries, even when a stale lockfile plugin row shares the name. Co-Authored-By: Codex --- .../dotagents/src/cli/commands/doctor.test.ts | 29 +++++++++++++ packages/dotagents/src/cli/commands/doctor.ts | 6 +++ .../dotagents/src/cli/commands/remove.test.ts | 41 +++++++++++++++++++ packages/dotagents/src/cli/commands/remove.ts | 12 ++++-- 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index b3debf2..1b81d59 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -298,6 +298,35 @@ source = "path:." expect(gitignore).not.toContain("/plugins/local-tools/"); }); + it("does not let stale lock entries gitignore same-project canonical plugins", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" })); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "local-tools" +source = "path:." +`, + ); + await writeFile( + join(projectRoot, "agents.lock"), + `version = 1 + +[plugins.local-tools] +source = "path:plugins/local-tools" +`, + ); + await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); + + await runDoctor({ scope: resolveScope("project", projectRoot), fix: true }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/plugins/local-tools/"); + }); + it("does not gitignore orphan skills that collide with Pi plugin projections when recreating .agents/.gitignore", async () => { await mkdir(join(projectRoot, ".agents", "skills", "review"), { recursive: true }); await writeFile(join(projectRoot, ".agents", "skills", "review", "SKILL.md"), "---\nname: review\ndescription: Review\n---\n"); diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index 583f5b4..5089cd5 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -359,8 +359,14 @@ function getManagedPluginNames( .filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) .map((plugin) => plugin.name), ); + const sameProjectPluginNames = new Set( + config.plugins + .filter((plugin) => isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) + .map((plugin) => plugin.name), + ); if (lockfile) { for (const [name, locked] of Object.entries(lockfile.plugins)) { + if (sameProjectPluginNames.has(name)) {continue;} if (!isInPlacePluginSource(locked.source)) { names.add(name); } diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index a7571ef..f23b027 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -141,6 +141,47 @@ source = "path:plugins/review-tools" expect(gitignore).not.toContain("/plugins/marketplace.json"); }); + it("does not gitignore same-project canonical plugins after removing a skill", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[skills]] +name = "pdf" +source = "path:local-skills/pdf" + +[[plugins]] +name = "local-tools" +source = "path:." +`, + ); + await mkdir(join(projectRoot, ".agents", "skills", "pdf"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"), SKILL_MD("pdf")); + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" }, null, 2)); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + pdf: { + source: "path:local-skills/pdf", + }, + }, + plugins: { + "local-tools": { + source: "path:plugins/local-tools", + }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await runRemove({ scope, skillName: "pdf" }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/skills/pdf"); + expect(gitignore).not.toContain("/plugins/local-tools/"); + }); + it("does not gitignore orphan skills that collide with Pi plugin projections after removing a skill", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index 1fb0ebb..55d4ea3 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -14,7 +14,7 @@ import { sourcesMatch, parseOwnerRepoShorthand, isExplicitSourceSpecifier } from import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { isInPlaceSkill } from "../../utils/fs.js"; -import { isInPlacePluginSource, loadInstalledPlugins } from "../../plugins/store.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins } from "../../plugins/store.js"; import { projectedPiSkillNames } from "../../plugins/runtime/writer.js"; export class RemoveError extends Error { @@ -167,11 +167,17 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { } const managedPluginNames = new Set( config.plugins - .filter((plugin) => !isInPlacePluginSource(plugin.source)) + .filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) + .map((plugin) => plugin.name), + ); + const sameProjectPluginNames = new Set( + config.plugins + .filter((plugin) => isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) .map((plugin) => plugin.name), ); if (lockfile) { for (const [name, locked] of Object.entries(lockfile.plugins)) { + if (sameProjectPluginNames.has(name)) {continue;} if (!isInPlacePluginSource(locked.source)) { managedPluginNames.add(name); } @@ -179,7 +185,7 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { } const installedPlugins = await loadInstalledPlugins( scope.pluginsDir, - config.plugins.filter((plugin) => !isInPlacePluginSource(plugin.source)), + config.plugins.filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)), ); await writeAgentsGitignore( scope.agentsDir, From 862f13c5e231ccca9762e63f29e6e3e73f75e421 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 29 Jun 2026 13:57:27 -0700 Subject: [PATCH 22/35] fix(dotagents): Guard plugin install destinations Only overwrite canonical plugin install destinations when agents.lock proves dotagents manages them. Keep marketplace discovery tolerant of unsupported extension source objects while reporting a clear error when no compatible plugin can be found. Update plugin docs and regressions for the managed overwrite rule, Pi plugin projection notes, and marketplace fallback behavior. Co-Authored-By: Codex --- README.md | 2 +- docs/public/llms.txt | 8 +- .../src/cli/commands/install.test.ts | 135 ++++++++++++++++-- .../dotagents/src/cli/commands/install.ts | 3 +- .../src/cli/commands/install/plugins.ts | 29 ++++ packages/dotagents/src/plugins/store.test.ts | 43 ------ packages/dotagents/src/plugins/store.ts | 22 ++- skills/dotagents/SKILL.md | 14 +- skills/dotagents/references/config-schema.md | 58 +++++++- skills/dotagents/references/configuration.md | 33 ++++- specs/SPEC.md | 2 +- specs/plugins.md | 1 + 12 files changed, 270 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 19cadbb..6be7d45 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ path = "plugins/review-tools" targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] ``` -The canonical plugin format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a Codex-compatible marketplace baseline. Known input fields are validated, component paths must be relative filesystem paths, unknown manifest extension fields are preserved in installed bundles, marketplace extension fields are accepted but not projected, `targets` are limited to configured agents, and generated outputs are deterministic. dotagents rejects plugin sources that resolve to the same project's `.agents/plugins//` install destination, so same-repo plugins are never installed onto themselves. +The canonical plugin format is `.agents/plugins/marketplace.json` plus `.agents/plugins//plugin.json`, using a Codex-compatible marketplace baseline. Known input fields are validated, component paths must be relative filesystem paths, unknown manifest extension fields are preserved in installed bundles, marketplace extension fields are accepted but not projected, `targets` are limited to configured agents, and generated outputs are deterministic. dotagents rejects plugin sources that resolve to the same project's `.agents/plugins//` install destination, so same-repo plugins are never installed onto themselves. Existing plugin install destinations are overwritten only when `agents.lock` proves they are managed by dotagents. Plugin declarations are project-scope only for now. `dotagents --user install` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. diff --git a/docs/public/llms.txt b/docs/public/llms.txt index f25f7fd..7d58ee3 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -296,7 +296,7 @@ Generated project-scope plugin outputs: - OpenCode: plugin `skills/` symlinked into `.opencode/skills/`; plugin Markdown `agents/` symlinked into `.opencode/agents/` - Pi: plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target -Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok copies and OpenCode/Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. +Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok copies and OpenCode/Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Existing plugin install destinations are overwritten only when `agents.lock` proves they are managed by dotagents. Plugin declarations are project-scope only for now. `install --user` rejects `[[plugins]]` entries and `sync --user` reports them as unsupported because user-scope runtime plugin projections are not generated yet. @@ -463,7 +463,7 @@ Skills from wildcard entries are marked with a wildcard indicator. npx @sentry/dotagents doctor [--fix] ``` -Check project health: gitignore setup, installed skills, symlinks, and legacy config fields. Use `--fix` to auto-repair issues. +Check project health: gitignore setup, installed skills and plugins, symlinks, generated config, and legacy config fields. Use `--fix` to auto-repair issues. | Flag | Description | |------|-------------| @@ -479,9 +479,9 @@ Check project health: gitignore setup, installed skills, symlinks, and legacy co | `vscode` | VS Code Copilot | `.vscode` | (reads `.agents/skills/` natively) | `.vscode/mcp.json` | `.claude/settings.json` | Not supported | | `opencode` | OpenCode | `.opencode` | (reads `.agents/skills/` natively) | `opencode.json` | Not supported | `.opencode/agents/*.md` | -Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, OpenCode, and Pi read `.agents/skills/` directly. +Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, and OpenCode read `.agents/skills/` directly. -[Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. No agent target or symlink configuration needed. +[Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. Normal skills need no Pi-specific target or symlink configuration; plugin bundles can target `pi` when their `skills/` components should be exposed there. ## Scopes diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 040362d..e22fd30 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -222,6 +222,79 @@ source = "path:plugin-source/review-tools" expect(agentsGitignore).toContain("/plugins/review-tools/"); }); + it("does not overwrite an existing unmanaged plugin install destination", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile(join(sourceDir, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + + const existingDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(existingDir, { recursive: true }); + await writeFile(join(existingDir, "plugin.json"), JSON.stringify({ name: "review-tools", description: "Hand written" }, null, 2)); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow(/install destination already exists and is not managed/); + + const existingManifest = JSON.parse(await readFile(join(existingDir, "plugin.json"), "utf-8")) as Record; + expect(existingManifest["description"]).toBe("Hand written"); + }); + + it("overwrites an existing managed plugin install destination", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Updated plugin" }, null, 2), + ); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + + const existingDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(existingDir, "skills", "old-review"), { recursive: true }); + await writeFile( + join(existingDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Old managed plugin" }, null, 2), + ); + await writeFile(join(existingDir, "skills", "old-review", "SKILL.md"), SKILL_MD("old-review")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "review-tools": { source: "path:plugin-source/review-tools" }, + }, + }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse(await readFile(join(existingDir, "plugin.json"), "utf-8")) as Record; + expect(installedManifest["description"]).toBe("Updated plugin"); + expect(existsSync(join(existingDir, "skills", "review", "SKILL.md"))).toBe(true); + expect(existsSync(join(existingDir, "skills", "old-review", "SKILL.md"))).toBe(false); + }); + it("does not gitignore in-place skills that collide with Pi plugin projections", async () => { await mkdir(join(projectRoot, ".agents", "skills", "review"), { recursive: true }); await writeFile(join(projectRoot, ".agents", "skills", "review", "SKILL.md"), SKILL_MD("review")); @@ -746,14 +819,14 @@ source = "path:plugin-source" name: "review-tools", source: { source: "github", - path: "plugins/marketplace-review-tools", + path: "external/marketplace-review-tools", repo: "org/review-tools", }, }, ], }, null, 2), ); - const marketplaceOnlyDir = join(sourceRoot, "plugins", "marketplace-review-tools"); + const marketplaceOnlyDir = join(sourceRoot, "external", "marketplace-review-tools"); await mkdir(marketplaceOnlyDir, { recursive: true }); await writeFile( join(marketplaceOnlyDir, "plugin.json"), @@ -793,6 +866,51 @@ source = "path:plugin-source" ); }); + it("reports unsupported marketplace source objects when no compatible plugin is found", async () => { + const sourceRoot = join(projectRoot, "plugin-source"); + await mkdir(sourceRoot, { recursive: true }); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "source-marketplace", + plugins: [ + { + name: "review-tools", + source: { + source: "github", + path: "external/marketplace-review-tools", + repo: "org/review-tools", + }, + }, + ], + }, null, 2), + ); + const marketplaceOnlyDir = join(sourceRoot, "external", "marketplace-review-tools"); + await mkdir(marketplaceOnlyDir, { recursive: true }); + await writeFile( + join(marketplaceOnlyDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Marketplace-only plugin", + }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow( + /Matching marketplace entries use unsupported source types: github/, + ); + }); + it("generates plugin runtime outputs in frozen mode from installed bundles", async () => { const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); @@ -1190,18 +1308,7 @@ path = "code-reviewer.md" ); const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); - expect(lockfile).toEqual({ - version: 1, - skills: { - pdf: { source: "path:local-skills/pdf" }, - }, - subagents: { - "code-reviewer": { - source: "path:agents", - }, - }, - plugins: {}, - }); + expect(lockfile).toEqual(originalLockfile); expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true); }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index ff023cd..f49231f 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -68,11 +68,10 @@ export async function runInstall(opts: InstallOptions): Promise { config.subagents.length > 0 || config.plugins.length > 0 ); + await writeCanonicalSubagents(config, scope, subagents.subagents, frozen); if (writeLock) { - // Preserve resolved dependency state even if later canonical file writes fail. await writeLockfile(scope.lockPath, newLock); } - await writeCanonicalSubagents(config, scope, subagents.subagents, frozen); await writeInstallGitignore(config, lockfile, scope, { installedSkillNames: skills.installed, diff --git a/packages/dotagents/src/cli/commands/install/plugins.ts b/packages/dotagents/src/cli/commands/install/plugins.ts index aea2f12..317c1ee 100644 --- a/packages/dotagents/src/cli/commands/install/plugins.ts +++ b/packages/dotagents/src/cli/commands/install/plugins.ts @@ -1,4 +1,6 @@ +import { existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; import type { AgentsConfig, PluginConfig } from "../../../config/schema.js"; import type { Lockfile } from "../../../lockfile/schema.js"; import type { ScopeRoot } from "../../../scope.js"; @@ -6,6 +8,7 @@ import { installPluginBundle, isInPlacePluginSource, isProjectPluginSource, + isSameProjectPluginConfig, loadInstalledPlugins, lockEntryForPlugin, type PluginDeclaration, @@ -50,6 +53,22 @@ function staleManagedPluginNames( .map(([name]) => name); } +/** Ensures installs only replace plugin destinations already owned by dotagents. */ +function assertPluginDestinationIsManaged( + pluginsDir: string, + name: string, + lockfile: Lockfile | null, +): void { + if (!existsSync(join(pluginsDir, name))) {return;} + + const locked = lockfile?.plugins[name]; + if (locked && !isInPlacePluginSource(locked.source)) {return;} + + throw new InstallError( + `Plugin "${name}" install destination already exists and is not managed by dotagents: ${join(pluginsDir, name)}`, + ); +} + /** Resolves, installs, and prunes canonical plugin bundles for install. */ export async function installPlugins( config: AgentsConfig, @@ -63,6 +82,15 @@ export async function installPlugins( if (frozen) { validateFrozenPlugins(config.plugins, lockfile); + const sameProjectPlugin = config.plugins.find((plugin) => + isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root) + ); + if (sameProjectPlugin) { + throw new InstallError( + `Plugin "${sameProjectPlugin.name}" source resolves inside this project's .agents/plugins/ tree. ` + + "Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.", + ); + } if (config.plugins.length > 0) { const loaded = await loadInstalledPlugins(scope.pluginsDir, config.plugins); if (loaded.issues.length > 0) { @@ -97,6 +125,7 @@ export async function installPlugins( "Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.", ); } + assertPluginDestinationIsManaged(scope.pluginsDir, resolved.plugin.name, lockfile); plugins.push(await installPluginBundle(scope.pluginsDir, resolved)); lockEntries[resolved.plugin.name] = lockEntryForPlugin(resolved); } diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 9c46407..7ef5dab 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -56,49 +56,6 @@ describe("plugin store", () => { )).toBe(true); }); - it("skips unsupported marketplace sources during discovery", async () => { - const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); - try { - const pluginDir = join(projectRoot, "plugins", "review-tools"); - const marketplaceOnlyDir = join(projectRoot, "plugins", "marketplace-review-tools"); - await mkdir(pluginDir, { recursive: true }); - await mkdir(marketplaceOnlyDir, { recursive: true }); - await writeFile( - join(projectRoot, "marketplace.json"), - JSON.stringify({ - name: "test-marketplace", - plugins: [ - { - name: "review-tools", - source: { source: "github", path: "plugins/marketplace-review-tools" }, - }, - ], - }), - "utf-8", - ); - await writeFile( - join(marketplaceOnlyDir, "plugin.json"), - JSON.stringify({ name: "review-tools", description: "Marketplace-only plugin" }), - "utf-8", - ); - await writeFile( - join(pluginDir, "plugin.json"), - JSON.stringify({ name: "review-tools", description: "Fallback local plugin" }), - "utf-8", - ); - - const resolved = await resolvePlugin( - { name: "review-tools", source: "path:." }, - { stateDir: join(projectRoot, "state"), projectRoot }, - ); - - expect(resolved.plugin.pluginDir).toBe(pluginDir); - expect(resolved.plugin.manifest.description).toBe("Fallback local plugin"); - } finally { - await rm(projectRoot, { recursive: true, force: true }); - } - }); - it("rejects canonical plugin discovery symlinks that escape the source root", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); try { diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts index 2dcd3d6..6fb8ffb 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -289,7 +289,8 @@ async function discoverPlugin( return canonical; } - const fromMarketplace = await discoverFromMarketplaces(sourceDir, config.name); + const unsupportedMarketplaceSources: string[] = []; + const fromMarketplace = await discoverFromMarketplaces(sourceDir, config.name, unsupportedMarketplaceSources); if (fromMarketplace) {return fromMarketplace;} for (const dir of [sourceDir, join(sourceDir, "plugins", config.name)]) { @@ -306,6 +307,11 @@ async function discoverPlugin( `Plugin "${config.name}" is ambiguous in ${config.source}: ${unique.map((m) => m.path).join(", ")}`, ); } + if (unique.length === 0 && unsupportedMarketplaceSources.length > 0) { + throw new Error( + `Plugin "${config.name}" not found in ${config.source}. Matching marketplace entries use unsupported source types: ${[...new Set(unsupportedMarketplaceSources)].join(", ")}. Only local marketplace sources are supported.`, + ); + } return unique[0] ?? null; } @@ -317,6 +323,7 @@ async function discoverPlugin( async function discoverFromMarketplaces( sourceDir: string, name: string, + unsupportedSources: string[], ): Promise { for (const marketplacePath of MARKETPLACE_PATHS) { const filePath = join(sourceDir, marketplacePath); @@ -329,8 +336,9 @@ async function discoverFromMarketplaces( for (const entry of marketplace.plugins) { if (entry.name !== name) {continue;} - const path = localMarketplacePath(entry.source); + const path = localMarketplacePath(entry); if (!path) { + unsupportedSources.push(marketplaceSourceType(entry)); continue; } @@ -490,7 +498,9 @@ function dedupeCandidates(candidates: PluginCandidate[]): PluginCandidate[] { return result; } -function localMarketplacePath(source: MarketplacePluginEntry["source"]): string | null { +/** Returns local marketplace paths; unsupported extension sources are skipped. */ +function localMarketplacePath(entry: MarketplacePluginEntry): string | null { + const { source } = entry; if (typeof source === "string") {return stripDotSlash(source);} if (source.source === "local" && typeof source.path === "string") { @@ -499,6 +509,12 @@ function localMarketplacePath(source: MarketplacePluginEntry["source"]): string return null; } +function marketplaceSourceType(entry: MarketplacePluginEntry): string { + const { source } = entry; + if (typeof source === "string") {return "local";} + return source.source ?? "extension"; +} + function stripDotSlash(path: string): string { return path.replace(/^\.\//, ""); } diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index 2fd8a0c..f3e0b24 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -1,9 +1,9 @@ --- name: dotagents -description: Manage agent skill dependencies with dotagents. Use when asked to "add a skill", "install skills", "remove a skill", "dotagents init", "agents.toml", "agents.lock", "sync skills", "list skills", "set up dotagents", "configure trust", "add MCP server", "add hook", "wildcard skills", "user scope", "dotagents doctor", or any dotagents-related task. +description: Manage dotagents dependencies and runtime config. Use when asked to "add a skill", "install skills", "remove a skill", "configure plugins", "configure subagents", "dotagents init", "agents.toml", "agents.lock", "sync skills", "list skills", "set up dotagents", "configure trust", "add MCP server", "add hook", "wildcard skills", "user scope", "dotagents doctor", or any dotagents-related task. --- -Manage agent skill dependencies declared in `agents.toml`. dotagents resolves, installs, and symlinks skills so multiple agent tools (Claude Code, Cursor, Codex, VS Code, OpenCode) discover them from `.agents/skills/`. +Manage dependencies declared in `agents.toml`. dotagents resolves skills, subagents, plugins, MCP servers, and hooks so agent tools (Claude Code, Cursor, Codex, Grok, VS Code, OpenCode, Pi) can use shared project config. ## Running dotagents @@ -49,11 +49,11 @@ npx @sentry/dotagents list | Command | Description | |---------|-------------| | `npx @sentry/dotagents init` | Initialize `agents.toml` and `.agents/` directory | -| `npx @sentry/dotagents install` | Install all skills from `agents.toml` | +| `npx @sentry/dotagents install` | Install all dependencies from `agents.toml` | | `npx @sentry/dotagents add ` | Add a skill dependency | | `npx @sentry/dotagents remove ` | Remove a skill | | `npx @sentry/dotagents sync` | Reconcile state (adopt orphans, repair symlinks, fix configs) | -| `npx @sentry/dotagents list` | Show installed skills and their status | +| `npx @sentry/dotagents list` | Show installed skills, plugins, and status | | `npx @sentry/dotagents mcp` | Add, remove, or list MCP server declarations | | `npx @sentry/dotagents trust` | Add, remove, or list trusted sources | | `npx @sentry/dotagents doctor` | Check project health and fix issues | @@ -76,12 +76,14 @@ For full options and flags, read [references/cli-reference.md](references/cli-re ## Key Concepts -- **`.agents/skills/`** is the canonical home for all installed skills -- **`agents.toml`** declares dependencies; **`agents.lock`** tracks managed skills +- **`.agents/skills/`** is the canonical home for skills; **`.agents/plugins/`** is the canonical home for plugins +- **`agents.toml`** declares dependencies; **`agents.lock`** tracks managed skills, subagents, and plugins - **Symlinks**: `.claude/skills/`, `.cursor/skills/` point to `.agents/skills/` - **Wildcards**: `name = "*"` installs all skills from a source, with optional `exclude` list - **Trust**: Optional `[trust]` section restricts which sources are allowed - **Hooks**: `[[hooks]]` declarations write tool-event hooks to each agent's config +- **Subagents**: `[[subagents]]` declarations install portable or native subagent files +- **Plugins**: `[[plugins]]` declarations install canonical bundles and generate runtime-specific plugin outputs - **Gitignore**: Managed skills are always gitignored; custom in-place skills are tracked - **User scope**: `--user` flag manages skills in `~/.agents/` shared across all projects - **Updates**: Run `npx @sentry/dotagents install` to refresh managed skills; there is no `update` command diff --git a/skills/dotagents/references/config-schema.md b/skills/dotagents/references/config-schema.md index a94394f..235af85 100644 --- a/skills/dotagents/references/config-schema.md +++ b/skills/dotagents/references/config-schema.md @@ -14,6 +14,8 @@ minimum_release_age_exclude = ["getsentry/*"] # Optional [[skills]] # Optional, array of skill entries [[mcp]] # Optional, array of MCP servers [[hooks]] # Optional, array of hook declarations +[[subagents]] # Optional, array of subagent dependencies +[[plugins]] # Optional, array of plugin dependencies ``` ## Top-Level Fields @@ -22,8 +24,8 @@ minimum_release_age_exclude = ["getsentry/*"] # Optional |-------|------|----------|---------|-------------| | `version` | integer | Yes | -- | Schema version, must be `1` | | `defaultRepositorySource` | string | No | `github` | Host for shorthand `owner/repo` sources. Valid values: `github`, `gitlab` | -| `agents` | string[] | No | `[]` | Agent targets: `claude`, `cursor`, `codex`, `vscode`, `opencode` | -| `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill can install | +| `agents` | string[] | No | `[]` | Agent targets: `claude`, `cursor`, `codex`, `vscode`, `grok`, `opencode`, `pi` | +| `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install | | `minimum_release_age_exclude` | string[] | No | `[]` | Sources that bypass `minimum_release_age` | ## Project Section @@ -144,6 +146,44 @@ command = "my-lint-check" # Required | `matcher` | string | No | Tool name to match (omit for all tools) | | `command` | string | Yes | Shell command to execute | +## Subagents Section + +```toml +[[subagents]] +name = "code-reviewer" # Required, unique subagent identifier +source = "getsentry/agent-pack" # Required, source repository or path +ref = "v1.0.0" # Optional, pin to tag/branch/commit +path = "agents/code-reviewer.md" # Optional, artifact path within source +targets = ["claude", "codex"] # Optional, subset of configured agents +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique subagent identifier | +| `source` | string | Yes | `owner/repo`, `owner/repo@ref`, GitHub/GitLab URL, `git:url`, or `path:relative` | +| `ref` | string | No | Tag, branch, or commit SHA to pin | +| `path` | string | No | Subagent artifact path within the source repo | +| `targets` | string[] | No | Runtime targets for generated subagent files | + +## Plugins Section + +```toml +[[plugins]] +name = "review-tools" # Required, unique plugin identifier +source = "getsentry/agent-plugins" # Required, source repository or path +ref = "v1.0.0" # Optional, pin to tag/branch/commit +path = "plugins/review-tools" # Optional, plugin directory within source +targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique plugin identifier | +| `source` | string | Yes | `owner/repo`, `owner/repo@ref`, GitHub/GitLab URL, `git:url`, or `path:relative`; well-known HTTPS sources are not supported for plugins | +| `ref` | string | No | Tag, branch, or commit SHA to pin | +| `path` | string | No | Plugin directory path within the source repo | +| `targets` | string[] | No | Plugin runtime targets; defaults to configured `agents` | + ## Lockfile (agents.lock) Auto-generated. Do not edit manually. Gitignored automatically. @@ -157,6 +197,18 @@ resolved_url = "https://github.com/getsentry/skills.git" resolved_path = "plugins/sentry-skills/skills/find-bugs" resolved_ref = "v1.0.0" resolved_commit = "0123456789abcdef0123456789abcdef01234567" + +[subagents.code-reviewer] +source = "getsentry/agent-pack" +resolved_url = "https://github.com/getsentry/agent-pack.git" +resolved_path = "agents/code-reviewer.md" +resolved_commit = "0123456789abcdef0123456789abcdef01234567" + +[plugins.review-tools] +source = "getsentry/agent-plugins" +resolved_url = "https://github.com/getsentry/agent-plugins.git" +resolved_path = "plugins/review-tools" +resolved_commit = "0123456789abcdef0123456789abcdef01234567" ``` | Field | Type | Description | @@ -167,7 +219,7 @@ resolved_commit = "0123456789abcdef0123456789abcdef01234567" | `resolved_ref` | string | Ref that was resolved (omitted for default branch) | | `resolved_commit` | string | Full commit SHA that was installed. Informational only | -Local path skills have `source` only. +Local path skills, subagents, and plugins have `source` only. ## Environment Variables diff --git a/skills/dotagents/references/configuration.md b/skills/dotagents/references/configuration.md index a3abd4e..0c96750 100644 --- a/skills/dotagents/references/configuration.md +++ b/skills/dotagents/references/configuration.md @@ -129,12 +129,38 @@ Hook configs are written per-agent: - `UserPromptSubmit` -> `beforeSubmitPrompt` - `Stop` -> `stop` +## Subagents + +Declare portable or native subagent artifacts with `[[subagents]]`. dotagents installs canonical managed files under `.agents/agents/` and writes runtime-specific files for supported agents. + +```toml +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +path = "agents/code-reviewer.md" +targets = ["claude", "codex", "opencode"] +``` + +## Plugins + +Declare plugin bundles with `[[plugins]]`. dotagents installs canonical bundles under `.agents/plugins//` and generates runtime-specific plugin outputs for configured targets. + +```toml +[[plugins]] +name = "review-tools" +source = "getsentry/agent-plugins" +path = "plugins/review-tools" +targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +``` + +Plugin declarations are project-scope only. User-scope plugin declarations are rejected. + ## Agents The `agents` array controls which agent tools get symlinks and configs. ```toml -agents = ["claude", "cursor", "codex", "vscode", "opencode"] +agents = ["claude", "cursor", "codex", "vscode", "grok", "opencode", "pi"] ``` Each agent gets: @@ -142,6 +168,7 @@ Each agent gets: - Or native discovery from `.agents/skills/` (Codex, VS Code, OpenCode) - MCP server configs in the agent's config file - Hook configs (where supported) +- Subagent and plugin runtime outputs (where supported) ## Scopes @@ -175,10 +202,10 @@ Use `minimum_release_age_exclude` for trusted sources that can bypass the age ga ## Gitignore -dotagents always manages gitignore. It generates `.agents/.gitignore` listing managed (remote) skills. In-place skills (`path:.agents/skills/...`) are never gitignored since they must be tracked in git. +dotagents always manages gitignore. It generates `.agents/.gitignore` listing managed skills, subagents, and plugins. In-place skills (`path:.agents/skills/...`) and in-place plugin sources are not gitignored since they must be tracked in git. Two files are added to the root `.gitignore` during `init`: -- `agents.lock` — tracks managed skills +- `agents.lock` — tracks managed skills, subagents, and plugins - `.agents/.gitignore` — excludes managed skill directories If these entries are missing, `install` and `sync` warn. Run `npx @sentry/dotagents doctor --fix` to add them. diff --git a/specs/SPEC.md b/specs/SPEC.md index 7a4c2eb..3d69f83 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -266,7 +266,7 @@ Generated project-scope plugin outputs: | OpenCode | Plugin `skills/` symlinked into `.opencode/skills/`; plugin Markdown `agents/` symlinked into `.opencode/agents/` | | Pi | Plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target | -Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok copies and OpenCode/Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. +Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok copies and OpenCode/Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Existing plugin install destinations are overwritten only when `agents.lock` proves they are managed by dotagents. Plugins are currently project-scope only. `install --user` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. diff --git a/specs/plugins.md b/specs/plugins.md index 777f5a9..eda3769 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -37,6 +37,7 @@ Generated outputs are deterministic: 3. Managed generated marketplace files are pruned when no configured plugin targets that runtime anymore. 4. Remote or copied plugin bundles under `.agents/plugins//` are treated as dotagents-managed and are listed in `.agents/.gitignore`. 5. Plugin sources that resolve to the same project's `.agents/plugins//` install destination are rejected. dotagents must not install a same-repo plugin onto itself. +6. Existing `.agents/plugins//` install destinations are overwritten only when `agents.lock` proves they are managed by dotagents. ## Documentation Sources From 379d6792a309b2a7ab2d1e14684d11615840a1de Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 30 Jun 2026 09:15:37 -0700 Subject: [PATCH 23/35] fix(dotagents): Address plugin support review feedback Add plugin-aware remove behavior for names and source removals, including managed bundle cleanup, lockfile updates, runtime projection pruning, and gitignore refresh. Constrain component projection pruning to managed plugin roots, remove obsolete OpenCode plugin module cleanup, and refresh public docs for plugin remove semantics. Co-Authored-By: GPT-5 Codex --- README.md | 2 +- docs/public/llms.txt | 4 +- docs/src/content/docs/cli.mdx | 60 +++++-- docs/src/content/docs/guide.mdx | 17 +- docs/src/content/docs/index.mdx | 15 +- .../dotagents/src/cli/commands/install.ts | 10 +- .../src/cli/commands/install/agent-runtime.ts | 3 +- .../dotagents/src/cli/commands/remove.test.ts | 85 ++++++++-- packages/dotagents/src/cli/commands/remove.ts | 159 +++++++++++++++--- .../dotagents/src/cli/commands/sync.test.ts | 2 +- packages/dotagents/src/cli/commands/sync.ts | 7 +- packages/dotagents/src/cli/index.ts | 4 +- packages/dotagents/src/config/writer.ts | 28 ++- .../src/plugins/runtime/writer.test.ts | 26 ++- .../dotagents/src/plugins/runtime/writer.ts | 57 +++---- skills/dotagents/SKILL.md | 12 +- skills/dotagents/references/cli-reference.md | 35 ++-- skills/dotagents/references/configuration.md | 2 +- specs/SPEC.md | 26 +-- 19 files changed, 413 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index 6be7d45..8b60187 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npx @sentry/dotagents install |---------|-------------| | `init` | Create `agents.toml` and `.agents/skills/` | | `add [skills...]` | Add skill dependencies | -| `remove [-y]` | Remove a skill or all skills from a source | +| `remove [-y]` | Remove a skill, plugin, or all dependencies from a source | | `install` | Install all dependencies from `agents.toml` | | `list` | Show declared skills, plugins, and their status | | `sync` | Reconcile state offline: adopt local skills, prune stale managed ones, repair configs | diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 7d58ee3..4b6e815 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -371,9 +371,9 @@ When adding multiple skills, any that already exist in `agents.toml` are skipped npx @sentry/dotagents remove [-y] ``` -Remove a skill from `agents.toml`, delete from disk, and update the lockfile. For skills sourced from a wildcard entry, prompts to add the skill to the `exclude` list instead of removing the entire wildcard. +Remove a skill or plugin from `agents.toml`, delete managed installed files, update the lockfile, prune generated plugin outputs when needed, and regenerate `.agents/.gitignore`. For skills sourced from a wildcard entry, prompts to add the skill to the `exclude` list instead of removing the entire wildcard. -When the argument is a source specifier (e.g. `owner/repo`, a URL) instead of a skill name, removes all skills from that source — both explicit and wildcard entries. Confirms before removing unless `-y` is passed. +When the argument is a source specifier (e.g. `owner/repo`, a URL) instead of a dependency name, removes all skills and plugins from that source. Confirms before removing unless `-y` is passed. ### sync diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 8f01f9d..bea8ef7 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -54,12 +54,15 @@ dotagents --user init dotagents install ``` -Install and refresh skill and subagent dependencies from `agents.toml`. Resolves -sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, -hook, and subagent configs. There is no separate update command. With -`--frozen`, declared skills and subagents must already exist in `agents.lock`, -subagents are loaded from existing installed files without resolving sources, -the lockfile is not updated, and existing managed subagent files are not pruned. +Install and refresh skill, subagent, and plugin dependencies from `agents.toml`. +Resolves sources, copies canonical artifacts, writes the lockfile, creates +symlinks, and generates MCP, hook, subagent, and plugin runtime configs. There +is no separate update command. Plugins are project-scope only; `dotagents +--user install` rejects configs that declare `[[plugins]]`. With `--frozen`, +declared skills, subagents, and plugins must already exist in `agents.lock`, +subagents and plugins are loaded from existing installed files without resolving +sources, the lockfile is not updated, and existing managed subagent/plugin files +are not pruned. Example: @@ -126,10 +129,10 @@ dotagents add path:./my-skills/custom dotagents remove [-y] ``` -Remove a skill from `agents.toml`, delete it from disk, and update the lockfile. -You can also pass a source to remove all skills from that source. When removing -a skill provided by a wildcard source, dotagents can add that skill to the -wildcard `exclude` list. +Remove a skill or plugin from `agents.toml`, delete managed installed files, and +update the lockfile. You can also pass a source to remove all skills and plugins +from that source. When removing a skill provided by a wildcard source, dotagents +can add that skill to the wildcard `exclude` list. Options: @@ -152,8 +155,9 @@ dotagents sync ``` Reconcile project state without network access. Adopts local orphaned skills, -prunes stale managed skills and subagents removed from config, regenerates gitignore, and -repairs symlinks plus MCP/hook configs. Reports issues as warnings or errors. +prunes stale managed skills, subagents, and plugins removed from config, +regenerates gitignore, and repairs symlinks plus MCP/hook/subagent/plugin +configs. Reports issues as warnings or errors. @@ -286,8 +290,9 @@ dotagents doctor [--fix] ``` Check project health and fix issues. Verifies gitignore setup, installed -skills, symlinks, and detects legacy config fields. Useful for migrating to a -new version of dotagents. +skills/plugins, symlinks, generated runtime configs, and detects legacy config +fields. User-scope plugin declarations and same-project plugin declarations are +reported because plugin runtime projections are project-scoped. Options: @@ -308,7 +313,9 @@ dotagents doctor --fix # fix what it can dotagents list [--json] ``` -Show installed skills and status. Use `--json` for machine-readable output. +Show declared skills and plugins with install/lock status. Use `--json` for +machine-readable output. JSON output contains separate `skills` and `plugins` +arrays. Status output: @@ -407,6 +414,29 @@ Generated files: Generated files include a dotagents header marker. `install` and `sync` update managed files and do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` loads subagents from existing installed files, preserves managed subagent files and lock entries instead of pruning removed subagents, and does not resolve subagent sources. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. + + +### Plugins + +`[[plugins]]` entries install canonical plugin bundles into +`.agents/plugins//` and generate runtime-specific outputs for configured +agents. Plugin sources support GitHub/GitLab shorthands, git URLs, and `path:` +sources; HTTPS well-known sources are not supported for plugins. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | Yes | Plugin identifier. Lowercase letters, numbers, hyphens, and dots. | +| `source` | string | Yes | Repository or local source. | +| `ref` | string | No | Optional git ref override. | +| `path` | string | No | Optional explicit plugin path inside the source. | +| `targets` | string[] | No | Optional subset of configured agents. | + +Generated plugin outputs include Claude/Cursor/Codex marketplaces and native +manifests, Grok plugin directories, OpenCode skill/agent links, and Pi skill +links. dotagents rejects plugin sources that resolve to the same project's +`.agents/plugins//` install destination, and user-scope plugins are not +supported yet. + ## Scopes ### Project Scope (default) diff --git a/docs/src/content/docs/guide.mdx b/docs/src/content/docs/guide.mdx index f588944..384085f 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -115,11 +115,11 @@ Use `dotagents sync` for offline repair. It does not fetch anything from the network. Instead, it: - Adopts local orphaned skills, meaning installed skills that are not declared. -- Prunes stale managed skills and subagents removed from config. +- Prunes stale managed skills, subagents, and plugins removed from config. - Regenerates `.agents/.gitignore`. - Repairs broken symlinks. - Fixes MCP and hook configs. -- Repairs generated subagent files. +- Repairs generated subagent and plugin runtime files. ## Diagnosing Issues @@ -131,8 +131,9 @@ dotagents doctor # check for issues dotagents doctor --fix # auto-fix what it can ``` -Checks for missing gitignore entries, legacy config fields, missing skills, and -broken symlinks. It is especially useful when migrating from an older version. +Checks for missing gitignore entries, legacy config fields, missing skills and +plugins, generated runtime config drift, and broken symlinks. It is especially +useful when migrating from an older version. ## Personal Skills @@ -158,7 +159,7 @@ back to user scope automatically. ## Full Configuration Example -`agents.toml` with skills, wildcards, MCP servers, hooks, and subagents: +`agents.toml` with skills, wildcards, plugins, MCP servers, hooks, and subagents: ```toml version = 1 @@ -213,6 +214,12 @@ command = "my-lint-check" name = "code-reviewer" source = "getsentry/agent-pack" targets = ["claude", "codex", "opencode"] + +# Plugin bundle +[[plugins]] +name = "review-tools" +source = "getsentry/agent-pack" +targets = ["claude", "cursor", "codex", "opencode"] ``` See the [CLI reference](/cli/#configuration-agentstoml) for all fields and diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 58f9f5b..d671cfe 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -4,7 +4,7 @@ description: Shared tooling for coding agents. template: splash hero: title: 'dotagentsOne Config for Your Coding Agents' - tagline: 'Declare skills, MCP servers, hooks, and subagents in agents.toml. dotagents installs shared skills and writes each tool''s local config.' + tagline: 'Declare skills, plugins, MCP servers, hooks, and subagents in agents.toml. dotagents installs shared dependencies and writes each tool''s local config.' actions: - text: Get Started link: /guide/ @@ -34,7 +34,12 @@ source = "getsentry/skills" [[subagents]] name = "code-reviewer" source = "getsentry/agent-pack" -targets = ["claude", "codex", "opencode"]`} +targets = ["claude", "codex", "opencode"] + +[[plugins]] +name = "review-tools" +source = "getsentry/agent-pack" +targets = ["claude", "cursor", "codex", "opencode"]`} @@ -61,9 +66,13 @@ targets = ["claude", "codex", "opencode"]`} Subagents Declare custom Claude, Cursor, Codex, and OpenCode subagents once and generate native runtime files. + + Plugins + Install canonical plugin bundles and project supported skills, agents, manifests, and marketplaces into runtime-native locations. + Trust policy - Restrict skill and subagent sources before dotagents performs any network operation. + Restrict skill, subagent, and plugin sources before dotagents performs any network operation. diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index f49231f..eaf878f 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { parseArgs } from "node:util"; import chalk from "chalk"; import { GitError, TrustError } from "@sentry/dotagents-lib"; @@ -82,7 +82,13 @@ export async function runInstall(opts: InstallOptions): Promise { await writeMcpRuntime(config, scope); const hookWarnings = await writeHookRuntime(config, scope); const subagentWarnings = await writeSubagentRuntime(config, scope, subagents.subagents, frozen); - const pluginWarnings = await writePluginRuntime(config, scope, plugins.plugins, frozen); + const pluginWarnings = await writePluginRuntime( + config, + scope, + plugins.plugins, + frozen, + plugins.pruned.map((name) => join(scope.pluginsDir, name)), + ); return { installed: skills.installed, diff --git a/packages/dotagents/src/cli/commands/install/agent-runtime.ts b/packages/dotagents/src/cli/commands/install/agent-runtime.ts index a231d3d..b79d880 100644 --- a/packages/dotagents/src/cli/commands/install/agent-runtime.ts +++ b/packages/dotagents/src/cli/commands/install/agent-runtime.ts @@ -97,11 +97,12 @@ export async function writePluginRuntime( scope: ScopeRoot, plugins: PluginDeclaration[], frozen?: boolean, + extraManagedPluginRoots: string[] = [], ): Promise<{ agent: string; name: string; message: string }[]> { if (scope.scope !== "project") {return [];} const result = await writePluginOutputs(config.agents, plugins, scope.root); if (!frozen) { - await prunePluginOutputs(config.agents, plugins, scope.root); + await prunePluginOutputs(config.agents, plugins, scope.root, extraManagedPluginRoots); } return result.warnings; } diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index f23b027..02315fd 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -3,7 +3,14 @@ import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { runRemove, runRemoveSource, collectSkillsFromSource, RemoveError, WildcardSkillRemoveError } from "./remove.js"; +import { + collectSkillsFromSource, + RemoveError, + runRemove, + runRemovePluginSource, + runRemoveSource, + WildcardSkillRemoveError, +} from "./remove.js"; import { runInstall } from "./install.js"; import { exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; @@ -63,7 +70,7 @@ describe("runRemove", () => { const scope = resolveScope("project", projectRoot); await runInstall({ scope }); - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); const config = await loadConfig(join(projectRoot, "agents.toml")); expect(config.skills.find((s) => s.name === "pdf")).toBeUndefined(); @@ -95,7 +102,7 @@ describe("runRemove", () => { }); const scope = resolveScope("project", projectRoot); - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); expect(gitignore).not.toContain("/skills/pdf"); @@ -133,7 +140,7 @@ source = "path:plugins/review-tools" }); const scope = resolveScope("project", projectRoot); - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); expect(gitignore).not.toContain("/skills/pdf"); @@ -175,7 +182,7 @@ source = "path:." }); const scope = resolveScope("project", projectRoot); - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); expect(gitignore).not.toContain("/skills/pdf"); @@ -220,18 +227,76 @@ source = "path:plugins/review-tools" }); const scope = resolveScope("project", projectRoot); - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); expect(gitignore).not.toContain("/skills/review"); expect(gitignore).toContain("/plugins/review-tools/"); }); + it("removes a plugin entry, managed bundle, lock entry, and runtime outputs", async () => { + 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 writeFile(join(pluginSource, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex", "pi"] + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools"))).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "skills", "review"))).toBe(true); + + await runRemove({ scope, name: "review-tools" }); + + const config = await loadConfig(join(projectRoot, "agents.toml")); + expect(config.plugins.find((plugin) => plugin.name === "review-tools")).toBeUndefined(); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins["review-tools"]).toBeUndefined(); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "skills", "review"))).toBe(false); + }); + + it("removes plugins by source", async () => { + const pluginSource = join(projectRoot, "plugins", "review-tools"); + await mkdir(pluginSource, { recursive: true }); + await writeFile(join(pluginSource, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + await expect(runRemovePluginSource({ scope, source: "path:plugins/review-tools" })) + .resolves.toEqual(["review-tools"]); + + const config = await loadConfig(join(projectRoot, "agents.toml")); + expect(config.plugins).toEqual([]); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins).toEqual({}); + }); + it("throws RemoveError for skill not in config", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); const scope = resolveScope("project", projectRoot); - await expect(runRemove({ scope, skillName: "nonexistent" })).rejects.toThrow(RemoveError); + await expect(runRemove({ scope, name: "nonexistent" })).rejects.toThrow(RemoveError); }); it("throws WildcardSkillRemoveError for wildcard-sourced skill", async () => { @@ -243,7 +308,7 @@ source = "path:plugins/review-tools" await runInstall({ scope }); // Trying to remove "pdf" which is wildcard-sourced - await expect(runRemove({ scope, skillName: "pdf" })).rejects.toThrow(WildcardSkillRemoveError); + await expect(runRemove({ scope, name: "pdf" })).rejects.toThrow(WildcardSkillRemoveError); }); it("WildcardSkillRemoveError carries the source", async () => { @@ -255,7 +320,7 @@ source = "path:plugins/review-tools" await runInstall({ scope }); try { - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(WildcardSkillRemoveError); @@ -273,7 +338,7 @@ source = "path:plugins/review-tools" await runInstall({ scope }); // Removing "pdf" should remove the explicit entry, not trigger wildcard error - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); const config = await loadConfig(join(projectRoot, "agents.toml")); expect(config.skills.find((s) => s.name === "pdf")).toBeUndefined(); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index 55d4ea3..e8760f8 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { rm } from "node:fs/promises"; import { createInterface } from "node:readline"; @@ -5,7 +6,13 @@ import { parseArgs } from "node:util"; import chalk from "chalk"; import { loadConfig } from "../../config/loader.js"; import { isWildcardDep } from "../../config/schema.js"; -import { removeSkillFromConfig, removeSkillBlocksBySource, addExcludeToWildcard } from "../../config/writer.js"; +import { + addExcludeToWildcard, + removePluginBlocksBySource, + removePluginFromConfig, + removeSkillBlocksBySource, + removeSkillFromConfig, +} from "../../config/writer.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { filterManagedPluginSkillNames } from "../../gitignore/skills.js"; @@ -14,8 +21,8 @@ import { sourcesMatch, parseOwnerRepoShorthand, isExplicitSourceSpecifier } from import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { isInPlaceSkill } from "../../utils/fs.js"; -import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins } from "../../plugins/store.js"; -import { projectedPiSkillNames } from "../../plugins/runtime/writer.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins, pruneInstalledPlugins } from "../../plugins/store.js"; +import { projectedPiSkillNames, prunePluginOutputs } from "../../plugins/runtime/writer.js"; export class RemoveError extends Error { constructor(message: string) { @@ -37,26 +44,26 @@ export class WildcardSkillRemoveError extends Error { export interface RemoveOptions { scope: ScopeRoot; - skillName: string; + name: string; } export async function runRemove(opts: RemoveOptions): Promise { - const { scope, skillName } = opts; + const { scope, name } = opts; const { configPath, lockPath, skillsDir } = scope; - const skillDir = join(skillsDir, skillName); + const skillDir = join(skillsDir, name); const config = await loadConfig(configPath); // Check if skill is an explicit entry - const explicitDep = config.skills.find((s) => s.name === skillName); + const explicitDep = config.skills.find((s) => s.name === name); if (explicitDep && !isWildcardDep(explicitDep)) { // Regular explicit entry — remove as before - await removeSkillFromConfig(configPath, skillName); + await removeSkillFromConfig(configPath, name); await rm(skillDir, { recursive: true, force: true }); const lockfile = await loadLockfile(lockPath); if (lockfile) { - delete lockfile.skills[skillName]; + delete lockfile.skills[name]; await writeLockfile(lockPath, lockfile); } @@ -64,9 +71,17 @@ export async function runRemove(opts: RemoveOptions): Promise { return; } + const explicitPlugin = config.plugins.find((plugin) => plugin.name === name); + if (explicitPlugin) { + const lockfile = await loadLockfile(lockPath); + await removePluginFromConfig(configPath, name); + await removePluginArtifacts(scope, [name], lockfile); + return; + } + // Check if skill is from a wildcard entry (via lockfile source matching) const lockfile = await loadLockfile(lockPath); - const locked = lockfile?.skills[skillName]; + const locked = lockfile?.skills[name]; if (locked) { const wildcardDep = config.skills.find( (s) => @@ -74,11 +89,11 @@ export async function runRemove(opts: RemoveOptions): Promise { sourcesMatch(s.source, locked.source), ); if (wildcardDep) { - throw new WildcardSkillRemoveError(skillName, locked.source); + throw new WildcardSkillRemoveError(name, locked.source); } } - throw new RemoveError(`Skill "${skillName}" not found in agents.toml.`); + throw new RemoveError(`Skill or plugin "${name}" not found in agents.toml.`); } export interface RemoveSourceOptions { @@ -119,6 +134,32 @@ export async function collectSkillsFromSource(scope: ScopeRoot, source: string): return [...names].toSorted(); } +/** Collect all concrete plugin names from a source (config + lockfile). */ +export async function collectPluginsFromSource(scope: ScopeRoot, source: string): Promise { + const config = await loadConfig(scope.configPath); + const lockfile = await loadLockfile(scope.lockPath); + + const names = new Set(); + for (const plugin of config.plugins) { + if (sourcesMatch(plugin.source, source)) { + names.add(plugin.name); + } + } + if (lockfile) { + for (const [name, locked] of Object.entries(lockfile.plugins)) { + if (sourcesMatch(locked.source, source)) { + names.add(name); + } + } + } + + if (names.size === 0) { + throw new RemoveError(`No plugins found from source "${source}".`); + } + + return [...names].toSorted(); +} + /** * Remove all skills from a source. Returns the list of removed skill names. */ @@ -149,6 +190,53 @@ export async function runRemoveSource(opts: RemoveSourceOptions): Promise { + const { scope, source } = opts; + const pluginNames = await collectPluginsFromSource(scope, source); + const lockfile = await loadLockfile(scope.lockPath); + + await removePluginBlocksBySource(scope.configPath, source); + await removePluginArtifacts(scope, pluginNames, lockfile); + + return pluginNames; +} + +/** Removes managed plugin state after config mutation while preserving in-place plugin sources. */ +async function removePluginArtifacts( + scope: ScopeRoot, + pluginNames: string[], + lockfile: Awaited>, +): Promise { + const managedPluginNames = pluginNames.filter((name) => { + const locked = lockfile?.plugins[name]; + return locked && !isInPlacePluginSource(locked.source); + }); + const managedPluginRoots = managedPluginNames.map((name) => join(scope.pluginsDir, name)); + + if (scope.scope === "project") { + await pruneInstalledPlugins(scope.pluginsDir, managedPluginNames); + } + + if (lockfile) { + for (const name of pluginNames) { + delete lockfile.plugins[name]; + } + await writeLockfile(scope.lockPath, lockfile); + } + + if (scope.scope === "project") { + const config = await loadConfig(scope.configPath); + const remainingPluginConfigs = config.plugins + .filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) + .filter((plugin) => existsSync(join(scope.pluginsDir, plugin.name))); + const installedPlugins = await loadInstalledPlugins(scope.pluginsDir, remainingPluginConfigs); + await prunePluginOutputs(config.agents, installedPlugins.plugins, scope.root, managedPluginRoots); + } + + await updateProjectGitignore(scope); +} + async function updateProjectGitignore(scope: ScopeRoot): Promise { if (scope.scope !== "project") {return;} const config = await loadConfig(scope.configPath); @@ -237,8 +325,13 @@ export default async function remove(args: string[], flags?: { user?: boolean }) await ensureUserScopeBootstrapped(scope); try { - await runRemove({ scope, skillName: arg }); - console.log(chalk.green(`Removed skill: ${arg}`)); + const before = await loadConfig(scope.configPath); + const kind = before.plugins.some((plugin) => plugin.name === arg) && + !before.skills.some((skill) => skill.name === arg) + ? "plugin" + : "skill"; + await runRemove({ scope, name: arg }); + console.log(chalk.green(`Removed ${kind}: ${arg}`)); } catch (err) { if (err instanceof WildcardSkillRemoveError) { console.log(chalk.yellow(err.message)); @@ -269,13 +362,30 @@ export default async function remove(args: string[], flags?: { user?: boolean }) const isSource = isExplicitSourceSpecifier(arg) || parseOwnerRepoShorthand(arg) !== undefined; if (!isSource) {throw err;} - // It's a valid source specifier — preview, confirm, then remove - const skillNames = await collectSkillsFromSource(scope, arg); + // It's a valid source specifier — preview, confirm, then remove. + let skillNames: string[] = []; + let pluginNames: string[] = []; + try { + skillNames = await collectSkillsFromSource(scope, arg); + } catch (sourceErr) { + if (!(sourceErr instanceof RemoveError)) {throw sourceErr;} + } + try { + pluginNames = await collectPluginsFromSource(scope, arg); + } catch (sourceErr) { + if (!(sourceErr instanceof RemoveError)) {throw sourceErr;} + } + if (skillNames.length === 0 && pluginNames.length === 0) { + throw new RemoveError(`No skills or plugins found from source "${arg}".`); + } if (!skipConfirm) { - const names = skillNames.join(", "); + const names = [ + ...skillNames.map((name) => `skill:${name}`), + ...pluginNames.map((name) => `plugin:${name}`), + ].join(", "); const confirmed = await promptYesNo( - `Will remove ${skillNames.length} skill(s) from "${arg}": ${names}\nContinue? (y/N) `, + `Will remove ${skillNames.length} skill(s) and ${pluginNames.length} plugin(s) from "${arg}": ${names}\nContinue? (y/N) `, ); if (!confirmed) { console.log("Aborted."); @@ -283,8 +393,17 @@ export default async function remove(args: string[], flags?: { user?: boolean }) } } - const removed = await runRemoveSource({ scope, source: arg }); - console.log(chalk.green(`Removed ${removed.length} skill(s) from "${arg}": ${removed.join(", ")}`)); + const removedSkills = skillNames.length > 0 + ? await runRemoveSource({ scope, source: arg }) + : []; + const removedPlugins = pluginNames.length > 0 + ? await runRemovePluginSource({ scope, source: arg }) + : []; + const summary = [ + removedSkills.length > 0 ? `${removedSkills.length} skill(s): ${removedSkills.join(", ")}` : undefined, + removedPlugins.length > 0 ? `${removedPlugins.length} plugin(s): ${removedPlugins.join(", ")}` : undefined, + ].filter(Boolean).join("; "); + console.log(chalk.green(`Removed from "${arg}": ${summary}`)); } } catch (err) { if (err instanceof ScopeError || err instanceof RemoveError) { diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index e3fe2b1..e6114be 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -240,7 +240,7 @@ describe("runSync", () => { expect(existsSync(bobSkillDir)).toBe(true); process.env["DOTAGENTS_STATE_DIR"] = aliceStateDir; - await runRemove({ scope: resolveScope("project", aliceRepo), skillName: "pdf" }); + await runRemove({ scope: resolveScope("project", aliceRepo), name: "pdf" }); await exec("git", ["add", "agents.toml"], { cwd: aliceRepo }); await exec("git", ["commit", "-m", "remove pdf"], { cwd: aliceRepo }); await exec("git", ["push", "origin", "main"], { cwd: aliceRepo }); diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index ee6c5b8..55a4bac 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -392,7 +392,12 @@ export async function runSync(opts: SyncOptions): Promise { if (scope.scope === "project") { const pluginResult = await writePluginOutputs(config.agents, pluginDecls, scope.root); - const prunedPluginOutputs = await prunePluginOutputs(config.agents, pluginDecls, scope.root); + const prunedPluginOutputs = await prunePluginOutputs( + config.agents, + pluginDecls, + scope.root, + staleManagedPluginNames.map((name) => join(pluginsDir, name)), + ); pluginsRepaired = pluginResult.written + prunedPluginOutputs.length + prunedInstalledPlugins.length; pluginIssues = await verifyPluginOutputs(config.agents, pluginDecls, scope.root); diff --git a/packages/dotagents/src/cli/index.ts b/packages/dotagents/src/cli/index.ts index a61bdfb..e180caf 100644 --- a/packages/dotagents/src/cli/index.ts +++ b/packages/dotagents/src/cli/index.ts @@ -30,9 +30,9 @@ Commands: init Initialize agents.toml and .agents/skills/ install Install dependencies from agents.toml add Add a skill dependency - remove Remove a skill or all skills from a source + remove Remove a skill, plugin, or source sync Reconcile state offline and repair generated config - list Show installed skills and plugins + list Show declared skills and plugins mcp Manage MCP server declarations trust Manage trusted sources doctor Check project health and fix issues diff --git a/packages/dotagents/src/config/writer.ts b/packages/dotagents/src/config/writer.ts index 74c2fc5..a024538 100644 --- a/packages/dotagents/src/config/writer.ts +++ b/packages/dotagents/src/config/writer.ts @@ -48,6 +48,15 @@ export async function removeSkillFromConfig( await writeFile(filePath, removeBlockByHeader(content, "[[skills]]", name), "utf-8"); } +/** Removes a plugin entry from agents.toml by name. */ +export async function removePluginFromConfig( + filePath: string, + name: string, +): Promise { + const content = await readFile(filePath, "utf-8"); + await writeFile(filePath, removeBlockByHeader(content, "[[plugins]]", name), "utf-8"); +} + /** * Remove all [[skills]] blocks whose source matches the given source. * Handles both explicit and wildcard entries. @@ -55,6 +64,23 @@ export async function removeSkillFromConfig( export async function removeSkillBlocksBySource( filePath: string, source: string, +): Promise { + await removeBlocksBySource(filePath, "[[skills]]", source); +} + +/** Removes all plugin blocks whose source matches the given source. */ +export async function removePluginBlocksBySource( + filePath: string, + source: string, +): Promise { + await removeBlocksBySource(filePath, "[[plugins]]", source); +} + +/** Removes array-of-table blocks for a header when their `source` matches. */ +async function removeBlocksBySource( + filePath: string, + header: string, + source: string, ): Promise { const content = await readFile(filePath, "utf-8"); const lines = content.split("\n"); @@ -62,7 +88,7 @@ export async function removeSkillBlocksBySource( let i = 0; while (i < lines.length) { - if (lines[i]!.trim() === "[[skills]]") { + if (lines[i]!.trim() === header) { const headerLine = lines[i]!; i++; const { blockLines, nextIndex } = collectBlockLines(lines, i); diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index b6c175d..39cedf9 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -561,12 +561,6 @@ describe("plugin writer", () => { "---\ndescription: Plugin reviewer\n---\nReview plugin output.\n", "utf-8", ); - await mkdir(join(root, ".opencode", "plugins"), { recursive: true }); - await writeFile( - join(root, ".opencode", "plugins", "alpha-tools.ts"), - `// Generated by dotagents. Do not edit.\nexport { default } from "../.agents/plugins/alpha-tools/opencode/plugin.ts";\n`, - "utf-8", - ); await writePluginOutputs(["claude", "cursor", "codex", "grok", "opencode", "pi"], [alpha], root); const pruned = await prunePluginOutputs([], [alpha], root); @@ -576,7 +570,6 @@ describe("plugin writer", () => { join(root, ".claude-plugin", "marketplace.json"), join(root, ".cursor-plugin", "marketplace.json"), join(root, ".grok", "plugins", "alpha-tools"), - join(root, ".opencode", "plugins", "alpha-tools.ts"), join(root, ".opencode", "skills", "plugin-qa"), join(root, ".opencode", "agents", "plugin-reviewer.md"), join(root, ".agents", "skills", "plugin-qa"), @@ -588,7 +581,6 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".claude-plugin", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".cursor-plugin", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".grok", "plugins", "alpha-tools"))).toBe(false); - expect(existsSync(join(root, ".opencode", "plugins", "alpha-tools.ts"))).toBe(false); expect(existsSync(join(root, ".opencode", "skills", "plugin-qa"))).toBe(false); expect(existsSync(join(root, ".opencode", "agents", "plugin-reviewer.md"))).toBe(false); expect(existsSync(join(root, ".agents", "skills", "plugin-qa"))).toBe(false); @@ -597,6 +589,24 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); }); + 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 }); + await mkdir(join(root, ".opencode", "skills"), { recursive: true }); + await symlink( + relative(join(root, ".opencode", "skills"), join(localPlugin, "skills", "local-qa")), + join(root, ".opencode", "skills", "local-qa"), + ); + + const pruned = await prunePluginOutputs([], [], root); + + expect(pruned).toEqual([]); + await expectSymlinkTarget( + join(root, ".opencode", "skills", "local-qa"), + join(localPlugin, "skills", "local-qa"), + ); + }); + it("does not rewrite unchanged managed Grok projections", async () => { const alpha = await plugin("alpha-tools"); diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index b04f348..fb8a28a 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -160,13 +160,22 @@ export async function verifyPluginOutputs( return issues; } -/** Removes stale dotagents-managed plugin runtime artifacts. */ +/** Removes stale dotagents-managed plugin runtime artifacts. + * + * `extraManagedPluginRoots` lets callers prune component symlinks for plugin + * bundles that were just removed from the canonical install tree. + */ export async function prunePluginOutputs( agentIds: string[], plugins: PluginDeclaration[], projectRoot: string, + extraManagedPluginRoots: string[] = [], ): Promise { const pruned: string[] = []; + const managedPluginRoots = [ + ...plugins.map((plugin) => plugin.pluginDir), + ...extraManagedPluginRoots, + ]; const desiredMarketplacePaths = new Set( marketplaceOutputs(agentIds, projectRoot, plugins).map((output) => output.filePath), ); @@ -196,18 +205,16 @@ export async function prunePluginOutputs( } } - pruned.push(...await pruneLegacyOpenCodeModules(projectRoot)); - const desiredOpenCodeLinks = new Set( (await desiredComponentLinks("opencode", agentIds, plugins, projectRoot, [])).map((link) => link.destPath), ); - pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".opencode", "skills"), desiredOpenCodeLinks, projectRoot)); - pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".opencode", "agents"), desiredOpenCodeLinks, projectRoot)); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".opencode", "skills"), desiredOpenCodeLinks, managedPluginRoots)); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".opencode", "agents"), desiredOpenCodeLinks, managedPluginRoots)); const desiredPiLinks = new Set( (await desiredComponentLinks("pi", agentIds, plugins, projectRoot, [])).map((link) => link.destPath), ); - pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".agents", "skills"), desiredPiLinks, projectRoot)); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".agents", "skills"), desiredPiLinks, managedPluginRoots)); const canonicalPluginDir = join(projectRoot, ".agents", "plugins"); const desiredClaude = new Set( @@ -551,34 +558,11 @@ async function isManagedProjection(path: string): Promise { } /** Checks legacy OpenCode JS projections from earlier dotagents plugin support. */ -async function isManagedOpenCodeModule(filePath: string): Promise { - try { - return (await readFile(filePath, "utf-8")).startsWith("// Generated by dotagents."); - } catch { - return false; - } -} - -async function pruneLegacyOpenCodeModules(projectRoot: string): Promise { - const opencodeDir = join(projectRoot, ".opencode", "plugins"); - if (!existsSync(opencodeDir)) {return [];} - - const pruned: string[] = []; - const entries = await readdir(opencodeDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile() && !entry.isSymbolicLink()) {continue;} - const path = join(opencodeDir, entry.name); - if (!await isManagedOpenCodeModule(path)) {continue;} - await rm(path, { force: true }); - pruned.push(path); - } - return pruned; -} - +/** Prunes stale component symlinks whose targets resolve under managed plugin roots. */ async function pruneManagedComponentLinks( dir: string, desiredPaths: Set, - projectRoot: string, + managedPluginRoots: string[], ): Promise { if (!existsSync(dir)) {return [];} @@ -587,7 +571,7 @@ async function pruneManagedComponentLinks( for (const entry of entries) { const path = join(dir, entry.name); if (desiredPaths.has(path)) {continue;} - if (!await isManagedComponentLink(path, projectRoot)) {continue;} + if (!await isManagedComponentLink(path, managedPluginRoots)) {continue;} await rm(path, { force: true }); pruned.push(path); } @@ -615,12 +599,15 @@ async function pathExists(filePath: string): Promise { } } -async function isManagedComponentLink(filePath: string, projectRoot: string): Promise { +async function isManagedComponentLink(filePath: string, managedRoot: string | string[]): Promise { try { const stat = await lstat(filePath); if (!stat.isSymbolicLink()) {return false;} - const target = await readlink(filePath); - return isInside(resolve(dirname(filePath), target), join(projectRoot, ".agents", "plugins")); + const target = resolve(dirname(filePath), await readlink(filePath)); + const roots = Array.isArray(managedRoot) + ? managedRoot + : [join(managedRoot, ".agents", "plugins")]; + return roots.some((root) => isInside(target, root)); } catch { return false; } diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index f3e0b24..5fd5536 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -51,9 +51,9 @@ npx @sentry/dotagents list | `npx @sentry/dotagents init` | Initialize `agents.toml` and `.agents/` directory | | `npx @sentry/dotagents install` | Install all dependencies from `agents.toml` | | `npx @sentry/dotagents add ` | Add a skill dependency | -| `npx @sentry/dotagents remove ` | Remove a skill | -| `npx @sentry/dotagents sync` | Reconcile state (adopt orphans, repair symlinks, fix configs) | -| `npx @sentry/dotagents list` | Show installed skills, plugins, and status | +| `npx @sentry/dotagents remove ` | Remove a skill or plugin | +| `npx @sentry/dotagents sync` | Reconcile state (adopt orphans, prune stale managed artifacts, repair configs) | +| `npx @sentry/dotagents list` | Show declared skills, plugins, and status | | `npx @sentry/dotagents mcp` | Add, remove, or list MCP server declarations | | `npx @sentry/dotagents trust` | Add, remove, or list trusted sources | | `npx @sentry/dotagents doctor` | Check project health and fix issues | @@ -84,6 +84,6 @@ For full options and flags, read [references/cli-reference.md](references/cli-re - **Hooks**: `[[hooks]]` declarations write tool-event hooks to each agent's config - **Subagents**: `[[subagents]]` declarations install portable or native subagent files - **Plugins**: `[[plugins]]` declarations install canonical bundles and generate runtime-specific plugin outputs -- **Gitignore**: Managed skills are always gitignored; custom in-place skills are tracked -- **User scope**: `--user` flag manages skills in `~/.agents/` shared across all projects -- **Updates**: Run `npx @sentry/dotagents install` to refresh managed skills; there is no `update` command +- **Gitignore**: Managed skills, subagents, and plugin bundles are gitignored; custom in-place sources are tracked +- **User scope**: `--user` flag manages skills in `~/.agents/` shared across all projects; plugins are project-scope only +- **Updates**: Run `npx @sentry/dotagents install` to refresh managed skills, subagents, and plugins; there is no `update` command diff --git a/skills/dotagents/references/cli-reference.md b/skills/dotagents/references/cli-reference.md index 59b4c2e..5ae778e 100644 --- a/skills/dotagents/references/cli-reference.md +++ b/skills/dotagents/references/cli-reference.md @@ -39,7 +39,7 @@ npx @sentry/dotagents --user init ### `install` -Install or refresh skill dependencies declared in `agents.toml`. There is no separate `update` command. +Install or refresh skill, subagent, and plugin dependencies declared in `agents.toml`. There is no separate `update` command. ```bash npx @sentry/dotagents install @@ -47,14 +47,16 @@ npx @sentry/dotagents install **Workflow:** 1. Load config and lockfile -2. Expand wildcard entries (discover all skills from source) -3. Validate trust for each skill source -4. Resolve skills (refreshing sources through the cache) -5. Copy skills into `.agents/skills//` +2. Expand wildcard skill entries +3. Validate trust for each skill, subagent, and plugin source +4. Resolve skills, subagents, and plugins +5. Copy canonical artifacts into `.agents/skills/`, `.agents/agents/`, and `.agents/plugins/` 6. Write/update lockfile 7. Generate `.agents/.gitignore` 8. Create/verify agent symlinks -9. Write MCP and hook configs +9. Write MCP, hook, subagent, and plugin runtime configs + +`dotagents --user install` rejects `[[plugins]]` because plugin runtime projections are project-scoped. ### `add [skill...]` @@ -96,21 +98,21 @@ When adding multiple skills, already-existing entries are skipped with a warning `--all` and `--name`/positional args are mutually exclusive. -### `remove ` +### `remove ` -Remove a skill dependency. +Remove a skill or plugin dependency. ```bash npx @sentry/dotagents remove find-bugs ``` -Removes from `agents.toml`, deletes `.agents/skills//`, updates the lockfile, and regenerates `.agents/.gitignore`. +Removes from `agents.toml`, deletes managed installed files, updates the lockfile, prunes generated plugin outputs when needed, and regenerates `.agents/.gitignore`. Passing a source removes all matching skills and plugins from that source. For skills sourced from a wildcard entry (`name = "*"`), interactively prompts whether to add the skill to the wildcard's `exclude` list. If declined, the removal is cancelled. ### `sync` -Reconcile project state without network access: adopt local orphans, prune stale managed skills, and repair symlinks and configs. +Reconcile project state without network access: adopt local orphans, prune stale managed skills/subagents/plugins, and repair symlinks and generated configs. ```bash npx @sentry/dotagents sync @@ -119,13 +121,14 @@ npx @sentry/dotagents sync **Actions performed:** 1. Adopt orphaned skills (installed but not declared in config) 2. Regenerate `.agents/.gitignore` -3. Prune stale managed skills removed from config -4. Check for missing skills +3. Prune stale managed skills, subagents, and plugins removed from config +4. Check for missing skills and plugins 5. Repair agent symlinks 6. Verify/repair MCP configs 7. Verify/repair hook configs +8. Verify/repair subagent and plugin runtime configs -Reports issues as warnings (missing MCP/hook configs) or errors (missing skills). +Reports issues as warnings or errors, including user-scope plugin declarations and same-project plugin declarations. ### `doctor` @@ -140,13 +143,13 @@ npx @sentry/dotagents doctor --fix |------|-------------| | `--fix` | Auto-fix issues where possible | -**Checks:** gitignore setup, legacy config fields, installed skills, symlinks, `.agents/.gitignore`. +**Checks:** gitignore setup, legacy config fields, installed skills/plugins, symlinks, generated runtime configs, `.agents/.gitignore`. Useful when migrating to a new version of dotagents. ### `list` -Show installed skills and their status. +Show declared skills and plugins with their status. ```bash npx @sentry/dotagents list @@ -164,6 +167,8 @@ npx @sentry/dotagents list --json Skills from wildcard entries are marked with a wildcard indicator. +JSON output contains separate `skills` and `plugins` arrays. + ### `mcp` Manage MCP (Model Context Protocol) server declarations in `agents.toml`. diff --git a/skills/dotagents/references/configuration.md b/skills/dotagents/references/configuration.md index 0c96750..31c8535 100644 --- a/skills/dotagents/references/configuration.md +++ b/skills/dotagents/references/configuration.md @@ -206,7 +206,7 @@ dotagents always manages gitignore. It generates `.agents/.gitignore` listing ma Two files are added to the root `.gitignore` during `init`: - `agents.lock` — tracks managed skills, subagents, and plugins -- `.agents/.gitignore` — excludes managed skill directories +- `.agents/.gitignore` — excludes managed skill directories, subagent files, and plugin bundles If these entries are missing, `install` and `sync` warn. Run `npx @sentry/dotagents doctor --fix` to add them. diff --git a/specs/SPEC.md b/specs/SPEC.md index 3d69f83..917046d 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -562,25 +562,27 @@ Positional skill names and `--skill` flags cannot be mixed. ### `dotagents remove [-y]` -Remove a skill dependency, or all skills from a source. +Remove a skill or plugin dependency, or all skills and plugins from a source. ``` dotagents remove [-y] ``` -**Behavior (single skill):** -1. Remove `[[skills]]` entry from `agents.toml` -2. Delete `.agents/skills//` -3. Remove entry from `agents.lock` -4. Regenerate `.agents/.gitignore` +**Behavior (single dependency):** +1. Remove matching `[[skills]]` or `[[plugins]]` entry from `agents.toml` +2. Delete the managed installed artifact (`.agents/skills//` for skills or `.agents/plugins//` for managed plugins) +3. Remove entry from the relevant `agents.lock` section +4. Prune generated plugin runtime outputs when removing a plugin +5. Regenerate `.agents/.gitignore` **Behavior (source removal):** -When the argument matches a source specifier (e.g. `owner/repo`, a URL) rather than a skill name, removes all skills from that source: -1. Find all skills from the source (explicit entries from config + wildcard-expanded entries from lockfile) +When the argument matches a source specifier (e.g. `owner/repo`, a URL) rather than a dependency name, removes all skills and plugins from that source: +1. Find all skills and plugins from the source (explicit entries from config + wildcard-expanded skill entries from lockfile) 2. Confirm with the user (unless `-y` is passed) -3. Remove all matching `[[skills]]` entries from `agents.toml` (both explicit and wildcard) -4. Delete skill directories and lockfile entries -5. Regenerate `.agents/.gitignore` +3. Remove all matching `[[skills]]` and `[[plugins]]` entries from `agents.toml` +4. Delete managed installed artifacts and lockfile entries +5. Prune generated plugin runtime outputs for removed managed plugins +6. Regenerate `.agents/.gitignore` **Flags:** - `-y`, `--yes`: Skip confirmation prompt @@ -735,7 +737,7 @@ The YAML frontmatter is parsed with the `yaml` package. `allowed-tools` can be a dotagents always manages gitignore. Two files are added to the root `.gitignore` during `init`: - `agents.lock` — tracks managed skills, subagents, and plugins -- `.agents/.gitignore` — excludes managed skill directories and canonical installed subagent files from git +- `.agents/.gitignore` — excludes managed skill directories, canonical installed subagent files, and managed plugin bundles from git ### How It Works From d63f2a4777c0af243a55bb6e28d6a9365b08aee2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 30 Jun 2026 10:15:46 -0700 Subject: [PATCH 24/35] fix(dotagents): Harden plugin install recovery Stage plugin bundle replacement through a managed temporary directory so failed copies do not corrupt the existing install. Mark installed plugin bundles as dotagents-managed so retries can recover when the lockfile was not written yet. Allow a plugin with an existing in-place lock entry to move to a new external source, and require conventional plugin discovery paths to remain inside the source root after realpath resolution. Co-Authored-By: Codex --- .../src/cli/commands/install.test.ts | 78 +++++++++++++++++++ .../src/cli/commands/install/plugins.ts | 8 +- packages/dotagents/src/plugins/store.test.ts | 23 ++++++ packages/dotagents/src/plugins/store.ts | 69 +++++++++++++--- 4 files changed, 167 insertions(+), 11 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index e22fd30..dc5f458 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -11,6 +11,7 @@ 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"; const SKILL_MD = (name: string) => `--- name: ${name} @@ -295,6 +296,83 @@ source = "path:plugin-source/review-tools" expect(existsSync(join(existingDir, "skills", "old-review", "SKILL.md"))).toBe(false); }); + it("recovers a dotagents-managed plugin destination missing from the lockfile", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Recovered plugin" }, null, 2), + ); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + + const existingDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(existingDir, "skills", "partial"), { recursive: true }); + await writeFile(join(existingDir, DOTAGENTS_MANAGED_PLUGIN_MARKER), "managedBy=dotagents\n", "utf-8"); + await writeFile(join(existingDir, "skills", "partial", "SKILL.md"), SKILL_MD("partial")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse(await readFile(join(existingDir, "plugin.json"), "utf-8")) as Record; + expect(installedManifest["description"]).toBe("Recovered plugin"); + expect(existsSync(join(existingDir, "skills", "review", "SKILL.md"))).toBe(true); + expect(existsSync(join(existingDir, "skills", "partial", "SKILL.md"))).toBe(false); + }); + + it("allows an in-place plugin lock entry to move to an external source", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "External plugin" }, null, 2), + ); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + + const existingDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(existingDir, { recursive: true }); + await writeFile(join(existingDir, "plugin.json"), JSON.stringify({ name: "review-tools", description: "In-place plugin" }, null, 2)); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "review-tools": { source: "path:.agents/plugins/review-tools" }, + }, + }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse(await readFile(join(existingDir, "plugin.json"), "utf-8")) as Record; + expect(installedManifest["description"]).toBe("External plugin"); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins["review-tools"]).toEqual({ + source: "path:plugin-source/review-tools", + }); + }); + it("does not gitignore in-place skills that collide with Pi plugin projections", async () => { await mkdir(join(projectRoot, ".agents", "skills", "review"), { recursive: true }); await writeFile(join(projectRoot, ".agents", "skills", "review", "SKILL.md"), SKILL_MD("review")); diff --git a/packages/dotagents/src/cli/commands/install/plugins.ts b/packages/dotagents/src/cli/commands/install/plugins.ts index 317c1ee..15b49ac 100644 --- a/packages/dotagents/src/cli/commands/install/plugins.ts +++ b/packages/dotagents/src/cli/commands/install/plugins.ts @@ -7,6 +7,7 @@ import type { ScopeRoot } from "../../../scope.js"; import { installPluginBundle, isInPlacePluginSource, + isManagedPluginInstall, isProjectPluginSource, isSameProjectPluginConfig, loadInstalledPlugins, @@ -56,13 +57,16 @@ function staleManagedPluginNames( /** Ensures installs only replace plugin destinations already owned by dotagents. */ function assertPluginDestinationIsManaged( pluginsDir: string, - name: string, + plugin: PluginConfig, lockfile: Lockfile | null, ): void { + const { name } = plugin; if (!existsSync(join(pluginsDir, name))) {return;} const locked = lockfile?.plugins[name]; if (locked && !isInPlacePluginSource(locked.source)) {return;} + if (locked && !isInPlacePluginSource(plugin.source)) {return;} + if (isManagedPluginInstall(join(pluginsDir, name))) {return;} throw new InstallError( `Plugin "${name}" install destination already exists and is not managed by dotagents: ${join(pluginsDir, name)}`, @@ -125,7 +129,7 @@ export async function installPlugins( "Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.", ); } - assertPluginDestinationIsManaged(scope.pluginsDir, resolved.plugin.name, lockfile); + assertPluginDestinationIsManaged(scope.pluginsDir, pluginConfig, lockfile); plugins.push(await installPluginBundle(scope.pluginsDir, resolved)); lockEntries[resolved.plugin.name] = lockEntryForPlugin(resolved); } diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 7ef5dab..d6fcc2e 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -137,4 +137,27 @@ describe("plugin store", () => { await rm(projectRoot, { recursive: true, force: true }); } }); + + it("rejects conventional plugin discovery symlinks that escape the source root", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const outsideDir = join(projectRoot, "outside", "review-tools"); + await mkdir(join(sourceRoot, "plugins"), { recursive: true }); + await mkdir(outsideDir, { recursive: true }); + await writeFile( + join(outsideDir, "plugin.json"), + JSON.stringify({ name: "review-tools" }), + "utf-8", + ); + await symlink(outsideDir, join(sourceRoot, "plugins", "review-tools")); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow(/Plugin source resolves outside source/); + } 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 6fb8ffb..1f32dab 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, posix, relative, resolve } from "node:path"; import { applyDefaultRepositorySource, @@ -81,6 +81,10 @@ const NATIVE_MANIFEST_PATHS = [ ".plugin/plugin.json", ] as const; +export const DOTAGENTS_MANAGED_PLUGIN_MARKER = ".dotagents-managed"; + +let tempInstallCounter = 0; + /** Resolves a plugin declaration to a trusted local or cached git source bundle. */ export async function resolvePlugin( config: PluginConfig, @@ -154,10 +158,38 @@ export async function installPluginBundle( ); } const installed = { ...resolved.plugin, pluginDir: destDir }; + const tempDir = join( + pluginsDir, + `.${resolved.plugin.name}.tmp-${process.pid}-${Date.now()}-${tempInstallCounter++}`, + ); + const backupDir = join( + pluginsDir, + `.${resolved.plugin.name}.backup-${process.pid}-${Date.now()}-${tempInstallCounter++}`, + ); - await copyDir(resolved.plugin.pluginDir, destDir); + try { + await copyDir(resolved.plugin.pluginDir, tempDir); + const staged = { ...resolved.plugin, pluginDir: tempDir }; + await ensureCanonicalManifest(staged); + await writeManagedMarker(tempDir); + + if (existsSync(destDir)) { + await rename(destDir, backupDir); + try { + await rename(tempDir, destDir); + } catch (err) { + await rename(backupDir, destDir).catch(() => {}); + throw err; + } + await rm(backupDir, { recursive: true, force: true }); + } else { + await rename(tempDir, destDir); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + await rm(backupDir, { recursive: true, force: true }); + } - await ensureCanonicalManifest(installed); return installed; } @@ -211,6 +243,11 @@ export async function pruneInstalledPlugins( return pruned; } +/** Returns true when an existing plugin bundle was previously installed by dotagents. */ +export function isManagedPluginInstall(pluginDir: string): boolean { + return existsSync(join(pluginDir, DOTAGENTS_MANAGED_PLUGIN_MARKER)); +} + /** Converts a resolved plugin to its lockfile entry. */ export function lockEntryForPlugin(resolved: ResolvedPlugin): LockedPlugin { if (resolved.type === "local") { @@ -381,6 +418,7 @@ async function loadPluginCandidate( overlay: Partial = {}, ): Promise { if (!existsSync(pluginDir)) {return null;} + await assertInsideSourceRoot(sourceRoot, pluginDir, "Plugin source"); const manifest = await loadManifest(pluginDir); if (!manifest && !await hasPluginComponents(pluginDir)) {return null;} @@ -441,6 +479,10 @@ async function ensureCanonicalManifest(plugin: PluginDeclaration): Promise await writeFile(filePath, `${JSON.stringify(plugin.manifest, null, 2)}\n`, "utf-8"); } +async function writeManagedMarker(pluginDir: string): Promise { + await writeFile(join(pluginDir, DOTAGENTS_MANAGED_PLUGIN_MARKER), "managedBy=dotagents\n", "utf-8"); +} + function toDeclaration( config: PluginConfig, candidate: PluginCandidate, @@ -528,16 +570,25 @@ async function resolveInside(root: string, childPath: string, label: string): Pr throw new Error(`${label} resolves outside source: ${childPath}`); } if (existsSync(filePath)) { - const rootRealPath = await realpath(rootPath); - const fileRealPath = await realpath(filePath); - const realRelPath = relative(rootRealPath, fileRealPath); - if (realRelPath.startsWith("..") || isAbsolute(realRelPath)) { - throw new Error(`${label} resolves outside source: ${childPath}`); - } + await assertInsideSourceRoot(rootPath, filePath, label, childPath); } return filePath; } +async function assertInsideSourceRoot( + root: string, + filePath: string, + label: string, + displayPath = relativePath(root, filePath), +): Promise { + const rootRealPath = await realpath(root); + const fileRealPath = await realpath(filePath); + const realRelPath = relative(rootRealPath, fileRealPath); + if (realRelPath.startsWith("..") || isAbsolute(realRelPath)) { + throw new Error(`${label} resolves outside source: ${displayPath}`); + } +} + function managedPluginPath(pluginsDir: string, name: string): string | null { const rootPath = resolve(pluginsDir); const pluginPath = resolve(rootPath, name); From 017313713748caf870594a5a61dd5d8e920e51d2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 30 Jun 2026 10:19:58 -0700 Subject: [PATCH 25/35] fix(dotagents): Remove shared skill and plugin names When remove is given a name used by both an explicit skill and a plugin, remove both entries and their managed artifacts in the same operation. This keeps plugin bundles, lock entries, and runtime outputs from being left behind. Co-Authored-By: Codex --- .../dotagents/src/cli/commands/remove.test.ts | 39 +++++++++++++++++++ packages/dotagents/src/cli/commands/remove.ts | 15 +++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index 02315fd..8f42413 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -267,6 +267,45 @@ source = "path:plugins/review-tools" expect(existsSync(join(projectRoot, ".agents", "skills", "review"))).toBe(false); }); + it("removes both an explicit skill and plugin that share a name", async () => { + const skillSource = join(projectRoot, "local-skills", "review-tools"); + await mkdir(skillSource, { recursive: true }); + await writeFile(join(skillSource, "SKILL.md"), SKILL_MD("review-tools")); + + const pluginSource = join(projectRoot, "plugins", "review-tools"); + await mkdir(join(pluginSource, "skills", "plugin-review"), { recursive: true }); + await writeFile(join(pluginSource, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(pluginSource, "skills", "plugin-review", "SKILL.md"), SKILL_MD("plugin-review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex", "pi"] + +[[skills]] +name = "review-tools" +source = "path:local-skills/review-tools" + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + await runRemove({ scope, name: "review-tools" }); + + const config = await loadConfig(join(projectRoot, "agents.toml")); + expect(config.skills.find((skill) => skill.name === "review-tools")).toBeUndefined(); + expect(config.plugins.find((plugin) => plugin.name === "review-tools")).toBeUndefined(); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.skills["review-tools"]).toBeUndefined(); + expect(lockfile!.plugins["review-tools"]).toBeUndefined(); + expect(existsSync(join(projectRoot, ".agents", "skills", "review-tools"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "skills", "plugin-review"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools"))).toBe(false); + }); + it("removes plugins by source", async () => { const pluginSource = join(projectRoot, "plugins", "review-tools"); await mkdir(pluginSource, { recursive: true }); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index e8760f8..6668cde 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -56,22 +56,29 @@ export async function runRemove(opts: RemoveOptions): Promise { // Check if skill is an explicit entry const explicitDep = config.skills.find((s) => s.name === name); + const explicitPlugin = config.plugins.find((plugin) => plugin.name === name); if (explicitDep && !isWildcardDep(explicitDep)) { - // Regular explicit entry — remove as before + const lockfile = await loadLockfile(lockPath); await removeSkillFromConfig(configPath, name); await rm(skillDir, { recursive: true, force: true }); - const lockfile = await loadLockfile(lockPath); if (lockfile) { delete lockfile.skills[name]; - await writeLockfile(lockPath, lockfile); } + if (explicitPlugin) { + await removePluginFromConfig(configPath, name); + await removePluginArtifacts(scope, [name], lockfile); + return; + } + + if (lockfile) { + await writeLockfile(lockPath, lockfile); + } await updateProjectGitignore(scope); return; } - const explicitPlugin = config.plugins.find((plugin) => plugin.name === name); if (explicitPlugin) { const lockfile = await loadLockfile(lockPath); await removePluginFromConfig(configPath, name); From 0d3b587b5cc03c5b7018f3977283bf6fc260ddd7 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 30 Jun 2026 10:31:00 -0700 Subject: [PATCH 26/35] fix(dotagents): Clean up unlocked plugin removals Skip malformed marketplace files during plugin discovery so conventional fallback paths can still resolve valid plugins. When removing plugins, use the removed config block as ownership evidence in addition to lockfile entries. This lets remove clean up installed plugin bundles and projections even if agents.lock is missing that plugin row. Co-Authored-By: Codex --- .../src/cli/commands/install.test.ts | 44 +++++++++++++++++++ .../dotagents/src/cli/commands/remove.test.ts | 32 ++++++++++++++ packages/dotagents/src/cli/commands/remove.ts | 44 +++++++++++++++---- packages/dotagents/src/plugins/store.ts | 7 ++- 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index dc5f458..c7ddbfa 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -944,6 +944,50 @@ source = "path:plugin-source" ); }); + it("skips malformed marketplace files during plugin discovery", async () => { + const sourceRoot = join(projectRoot, "plugin-source"); + await mkdir(sourceRoot, { recursive: true }); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "source-marketplace", + plugins: [ + { + name: "review-tools", + source: { source: "local" }, + }, + ], + }, null, 2), + ); + const pluginDir = join(sourceRoot, "plugins", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Fallback local plugin", + }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse( + await readFile(join(projectRoot, ".agents", "plugins", "review-tools", "plugin.json"), "utf-8"), + ) as Record; + expect(installedManifest["description"]).toBe("Fallback local plugin"); + }); + it("reports unsupported marketplace source objects when no compatible plugin is found", async () => { const sourceRoot = join(projectRoot, "plugin-source"); await mkdir(sourceRoot, { recursive: true }); diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index 8f42413..d2bd8ba 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -17,6 +17,7 @@ import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { loadConfig } from "../../config/loader.js"; import { resolveScope } from "../../scope.js"; +import { DOTAGENTS_MANAGED_PLUGIN_MARKER } from "../../plugins/store.js"; const SKILL_MD = (name: string) => `--- name: ${name} @@ -306,6 +307,37 @@ source = "path:plugins/review-tools" expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools"))).toBe(false); }); + it("removes an installed plugin even when its lock entry is missing", async () => { + 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 writeFile(join(pluginSource, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex", "pi"] + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + delete lockfile!.plugins["review-tools"]; + await writeLockfile(join(projectRoot, "agents.lock"), lockfile!); + await rm(join(projectRoot, ".agents", "plugins", "review-tools", DOTAGENTS_MANAGED_PLUGIN_MARKER), { force: true }); + + await runRemove({ scope, name: "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(false); + expect(existsSync(join(projectRoot, ".agents", "skills", "review"))).toBe(false); + }); + it("removes plugins by source", async () => { const pluginSource = join(projectRoot, "plugins", "review-tools"); await mkdir(pluginSource, { recursive: true }); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index 6668cde..dc6109c 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -5,7 +5,7 @@ import { createInterface } from "node:readline"; import { parseArgs } from "node:util"; import chalk from "chalk"; import { loadConfig } from "../../config/loader.js"; -import { isWildcardDep } from "../../config/schema.js"; +import { isWildcardDep, type PluginConfig } from "../../config/schema.js"; import { addExcludeToWildcard, removePluginBlocksBySource, @@ -21,7 +21,13 @@ import { sourcesMatch, parseOwnerRepoShorthand, isExplicitSourceSpecifier } from import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { isInPlaceSkill } from "../../utils/fs.js"; -import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins, pruneInstalledPlugins } from "../../plugins/store.js"; +import { + isInPlacePluginSource, + isManagedPluginInstall, + isSameProjectPluginConfig, + loadInstalledPlugins, + pruneInstalledPlugins, +} from "../../plugins/store.js"; import { projectedPiSkillNames, prunePluginOutputs } from "../../plugins/runtime/writer.js"; export class RemoveError extends Error { @@ -68,7 +74,7 @@ export async function runRemove(opts: RemoveOptions): Promise { if (explicitPlugin) { await removePluginFromConfig(configPath, name); - await removePluginArtifacts(scope, [name], lockfile); + await removePluginArtifacts(scope, [name], [explicitPlugin], lockfile); return; } @@ -82,7 +88,7 @@ export async function runRemove(opts: RemoveOptions): Promise { if (explicitPlugin) { const lockfile = await loadLockfile(lockPath); await removePluginFromConfig(configPath, name); - await removePluginArtifacts(scope, [name], lockfile); + await removePluginArtifacts(scope, [name], [explicitPlugin], lockfile); return; } @@ -201,10 +207,12 @@ export async function runRemoveSource(opts: RemoveSourceOptions): Promise { const { scope, source } = opts; const pluginNames = await collectPluginsFromSource(scope, source); + const config = await loadConfig(scope.configPath); + const removedPlugins = config.plugins.filter((plugin) => pluginNames.includes(plugin.name)); const lockfile = await loadLockfile(scope.lockPath); await removePluginBlocksBySource(scope.configPath, source); - await removePluginArtifacts(scope, pluginNames, lockfile); + await removePluginArtifacts(scope, pluginNames, removedPlugins, lockfile); return pluginNames; } @@ -213,13 +221,31 @@ export async function runRemovePluginSource(opts: RemoveSourceOptions): Promise< async function removePluginArtifacts( scope: ScopeRoot, pluginNames: string[], + plugins: PluginConfig[], lockfile: Awaited>, ): Promise { - const managedPluginNames = pluginNames.filter((name) => { + const managedPluginNames = new Set(); + for (const plugin of plugins) { + const name = plugin.name; + const pluginDir = join(scope.pluginsDir, name); + if (isManagedPluginInstall(pluginDir)) { + managedPluginNames.add(name); + continue; + } + if (!existsSync(pluginDir)) {continue;} + + if (!isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) { + managedPluginNames.add(name); + } + } + + for (const name of pluginNames) { const locked = lockfile?.plugins[name]; - return locked && !isInPlacePluginSource(locked.source); - }); - const managedPluginRoots = managedPluginNames.map((name) => join(scope.pluginsDir, name)); + if (locked && !isInPlacePluginSource(locked.source)) { + managedPluginNames.add(name); + } + } + const managedPluginRoots = [...managedPluginNames].map((name) => join(scope.pluginsDir, name)); if (scope.scope === "project") { await pruneInstalledPlugins(scope.pluginsDir, managedPluginNames); diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts index 1f32dab..b1d28ec 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -366,7 +366,12 @@ async function discoverFromMarketplaces( const filePath = join(sourceDir, marketplacePath); if (!existsSync(filePath)) {continue;} - const marketplace = parsePluginMarketplace(await readJson(filePath), filePath); + let marketplace: ReturnType; + try { + marketplace = parsePluginMarketplace(await readJson(filePath), filePath); + } catch { + continue; + } const root = typeof marketplace.metadata?.pluginRoot === "string" ? marketplace.metadata.pluginRoot : "."; From 51dddf6e2008032f3d27b7e75928047fab7bb454 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 30 Jun 2026 13:04:13 -0700 Subject: [PATCH 27/35] test(dotagents): Thin plugin QA coverage Remove the broad runtime-auth QA reference and keep runtime guidance focused on plugin verification. Collapse repeated unmanaged plugin manifest tests into one table-driven case while preserving per-runtime coverage. Co-Authored-By: Codex --- .../src/plugins/runtime/writer.test.ts | 53 +--- skills/dotagents-qa/SKILL.md | 17 +- skills/dotagents-qa/SOURCES.md | 9 - skills/dotagents-qa/SPEC.md | 4 - skills/dotagents-qa/references/claude.md | 7 +- skills/dotagents-qa/references/codex.md | 5 +- skills/dotagents-qa/references/cursor.md | 9 +- skills/dotagents-qa/references/grok.md | 8 +- skills/dotagents-qa/references/opencode.md | 10 +- .../dotagents-qa/references/plugin-runtime.md | 2 +- .../dotagents-qa/references/runtime-auth.md | 230 ------------------ 11 files changed, 34 insertions(+), 320 deletions(-) delete mode 100644 skills/dotagents-qa/references/runtime-auth.md diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index 39cedf9..4a50b1b 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -284,55 +284,26 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(true); }); - it("does not overwrite unmanaged Codex plugin manifests", async () => { + it.each([ + ["codex", "Codex", ".codex-plugin"], + ["claude", "Claude", ".claude-plugin"], + ["cursor", "Cursor", ".cursor-plugin"], + ] as const)("does not overwrite unmanaged %s plugin manifests", async (agent, label, manifestDir) => { const alpha = await plugin("alpha-tools"); - await mkdir(join(alpha.pluginDir, ".codex-plugin"), { recursive: true }); - await writeFile(join(alpha.pluginDir, ".codex-plugin", "plugin.json"), "{ \"name\": \"mine\" }\n", "utf-8"); + const manifestPath = join(alpha.pluginDir, manifestDir, "plugin.json"); + await mkdir(join(alpha.pluginDir, manifestDir), { recursive: true }); + await writeFile(manifestPath, "{ \"name\": \"mine\" }\n", "utf-8"); - const result = await writePluginOutputs(["codex"], [alpha], root); - - expect(result.warnings).toEqual([ - { - agent: "codex", - name: "alpha-tools", - message: `Codex plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json")}`, - }, - ]); - expect(await readFile(join(alpha.pluginDir, ".codex-plugin", "plugin.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); - }); - - it("does not overwrite unmanaged Claude plugin manifests", async () => { - const alpha = await plugin("alpha-tools"); - await mkdir(join(alpha.pluginDir, ".claude-plugin"), { recursive: true }); - await writeFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), "{ \"name\": \"mine\" }\n", "utf-8"); - - const result = await writePluginOutputs(["claude"], [alpha], root); - - expect(result.warnings).toEqual([ - { - agent: "claude", - name: "alpha-tools", - message: `Claude plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json")}`, - }, - ]); - expect(await readFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); - }); - - it("does not overwrite unmanaged Cursor plugin manifests", async () => { - const alpha = await plugin("alpha-tools"); - await mkdir(join(alpha.pluginDir, ".cursor-plugin"), { recursive: true }); - await writeFile(join(alpha.pluginDir, ".cursor-plugin", "plugin.json"), "{ \"name\": \"mine\" }\n", "utf-8"); - - const result = await writePluginOutputs(["cursor"], [alpha], root); + const result = await writePluginOutputs([agent], [alpha], root); expect(result.warnings).toEqual([ { - agent: "cursor", + agent, name: "alpha-tools", - message: `Cursor plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", ".cursor-plugin", "plugin.json")}`, + message: `${label} plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", manifestDir, "plugin.json")}`, }, ]); - expect(await readFile(join(alpha.pluginDir, ".cursor-plugin", "plugin.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + expect(await readFile(manifestPath, "utf-8")).toBe("{ \"name\": \"mine\" }\n"); }); it("does not generate runtime outputs when no agent targets are selected", async () => { diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index dd3d2be..169aed3 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -35,7 +35,6 @@ Write down the QA target before running commands: Read the targeted reference before running runtime-specific QA: - Core install/sync example: [references/core-agentic-qa.md](references/core-agentic-qa.md) -- Runtime auth, gateway, and `.env.qa.local` handling: [references/runtime-auth.md](references/runtime-auth.md) - Plugin runtime verification matrix: [references/plugin-runtime.md](references/plugin-runtime.md) - Codex custom agents and plugin marketplace/install proof: [references/codex.md](references/codex.md) - Claude Code files, plugin validation, and runtime caveats: [references/claude.md](references/claude.md) @@ -65,11 +64,8 @@ runtime discovery by itself; for plugins, prefer native dry-run/management commands where available: `claude plugin validate` for Claude plugin and marketplace shape, and `codex plugin marketplace add/list` plus `codex plugin add/list` for Codex marketplace installation. Authenticated -model-backed checks are still explicit opt-ins. When a model-backed check needs -secrets, put them in host `.env.qa.local`, keep that file out of git, and pass -only the specific variables required for the check into Docker; do not copy the -env file into the fixture or retained `/qa-out` artifacts. See -[references/runtime-auth.md](references/runtime-auth.md). +model-backed checks are still explicit opt-ins. Keep runtime credentials out of +fixtures and retained `/qa-out` artifacts. Use an interactive container so the QA steps stay change-specific: @@ -370,12 +366,9 @@ Claude has no cheap dry-run skill list. If auth/network/model cost is acceptable, run a minimal non-interactive prompt from the temp project; otherwise report it as skipped. -Provider and gateway checks are runtime-specific. OpenRouter can be used where -the runtime supports an Anthropic-compatible or OpenAI-compatible custom -provider, but Cursor's documented runtime auth is Cursor login/API-key based. -Before claiming runtime proof through a gateway, read -[references/runtime-auth.md](references/runtime-auth.md), run the check inside -Docker, and report the exact provider config used with secret values redacted. +Provider and gateway checks are runtime-specific. Before claiming model-backed +runtime proof, run the check inside Docker and report the exact provider config +used with secret values redacted. ## 7. Report Evidence diff --git a/skills/dotagents-qa/SOURCES.md b/skills/dotagents-qa/SOURCES.md index a23a422..d862603 100644 --- a/skills/dotagents-qa/SOURCES.md +++ b/skills/dotagents-qa/SOURCES.md @@ -17,12 +17,6 @@ | `skills/dotagents/SKILL.md` | sibling skill layout | | `/Users/dcramer/src/sentry-mcp/.agents/skills/mcp-qa/SKILL.md` | Numbered QA flow, primary path guidance, optional client checks, and pass criteria structure | | local `codex debug prompt-input` probe | verifies Codex can expose `.agents/skills` metadata without a model call | -| Claude Code LLM gateway docs | Anthropic-compatible gateway env vars for authenticated runtime QA | -| Codex manual | Custom model provider and `CODEX_HOME` auth/config isolation | -| Cursor CLI auth and BYOK docs | Cursor-specific auth limits and OpenRouter caveat | -| Grok Build docs | Custom model provider config shape | -| OpenCode provider docs | OpenAI-compatible provider config shape | -| OpenRouter API docs | OpenAI-compatible and Anthropic Messages endpoint shapes | ## Decisions @@ -37,9 +31,6 @@ - Require inspection of generated files and command output that demonstrate the changed behavior, not only exit codes. - Expose the skill through `.agents/skills/dotagents-qa` so existing Claude/Cursor symlinks discover it. - Keep agent CLI registration and remote-source checks optional because they add auth, network, or tool-version variance. -- Keep authenticated runtime QA opt-in, pass only explicit env vars into Docker, - and document runtime-specific gateway support instead of assuming one API key - works across every client. ## Trigger Notes diff --git a/skills/dotagents-qa/SPEC.md b/skills/dotagents-qa/SPEC.md index 41756ca..de18814 100644 --- a/skills/dotagents-qa/SPEC.md +++ b/skills/dotagents-qa/SPEC.md @@ -14,8 +14,6 @@ In scope: - optionally checking agent CLI registration when discovery paths changed - optionally using `getsentry/skills` for remote source behavior - isolating home and cache state from the host -- optionally forwarding specific runtime API keys into Docker for model-backed - proof, with secrets kept out of fixtures and retained artifacts Out of scope: - release publishing @@ -33,8 +31,6 @@ Out of scope: - Run the built CLI from inside the container fixture project so project scope resolves correctly. - Inspect generated files and command output that demonstrate the changed behavior, not just exit codes. - Keep `HOME`, `DOTAGENTS_STATE_DIR`, and `DOTAGENTS_HOME` inside Docker for user-scope checks. -- Keep runtime credentials in host `.env.qa.local`, pass only explicit variables - into Docker, and isolate runtime config such as `CODEX_HOME`. - Report fixture shape, commands, assertions, skipped checks, and residual risk. ## Maintenance diff --git a/skills/dotagents-qa/references/claude.md b/skills/dotagents-qa/references/claude.md index bfbba6f..a63e58e 100644 --- a/skills/dotagents-qa/references/claude.md +++ b/skills/dotagents-qa/references/claude.md @@ -54,10 +54,9 @@ use relative string sources like `"./.agents/plugins/qa-tools"`. There is no cheap dry-run in this QA skill that proves Claude Code loads custom agents. Do not claim Claude runtime discovery from file-level checks alone. -For authenticated or OpenRouter-backed runtime proof, read -[runtime-auth.md](runtime-auth.md) first. Keep credentials in `.env.qa.local`, -pass only `OPENROUTER_API_KEY` or `ANTHROPIC_*` variables into Docker, and keep -`HOME` isolated to the temp container home. +For authenticated runtime proof, pass only the required provider variables into +Docker, keep credentials out of retained fixtures, and keep `HOME` isolated to +the temp container home. If a branch specifically requires Claude runtime proof, run an explicit Claude Code invocation only when auth/model cost is acceptable, keep it isolated to a temp project, and report: diff --git a/skills/dotagents-qa/references/codex.md b/skills/dotagents-qa/references/codex.md index 8964e28..a4dce60 100644 --- a/skills/dotagents-qa/references/codex.md +++ b/skills/dotagents-qa/references/codex.md @@ -40,10 +40,9 @@ With `--keep`, inspect: - `codex-runtime.jsonl` for `spawn_agent`, `wait`, and child-agent response events - `project/.codex/agents/code-reviewer.toml` for the generated file Codex loaded -For OpenRouter or other gateway-backed Codex proof, read -[runtime-auth.md](runtime-auth.md) first. Put provider config in the isolated +For gateway-backed Codex proof, put provider config in the isolated `CODEX_HOME/config.toml`; Codex ignores provider redirects in project -`.codex/config.toml`. Pass `OPENROUTER_API_KEY` into Docker only for the +`.codex/config.toml`. Pass provider credentials into Docker only for the runtime invocation. ## Important Caveats diff --git a/skills/dotagents-qa/references/cursor.md b/skills/dotagents-qa/references/cursor.md index c45d27c..356f58b 100644 --- a/skills/dotagents-qa/references/cursor.md +++ b/skills/dotagents-qa/references/cursor.md @@ -20,10 +20,9 @@ For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated Curso This QA skill does not currently include an automated Cursor desktop/runtime proof. Do not claim Cursor runtime discovery from file-level checks alone. -Cursor runtime proof uses Cursor auth, not the generic OpenRouter path. Read -[runtime-auth.md](runtime-auth.md) before using secrets. Use `CURSOR_API_KEY` -or browser login for Cursor CLI/headless checks. Do not claim OpenRouter proof -for Cursor unless current Cursor docs or observed local behavior proves an -OpenRouter-compatible endpoint. +Cursor runtime proof uses Cursor auth. Use `CURSOR_API_KEY` or browser login +for Cursor CLI/headless checks. Do not claim generic gateway proof for Cursor +unless current Cursor docs or observed local behavior proves a compatible +endpoint. If a branch specifically requires Cursor runtime proof, use a real Cursor session or a documented headless path if one exists in the local environment. Report the exact interaction and evidence. Otherwise report file-level Cursor wiring only. diff --git a/skills/dotagents-qa/references/grok.md b/skills/dotagents-qa/references/grok.md index 266c802..fc0f41a 100644 --- a/skills/dotagents-qa/references/grok.md +++ b/skills/dotagents-qa/references/grok.md @@ -20,11 +20,9 @@ This QA skill does not currently include an automated Grok runtime proof, and the QA Docker image does not install `grok` yet. Do not claim Grok runtime discovery from file-level checks alone. -For authenticated or OpenRouter-backed runtime proof, read -[runtime-auth.md](runtime-auth.md) first. Use a temp Grok config with a custom -model provider and `env_key = "OPENROUTER_API_KEY"` or `env_key = -"XAI_API_KEY"`, then run `grok inspect` before any paid prompt to prove Grok -discovered the temp project config, skills, plugins, hooks, and MCP servers. +For authenticated runtime proof, use a temp Grok config and run `grok inspect` +before any paid prompt to prove Grok discovered the temp project config, skills, +plugins, hooks, and MCP servers. If a branch specifically requires Grok runtime proof, install or make `grok` available inside the Docker container and report: diff --git a/skills/dotagents-qa/references/opencode.md b/skills/dotagents-qa/references/opencode.md index 32c50d6..5bd3312 100644 --- a/skills/dotagents-qa/references/opencode.md +++ b/skills/dotagents-qa/references/opencode.md @@ -47,13 +47,11 @@ Manual Docker probes can prove more when the branch affects OpenCode output: `.opencode/skills/*`; verify from raw output instead of assuming it is stable across OpenCode versions -For authenticated or OpenRouter-backed runtime proof, read -[runtime-auth.md](runtime-auth.md) first. Use a temp `opencode.json` provider -configuration with `@ai-sdk/openai-compatible`, `options.baseURL`, and -`options.apiKey` referencing `{env:OPENROUTER_API_KEY}`. Keep credentials in -the environment, not in retained fixture files. +For authenticated runtime proof, use a temp `opencode.json` provider +configuration and keep credentials in the environment, not in retained fixture +files. -With `OPENROUTER_API_KEY` forwarded into Docker, a minimal proof is: +With provider credentials forwarded into Docker, a minimal proof is: - `opencode auth list` reports the OpenRouter environment credential - `opencode models openrouter` lists the chosen model diff --git a/skills/dotagents-qa/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md index 318cca4..6e6bf6f 100644 --- a/skills/dotagents-qa/references/plugin-runtime.md +++ b/skills/dotagents-qa/references/plugin-runtime.md @@ -109,7 +109,7 @@ Expected evidence: Manual final check with model auth: -- Use `OPENROUTER_API_KEY` or another configured provider key +- Use a configured provider key - Run `opencode run --model "$OPENCODE_QA_MODEL" --format json ""` - For generated subagents, ask the primary agent to call the `task` tool with `subagent_type = "code-reviewer"`; do not pass the subagent to `--agent` diff --git a/skills/dotagents-qa/references/runtime-auth.md b/skills/dotagents-qa/references/runtime-auth.md deleted file mode 100644 index d698251..0000000 --- a/skills/dotagents-qa/references/runtime-auth.md +++ /dev/null @@ -1,230 +0,0 @@ -# Runtime Auth And Gateway QA - -Use this reference when a dotagents change needs model-backed proof from a real -agent client, or when forwarding API keys into the Docker QA sandbox. - -## Secret Handling - -Keep runtime credentials in host `.env.qa.local`. The file is intentionally -gitignored and must stay out of copied fixtures, retained temp projects, and -`/qa-out` evidence. - -Load secrets on the host, then pass only the specific variables needed for the -check into Docker: - -```bash -set -a -source .env.qa.local -set +a - -docker run --rm -i \ - -e OPENROUTER_API_KEY \ - -e ANTHROPIC_API_KEY \ - -e XAI_API_KEY \ - -e CURSOR_API_KEY \ - -v "$REPO:/host-repo:ro" \ - -v "$OUT:/qa-out" \ - dotagents-qa:local -``` - -A useful `.env.qa.local` template is: - -```bash -# General gateway key used by OpenCode and optional Claude/Codex/Grok probes. -OPENROUTER_API_KEY=... - -# Claude Code direct Anthropic auth or OpenRouter-over-Anthropic gateway. -ANTHROPIC_API_KEY=... -ANTHROPIC_AUTH_TOKEN=${OPENROUTER_API_KEY} -ANTHROPIC_BASE_URL=https://openrouter.ai/api -ANTHROPIC_MODEL=anthropic/claude-sonnet-4 -ANTHROPIC_CUSTOM_MODEL_OPTION=anthropic/claude-sonnet-4 -ANTHROPIC_DEFAULT_SONNET_MODEL=anthropic/claude-sonnet-4 - -# Codex direct OpenAI auth or custom-provider experiments. -OPENAI_API_KEY=... -CODEX_API_KEY=... -CODEX_ACCESS_TOKEN=... - -# Grok and Cursor runtime-specific auth. -XAI_API_KEY=... -CURSOR_API_KEY=... - -# Optional model override for manual OpenCode runtime QA. -OPENCODE_QA_MODEL=openrouter/anthropic/claude-haiku-4.5 -``` - -Only set aliases like `ANTHROPIC_AUTH_TOKEN=${OPENROUTER_API_KEY}` when you -intend that runtime to use the gateway. For first-party provider proof, set the -provider's real key instead and leave the gateway overrides unset. - -Inside Docker, keep client state isolated with temp homes such as `HOME`, -`CODEX_HOME`, `DOTAGENTS_HOME`, and any runtime-specific config directory the -client supports. Do not use or mount host runtime homes for ordinary QA. - -Report the provider config shape and model IDs, but redact tokens. If a check -requires network or paid model calls and is skipped, say exactly which runtime -was skipped and what file-level or dry-run proof was completed instead. - -## Runtime Matrix - -| Runtime | OpenRouter or custom gateway path | QA status | -| --- | --- | --- | -| Claude Code | Supported through Anthropic Messages gateways. Use `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, and an explicit model override. | Good candidate for model-backed plugin/skill discovery proof. | -| Codex | Supported through user-level custom model providers in `CODEX_HOME/config.toml`. Use `base_url`, `env_key`, and a model that the gateway supports. | Good candidate, but provider config must live in isolated `CODEX_HOME`, not project `.codex/config.toml`. | -| Grok Build | Supported through custom model config with `model`, `base_url`, `name`, and `env_key`. | Candidate once the Grok CLI is available in the QA image or installed in the container. | -| OpenCode | Supported through custom providers using `@ai-sdk/openai-compatible`, `options.baseURL`, and `options.apiKey`. | Candidate once provider config is written to isolated `opencode.json`. | -| Cursor | Cursor CLI documents browser login, `CURSOR_API_KEY`, and `--endpoint`. Cursor BYOK docs cover named providers such as OpenAI, Anthropic, Google, Azure, and Bedrock, not arbitrary OpenRouter-compatible provider config. | Do not claim OpenRouter proof for Cursor unless a documented endpoint or local runtime behavior is verified. Use Cursor API-key/login proof separately. | - -## OpenRouter Baseline - -OpenRouter exposes an OpenAI-compatible chat endpoint at: - -```text -https://openrouter.ai/api/v1/chat/completions -``` - -It also exposes an Anthropic Messages endpoint at: - -```text -https://openrouter.ai/api/v1/messages -``` - -Use `OPENROUTER_API_KEY` as a bearer token. Model IDs include provider prefixes, -for example `anthropic/claude-sonnet-4` or `openai/gpt-5.2`. Pick a cheap, -tool-capable model for QA unless the runtime requires a particular family. - -## Claude Code With OpenRouter - -Claude Code supports LLM gateways that expose Anthropic Messages APIs and sends -`ANTHROPIC_AUTH_TOKEN` as a bearer token. A minimal OpenRouter probe should set: - -```bash -export ANTHROPIC_BASE_URL="https://openrouter.ai/api" -export ANTHROPIC_AUTH_TOKEN="$OPENROUTER_API_KEY" -export ANTHROPIC_MODEL="anthropic/claude-sonnet-4" -``` - -If the model is not available in the picker or aliases resolve incorrectly, add: - -```bash -export ANTHROPIC_CUSTOM_MODEL_OPTION="anthropic/claude-sonnet-4" -export ANTHROPIC_DEFAULT_SONNET_MODEL="anthropic/claude-sonnet-4" -``` - -Keep `HOME` pointed at the container temp home. For plugin QA, install the -example, validate generated plugin files first, then run the smallest prompt -that can prove the desired skill/plugin behavior. - -## Codex With OpenRouter - -Codex custom provider settings must be in user-level `CODEX_HOME/config.toml`. -Project `.codex/config.toml` cannot set provider redirects such as -`model_provider`, `model_providers`, or `openai_base_url`. - -For OpenRouter, use an isolated `CODEX_HOME` and write a provider config like: - -```toml -model = "openai/gpt-5.2" -model_provider = "openrouter" - -[model_providers.openrouter] -name = "OpenRouter" -base_url = "https://openrouter.ai/api/v1" -env_key = "OPENROUTER_API_KEY" -``` - -If the selected model requires the Responses API and the gateway supports it, -add: - -```toml -wire_api = "responses" -``` - -Then run `codex exec` inside Docker with `CODEX_HOME` and -`OPENROUTER_API_KEY` set. Keep the existing `codex-runtime` task as the -reference pattern for trusting only the temp project and removing copied auth. - -## Grok Build With OpenRouter - -Grok Build documents custom models with `model`, `base_url`, `name`, and -`env_key`. An OpenRouter candidate config is: - -```toml -[model.openrouter-claude] -model = "anthropic/claude-sonnet-4" -base_url = "https://openrouter.ai/api/v1" -name = "Claude via OpenRouter" -env_key = "OPENROUTER_API_KEY" - -[models] -default = "openrouter-claude" -``` - -Run `grok inspect` before any paid prompt to confirm the config, skills, -plugins, hooks, and MCP servers discovered in the temp project. Use -`grok -p "..." -m openrouter-claude` for a model-backed proof. If `grok` is not -installed in the QA image, report Grok file-level plugin output plus the -missing CLI runtime proof. - -## OpenCode With OpenRouter - -OpenCode supports custom OpenAI-compatible providers through -`@ai-sdk/openai-compatible`, `options.baseURL`, model definitions, and env -interpolation in `options.apiKey`. - -Use a temp project `opencode.json` or isolated config that includes: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "provider": { - "openrouter": { - "npm": "@ai-sdk/openai-compatible", - "name": "OpenRouter", - "options": { - "baseURL": "https://openrouter.ai/api/v1", - "apiKey": "{env:OPENROUTER_API_KEY}" - }, - "models": { - "anthropic/claude-sonnet-4": { - "name": "Claude Sonnet via OpenRouter" - } - } - } - } -} -``` - -Run `opencode auth list` or a cheap prompt from the temp project only after the -file-level plugin component projection passes. Report whether OpenCode sees -the generated `.opencode/skills/*` or `.opencode/agents/*` component separately -from whether the model call succeeded. - -## Cursor - -Cursor runtime QA is not the same as gateway QA. The CLI documents -`CURSOR_API_KEY` or browser login, and exposes `--endpoint` for endpoint issues. -The Cursor BYOK UI documents provider-specific API keys, but not arbitrary -OpenRouter-compatible provider configuration. - -For now: - -- Use file-level checks for `.cursor/mcp.json`, `.cursor/hooks.json`, and - `.cursor/agents/*.md`. -- Use `CURSOR_API_KEY` only for Cursor CLI/headless proof when the test target - is Cursor runtime behavior. -- Do not represent `OPENROUTER_API_KEY` as sufficient Cursor proof unless a - current Cursor doc or an observed local command proves the endpoint shape. - -## Sources - -- Claude Code LLM gateway configuration: https://code.claude.com/docs/en/llm-gateway -- Claude Code model configuration: https://code.claude.com/docs/en/model-config -- Codex custom model providers and environment variables: https://developers.openai.com/codex/codex-manual.md -- Cursor CLI authentication: https://cursor.com/docs/cli/reference/authentication.md -- Cursor BYOK: https://cursor.com/help/models-and-usage/api-keys.md -- Grok Build getting started and custom models: https://docs.x.ai/build/overview -- OpenCode providers: https://opencode.ai/docs/providers/ -- OpenRouter API overview: https://openrouter.ai/docs/api/reference/overview -- OpenRouter Anthropic Messages endpoint: https://openrouter.ai/docs/api/api-reference/anthropic-messages/create-messages From 3cbbbcd9786d434e6c45fa1ae8273a375457a851 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 7 Jul 2026 12:31:17 -0700 Subject: [PATCH 28/35] fix(dotagents): Emit relative plugin marketplace paths Generate plugin marketplace sources relative to each marketplace file directory so projected marketplace entries resolve when consumed directly. Co-Authored-By: OpenAI Codex --- .../src/plugins/runtime/marketplace.ts | 33 ++++++++++--------- .../src/plugins/runtime/writer.test.ts | 18 ++++++---- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/dotagents/src/plugins/runtime/marketplace.ts b/packages/dotagents/src/plugins/runtime/marketplace.ts index b20691b..194cbe3 100644 --- a/packages/dotagents/src/plugins/runtime/marketplace.ts +++ b/packages/dotagents/src/plugins/runtime/marketplace.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { PluginDeclaration } from "../store.js"; import { selectedAgentIds } from "../targets.js"; import { DOTAGENTS_METADATA, stableJson } from "./files.js"; @@ -28,24 +28,27 @@ export function marketplaceOutputs( const codexPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); if (claudePlugins.length > 0) { + const filePath = join(projectRoot, ".claude-plugin", "marketplace.json"); outputs.push({ agent: "claude", - filePath: join(projectRoot, ".claude-plugin", "marketplace.json"), - content: stableJson(pathMarketplace(projectRoot, "dotagents", claudePlugins)), + filePath, + content: stableJson(pathMarketplace(filePath, "dotagents", claudePlugins)), }); } if (cursorPlugins.length > 0) { + const filePath = join(projectRoot, ".cursor-plugin", "marketplace.json"); outputs.push({ agent: "cursor", - filePath: join(projectRoot, ".cursor-plugin", "marketplace.json"), - content: stableJson(pathMarketplace(projectRoot, "dotagents", cursorPlugins)), + filePath, + content: stableJson(pathMarketplace(filePath, "dotagents", cursorPlugins)), }); } if (codexPlugins.length > 0) { + const filePath = join(projectRoot, ".agents", "plugins", "marketplace.json"); outputs.push({ agent: "codex", - filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), - content: stableJson(codexMarketplace(projectRoot, "dotagents-local", codexPlugins)), + filePath, + content: stableJson(codexMarketplace(filePath, "dotagents-local", codexPlugins)), }); } @@ -53,7 +56,7 @@ export function marketplaceOutputs( } function pathMarketplace( - projectRoot: string, + marketplaceFile: string, name: string, plugins: PluginDeclaration[], ): Record { @@ -65,7 +68,7 @@ function pathMarketplace( metadata: DOTAGENTS_METADATA, plugins: plugins .toSorted((a, b) => a.name.localeCompare(b.name)) - .map((plugin) => pathMarketplaceEntry(projectRoot, plugin)), + .map((plugin) => pathMarketplaceEntry(marketplaceFile, plugin)), }; } @@ -74,12 +77,12 @@ function pathMarketplace( * structured local source objects, so keep this projection format separate. */ function pathMarketplaceEntry( - projectRoot: string, + marketplaceFile: string, plugin: PluginDeclaration, ): Record { const entry: Record = { name: plugin.name, - source: `./${relativePath(projectRoot, plugin.pluginDir)}`, + source: relativePath(dirname(marketplaceFile), plugin.pluginDir), }; const description = manifestString(plugin.manifest, "description"); if (description) {entry["description"] = description;} @@ -89,7 +92,7 @@ function pathMarketplaceEntry( } function codexMarketplace( - projectRoot: string, + marketplaceFile: string, name: string, plugins: PluginDeclaration[], ): Record { @@ -104,19 +107,19 @@ function codexMarketplace( }, plugins: plugins .toSorted((a, b) => a.name.localeCompare(b.name)) - .map((plugin) => codexMarketplaceEntry(projectRoot, plugin)), + .map((plugin) => codexMarketplaceEntry(marketplaceFile, plugin)), }; } function codexMarketplaceEntry( - projectRoot: string, + marketplaceFile: string, plugin: PluginDeclaration, ): Record { const entry: Record = { category: manifestString(plugin.manifest, "category") ?? "Productivity", name: plugin.name, source: { - path: `./${relativePath(projectRoot, plugin.pluginDir)}`, + path: relativePath(dirname(marketplaceFile), plugin.pluginDir), source: "local", }, }; diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index 4a50b1b..28d3fa8 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -90,7 +90,7 @@ describe("plugin writer", () => { description: "Tools for alpha-tools", name: "alpha-tools", source: { - path: "./.agents/plugins/alpha-tools", + path: "./alpha-tools", source: "local", }, version: "1.0.0", @@ -100,14 +100,18 @@ describe("plugin writer", () => { description: "Tools for beta-tools", name: "beta-tools", source: { - path: "./.agents/plugins/beta-tools", + path: "./beta-tools", source: "local", }, version: "1.0.0", }, ], }); - expect(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ + const codexPlugin = (codexMarketplace["plugins"] as Array<{ source: { path: string } }>)[0]!; + expect(resolve(join(root, ".agents", "plugins"), codexPlugin["source"].path)).toBe(alpha.pluginDir); + + const claudeMarketplaceJson = await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8"); + expect(claudeMarketplaceJson).toBe(`{ "metadata": { "managedBy": "dotagents" }, @@ -119,19 +123,21 @@ describe("plugin writer", () => { { "description": "Tools for alpha-tools", "name": "alpha-tools", - "source": "./.agents/plugins/alpha-tools", + "source": "../.agents/plugins/alpha-tools", "version": "1.0.0" }, { "description": "Tools for beta-tools", "name": "beta-tools", - "source": "./.agents/plugins/beta-tools", + "source": "../.agents/plugins/beta-tools", "version": "1.0.0" } ] } `); - expect(await readFile(join(root, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")); + const claudeMarketplace = JSON.parse(claudeMarketplaceJson) as { plugins: Array<{ source: string }> }; + expect(resolve(join(root, ".claude-plugin"), claudeMarketplace["plugins"][0]!["source"])).toBe(alpha.pluginDir); + expect(await readFile(join(root, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(claudeMarketplaceJson); const claudeManifest = JSON.parse(await readFile(join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"), "utf-8")) as Record; expect(claudeManifest["skills"]).toBe("./skills"); From 27aec997d602befecead79b2a47a2a517fc94ed5 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 7 Jul 2026 12:44:01 -0700 Subject: [PATCH 29/35] fix(dotagents): Resolve nested plugin marketplace paths Allow marketplace-local plugin selectors to resolve relative to nested marketplace files while still enforcing source-root containment. Update generated marketplace expectations and plugin specs to match the file-relative path contract. Co-Authored-By: OpenAI Codex --- .../src/cli/commands/install.test.ts | 20 ++++-- packages/dotagents/src/plugins/schema.test.ts | 32 +++++++-- packages/dotagents/src/plugins/schema.ts | 15 +++- packages/dotagents/src/plugins/store.test.ts | 72 +++++++++++++++++++ packages/dotagents/src/plugins/store.ts | 20 +++++- specs/plugins.md | 12 ++-- 6 files changed, 152 insertions(+), 19 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index c7ddbfa..8eccbee 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { runInstall as runInstallCommand, InstallError, type InstallOptions, type InstallResult } from "./install.js"; import { runSync } from "./sync.js"; @@ -178,14 +178,20 @@ source = "path:plugin-source/review-tools" description: "Review workflow helpers", name: "review-tools", source: { - path: "./.agents/plugins/review-tools", + path: "./review-tools", source: "local", }, version: "1.0.0", }, ], }); - expect(await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ + const codexPlugin = (codexMarketplace["plugins"] as Array<{ source: { path: string } }>)[0]!; + expect(resolve(join(projectRoot, ".agents", "plugins"), codexPlugin["source"].path)).toBe( + join(projectRoot, ".agents", "plugins", "review-tools"), + ); + + const claudeMarketplaceJson = await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8"); + expect(claudeMarketplaceJson).toBe(`{ "metadata": { "managedBy": "dotagents" }, @@ -197,13 +203,17 @@ source = "path:plugin-source/review-tools" { "description": "Review workflow helpers", "name": "review-tools", - "source": "./.agents/plugins/review-tools", + "source": "../.agents/plugins/review-tools", "version": "1.0.0" } ] } `); - expect(await readFile(join(projectRoot, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8")); + const claudeMarketplace = JSON.parse(claudeMarketplaceJson) as { plugins: Array<{ source: string }> }; + expect(resolve(join(projectRoot, ".claude-plugin"), claudeMarketplace["plugins"][0]!["source"])).toBe( + join(projectRoot, ".agents", "plugins", "review-tools"), + ); + expect(await readFile(join(projectRoot, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(claudeMarketplaceJson); const claudeManifest = JSON.parse(await readFile(join(projectRoot, ".agents", "plugins", "review-tools", ".claude-plugin", "plugin.json"), "utf-8")) as Record; expect(claudeManifest["name"]).toBe("review-tools"); diff --git a/packages/dotagents/src/plugins/schema.test.ts b/packages/dotagents/src/plugins/schema.test.ts index 18b367c..2bcf152 100644 --- a/packages/dotagents/src/plugins/schema.test.ts +++ b/packages/dotagents/src/plugins/schema.test.ts @@ -76,11 +76,35 @@ describe("plugin marketplace schema", () => { expect(marketplace.plugins[0]!["extra"]).toBe("kept"); }); + it("accepts marketplace-file-relative local entries", () => { + const marketplace = parsePluginMarketplace( + { + name: "dotagents", + plugins: [ + { + name: "review-tools", + source: "../.agents/plugins/review-tools", + }, + { + name: "codex-tools", + source: { + source: "local", + path: "./codex-tools", + }, + }, + ], + }, + "marketplace.json", + ); + + expect(marketplace.plugins[0]!.source).toBe("../.agents/plugins/review-tools"); + expect(marketplace.plugins[1]!.source).toEqual({ + source: "local", + path: "./codex-tools", + }); + }); + it("rejects unsafe marketplace paths", () => { - expect(pluginMarketplaceSchema.safeParse({ - name: "dotagents", - plugins: [{ name: "bad", source: "../bad" }], - }).success).toBe(false); expect(pluginMarketplaceSchema.safeParse({ name: "dotagents", plugins: [{ name: "bad", source: "https://example.com/plugin.git" }], diff --git a/packages/dotagents/src/plugins/schema.ts b/packages/dotagents/src/plugins/schema.ts index 0c06e9c..e8b6380 100644 --- a/packages/dotagents/src/plugins/schema.ts +++ b/packages/dotagents/src/plugins/schema.ts @@ -13,6 +13,15 @@ export const pluginPathSchema = z.string().check( }, "Plugin paths must be relative filesystem paths and must not contain '..'"), ); +const marketplacePathSchema = z.string().check( + z.refine((value) => { + if (value.length === 0) {return false;} + if (value.startsWith("/") || value.startsWith("\\")) {return false;} + if (/^[a-zA-Z]:[\\/]/.test(value)) {return false;} + return !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value); + }, "Marketplace paths must be relative filesystem paths"), +); + const pluginAuthorSchema = z.object({ name: z.string().optional(), email: z.string().optional(), @@ -50,16 +59,16 @@ export type PluginManifest = z.infer; const localMarketplaceSourceSchema = z.object({ source: z.literal("local"), - path: pluginPathSchema, + path: marketplacePathSchema, }).passthrough(); const extensionMarketplaceSourceSchema = z.object({ source: z.string().optional(), - path: pluginPathSchema, + path: marketplacePathSchema, }).passthrough(); export const marketplaceSourceSchema = z.union([ - pluginPathSchema, + marketplacePathSchema, localMarketplaceSourceSchema, extensionMarketplaceSourceSchema, ]); diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index d6fcc2e..09e97ea 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -138,6 +138,78 @@ describe("plugin store", () => { } }); + it("resolves marketplace paths relative to nested marketplace files", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const pluginDir = join(sourceRoot, ".agents", "plugins", "marketplace-review-tools"); + await mkdir(join(sourceRoot, ".claude-plugin"), { recursive: true }); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Nested marketplace plugin" }), + "utf-8", + ); + await writeFile( + join(sourceRoot, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + plugins: [ + { + name: "review-tools", + source: "../.agents/plugins/marketplace-review-tools", + }, + ], + }), + "utf-8", + ); + + const fromMarketplace = await resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + expect(fromMarketplace.plugin.pluginDir).toBe(pluginDir); + expect(fromMarketplace.plugin.manifest.description).toBe("Nested marketplace plugin"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("rejects marketplace paths that traverse outside the source root", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const outsideDir = join(projectRoot, "outside", "review-tools"); + await mkdir(join(sourceRoot, ".claude-plugin"), { recursive: true }); + await mkdir(outsideDir, { recursive: true }); + await writeFile( + join(outsideDir, "plugin.json"), + JSON.stringify({ name: "review-tools" }), + "utf-8", + ); + await writeFile( + join(sourceRoot, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + plugins: [ + { + name: "review-tools", + source: "../../outside/review-tools", + }, + ], + }), + "utf-8", + ); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow(/Marketplace plugin source resolves outside source/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + it("rejects conventional plugin discovery symlinks that escape the source root", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); try { diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts index b1d28ec..9172e15 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -385,7 +385,7 @@ async function discoverFromMarketplaces( } const marketplaceRoot = dirname(filePath); - const pluginDir = await resolveInside(marketplaceRoot, join(root, path), "Marketplace plugin source"); + const pluginDir = await resolveMarketplaceSource(sourceDir, marketplaceRoot, join(root, path), "Marketplace plugin source"); const candidate = await loadPluginCandidate(sourceDir, pluginDir, marketplaceManifestOverlay(entry)); if (candidate) {return candidate;} } @@ -580,6 +580,24 @@ async function resolveInside(root: string, childPath: string, label: string): Pr return filePath; } +async function resolveMarketplaceSource( + sourceRoot: string, + marketplaceRoot: string, + childPath: string, + label: string, +): Promise { + const filePath = resolve(marketplaceRoot, childPath); + const sourceRootPath = resolve(sourceRoot); + const relPath = relative(sourceRootPath, filePath); + if (relPath.startsWith("..") || isAbsolute(relPath)) { + throw new Error(`${label} resolves outside source: ${childPath}`); + } + if (existsSync(filePath)) { + await assertInsideSourceRoot(sourceRootPath, filePath, label, childPath); + } + return filePath; +} + async function assertInsideSourceRoot( root: string, filePath: string, diff --git a/specs/plugins.md b/specs/plugins.md index eda3769..6b09d04 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -25,9 +25,9 @@ Every other runtime output is generated from `.agents/plugins/` when that runtim The canonical inputs are tightly defined but forward-compatible: 1. `agents.toml` `[[plugins]]` declarations are strict operational config. Unknown fields are rejected. -2. `.agents/plugins/marketplace.json` must be a JSON object with `name` and `plugins[]`. Each plugin entry must have `name` and `source`. dotagents resolves local plugin selectors only when `source` is a relative path string or `{ "source": "local", "path": "" }`. Runtime extension source objects may be accepted when their known path fields are safe, but dotagents must report them as unsupported instead of guessing how to resolve them. +2. `.agents/plugins/marketplace.json` must be a JSON object with `name` and `plugins[]`. Each plugin entry must have `name` and `source`. dotagents resolves local plugin selectors only when `source` is a relative path string or `{ "source": "local", "path": "" }`; selectors are anchored at the marketplace file directory and rejected when the resolved path escapes the source root. Runtime extension source objects may be accepted when their known path fields are safe, but dotagents must report them as unsupported instead of guessing how to resolve them. 3. `.agents/plugins//plugin.json` must be a JSON object. Known component path fields are validated as relative filesystem paths without `..` or URL/scheme prefixes when present. Unknown fields are allowed and preserved so native runtimes and future dotagents versions can add metadata without breaking older installs. -4. All component paths in canonical manifests and marketplaces must be relative filesystem paths and must not contain `..`, URL/scheme prefixes, absolute POSIX paths, absolute Windows paths, or backslash-rooted paths. +4. All component paths in canonical manifests and marketplace component fields must be relative filesystem paths and must not contain `..`, URL/scheme prefixes, absolute POSIX paths, absolute Windows paths, or backslash-rooted paths. 5. The portable plugin `name` in `agents.toml` is authoritative. If a discovered manifest also declares `name`, it must match the configured name. Generated outputs are deterministic: @@ -148,7 +148,7 @@ Example canonical marketplace: "name": "my-plugin", "source": { "source": "local", - "path": "./.agents/plugins/my-plugin" + "path": "./my-plugin" }, "policy": { "installation": "AVAILABLE", @@ -250,9 +250,9 @@ Generated project-scope outputs should be: | Agent | Project Scope Output | User Scope Output | Notes | |-------|----------------------|-------------------|-------| -| Claude Code | `.claude-plugin/marketplace.json` and `.agents/plugins//.claude-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources and each targeted plugin gets a Claude-native manifest. | -| Cursor | `.cursor-plugin/marketplace.json` and `.agents/plugins//.cursor-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources and each targeted plugin gets a Cursor-native manifest. | -| Codex | `.agents/plugins/marketplace.json` and generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | Generated marketplace uses deterministic `{ "source": "local", "path": "./.agents/plugins/" }` entries relative to the project root. | +| Claude Code | `.claude-plugin/marketplace.json` and `.agents/plugins//.claude-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `../.agents/plugins/` sources relative to `.claude-plugin/marketplace.json`, and each targeted plugin gets a Claude-native manifest. | +| Cursor | `.cursor-plugin/marketplace.json` and `.agents/plugins//.cursor-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `../.agents/plugins/` sources relative to `.cursor-plugin/marketplace.json`, and each targeted plugin gets a Cursor-native manifest. | +| Codex | `.agents/plugins/marketplace.json` and generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | Generated marketplace uses deterministic `{ "source": "local", "path": "./" }` entries relative to `.agents/plugins/marketplace.json`. | | Grok Build | `.grok/plugins/` for targeted plugins | Not generated yet | The projection is a managed copy of the canonical plugin bundle with a `.dotagents-managed` marker. | | OpenCode | Plugin `skills/` symlinked into `.opencode/skills/`; plugin Markdown `agents/` symlinked into `.opencode/agents/` | Not generated yet | dotagents exposes bundle components through OpenCode's native resource directories and skips collisions with user-authored files. | | Pi | Plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target | Not generated yet | Pi reads agentskills from `.agents/skills/`; only plugin skills are projected. | From a3918cf49d2cb914ef00cbe35083f382fb91e518 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 7 Jul 2026 12:49:14 -0700 Subject: [PATCH 30/35] fix(dotagents): Keep extension marketplace paths strict Only local marketplace selectors need file-relative traversal. Keep unsupported extension source paths on the stricter component path schema and clarify the discovery docs. Co-Authored-By: OpenAI Codex --- packages/dotagents/src/plugins/schema.test.ts | 4 ++++ packages/dotagents/src/plugins/schema.ts | 2 +- specs/plugins.md | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/dotagents/src/plugins/schema.test.ts b/packages/dotagents/src/plugins/schema.test.ts index 2bcf152..e96ebb0 100644 --- a/packages/dotagents/src/plugins/schema.test.ts +++ b/packages/dotagents/src/plugins/schema.test.ts @@ -109,6 +109,10 @@ describe("plugin marketplace schema", () => { name: "dotagents", plugins: [{ name: "bad", source: "https://example.com/plugin.git" }], }).success).toBe(false); + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: { source: "github", path: "../outside" } }], + }).success).toBe(false); expect(pluginMarketplaceSchema.safeParse({ name: "dotagents", metadata: { pluginRoot: "../plugins" }, diff --git a/packages/dotagents/src/plugins/schema.ts b/packages/dotagents/src/plugins/schema.ts index e8b6380..f2a0018 100644 --- a/packages/dotagents/src/plugins/schema.ts +++ b/packages/dotagents/src/plugins/schema.ts @@ -64,7 +64,7 @@ const localMarketplaceSourceSchema = z.object({ const extensionMarketplaceSourceSchema = z.object({ source: z.string().optional(), - path: marketplacePathSchema, + path: pluginPathSchema, }).passthrough(); export const marketplaceSourceSchema = z.union([ diff --git a/specs/plugins.md b/specs/plugins.md index 6b09d04..6c0f40e 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -209,7 +209,7 @@ Without an explicit `path`, dotagents should scan source directories in this ord Directory-name matches for `agents.toml` `name` take precedence over manifest-name matches. Multiple matches in one source are rejected as ambiguous unless a marketplace manifest explicitly selects a path. -Marketplace entries should be treated as plugin selectors. If a marketplace entry points at a local path, dotagents resolves it relative to the marketplace root, optionally applying the marketplace's `metadata.pluginRoot` prefix when present. If a marketplace entry points at a remote source object that dotagents cannot resolve yet, it should produce an unsupported-source warning rather than guessing. +Marketplace entries should be treated as plugin selectors. If a marketplace entry points at a local path, dotagents resolves it relative to the marketplace file directory, optionally applying the marketplace's `metadata.pluginRoot` prefix when present. If a marketplace entry points at a remote source object that dotagents cannot resolve yet, it should produce an unsupported-source warning rather than guessing. ## Component Handling From f97cc675bf7dc28c73a879b1fdc2c2df50b986b6 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 7 Jul 2026 12:53:32 -0700 Subject: [PATCH 31/35] fix(dotagents): Skip unsupported plugin marketplace sources Allow non-local marketplace source objects without local paths so unsupported entries do not invalidate the entire marketplace file. Keep local source objects strict and add regression coverage for mixed marketplace files. Co-Authored-By: OpenAI Codex --- packages/dotagents/src/plugins/schema.test.ts | 29 ++++++++++--- packages/dotagents/src/plugins/schema.ts | 6 ++- packages/dotagents/src/plugins/store.test.ts | 43 +++++++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/dotagents/src/plugins/schema.test.ts b/packages/dotagents/src/plugins/schema.test.ts index e96ebb0..2f51c2c 100644 --- a/packages/dotagents/src/plugins/schema.test.ts +++ b/packages/dotagents/src/plugins/schema.test.ts @@ -104,6 +104,29 @@ describe("plugin marketplace schema", () => { }); }); + it("accepts unsupported extension source objects without local paths", () => { + const marketplace = parsePluginMarketplace( + { + name: "dotagents", + plugins: [ + { + name: "external-tools", + source: { + source: "github", + repo: "org/external-tools", + }, + }, + ], + }, + "marketplace.json", + ); + + expect(marketplace.plugins[0]!.source).toEqual({ + source: "github", + repo: "org/external-tools", + }); + }); + it("rejects unsafe marketplace paths", () => { expect(pluginMarketplaceSchema.safeParse({ name: "dotagents", @@ -120,14 +143,10 @@ describe("plugin marketplace schema", () => { }).success).toBe(false); }); - it("rejects marketplace source objects without a path selector", () => { + it("rejects local marketplace source objects without a path selector", () => { expect(pluginMarketplaceSchema.safeParse({ name: "dotagents", plugins: [{ name: "bad", source: { source: "local" } }], }).success).toBe(false); - expect(pluginMarketplaceSchema.safeParse({ - name: "dotagents", - plugins: [{ name: "bad", source: { url: "https://example.com/plugin.git" } }], - }).success).toBe(false); }); }); diff --git a/packages/dotagents/src/plugins/schema.ts b/packages/dotagents/src/plugins/schema.ts index f2a0018..0681d97 100644 --- a/packages/dotagents/src/plugins/schema.ts +++ b/packages/dotagents/src/plugins/schema.ts @@ -64,8 +64,10 @@ const localMarketplaceSourceSchema = z.object({ const extensionMarketplaceSourceSchema = z.object({ source: z.string().optional(), - path: pluginPathSchema, -}).passthrough(); + path: pluginPathSchema.optional(), +}).passthrough().check( + z.refine((value) => value.source !== "local", "Local marketplace sources must include a path"), +); export const marketplaceSourceSchema = z.union([ marketplacePathSchema, diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 09e97ea..2a90d0b 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -175,6 +175,49 @@ describe("plugin store", () => { } }); + it("skips unsupported marketplace entries without dropping local entries", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const pluginDir = join(sourceRoot, "marketplace", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Local marketplace plugin" }), + "utf-8", + ); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + plugins: [ + { + name: "external-tools", + source: { + source: "github", + repo: "org/external-tools", + }, + }, + { + name: "review-tools", + source: { source: "local", path: "marketplace/review-tools" }, + }, + ], + }), + "utf-8", + ); + + const fromMarketplace = await resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + expect(fromMarketplace.plugin.pluginDir).toBe(pluginDir); + expect(fromMarketplace.plugin.manifest.description).toBe("Local marketplace plugin"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + it("rejects marketplace paths that traverse outside the source root", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); try { From 3d25295934d9fec376467a594eb867ba3e2f33be Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 7 Jul 2026 12:57:00 -0700 Subject: [PATCH 32/35] test(dotagents): Cover matching unsupported marketplace entries Exercise the unsupported-source skip path with a matching plugin name before the valid local marketplace entry. Co-Authored-By: OpenAI Codex --- packages/dotagents/src/plugins/store.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 2a90d0b..d1a4788 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -192,10 +192,10 @@ describe("plugin store", () => { name: "test-marketplace", plugins: [ { - name: "external-tools", + name: "review-tools", source: { source: "github", - repo: "org/external-tools", + repo: "org/review-tools", }, }, { From 3601245566cc488ec42704ec1404d585cd20c12d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 7 Jul 2026 13:22:58 -0700 Subject: [PATCH 33/35] fix(dotagents): Report concurrent plugin doctor errors Aggregate project-scope plugin doctor diagnostics so same-project declarations do not hide missing external plugin installs. Co-Authored-By: OpenAI Codex --- .../dotagents/src/cli/commands/doctor.test.ts | 29 +++++++++++++++++ packages/dotagents/src/cli/commands/doctor.ts | 31 +++++++++++-------- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 1b81d59..fca950c 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -146,6 +146,35 @@ source = "path:." expect(check?.message).toContain("Same-project plugins cannot be installed into the same project"); }); + it("reports same-project and missing plugin errors together", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" })); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "local-tools" +source = "path:.agents/plugins/local-tools" + +[[plugins]] +name = "review-tools" +source = "path:external-review-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 check = result.checks.find((c) => c.name === "installed plugins"); + expect(check?.status).toBe("error"); + expect(check?.message).toContain("local-tools"); + expect(check?.message).toContain("Same-project plugins cannot be installed into the same project"); + expect(check?.message).toContain("review-tools"); + expect(check?.message).toContain("Run 'npx @sentry/dotagents install'"); + }); + it("reports user-scope plugins as unsupported", async () => { const previousHome = process.env["DOTAGENTS_HOME"]; const userRoot = join(tmpDir, "user-agents"); diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index 5089cd5..427d5aa 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -222,23 +222,28 @@ export async function runDoctor(opts: DoctorOptions): Promise { .filter((plugin) => !sameProjectPlugins.includes(plugin.name)) .filter((plugin) => !existsSync(`${scope.pluginsDir}/${plugin.name}`)) .map((plugin) => plugin.name); + const pluginErrors: string[] = []; if (userScopePlugins.length > 0) { + pluginErrors.push( + `${userScopePlugins.length} plugin(s) declared in user scope: ${userScopePlugins.join(", ")}. User-scope plugins are not supported yet; declare plugins in a project agents.toml instead.`, + ); + } else { + if (sameProjectPlugins.length > 0) { + pluginErrors.push( + `${sameProjectPlugins.length} plugin(s) resolve inside this project's .agents/plugins/ tree: ${sameProjectPlugins.join(", ")}. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.`, + ); + } + if (missingPlugins.length > 0) { + pluginErrors.push( + `${missingPlugins.length} plugin(s) not installed: ${missingPlugins.join(", ")}. Run 'npx @sentry/dotagents install'.`, + ); + } + } + if (pluginErrors.length > 0) { checks.push({ name: "installed plugins", status: "error", - message: `${userScopePlugins.length} plugin(s) declared in user scope: ${userScopePlugins.join(", ")}. User-scope plugins are not supported yet; declare plugins in a project agents.toml instead.`, - }); - } else if (sameProjectPlugins.length > 0) { - checks.push({ - name: "installed plugins", - status: "error", - message: `${sameProjectPlugins.length} plugin(s) resolve inside this project's .agents/plugins/ tree: ${sameProjectPlugins.join(", ")}. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.`, - }); - } else if (missingPlugins.length > 0) { - checks.push({ - name: "installed plugins", - status: "error", - message: `${missingPlugins.length} plugin(s) not installed: ${missingPlugins.join(", ")}. Run 'npx @sentry/dotagents install'.`, + message: pluginErrors.join(" "), }); } else if (config.plugins.length > 0) { checks.push({ name: "installed plugins", status: "ok", message: `All ${config.plugins.length} declared plugin(s) installed.` }); From 095e354cd06fe6ad0b060e71706bad23691e5d8e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 7 Jul 2026 13:28:40 -0700 Subject: [PATCH 34/35] fix(dotagents): Preserve plugin backup on failed rollback Keep the existing plugin backup when replacing an installed plugin fails and rollback cannot restore it. This avoids deleting the only recoverable copy after a partial install failure. Also convert missing plugin source roots into a controlled error that preserves the original filesystem cause. Co-Authored-By: OpenAI Codex --- packages/dotagents/src/plugins/store.test.ts | 71 +++++++++++++++++++- packages/dotagents/src/plugins/store.ts | 15 ++++- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index d1a4788..4204013 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -1,10 +1,23 @@ -import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { mkdtemp, mkdir, readdir, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { describe, expect, it } from "vitest"; -import { isSameProjectPluginConfig, lockEntryForPlugin, resolvePlugin, type ResolvedPlugin } from "./store.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { installPluginBundle, isSameProjectPluginConfig, lockEntryForPlugin, resolvePlugin, type ResolvedPlugin } from "./store.js"; + +vi.mock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { + ...actual, + rename: vi.fn(actual.rename), + }; +}); describe("plugin store", () => { + afterEach(() => { + vi.mocked(rename).mockReset(); + }); + it("preserves an empty resolved path for root git plugins", () => { const resolved = { type: "git", @@ -39,6 +52,58 @@ describe("plugin store", () => { )).toBe(false); }); + it("keeps the backup when replacing an installed plugin fails and rollback also fails", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceDir = join(projectRoot, "source", "review-tools"); + const pluginsDir = join(projectRoot, ".agents", "plugins"); + const destDir = join(pluginsDir, "review-tools"); + await mkdir(sourceDir, { recursive: true }); + await mkdir(destDir, { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "New plugin" }), + "utf-8", + ); + await writeFile( + join(destDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Original plugin" }), + "utf-8", + ); + + const actual = await vi.importActual("node:fs/promises"); + vi.mocked(rename).mockImplementation(async (oldPath, newPath) => { + const oldString = String(oldPath); + const newString = String(newPath); + if (oldString.includes(".tmp-") && newString === destDir) { + throw new Error("destination rename failed"); + } + if (oldString.includes(".backup-") && newString === destDir) { + throw new Error("rollback failed"); + } + await actual.rename(oldPath, newPath); + }); + + await expect(installPluginBundle(pluginsDir, { + type: "local", + plugin: { + name: "review-tools", + source: "path:source/review-tools", + pluginDir: sourceDir, + manifest: { name: "review-tools", description: "New plugin" }, + }, + })).rejects.toThrow("destination rename failed"); + + expect(existsSync(destDir)).toBe(false); + const backupName = (await readdir(pluginsDir)).find((name) => name.startsWith(".review-tools.backup-")); + expect(backupName).toBeDefined(); + const backupManifest = JSON.parse(await readFile(join(pluginsDir, backupName!, "plugin.json"), "utf-8")); + expect(backupManifest.description).toBe("Original plugin"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + it("detects same-project plugins without requiring the explicit source path to exist", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); const pluginsDir = join(projectRoot, ".agents", "plugins"); diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts index 9172e15..400e317 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -187,7 +187,6 @@ export async function installPluginBundle( } } finally { await rm(tempDir, { recursive: true, force: true }); - await rm(backupDir, { recursive: true, force: true }); } return installed; @@ -604,7 +603,15 @@ async function assertInsideSourceRoot( label: string, displayPath = relativePath(root, filePath), ): Promise { - const rootRealPath = await realpath(root); + let rootRealPath: string; + try { + rootRealPath = await realpath(root); + } catch (err) { + if (isNotFoundError(err)) { + throw new Error(`${label} source root does not exist: ${displayPath}`, { cause: err }); + } + throw err; + } const fileRealPath = await realpath(filePath); const realRelPath = relative(rootRealPath, fileRealPath); if (realRelPath.startsWith("..") || isAbsolute(realRelPath)) { @@ -612,6 +619,10 @@ async function assertInsideSourceRoot( } } +function isNotFoundError(err: unknown): boolean { + return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; +} + function managedPluginPath(pluginsDir: string, name: string): string | null { const rootPath = resolve(pluginsDir); const pluginPath = resolve(rootPath, name); From b61ec3267b089e12086744c20a7609b16dbcd8b5 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 7 Jul 2026 13:34:14 -0700 Subject: [PATCH 35/35] test(dotagents): Cover missing plugin source roots Assert plugin source-root realpath failures use the controlled error path and preserve the original filesystem cause. Co-Authored-By: OpenAI Codex --- packages/dotagents/src/plugins/store.test.ts | 49 ++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 4204013..b4fec7c 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { mkdtemp, mkdir, readdir, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -9,13 +9,16 @@ vi.mock("node:fs/promises", async () => { const actual = await vi.importActual("node:fs/promises"); return { ...actual, + realpath: vi.fn(actual.realpath), rename: vi.fn(actual.rename), }; }); describe("plugin store", () => { - afterEach(() => { - vi.mocked(rename).mockReset(); + afterEach(async () => { + const actual = await vi.importActual("node:fs/promises"); + vi.mocked(realpath).mockImplementation(actual.realpath); + vi.mocked(rename).mockImplementation(actual.rename); }); it("preserves an empty resolved path for root git plugins", () => { @@ -144,6 +147,46 @@ describe("plugin store", () => { } }); + it("reports a controlled error when the plugin source root disappears during realpath checks", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const pluginDir = join(sourceRoot, "plugins", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "review-tools" }), + "utf-8", + ); + + const actual = await vi.importActual("node:fs/promises"); + const missingRoot = new Error("missing source root") as NodeJS.ErrnoException; + missingRoot.code = "ENOENT"; + vi.mocked(realpath).mockImplementation(async (path, options) => { + if (String(path) === sourceRoot) { + throw missingRoot; + } + return actual.realpath(path, options); + }); + + let error: unknown; + try { + await resolvePlugin( + { name: "review-tools", source: "path:source", path: "plugins/review-tools" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Plugin path source root does not exist: plugins/review-tools"); + expect((error as Error).cause).toBe(missingRoot); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + it("rejects explicit plugin path symlinks that escape the source root", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); try {