From 925d589a13083e4622d55516a8997a0fa73f2b9c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 12 Jun 2026 14:04:22 -0700 Subject: [PATCH 01/27] 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 --- 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/agents/registry.ts | 10 + packages/dotagents/src/cli/commands/doctor.ts | 40 +- .../src/cli/commands/install.test.ts | 242 +++++++ .../dotagents/src/cli/commands/install.ts | 195 +++++- .../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 + specs/SPEC.md | 116 ++- specs/plugins.md | 313 +++++++++ 34 files changed, 3333 insertions(+), 80 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 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/agents/registry.ts b/packages/dotagents/src/agents/registry.ts index 8bffe05..aa1c6be 100644 --- a/packages/dotagents/src/agents/registry.ts +++ b/packages/dotagents/src/agents/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/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index af6a9b1..afcc223 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -14,6 +14,7 @@ import { getAgent } from "../../agents/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 17bd773..9d42605 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -114,6 +114,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"), diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 1a2b441..eb2194b 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -7,11 +7,12 @@ import { isWildcardDep, type RepositorySource, type SkillDependency, + type PluginConfig, type SubagentConfig, } from "../../config/schema.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; -import { type Lockfile, type LockedSkill, type LockedSubagent } from "../../lockfile/schema.js"; +import { type Lockfile, type LockedSkill, type LockedPlugin, type LockedSubagent } from "../../lockfile/schema.js"; import { applyDefaultRepositorySource, resolveSkill, @@ -40,6 +41,16 @@ import { resolveSubagent, writeInstalledSubagents, } from "../../agents/subagent-store.js"; +import { + installPluginBundle, + isInPlacePluginSource, + isProjectPluginSource, + loadInstalledPlugins, + lockEntryForPlugin, + pruneInstalledPlugins, + resolvePlugin, +} from "../../agents/plugin-store.js"; +import { prunePluginOutputs, writePluginOutputs } from "../../agents/plugin-writer.js"; import { userMcpResolver } from "../../agents/paths.js"; import type { SubagentDeclaration } from "../../agents/types.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; @@ -61,8 +72,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 }[]; } /** Expanded skill ready for install — either from an explicit entry or a wildcard */ @@ -173,6 +186,24 @@ function validateFrozenSubagents( } } +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 optionalSubagentLockValue( entry: LockedSubagent, key: "resolved_url" | "resolved_path" | "resolved_ref" | "resolved_commit", @@ -197,6 +228,30 @@ function subagentLockEntriesEqual(a: LockedSubagent, b: LockedSubagent): boolean && optionalSubagentLockValue(a, "resolved_commit") === optionalSubagentLockValue(b, "resolved_commit"); } +function pluginLockEntriesEqual(a: LockedPlugin, b: LockedPlugin): boolean { + return a.source === b.source + && optionalPluginLockValue(a, "resolved_url") === optionalPluginLockValue(b, "resolved_url") + && optionalPluginLockValue(a, "resolved_path") === optionalPluginLockValue(b, "resolved_path") + && optionalPluginLockValue(a, "resolved_ref") === optionalPluginLockValue(b, "resolved_ref") + && optionalPluginLockValue(a, "resolved_commit") === optionalPluginLockValue(b, "resolved_commit"); +} + +function optionalPluginLockValue( + entry: LockedPlugin, + key: "resolved_url" | "resolved_path" | "resolved_ref" | "resolved_commit", +): string | undefined { + switch (key) { + case "resolved_url": + return "resolved_url" in entry ? entry.resolved_url : undefined; + case "resolved_path": + return "resolved_path" in entry ? entry.resolved_path : undefined; + case "resolved_ref": + return "resolved_ref" in entry ? entry.resolved_ref : undefined; + case "resolved_commit": + return "resolved_commit" in entry ? entry.resolved_commit : undefined; + } +} + function unchangedSubagentLockEntries( current: Lockfile | null, next: Lockfile, @@ -213,18 +268,45 @@ function unchangedSubagentLockEntries( return unchanged; } +function unchangedPluginLockEntries( + current: Lockfile | null, + next: Lockfile, +): Lockfile["plugins"] { + if (!current) {return {};} + + const unchanged: Lockfile["plugins"] = {}; + for (const [name, entry] of Object.entries(current.plugins)) { + const nextEntry = next.plugins[name]; + if (!nextEntry) {continue;} + if (!pluginLockEntriesEqual(entry, nextEntry)) {continue;} + unchanged[name] = entry; + } + return unchanged; +} + +function staleManagedPluginNames( + current: Lockfile | null, + next: Lockfile, +): string[] { + if (!current) {return [];} + return Object.entries(current.plugins) + .filter(([name, locked]) => !next.plugins[name] && !isInPlacePluginSource(locked.source)) + .map(([name]) => name); +} + export async function runInstall(opts: InstallOptions): Promise { const { scope, frozen } = opts; - const { configPath, lockPath, agentsDir, skillsDir } = scope; + const { configPath, lockPath, agentsDir, skillsDir, pluginsDir } = scope; const subagentsDir = join(agentsDir, "agents"); // 1. Read config const config = await loadConfig(configPath); const lockfile = await loadLockfile(lockPath); - const newLock: Lockfile = { version: 1, skills: {}, subagents: {} }; + const newLock: Lockfile = { version: 1, skills: {}, subagents: {}, plugins: {} }; const installed: string[] = []; const skipped: string[] = []; const pruned: string[] = []; + const prunedPlugins: string[] = []; // Ensure skills/ exists (needed for symlinks even without skills) await mkdir(skillsDir, { recursive: true }); @@ -372,7 +454,56 @@ export async function runInstall(opts: InstallOptions): Promise { } } - const shouldWriteLockfile = !frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0); + // 4. Resolve and install plugin bundles + let installedPlugins: Awaited>["plugins"] = []; + if (frozen) { + validateFrozenPlugins(config.plugins, lockfile); + if (config.plugins.length > 0) { + const loaded = await loadInstalledPlugins(pluginsDir, config.plugins); + if (loaded.issues.length > 0) { + throw new InstallError(loaded.issues.join("\n")); + } + installedPlugins = loaded.plugins; + } + } else if (config.plugins.length > 0) { + await mkdir(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, 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.", + ); + } + installedPlugins.push(await installPluginBundle(pluginsDir, resolved)); + newLock.plugins[resolved.plugin.name] = lockEntryForPlugin(resolved); + } + prunedPlugins.push(...await pruneInstalledPlugins( + pluginsDir, + staleManagedPluginNames(lockfile, newLock), + )); + } else if (!frozen && lockfile) { + prunedPlugins.push(...await pruneInstalledPlugins( + pluginsDir, + staleManagedPluginNames(lockfile, newLock), + )); + } + + const shouldWriteLockfile = !frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0 || config.plugins.length > 0); let installedSubagentsSynced = false; try { @@ -389,6 +520,7 @@ export async function runInstall(opts: InstallOptions): Promise { subagents: installedSubagentsSynced ? newLock.subagents : unchangedSubagentLockEntries(lockfile, newLock), + plugins: unchangedPluginLockEntries(lockfile, newLock), }); } catch { // Preserve the original install failure; this recovery write is best-effort. @@ -404,10 +536,11 @@ export async function runInstall(opts: InstallOptions): Promise { await writeLockfile(lockPath, { ...newLock, subagents: unchangedSubagentLockEntries(lockfile, newLock), + plugins: unchangedPluginLockEntries(lockfile, newLock), }); } - // 4. Gitignore (skip for user scope — ~/.agents/ is not a git repo) + // 5. Gitignore (skip for user scope — ~/.agents/ is not a git repo) if (scope.scope === "project") { // For wildcard entries all expanded skills are managed (wildcards can't be in-place) const managedNames = installed.filter((name) => { @@ -419,10 +552,16 @@ export async function runInstall(opts: InstallOptions): Promise { const managedSubagentNames = frozen ? Object.keys(lockfile?.subagents ?? {}) : installedSubagents.map((subagent) => subagent.name); + const managedPluginNames = frozen + ? Object.keys(lockfile?.plugins ?? {}) + : installedPlugins + .filter((plugin) => !isInPlacePluginSource(plugin.source)) + .map((plugin) => plugin.name); await writeAgentsGitignore( agentsDir, managedNames, managedSubagentNames, + managedPluginNames, ); // Health check: warn if agents.lock and .agents/.gitignore are not in root .gitignore @@ -432,7 +571,7 @@ export async function runInstall(opts: InstallOptions): Promise { } } - // 5. Symlinks — create per-agent symlinks so each agent discovers skills + // 6. Symlinks — create per-agent symlinks so each agent discovers skills if (scope.scope === "user") { const seen = new Set(); for (const agentId of config.agents) { @@ -460,11 +599,11 @@ export async function runInstall(opts: InstallOptions): Promise { } } - // 6. Write MCP config files + // 7. Write MCP config files const mcpResolver = scope.scope === "user" ? userMcpResolver() : projectMcpResolver(scope.root); await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), mcpResolver); - // 7. Write hook config files (skip for user scope) + // 8. Write hook config files (skip for user scope) let hookWarnings: { agent: string; message: string }[] = []; if (scope.scope === "project") { hookWarnings = await writeHookConfigs( @@ -474,7 +613,7 @@ export async function runInstall(opts: InstallOptions): Promise { ); } - // 8. Write custom subagent files + // 9. Write custom subagent files const subagentResolver = scope.scope === "user" ? userSubagentResolver() : projectSubagentResolver(scope.root); @@ -497,6 +636,7 @@ export async function runInstall(opts: InstallOptions): Promise { await writeLockfile(lockPath, { ...newLock, subagents: unchangedSubagentLockEntries(lockfile, newLock), + plugins: unchangedPluginLockEntries(lockfile, newLock), }); } catch { // Preserve the runtime config failure; this recovery write is best-effort. @@ -505,7 +645,34 @@ export async function runInstall(opts: InstallOptions): Promise { throw err; } - return { installed, skipped, pruned, hookWarnings, subagentWarnings: subagentResult.warnings }; + // 10. Write plugin runtime projections + let pluginWarnings: Awaited>["warnings"] = []; + if (scope.scope === "project") { + try { + const pluginResult = await writePluginOutputs(config.agents, installedPlugins, scope.root); + pluginWarnings = pluginResult.warnings; + if (!frozen) { + await prunePluginOutputs(config.agents, installedPlugins, scope.root); + } + if (shouldWriteLockfile) { + await writeLockfile(lockPath, newLock); + } + } catch (err) { + if (shouldWriteLockfile) { + try { + await writeLockfile(lockPath, { + ...newLock, + plugins: unchangedPluginLockEntries(lockfile, newLock), + }); + } catch { + // Preserve the runtime projection failure; this recovery write is best-effort. + } + } + throw err; + } + } + + return { installed, skipped, pruned, prunedPlugins, hookWarnings, subagentWarnings: subagentResult.warnings, pluginWarnings }; } export default async function install(args: string[], flags?: { user?: boolean }): Promise { @@ -539,12 +706,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/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 06df0f5..37a0b02 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -101,6 +101,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 7c7df5a..fd83ae7 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -617,6 +617,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 7228845..a3a40b4 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 "../../agents/hook-writer.js"; import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; import { loadInstalledSubagents, pruneInstalledSubagents } from "../../agents/subagent-store.js"; +import { isInPlacePluginSource, isProjectPluginSource, loadInstalledPlugins, pruneInstalledPlugins } from "../../agents/plugin-store.js"; +import { prunePluginOutputs, verifyPluginOutputs, writePluginOutputs } from "../../agents/plugin-writer.js"; import { userMcpResolver } from "../../agents/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 ef03e7a..54bcfed 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 3b92ed4..ebd6af0 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 "../agents/registry.js"; +import { allAgentIds, allConfigAgentIds } from "../agents/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/specs/SPEC.md b/specs/SPEC.md index 9771504..fde3631 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 b5d7caea4c49dfb0e6fc13c9cd2b877763f17fb5 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 12 Jun 2026 15:00:51 -0700 Subject: [PATCH 02/27] fix(plugins): Harden plugin install projections Keep plugin lock entries after bundle installation so later syncs can repair runtime projection failures. Validate same-project plugin declarations in doctor, emit a single conventional OpenCode module, escape generated OpenCode imports, and preserve empty resolved paths for root git plugins. Co-Authored-By: GPT-5 Codex --- .../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 +++++++++++ .../dotagents/src/cli/commands/install.ts | 53 ++----------------- packages/dotagents/src/cli/commands/sync.ts | 27 ++-------- 9 files changed, 175 insertions(+), 84 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 4471cb9..f113ecf 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 afcc223..f7bd0ea 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -14,7 +14,7 @@ import { getAgent } from "../../agents/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 9d42605..25879f9 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -212,6 +212,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/install.ts b/packages/dotagents/src/cli/commands/install.ts index eb2194b..181038c 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -12,7 +12,7 @@ import { } from "../../config/schema.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; -import { type Lockfile, type LockedSkill, type LockedPlugin, type LockedSubagent } from "../../lockfile/schema.js"; +import { type Lockfile, type LockedSkill, type LockedSubagent } from "../../lockfile/schema.js"; import { applyDefaultRepositorySource, resolveSkill, @@ -228,30 +228,6 @@ function subagentLockEntriesEqual(a: LockedSubagent, b: LockedSubagent): boolean && optionalSubagentLockValue(a, "resolved_commit") === optionalSubagentLockValue(b, "resolved_commit"); } -function pluginLockEntriesEqual(a: LockedPlugin, b: LockedPlugin): boolean { - return a.source === b.source - && optionalPluginLockValue(a, "resolved_url") === optionalPluginLockValue(b, "resolved_url") - && optionalPluginLockValue(a, "resolved_path") === optionalPluginLockValue(b, "resolved_path") - && optionalPluginLockValue(a, "resolved_ref") === optionalPluginLockValue(b, "resolved_ref") - && optionalPluginLockValue(a, "resolved_commit") === optionalPluginLockValue(b, "resolved_commit"); -} - -function optionalPluginLockValue( - entry: LockedPlugin, - key: "resolved_url" | "resolved_path" | "resolved_ref" | "resolved_commit", -): string | undefined { - switch (key) { - case "resolved_url": - return "resolved_url" in entry ? entry.resolved_url : undefined; - case "resolved_path": - return "resolved_path" in entry ? entry.resolved_path : undefined; - case "resolved_ref": - return "resolved_ref" in entry ? entry.resolved_ref : undefined; - case "resolved_commit": - return "resolved_commit" in entry ? entry.resolved_commit : undefined; - } -} - function unchangedSubagentLockEntries( current: Lockfile | null, next: Lockfile, @@ -268,22 +244,6 @@ function unchangedSubagentLockEntries( return unchanged; } -function unchangedPluginLockEntries( - current: Lockfile | null, - next: Lockfile, -): Lockfile["plugins"] { - if (!current) {return {};} - - const unchanged: Lockfile["plugins"] = {}; - for (const [name, entry] of Object.entries(current.plugins)) { - const nextEntry = next.plugins[name]; - if (!nextEntry) {continue;} - if (!pluginLockEntriesEqual(entry, nextEntry)) {continue;} - unchanged[name] = entry; - } - return unchanged; -} - function staleManagedPluginNames( current: Lockfile | null, next: Lockfile, @@ -520,7 +480,7 @@ export async function runInstall(opts: InstallOptions): Promise { subagents: installedSubagentsSynced ? newLock.subagents : unchangedSubagentLockEntries(lockfile, newLock), - plugins: unchangedPluginLockEntries(lockfile, newLock), + plugins: newLock.plugins, }); } catch { // Preserve the original install failure; this recovery write is best-effort. @@ -536,7 +496,7 @@ export async function runInstall(opts: InstallOptions): Promise { await writeLockfile(lockPath, { ...newLock, subagents: unchangedSubagentLockEntries(lockfile, newLock), - plugins: unchangedPluginLockEntries(lockfile, newLock), + plugins: newLock.plugins, }); } @@ -636,7 +596,7 @@ export async function runInstall(opts: InstallOptions): Promise { await writeLockfile(lockPath, { ...newLock, subagents: unchangedSubagentLockEntries(lockfile, newLock), - plugins: unchangedPluginLockEntries(lockfile, newLock), + plugins: newLock.plugins, }); } catch { // Preserve the runtime config failure; this recovery write is best-effort. @@ -660,10 +620,7 @@ export async function runInstall(opts: InstallOptions): Promise { } catch (err) { if (shouldWriteLockfile) { try { - await writeLockfile(lockPath, { - ...newLock, - plugins: unchangedPluginLockEntries(lockfile, newLock), - }); + await writeLockfile(lockPath, newLock); } catch { // Preserve the runtime projection failure; this recovery write is best-effort. } diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index a3a40b4..2a16ed5 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 "../../agents/hook-writer.js"; import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; import { loadInstalledSubagents, pruneInstalledSubagents } from "../../agents/subagent-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 "../../agents/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 8b4ba00843202260db24f53a16100cfa12fff115 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 12 Jun 2026 15:21:43 -0700 Subject: [PATCH 03/27] fix(plugins): Keep marketplace metadata out of manifests Use a manifest-shaped marketplace overlay so marketplace source and policy metadata do not leak into installed plugin manifests or generated Codex manifests. Reject same-project plugin declarations during frozen installs so frozen mode matches normal install, sync, and doctor behavior. Co-Authored-By: GPT-5 Codex --- packages/dotagents/src/agents/plugin-store.ts | 14 +++- .../src/cli/commands/install.test.ts | 84 +++++++++++++++++++ .../dotagents/src/cli/commands/install.ts | 19 +++++ 3 files changed, 115 insertions(+), 2 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 25879f9..7254374 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -298,6 +298,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"); @@ -347,6 +374,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 181038c..1a2a8f7 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -45,6 +45,7 @@ import { installPluginBundle, isInPlacePluginSource, isProjectPluginSource, + isSameProjectPluginConfig, loadInstalledPlugins, lockEntryForPlugin, pruneInstalledPlugins, @@ -418,6 +419,9 @@ export async function runInstall(opts: InstallOptions): Promise { let installedPlugins: Awaited>["plugins"] = []; if (frozen) { validateFrozenPlugins(config.plugins, lockfile); + if (scope.scope === "project") { + assertNoSameProjectPluginConfigs(config.plugins, pluginsDir, scope.root); + } if (config.plugins.length > 0) { const loaded = await loadInstalledPlugins(pluginsDir, config.plugins); if (loaded.issues.length > 0) { @@ -632,6 +636,21 @@ export async function runInstall(opts: InstallOptions): Promise { return { installed, skipped, pruned, prunedPlugins, hookWarnings, subagentWarnings: subagentResult.warnings, pluginWarnings }; } +function assertNoSameProjectPluginConfigs( + plugins: PluginConfig[], + pluginsDir: string, + projectRoot: string, +): void { + for (const plugin of plugins) { + if (isSameProjectPluginConfig(plugin, pluginsDir, projectRoot)) { + throw new InstallError( + `Plugin "${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.", + ); + } + } +} + export default async function install(args: string[], flags?: { user?: boolean }): Promise { const { values } = parseArgs({ args, From 85b60b10f146c46226e8fe0cfefdda8ba22763a9 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 12 Jun 2026 15:43:24 -0700 Subject: [PATCH 04/27] fix(plugins): Tighten plugin sync diagnostics Report missing plugin bundles as errors, avoid OpenCode stubs for absent explicit modules, and reject user-scope plugin declarations until user-scope projections are defined. Document the project-scope-only plugin behavior in the public docs and specs. Co-Authored-By: GPT-5 Codex --- 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/install.ts | 6 ++++ .../dotagents/src/cli/commands/sync.test.ts | 21 ++++++++++++++ packages/dotagents/src/cli/commands/sync.ts | 19 +++++++++++-- specs/SPEC.md | 4 ++- specs/plugins.md | 2 ++ 10 files changed, 117 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 7254374..cf5005c 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -325,6 +325,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/install.ts b/packages/dotagents/src/cli/commands/install.ts index 1a2a8f7..e495c58 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -262,6 +262,12 @@ export async function runInstall(opts: InstallOptions): Promise { // 1. Read config const config = await loadConfig(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(lockPath); const newLock: Lockfile = { version: 1, skills: {}, subagents: {}, plugins: {} }; const installed: string[] = []; diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index fd83ae7..a08b0d0 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -281,6 +281,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 2a16ed5..d07078c 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 fde3631..de036d1 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 73884c2ded6b2c022e8ce5961cb02bb4657564f3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 12 Jun 2026 16:20:26 -0700 Subject: [PATCH 05/27] 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 +---- packages/dotagents/src/agents/registry.ts | 2 + .../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 +- scripts/smoke-examples.mjs | 55 ++++ skills/dotagents-qa/SKILL.md | 19 ++ specs/SPEC.md | 4 +- specs/plugins.md | 18 +- 30 files changed, 552 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/agents/registry.ts b/packages/dotagents/src/agents/registry.ts index aa1c6be..754ef66 100644 --- a/packages/dotagents/src/agents/registry.ts +++ b/packages/dotagents/src/agents/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/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index f113ecf..3960104 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 f7bd0ea..f9e42d7 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 cf5005c..847f6ef 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -153,35 +153,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" @@ -212,6 +184,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 }); @@ -325,6 +422,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"); @@ -459,6 +583,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 }); @@ -494,7 +695,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 37a0b02..5fdbd48 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -137,7 +137,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 a08b0d0..fd1465b 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -664,29 +664,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); }); @@ -749,10 +728,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 d07078c..f883bc7 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/scripts/smoke-examples.mjs b/scripts/smoke-examples.mjs index 24bd009..338aaab 100644 --- a/scripts/smoke-examples.mjs +++ b/scripts/smoke-examples.mjs @@ -61,6 +61,7 @@ try { runCli(["doctor"]); assertIncludes(list, "review", "list output should include review"); assertIncludes(list, "commit", "list output should include commit"); + assertIncludes(list, "qa-tools", "list output should include qa-tools plugin"); assertFile(".agents/skills/review/SKILL.md"); assertFile(".agents/skills/commit/SKILL.md"); @@ -76,16 +77,22 @@ try { assertFile(".claude/settings.json"); assertFile(".cursor/hooks.json"); assertSubagentOutputs(); + assertPluginOutputs(); rmSync(join(projectDir, ".mcp.json"), { force: true }); 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(); if (runCodexRuntime) { proveCodexRuntime(); @@ -206,6 +213,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)) { @@ -213,6 +251,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()) { @@ -225,6 +270,16 @@ function assertFileIncludes(relativePath, expected) { assertIncludes(readFileSync(join(projectDir, relativePath), "utf-8"), expected, `${relativePath} should include ${expected}`); } +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/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index 6e5fdcd..e9954c7 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 scripts/smoke-examples.mjs --keep` when you need to inspect the temp @@ -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/specs/SPEC.md b/specs/SPEC.md index de036d1..f5ee009 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 e52c63eef2978fd5401e2c287b80339ab6214af1 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 09:14:40 -0700 Subject: [PATCH 06/27] test(qa): Add opt-in runtime smoke wrapper Add an explicit runtime QA command that loads untracked QA env files, keeps the default smoke no-cost, and runs timeout-bounded runtime probes only when requested. Document safe env forwarding for Docker QA and prevent repo-local .env files from being copied into sandbox fixtures. Co-Authored-By: Codex --- .env.qa.example | 14 +++ .gitignore | 4 + package.json | 3 +- scripts/qa-runtime.mjs | 238 +++++++++++++++++++++++++++++++++++ skills/dotagents-qa/SKILL.md | 33 +++++ 5 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 .env.qa.example create mode 100644 scripts/qa-runtime.mjs diff --git a/.env.qa.example b/.env.qa.example new file mode 100644 index 0000000..6ad5e35 --- /dev/null +++ b/.env.qa.example @@ -0,0 +1,14 @@ +# Copy to .env.qa.local for opt-in runtime QA. Never commit real keys. +# +# Codex runtime QA uses your existing Codex auth by default: +# CODEX_HOME=/Users/you/.codex +# +# Claude runtime QA currently requires Claude Code bare-mode API auth. +# ANTHROPIC_API_KEY=sk-ant-... +# +# OpenRouter is useful for future OpenCode/Grok runtime probes, but Claude Code +# does not consume it directly. +# OPENROUTER_API_KEY=sk-or-v1-... +# +# Comma-separated allowlist for Docker/sandbox runtime probes when needed. +DOTAGENTS_QA_FORWARD_ENV=CODEX_HOME,ANTHROPIC_API_KEY,OPENROUTER_API_KEY diff --git a/.gitignore b/.gitignore index 845dc88..3db29ce 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ dist/ *.tsbuildinfo *.tgz .DS_Store +.env +.env.* +!.env.example +!.env.qa.example docs/out/ docs/.next/ docs/.astro/ diff --git a/package.json b/package.json index 47bbb24..d6900c6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "typecheck": "pnpm -r typecheck", "check": "pnpm lint && pnpm typecheck && pnpm test", "dev": "pnpm --filter @sentry/dotagents dev", - "smoke:examples": "pnpm build && node scripts/smoke-examples.mjs" + "smoke:examples": "pnpm build && node scripts/smoke-examples.mjs", + "qa:runtime": "pnpm build && node scripts/qa-runtime.mjs" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/scripts/qa-runtime.mjs b/scripts/qa-runtime.mjs new file mode 100644 index 0000000..a87f36e --- /dev/null +++ b/scripts/qa-runtime.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node +// Optional runtime QA for checks that need local auth or provider API keys. +// The default example smoke remains deterministic and key-free. + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const args = parseArgs(process.argv.slice(2)); +const envFile = resolve(repoRoot, args.envFile ?? ".env.qa.local"); +const runAll = args.targets.has("all"); +const keep = args.keep; +const failures = []; + +loadEnvFile(envFile); + +console.log("qa-runtime: running deterministic example smoke"); +const smokeResult = runNodeScript("scripts/smoke-examples.mjs", [ + ...(keep ? ["--keep"] : []), +], { timeoutMs: 120_000 }); +if (smokeResult.status !== 0) { + process.exit(smokeResult.status ?? 1); +} + +if (args.targets.size === 0) { + console.log("qa-runtime: no paid runtime target requested; use --codex, --claude, --opencode, or --all"); +} + +if (runAll || args.targets.has("codex")) { + runCodexRuntime(); +} + +if (runAll || args.targets.has("claude")) { + runClaudeRuntime(); +} + +if (runAll || args.targets.has("opencode")) { + runPlaceholder("opencode", "OpenCode runtime proof is not implemented yet; file-level plugin output is covered by smoke:examples."); +} + +if (failures.length > 0) { + console.error("qa-runtime: failed runtime probes:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log("qa-runtime: ok"); + +function runCodexRuntime() { + if (!commandExists("codex")) { + skip("codex", "codex CLI is not on PATH"); + return; + } + + const codexHome = process.env["CODEX_HOME"] ?? join(homedir(), ".codex"); + if (!existsSync(join(codexHome, "auth.json"))) { + skip("codex", `missing auth.json at ${codexHome}/auth.json`); + return; + } + + console.log("qa-runtime: running Codex custom-agent runtime proof"); + const result = runNodeScript("scripts/smoke-examples.mjs", [ + "--codex-runtime", + ...(keep ? ["--keep"] : []), + ], { timeoutMs: 180_000 }); + if (result.status !== 0) { + failures.push("codex runtime proof failed"); + } +} + +function runClaudeRuntime() { + if (!commandExists("claude")) { + skip("claude", "claude CLI is not on PATH"); + return; + } + if (!process.env["ANTHROPIC_API_KEY"]) { + skip("claude", "ANTHROPIC_API_KEY is not set"); + return; + } + + console.log("qa-runtime: preparing temp project for Claude plugin runtime proof"); + const smokeResult = runNodeScript("scripts/smoke-examples.mjs", ["--keep"], { timeoutMs: 120_000, capture: true }); + if (smokeResult.status !== 0) { + failures.push("claude setup smoke failed"); + return; + } + + const projectDir = parseProjectDir(smokeResult.stdout); + if (!projectDir) { + failures.push("claude setup did not report a temp project path"); + return; + } + + const pluginDir = join(projectDir, ".agents", "plugins", "qa-tools"); + const prompt = [ + "/plugin-qa", + "Use the loaded plugin fixture. Return only the exact proof token from that plugin.", + ].join("\n"); + const result = runCommand("claude", [ + "--bare", + "-p", + "--plugin-dir", + pluginDir, + "--max-budget-usd", + "0.10", + "--output-format", + "json", + "--tools", + "", + prompt, + ], { + cwd: projectDir, + timeoutMs: 45_000, + capture: true, + }); + + if (result.timedOut) { + failures.push("claude runtime proof timed out; local Claude CLI bare print mode appears non-automatable in this environment"); + } else if (result.status !== 0) { + failures.push("claude runtime proof failed"); + } else if (!result.stdout.includes("DOTAGENTS_PLUGIN_QA_FIXTURE")) { + failures.push("claude runtime proof did not return the plugin fixture token"); + } + + if (!keep) { + rmSync(dirname(projectDir), { recursive: true, force: true }); + } +} + +function runPlaceholder(name, reason) { + skip(name, reason); +} + +function parseArgs(argv) { + const parsed = { envFile: undefined, keep: false, targets: new Set() }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--") { + continue; + } else if (arg === "--env-file") { + parsed.envFile = argv[++i]; + if (!parsed.envFile) {throw new Error("--env-file requires a path");} + } else if (arg === "--keep") { + parsed.keep = true; + } else if (arg === "--codex" || arg === "--codex-runtime") { + parsed.targets.add("codex"); + } else if (arg === "--claude" || arg === "--claude-runtime") { + parsed.targets.add("claude"); + } else if (arg === "--opencode" || arg === "--opencode-runtime") { + parsed.targets.add("opencode"); + } else if (arg === "--all") { + parsed.targets.add("all"); + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + return parsed; +} + +function loadEnvFile(path) { + if (!existsSync(path)) { + console.log(`qa-runtime: no env file at ${path}; using process environment`); + return; + } + + for (const [lineNumber, rawLine] of readFileSync(path, "utf-8").split(/\r?\n/).entries()) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) {continue;} + const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); + if (!match) { + throw new Error(`${path}:${lineNumber + 1}: expected KEY=value`); + } + const key = match[1]; + let value = match[2] ?? ""; + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + process.env[key] ??= value; + } + console.log(`qa-runtime: loaded env file ${path}`); +} + +function runNodeScript(relativePath, scriptArgs, opts = {}) { + return runCommand(process.execPath, [join(repoRoot, relativePath), ...scriptArgs], { + cwd: repoRoot, + timeoutMs: opts.timeoutMs, + capture: opts.capture, + }); +} + +function runCommand(command, commandArgs, opts = {}) { + const result = spawnSync(command, commandArgs, { + cwd: opts.cwd ?? repoRoot, + env: process.env, + encoding: "utf-8", + stdio: opts.capture ? ["ignore", "pipe", "pipe"] : "inherit", + timeout: opts.timeoutMs, + }); + + if (opts.capture && result.stdout) { + process.stdout.write(result.stdout); + } + if (opts.capture && result.stderr) { + process.stderr.write(result.stderr); + } + + if (result.error?.code === "ETIMEDOUT") { + return { status: null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", timedOut: true }; + } + if (result.error) { + return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "", timedOut: false }; + } + return { status: result.status ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", timedOut: false }; +} + +function commandExists(command) { + const result = spawnSync(command, ["--version"], { + encoding: "utf-8", + stdio: ["ignore", "ignore", "ignore"], + timeout: 10_000, + }); + return result.status === 0; +} + +function parseProjectDir(output) { + const match = /^smoke-examples: project=(.+)$/m.exec(output); + return match?.[1]; +} + +function skip(name, reason) { + console.log(`qa-runtime: skipping ${name}: ${reason}`); +} diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index e9954c7..56ac2cf 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -72,6 +72,21 @@ docker run --rm -it \ dotagents-qa:local ``` +For opt-in runtime probes that need API keys, keep secrets in an untracked +`.env.qa.local` file and pass them as environment variables instead of copying +the file into the sandbox: + +```bash +docker run --rm -it \ + --env-file .env.qa.local \ + -v "$REPO:/host-repo:ro" \ + -v "$OUT:/qa-out" \ + dotagents-qa:local +``` + +Only use stable, low-privilege QA keys. Do not commit `.env.qa.local`, and do +not copy repo-local `.env*` files into sandbox fixtures. + If your tool environment is not attached to a TTY, use `-i` instead of `-it` and feed the same commands with a here-doc. Keep `-i`; without stdin attached, the container shell will receive no script. @@ -92,6 +107,8 @@ tar -C /host-repo \ --exclude=.turbo \ --exclude=coverage \ --exclude=core \ + --exclude=.env \ + --exclude='.env.*' \ --exclude='*.tsbuildinfo' \ --exclude='packages/*/dist' \ -cf - . | tar -C /sandbox/repo -xf - @@ -152,6 +169,22 @@ asserts: Use `node scripts/smoke-examples.mjs --keep` when you need to inspect the temp project; the script prints the retained path. +Use the runtime QA wrapper when you need the strongest easy proof available +from local CLIs and credentials: + +```bash +pnpm qa:runtime -- --all +``` + +The wrapper loads `.env.qa.local` when present, runs the deterministic example +smoke, and then runs explicitly requested runtime probes when their CLI and +credentials are available. Missing credentials skip that runtime; a configured +runtime that fails or times out is a QA failure. Runtime probes are not run +implicitly because they may spend model/API budget. Use `pnpm qa:runtime`, +`pnpm qa:runtime -- --codex`, `pnpm qa:runtime -- --claude`, or +`pnpm qa:runtime -- --keep` to run only the deterministic smoke, narrow the +target, or retain the temp project. + For paid Codex runtime proof of generated custom agents, run the runtime proof outside Docker only when the branch affects Codex custom agents or when reporting that Codex itself works: From 0632c6e85b416449755bfdd4fd9d455042175232 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 09:18:01 -0700 Subject: [PATCH 07/27] Revert "test(qa): Add opt-in runtime smoke wrapper" This reverts commit e52c63eef2978fd5401e2c287b80339ab6214af1. --- .env.qa.example | 14 --- .gitignore | 4 - package.json | 3 +- scripts/qa-runtime.mjs | 238 ----------------------------------- skills/dotagents-qa/SKILL.md | 33 ----- 5 files changed, 1 insertion(+), 291 deletions(-) delete mode 100644 .env.qa.example delete mode 100644 scripts/qa-runtime.mjs diff --git a/.env.qa.example b/.env.qa.example deleted file mode 100644 index 6ad5e35..0000000 --- a/.env.qa.example +++ /dev/null @@ -1,14 +0,0 @@ -# Copy to .env.qa.local for opt-in runtime QA. Never commit real keys. -# -# Codex runtime QA uses your existing Codex auth by default: -# CODEX_HOME=/Users/you/.codex -# -# Claude runtime QA currently requires Claude Code bare-mode API auth. -# ANTHROPIC_API_KEY=sk-ant-... -# -# OpenRouter is useful for future OpenCode/Grok runtime probes, but Claude Code -# does not consume it directly. -# OPENROUTER_API_KEY=sk-or-v1-... -# -# Comma-separated allowlist for Docker/sandbox runtime probes when needed. -DOTAGENTS_QA_FORWARD_ENV=CODEX_HOME,ANTHROPIC_API_KEY,OPENROUTER_API_KEY diff --git a/.gitignore b/.gitignore index 3db29ce..845dc88 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,6 @@ dist/ *.tsbuildinfo *.tgz .DS_Store -.env -.env.* -!.env.example -!.env.qa.example docs/out/ docs/.next/ docs/.astro/ diff --git a/package.json b/package.json index d6900c6..47bbb24 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,7 @@ "typecheck": "pnpm -r typecheck", "check": "pnpm lint && pnpm typecheck && pnpm test", "dev": "pnpm --filter @sentry/dotagents dev", - "smoke:examples": "pnpm build && node scripts/smoke-examples.mjs", - "qa:runtime": "pnpm build && node scripts/qa-runtime.mjs" + "smoke:examples": "pnpm build && node scripts/smoke-examples.mjs" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/scripts/qa-runtime.mjs b/scripts/qa-runtime.mjs deleted file mode 100644 index a87f36e..0000000 --- a/scripts/qa-runtime.mjs +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env node -// Optional runtime QA for checks that need local auth or provider API keys. -// The default example smoke remains deterministic and key-free. - -import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync, rmSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(__dirname, ".."); -const args = parseArgs(process.argv.slice(2)); -const envFile = resolve(repoRoot, args.envFile ?? ".env.qa.local"); -const runAll = args.targets.has("all"); -const keep = args.keep; -const failures = []; - -loadEnvFile(envFile); - -console.log("qa-runtime: running deterministic example smoke"); -const smokeResult = runNodeScript("scripts/smoke-examples.mjs", [ - ...(keep ? ["--keep"] : []), -], { timeoutMs: 120_000 }); -if (smokeResult.status !== 0) { - process.exit(smokeResult.status ?? 1); -} - -if (args.targets.size === 0) { - console.log("qa-runtime: no paid runtime target requested; use --codex, --claude, --opencode, or --all"); -} - -if (runAll || args.targets.has("codex")) { - runCodexRuntime(); -} - -if (runAll || args.targets.has("claude")) { - runClaudeRuntime(); -} - -if (runAll || args.targets.has("opencode")) { - runPlaceholder("opencode", "OpenCode runtime proof is not implemented yet; file-level plugin output is covered by smoke:examples."); -} - -if (failures.length > 0) { - console.error("qa-runtime: failed runtime probes:"); - for (const failure of failures) { - console.error(`- ${failure}`); - } - process.exit(1); -} - -console.log("qa-runtime: ok"); - -function runCodexRuntime() { - if (!commandExists("codex")) { - skip("codex", "codex CLI is not on PATH"); - return; - } - - const codexHome = process.env["CODEX_HOME"] ?? join(homedir(), ".codex"); - if (!existsSync(join(codexHome, "auth.json"))) { - skip("codex", `missing auth.json at ${codexHome}/auth.json`); - return; - } - - console.log("qa-runtime: running Codex custom-agent runtime proof"); - const result = runNodeScript("scripts/smoke-examples.mjs", [ - "--codex-runtime", - ...(keep ? ["--keep"] : []), - ], { timeoutMs: 180_000 }); - if (result.status !== 0) { - failures.push("codex runtime proof failed"); - } -} - -function runClaudeRuntime() { - if (!commandExists("claude")) { - skip("claude", "claude CLI is not on PATH"); - return; - } - if (!process.env["ANTHROPIC_API_KEY"]) { - skip("claude", "ANTHROPIC_API_KEY is not set"); - return; - } - - console.log("qa-runtime: preparing temp project for Claude plugin runtime proof"); - const smokeResult = runNodeScript("scripts/smoke-examples.mjs", ["--keep"], { timeoutMs: 120_000, capture: true }); - if (smokeResult.status !== 0) { - failures.push("claude setup smoke failed"); - return; - } - - const projectDir = parseProjectDir(smokeResult.stdout); - if (!projectDir) { - failures.push("claude setup did not report a temp project path"); - return; - } - - const pluginDir = join(projectDir, ".agents", "plugins", "qa-tools"); - const prompt = [ - "/plugin-qa", - "Use the loaded plugin fixture. Return only the exact proof token from that plugin.", - ].join("\n"); - const result = runCommand("claude", [ - "--bare", - "-p", - "--plugin-dir", - pluginDir, - "--max-budget-usd", - "0.10", - "--output-format", - "json", - "--tools", - "", - prompt, - ], { - cwd: projectDir, - timeoutMs: 45_000, - capture: true, - }); - - if (result.timedOut) { - failures.push("claude runtime proof timed out; local Claude CLI bare print mode appears non-automatable in this environment"); - } else if (result.status !== 0) { - failures.push("claude runtime proof failed"); - } else if (!result.stdout.includes("DOTAGENTS_PLUGIN_QA_FIXTURE")) { - failures.push("claude runtime proof did not return the plugin fixture token"); - } - - if (!keep) { - rmSync(dirname(projectDir), { recursive: true, force: true }); - } -} - -function runPlaceholder(name, reason) { - skip(name, reason); -} - -function parseArgs(argv) { - const parsed = { envFile: undefined, keep: false, targets: new Set() }; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === "--") { - continue; - } else if (arg === "--env-file") { - parsed.envFile = argv[++i]; - if (!parsed.envFile) {throw new Error("--env-file requires a path");} - } else if (arg === "--keep") { - parsed.keep = true; - } else if (arg === "--codex" || arg === "--codex-runtime") { - parsed.targets.add("codex"); - } else if (arg === "--claude" || arg === "--claude-runtime") { - parsed.targets.add("claude"); - } else if (arg === "--opencode" || arg === "--opencode-runtime") { - parsed.targets.add("opencode"); - } else if (arg === "--all") { - parsed.targets.add("all"); - } else { - throw new Error(`unknown argument: ${arg}`); - } - } - return parsed; -} - -function loadEnvFile(path) { - if (!existsSync(path)) { - console.log(`qa-runtime: no env file at ${path}; using process environment`); - return; - } - - for (const [lineNumber, rawLine] of readFileSync(path, "utf-8").split(/\r?\n/).entries()) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) {continue;} - const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); - if (!match) { - throw new Error(`${path}:${lineNumber + 1}: expected KEY=value`); - } - const key = match[1]; - let value = match[2] ?? ""; - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - process.env[key] ??= value; - } - console.log(`qa-runtime: loaded env file ${path}`); -} - -function runNodeScript(relativePath, scriptArgs, opts = {}) { - return runCommand(process.execPath, [join(repoRoot, relativePath), ...scriptArgs], { - cwd: repoRoot, - timeoutMs: opts.timeoutMs, - capture: opts.capture, - }); -} - -function runCommand(command, commandArgs, opts = {}) { - const result = spawnSync(command, commandArgs, { - cwd: opts.cwd ?? repoRoot, - env: process.env, - encoding: "utf-8", - stdio: opts.capture ? ["ignore", "pipe", "pipe"] : "inherit", - timeout: opts.timeoutMs, - }); - - if (opts.capture && result.stdout) { - process.stdout.write(result.stdout); - } - if (opts.capture && result.stderr) { - process.stderr.write(result.stderr); - } - - if (result.error?.code === "ETIMEDOUT") { - return { status: null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", timedOut: true }; - } - if (result.error) { - return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "", timedOut: false }; - } - return { status: result.status ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", timedOut: false }; -} - -function commandExists(command) { - const result = spawnSync(command, ["--version"], { - encoding: "utf-8", - stdio: ["ignore", "ignore", "ignore"], - timeout: 10_000, - }); - return result.status === 0; -} - -function parseProjectDir(output) { - const match = /^smoke-examples: project=(.+)$/m.exec(output); - return match?.[1]; -} - -function skip(name, reason) { - console.log(`qa-runtime: skipping ${name}: ${reason}`); -} diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index 56ac2cf..e9954c7 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -72,21 +72,6 @@ docker run --rm -it \ dotagents-qa:local ``` -For opt-in runtime probes that need API keys, keep secrets in an untracked -`.env.qa.local` file and pass them as environment variables instead of copying -the file into the sandbox: - -```bash -docker run --rm -it \ - --env-file .env.qa.local \ - -v "$REPO:/host-repo:ro" \ - -v "$OUT:/qa-out" \ - dotagents-qa:local -``` - -Only use stable, low-privilege QA keys. Do not commit `.env.qa.local`, and do -not copy repo-local `.env*` files into sandbox fixtures. - If your tool environment is not attached to a TTY, use `-i` instead of `-it` and feed the same commands with a here-doc. Keep `-i`; without stdin attached, the container shell will receive no script. @@ -107,8 +92,6 @@ tar -C /host-repo \ --exclude=.turbo \ --exclude=coverage \ --exclude=core \ - --exclude=.env \ - --exclude='.env.*' \ --exclude='*.tsbuildinfo' \ --exclude='packages/*/dist' \ -cf - . | tar -C /sandbox/repo -xf - @@ -169,22 +152,6 @@ asserts: Use `node scripts/smoke-examples.mjs --keep` when you need to inspect the temp project; the script prints the retained path. -Use the runtime QA wrapper when you need the strongest easy proof available -from local CLIs and credentials: - -```bash -pnpm qa:runtime -- --all -``` - -The wrapper loads `.env.qa.local` when present, runs the deterministic example -smoke, and then runs explicitly requested runtime probes when their CLI and -credentials are available. Missing credentials skip that runtime; a configured -runtime that fails or times out is a QA failure. Runtime probes are not run -implicitly because they may spend model/API budget. Use `pnpm qa:runtime`, -`pnpm qa:runtime -- --codex`, `pnpm qa:runtime -- --claude`, or -`pnpm qa:runtime -- --keep` to run only the deterministic smoke, narrow the -target, or retain the temp project. - For paid Codex runtime proof of generated custom agents, run the runtime proof outside Docker only when the branch affects Codex custom agents or when reporting that Codex itself works: From 7c0832b82c5a7ea5ddd73cfb7ad7d26cfa3c3b40 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 09:37:54 -0700 Subject: [PATCH 08/27] 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 ++- scripts/smoke-examples.mjs | 7 +- skills/dotagents-qa/SKILL.md | 5 +- specs/SPEC.md | 4 +- specs/plugins.md | 6 +- 7 files changed, 143 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 847f6ef..cbd1c07 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -166,7 +166,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" } ] @@ -174,6 +174,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/scripts/smoke-examples.mjs b/scripts/smoke-examples.mjs index 338aaab..904104d 100644 --- a/scripts/smoke-examples.mjs +++ b/scripts/smoke-examples.mjs @@ -84,6 +84,7 @@ try { 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", ".claude-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 }); @@ -219,16 +220,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/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index e9954c7..ee7fc79 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/specs/SPEC.md b/specs/SPEC.md index f5ee009..c61724d 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 fbeb82e98d758c10e26eed283c89d3c990fbcf3d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 09:51:42 -0700 Subject: [PATCH 09/27] 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 +- scripts/smoke-examples.mjs | 7 ++- skills/dotagents-qa/SKILL.md | 5 +- 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 cbd1c07..3465dc3 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -153,7 +153,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" @@ -703,7 +727,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 fd1465b..4fe7a76 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -665,7 +665,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/scripts/smoke-examples.mjs b/scripts/smoke-examples.mjs index 904104d..0039442 100644 --- a/scripts/smoke-examples.mjs +++ b/scripts/smoke-examples.mjs @@ -83,6 +83,7 @@ try { 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", ".claude-plugin", "plugin.json"), { force: true }); rmSync(join(projectDir, ".agents", "plugins", "qa-tools", ".codex-plugin", "plugin.json"), { force: true }); @@ -222,7 +223,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/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index ee7fc79..465eca5 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/specs/SPEC.md b/specs/SPEC.md index c61724d..d10d87e 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 2206c837be52c44d500a33e78f6795bf0a09041c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 09:55:03 -0700 Subject: [PATCH 10/27] test(qa): Add task-based agentic QA runner Move the checked-in example QA runner into the dotagents-qa skill and split it into explicit tasks for install file checks, sync repair, Claude plugin validation, Codex plugin install proof, OpenCode module projection, and optional Codex runtime proof. Document the task-specific Claude and Codex plugin QA flows so future validation uses the Docker skill instead of one all-in-one smoke script. Co-Authored-By: Codex --- package.json | 2 +- skills/dotagents-qa/SKILL.md | 37 +-- skills/dotagents-qa/SOURCES.md | 2 +- skills/dotagents-qa/SPEC.md | 4 +- skills/dotagents-qa/references/claude.md | 28 ++- skills/dotagents-qa/references/codex.md | 37 ++- .../{core-smoke.md => core-agentic-qa.md} | 10 +- skills/dotagents-qa/references/cursor.md | 2 +- skills/dotagents-qa/references/opencode.md | 2 +- .../dotagents-qa/scripts/qa-example.mjs | 224 ++++++++++++++---- 10 files changed, 263 insertions(+), 85 deletions(-) rename skills/dotagents-qa/references/{core-smoke.md => core-agentic-qa.md} (74%) rename scripts/smoke-examples.mjs => skills/dotagents-qa/scripts/qa-example.mjs (71%) diff --git a/package.json b/package.json index 47bbb24..76cdf50 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "pnpm -r typecheck", "check": "pnpm lint && pnpm typecheck && pnpm test", "dev": "pnpm --filter @sentry/dotagents dev", - "smoke:examples": "pnpm build && node scripts/smoke-examples.mjs" + "qa:example": "pnpm build && node skills/dotagents-qa/scripts/qa-example.mjs" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index 465eca5..6abaf7d 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -34,9 +34,9 @@ Write down the QA target before running commands: broken state, user-scope state, or remote source. Read the targeted reference before running runtime-specific QA: -- Core install/sync example: [references/core-smoke.md](references/core-smoke.md) -- Codex custom agents: [references/codex.md](references/codex.md) -- Claude Code files/runtime caveats: [references/claude.md](references/claude.md) +- Core install/sync example: [references/core-agentic-qa.md](references/core-agentic-qa.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) - OpenCode files/runtime caveats: [references/opencode.md](references/opencode.md) @@ -58,8 +58,11 @@ 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. Use an interactive container so the QA steps stay change-specific: @@ -119,7 +122,7 @@ su -s /bin/bash node -c ' pnpm install --frozen-lockfile pnpm build pnpm check - pnpm smoke:examples + pnpm qa:example ' ``` @@ -128,15 +131,15 @@ the check is already known to be unrelated. If `build` or `check` fails, treat that as a QA finding and stop before fixture work unless you are explicitly isolating the playbook mechanics. If skipped or bypassed, report why. -## 3. Prefer The Checked-In Smoke +## 3. Prefer The Checked-In Agentic QA -Use the checked-in example smoke for ordinary install/sync QA: +Use the checked-in example QA for ordinary install/sync QA: ```bash -pnpm smoke:examples +pnpm qa:example ``` -The smoke builds the local CLI, copies `examples/full/` to a temp project, and +The QA runner builds the local CLI, copies `examples/full/` to a temp project, and asserts: - `install`, `list`, `doctor --fix`, and `doctor` - managed skills under `.agents/skills/` @@ -150,15 +153,15 @@ asserts: - generated plugin runtime files for Claude, Cursor, Codex, Grok, and OpenCode - `sync` repair after deleting representative generated files -Use `node scripts/smoke-examples.mjs --keep` when you need to inspect the temp -project; the script prints the retained path. +Use `node skills/dotagents-qa/scripts/qa-example.mjs all --keep` when you need +to inspect the temp project; the script prints the retained path. For paid Codex runtime proof of generated custom agents, run the runtime proof outside Docker only when the branch affects Codex custom agents or when reporting that Codex itself works: ```bash -node scripts/smoke-examples.mjs --codex-runtime --keep +node skills/dotagents-qa/scripts/qa-example.mjs codex-runtime --keep ``` That mode copies Codex auth/config into a temp `CODEX_HOME`, marks only the @@ -345,10 +348,10 @@ opencode --version ``` Codex subagents need real runtime proof before claiming Codex loaded them. Use -`node scripts/smoke-examples.mjs --codex-runtime --keep`; `codex debug -prompt-input` is not enough unless it visibly includes the generated agent name -or instructions. Project-scoped `.codex/agents/` load only when Codex trusts -the project. See [references/codex.md](references/codex.md). +`node skills/dotagents-qa/scripts/qa-example.mjs codex-runtime --keep`; +`codex debug prompt-input` is not enough unless it visibly includes the +generated agent name or instructions. Project-scoped `.codex/agents/` load only +when Codex trusts the project. See [references/codex.md](references/codex.md). 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; diff --git a/skills/dotagents-qa/SOURCES.md b/skills/dotagents-qa/SOURCES.md index e2c548b..b1011d4 100644 --- a/skills/dotagents-qa/SOURCES.md +++ b/skills/dotagents-qa/SOURCES.md @@ -20,7 +20,7 @@ ## Decisions -- Keep the runtime skill as a guided QA playbook rather than a broad QA matrix or fixed smoke harness. +- Keep the runtime skill as a guided QA playbook rather than a broad QA matrix or fixed all-in-one harness. - Provide a repo-local Dockerfile for the sandbox/toolchain only; keep fixture design and assertions manual. - Keep the Dockerfile's prepared pnpm version aligned with the root `packageManager`. - Mount the host checkout read-only and do dependency install/build inside Docker to avoid host system-file writes and host binary assumptions. diff --git a/skills/dotagents-qa/SPEC.md b/skills/dotagents-qa/SPEC.md index 782c9d5..de18814 100644 --- a/skills/dotagents-qa/SPEC.md +++ b/skills/dotagents-qa/SPEC.md @@ -19,7 +19,7 @@ Out of scope: - release publishing - network-backed source testing for ordinary install-location changes - replacing focused Vitest regression coverage for logic bugs -- treating a fixed smoke script as sufficient QA for behavior-specific changes +- treating a fixed all-in-one QA task as sufficient for behavior-specific changes ## Runtime Contract @@ -36,6 +36,6 @@ Out of scope: ## Maintenance - Keep `SKILL.md` focused on guided, change-specific Docker QA. -- Keep examples editable and illustrative; do not turn the skill into a fixed test harness. +- Keep examples editable and illustrative; do not turn the skill into a fixed all-in-one harness. - Keep the Dockerfile pnpm version aligned with the root `packageManager`. - Update examples when dotagents changes config fields, generated file locations, or supported agents. diff --git a/skills/dotagents-qa/references/claude.md b/skills/dotagents-qa/references/claude.md index 72561dc..6f0258b 100644 --- a/skills/dotagents-qa/references/claude.md +++ b/skills/dotagents-qa/references/claude.md @@ -1,10 +1,10 @@ # Claude Code QA -Use this reference when changes affect Claude Code skill symlinks, `.claude/settings.json`, `.claude/agents/*.md`, hooks, MCP config, or user-scope Claude paths. +Use this reference when changes affect Claude Code skill symlinks, `.claude/settings.json`, `.claude/agents/*.md`, hooks, MCP config, plugin marketplace output, plugin manifests, or user-scope Claude paths. ## File-Level Checks -The core smoke asserts: +The core agentic QA asserts: - `.claude/skills` is a symlink to `.agents/skills` - `.mcp.json` exists for Claude MCP config @@ -22,6 +22,30 @@ 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. + +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/codex.md b/skills/dotagents-qa/references/codex.md index 7e7c294..69106a2 100644 --- a/skills/dotagents-qa/references/codex.md +++ b/skills/dotagents-qa/references/codex.md @@ -1,10 +1,10 @@ # Codex QA -Use this reference when changes affect Codex config generation, `.codex/agents/*.toml`, Codex MCP config, Codex user scope, or claims that generated Codex subagents work in Codex. +Use this reference when changes affect Codex config generation, `.codex/agents/*.toml`, Codex MCP config, Codex plugin marketplace output, Codex user scope, or claims that generated Codex subagents work in Codex. ## File-Level Checks -The core smoke asserts: +The core agentic QA asserts: - `.codex/config.toml` exists for MCP config - `.codex/agents/code-reviewer.toml` exists @@ -14,15 +14,42 @@ The core smoke asserts: These checks prove dotagents wrote the expected files. They do not prove Codex loaded the agent. +## Plugin Checks + +Use these checks when a branch affects plugin output for Codex. Run them inside +the Docker QA container with an isolated `CODEX_HOME`, against a retained QA +project: + +```bash +node skills/dotagents-qa/scripts/qa-example.mjs plugin-codex --keep +``` + +This proves Codex can consume the generated repo-scoped marketplace, discover +the plugin as available, install it into the isolated plugin cache, and list it +as installed/enabled. + +Codex expects repo-scoped marketplaces at +`$REPO_ROOT/.agents/plugins/marketplace.json`. Pass the project root to +`codex plugin marketplace add`, not the `.agents/plugins` directory. Marketplace +entries must use structured local sources with `source = "local"` and +`path = "./.agents/plugins/"`; Codex resolves `source.path` relative to +the marketplace root/project root. + +If `codex plugin marketplace add "$project/.agents/plugins"` fails with +`marketplace root does not contain a supported manifest`, retry with +`"$project"`. If `codex plugin list --available --json` is empty, inspect +`.agents/plugins/marketplace.json` and confirm its `name` matches the selector +used for `codex plugin add @`. + ## Runtime Proof Run the paid runtime proof when the branch affects Codex custom agents or when reporting that Codex itself works: ```bash -node scripts/smoke-examples.mjs --codex-runtime --keep +node skills/dotagents-qa/scripts/qa-example.mjs codex-runtime --keep ``` -The script: +The task: - copies `examples/full/` to a temp project - runs the built local dotagents CLI @@ -48,4 +75,4 @@ Project-scoped `.codex/` layers load only when Codex trusts the project. A one-o If Codex reports `unknown agent_type`, check project trust and the canonical path (`pwd -P`) before assuming the generated TOML schema is wrong. -Do not leave copied Codex auth in retained temp directories. The smoke script scrubs its temp `codex-home`; if you run manual experiments, remove copied auth/config before reporting. +Do not leave copied Codex auth in retained temp directories. The QA task scrubs its temp `codex-home`; if you run manual experiments, remove copied auth/config before reporting. diff --git a/skills/dotagents-qa/references/core-smoke.md b/skills/dotagents-qa/references/core-agentic-qa.md similarity index 74% rename from skills/dotagents-qa/references/core-smoke.md rename to skills/dotagents-qa/references/core-agentic-qa.md index 17be8b9..49b7733 100644 --- a/skills/dotagents-qa/references/core-smoke.md +++ b/skills/dotagents-qa/references/core-agentic-qa.md @@ -1,4 +1,4 @@ -# Core Smoke +# Core Agentic QA Use this reference for ordinary dotagents install/sync QA before drilling into a specific runtime. @@ -7,7 +7,7 @@ Use this reference for ordinary dotagents install/sync QA before drilling into a Run: ```bash -pnpm smoke:examples +pnpm qa:example ``` This builds the local CLI, copies `examples/full/` to a temp project, and verifies: @@ -19,9 +19,13 @@ This builds the local CLI, copies `examples/full/` to a temp project, and verifi - 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/` +- 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 -Use `node scripts/smoke-examples.mjs --keep` to keep the temp project for inspection. The script prints the project path. +Use `node skills/dotagents-qa/scripts/qa-example.mjs all --keep` to keep the +temp project for inspection. The script prints the project path. ## What This Proves diff --git a/skills/dotagents-qa/references/cursor.md b/skills/dotagents-qa/references/cursor.md index 0503885..11ad2d5 100644 --- a/skills/dotagents-qa/references/cursor.md +++ b/skills/dotagents-qa/references/cursor.md @@ -4,7 +4,7 @@ Use this reference when changes affect Cursor skill sharing, `.cursor/mcp.json`, ## File-Level Checks -The core smoke asserts: +The core agentic QA asserts: - Cursor shares Claude-compatible skills through `.claude/skills` - `.cursor/mcp.json` exists for Cursor MCP config diff --git a/skills/dotagents-qa/references/opencode.md b/skills/dotagents-qa/references/opencode.md index 4465b07..d061308 100644 --- a/skills/dotagents-qa/references/opencode.md +++ b/skills/dotagents-qa/references/opencode.md @@ -4,7 +4,7 @@ Use this reference when changes affect OpenCode config generation, `opencode.jso ## File-Level Checks -The core smoke asserts: +The core agentic QA asserts: - `opencode.json` exists for OpenCode MCP config - `.opencode/agents/code-reviewer.md` exists diff --git a/scripts/smoke-examples.mjs b/skills/dotagents-qa/scripts/qa-example.mjs similarity index 71% rename from scripts/smoke-examples.mjs rename to skills/dotagents-qa/scripts/qa-example.mjs index 0039442..0be0f72 100644 --- a/scripts/smoke-examples.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node -// Owns local dotagents example QA. The default path proves file wiring with an -// isolated HOME/state; --codex-runtime additionally proves Codex can spawn the -// generated project agent and always scrubs copied Codex auth/config. +// Task-oriented agentic QA for the checked-in dotagents example. Keep this +// script with the dotagents-qa skill so runtime proof stays beside its docs. import { execFileSync } from "node:child_process"; import { @@ -20,20 +19,46 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(__dirname, ".."); +const repoRoot = resolve(__dirname, "../../.."); const cliPath = join(repoRoot, "packages", "dotagents", "dist", "cli", "index.js"); const exampleRoot = join(repoRoot, "examples", "full"); const sentinel = "DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c"; -const args = new Set(process.argv.slice(2)); -const keep = args.has("--keep"); -const runCodexRuntime = args.has("--codex-runtime"); + +const rawArgs = process.argv.slice(2); +const flags = new Set(rawArgs.filter((arg) => arg.startsWith("--"))); +const keep = flags.has("--keep"); +const task = taskName(rawArgs); + +const taskGroups = { + all: ["install-files", "sync-repair"], +}; + +const tasks = { + "install-files": runInstallFiles, + "sync-repair": runSyncRepair, + "plugin-claude": runClaudePluginProof, + "plugin-codex": runCodexPluginProof, + "plugin-opencode": runOpenCodePluginProof, + "codex-runtime": runCodexRuntimeProof, +}; + +if (flags.has("--help") || flags.has("-h")) { + printUsage(); + process.exit(0); +} if (!existsSync(cliPath)) { - console.error(`smoke-examples: missing built CLI at ${cliPath}`); + console.error(`qa-example: missing built CLI at ${cliPath}`); console.error("Run `pnpm build` first."); process.exit(1); } +if (!tasks[task] && !taskGroups[task]) { + console.error(`qa-example: unknown task "${task}"`); + printUsage(); + process.exit(1); +} + const tmp = mkdtempSync(join(tmpdir(), "dotagents-example-")); const projectDir = join(tmp, "project"); const homeDir = join(tmp, "home"); @@ -53,7 +78,134 @@ const fixtureEnv = { }; try { - console.log(`smoke-examples: project=${projectDir}`); + console.log(`qa-example: project=${projectDir}`); + for (const name of expandedTasks(task)) { + console.log(`qa-example: task=${name}`); + tasks[name](); + } + console.log("qa-example: ok"); +} catch (err) { + console.error("qa-example: failed"); + console.error(err instanceof Error ? err.message : String(err)); + console.error(`qa-example: project kept at ${projectDir}`); + process.exitCode = 1; + throw err; +} finally { + rmSync(codexHomeDir, { recursive: true, force: true }); + if (!keep && process.exitCode !== 1) { + rmSync(tmp, { recursive: true, force: true }); + } +} + +function taskName(args) { + if (flags.has("--codex-runtime")) {return "codex-runtime";} + return args.find((arg) => !arg.startsWith("--")) ?? "all"; +} + +function expandedTasks(name) { + return taskGroups[name] ?? [name]; +} + +function printUsage() { + console.error(`Usage: node skills/dotagents-qa/scripts/qa-example.mjs [--keep] + +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: + --codex-runtime Alias for the codex-runtime task +`); +} + +function runInstallFiles() { + installAndAssert(); +} + +function runSyncRepair() { + installAndAssert(); + rmSync(join(projectDir, ".mcp.json"), { force: true }); + 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", ".claude-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 }); + runCli(["sync"]); + assertFile(".mcp.json"); + assertSymlink(".claude/skills"); + assertFile(".claude/agents/code-reviewer.md"); + assertFile(".codex/agents/code-reviewer.toml"); + assertPluginOutputs(); +} + +function runClaudePluginProof() { + 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", + }); +} + +function runCodexPluginProof() { + 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"); + } +} + +function runOpenCodePluginProof() { + 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"); +} + +function runCodexRuntimeProof() { + installAndAssert(); + proveCodexRuntime(); +} + +function installAndAssert() { runCli(["install"]); const list = runCli(["list"]); writeFileSync(join(tmp, "list.out"), list); @@ -78,40 +230,6 @@ try { assertFile(".cursor/hooks.json"); assertSubagentOutputs(); assertPluginOutputs(); - - rmSync(join(projectDir, ".mcp.json"), { force: true }); - 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", ".claude-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 }); - runCli(["sync"]); - assertFile(".mcp.json"); - assertSymlink(".claude/skills"); - assertFile(".claude/agents/code-reviewer.md"); - assertFile(".codex/agents/code-reviewer.toml"); - assertPluginOutputs(); - - if (runCodexRuntime) { - proveCodexRuntime(); - } - - console.log("smoke-examples: ok"); -} catch (err) { - console.error("smoke-examples: failed"); - console.error(err instanceof Error ? err.message : String(err)); - console.error(`smoke-examples: project kept at ${projectDir}`); - process.exitCode = 1; - throw err; -} finally { - rmSync(codexHomeDir, { recursive: true, force: true }); - if (!keep && process.exitCode !== 1) { - rmSync(tmp, { recursive: true, force: true }); - } } function runCli(cliArgs) { @@ -123,6 +241,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); +} + function proveCodexRuntime() { if (!existsSync(join(projectDir, ".git"))) { execFileSync("git", ["init"], { @@ -136,7 +264,7 @@ function proveCodexRuntime() { const sourceAuth = join(sourceCodexHome, "auth.json"); const sourceConfig = join(sourceCodexHome, "config.toml"); if (!existsSync(sourceAuth)) { - throw new Error(`Codex runtime smoke requires auth.json at ${sourceAuth}`); + throw new Error(`Codex runtime QA requires auth.json at ${sourceAuth}`); } mkdirSync(codexHomeDir, { recursive: true }); @@ -261,13 +389,6 @@ 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()) { @@ -296,7 +417,6 @@ function assertIncludes(value, expected, message) { } } -/** Verifies Codex spawned and waited on a child agent that returned the sentinel. */ function assertCodexRuntimeEvents(output) { assertIncludes(output, '"tool":"spawn_agent"', "Codex runtime JSONL should include a spawn_agent event"); assertIncludes(output, '"tool":"wait"', "Codex runtime JSONL should include a wait event"); From e70c956cd386a0a0b84eb65f3c6a5c23629ad7d4 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 10:16:21 -0700 Subject: [PATCH 11/27] 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 | 15 +- 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, 290 insertions(+), 1 deletion(-) 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 6abaf7d..24dc7aa 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) +- 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 @@ -62,7 +64,11 @@ 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. +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: @@ -357,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 b1011d4..259c7ce 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 6f0258b..7d57c69 100644 --- a/skills/dotagents-qa/references/claude.md +++ b/skills/dotagents-qa/references/claude.md @@ -50,6 +50,11 @@ 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. + 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 69106a2..afc5490 100644 --- a/skills/dotagents-qa/references/codex.md +++ b/skills/dotagents-qa/references/codex.md @@ -67,6 +67,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 80e29ec2355b7daf4cb43906ca49963fc79c07fa Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 11:11:09 -0700 Subject: [PATCH 12/27] 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 7fd2c298ee7603528e5056f362a744cfab1a374e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 11:26:03 -0700 Subject: [PATCH 13/27] 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 24dc7aa..3f56a67 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 0be0f72..4f01950 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -367,6 +367,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 d10d87e..dd786ee 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 a7bb27cc67470609166a43abfe4dc7e93c7db870 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 11:51:11 -0700 Subject: [PATCH 14/27] 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 | 11 ++- specs/SPEC.md | 2 +- specs/plugins.md | 2 +- 14 files changed, 143 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 3960104..be4e83f 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -276,6 +276,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 f9e42d7..e7ad67b 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 4f01950..afac2b6 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,7 +135,9 @@ 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 }); @@ -190,14 +192,17 @@ function runCodexPluginProof() { function runOpenCodePluginProof() { 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"); + } } function runCodexRuntimeProof() { diff --git a/specs/SPEC.md b/specs/SPEC.md index dd786ee..90eb57f 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 8a9d900ffaccb1d997d0555eb87874ef6e777325 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 13 Jun 2026 11:53:18 -0700 Subject: [PATCH 15/27] 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 a76c7cf026758e5d977306e108334b379aac3801 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 13:35:42 -0700 Subject: [PATCH 16/27] fix(install): Simplify lockfile updates Write agents.lock once after canonical install work succeeds instead of preserving several partial fallback states. This keeps lockfile updates tied to installed bundles while avoiding extra state comparisons for runtime projection failures. Co-Authored-By: Codex --- .../src/cli/commands/install.test.ts | 92 ++++++------- .../dotagents/src/cli/commands/install.ts | 122 +++--------------- 2 files changed, 52 insertions(+), 162 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 3465dc3..f69e27b 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -8,6 +8,7 @@ import { runSync } from "./sync.js"; import { exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; +import type { Lockfile } from "../../lockfile/schema.js"; import { resolveScope } from "../../scope.js"; import { DOTAGENTS_SUBAGENT_MARKER } from "../../agents/definitions/helpers.js"; @@ -731,6 +732,31 @@ source = "path:external-source" expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools", ".codex-plugin", "plugin.json"))).toBe(true); }); + it("excludes in-place plugin lock entries from gitignore in frozen mode", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] +`, + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "local-tools": { source: "path:.agents/plugins/local-tools" }, + "managed-tools": { source: "path:external-source" }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope, frozen: true }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/plugins/local-tools/"); + expect(gitignore).toContain("/plugins/managed-tools/"); + }); + it("installs multiple skills", async () => { await writeFile( join(projectRoot, "agents.toml"), @@ -1035,7 +1061,7 @@ path = "code-reviewer.md" expect(syncResult.adopted).toEqual([]); }); - it("updates skill lock entries when installed subagent writes fail", async () => { + it("does not update the lockfile 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")); @@ -1050,7 +1076,7 @@ path = "code-reviewer.md" "hand-written subagent\n", "utf-8", ); - await writeLockfile(join(projectRoot, "agents.lock"), { + const originalLockfile: Lockfile = { version: 1, skills: {}, subagents: { @@ -1064,7 +1090,9 @@ path = "code-reviewer.md" source: "path:old-agents", }, }, - }); + plugins: {}, + }; + await writeLockfile(join(projectRoot, "agents.lock"), originalLockfile); await writeFile( join(projectRoot, "agents.toml"), @@ -1087,58 +1115,11 @@ path = "code-reviewer.md" ); const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); - expect(lockfile!.skills["pdf"]).toBeDefined(); - expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); - expect(lockfile!.subagents["old-reviewer"]).toBeUndefined(); + expect(lockfile).toEqual(originalLockfile); expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true); }); - it("preserves the original install error when fallback lockfile write fails", async () => { - const skillSourceDir = join(projectRoot, "local-skills", "pdf"); - await mkdir(skillSourceDir, { recursive: true }); - await writeFile(join(skillSourceDir, "SKILL.md"), SKILL_MD("pdf")); - - const sourceDir = join(projectRoot, "agents"); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); - - await mkdir(join(projectRoot, ".agents", "agents"), { recursive: true }); - await writeFile( - join(projectRoot, ".agents", "agents", "code-reviewer.md"), - "hand-written subagent\n", - "utf-8", - ); - - const lockPath = join(projectRoot, "agents.lock"); - await writeLockfile(lockPath, { version: 1, skills: {}, subagents: {} }); - await chmod(lockPath, 0o400); - - await writeFile( - join(projectRoot, "agents.toml"), - `version = 1 -[[skills]] -name = "pdf" -source = "path:local-skills/pdf" - -[[subagents]] -name = "code-reviewer" -source = "path:agents" -path = "code-reviewer.md" -`, - ); - - const scope = resolveScope("project", projectRoot); - - try { - await expect(runInstall({ scope })).rejects.toThrow( - /Subagent file exists and is not managed by dotagents/, - ); - } finally { - await chmod(lockPath, 0o600).catch(() => {}); - } - }); - - it("does not commit subagent lock entries when stale subagent pruning fails", async () => { + 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")); @@ -1177,12 +1158,11 @@ path = "code-reviewer.md" await chmod(stalePath, 0o600).catch(() => {}); } - const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); - expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); + expect(await loadLockfile(join(projectRoot, "agents.lock"))).toBeNull(); expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(true); }); - it("does not commit subagent lock entries when runtime subagent writes fail", async () => { + it("keeps lock entries when runtime 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")); @@ -1228,7 +1208,7 @@ path = "code-reviewer.md" const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); expect(lockfile!.skills["pdf"]).toBeDefined(); - expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); + expect(lockfile!.subagents["code-reviewer"]).toEqual({ source: "path:agents" }); expect(existsSync(join(projectRoot, ".agents", "agents", "code-reviewer.md"))).toBe(true); }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index e495c58..73c8ae7 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -12,7 +12,7 @@ import { } from "../../config/schema.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; -import { type Lockfile, type LockedSkill, type LockedSubagent } from "../../lockfile/schema.js"; +import { type Lockfile, type LockedSkill } from "../../lockfile/schema.js"; import { applyDefaultRepositorySource, resolveSkill, @@ -205,46 +205,6 @@ function validateFrozenPlugins( } } -function optionalSubagentLockValue( - entry: LockedSubagent, - key: "resolved_url" | "resolved_path" | "resolved_ref" | "resolved_commit", -): string | undefined { - switch (key) { - case "resolved_url": - return "resolved_url" in entry ? entry.resolved_url : undefined; - case "resolved_path": - return "resolved_path" in entry ? entry.resolved_path : undefined; - case "resolved_ref": - return "resolved_ref" in entry ? entry.resolved_ref : undefined; - case "resolved_commit": - return "resolved_commit" in entry ? entry.resolved_commit : undefined; - } -} - -function subagentLockEntriesEqual(a: LockedSubagent, b: LockedSubagent): boolean { - return a.source === b.source - && optionalSubagentLockValue(a, "resolved_url") === optionalSubagentLockValue(b, "resolved_url") - && optionalSubagentLockValue(a, "resolved_path") === optionalSubagentLockValue(b, "resolved_path") - && optionalSubagentLockValue(a, "resolved_ref") === optionalSubagentLockValue(b, "resolved_ref") - && optionalSubagentLockValue(a, "resolved_commit") === optionalSubagentLockValue(b, "resolved_commit"); -} - -function unchangedSubagentLockEntries( - current: Lockfile | null, - next: Lockfile, -): Lockfile["subagents"] { - if (!current) {return {};} - - const unchanged: Lockfile["subagents"] = {}; - for (const [name, entry] of Object.entries(current.subagents)) { - const nextEntry = next.subagents[name]; - if (!nextEntry) {continue;} - if (!subagentLockEntriesEqual(entry, nextEntry)) {continue;} - unchanged[name] = entry; - } - return unchanged; -} - function staleManagedPluginNames( current: Lockfile | null, next: Lockfile, @@ -474,28 +434,13 @@ export async function runInstall(opts: InstallOptions): Promise { } const shouldWriteLockfile = !frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0 || config.plugins.length > 0); - let installedSubagentsSynced = false; try { if (!frozen) { await writeInstalledSubagents(subagentsDir, installedSubagents); await pruneInstalledSubagents(subagentsDir, config.subagents); - installedSubagentsSynced = true; } } catch (err) { - if (shouldWriteLockfile) { - try { - await writeLockfile(lockPath, { - ...newLock, - subagents: installedSubagentsSynced - ? newLock.subagents - : unchangedSubagentLockEntries(lockfile, newLock), - plugins: newLock.plugins, - }); - } catch { - // Preserve the original install failure; this recovery write is best-effort. - } - } if (err instanceof InstalledSubagentWriteError) { throw new InstallError(err.message); } @@ -503,11 +448,7 @@ export async function runInstall(opts: InstallOptions): Promise { } if (shouldWriteLockfile) { - await writeLockfile(lockPath, { - ...newLock, - subagents: unchangedSubagentLockEntries(lockfile, newLock), - plugins: newLock.plugins, - }); + await writeLockfile(lockPath, newLock); } // 5. Gitignore (skip for user scope — ~/.agents/ is not a git repo) @@ -523,7 +464,9 @@ export async function runInstall(opts: InstallOptions): Promise { ? Object.keys(lockfile?.subagents ?? {}) : installedSubagents.map((subagent) => subagent.name); const managedPluginNames = frozen - ? Object.keys(lockfile?.plugins ?? {}) + ? Object.entries(lockfile?.plugins ?? {}) + .filter(([, locked]) => !isInPlacePluginSource(locked.source)) + .map(([name]) => name) : installedPlugins .filter((plugin) => !isInPlacePluginSource(plugin.source)) .map((plugin) => plugin.name); @@ -587,55 +530,22 @@ export async function runInstall(opts: InstallOptions): Promise { const subagentResolver = scope.scope === "user" ? userSubagentResolver() : projectSubagentResolver(scope.root); - let subagentResult: Awaited>; - try { - subagentResult = await writeSubagentConfigs( - config.agents, - installedSubagents, - subagentResolver, - ); - if (!frozen) { - await pruneSubagentConfigs(config.agents, installedSubagents, subagentResolver); - } - if (shouldWriteLockfile) { - await writeLockfile(lockPath, newLock); - } - } catch (err) { - if (shouldWriteLockfile) { - try { - await writeLockfile(lockPath, { - ...newLock, - subagents: unchangedSubagentLockEntries(lockfile, newLock), - plugins: newLock.plugins, - }); - } catch { - // Preserve the runtime config failure; this recovery write is best-effort. - } - } - throw err; + const subagentResult = await writeSubagentConfigs( + config.agents, + installedSubagents, + subagentResolver, + ); + if (!frozen) { + await pruneSubagentConfigs(config.agents, installedSubagents, subagentResolver); } // 10. Write plugin runtime projections let pluginWarnings: Awaited>["warnings"] = []; if (scope.scope === "project") { - try { - const pluginResult = await writePluginOutputs(config.agents, installedPlugins, scope.root); - pluginWarnings = pluginResult.warnings; - if (!frozen) { - await prunePluginOutputs(config.agents, installedPlugins, scope.root); - } - if (shouldWriteLockfile) { - await writeLockfile(lockPath, newLock); - } - } catch (err) { - if (shouldWriteLockfile) { - try { - await writeLockfile(lockPath, newLock); - } catch { - // Preserve the runtime projection failure; this recovery write is best-effort. - } - } - throw err; + const pluginResult = await writePluginOutputs(config.agents, installedPlugins, scope.root); + pluginWarnings = pluginResult.warnings; + if (!frozen) { + await prunePluginOutputs(config.agents, installedPlugins, scope.root); } } From 0ccad3dc65ed9528aa75a72c70d09c24557bdf91 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 14:18:57 -0700 Subject: [PATCH 17/27] ref(install): Split install command concerns Move skill, subagent, plugin, runtime, gitignore, and error handling out of the install command so the command only orchestrates install flow. Keep behavior unchanged while tightening the module boundaries around each concern. Add user-scope install coverage for path skills and agent skill symlink projection. Co-Authored-By: Codex --- .../dotagents/src/cli/commands/init.test.ts | 2 +- .../src/cli/commands/install-user.test.ts | 92 +++ .../dotagents/src/cli/commands/install.ts | 599 ++---------------- .../src/cli/commands/install/agent-runtime.ts | 107 ++++ .../src/cli/commands/install/errors.ts | 6 + .../src/cli/commands/install/gitignore.ts | 72 +++ .../src/cli/commands/install/plugins.ts | 132 ++++ .../src/cli/commands/install/skills.ts | 221 +++++++ .../src/cli/commands/install/subagents.ts | 106 ++++ 9 files changed, 800 insertions(+), 537 deletions(-) create mode 100644 packages/dotagents/src/cli/commands/install-user.test.ts create mode 100644 packages/dotagents/src/cli/commands/install/agent-runtime.ts create mode 100644 packages/dotagents/src/cli/commands/install/errors.ts create mode 100644 packages/dotagents/src/cli/commands/install/gitignore.ts create mode 100644 packages/dotagents/src/cli/commands/install/plugins.ts create mode 100644 packages/dotagents/src/cli/commands/install/skills.ts create mode 100644 packages/dotagents/src/cli/commands/install/subagents.ts diff --git a/packages/dotagents/src/cli/commands/init.test.ts b/packages/dotagents/src/cli/commands/init.test.ts index 51f3674..0f9ccb3 100644 --- a/packages/dotagents/src/cli/commands/init.test.ts +++ b/packages/dotagents/src/cli/commands/init.test.ts @@ -8,7 +8,7 @@ import { runInit, InitError, installPostMergeHook } from "./init.js"; import { loadConfig } from "../../config/loader.js"; vi.mock("./install.js", () => ({ - runInstall: vi.fn().mockResolvedValue({ installed: [], skipped: [], hookWarnings: [] }), + runInstall: vi.fn().mockResolvedValue({ installed: [], hookWarnings: [] }), })); describe("runInit", () => { diff --git a/packages/dotagents/src/cli/commands/install-user.test.ts b/packages/dotagents/src/cli/commands/install-user.test.ts new file mode 100644 index 0000000..aeb3a0f --- /dev/null +++ b/packages/dotagents/src/cli/commands/install-user.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { mkdtemp, mkdir, readFile, readlink, rm, writeFile, lstat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, relative } from "node:path"; +import { tmpdir } from "node:os"; + +const SKILL_MD = `--- +name: pdf +description: Test skill pdf +--- + +# pdf +`; + +describe("runInstall user scope", () => { + let tmpDir: string | undefined; + const previousHome = process.env["HOME"]; + const previousDotagentsHome = process.env["DOTAGENTS_HOME"]; + const previousStateDir = process.env["DOTAGENTS_STATE_DIR"]; + + afterEach(async () => { + if (previousHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = previousHome; + } + if (previousDotagentsHome === undefined) { + delete process.env["DOTAGENTS_HOME"]; + } else { + process.env["DOTAGENTS_HOME"] = previousDotagentsHome; + } + if (previousStateDir === undefined) { + delete process.env["DOTAGENTS_STATE_DIR"]; + } else { + process.env["DOTAGENTS_STATE_DIR"] = previousStateDir; + } + vi.resetModules(); + + if (tmpDir) { + await rm(tmpDir, { recursive: true }); + tmpDir = undefined; + } + }); + + it("installs a user-scope path skill and writes the user agent skill symlink", async () => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-user-install-")); + const homeDir = join(tmpDir, "home"); + const dotagentsHome = join(tmpDir, "agents"); + const stateDir = join(tmpDir, "state"); + const sourceDir = join(dotagentsHome, "skill-source", "pdf"); + + process.env["HOME"] = homeDir; + process.env["DOTAGENTS_HOME"] = dotagentsHome; + process.env["DOTAGENTS_STATE_DIR"] = stateDir; + vi.resetModules(); + + const [{ runInstall }, { resolveScope }, { loadLockfile }] = await Promise.all([ + import("./install.js"), + import("../../scope.js"), + import("../../lockfile/loader.js"), + ]); + + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD); + const scope = resolveScope("user"); + await mkdir(scope.root, { recursive: true }); + await writeFile( + scope.configPath, + `version = 1 +agents = ["claude"] + +[[skills]] +name = "pdf" +source = "path:skill-source/pdf" +`, + ); + + const result = await runInstall({ scope }); + + expect(result.installed).toEqual(["pdf"]); + expect(existsSync(join(scope.skillsDir, "pdf", "SKILL.md"))).toBe(true); + expect(await readFile(join(scope.skillsDir, "pdf", "SKILL.md"), "utf-8")).toBe(SKILL_MD); + + const skillsLink = join(homeDir, ".claude", "skills"); + const stat = await lstat(skillsLink); + expect(stat.isSymbolicLink()).toBe(true); + expect(await readlink(skillsLink)).toBe(relative(join(homeDir, ".claude"), scope.skillsDir)); + + const lockfile = await loadLockfile(scope.lockPath); + expect(lockfile!.skills["pdf"]).toEqual({ source: "path:skill-source/pdf" }); + }); +}); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 73c8ae7..e101ec5 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -1,69 +1,32 @@ -import { join, resolve } from "node:path"; -import { mkdir, rm } from "node:fs/promises"; +import { resolve } from "node:path"; import { parseArgs } from "node:util"; import chalk from "chalk"; +import { GitError, TrustError } from "@sentry/dotagents-lib"; import { loadConfig } from "../../config/loader.js"; -import { - isWildcardDep, - type RepositorySource, - type SkillDependency, - type PluginConfig, - type SubagentConfig, -} from "../../config/schema.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; -import { type Lockfile, type LockedSkill } from "../../lockfile/schema.js"; -import { - applyDefaultRepositorySource, - resolveSkill, - resolveWildcardSkills, - sourcesMatch, - type ResolvedSkill, - validateTrustedSource, - TrustError, - GitError, - copyDir, -} from "@sentry/dotagents-lib"; -import { isInPlaceSkill, managedSkillPath } from "../../utils/fs.js"; -import { getCacheStateDir, HOST_SCAN_DIRS } from "../cache.js"; -import { formatGitError, formatTrustError } from "../errors.js"; -import { writeAgentsGitignore, checkRootGitignoreEntries } from "../../gitignore/writer.js"; -import { ensureSkillsSymlink } from "../../symlinks/manager.js"; -import { getAgent } from "../../agents/registry.js"; -import { writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js"; -import { writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js"; -import { pruneSubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; -import { - InstalledSubagentWriteError, - lockEntryForSubagent, - loadInstalledSubagents, - pruneInstalledSubagents, - resolveSubagent, - writeInstalledSubagents, -} from "../../agents/subagent-store.js"; -import { - installPluginBundle, - isInPlacePluginSource, - isProjectPluginSource, - isSameProjectPluginConfig, - loadInstalledPlugins, - lockEntryForPlugin, - pruneInstalledPlugins, - resolvePlugin, -} from "../../agents/plugin-store.js"; -import { prunePluginOutputs, writePluginOutputs } from "../../agents/plugin-writer.js"; -import { userMcpResolver } from "../../agents/paths.js"; -import type { SubagentDeclaration } from "../../agents/types.js"; +import type { Lockfile } from "../../lockfile/schema.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; - -export class InstallError extends Error { - constructor(message: string) { - super(message); - this.name = "InstallError"; - } -} - +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"; + +export { InstallError }; + +// Owns install orchestration only: concern modules resolve/copy/prune +// canonical artifacts, this command assembles the lockfile once, then writes +// runtime projections and user-facing CLI output. export interface InstallOptions { scope: ScopeRoot; frozen?: boolean; @@ -71,7 +34,6 @@ export interface InstallOptions { export interface InstallResult { installed: string[]; - skipped: string[]; pruned: string[]; prunedPlugins: string[]; hookWarnings: { agent: string; message: string }[]; @@ -79,492 +41,57 @@ export interface InstallResult { pluginWarnings: { agent: string; name: string; message: string }[]; } -/** Expanded skill ready for install — either from an explicit entry or a wildcard */ -interface ExpandedSkill { - name: string; - dep: SkillDependency; - resolved?: ResolvedSkill; -} - -/** - * Expand config skills into a flat list, resolving wildcards. - * Explicit entries always win over wildcard-discovered skills. - */ -async function expandSkills( - config: { - skills: SkillDependency[]; - trust?: Parameters[1]; - defaultRepositorySource: RepositorySource; - }, - lockfile: Lockfile | null, - opts: { frozen?: boolean; projectRoot: string; minimumReleaseAge?: number; minimumReleaseAgeExclude?: string[] }, -): Promise { - const regularDeps = config.skills.filter((d) => !isWildcardDep(d)); - const wildcardDeps = config.skills.filter(isWildcardDep); - const explicitNames = new Set(regularDeps.map((d) => d.name)); - - const expanded: ExpandedSkill[] = []; - - // Add regular deps - for (const dep of regularDeps) { - expanded.push({ name: dep.name, dep }); - } - - // Expand wildcards - const wildcardNames = new Map(); // name → source (for conflict detection) - for (const wDep of wildcardDeps) { - const wildcardSourceForTrust = applyDefaultRepositorySource( - wDep.source, - config.defaultRepositorySource, - ); - validateTrustedSource(wildcardSourceForTrust, config.trust); - const excludeSet = new Set(wDep.exclude); - - if (opts.frozen) { - // In frozen mode, expand from lockfile — no network needed - if (!lockfile) {continue;} - for (const [name, locked] of Object.entries(lockfile.skills)) { - if (!sourcesMatch(locked.source, wDep.source)) {continue;} - if (explicitNames.has(name)) {continue;} - if (excludeSet.has(name)) {continue;} - - expanded.push({ name, dep: wDep }); - } - } else { - let named; - try { - named = await resolveWildcardSkills(wDep, { - stateDir: getCacheStateDir(), - scanDirs: HOST_SCAN_DIRS, - projectRoot: opts.projectRoot, - defaultRepositorySource: config.defaultRepositorySource, - minimumReleaseAge: opts.minimumReleaseAge, - minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude, - }); - } catch (err) { - // Let GitError and TrustError bubble — they carry structured details - // (auth-required SSH hint, allowed-source list) the outer handler renders. - if (err instanceof GitError || err instanceof TrustError) {throw err;} - const msg = err instanceof Error ? err.message : String(err); - throw new InstallError(`Failed to resolve wildcard source "${wDep.source}": ${msg}`); - } - for (const { name, resolved } of named) { - if (explicitNames.has(name)) {continue;} // explicit wins - - // Check for conflicts between different wildcards - const existingSource = wildcardNames.get(name); - if (existingSource && !sourcesMatch(existingSource, wDep.source)) { - throw new InstallError( - `Skill "${name}" found in both wildcard sources: "${existingSource}" and "${wDep.source}". ` + - `Use an explicit [[skills]] entry or add it to one source's exclude list.`, - ); - } - wildcardNames.set(name, wDep.source); - - expanded.push({ name, dep: wDep, resolved }); - } - } - } - - return expanded; -} - -function validateFrozenSubagents( - subagents: SubagentConfig[], - lockfile: Lockfile | null, -): void { - if (subagents.length === 0) {return;} - if (!lockfile) { - throw new InstallError("--frozen requires agents.lock to exist."); - } - - for (const subagent of subagents) { - if (!lockfile.subagents[subagent.name]) { - throw new InstallError( - `--frozen: subagent "${subagent.name}" is in agents.toml but missing from agents.lock.`, - ); - } - } -} - -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, - next: Lockfile, -): string[] { - if (!current) {return [];} - return Object.entries(current.plugins) - .filter(([name, locked]) => !next.plugins[name] && !isInPlacePluginSource(locked.source)) - .map(([name]) => name); -} - export async function runInstall(opts: InstallOptions): Promise { const { scope, frozen } = opts; - const { configPath, lockPath, agentsDir, skillsDir, pluginsDir } = scope; - const subagentsDir = join(agentsDir, "agents"); - - // 1. Read config - const config = await loadConfig(configPath); + 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(lockPath); - const newLock: Lockfile = { version: 1, skills: {}, subagents: {}, plugins: {} }; - const installed: string[] = []; - const skipped: string[] = []; - const pruned: string[] = []; - const prunedPlugins: string[] = []; - - // Ensure skills/ exists (needed for symlinks even without skills) - await mkdir(skillsDir, { recursive: true }); - - // 2. Resolve and install skills (if any declared) - if (config.skills.length > 0) { - if (frozen && !lockfile) { - throw new InstallError("--frozen requires agents.lock to exist."); - } - - const minimumReleaseAge = config.minimum_release_age; - const minimumReleaseAgeExclude = config.minimum_release_age_exclude; - const expanded = await expandSkills( - { - skills: config.skills, - trust: config.trust, - defaultRepositorySource: config.defaultRepositorySource, - }, - lockfile, - { frozen, projectRoot: scope.root, minimumReleaseAge, minimumReleaseAgeExclude }, - ); - - if (frozen) { - for (const { name } of expanded) { - if (!lockfile!.skills[name]) { - throw new InstallError( - `--frozen: skill "${name}" is in agents.toml but missing from agents.lock.`, - ); - } - } - } - - for (const item of expanded) { - const { name, dep } = item; - - // Validate trust before any network work - const sourceForTrust = applyDefaultRepositorySource( - dep.source, - config.defaultRepositorySource, - ); - validateTrustedSource(sourceForTrust, config.trust); - - const resolveOpts = { - stateDir: getCacheStateDir(), - scanDirs: HOST_SCAN_DIRS, - projectRoot: scope.root, - defaultRepositorySource: config.defaultRepositorySource, - minimumReleaseAge, - minimumReleaseAgeExclude, - }; - - let resolved: ResolvedSkill; - if (item.resolved) { - resolved = item.resolved; - } else { - try { - resolved = await resolveSkill(name, dep, resolveOpts); - } 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 skill "${name}": ${msg}`); - } - } - - const destDir = join(skillsDir, name); - - // Skip copy when source resolves to the install destination (in-place skills) - if (resolve(resolved.skillDir) !== resolve(destDir)) { - await copyDir(resolved.skillDir, destDir); - } - - let lockEntry: LockedSkill; - if (resolved.type === "git") { - lockEntry = { - source: dep.source, - resolved_url: resolved.resolvedUrl, - resolved_path: resolved.resolvedPath, - ...(resolved.resolvedRef ? { resolved_ref: resolved.resolvedRef } : {}), - resolved_commit: resolved.commit, - }; - } else if (resolved.type === "well-known") { - lockEntry = { - source: dep.source, - resolved_url: resolved.resolvedUrl, - }; - } else { - lockEntry = { source: dep.source }; - } - - newLock.skills[name] = lockEntry; - installed.push(name); - } - - // Prune stale managed skills (skip in frozen mode to avoid disk/lockfile inconsistency) - if (!frozen && lockfile) { - for (const [name, locked] of Object.entries(lockfile.skills)) { - if (newLock.skills[name]) {continue;} // still tracked - if (isInPlaceSkill(locked.source)) {continue;} - const skillPath = managedSkillPath(skillsDir, name); - if (!skillPath) {continue;} - await rm(skillPath, { recursive: true, force: true }); - pruned.push(name); - } - } - } else if (!frozen && lockfile) { - for (const [name, locked] of Object.entries(lockfile.skills)) { - if (isInPlaceSkill(locked.source)) {continue;} - const skillPath = managedSkillPath(skillsDir, name); - if (!skillPath) {continue;} - await rm(skillPath, { recursive: true, force: true }); - pruned.push(name); - } - } - - // 3. Resolve and install subagent markdown files - const installedSubagents: SubagentDeclaration[] = []; - if (frozen) { - validateFrozenSubagents(config.subagents, lockfile); - if (config.subagents.length > 0) { - const loaded = await loadInstalledSubagents(subagentsDir, config.subagents); - if (loaded.issues.length > 0) { - throw new InstallError(loaded.issues.map((issue) => issue.issue).join("\n")); - } - installedSubagents.push(...loaded.subagents); - } - } else if (config.subagents.length > 0) { - for (const subagentConfig of config.subagents) { - let resolved: Awaited>; - try { - resolved = await resolveSubagent(subagentConfig, { - 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 subagent "${subagentConfig.name}": ${msg}`); - } - installedSubagents.push(resolved.subagent); - newLock.subagents[resolved.subagent.name] = lockEntryForSubagent(resolved); - } - } - - // 4. Resolve and install plugin bundles - let installedPlugins: Awaited>["plugins"] = []; - if (frozen) { - validateFrozenPlugins(config.plugins, lockfile); - if (scope.scope === "project") { - assertNoSameProjectPluginConfigs(config.plugins, pluginsDir, scope.root); - } - if (config.plugins.length > 0) { - const loaded = await loadInstalledPlugins(pluginsDir, config.plugins); - if (loaded.issues.length > 0) { - throw new InstallError(loaded.issues.join("\n")); - } - installedPlugins = loaded.plugins; - } - } else if (config.plugins.length > 0) { - await mkdir(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, 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.", - ); - } - installedPlugins.push(await installPluginBundle(pluginsDir, resolved)); - newLock.plugins[resolved.plugin.name] = lockEntryForPlugin(resolved); - } - prunedPlugins.push(...await pruneInstalledPlugins( - pluginsDir, - staleManagedPluginNames(lockfile, newLock), - )); - } else if (!frozen && lockfile) { - prunedPlugins.push(...await pruneInstalledPlugins( - pluginsDir, - staleManagedPluginNames(lockfile, newLock), - )); - } - - const shouldWriteLockfile = !frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0 || config.plugins.length > 0); - - try { - if (!frozen) { - await writeInstalledSubagents(subagentsDir, installedSubagents); - await pruneInstalledSubagents(subagentsDir, config.subagents); - } - } catch (err) { - if (err instanceof InstalledSubagentWriteError) { - throw new InstallError(err.message); - } - throw err; - } - - if (shouldWriteLockfile) { - await writeLockfile(lockPath, newLock); - } - - // 5. Gitignore (skip for user scope — ~/.agents/ is not a git repo) - if (scope.scope === "project") { - // For wildcard entries all expanded skills are managed (wildcards can't be in-place) - const managedNames = installed.filter((name) => { - const dep = config.skills.find((s) => s.name === name); - // Wildcard-sourced skills are always managed - if (!dep || isWildcardDep(dep)) {return true;} - return !isInPlaceSkill(dep.source); - }); - const managedSubagentNames = frozen - ? Object.keys(lockfile?.subagents ?? {}) - : installedSubagents.map((subagent) => subagent.name); - const managedPluginNames = frozen - ? Object.entries(lockfile?.plugins ?? {}) - .filter(([, locked]) => !isInPlacePluginSource(locked.source)) - .map(([name]) => name) - : installedPlugins - .filter((plugin) => !isInPlacePluginSource(plugin.source)) - .map((plugin) => plugin.name); - await writeAgentsGitignore( - agentsDir, - managedNames, - managedSubagentNames, - managedPluginNames, - ); - - // Health check: warn if agents.lock and .agents/.gitignore are not in root .gitignore - const missing = await checkRootGitignoreEntries(scope.root); - if (missing.length > 0) { - console.log(chalk.yellow(`Warning: ${missing.join(", ")} should be in .gitignore. Run 'npx @sentry/dotagents doctor --fix' to fix.`)); - } - } - - // 6. Symlinks — create per-agent symlinks so each agent discovers skills - if (scope.scope === "user") { - const seen = new Set(); - for (const agentId of config.agents) { - const agent = getAgent(agentId); - if (!agent?.userSkillsParentDirs) {continue;} - for (const dir of agent.userSkillsParentDirs) { - if (seen.has(dir)) {continue;} - seen.add(dir); - await ensureSkillsSymlink(agentsDir, dir); - } - } - } else { - const targets = config.symlinks?.targets ?? []; - for (const target of targets) { - await ensureSkillsSymlink(agentsDir, join(scope.root, target)); - } - - const seenParentDirs = new Set(targets); - for (const agentId of config.agents) { - const agent = getAgent(agentId); - if (!agent?.skillsParentDir) {continue;} - if (seenParentDirs.has(agent.skillsParentDir)) {continue;} - seenParentDirs.add(agent.skillsParentDir); - await ensureSkillsSymlink(agentsDir, join(scope.root, agent.skillsParentDir)); - } - } - - // 7. Write MCP config files - const mcpResolver = scope.scope === "user" ? userMcpResolver() : projectMcpResolver(scope.root); - await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), mcpResolver); - // 8. Write hook config files (skip for user scope) - let hookWarnings: { agent: string; message: string }[] = []; - if (scope.scope === "project") { - hookWarnings = await writeHookConfigs( - config.agents, - toHookDeclarations(config.hooks), - projectHookResolver(scope.root), - ); - } - - // 9. Write custom subagent files - const subagentResolver = scope.scope === "user" - ? userSubagentResolver() - : projectSubagentResolver(scope.root); - const subagentResult = await writeSubagentConfigs( - config.agents, - installedSubagents, - subagentResolver, + 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.plugins.length > 0 ); - if (!frozen) { - await pruneSubagentConfigs(config.agents, installedSubagents, subagentResolver); - } - - // 10. Write plugin runtime projections - let pluginWarnings: Awaited>["warnings"] = []; - if (scope.scope === "project") { - const pluginResult = await writePluginOutputs(config.agents, installedPlugins, scope.root); - pluginWarnings = pluginResult.warnings; - if (!frozen) { - await prunePluginOutputs(config.agents, installedPlugins, scope.root); - } - } - - return { installed, skipped, pruned, prunedPlugins, hookWarnings, subagentWarnings: subagentResult.warnings, pluginWarnings }; -} - -function assertNoSameProjectPluginConfigs( - plugins: PluginConfig[], - pluginsDir: string, - projectRoot: string, -): void { - for (const plugin of plugins) { - if (isSameProjectPluginConfig(plugin, pluginsDir, projectRoot)) { - throw new InstallError( - `Plugin "${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.", - ); - } - } + if (writeLock) { + await writeLockfile(scope.lockPath, newLock); + } + + 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, + pruned: skills.pruned, + prunedPlugins: plugins.pruned, + hookWarnings, + subagentWarnings, + pluginWarnings, + }; } export default async function install(args: string[], flags?: { user?: boolean }): Promise { diff --git a/packages/dotagents/src/cli/commands/install/agent-runtime.ts b/packages/dotagents/src/cli/commands/install/agent-runtime.ts new file mode 100644 index 0000000..58a2abd --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/agent-runtime.ts @@ -0,0 +1,107 @@ +import { join } from "node:path"; +import type { AgentsConfig } from "../../../config/schema.js"; +import type { ScopeRoot } from "../../../scope.js"; +import { getAgent } from "../../../agents/registry.js"; +import { ensureSkillsSymlink } from "../../../symlinks/manager.js"; +import { projectMcpResolver, toMcpDeclarations, writeMcpConfigs } from "../../../agents/mcp-writer.js"; +import { projectHookResolver, toHookDeclarations, writeHookConfigs } from "../../../agents/hook-writer.js"; +import { userMcpResolver } from "../../../agents/paths.js"; +import { + pruneSubagentConfigs, + projectSubagentResolver, + userSubagentResolver, + writeSubagentConfigs, +} from "../../../agents/subagent-writer.js"; +import { prunePluginOutputs, writePluginOutputs } from "../../../agents/plugin-writer.js"; +import type { PluginDeclaration } from "../../../agents/plugin-store.js"; +import type { SubagentDeclaration } from "../../../agents/types.js"; + +/** Writes agent skill symlinks after canonical install artifacts are ready. */ +export async function writeSkillSymlinks( + config: AgentsConfig, + scope: ScopeRoot, +): Promise { + if (scope.scope === "user") { + const seen = new Set(); + for (const agentId of config.agents) { + const agent = getAgent(agentId); + if (!agent?.userSkillsParentDirs) {continue;} + for (const dir of agent.userSkillsParentDirs) { + if (seen.has(dir)) {continue;} + seen.add(dir); + await ensureSkillsSymlink(scope.agentsDir, dir); + } + } + return; + } + + const targets = config.symlinks?.targets ?? []; + for (const target of targets) { + await ensureSkillsSymlink(scope.agentsDir, join(scope.root, target)); + } + + const seenParentDirs = new Set(targets); + for (const agentId of config.agents) { + const agent = getAgent(agentId); + if (!agent?.skillsParentDir) {continue;} + if (seenParentDirs.has(agent.skillsParentDir)) {continue;} + seenParentDirs.add(agent.skillsParentDir); + await ensureSkillsSymlink(scope.agentsDir, join(scope.root, agent.skillsParentDir)); + } +} + +/** Writes MCP runtime config for configured agents. */ +export async function writeMcpRuntime( + config: AgentsConfig, + scope: ScopeRoot, +): Promise { + const resolver = scope.scope === "user" + ? userMcpResolver() + : projectMcpResolver(scope.root); + await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), resolver); +} + +/** Writes project-scoped hook runtime config for configured agents. */ +export async function writeHookRuntime( + config: AgentsConfig, + scope: ScopeRoot, +): Promise<{ agent: string; message: string }[]> { + if (scope.scope !== "project") {return [];} + return writeHookConfigs( + config.agents, + toHookDeclarations(config.hooks), + projectHookResolver(scope.root), + ); +} + +/** Writes agent-specific subagent runtime projections. */ +export async function writeSubagentRuntime( + config: AgentsConfig, + scope: ScopeRoot, + subagents: SubagentDeclaration[], + frozen?: boolean, +): Promise<{ agent: string; name: string; message: string }[]> { + const resolver = scope.scope === "user" + ? userSubagentResolver() + : projectSubagentResolver(scope.root); + const result = await writeSubagentConfigs(config.agents, subagents, resolver); + if (!frozen) { + await pruneSubagentConfigs(config.agents, subagents, resolver); + } + 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/errors.ts b/packages/dotagents/src/cli/commands/install/errors.ts new file mode 100644 index 0000000..fb54901 --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/errors.ts @@ -0,0 +1,6 @@ +export class InstallError extends Error { + constructor(message: string) { + super(message); + this.name = "InstallError"; + } +} diff --git a/packages/dotagents/src/cli/commands/install/gitignore.ts b/packages/dotagents/src/cli/commands/install/gitignore.ts new file mode 100644 index 0000000..754b55c --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/gitignore.ts @@ -0,0 +1,72 @@ +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 { 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 "../../../agents/types.js"; + +export interface InstallGitignoreArtifacts { + installedSkillNames: string[]; + subagents: SubagentDeclaration[]; + plugins: PluginDeclaration[]; +} + +function managedSkillNames( + config: AgentsConfig, + installed: string[], +): string[] { + return installed.filter((name) => { + const dep = config.skills.find((s) => s.name === name); + if (!dep || isWildcardDep(dep)) {return true;} + return !isInPlaceSkill(dep.source); + }); +} + +function managedSubagentNames( + lockfile: Lockfile | null, + subagents: SubagentDeclaration[], + frozen?: boolean, +): string[] { + return frozen + ? Object.keys(lockfile?.subagents ?? {}) + : 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, + lockfile: Lockfile | null, + scope: ScopeRoot, + artifacts: InstallGitignoreArtifacts, + frozen?: boolean, +): Promise { + if (scope.scope !== "project") {return;} + + await writeAgentsGitignore( + scope.agentsDir, + managedSkillNames(config, artifacts.installedSkillNames), + managedSubagentNames(lockfile, artifacts.subagents, frozen), + managedPluginNames(lockfile, artifacts.plugins, frozen), + ); + + const missing = await checkRootGitignoreEntries(scope.root); + if (missing.length > 0) { + console.log(chalk.yellow(`Warning: ${missing.join(", ")} should be in .gitignore. Run 'npx @sentry/dotagents doctor --fix' to fix.`)); + } +} 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..ed10d5e --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/plugins.ts @@ -0,0 +1,132 @@ +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, + isSameProjectPluginConfig, + 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 assertNoSameProjectPluginConfigs( + plugins: PluginConfig[], + pluginsDir: string, + projectRoot: string, +): void { + for (const plugin of plugins) { + if (isSameProjectPluginConfig(plugin, pluginsDir, projectRoot)) { + throw new InstallError( + `Plugin "${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.", + ); + } + } +} + +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 (scope.scope === "project") { + assertNoSameProjectPluginConfigs(config.plugins, scope.pluginsDir, scope.root); + } + 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/install/skills.ts b/packages/dotagents/src/cli/commands/install/skills.ts new file mode 100644 index 0000000..cca1e75 --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/skills.ts @@ -0,0 +1,221 @@ +import { join, 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"; +import type { ScopeRoot } from "../../../scope.js"; +import { isInPlaceSkill, managedSkillPath } from "../../../utils/fs.js"; +import { + applyDefaultRepositorySource, + copyDir, + GitError, + resolveSkill, + resolveWildcardSkills, + sourcesMatch, + TrustError, + type ResolvedSkill, + validateTrustedSource, +} from "@sentry/dotagents-lib"; +import { getCacheStateDir, HOST_SCAN_DIRS } from "../../cache.js"; +import { InstallError } from "./errors.js"; + +/** Expanded skill ready for install: either an explicit entry or wildcard result. */ +interface ExpandedSkill { + name: string; + dep: SkillDependency; + resolved?: ResolvedSkill; +} + +export interface InstallSkillsResult { + installed: string[]; + pruned: string[]; + lockEntries: Lockfile["skills"]; +} + +/** + * Expand config skills into a flat list, resolving wildcards. + * Explicit entries always win over wildcard-discovered skills. + */ +async function expandSkills( + config: { + skills: SkillDependency[]; + trust?: Parameters[1]; + defaultRepositorySource: RepositorySource; + }, + lockfile: Lockfile | null, + opts: { frozen?: boolean; projectRoot: string; minimumReleaseAge?: number; minimumReleaseAgeExclude?: string[] }, +): Promise { + const regularDeps = config.skills.filter((d) => !isWildcardDep(d)); + const wildcardDeps = config.skills.filter(isWildcardDep); + const explicitNames = new Set(regularDeps.map((d) => d.name)); + + const expanded: ExpandedSkill[] = []; + for (const dep of regularDeps) { + expanded.push({ name: dep.name, dep }); + } + + const wildcardNames = new Map(); + for (const wDep of wildcardDeps) { + const wildcardSourceForTrust = applyDefaultRepositorySource( + wDep.source, + config.defaultRepositorySource, + ); + validateTrustedSource(wildcardSourceForTrust, config.trust); + const excludeSet = new Set(wDep.exclude); + + if (opts.frozen) { + if (!lockfile) {continue;} + for (const [name, locked] of Object.entries(lockfile.skills)) { + if (!sourcesMatch(locked.source, wDep.source)) {continue;} + if (explicitNames.has(name)) {continue;} + if (excludeSet.has(name)) {continue;} + expanded.push({ name, dep: wDep }); + } + continue; + } + + let named; + try { + named = await resolveWildcardSkills(wDep, { + stateDir: getCacheStateDir(), + scanDirs: HOST_SCAN_DIRS, + projectRoot: opts.projectRoot, + defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge: opts.minimumReleaseAge, + minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude, + }); + } 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 wildcard source "${wDep.source}": ${msg}`); + } + + for (const { name, resolved } of named) { + if (explicitNames.has(name)) {continue;} + const existingSource = wildcardNames.get(name); + if (existingSource && !sourcesMatch(existingSource, wDep.source)) { + throw new InstallError( + `Skill "${name}" found in both wildcard sources: "${existingSource}" and "${wDep.source}". ` + + "Use an explicit [[skills]] entry or add it to one source's exclude list.", + ); + } + wildcardNames.set(name, wDep.source); + expanded.push({ name, dep: wDep, resolved }); + } + } + + return expanded; +} + +function lockEntryForSkill(dep: SkillDependency, resolved: ResolvedSkill): LockedSkill { + if (resolved.type === "git") { + return { + source: dep.source, + resolved_url: resolved.resolvedUrl, + resolved_path: resolved.resolvedPath, + ...(resolved.resolvedRef ? { resolved_ref: resolved.resolvedRef } : {}), + resolved_commit: resolved.commit, + }; + } + if (resolved.type === "well-known") { + return { + source: dep.source, + resolved_url: resolved.resolvedUrl, + }; + } + return { source: dep.source }; +} + +/** Resolves, copies, and prunes canonical skill directories for install. */ +export async function installSkills( + config: AgentsConfig, + lockfile: Lockfile | null, + scope: ScopeRoot, + frozen?: boolean, +): Promise { + const lockEntries: Lockfile["skills"] = {}; + const installed: string[] = []; + const pruned: string[] = []; + + await mkdir(scope.skillsDir, { recursive: true }); + + if (config.skills.length > 0) { + if (frozen && !lockfile) { + throw new InstallError("--frozen requires agents.lock to exist."); + } + + const expanded = await expandSkills( + { + skills: config.skills, + trust: config.trust, + defaultRepositorySource: config.defaultRepositorySource, + }, + lockfile, + { + frozen, + projectRoot: scope.root, + minimumReleaseAge: config.minimum_release_age, + minimumReleaseAgeExclude: config.minimum_release_age_exclude, + }, + ); + + if (frozen) { + for (const { name } of expanded) { + if (!lockfile!.skills[name]) { + throw new InstallError( + `--frozen: skill "${name}" is in agents.toml but missing from agents.lock.`, + ); + } + } + } + + for (const item of expanded) { + const { name, dep } = item; + const sourceForTrust = applyDefaultRepositorySource( + dep.source, + config.defaultRepositorySource, + ); + validateTrustedSource(sourceForTrust, config.trust); + + let resolved: ResolvedSkill; + if (item.resolved) { + resolved = item.resolved; + } else { + try { + resolved = await resolveSkill(name, dep, { + stateDir: getCacheStateDir(), + scanDirs: HOST_SCAN_DIRS, + projectRoot: scope.root, + defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge: config.minimum_release_age, + minimumReleaseAgeExclude: config.minimum_release_age_exclude, + }); + } 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 skill "${name}": ${msg}`); + } + } + + const destDir = join(scope.skillsDir, name); + if (resolve(resolved.skillDir) !== resolve(destDir)) { + await copyDir(resolved.skillDir, destDir); + } + + lockEntries[name] = lockEntryForSkill(dep, resolved); + installed.push(name); + } + } + + if (!frozen && lockfile) { + for (const [name, locked] of Object.entries(lockfile.skills)) { + if (lockEntries[name]) {continue;} + if (isInPlaceSkill(locked.source)) {continue;} + const skillPath = managedSkillPath(scope.skillsDir, name); + if (!skillPath) {continue;} + await rm(skillPath, { recursive: true, force: true }); + pruned.push(name); + } + } + + return { installed, pruned, lockEntries }; +} diff --git a/packages/dotagents/src/cli/commands/install/subagents.ts b/packages/dotagents/src/cli/commands/install/subagents.ts new file mode 100644 index 0000000..69f99cd --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/subagents.ts @@ -0,0 +1,106 @@ +import { join } from "node:path"; +import type { AgentsConfig, SubagentConfig } from "../../../config/schema.js"; +import type { Lockfile } from "../../../lockfile/schema.js"; +import type { ScopeRoot } from "../../../scope.js"; +import type { SubagentDeclaration } from "../../../agents/types.js"; +import { + InstalledSubagentWriteError, + lockEntryForSubagent, + loadInstalledSubagents, + pruneInstalledSubagents, + resolveSubagent, + writeInstalledSubagents, +} from "../../../agents/subagent-store.js"; +import { GitError, TrustError } from "@sentry/dotagents-lib"; +import { getCacheStateDir } from "../../cache.js"; +import { InstallError } from "./errors.js"; + +export interface InstallSubagentsResult { + subagents: SubagentDeclaration[]; + lockEntries: Lockfile["subagents"]; +} + +function validateFrozenSubagents( + subagents: SubagentConfig[], + lockfile: Lockfile | null, +): void { + if (subagents.length === 0) {return;} + if (!lockfile) { + throw new InstallError("--frozen requires agents.lock to exist."); + } + + for (const subagent of subagents) { + if (!lockfile.subagents[subagent.name]) { + throw new InstallError( + `--frozen: subagent "${subagent.name}" is in agents.toml but missing from agents.lock.`, + ); + } + } +} + +/** Resolves subagent declarations and lock entries without writing runtime files. */ +export async function installSubagents( + config: AgentsConfig, + lockfile: Lockfile | null, + scope: ScopeRoot, + frozen?: boolean, +): Promise { + const subagentsDir = join(scope.agentsDir, "agents"); + const subagents: SubagentDeclaration[] = []; + const lockEntries: Lockfile["subagents"] = {}; + + if (frozen) { + validateFrozenSubagents(config.subagents, lockfile); + if (config.subagents.length > 0) { + const loaded = await loadInstalledSubagents(subagentsDir, config.subagents); + if (loaded.issues.length > 0) { + throw new InstallError(loaded.issues.map((issue) => issue.issue).join("\n")); + } + subagents.push(...loaded.subagents); + } + return { subagents, lockEntries }; + } + + for (const subagentConfig of config.subagents) { + let resolved: Awaited>; + try { + resolved = await resolveSubagent(subagentConfig, { + 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 subagent "${subagentConfig.name}": ${msg}`); + } + + subagents.push(resolved.subagent); + lockEntries[resolved.subagent.name] = lockEntryForSubagent(resolved); + } + + return { subagents, lockEntries }; +} + +/** Writes canonical `.agents/agents` markdown files before the lockfile commit. */ +export async function writeCanonicalSubagents( + config: AgentsConfig, + scope: ScopeRoot, + subagents: SubagentDeclaration[], + frozen?: boolean, +): Promise { + if (frozen) {return;} + const subagentsDir = join(scope.agentsDir, "agents"); + try { + await writeInstalledSubagents(subagentsDir, subagents); + await pruneInstalledSubagents(subagentsDir, config.subagents); + } catch (err) { + if (err instanceof InstalledSubagentWriteError) { + throw new InstallError(err.message); + } + throw err; + } +} From df4f423645f92dfe645ad0c2c94f9f7beb5ee0a1 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 14:34:50 -0700 Subject: [PATCH 18/27] test: Disable signing in temp git repos Local git integration fixtures can inherit developer signing settings and hang when git commit invokes external signing helpers. Configure the temporary repositories to disable commit signing so the tests exercise dotagents behavior instead of host git config. Co-Authored-By: Codex --- .../src/skills/resolver.integration.test.ts | 15 +++++++++------ .../dotagents-lib/src/sources/cache.test.ts | 9 +++++++-- .../src/cli/commands/install.test.ts | 19 ++++++++++--------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/dotagents-lib/src/skills/resolver.integration.test.ts b/packages/dotagents-lib/src/skills/resolver.integration.test.ts index 9f58b63..5b215c1 100644 --- a/packages/dotagents-lib/src/skills/resolver.integration.test.ts +++ b/packages/dotagents-lib/src/skills/resolver.integration.test.ts @@ -5,6 +5,13 @@ import { tmpdir } from "node:os"; import { resolveSkill, resolveWildcardSkills } from "./resolver.js"; import { exec } from "../utils/exec.js"; +async function initTestGitRepo(repoDir: string): Promise { + await exec("git", ["init"], { cwd: repoDir }); + await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); +} + /** * Integration tests that use real git operations. * These create local git repos to test the full resolve pipeline. @@ -27,9 +34,7 @@ describe("resolveSkill integration", () => { // Point cache to temp dir // Create a local git repo that looks like a skill repository await mkdir(repoDir, { recursive: true }); - await exec("git", ["init"], { cwd: repoDir }); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await initTestGitRepo(repoDir); // Create a skill at the root level await mkdir(join(repoDir, "pdf"), { recursive: true }); @@ -179,9 +184,7 @@ describe("resolveWildcardSkills integration", () => { // Create a local git repo with multiple skills await mkdir(repoDir, { recursive: true }); - await exec("git", ["init"], { cwd: repoDir }); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await initTestGitRepo(repoDir); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile( diff --git a/packages/dotagents-lib/src/sources/cache.test.ts b/packages/dotagents-lib/src/sources/cache.test.ts index 1a2596c..d303a3f 100644 --- a/packages/dotagents-lib/src/sources/cache.test.ts +++ b/packages/dotagents-lib/src/sources/cache.test.ts @@ -5,6 +5,12 @@ import { tmpdir } from "node:os"; import { ensureCached, validateCacheKey, CacheError } from "./cache.js"; import { exec } from "../utils/exec.js"; +async function configureTestGitRepo(repoDir: string): Promise { + await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); +} + describe("validateCacheKey", () => { it.each([ ["empty string", ""], @@ -45,8 +51,7 @@ describe("ensureCached", () => { await exec("git", ["init", "--bare", "--initial-branch=main", remoteDir]); await exec("git", ["clone", remoteDir, repoDir]); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await configureTestGitRepo(repoDir); }); afterEach(async () => { diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index f69e27b..913e9d8 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -28,6 +28,13 @@ description: Review code for correctness. Review the current diff. `; +async function initTestGitRepo(repoDir: string): Promise { + await exec("git", ["init"], { cwd: repoDir }); + await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); +} + describe("runInstall", () => { let tmpDir: string; let stateDir: string; @@ -53,9 +60,7 @@ describe("runInstall", () => { if (repoInitialized) {return;} await mkdir(repoDir, { recursive: true }); - await exec("git", ["init"], { cwd: repoDir }); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await initTestGitRepo(repoDir); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); @@ -1595,9 +1600,7 @@ path = "reviewer.md" // Create a second repo that also has a "pdf" skill const repoDir2 = join(tmpDir, "repo2"); await mkdir(repoDir2, { recursive: true }); - await exec("git", ["init"], { cwd: repoDir2 }); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir2 }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir2 }); + await initTestGitRepo(repoDir2); await mkdir(join(repoDir2, "pdf"), { recursive: true }); await writeFile(join(repoDir2, "pdf", "SKILL.md"), SKILL_MD("pdf")); await exec("git", ["add", "."], { cwd: repoDir2 }); @@ -1652,9 +1655,7 @@ path = "reviewer.md" // Create a second repo with a "helper" skill const repoDir2 = join(tmpDir, "repo2"); await mkdir(repoDir2, { recursive: true }); - await exec("git", ["init"], { cwd: repoDir2 }); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir2 }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir2 }); + await initTestGitRepo(repoDir2); await mkdir(join(repoDir2, "helper"), { recursive: true }); await writeFile(join(repoDir2, "helper", "SKILL.md"), SKILL_MD("helper")); await exec("git", ["add", "."], { cwd: repoDir2 }); From 4fe38b380df812d0c037c53fc2c2d469f326f7e2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 14:43:48 -0700 Subject: [PATCH 19/27] 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 0853ff368d6483ac442424dd973bb15cf53d0a0a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 17:19:53 -0700 Subject: [PATCH 20/27] 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 --- AGENTS.md | 7 +- packages/dotagents/src/agents/index.ts | 28 +- .../dotagents/src/agents/plugin-writer.ts | 850 ------------------ .../dotagents/src/cli/commands/add.test.ts | 3 + .../dotagents/src/cli/commands/doctor.test.ts | 1 + packages/dotagents/src/cli/commands/doctor.ts | 4 +- packages/dotagents/src/cli/commands/init.ts | 2 +- .../src/cli/commands/install.test.ts | 2 +- .../src/cli/commands/install/agent-runtime.ts | 16 +- .../src/cli/commands/install/gitignore.ts | 4 +- .../src/cli/commands/install/plugins.ts | 2 +- .../src/cli/commands/install/subagents.ts | 4 +- .../dotagents/src/cli/commands/remove.test.ts | 3 + packages/dotagents/src/cli/commands/remove.ts | 2 +- .../dotagents/src/cli/commands/sync.test.ts | 8 +- packages/dotagents/src/cli/commands/sync.ts | 16 +- .../src/cli/ensure-user-scope.test.ts | 2 +- .../dotagents/src/cli/ensure-user-scope.ts | 2 +- 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 ++ .../format.test.ts} | 98 +- .../helpers.ts => subagents/format.ts} | 96 +- .../identity.ts} | 0 .../store.test.ts} | 4 +- .../subagent-store.ts => subagents/store.ts} | 13 +- packages/dotagents/src/subagents/types.ts | 49 + .../writer.test.ts} | 4 +- .../writer.ts} | 13 +- .../dotagents/src/symlinks/manager.test.ts | 1 + .../{agents => targets}/definitions/claude.ts | 3 +- .../{agents => targets}/definitions/codex.ts | 3 +- .../{agents => targets}/definitions/cursor.ts | 3 +- .../src/targets/definitions/helpers.test.ts | 95 ++ .../src/targets/definitions/helpers.ts | 93 ++ .../definitions/opencode.ts | 3 +- .../{agents => targets}/definitions/vscode.ts | 0 .../src/{agents => targets}/errors.ts | 0 .../{agents => targets}/hook-writer.test.ts | 0 .../src/{agents => targets}/hook-writer.ts | 0 .../{agents => targets}/mcp-writer.test.ts | 0 .../src/{agents => targets}/mcp-writer.ts | 0 .../src/{agents => targets}/paths.test.ts | 0 .../src/{agents => targets}/paths.ts | 0 .../src/{agents => targets}/registry.test.ts | 0 .../src/{agents => targets}/registry.ts | 12 - .../src/{agents => targets}/types.ts | 51 +- skills/dotagents-qa/SOURCES.md | 10 +- specs/SPEC.md | 6 +- 59 files changed, 1273 insertions(+), 1171 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 rename packages/dotagents/src/{agents/definitions/helpers.test.ts => subagents/format.test.ts} (56%) rename packages/dotagents/src/{agents/definitions/helpers.ts => subagents/format.ts} (55%) rename packages/dotagents/src/{agents/subagent-identity.ts => subagents/identity.ts} (100%) rename packages/dotagents/src/{agents/subagent-store.test.ts => subagents/store.test.ts} (99%) rename packages/dotagents/src/{agents/subagent-store.ts => subagents/store.ts} (96%) create mode 100644 packages/dotagents/src/subagents/types.ts rename packages/dotagents/src/{agents/subagent-writer.test.ts => subagents/writer.test.ts} (99%) rename packages/dotagents/src/{agents/subagent-writer.ts => subagents/writer.ts} (94%) rename packages/dotagents/src/{agents => targets}/definitions/claude.ts (88%) rename packages/dotagents/src/{agents => targets}/definitions/codex.ts (91%) rename packages/dotagents/src/{agents => targets}/definitions/cursor.ts (94%) create mode 100644 packages/dotagents/src/targets/definitions/helpers.test.ts create mode 100644 packages/dotagents/src/targets/definitions/helpers.ts rename packages/dotagents/src/{agents => targets}/definitions/opencode.ts (91%) rename packages/dotagents/src/{agents => targets}/definitions/vscode.ts (100%) rename packages/dotagents/src/{agents => targets}/errors.ts (100%) rename packages/dotagents/src/{agents => targets}/hook-writer.test.ts (100%) rename packages/dotagents/src/{agents => targets}/hook-writer.ts (100%) rename packages/dotagents/src/{agents => targets}/mcp-writer.test.ts (100%) rename packages/dotagents/src/{agents => targets}/mcp-writer.ts (100%) rename packages/dotagents/src/{agents => targets}/paths.test.ts (100%) rename packages/dotagents/src/{agents => targets}/paths.ts (100%) rename packages/dotagents/src/{agents => targets}/registry.test.ts (100%) rename packages/dotagents/src/{agents => targets}/registry.ts (61%) rename packages/dotagents/src/{agents => targets}/types.ts (67%) diff --git a/AGENTS.md b/AGENTS.md index 9fe8388..ac2ccc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Use **pnpm**: `pnpm install`, `pnpm build`, `pnpm test` ## What This Project Is -dotagents is shared tooling for coding agents. It manages skills, MCP servers, and hooks declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code can be configured from a single source of truth. +dotagents is shared tooling for coding agents. It manages skills, subagents, plugins, MCP servers, and hooks declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code can be configured from a single source of truth. See `specs/SPEC.md` for the full design. @@ -21,7 +21,10 @@ packages/ │ ├── index.ts # Library entry point (re-exports lib symbols with @deprecated) │ ├── scope.ts # Project/user scope resolution │ ├── cli/ # CLI entry point + commands (init, install, add, remove, sync, list, mcp, doctor, trust) -│ ├── agents/ # Agent definitions, MCP/hook config writers +│ ├── targets/ # Target agent definitions plus MCP/hook config writers +│ ├── subagents/ # Subagent identity, store, and runtime writer +│ ├── plugins/ # Plugin schema/store and target-specific runtime projection +│ ├── agents/ # Compatibility re-export barrel for older internal imports │ ├── config/ # agents.toml schema, loader, writer │ ├── lockfile/ # agents.lock schema, loader, writer │ ├── symlinks/ # Symlink creation/management diff --git a/packages/dotagents/src/agents/index.ts b/packages/dotagents/src/agents/index.ts index 72d00fc..30dc346 100644 --- a/packages/dotagents/src/agents/index.ts +++ b/packages/dotagents/src/agents/index.ts @@ -1,39 +1,39 @@ -export { getAgent, allAgentIds } from "./registry.js"; -export { writeMcpConfigs, verifyMcpConfigs, toMcpDeclarations, projectMcpResolver } from "./mcp-writer.js"; -export type { McpTargetResolver, McpResolvedTarget } from "./mcp-writer.js"; -export { writeHookConfigs, verifyHookConfigs, toHookDeclarations, projectHookResolver } from "./hook-writer.js"; -export type { HookTargetResolver, HookResolvedTarget } from "./hook-writer.js"; +export { getAgent, allAgentIds } from "../targets/registry.js"; +export { writeMcpConfigs, verifyMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../targets/mcp-writer.js"; +export type { McpTargetResolver, McpResolvedTarget } from "../targets/mcp-writer.js"; +export { writeHookConfigs, verifyHookConfigs, toHookDeclarations, projectHookResolver } from "../targets/hook-writer.js"; +export type { HookTargetResolver, HookResolvedTarget } from "../targets/hook-writer.js"; export { writeSubagentConfigs, pruneSubagentConfigs, verifySubagentConfigs, projectSubagentResolver, userSubagentResolver, -} from "./subagent-writer.js"; +} from "../subagents/writer.js"; export { resolveSubagent, writeInstalledSubagents, loadInstalledSubagents, pruneInstalledSubagents, lockEntryForSubagent, -} from "./subagent-store.js"; +} from "../subagents/store.js"; export type { SubagentTargetResolver, SubagentResolvedTarget, SubagentWriteWarning, SubagentWriteResult, SubagentVerifyIssue, -} from "./subagent-writer.js"; +} from "../subagents/writer.js"; export type { SubagentResolveOptions, ResolvedSubagent, ResolvedSubagentType, InstalledSubagentLoadIssue, -} from "./subagent-store.js"; +} from "../subagents/store.js"; export type { LockedSubagent } from "../lockfile/schema.js"; -export { UnsupportedFeature } from "./errors.js"; -export { getUserMcpTarget, userMcpResolver } from "./paths.js"; -export type { UserMcpTarget } from "./paths.js"; +export { UnsupportedFeature } from "../targets/errors.js"; +export { getUserMcpTarget, userMcpResolver } from "../targets/paths.js"; +export type { UserMcpTarget } from "../targets/paths.js"; export type { AgentDefinition, McpDeclaration, @@ -42,6 +42,8 @@ export type { HookDeclaration, HookConfigSpec, HookSerializer, +} from "../targets/types.js"; +export type { SubagentDeclaration, NativeSubagentConfig, NativeSubagentContent, @@ -49,4 +51,4 @@ export type { SubagentConfigSpec, SubagentIdentityStrategy, SubagentSerializer, -} from "./types.js"; +} from "../subagents/types.js"; 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/add.test.ts b/packages/dotagents/src/cli/commands/add.test.ts index a2fe789..bfabd1f 100644 --- a/packages/dotagents/src/cli/commands/add.test.ts +++ b/packages/dotagents/src/cli/commands/add.test.ts @@ -39,6 +39,7 @@ describe("runAdd", () => { cwd: repoDir, }); await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); @@ -235,6 +236,7 @@ describe("runAdd", () => { cwd: singleRepo, }); await exec("git", ["config", "user.name", "Test"], { cwd: singleRepo }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: singleRepo }); await mkdir(join(singleRepo, "only-skill"), { recursive: true }); await writeFile( join(singleRepo, "only-skill", "SKILL.md"), @@ -439,6 +441,7 @@ describe("add() CLI parsing", () => { cwd: repoDir, }); await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index be4e83f..38b2097 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -183,6 +183,7 @@ source = "getsentry/plugins" execSync("git init", { cwd: projectRoot, stdio: "ignore" }); execSync("git config user.email test@test.com", { cwd: projectRoot, stdio: "ignore" }); execSync("git config user.name test", { cwd: projectRoot, stdio: "ignore" }); + execSync("git config commit.gpgsign false", { cwd: projectRoot, stdio: "ignore" }); await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); await writeFile(join(projectRoot, "agents.lock"), "version = 1\n"); diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index e7ad67b..2b3feae 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -10,11 +10,11 @@ import { isWildcardDep } from "../../config/schema.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { verifySymlinks } from "../../symlinks/manager.js"; -import { getAgent } from "../../agents/registry.js"; +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/init.ts b/packages/dotagents/src/cli/commands/init.ts index f8e730c..86ff0c8 100644 --- a/packages/dotagents/src/cli/commands/init.ts +++ b/packages/dotagents/src/cli/commands/init.ts @@ -6,7 +6,7 @@ import { generateDefaultConfig } from "../../config/writer.js"; import { writeAgentsGitignore, ensureRootGitignoreEntries } from "../../gitignore/writer.js"; import { ensureSkillsSymlink } from "../../symlinks/manager.js"; import { loadConfig } from "../../config/loader.js"; -import { getAgent, allAgentIds, allAgents } from "../../agents/registry.js"; +import { getAgent, allAgentIds, allAgents } from "../../targets/registry.js"; import { parseArgs } from "node:util"; import * as clack from "@clack/prompts"; import { resolveScope, isInsideGitRepo, findGitDir, type ScopeRoot } from "../../scope.js"; diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 913e9d8..b1a4eb7 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -10,7 +10,7 @@ import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import type { Lockfile } from "../../lockfile/schema.js"; import { resolveScope } from "../../scope.js"; -import { DOTAGENTS_SUBAGENT_MARKER } from "../../agents/definitions/helpers.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "../../subagents/format.js"; const SKILL_MD = (name: string) => `--- name: ${name} diff --git a/packages/dotagents/src/cli/commands/install/agent-runtime.ts b/packages/dotagents/src/cli/commands/install/agent-runtime.ts index 58a2abd..a231d3d 100644 --- a/packages/dotagents/src/cli/commands/install/agent-runtime.ts +++ b/packages/dotagents/src/cli/commands/install/agent-runtime.ts @@ -1,20 +1,20 @@ import { join } from "node:path"; import type { AgentsConfig } from "../../../config/schema.js"; import type { ScopeRoot } from "../../../scope.js"; -import { getAgent } from "../../../agents/registry.js"; +import { getAgent } from "../../../targets/registry.js"; import { ensureSkillsSymlink } from "../../../symlinks/manager.js"; -import { projectMcpResolver, toMcpDeclarations, writeMcpConfigs } from "../../../agents/mcp-writer.js"; -import { projectHookResolver, toHookDeclarations, writeHookConfigs } from "../../../agents/hook-writer.js"; -import { userMcpResolver } from "../../../agents/paths.js"; +import { projectMcpResolver, toMcpDeclarations, writeMcpConfigs } from "../../../targets/mcp-writer.js"; +import { projectHookResolver, toHookDeclarations, writeHookConfigs } from "../../../targets/hook-writer.js"; +import { userMcpResolver } from "../../../targets/paths.js"; import { pruneSubagentConfigs, projectSubagentResolver, userSubagentResolver, writeSubagentConfigs, -} from "../../../agents/subagent-writer.js"; -import { prunePluginOutputs, writePluginOutputs } from "../../../agents/plugin-writer.js"; -import type { PluginDeclaration } from "../../../agents/plugin-store.js"; -import type { SubagentDeclaration } from "../../../agents/types.js"; +} from "../../../subagents/writer.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. */ export async function writeSkillSymlinks( diff --git a/packages/dotagents/src/cli/commands/install/gitignore.ts b/packages/dotagents/src/cli/commands/install/gitignore.ts index 754b55c..6663f73 100644 --- a/packages/dotagents/src/cli/commands/install/gitignore.ts +++ b/packages/dotagents/src/cli/commands/install/gitignore.ts @@ -4,8 +4,8 @@ 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 "../../../agents/types.js"; +import { isInPlacePluginSource, type PluginDeclaration } from "../../../plugins/store.js"; +import type { SubagentDeclaration } from "../../../subagents/types.js"; export interface InstallGitignoreArtifacts { installedSkillNames: string[]; diff --git a/packages/dotagents/src/cli/commands/install/plugins.ts b/packages/dotagents/src/cli/commands/install/plugins.ts index ed10d5e..cb05ca1 100644 --- a/packages/dotagents/src/cli/commands/install/plugins.ts +++ b/packages/dotagents/src/cli/commands/install/plugins.ts @@ -12,7 +12,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/install/subagents.ts b/packages/dotagents/src/cli/commands/install/subagents.ts index 69f99cd..43a3841 100644 --- a/packages/dotagents/src/cli/commands/install/subagents.ts +++ b/packages/dotagents/src/cli/commands/install/subagents.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import type { AgentsConfig, SubagentConfig } from "../../../config/schema.js"; import type { Lockfile } from "../../../lockfile/schema.js"; import type { ScopeRoot } from "../../../scope.js"; -import type { SubagentDeclaration } from "../../../agents/types.js"; +import type { SubagentDeclaration } from "../../../subagents/types.js"; import { InstalledSubagentWriteError, lockEntryForSubagent, @@ -10,7 +10,7 @@ import { pruneInstalledSubagents, resolveSubagent, writeInstalledSubagents, -} from "../../../agents/subagent-store.js"; +} from "../../../subagents/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.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index 5fdbd48..99ac475 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -38,6 +38,7 @@ describe("runRemove", () => { await exec("git", ["init"], { cwd: repoDir }); await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); @@ -218,6 +219,7 @@ describe("runRemoveSource", () => { await exec("git", ["init"], { cwd: repoDir }); await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); @@ -233,6 +235,7 @@ describe("runRemoveSource", () => { await exec("git", ["init"], { cwd: otherRepoDir }); await exec("git", ["config", "user.email", "test@test.com"], { cwd: otherRepoDir }); await exec("git", ["config", "user.name", "Test"], { cwd: otherRepoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: otherRepoDir }); await mkdir(join(otherRepoDir, "deploy"), { recursive: true }); await writeFile(join(otherRepoDir, "deploy", "SKILL.md"), SKILL_MD("deploy")); 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 4fe7a76..d4ed196 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -10,7 +10,7 @@ import { writeLockfile } from "../../lockfile/writer.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { loadConfig } from "../../config/loader.js"; import { resolveScope } from "../../scope.js"; -import { DOTAGENTS_SUBAGENT_MARKER } from "../../agents/definitions/helpers.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "../../subagents/format.js"; import { exec } from "@sentry/dotagents-lib"; const SKILL_MD = (name: string) => `--- @@ -199,6 +199,7 @@ describe("runSync", () => { await exec("git", ["init"], { cwd: skillRepo }); await exec("git", ["config", "user.email", "test@example.com"], { cwd: skillRepo }); await exec("git", ["config", "user.name", "Test User"], { cwd: skillRepo }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: skillRepo }); await mkdir(join(skillRepo, "pdf"), { recursive: true }); await writeFile(join(skillRepo, "pdf", "SKILL.md"), SKILL_MD("pdf")); await exec("git", ["add", "."], { cwd: skillRepo }); @@ -209,6 +210,7 @@ describe("runSync", () => { await exec("git", ["init", "-b", "main"], { cwd: projectSeed }); await exec("git", ["config", "user.email", "test@example.com"], { cwd: projectSeed }); await exec("git", ["config", "user.name", "Test User"], { cwd: projectSeed }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: projectSeed }); await writeFile( join(projectSeed, "agents.toml"), `version = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${skillRepo}"\n`, @@ -222,8 +224,10 @@ 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: bobRepo }); try { process.env["DOTAGENTS_STATE_DIR"] = aliceStateDir; @@ -267,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 f883bc7..3a12633 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -10,14 +10,14 @@ import { writeLockfile } from "../../lockfile/writer.js"; import { addSkillToConfig } from "../../config/writer.js"; import { writeAgentsGitignore, checkRootGitignoreEntries } from "../../gitignore/writer.js"; import { ensureSkillsSymlink, verifySymlinks } from "../../symlinks/manager.js"; -import { getAgent } from "../../agents/registry.js"; -import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js"; -import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js"; -import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; -import { loadInstalledSubagents, pruneInstalledSubagents } from "../../agents/subagent-store.js"; -import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins, pruneInstalledPlugins } from "../../agents/plugin-store.js"; -import { prunePluginOutputs, verifyPluginOutputs, writePluginOutputs } from "../../agents/plugin-writer.js"; -import { userMcpResolver } from "../../agents/paths.js"; +import { getAgent } from "../../targets/registry.js"; +import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../targets/mcp-writer.js"; +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 "../../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"; import { isInPlaceSkill, managedSkillPath } from "../../utils/fs.js"; diff --git a/packages/dotagents/src/cli/ensure-user-scope.test.ts b/packages/dotagents/src/cli/ensure-user-scope.test.ts index 54bcfed..769bf29 100644 --- a/packages/dotagents/src/cli/ensure-user-scope.test.ts +++ b/packages/dotagents/src/cli/ensure-user-scope.test.ts @@ -4,7 +4,7 @@ import { readFile, rm, mkdtemp } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { ensureUserScopeBootstrapped } from "./ensure-user-scope.js"; -import { allAgentIds } from "../agents/registry.js"; +import { allAgentIds } from "../targets/registry.js"; import type { ScopeRoot } from "../scope.js"; describe("ensureUserScopeBootstrapped", () => { diff --git a/packages/dotagents/src/cli/ensure-user-scope.ts b/packages/dotagents/src/cli/ensure-user-scope.ts index 04399fc..e5eddf2 100644 --- a/packages/dotagents/src/cli/ensure-user-scope.ts +++ b/packages/dotagents/src/cli/ensure-user-scope.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import chalk from "chalk"; import { generateDefaultConfig } from "../config/writer.js"; -import { allAgentIds } from "../agents/registry.js"; +import { allAgentIds } from "../targets/registry.js"; import type { ScopeRoot } from "../scope.js"; /** diff --git a/packages/dotagents/src/config/loader.ts b/packages/dotagents/src/config/loader.ts index ebd6af0..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 "../agents/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/agents/definitions/helpers.test.ts b/packages/dotagents/src/subagents/format.test.ts similarity index 56% rename from packages/dotagents/src/agents/definitions/helpers.test.ts rename to packages/dotagents/src/subagents/format.test.ts index 1fe4efa..6bae52a 100644 --- a/packages/dotagents/src/agents/definitions/helpers.test.ts +++ b/packages/dotagents/src/subagents/format.test.ts @@ -1,104 +1,12 @@ import { describe, it, expect } from "vitest"; import { parseMarkdownFrontmatterContent } from "@sentry/dotagents-lib"; import { - interpolateEnvRefs, - interpolateHeaders, - extractCodexHeaders, - markManagedMarkdownSubagent, + DOTAGENTS_SUBAGENT_MARKER, hasDotagentsMarkdownSubagentMarker, hasDotagentsTomlSubagentMarker, + markManagedMarkdownSubagent, serializeMarkdownSubagent, - DOTAGENTS_SUBAGENT_MARKER, -} from "./helpers.js"; - -const cursorTpl = (k: string) => `\${env:${k}}`; - -describe("interpolateEnvRefs", () => { - - it("passes through strings with no refs", () => { - expect(interpolateEnvRefs("Bearer tok", cursorTpl)).toBe("Bearer tok"); - }); - - it("replaces a single ref", () => { - expect(interpolateEnvRefs("${API_KEY}", cursorTpl)).toBe("${env:API_KEY}"); - }); - - it("replaces ref in a mixed string", () => { - expect(interpolateEnvRefs("Bearer ${TOKEN}", cursorTpl)).toBe("Bearer ${env:TOKEN}"); - }); - - it("replaces multiple refs", () => { - expect(interpolateEnvRefs("${A}:${B}", cursorTpl)).toBe("${env:A}:${env:B}"); - }); - - it("ignores non-matching patterns", () => { - // No braces, empty braces, leading digit, hyphen in name - for (const s of ["$NOBRACES", "${}", "${123}", "${foo-bar}"]) { - expect(interpolateEnvRefs(s, cursorTpl)).toBe(s); - } - }); - - it("replaces underscore-prefixed and numeric-suffixed vars", () => { - expect(interpolateEnvRefs("${_FOO_2}", cursorTpl)).toBe("${env:_FOO_2}"); - }); -}); - -describe("interpolateHeaders", () => { - it("transforms all header values", () => { - const headers = { "X-Key": "${KEY}", "Authorization": "Bearer ${TOKEN}" }; - const result = interpolateHeaders(headers, (k) => `{env:${k}}`); - expect(result).toEqual({ "X-Key": "{env:KEY}", "Authorization": "Bearer {env:TOKEN}" }); - }); - - it("returns undefined for undefined input", () => { - const noHeaders: Record | undefined = undefined; - expect(interpolateHeaders(noHeaders, (k) => k)).toBeUndefined(); - }); -}); - -describe("extractCodexHeaders", () => { - it("maps pure env ref to envHttpHeaders", () => { - const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ - "X-Api-Key": "${GOOGLE_API_KEY}", - }); - expect(httpHeaders).toBeUndefined(); - expect(envHttpHeaders).toEqual({ GOOGLE_API_KEY: "X-Api-Key" }); - }); - - it("keeps static values in httpHeaders", () => { - const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ - Authorization: "Bearer tok", - }); - expect(httpHeaders).toEqual({ Authorization: "Bearer tok" }); - expect(envHttpHeaders).toBeUndefined(); - }); - - it("keeps mixed values in httpHeaders as literal fallback", () => { - const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ - Authorization: "Bearer ${TOKEN}", - }); - expect(httpHeaders).toEqual({ Authorization: "Bearer ${TOKEN}" }); - expect(envHttpHeaders).toBeUndefined(); - }); - - it("splits mixed and pure refs correctly", () => { - const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ - "X-Api-Key": "${API_KEY}", - Authorization: "Bearer tok", - "X-Mixed": "prefix ${VAR} suffix", - }); - expect(envHttpHeaders).toEqual({ API_KEY: "X-Api-Key" }); - expect(httpHeaders).toEqual({ - Authorization: "Bearer tok", - "X-Mixed": "prefix ${VAR} suffix", - }); - }); - - it("returns empty object for undefined input", () => { - const noHeaders: Record | undefined = undefined; - expect(extractCodexHeaders(noHeaders)).toEqual({}); - }); -}); +} from "./format.js"; describe("serializeMarkdownSubagent", () => { it("serializes simple frontmatter fields", () => { diff --git a/packages/dotagents/src/agents/definitions/helpers.ts b/packages/dotagents/src/subagents/format.ts similarity index 55% rename from packages/dotagents/src/agents/definitions/helpers.ts rename to packages/dotagents/src/subagents/format.ts index 14f3b5c..be526d9 100644 --- a/packages/dotagents/src/agents/definitions/helpers.ts +++ b/packages/dotagents/src/subagents/format.ts @@ -1,5 +1,4 @@ import { stringify as tomlStringify } from "smol-toml"; -import type { McpDeclaration, HookDeclaration } from "../types.js"; export const DOTAGENTS_SUBAGENT_MARKER = "Generated by dotagents. Edit agents.toml instead."; const DOTAGENTS_SUBAGENT_MARKER_RE = escapeRegExp(DOTAGENTS_SUBAGENT_MARKER); @@ -10,98 +9,7 @@ const MANAGED_TOML_SUBAGENT_RE = new RegExp( `^\\uFEFF?# ${DOTAGENTS_SUBAGENT_MARKER_RE}\\r?\\n`, ); -export function envRecord( - env: string[] | undefined, - template: (key: string) => string, -): Record | undefined { - if (!env || env.length === 0) {return undefined;} - const rec: Record = {}; - for (const key of env) {rec[key] = template(key);} - return rec; -} - -const ENV_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; - -export function interpolateEnvRefs( - value: string, - template: (varName: string) => string, -): string { - return value.replace(ENV_REF_RE, (_match, varName: string) => template(varName)); -} - -export function interpolateHeaders( - headers: Record | undefined, - template: (varName: string) => string, -): Record | undefined { - if (!headers) {return undefined;} - const result: Record = {}; - for (const [key, value] of Object.entries(headers)) { - result[key] = interpolateEnvRefs(value, template); - } - return result; -} - -/** - * Split headers for Codex's model: pure `${VAR}` refs go to `envHttpHeaders` - * (mapping env var name → header name), everything else stays in `httpHeaders`. - * Mixed values like `"Bearer ${TOKEN}"` can't be represented in Codex's - * env_http_headers format and fall through as literal strings in httpHeaders. - */ -export function extractCodexHeaders( - headers: Record | undefined, -): { httpHeaders?: Record; envHttpHeaders?: Record } { - if (!headers) {return {};} - let httpHeaders: Record | undefined; - let envHttpHeaders: Record | undefined; - const pureRefRe = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; - for (const [key, value] of Object.entries(headers)) { - const match = pureRefRe.exec(value); - if (match) { - (envHttpHeaders ??= {})[match[1]!] = key; - } else { - (httpHeaders ??= {})[key] = value; - } - } - return { httpHeaders, envHttpHeaders }; -} - -export function httpServer( - s: McpDeclaration, - type?: string, - template?: (varName: string) => string, -): [string, unknown] { - const tpl = template ?? ((k: string) => `\${${k}}`); - return [ - s.name, - { - ...(type && { type }), - url: interpolateEnvRefs(s.url!, tpl), - ...(s.headers && { headers: interpolateHeaders(s.headers, tpl) }), - }, - ]; -} - -/** - * Serialize hooks into Claude Code / VS Code settings.json format. - * - * Output shape: - * { - * "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "..." }] }], - * "Stop": [{ "hooks": [{ "type": "command", "command": "..." }] }] - * } - */ -export function serializeClaudeHooks(hooks: HookDeclaration[]): Record { - const result: Record = {}; - for (const h of hooks) { - const entry = { - ...(h.matcher && { matcher: h.matcher }), - hooks: [{ type: "command", command: h.command }], - }; - (result[h.event] ??= []).push(entry); - } - return result; -} - +/** Serializes a managed Markdown subagent with marker-bearing frontmatter. */ export function serializeMarkdownSubagent( fields: Record, instructions: string, @@ -140,6 +48,7 @@ export function hasDotagentsTomlSubagentMarker(content: string): boolean { return MANAGED_TOML_SUBAGENT_RE.test(content); } +/** Serializes a managed Codex TOML subagent with the dotagents marker header. */ export function serializeCodexSubagent( fields: Record, ): string { @@ -153,6 +62,7 @@ export function serializeCodexSubagent( return `# ${DOTAGENTS_SUBAGENT_MARKER}\n${tomlStringify(doc)}`; } +/** Inserts the dotagents marker into existing TOML subagent content. */ export function markManagedTomlSubagent(content: string): string { if (hasDotagentsTomlSubagentMarker(content)) {return content;} return `# ${DOTAGENTS_SUBAGENT_MARKER}\n${content}`; diff --git a/packages/dotagents/src/agents/subagent-identity.ts b/packages/dotagents/src/subagents/identity.ts similarity index 100% rename from packages/dotagents/src/agents/subagent-identity.ts rename to packages/dotagents/src/subagents/identity.ts diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/subagents/store.test.ts similarity index 99% rename from packages/dotagents/src/agents/subagent-store.test.ts rename to packages/dotagents/src/subagents/store.test.ts index 1d8044c..af56687 100644 --- a/packages/dotagents/src/agents/subagent-store.test.ts +++ b/packages/dotagents/src/subagents/store.test.ts @@ -9,8 +9,8 @@ import { pruneInstalledSubagents, resolveSubagent, writeInstalledSubagents, -} from "./subagent-store.js"; -import { DOTAGENTS_SUBAGENT_MARKER, markManagedMarkdownSubagent } from "./definitions/helpers.js"; +} from "./store.js"; +import { DOTAGENTS_SUBAGENT_MARKER, markManagedMarkdownSubagent } from "./format.js"; import type { SubagentConfig } from "../config/schema.js"; const SUBAGENT_MD = (name: string) => `--- diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/subagents/store.ts similarity index 96% rename from packages/dotagents/src/agents/subagent-store.ts rename to packages/dotagents/src/subagents/store.ts index 87e7ab1..9a9b3d9 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/subagents/store.ts @@ -14,9 +14,9 @@ import { type RepositorySource, type TrustPolicy, } from "@sentry/dotagents-lib"; -import { hasDotagentsMarkdownSubagentMarker, serializeMarkdownSubagent } from "./definitions/helpers.js"; -import { getAgent } from "./registry.js"; -import { subagentIdentityFromMarkdownMeta } from "./subagent-identity.js"; +import { hasDotagentsMarkdownSubagentMarker, serializeMarkdownSubagent } from "./format.js"; +import { getAgent } from "../targets/registry.js"; +import { subagentIdentityFromMarkdownMeta } from "./identity.js"; import { SUBAGENT_NAME_PATTERN, type SubagentConfig } from "../config/schema.js"; import type { LockedSubagent } from "../lockfile/schema.js"; import type { @@ -26,6 +26,8 @@ import type { SubagentIdentityStrategy, } from "./types.js"; +// Owns installed subagent sources under `.agents/agents/`. Runtime-specific +// files are generated later by `subagents/writer.ts` and guarded by markers. const DOTAGENTS_NATIVE_FIELD = "dotagents_native"; const NATIVE_SUBAGENT_TARGETS = ["claude", "cursor", "codex", "opencode"] satisfies NativeSubagentTarget[]; let tempFileCounter = 0; @@ -85,6 +87,7 @@ export class InstalledSubagentWriteError extends Error { } } +/** Resolves a configured subagent source into a validated declaration and lock metadata. */ export async function resolveSubagent( config: SubagentConfig, opts: SubagentResolveOptions, @@ -140,6 +143,7 @@ export async function resolveSubagent( }; } +/** Writes installed managed subagent source files atomically, rolling back partial writes. */ export async function writeInstalledSubagents( subagentsDir: string, subagents: SubagentDeclaration[], @@ -191,6 +195,7 @@ export async function writeInstalledSubagents( return written; } +/** Loads installed source subagents declared in agents.toml and reports stale or invalid files. */ export async function loadInstalledSubagents( subagentsDir: string, configs: SubagentConfig[], @@ -225,6 +230,7 @@ export async function loadInstalledSubagents( return { subagents, issues }; } +/** Removes managed installed subagent source files that are no longer configured. */ export async function pruneInstalledSubagents( subagentsDir: string, configs: SubagentConfig[], @@ -233,6 +239,7 @@ export async function pruneInstalledSubagents( return pruneManagedMarkdownFiles(subagentsDir, desired); } +/** Converts a resolved subagent into its durable agents.lock representation. */ export function lockEntryForSubagent(resolved: ResolvedSubagent): LockedSubagent { return { source: resolved.source, diff --git a/packages/dotagents/src/subagents/types.ts b/packages/dotagents/src/subagents/types.ts new file mode 100644 index 0000000..9bc39f2 --- /dev/null +++ b/packages/dotagents/src/subagents/types.ts @@ -0,0 +1,49 @@ +import type { SubagentConfig } from "../config/schema.js"; + +/** + * Universal subagent declaration loaded from an installed subagent Markdown file. + */ +export interface SubagentDeclaration { + name: string; + description: string; + instructions: string; + targets?: SubagentConfig["targets"]; + native?: NativeSubagentContent; +} + +export type NativeSubagentTarget = "claude" | "cursor" | "codex" | "opencode"; + +/** Raw source content in the runtime's native subagent format. */ +export type NativeSubagentConfig = string; + +export type NativeSubagentContent = Partial>; + +export type SubagentIdentityStrategy = + | "frontmatter-name" + | "frontmatter-name-or-filename" + | "filename" + | "toml-name"; + +/** + * Describes where an agent stores custom subagent definitions. + */ +export interface SubagentConfigSpec { + /** Project-scope directory, relative to project root */ + projectDir: string; + /** User-scope directory, absolute path */ + userDir: string; + /** Generated file extension, including the leading dot */ + fileExtension: ".md" | ".toml"; + /** Runtime-specific rule for identifying a subagent artifact */ + identity: SubagentIdentityStrategy; + /** Transforms a universal subagent declaration into an agent-specific file */ + serialize: SubagentSerializer; +} + +/** + * Transforms a universal SubagentDeclaration into a runtime-specific file. + */ +export type SubagentSerializer = (subagent: SubagentDeclaration) => { + fileName: string; + content: string; +}; diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/subagents/writer.test.ts similarity index 99% rename from packages/dotagents/src/agents/subagent-writer.test.ts rename to packages/dotagents/src/subagents/writer.test.ts index b9b55cf..ab124bb 100644 --- a/packages/dotagents/src/agents/subagent-writer.test.ts +++ b/packages/dotagents/src/subagents/writer.test.ts @@ -9,8 +9,8 @@ import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, -} from "./subagent-writer.js"; -import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; +} from "./writer.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "./format.js"; import type { SubagentDeclaration } from "./types.js"; const SUBAGENT: SubagentDeclaration = { diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/subagents/writer.ts similarity index 94% rename from packages/dotagents/src/agents/subagent-writer.ts rename to packages/dotagents/src/subagents/writer.ts index 3f16e91..6f77edc 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/subagents/writer.ts @@ -1,11 +1,13 @@ import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { getAgent } from "./registry.js"; -import { hasDotagentsMarkdownSubagentMarker, hasDotagentsTomlSubagentMarker } from "./definitions/helpers.js"; -import { generatedSubagentIdentity, readSubagentFileIdentity } from "./subagent-identity.js"; +import { getAgent } from "../targets/registry.js"; +import { hasDotagentsMarkdownSubagentMarker, hasDotagentsTomlSubagentMarker } from "./format.js"; +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/symlinks/manager.test.ts b/packages/dotagents/src/symlinks/manager.test.ts index 2237ee0..6afa6df 100644 --- a/packages/dotagents/src/symlinks/manager.test.ts +++ b/packages/dotagents/src/symlinks/manager.test.ts @@ -107,6 +107,7 @@ describe("symlinks", () => { cwd: dir, }); await exec("git", ["config", "user.name", "Test"], { cwd: dir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: dir }); // Create a real skills directory with a committed file const targetDir = join(dir, ".claude"); diff --git a/packages/dotagents/src/agents/definitions/claude.ts b/packages/dotagents/src/targets/definitions/claude.ts similarity index 88% rename from packages/dotagents/src/agents/definitions/claude.ts rename to packages/dotagents/src/targets/definitions/claude.ts index d9e5a13..33fa492 100644 --- a/packages/dotagents/src/agents/definitions/claude.ts +++ b/packages/dotagents/src/targets/definitions/claude.ts @@ -1,7 +1,8 @@ import { join } from "node:path"; import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; -import { envRecord, httpServer, markManagedMarkdownSubagent, serializeClaudeHooks, serializeMarkdownSubagent } from "./helpers.js"; +import { markManagedMarkdownSubagent, serializeMarkdownSubagent } from "../../subagents/format.js"; +import { envRecord, httpServer, serializeClaudeHooks } from "./helpers.js"; const claude: AgentDefinition = { id: "claude", diff --git a/packages/dotagents/src/agents/definitions/codex.ts b/packages/dotagents/src/targets/definitions/codex.ts similarity index 91% rename from packages/dotagents/src/agents/definitions/codex.ts rename to packages/dotagents/src/targets/definitions/codex.ts index e02bc0a..75fcfd1 100644 --- a/packages/dotagents/src/agents/definitions/codex.ts +++ b/packages/dotagents/src/targets/definitions/codex.ts @@ -3,7 +3,8 @@ import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; import { UnsupportedFeature } from "../errors.js"; import claude from "./claude.js"; -import { envRecord, extractCodexHeaders, markManagedTomlSubagent, serializeCodexSubagent } from "./helpers.js"; +import { markManagedTomlSubagent, serializeCodexSubagent } from "../../subagents/format.js"; +import { envRecord, extractCodexHeaders } from "./helpers.js"; const codex: AgentDefinition = { ...claude, diff --git a/packages/dotagents/src/agents/definitions/cursor.ts b/packages/dotagents/src/targets/definitions/cursor.ts similarity index 94% rename from packages/dotagents/src/agents/definitions/cursor.ts rename to packages/dotagents/src/targets/definitions/cursor.ts index a3eaf90..0d285aa 100644 --- a/packages/dotagents/src/agents/definitions/cursor.ts +++ b/packages/dotagents/src/targets/definitions/cursor.ts @@ -3,7 +3,8 @@ import { homedir } from "node:os"; import type { AgentDefinition, HookDeclaration } from "../types.js"; import type { HookEvent } from "../../config/schema.js"; import claude from "./claude.js"; -import { httpServer, markManagedMarkdownSubagent, serializeMarkdownSubagent } from "./helpers.js"; +import { markManagedMarkdownSubagent, serializeMarkdownSubagent } from "../../subagents/format.js"; +import { httpServer } from "./helpers.js"; /** * Maps universal hook events to Cursor event names. diff --git a/packages/dotagents/src/targets/definitions/helpers.test.ts b/packages/dotagents/src/targets/definitions/helpers.test.ts new file mode 100644 index 0000000..a0675eb --- /dev/null +++ b/packages/dotagents/src/targets/definitions/helpers.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { + interpolateEnvRefs, + interpolateHeaders, + extractCodexHeaders, +} from "./helpers.js"; + +const cursorTpl = (k: string) => `\${env:${k}}`; + +describe("interpolateEnvRefs", () => { + + it("passes through strings with no refs", () => { + expect(interpolateEnvRefs("Bearer tok", cursorTpl)).toBe("Bearer tok"); + }); + + it("replaces a single ref", () => { + expect(interpolateEnvRefs("${API_KEY}", cursorTpl)).toBe("${env:API_KEY}"); + }); + + it("replaces ref in a mixed string", () => { + expect(interpolateEnvRefs("Bearer ${TOKEN}", cursorTpl)).toBe("Bearer ${env:TOKEN}"); + }); + + it("replaces multiple refs", () => { + expect(interpolateEnvRefs("${A}:${B}", cursorTpl)).toBe("${env:A}:${env:B}"); + }); + + it("ignores non-matching patterns", () => { + // No braces, empty braces, leading digit, hyphen in name + for (const s of ["$NOBRACES", "${}", "${123}", "${foo-bar}"]) { + expect(interpolateEnvRefs(s, cursorTpl)).toBe(s); + } + }); + + it("replaces underscore-prefixed and numeric-suffixed vars", () => { + expect(interpolateEnvRefs("${_FOO_2}", cursorTpl)).toBe("${env:_FOO_2}"); + }); +}); + +describe("interpolateHeaders", () => { + it("transforms all header values", () => { + const headers = { "X-Key": "${KEY}", "Authorization": "Bearer ${TOKEN}" }; + const result = interpolateHeaders(headers, (k) => `{env:${k}}`); + expect(result).toEqual({ "X-Key": "{env:KEY}", "Authorization": "Bearer {env:TOKEN}" }); + }); + + it("returns undefined for undefined input", () => { + const noHeaders: Record | undefined = undefined; + expect(interpolateHeaders(noHeaders, (k) => k)).toBeUndefined(); + }); +}); + +describe("extractCodexHeaders", () => { + it("maps pure env ref to envHttpHeaders", () => { + const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ + "X-Api-Key": "${GOOGLE_API_KEY}", + }); + expect(httpHeaders).toBeUndefined(); + expect(envHttpHeaders).toEqual({ GOOGLE_API_KEY: "X-Api-Key" }); + }); + + it("keeps static values in httpHeaders", () => { + const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ + Authorization: "Bearer tok", + }); + expect(httpHeaders).toEqual({ Authorization: "Bearer tok" }); + expect(envHttpHeaders).toBeUndefined(); + }); + + it("keeps mixed values in httpHeaders as literal fallback", () => { + const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ + Authorization: "Bearer ${TOKEN}", + }); + expect(httpHeaders).toEqual({ Authorization: "Bearer ${TOKEN}" }); + expect(envHttpHeaders).toBeUndefined(); + }); + + it("splits mixed and pure refs correctly", () => { + const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ + "X-Api-Key": "${API_KEY}", + Authorization: "Bearer tok", + "X-Mixed": "prefix ${VAR} suffix", + }); + expect(envHttpHeaders).toEqual({ API_KEY: "X-Api-Key" }); + expect(httpHeaders).toEqual({ + Authorization: "Bearer tok", + "X-Mixed": "prefix ${VAR} suffix", + }); + }); + + it("returns empty object for undefined input", () => { + const noHeaders: Record | undefined = undefined; + expect(extractCodexHeaders(noHeaders)).toEqual({}); + }); +}); diff --git a/packages/dotagents/src/targets/definitions/helpers.ts b/packages/dotagents/src/targets/definitions/helpers.ts new file mode 100644 index 0000000..970530a --- /dev/null +++ b/packages/dotagents/src/targets/definitions/helpers.ts @@ -0,0 +1,93 @@ +import type { McpDeclaration, HookDeclaration } from "../types.js"; + +export function envRecord( + env: string[] | undefined, + template: (key: string) => string, +): Record | undefined { + if (!env || env.length === 0) {return undefined;} + const rec: Record = {}; + for (const key of env) {rec[key] = template(key);} + return rec; +} + +const ENV_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +export function interpolateEnvRefs( + value: string, + template: (varName: string) => string, +): string { + return value.replace(ENV_REF_RE, (_match, varName: string) => template(varName)); +} + +export function interpolateHeaders( + headers: Record | undefined, + template: (varName: string) => string, +): Record | undefined { + if (!headers) {return undefined;} + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + result[key] = interpolateEnvRefs(value, template); + } + return result; +} + +/** + * Split headers for Codex's model: pure `${VAR}` refs go to `envHttpHeaders` + * (mapping env var name → header name), everything else stays in `httpHeaders`. + * Mixed values like `"Bearer ${TOKEN}"` can't be represented in Codex's + * env_http_headers format and fall through as literal strings in httpHeaders. + */ +export function extractCodexHeaders( + headers: Record | undefined, +): { httpHeaders?: Record; envHttpHeaders?: Record } { + if (!headers) {return {};} + let httpHeaders: Record | undefined; + let envHttpHeaders: Record | undefined; + const pureRefRe = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; + for (const [key, value] of Object.entries(headers)) { + const match = pureRefRe.exec(value); + if (match) { + (envHttpHeaders ??= {})[match[1]!] = key; + } else { + (httpHeaders ??= {})[key] = value; + } + } + return { httpHeaders, envHttpHeaders }; +} + +export function httpServer( + s: McpDeclaration, + type?: string, + template?: (varName: string) => string, +): [string, unknown] { + const tpl = template ?? ((k: string) => `\${${k}}`); + return [ + s.name, + { + ...(type && { type }), + url: interpolateEnvRefs(s.url!, tpl), + ...(s.headers && { headers: interpolateHeaders(s.headers, tpl) }), + }, + ]; +} + +/** + * Serialize hooks into Claude Code / VS Code settings.json format. + * + * Output shape: + * { + * "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "..." }] }], + * "Stop": [{ "hooks": [{ "type": "command", "command": "..." }] }] + * } + */ +export function serializeClaudeHooks(hooks: HookDeclaration[]): Record { + const result: Record = {}; + for (const h of hooks) { + const entry = { + ...(h.matcher && { matcher: h.matcher }), + hooks: [{ type: "command", command: h.command }], + }; + (result[h.event] ??= []).push(entry); + } + return result; +} diff --git a/packages/dotagents/src/agents/definitions/opencode.ts b/packages/dotagents/src/targets/definitions/opencode.ts similarity index 91% rename from packages/dotagents/src/agents/definitions/opencode.ts rename to packages/dotagents/src/targets/definitions/opencode.ts index 7e8b231..02a5665 100644 --- a/packages/dotagents/src/agents/definitions/opencode.ts +++ b/packages/dotagents/src/targets/definitions/opencode.ts @@ -2,7 +2,8 @@ import { join } from "node:path"; import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; import { UnsupportedFeature } from "../errors.js"; -import { envRecord, httpServer, markManagedMarkdownSubagent, serializeMarkdownSubagent } from "./helpers.js"; +import { markManagedMarkdownSubagent, serializeMarkdownSubagent } from "../../subagents/format.js"; +import { envRecord, httpServer } from "./helpers.js"; const opencode: AgentDefinition = { id: "opencode", diff --git a/packages/dotagents/src/agents/definitions/vscode.ts b/packages/dotagents/src/targets/definitions/vscode.ts similarity index 100% rename from packages/dotagents/src/agents/definitions/vscode.ts rename to packages/dotagents/src/targets/definitions/vscode.ts diff --git a/packages/dotagents/src/agents/errors.ts b/packages/dotagents/src/targets/errors.ts similarity index 100% rename from packages/dotagents/src/agents/errors.ts rename to packages/dotagents/src/targets/errors.ts diff --git a/packages/dotagents/src/agents/hook-writer.test.ts b/packages/dotagents/src/targets/hook-writer.test.ts similarity index 100% rename from packages/dotagents/src/agents/hook-writer.test.ts rename to packages/dotagents/src/targets/hook-writer.test.ts diff --git a/packages/dotagents/src/agents/hook-writer.ts b/packages/dotagents/src/targets/hook-writer.ts similarity index 100% rename from packages/dotagents/src/agents/hook-writer.ts rename to packages/dotagents/src/targets/hook-writer.ts diff --git a/packages/dotagents/src/agents/mcp-writer.test.ts b/packages/dotagents/src/targets/mcp-writer.test.ts similarity index 100% rename from packages/dotagents/src/agents/mcp-writer.test.ts rename to packages/dotagents/src/targets/mcp-writer.test.ts diff --git a/packages/dotagents/src/agents/mcp-writer.ts b/packages/dotagents/src/targets/mcp-writer.ts similarity index 100% rename from packages/dotagents/src/agents/mcp-writer.ts rename to packages/dotagents/src/targets/mcp-writer.ts diff --git a/packages/dotagents/src/agents/paths.test.ts b/packages/dotagents/src/targets/paths.test.ts similarity index 100% rename from packages/dotagents/src/agents/paths.test.ts rename to packages/dotagents/src/targets/paths.test.ts diff --git a/packages/dotagents/src/agents/paths.ts b/packages/dotagents/src/targets/paths.ts similarity index 100% rename from packages/dotagents/src/agents/paths.ts rename to packages/dotagents/src/targets/paths.ts diff --git a/packages/dotagents/src/agents/registry.test.ts b/packages/dotagents/src/targets/registry.test.ts similarity index 100% rename from packages/dotagents/src/agents/registry.test.ts rename to packages/dotagents/src/targets/registry.test.ts diff --git a/packages/dotagents/src/agents/registry.ts b/packages/dotagents/src/targets/registry.ts similarity index 61% rename from packages/dotagents/src/agents/registry.ts rename to packages/dotagents/src/targets/registry.ts index 754ef66..8bffe05 100644 --- a/packages/dotagents/src/agents/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/packages/dotagents/src/agents/types.ts b/packages/dotagents/src/targets/types.ts similarity index 67% rename from packages/dotagents/src/agents/types.ts rename to packages/dotagents/src/targets/types.ts index 9518aec..bef9c70 100644 --- a/packages/dotagents/src/agents/types.ts +++ b/packages/dotagents/src/targets/types.ts @@ -1,4 +1,5 @@ -import type { HookEvent, SubagentConfig } from "../config/schema.js"; +import type { HookEvent } from "../config/schema.js"; +import type { SubagentConfigSpec } from "../subagents/types.js"; /** * Universal MCP server declaration from agents.toml [[mcp]] sections. @@ -73,54 +74,6 @@ export interface HookConfigSpec { */ export type HookSerializer = (hooks: HookDeclaration[]) => unknown; -/** - * Universal subagent declaration loaded from an installed subagent Markdown file. - */ -export interface SubagentDeclaration { - name: string; - description: string; - instructions: string; - targets?: SubagentConfig["targets"]; - native?: NativeSubagentContent; -} - -export type NativeSubagentTarget = "claude" | "cursor" | "codex" | "opencode"; - -/** Raw source content in the runtime's native subagent format. */ -export type NativeSubagentConfig = string; - -export type NativeSubagentContent = Partial>; - -export type SubagentIdentityStrategy = - | "frontmatter-name" - | "frontmatter-name-or-filename" - | "filename" - | "toml-name"; - -/** - * Describes where an agent stores custom subagent definitions. - */ -export interface SubagentConfigSpec { - /** Project-scope directory, relative to project root */ - projectDir: string; - /** User-scope directory, absolute path */ - userDir: string; - /** Generated file extension, including the leading dot */ - fileExtension: ".md" | ".toml"; - /** Runtime-specific rule for identifying a subagent artifact */ - identity: SubagentIdentityStrategy; - /** Transforms a universal subagent declaration into an agent-specific file */ - serialize: SubagentSerializer; -} - -/** - * Transforms a universal SubagentDeclaration into a runtime-specific file. - */ -export type SubagentSerializer = (subagent: SubagentDeclaration) => { - fileName: string; - content: string; -}; - /** * Definition of an agent tool that dotagents manages. */ diff --git a/skills/dotagents-qa/SOURCES.md b/skills/dotagents-qa/SOURCES.md index 259c7ce..a23a422 100644 --- a/skills/dotagents-qa/SOURCES.md +++ b/skills/dotagents-qa/SOURCES.md @@ -7,11 +7,11 @@ | `AGENTS.md` | pnpm and validation conventions | | `package.json` | `pnpm build` and `pnpm check` scripts | | `specs/SPEC.md` | canonical `.agents/skills/`, agent symlink, MCP, hook, install, sync, list, and doctor behavior | -| `packages/dotagents/src/agents/definitions/claude.ts` | Claude project skills and generated config paths | -| `packages/dotagents/src/agents/definitions/cursor.ts` | Cursor shares `.claude/skills` and writes Cursor-specific MCP/hook config | -| `packages/dotagents/src/agents/definitions/vscode.ts` | VS Code reads `.agents/skills` natively and writes `.vscode/mcp.json` plus shared Claude-style hooks | -| `packages/dotagents/src/agents/definitions/codex.ts` | Codex reads `.agents/skills` natively and writes `.codex/config.toml` | -| `packages/dotagents/src/agents/definitions/opencode.ts` | OpenCode reads `.agents/skills` natively and writes `opencode.json` | +| `packages/dotagents/src/targets/definitions/claude.ts` | Claude project skills and generated config paths | +| `packages/dotagents/src/targets/definitions/cursor.ts` | Cursor shares `.claude/skills` and writes Cursor-specific MCP/hook config | +| `packages/dotagents/src/targets/definitions/vscode.ts` | VS Code reads `.agents/skills` natively and writes `.vscode/mcp.json` plus shared Claude-style hooks | +| `packages/dotagents/src/targets/definitions/codex.ts` | Codex reads `.agents/skills` natively and writes `.codex/config.toml` | +| `packages/dotagents/src/targets/definitions/opencode.ts` | OpenCode reads `.agents/skills` natively and writes `opencode.json` | | `packages/dotagents/src/cli/cache.ts` | `DOTAGENTS_STATE_DIR` cache isolation | | `packages/dotagents/src/cli/update-notifier.ts` | `HOME` isolation for update-check cache | | `skills/dotagents/SKILL.md` | sibling skill layout | diff --git a/specs/SPEC.md b/specs/SPEC.md index 90eb57f..1f799e1 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -840,7 +840,7 @@ dotagents/ mcp.ts trust.ts doctor.ts - agents/ + targets/ types.ts # McpDeclaration, AgentDefinition interfaces registry.ts # Agent registry (claude, cursor, codex, vscode, opencode) definitions/ # Per-agent definitions @@ -848,6 +848,10 @@ dotagents/ hook-writer.ts # Hook config file generation per agent paths.ts # Agent config path resolution errors.ts # Agent-specific error types + subagents/ # Subagent identity, store, formatting, and runtime writer + plugins/ # Plugin schema/store plus target-specific runtime projection + agents/ + index.ts # Compatibility re-export barrel for older internal imports config/ # agents.toml schema, loader, writer lockfile/ # agents.lock schema, loader, writer symlinks/ # Symlink create/verify/repair From 75bdd7949da32a02e17283de4aa4d0b710f439c3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 11:13:29 -0700 Subject: [PATCH 21/27] 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 b1a4eb7..480d35e 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", ); }); @@ -1582,6 +1605,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 66e2d5e1cf0f099cadd2454c3e939f9a0f084203 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 14:22:24 -0700 Subject: [PATCH 22/27] 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 --- packages/dotagents/src/plugins/runtime/manifests.ts | 3 --- packages/dotagents/src/plugins/runtime/writer.test.ts | 7 +++++-- skills/dotagents-qa/references/claude.md | 4 ++++ skills/dotagents-qa/references/plugin-runtime.md | 5 +++-- specs/plugins.md | 2 +- 5 files changed, 13 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 7d57c69..e54951d 100644 --- a/skills/dotagents-qa/references/claude.md +++ b/skills/dotagents-qa/references/claude.md @@ -37,6 +37,10 @@ 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. 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 e4fe3d0a67e2bd7480fde65ce64e101ceb930b1e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 15:09:46 -0700 Subject: [PATCH 23/27] 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 480d35e..016b501 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -811,7 +811,7 @@ agents = ["codex"] join(projectRoot, ".agents", ".gitignore"), "utf-8", ); - expect(gitignore).toContain("/skills/pdf/"); + expect(gitignore).toContain("/skills/pdf"); }); it("handles empty skills list", async () => { @@ -1527,9 +1527,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 () => { @@ -1644,8 +1644,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 3f56a67..4687320 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 afac2b6..297535b 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 @@ 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 @@ function runCodexPluginProof() { function runOpenCodePluginProof() { 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"); } } @@ -353,8 +362,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"'); @@ -387,9 +397,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 1f799e1..3d98740 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 65687d6dd74503d6f27bcc380a8e7114c92dbe8d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 15:40:41 -0700 Subject: [PATCH 24/27] 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 016b501..1b2f8cd 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"); @@ -1089,7 +1161,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")); @@ -1143,53 +1215,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 e101ec5..992be3f 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 3d98740..2fa4d2d 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 252c9a18e9a7b612097a9c2499ba47d94a22f6f3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 15:51:16 -0700 Subject: [PATCH 25/27] 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 2c1f2307de20707acd753c43ddb3d183ae04c661 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 16:03:47 -0700 Subject: [PATCH 26/27] 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 e703cb905d2e437f14120e77ddccb43c85ce2df2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 16:08:28 -0700 Subject: [PATCH 27/27] 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,