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/README.md b/README.md index 770456e..8b60187 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 @@ -45,9 +45,9 @@ npx @sentry/dotagents install |---------|-------------| | `init` | Create `agents.toml` and `.agents/skills/` | | `add [skills...]` | Add skill dependencies | -| `remove [-y]` | Remove a skill or all skills from a source | +| `remove [-y]` | Remove a skill, plugin, or all dependencies from a source | | `install` | Install all dependencies from `agents.toml` | -| `list` | Show 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 | @@ -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 | @@ -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,25 @@ 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. -[Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively and needs no configuration. +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", "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. Existing plugin install destinations are overwritten only when `agents.lock` proves they are managed by dotagents. + +Plugin declarations are project-scope only for now. `dotagents --user install` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. + +[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 -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..4b6e815 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", "pi"] 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", "pi"] ``` ### 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`, `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. | -| `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, 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/*`. | ### Skills @@ -266,9 +274,35 @@ 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, 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 | +|-------|------|----------|-------------| +| `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` 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: plugin `skills/` symlinked into `.opencode/skills/`; plugin Markdown `agents/` symlinked into `.opencode/agents/` +- Pi: plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target + +Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok copies and OpenCode/Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Existing plugin install destinations are overwritten only when `agents.lock` proves they are managed by dotagents. + +Plugin declarations are project-scope only for now. `install --user` rejects `[[plugins]]` entries and `sync --user` reports them as unsupported because user-scope runtime plugin projections are not generated yet. + ### 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 +316,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 +344,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 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 @@ -337,9 +371,9 @@ When adding multiple skills, any that already exist in `agents.toml` are skipped npx @sentry/dotagents remove [-y] ``` -Remove a skill from `agents.toml`, delete from disk, and update the lockfile. For skills sourced from a wildcard entry, prompts to add the skill to the `exclude` list instead of removing the entire wildcard. +Remove a skill or plugin from `agents.toml`, delete managed installed files, update the lockfile, prune generated plugin outputs when needed, and regenerate `.agents/.gitignore`. For skills sourced from a wildcard entry, prompts to add the skill to the `exclude` list instead of removing the entire wildcard. -When the argument is a source specifier (e.g. `owner/repo`, a URL) instead of a skill name, removes all skills from that source — both explicit and wildcard entries. Confirms before removing unless `-y` is passed. +When the argument is a source specifier (e.g. `owner/repo`, a URL) instead of a dependency name, removes all skills and plugins from that source. Confirms before removing unless `-y` is passed. ### sync @@ -347,7 +381,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 +447,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 | |--------|---------| @@ -429,7 +463,7 @@ Skills from wildcard entries are marked with a wildcard indicator. npx @sentry/dotagents doctor [--fix] ``` -Check project health: gitignore setup, installed skills, symlinks, and legacy config fields. Use `--fix` to auto-repair issues. +Check project health: gitignore setup, installed skills and plugins, symlinks, generated config, and legacy config fields. Use `--fix` to auto-repair issues. | Flag | Description | |------|-------------| @@ -445,9 +479,9 @@ Check project health: gitignore setup, installed skills, symlinks, and legacy co | `vscode` | VS Code Copilot | `.vscode` | (reads `.agents/skills/` natively) | `.vscode/mcp.json` | `.claude/settings.json` | Not supported | | `opencode` | OpenCode | `.opencode` | (reads `.agents/skills/` natively) | `opencode.json` | Not supported | `.opencode/agents/*.md` | -Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, OpenCode, and Pi read `.agents/skills/` directly. +Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, and OpenCode read `.agents/skills/` directly. -[Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. No agent target or symlink configuration needed. +[Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. Normal skills need no Pi-specific target or symlink configuration; plugin bundles can target `pi` when their `skills/` components should be exposed there. ## Scopes @@ -519,6 +553,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 +569,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 +589,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/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 8f01f9d..bea8ef7 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -54,12 +54,15 @@ dotagents --user init dotagents install ``` -Install and refresh skill and subagent dependencies from `agents.toml`. Resolves -sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, -hook, and subagent configs. There is no separate update command. With -`--frozen`, declared skills and subagents must already exist in `agents.lock`, -subagents are loaded from existing installed files without resolving sources, -the lockfile is not updated, and existing managed subagent files are not pruned. +Install and refresh skill, subagent, and plugin dependencies from `agents.toml`. +Resolves sources, copies canonical artifacts, writes the lockfile, creates +symlinks, and generates MCP, hook, subagent, and plugin runtime configs. There +is no separate update command. Plugins are project-scope only; `dotagents +--user install` rejects configs that declare `[[plugins]]`. With `--frozen`, +declared skills, subagents, and plugins must already exist in `agents.lock`, +subagents and plugins are loaded from existing installed files without resolving +sources, the lockfile is not updated, and existing managed subagent/plugin files +are not pruned. Example: @@ -126,10 +129,10 @@ dotagents add path:./my-skills/custom dotagents remove [-y] ``` -Remove a skill from `agents.toml`, delete it from disk, and update the lockfile. -You can also pass a source to remove all skills from that source. When removing -a skill provided by a wildcard source, dotagents can add that skill to the -wildcard `exclude` list. +Remove a skill or plugin from `agents.toml`, delete managed installed files, and +update the lockfile. You can also pass a source to remove all skills and plugins +from that source. When removing a skill provided by a wildcard source, dotagents +can add that skill to the wildcard `exclude` list. Options: @@ -152,8 +155,9 @@ dotagents sync ``` Reconcile project state without network access. Adopts local orphaned skills, -prunes stale managed skills and subagents removed from config, regenerates gitignore, and -repairs symlinks plus MCP/hook configs. Reports issues as warnings or errors. +prunes stale managed skills, subagents, and plugins removed from config, +regenerates gitignore, and repairs symlinks plus MCP/hook/subagent/plugin +configs. Reports issues as warnings or errors. @@ -286,8 +290,9 @@ dotagents doctor [--fix] ``` Check project health and fix issues. Verifies gitignore setup, installed -skills, symlinks, and detects legacy config fields. Useful for migrating to a -new version of dotagents. +skills/plugins, symlinks, generated runtime configs, and detects legacy config +fields. User-scope plugin declarations and same-project plugin declarations are +reported because plugin runtime projections are project-scoped. Options: @@ -308,7 +313,9 @@ dotagents doctor --fix # fix what it can dotagents list [--json] ``` -Show installed skills and status. Use `--json` for machine-readable output. +Show declared skills and plugins with install/lock status. Use `--json` for +machine-readable output. JSON output contains separate `skills` and `plugins` +arrays. Status output: @@ -407,6 +414,29 @@ Generated files: Generated files include a dotagents header marker. `install` and `sync` update managed files and do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` loads subagents from existing installed files, preserves managed subagent files and lock entries instead of pruning removed subagents, and does not resolve subagent sources. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. + + +### Plugins + +`[[plugins]]` entries install canonical plugin bundles into +`.agents/plugins//` and generate runtime-specific outputs for configured +agents. Plugin sources support GitHub/GitLab shorthands, git URLs, and `path:` +sources; HTTPS well-known sources are not supported for plugins. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | Yes | Plugin identifier. Lowercase letters, numbers, hyphens, and dots. | +| `source` | string | Yes | Repository or local source. | +| `ref` | string | No | Optional git ref override. | +| `path` | string | No | Optional explicit plugin path inside the source. | +| `targets` | string[] | No | Optional subset of configured agents. | + +Generated plugin outputs include Claude/Cursor/Codex marketplaces and native +manifests, Grok plugin directories, OpenCode skill/agent links, and Pi skill +links. dotagents rejects plugin sources that resolve to the same project's +`.agents/plugins//` install destination, and user-scope plugins are not +supported yet. + ## Scopes ### Project Scope (default) diff --git a/docs/src/content/docs/guide.mdx b/docs/src/content/docs/guide.mdx index f588944..384085f 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -115,11 +115,11 @@ Use `dotagents sync` for offline repair. It does not fetch anything from the network. Instead, it: - Adopts local orphaned skills, meaning installed skills that are not declared. -- Prunes stale managed skills and subagents removed from config. +- Prunes stale managed skills, subagents, and plugins removed from config. - Regenerates `.agents/.gitignore`. - Repairs broken symlinks. - Fixes MCP and hook configs. -- Repairs generated subagent files. +- Repairs generated subagent and plugin runtime files. ## Diagnosing Issues @@ -131,8 +131,9 @@ dotagents doctor # check for issues dotagents doctor --fix # auto-fix what it can ``` -Checks for missing gitignore entries, legacy config fields, missing skills, and -broken symlinks. It is especially useful when migrating from an older version. +Checks for missing gitignore entries, legacy config fields, missing skills and +plugins, generated runtime config drift, and broken symlinks. It is especially +useful when migrating from an older version. ## Personal Skills @@ -158,7 +159,7 @@ back to user scope automatically. ## Full Configuration Example -`agents.toml` with skills, wildcards, MCP servers, hooks, and subagents: +`agents.toml` with skills, wildcards, plugins, MCP servers, hooks, and subagents: ```toml version = 1 @@ -213,6 +214,12 @@ command = "my-lint-check" name = "code-reviewer" source = "getsentry/agent-pack" targets = ["claude", "codex", "opencode"] + +# Plugin bundle +[[plugins]] +name = "review-tools" +source = "getsentry/agent-pack" +targets = ["claude", "cursor", "codex", "opencode"] ``` See the [CLI reference](/cli/#configuration-agentstoml) for all fields and diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 58f9f5b..d671cfe 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -4,7 +4,7 @@ description: Shared tooling for coding agents. template: splash hero: title: 'dotagentsOne Config for Your Coding Agents' - tagline: 'Declare skills, MCP servers, hooks, and subagents in agents.toml. dotagents installs shared skills and writes each tool''s local config.' + tagline: 'Declare skills, plugins, MCP servers, hooks, and subagents in agents.toml. dotagents installs shared dependencies and writes each tool''s local config.' actions: - text: Get Started link: /guide/ @@ -34,7 +34,12 @@ source = "getsentry/skills" [[subagents]] name = "code-reviewer" source = "getsentry/agent-pack" -targets = ["claude", "codex", "opencode"]`} +targets = ["claude", "codex", "opencode"] + +[[plugins]] +name = "review-tools" +source = "getsentry/agent-pack" +targets = ["claude", "cursor", "codex", "opencode"]`} @@ -61,9 +66,13 @@ targets = ["claude", "codex", "opencode"]`} Subagents Declare custom Claude, Cursor, Codex, and OpenCode subagents once and generate native runtime files. + + Plugins + Install canonical plugin bundles and project supported skills, agents, manifests, and marketplaces into runtime-native locations. + Trust policy - Restrict skill and subagent sources before dotagents performs any network operation. + Restrict skill, subagent, and plugin sources before dotagents performs any network operation. diff --git a/examples/full/agents.toml b/examples/full/agents.toml index 9932e37..a5d5067 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", "pi"] [[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/plugin.json b/examples/full/local-plugins/qa-tools/plugin.json new file mode 100644 index 0000000..f5a3029 --- /dev/null +++ b/examples/full/local-plugins/qa-tools/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "qa-tools", + "version": "1.0.0", + "description": "Portable plugin fixture for dotagents QA.", + "category": "Testing", + "author": { + "name": "dotagents" + }, + "keywords": ["qa", "plugins"] +} 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/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 4084996..fca950c 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -102,6 +102,110 @@ 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 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 same-project and missing plugin errors together", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" })); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "local-tools" +source = "path:.agents/plugins/local-tools" + +[[plugins]] +name = "review-tools" +source = "path:external-review-tools" +`, + ); + await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); + await writeFile(join(projectRoot, ".agents", ".gitignore"), "# managed\n"); + + const result = await runDoctor({ scope: resolveScope("project", projectRoot) }); + const check = result.checks.find((c) => c.name === "installed plugins"); + expect(check?.status).toBe("error"); + expect(check?.message).toContain("local-tools"); + expect(check?.message).toContain("Same-project plugins cannot be installed into the same project"); + expect(check?.message).toContain("review-tools"); + expect(check?.message).toContain("Run 'npx @sentry/dotagents install'"); + }); + + it("reports user-scope plugins as unsupported", async () => { + const previousHome = process.env["DOTAGENTS_HOME"]; + const userRoot = join(tmpDir, "user-agents"); + 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"); @@ -202,6 +306,82 @@ describe("runDoctor", () => { 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("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"); + 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 0b00479..427d5aa 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"; @@ -14,6 +15,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, loadInstalledPlugins } from "../../plugins/store.js"; +import { projectedPiSkillNames } from "../../plugins/runtime/writer.js"; export interface DoctorCheck { name: string; @@ -151,15 +154,29 @@ export async function runDoctor(opts: DoctorOptions): Promise { } else { const managedNames = getManagedSkillNames(config, lockfile); const managedSubagentNames = getManagedSubagentNames(config, lockfile); + const managedPluginNames = getManagedPluginNames(config, lockfile, scope); checks.push({ name: ".agents/.gitignore", 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 filterManagedPluginSkillNames( + await projectedPiSkillNames(config.agents, installedPlugins.plugins), + config, + scope.skillsDir, + scope.pluginsDir, + ), + ], managedSubagentNames, + managedPluginNames, ); }, }); @@ -192,7 +209,49 @@ 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 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)) + .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); + const pluginErrors: string[] = []; + if (userScopePlugins.length > 0) { + pluginErrors.push( + `${userScopePlugins.length} plugin(s) declared in user scope: ${userScopePlugins.join(", ")}. User-scope plugins are not supported yet; declare plugins in a project agents.toml instead.`, + ); + } else { + if (sameProjectPlugins.length > 0) { + pluginErrors.push( + `${sameProjectPlugins.length} plugin(s) resolve inside this project's .agents/plugins/ tree: ${sameProjectPlugins.join(", ")}. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.`, + ); + } + if (missingPlugins.length > 0) { + pluginErrors.push( + `${missingPlugins.length} plugin(s) not installed: ${missingPlugins.join(", ")}. Run 'npx @sentry/dotagents install'.`, + ); + } + } + if (pluginErrors.length > 0) { + checks.push({ + name: "installed plugins", + status: "error", + message: pluginErrors.join(" "), + }); + } 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 +354,32 @@ function getManagedSubagentNames( return [...names]; } +function getManagedPluginNames( + config: Awaited>, + lockfile: Awaited>, + scope: ScopeRoot, +): string[] { + const names = new Set( + config.plugins + .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); + } + } + } + return [...names]; +} + export default async function doctor(args: string[], flags?: { user?: boolean }): Promise { const { values } = parseArgs({ args, diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index a73552d..8eccbee 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access, 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 { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { runInstall as runInstallCommand, InstallError, type InstallOptions, type InstallResult } from "./install.js"; import { runSync } from "./sync.js"; @@ -11,6 +11,7 @@ import { writeLockfile } from "../../lockfile/writer.js"; import type { Lockfile } from "../../lockfile/schema.js"; import { resolveScope } from "../../scope.js"; import { DOTAGENTS_SUBAGENT_MARKER } from "../../subagents/format.js"; +import { DOTAGENTS_MANAGED_PLUGIN_MARKER } from "../../plugins/store.js"; const SKILL_MD = (name: string) => `--- name: ${name} @@ -120,6 +121,967 @@ 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", + }); + + 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: "./review-tools", + source: "local", + }, + version: "1.0.0", + }, + ], + }); + const codexPlugin = (codexMarketplace["plugins"] as Array<{ source: { path: string } }>)[0]!; + expect(resolve(join(projectRoot, ".agents", "plugins"), codexPlugin["source"].path)).toBe( + join(projectRoot, ".agents", "plugins", "review-tools"), + ); + + const claudeMarketplaceJson = await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8"); + expect(claudeMarketplaceJson).toBe(`{ + "metadata": { + "managedBy": "dotagents" + }, + "name": "dotagents", + "owner": { + "name": "dotagents" + }, + "plugins": [ + { + "description": "Review workflow helpers", + "name": "review-tools", + "source": "../.agents/plugins/review-tools", + "version": "1.0.0" + } + ] +} +`); + const claudeMarketplace = JSON.parse(claudeMarketplaceJson) as { plugins: Array<{ source: string }> }; + expect(resolve(join(projectRoot, ".claude-plugin"), claudeMarketplace["plugins"][0]!["source"])).toBe( + join(projectRoot, ".agents", "plugins", "review-tools"), + ); + expect(await readFile(join(projectRoot, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(claudeMarketplaceJson); + + const claudeManifest = JSON.parse(await readFile(join(projectRoot, ".agents", "plugins", "review-tools", ".claude-plugin", "plugin.json"), "utf-8")) as Record; + expect(claudeManifest["name"]).toBe("review-tools"); + 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"); + 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("does not overwrite an existing unmanaged plugin install destination", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile(join(sourceDir, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + + const existingDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(existingDir, { recursive: true }); + await writeFile(join(existingDir, "plugin.json"), JSON.stringify({ name: "review-tools", description: "Hand written" }, null, 2)); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow(/install destination already exists and is not managed/); + + const existingManifest = JSON.parse(await readFile(join(existingDir, "plugin.json"), "utf-8")) as Record; + expect(existingManifest["description"]).toBe("Hand written"); + }); + + it("overwrites an existing managed plugin install destination", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Updated plugin" }, null, 2), + ); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + + const existingDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(existingDir, "skills", "old-review"), { recursive: true }); + await writeFile( + join(existingDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Old managed plugin" }, null, 2), + ); + await writeFile(join(existingDir, "skills", "old-review", "SKILL.md"), SKILL_MD("old-review")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "review-tools": { source: "path:plugin-source/review-tools" }, + }, + }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse(await readFile(join(existingDir, "plugin.json"), "utf-8")) as Record; + expect(installedManifest["description"]).toBe("Updated plugin"); + expect(existsSync(join(existingDir, "skills", "review", "SKILL.md"))).toBe(true); + expect(existsSync(join(existingDir, "skills", "old-review", "SKILL.md"))).toBe(false); + }); + + it("recovers a dotagents-managed plugin destination missing from the lockfile", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Recovered plugin" }, null, 2), + ); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + + const existingDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(existingDir, "skills", "partial"), { recursive: true }); + await writeFile(join(existingDir, DOTAGENTS_MANAGED_PLUGIN_MARKER), "managedBy=dotagents\n", "utf-8"); + await writeFile(join(existingDir, "skills", "partial", "SKILL.md"), SKILL_MD("partial")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse(await readFile(join(existingDir, "plugin.json"), "utf-8")) as Record; + expect(installedManifest["description"]).toBe("Recovered plugin"); + expect(existsSync(join(existingDir, "skills", "review", "SKILL.md"))).toBe(true); + expect(existsSync(join(existingDir, "skills", "partial", "SKILL.md"))).toBe(false); + }); + + it("allows an in-place plugin lock entry to move to an external source", async () => { + const sourceDir = join(projectRoot, "plugin-source", "review-tools"); + await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "External plugin" }, null, 2), + ); + await writeFile(join(sourceDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + + const existingDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(existingDir, { recursive: true }); + await writeFile(join(existingDir, "plugin.json"), JSON.stringify({ name: "review-tools", description: "In-place plugin" }, null, 2)); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "review-tools": { source: "path:.agents/plugins/review-tools" }, + }, + }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse(await readFile(join(existingDir, "plugin.json"), "utf-8")) as Record; + expect(installedManifest["description"]).toBe("External plugin"); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins["review-tools"]).toEqual({ + source: "path:plugin-source/review-tools", + }); + }); + + it("does not gitignore in-place skills that collide with Pi plugin projections", async () => { + await mkdir(join(projectRoot, ".agents", "skills", "review"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "review", "SKILL.md"), SKILL_MD("review")); + + 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"); + 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 }); + 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 }); + 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("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("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"); + 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"); + 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("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("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("skips unsupported marketplace source objects during plugin discovery", async () => { + const sourceRoot = join(projectRoot, "plugin-source"); + await mkdir(sourceRoot, { recursive: true }); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "source-marketplace", + plugins: [ + { + name: "review-tools", + source: { + source: "github", + path: "external/marketplace-review-tools", + repo: "org/review-tools", + }, + }, + ], + }, null, 2), + ); + const marketplaceOnlyDir = join(sourceRoot, "external", "marketplace-review-tools"); + await mkdir(marketplaceOnlyDir, { recursive: true }); + await writeFile( + join(marketplaceOnlyDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Marketplace-only plugin", + }, null, 2), + ); + const pluginDir = join(sourceRoot, "plugins", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Fallback local plugin", + }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse( + await readFile(join(projectRoot, ".agents", "plugins", "review-tools", "plugin.json"), "utf-8"), + ) as Record; + expect(installedManifest["description"]).toBe( + "Fallback local plugin", + ); + }); + + it("skips malformed marketplace files during plugin discovery", async () => { + const sourceRoot = join(projectRoot, "plugin-source"); + await mkdir(sourceRoot, { recursive: true }); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "source-marketplace", + plugins: [ + { + name: "review-tools", + source: { source: "local" }, + }, + ], + }, null, 2), + ); + const pluginDir = join(sourceRoot, "plugins", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Fallback local plugin", + }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const installedManifest = JSON.parse( + await readFile(join(projectRoot, ".agents", "plugins", "review-tools", "plugin.json"), "utf-8"), + ) as Record; + expect(installedManifest["description"]).toBe("Fallback local plugin"); + }); + + it("reports unsupported marketplace source objects when no compatible plugin is found", async () => { + const sourceRoot = join(projectRoot, "plugin-source"); + await mkdir(sourceRoot, { recursive: true }); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "source-marketplace", + plugins: [ + { + name: "review-tools", + source: { + source: "github", + path: "external/marketplace-review-tools", + repo: "org/review-tools", + }, + }, + ], + }, null, 2), + ); + const marketplaceOnlyDir = join(sourceRoot, "external", "marketplace-review-tools"); + await mkdir(marketplaceOnlyDir, { recursive: true }); + await writeFile( + join(marketplaceOnlyDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Marketplace-only plugin", + }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] + +[[plugins]] +name = "review-tools" +source = "path:plugin-source" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow( + /Matching marketplace entries use unsupported source types: github/, + ); + }); + + it("generates plugin runtime outputs in frozen mode from installed bundles", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + 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"), @@ -146,7 +1108,7 @@ describe("runInstall", () => { join(projectRoot, ".agents", ".gitignore"), "utf-8", ); - expect(gitignore).toContain("/skills/pdf/"); + expect(gitignore).toContain("/skills/pdf"); }); it("handles empty skills list", async () => { @@ -424,7 +1386,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")); @@ -453,6 +1415,7 @@ path = "code-reviewer.md" source: "path:old-agents", }, }, + plugins: {}, }; await writeLockfile(join(projectRoot, "agents.lock"), originalLockfile); @@ -481,49 +1444,6 @@ path = "code-reviewer.md" 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 }); @@ -861,9 +1781,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 () => { @@ -939,6 +1859,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"), @@ -949,8 +1898,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.ts b/packages/dotagents/src/cli/commands/install.ts index a83dc7b..eaf878f 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { parseArgs } from "node:util"; import chalk from "chalk"; import { GitError, TrustError } from "@sentry/dotagents-lib"; @@ -12,10 +12,12 @@ import { formatGitError, formatTrustError } from "../errors.js"; import { InstallError } from "./install/errors.js"; import { installSkills } from "./install/skills.js"; import { installSubagents, writeCanonicalSubagents } from "./install/subagents.js"; +import { installPlugins } from "./install/plugins.js"; import { writeInstallGitignore } from "./install/gitignore.js"; import { writeHookRuntime, writeMcpRuntime, + writePluginRuntime, writeSkillSymlinks, writeSubagentRuntime, } from "./install/agent-runtime.js"; @@ -32,30 +34,41 @@ export interface InstallOptions { export interface InstallResult { installed: string[]; - skipped: string[]; pruned: string[]; + prunedPlugins: string[]; hookWarnings: { agent: string; message: string }[]; subagentWarnings: { agent: string; name: string; message: string }[]; + pluginWarnings: { agent: string; name: string; message: string }[]; } export async function runInstall(opts: InstallOptions): Promise { const { scope, frozen } = opts; const config = await loadConfig(scope.configPath); + if (scope.scope === "user" && config.plugins.length > 0) { + throw new InstallError( + "User-scope plugins are not supported yet because plugin runtime projections are project-scoped. " + + "Declare plugins in a project agents.toml instead.", + ); + } + const lockfile = await loadLockfile(scope.lockPath); const skills = await installSkills(config, lockfile, scope, frozen); const subagents = await installSubagents(config, lockfile, scope, frozen); + const plugins = await installPlugins(config, lockfile, scope, frozen); const newLock: Lockfile = { version: 1, skills: skills.lockEntries, subagents: subagents.lockEntries, + plugins: plugins.lockEntries, }; - await writeCanonicalSubagents(config, scope, subagents.subagents, frozen); const writeLock = !frozen && ( !!lockfile || config.skills.length > 0 || - config.subagents.length > 0 + config.subagents.length > 0 || + config.plugins.length > 0 ); + await writeCanonicalSubagents(config, scope, subagents.subagents, frozen); if (writeLock) { await writeLockfile(scope.lockPath, newLock); } @@ -63,18 +76,27 @@ export async function runInstall(opts: InstallOptions): Promise { await writeInstallGitignore(config, lockfile, scope, { installedSkillNames: skills.installed, subagents: subagents.subagents, + plugins: plugins.plugins, }, frozen); await writeSkillSymlinks(config, scope); await writeMcpRuntime(config, scope); const hookWarnings = await writeHookRuntime(config, scope); const subagentWarnings = await writeSubagentRuntime(config, scope, subagents.subagents, frozen); + const pluginWarnings = await writePluginRuntime( + config, + scope, + plugins.plugins, + frozen, + plugins.pruned.map((name) => join(scope.pluginsDir, name)), + ); return { installed: skills.installed, - skipped: [], pruned: skills.pruned, + prunedPlugins: plugins.pruned, hookWarnings, subagentWarnings, + pluginWarnings, }; } @@ -109,12 +131,20 @@ export default async function install(args: string[], flags?: { user?: boolean } chalk.yellow(`Pruned ${result.pruned.length} stale skill(s): ${result.pruned.join(", ")}`), ); } + if (result.prunedPlugins.length > 0) { + console.log( + chalk.yellow(`Pruned ${result.prunedPlugins.length} stale plugin(s): ${result.prunedPlugins.join(", ")}`), + ); + } for (const w of result.hookWarnings) { console.log(chalk.yellow(` warn: ${w.message}`)); } for (const w of result.subagentWarnings) { console.log(chalk.yellow(` warn: ${w.message}`)); } + for (const w of result.pluginWarnings) { + console.log(chalk.yellow(` warn: ${w.message}`)); + } } catch (err) { if (err instanceof TrustError) { console.error(chalk.red(formatTrustError(err))); diff --git a/packages/dotagents/src/cli/commands/install/agent-runtime.ts b/packages/dotagents/src/cli/commands/install/agent-runtime.ts index 9506661..b79d880 100644 --- a/packages/dotagents/src/cli/commands/install/agent-runtime.ts +++ b/packages/dotagents/src/cli/commands/install/agent-runtime.ts @@ -12,6 +12,8 @@ import { userSubagentResolver, writeSubagentConfigs, } from "../../../subagents/writer.js"; +import { prunePluginOutputs, writePluginOutputs } from "../../../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. */ @@ -88,3 +90,19 @@ export async function writeSubagentRuntime( } return result.warnings; } + +/** Writes project-scoped plugin runtime projections. */ +export async function writePluginRuntime( + config: AgentsConfig, + scope: ScopeRoot, + plugins: PluginDeclaration[], + frozen?: boolean, + extraManagedPluginRoots: string[] = [], +): Promise<{ agent: string; name: string; message: string }[]> { + if (scope.scope !== "project") {return [];} + const result = await writePluginOutputs(config.agents, plugins, scope.root); + if (!frozen) { + await prunePluginOutputs(config.agents, plugins, scope.root, extraManagedPluginRoots); + } + return result.warnings; +} diff --git a/packages/dotagents/src/cli/commands/install/gitignore.ts b/packages/dotagents/src/cli/commands/install/gitignore.ts index 9cf8bc4..267c3de 100644 --- a/packages/dotagents/src/cli/commands/install/gitignore.ts +++ b/packages/dotagents/src/cli/commands/install/gitignore.ts @@ -2,13 +2,17 @@ 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"; +import { projectedPiSkillNames } from "../../../plugins/runtime/writer.js"; import type { SubagentDeclaration } from "../../../subagents/types.js"; export interface InstallGitignoreArtifacts { installedSkillNames: string[]; subagents: SubagentDeclaration[]; + plugins: PluginDeclaration[]; } function managedSkillNames( @@ -32,6 +36,20 @@ function managedSubagentNames( : subagents.map((subagent) => subagent.name); } +function managedPluginNames( + lockfile: Lockfile | null, + plugins: PluginDeclaration[], + frozen?: boolean, +): string[] { + return frozen + ? Object.entries(lockfile?.plugins ?? {}) + .filter(([, locked]) => !isInPlacePluginSource(locked.source)) + .map(([name]) => name) + : plugins + .filter((plugin) => !isInPlacePluginSource(plugin.source)) + .map((plugin) => plugin.name); +} + /** Regenerates project `.agents/.gitignore` from install results and lockfile state. */ export async function writeInstallGitignore( config: AgentsConfig, @@ -42,10 +60,21 @@ export async function writeInstallGitignore( ): Promise { if (scope.scope !== "project") {return;} + const managedSkills = [ + ...managedSkillNames(config, artifacts.installedSkillNames), + ...await filterManagedPluginSkillNames( + await projectedPiSkillNames(config.agents, artifacts.plugins), + config, + scope.skillsDir, + scope.pluginsDir, + ), + ]; + await writeAgentsGitignore( scope.agentsDir, - managedSkillNames(config, artifacts.installedSkillNames), + managedSkills, managedSubagentNames(lockfile, artifacts.subagents, frozen), + managedPluginNames(lockfile, artifacts.plugins, frozen), ); const missing = await checkRootGitignoreEntries(scope.root); diff --git a/packages/dotagents/src/cli/commands/install/plugins.ts b/packages/dotagents/src/cli/commands/install/plugins.ts new file mode 100644 index 0000000..15b49ac --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/plugins.ts @@ -0,0 +1,146 @@ +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import type { AgentsConfig, PluginConfig } from "../../../config/schema.js"; +import type { Lockfile } from "../../../lockfile/schema.js"; +import type { ScopeRoot } from "../../../scope.js"; +import { + installPluginBundle, + isInPlacePluginSource, + isManagedPluginInstall, + isProjectPluginSource, + isSameProjectPluginConfig, + loadInstalledPlugins, + lockEntryForPlugin, + type PluginDeclaration, + pruneInstalledPlugins, + resolvePlugin, +} from "../../../plugins/store.js"; +import { GitError, TrustError } from "@sentry/dotagents-lib"; +import { getCacheStateDir } from "../../cache.js"; +import { InstallError } from "./errors.js"; + +export interface InstallPluginsResult { + plugins: PluginDeclaration[]; + pruned: string[]; + lockEntries: Lockfile["plugins"]; +} + +function validateFrozenPlugins( + plugins: PluginConfig[], + lockfile: Lockfile | null, +): void { + if (plugins.length === 0) {return;} + if (!lockfile) { + throw new InstallError("--frozen requires agents.lock to exist."); + } + + for (const plugin of plugins) { + if (!lockfile.plugins[plugin.name]) { + throw new InstallError( + `--frozen: plugin "${plugin.name}" is in agents.toml but missing from agents.lock.`, + ); + } + } +} + +function staleManagedPluginNames( + current: Lockfile | null, + nextPlugins: Lockfile["plugins"], +): string[] { + if (!current) {return [];} + return Object.entries(current.plugins) + .filter(([name, locked]) => !nextPlugins[name] && !isInPlacePluginSource(locked.source)) + .map(([name]) => name); +} + +/** Ensures installs only replace plugin destinations already owned by dotagents. */ +function assertPluginDestinationIsManaged( + pluginsDir: string, + plugin: PluginConfig, + lockfile: Lockfile | null, +): void { + const { name } = plugin; + if (!existsSync(join(pluginsDir, name))) {return;} + + const locked = lockfile?.plugins[name]; + if (locked && !isInPlacePluginSource(locked.source)) {return;} + if (locked && !isInPlacePluginSource(plugin.source)) {return;} + if (isManagedPluginInstall(join(pluginsDir, name))) {return;} + + throw new InstallError( + `Plugin "${name}" install destination already exists and is not managed by dotagents: ${join(pluginsDir, name)}`, + ); +} + +/** 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); + const sameProjectPlugin = config.plugins.find((plugin) => + isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root) + ); + if (sameProjectPlugin) { + throw new InstallError( + `Plugin "${sameProjectPlugin.name}" source resolves inside this project's .agents/plugins/ tree. ` + + "Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.", + ); + } + if (config.plugins.length > 0) { + const loaded = await loadInstalledPlugins(scope.pluginsDir, config.plugins); + if (loaded.issues.length > 0) { + 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.", + ); + } + assertPluginDestinationIsManaged(scope.pluginsDir, pluginConfig, lockfile); + 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 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/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..620f474 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,35 @@ 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; + + 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 +131,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 +169,31 @@ export default async function list(args: string[], flags?: { user?: boolean }): scope, json: values["json"], }); + const pluginResults = await runPluginList({ + scope, + }); - if (results.length === 0) { - console.log(chalk.dim("No skills declared in agents.toml.")); + if (results.length === 0 && pluginResults.length === 0) { + console.log(chalk.dim("No skills or plugins declared in agents.toml.")); return; } if (values["json"]) { - console.log(JSON.stringify(results, null, 2)); + console.log(JSON.stringify({ skills: results, plugins: pluginResults }, null, 2)); return; } - console.log(chalk.bold("Skills:")); - for (const s of results) { - console.log(formatStatus(s)); + if (results.length > 0) { + console.log(chalk.bold("Skills:")); + for (const s of results) { + console.log(formatStatus(s)); + } + } + if (pluginResults.length > 0) { + if (results.length > 0) {console.log("");} + console.log(chalk.bold("Plugins:")); + for (const p of pluginResults) { + console.log(formatPluginStatus(p)); + } } } diff --git a/packages/dotagents/src/cli/commands/mcp.test.ts b/packages/dotagents/src/cli/commands/mcp.test.ts index 9780a41..0229a37 100644 --- a/packages/dotagents/src/cli/commands/mcp.test.ts +++ b/packages/dotagents/src/cli/commands/mcp.test.ts @@ -27,6 +27,7 @@ describe("mcp", () => { root: projectRoot, agentsDir: join(projectRoot, ".agents"), skillsDir: join(projectRoot, ".agents", "skills"), + pluginsDir: join(projectRoot, ".agents", "plugins"), configPath: join(projectRoot, "agents.toml"), lockPath: join(projectRoot, "agents.lock"), }; diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index 9a7332b..d2bd8ba 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -3,13 +3,21 @@ import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { runRemove, runRemoveSource, collectSkillsFromSource, RemoveError, WildcardSkillRemoveError } from "./remove.js"; +import { + collectSkillsFromSource, + RemoveError, + runRemove, + runRemovePluginSource, + runRemoveSource, + WildcardSkillRemoveError, +} from "./remove.js"; import { runInstall } from "./install.js"; import { exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { loadConfig } from "../../config/loader.js"; import { resolveScope } from "../../scope.js"; +import { DOTAGENTS_MANAGED_PLUGIN_MARKER } from "../../plugins/store.js"; const SKILL_MD = (name: string) => `--- name: ${name} @@ -63,7 +71,7 @@ describe("runRemove", () => { const scope = resolveScope("project", projectRoot); await runInstall({ scope }); - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); const config = await loadConfig(join(projectRoot, "agents.toml")); expect(config.skills.find((s) => s.name === "pdf")).toBeUndefined(); @@ -95,18 +103,271 @@ describe("runRemove", () => { }); const scope = resolveScope("project", projectRoot); - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); - expect(gitignore).not.toContain("/skills/pdf/"); + expect(gitignore).not.toContain("/skills/pdf"); 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, name: "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).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, name: "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"), + `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, name: "pdf" }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/skills/review"); + expect(gitignore).toContain("/plugins/review-tools/"); + }); + + it("removes a plugin entry, managed bundle, lock entry, and runtime outputs", async () => { + const pluginSource = join(projectRoot, "plugins", "review-tools"); + await mkdir(join(pluginSource, "skills", "review"), { recursive: true }); + await writeFile(join(pluginSource, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(pluginSource, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex", "pi"] + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools"))).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(true); + expect(existsSync(join(projectRoot, ".agents", "skills", "review"))).toBe(true); + + await runRemove({ scope, name: "review-tools" }); + + const config = await loadConfig(join(projectRoot, "agents.toml")); + expect(config.plugins.find((plugin) => plugin.name === "review-tools")).toBeUndefined(); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins["review-tools"]).toBeUndefined(); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "plugins", "marketplace.json"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "skills", "review"))).toBe(false); + }); + + it("removes both an explicit skill and plugin that share a name", async () => { + const skillSource = join(projectRoot, "local-skills", "review-tools"); + await mkdir(skillSource, { recursive: true }); + await writeFile(join(skillSource, "SKILL.md"), SKILL_MD("review-tools")); + + const pluginSource = join(projectRoot, "plugins", "review-tools"); + await mkdir(join(pluginSource, "skills", "plugin-review"), { recursive: true }); + await writeFile(join(pluginSource, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(pluginSource, "skills", "plugin-review", "SKILL.md"), SKILL_MD("plugin-review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex", "pi"] + +[[skills]] +name = "review-tools" +source = "path:local-skills/review-tools" + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + await runRemove({ scope, name: "review-tools" }); + + const config = await loadConfig(join(projectRoot, "agents.toml")); + expect(config.skills.find((skill) => skill.name === "review-tools")).toBeUndefined(); + expect(config.plugins.find((plugin) => plugin.name === "review-tools")).toBeUndefined(); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.skills["review-tools"]).toBeUndefined(); + expect(lockfile!.plugins["review-tools"]).toBeUndefined(); + expect(existsSync(join(projectRoot, ".agents", "skills", "review-tools"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "skills", "plugin-review"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools"))).toBe(false); + }); + + it("removes an installed plugin even when its lock entry is missing", async () => { + const pluginSource = join(projectRoot, "plugins", "review-tools"); + await mkdir(join(pluginSource, "skills", "review"), { recursive: true }); + await writeFile(join(pluginSource, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(pluginSource, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex", "pi"] + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + delete lockfile!.plugins["review-tools"]; + await writeLockfile(join(projectRoot, "agents.lock"), lockfile!); + await rm(join(projectRoot, ".agents", "plugins", "review-tools", DOTAGENTS_MANAGED_PLUGIN_MARKER), { force: true }); + + await runRemove({ scope, name: "review-tools" }); + + const config = await loadConfig(join(projectRoot, "agents.toml")); + expect(config.plugins.find((plugin) => plugin.name === "review-tools")).toBeUndefined(); + expect(existsSync(join(projectRoot, ".agents", "plugins", "review-tools"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "skills", "review"))).toBe(false); + }); + + it("removes plugins by source", async () => { + const pluginSource = join(projectRoot, "plugins", "review-tools"); + await mkdir(pluginSource, { recursive: true }); + await writeFile(join(pluginSource, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + await expect(runRemovePluginSource({ scope, source: "path:plugins/review-tools" })) + .resolves.toEqual(["review-tools"]); + + const config = await loadConfig(join(projectRoot, "agents.toml")); + expect(config.plugins).toEqual([]); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins).toEqual({}); + }); + it("throws RemoveError for skill not in config", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); const scope = resolveScope("project", projectRoot); - await expect(runRemove({ scope, skillName: "nonexistent" })).rejects.toThrow(RemoveError); + await expect(runRemove({ scope, name: "nonexistent" })).rejects.toThrow(RemoveError); }); it("throws WildcardSkillRemoveError for wildcard-sourced skill", async () => { @@ -118,7 +379,7 @@ describe("runRemove", () => { await runInstall({ scope }); // Trying to remove "pdf" which is wildcard-sourced - await expect(runRemove({ scope, skillName: "pdf" })).rejects.toThrow(WildcardSkillRemoveError); + await expect(runRemove({ scope, name: "pdf" })).rejects.toThrow(WildcardSkillRemoveError); }); it("WildcardSkillRemoveError carries the source", async () => { @@ -130,7 +391,7 @@ describe("runRemove", () => { await runInstall({ scope }); try { - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(WildcardSkillRemoveError); @@ -148,7 +409,7 @@ describe("runRemove", () => { await runInstall({ scope }); // Removing "pdf" should remove the explicit entry, not trigger wildcard error - await runRemove({ scope, skillName: "pdf" }); + await runRemove({ scope, name: "pdf" }); const config = await loadConfig(join(projectRoot, "agents.toml")); expect(config.skills.find((s) => s.name === "pdf")).toBeUndefined(); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index ec3dd93..dc6109c 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -1,18 +1,34 @@ +import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { rm } from "node:fs/promises"; import { createInterface } from "node:readline"; import { parseArgs } from "node:util"; import chalk from "chalk"; import { loadConfig } from "../../config/loader.js"; -import { isWildcardDep } from "../../config/schema.js"; -import { removeSkillFromConfig, removeSkillBlocksBySource, addExcludeToWildcard } from "../../config/writer.js"; +import { isWildcardDep, type PluginConfig } from "../../config/schema.js"; +import { + addExcludeToWildcard, + removePluginBlocksBySource, + removePluginFromConfig, + removeSkillBlocksBySource, + removeSkillFromConfig, +} from "../../config/writer.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; +import { filterManagedPluginSkillNames } from "../../gitignore/skills.js"; import { writeAgentsGitignore } from "../../gitignore/writer.js"; import { sourcesMatch, parseOwnerRepoShorthand, isExplicitSourceSpecifier } from "@sentry/dotagents-lib"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { isInPlaceSkill } from "../../utils/fs.js"; +import { + isInPlacePluginSource, + isManagedPluginInstall, + isSameProjectPluginConfig, + loadInstalledPlugins, + pruneInstalledPlugins, +} from "../../plugins/store.js"; +import { projectedPiSkillNames, prunePluginOutputs } from "../../plugins/runtime/writer.js"; export class RemoveError extends Error { constructor(message: string) { @@ -34,36 +50,51 @@ export class WildcardSkillRemoveError extends Error { export interface RemoveOptions { scope: ScopeRoot; - skillName: string; + name: string; } export async function runRemove(opts: RemoveOptions): Promise { - const { scope, skillName } = opts; + const { scope, name } = opts; const { configPath, lockPath, skillsDir } = scope; - const skillDir = join(skillsDir, skillName); + const skillDir = join(skillsDir, name); const config = await loadConfig(configPath); // Check if skill is an explicit entry - const explicitDep = config.skills.find((s) => s.name === skillName); + const explicitDep = config.skills.find((s) => s.name === name); + const explicitPlugin = config.plugins.find((plugin) => plugin.name === name); if (explicitDep && !isWildcardDep(explicitDep)) { - // Regular explicit entry — remove as before - await removeSkillFromConfig(configPath, skillName); + const lockfile = await loadLockfile(lockPath); + await removeSkillFromConfig(configPath, name); await rm(skillDir, { recursive: true, force: true }); - const lockfile = await loadLockfile(lockPath); if (lockfile) { - delete lockfile.skills[skillName]; - await writeLockfile(lockPath, lockfile); + delete lockfile.skills[name]; } + if (explicitPlugin) { + await removePluginFromConfig(configPath, name); + await removePluginArtifacts(scope, [name], [explicitPlugin], lockfile); + return; + } + + if (lockfile) { + await writeLockfile(lockPath, lockfile); + } await updateProjectGitignore(scope); return; } + if (explicitPlugin) { + const lockfile = await loadLockfile(lockPath); + await removePluginFromConfig(configPath, name); + await removePluginArtifacts(scope, [name], [explicitPlugin], lockfile); + return; + } + // Check if skill is from a wildcard entry (via lockfile source matching) const lockfile = await loadLockfile(lockPath); - const locked = lockfile?.skills[skillName]; + const locked = lockfile?.skills[name]; if (locked) { const wildcardDep = config.skills.find( (s) => @@ -71,11 +102,11 @@ export async function runRemove(opts: RemoveOptions): Promise { sourcesMatch(s.source, locked.source), ); if (wildcardDep) { - throw new WildcardSkillRemoveError(skillName, locked.source); + throw new WildcardSkillRemoveError(name, locked.source); } } - throw new RemoveError(`Skill "${skillName}" not found in agents.toml.`); + throw new RemoveError(`Skill or plugin "${name}" not found in agents.toml.`); } export interface RemoveSourceOptions { @@ -116,6 +147,32 @@ export async function collectSkillsFromSource(scope: ScopeRoot, source: string): return [...names].toSorted(); } +/** Collect all concrete plugin names from a source (config + lockfile). */ +export async function collectPluginsFromSource(scope: ScopeRoot, source: string): Promise { + const config = await loadConfig(scope.configPath); + const lockfile = await loadLockfile(scope.lockPath); + + const names = new Set(); + for (const plugin of config.plugins) { + if (sourcesMatch(plugin.source, source)) { + names.add(plugin.name); + } + } + if (lockfile) { + for (const [name, locked] of Object.entries(lockfile.plugins)) { + if (sourcesMatch(locked.source, source)) { + names.add(name); + } + } + } + + if (names.size === 0) { + throw new RemoveError(`No plugins found from source "${source}".`); + } + + return [...names].toSorted(); +} + /** * Remove all skills from a source. Returns the list of removed skill names. */ @@ -146,6 +203,73 @@ export async function runRemoveSource(opts: RemoveSourceOptions): Promise { + const { scope, source } = opts; + const pluginNames = await collectPluginsFromSource(scope, source); + const config = await loadConfig(scope.configPath); + const removedPlugins = config.plugins.filter((plugin) => pluginNames.includes(plugin.name)); + const lockfile = await loadLockfile(scope.lockPath); + + await removePluginBlocksBySource(scope.configPath, source); + await removePluginArtifacts(scope, pluginNames, removedPlugins, lockfile); + + return pluginNames; +} + +/** Removes managed plugin state after config mutation while preserving in-place plugin sources. */ +async function removePluginArtifacts( + scope: ScopeRoot, + pluginNames: string[], + plugins: PluginConfig[], + lockfile: Awaited>, +): Promise { + const managedPluginNames = new Set(); + for (const plugin of plugins) { + const name = plugin.name; + const pluginDir = join(scope.pluginsDir, name); + if (isManagedPluginInstall(pluginDir)) { + managedPluginNames.add(name); + continue; + } + if (!existsSync(pluginDir)) {continue;} + + if (!isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) { + managedPluginNames.add(name); + } + } + + for (const name of pluginNames) { + const locked = lockfile?.plugins[name]; + if (locked && !isInPlacePluginSource(locked.source)) { + managedPluginNames.add(name); + } + } + const managedPluginRoots = [...managedPluginNames].map((name) => join(scope.pluginsDir, name)); + + if (scope.scope === "project") { + await pruneInstalledPlugins(scope.pluginsDir, managedPluginNames); + } + + if (lockfile) { + for (const name of pluginNames) { + delete lockfile.plugins[name]; + } + await writeLockfile(scope.lockPath, lockfile); + } + + if (scope.scope === "project") { + const config = await loadConfig(scope.configPath); + const remainingPluginConfigs = config.plugins + .filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)) + .filter((plugin) => existsSync(join(scope.pluginsDir, plugin.name))); + const installedPlugins = await loadInstalledPlugins(scope.pluginsDir, remainingPluginConfigs); + await prunePluginOutputs(config.agents, installedPlugins.plugins, scope.root, managedPluginRoots); + } + + await updateProjectGitignore(scope); +} + async function updateProjectGitignore(scope: ScopeRoot): Promise { if (scope.scope !== "project") {return;} const config = await loadConfig(scope.configPath); @@ -162,10 +286,41 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { managedSubagentNames.add(name); } } + const managedPluginNames = new Set( + config.plugins + .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); + } + } + } + const installedPlugins = await loadInstalledPlugins( + scope.pluginsDir, + config.plugins.filter((plugin) => !isSameProjectPluginConfig(plugin, scope.pluginsDir, scope.root)), + ); await writeAgentsGitignore( scope.agentsDir, - managedNames, + [ + ...managedNames, + ...await filterManagedPluginSkillNames( + await projectedPiSkillNames(config.agents, installedPlugins.plugins), + config, + scope.skillsDir, + scope.pluginsDir, + ), + ], [...managedSubagentNames], + [...managedPluginNames], ); } @@ -203,8 +358,13 @@ export default async function remove(args: string[], flags?: { user?: boolean }) await ensureUserScopeBootstrapped(scope); try { - await runRemove({ scope, skillName: arg }); - console.log(chalk.green(`Removed skill: ${arg}`)); + const before = await loadConfig(scope.configPath); + const kind = before.plugins.some((plugin) => plugin.name === arg) && + !before.skills.some((skill) => skill.name === arg) + ? "plugin" + : "skill"; + await runRemove({ scope, name: arg }); + console.log(chalk.green(`Removed ${kind}: ${arg}`)); } catch (err) { if (err instanceof WildcardSkillRemoveError) { console.log(chalk.yellow(err.message)); @@ -235,13 +395,30 @@ export default async function remove(args: string[], flags?: { user?: boolean }) const isSource = isExplicitSourceSpecifier(arg) || parseOwnerRepoShorthand(arg) !== undefined; if (!isSource) {throw err;} - // It's a valid source specifier — preview, confirm, then remove - const skillNames = await collectSkillsFromSource(scope, arg); + // It's a valid source specifier — preview, confirm, then remove. + let skillNames: string[] = []; + let pluginNames: string[] = []; + try { + skillNames = await collectSkillsFromSource(scope, arg); + } catch (sourceErr) { + if (!(sourceErr instanceof RemoveError)) {throw sourceErr;} + } + try { + pluginNames = await collectPluginsFromSource(scope, arg); + } catch (sourceErr) { + if (!(sourceErr instanceof RemoveError)) {throw sourceErr;} + } + if (skillNames.length === 0 && pluginNames.length === 0) { + throw new RemoveError(`No skills or plugins found from source "${arg}".`); + } if (!skipConfirm) { - const names = skillNames.join(", "); + const names = [ + ...skillNames.map((name) => `skill:${name}`), + ...pluginNames.map((name) => `plugin:${name}`), + ].join(", "); const confirmed = await promptYesNo( - `Will remove ${skillNames.length} skill(s) from "${arg}": ${names}\nContinue? (y/N) `, + `Will remove ${skillNames.length} skill(s) and ${pluginNames.length} plugin(s) from "${arg}": ${names}\nContinue? (y/N) `, ); if (!confirmed) { console.log("Aborted."); @@ -249,8 +426,17 @@ export default async function remove(args: string[], flags?: { user?: boolean }) } } - const removed = await runRemoveSource({ scope, source: arg }); - console.log(chalk.green(`Removed ${removed.length} skill(s) from "${arg}": ${removed.join(", ")}`)); + const removedSkills = skillNames.length > 0 + ? await runRemoveSource({ scope, source: arg }) + : []; + const removedPlugins = pluginNames.length > 0 + ? await runRemovePluginSource({ scope, source: arg }) + : []; + const summary = [ + removedSkills.length > 0 ? `${removedSkills.length} skill(s): ${removedSkills.join(", ")}` : undefined, + removedPlugins.length > 0 ? `${removedPlugins.length} plugin(s): ${removedPlugins.join(", ")}` : undefined, + ].filter(Boolean).join("; "); + console.log(chalk.green(`Removed from "${arg}": ${summary}`)); } } catch (err) { if (err instanceof ScopeError || err instanceof RemoveError) { diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 54da37d..e6114be 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -224,9 +224,9 @@ describe("runSync", () => { await exec("git", ["clone", "--branch", "main", projectOrigin, bobRepo], { cwd: tmpDir }); await exec("git", ["config", "user.email", "alice@example.com"], { cwd: aliceRepo }); await exec("git", ["config", "user.name", "Alice"], { cwd: aliceRepo }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: aliceRepo }); await exec("git", ["config", "user.email", "bob@example.com"], { cwd: bobRepo }); await exec("git", ["config", "user.name", "Bob"], { cwd: bobRepo }); - await exec("git", ["config", "commit.gpgsign", "false"], { cwd: aliceRepo }); await exec("git", ["config", "commit.gpgsign", "false"], { cwd: bobRepo }); try { @@ -240,7 +240,7 @@ describe("runSync", () => { expect(existsSync(bobSkillDir)).toBe(true); process.env["DOTAGENTS_STATE_DIR"] = aliceStateDir; - await runRemove({ scope: resolveScope("project", aliceRepo), skillName: "pdf" }); + await runRemove({ scope: resolveScope("project", aliceRepo), name: "pdf" }); await exec("git", ["add", "agents.toml"], { cwd: aliceRepo }); await exec("git", ["commit", "-m", "remove pdf"], { cwd: aliceRepo }); await exec("git", ["push", "origin", "main"], { cwd: aliceRepo }); @@ -271,7 +271,7 @@ describe("runSync", () => { process.env["DOTAGENTS_STATE_DIR"] = previousStateDir; } } - }, 30_000); + }, 90_000); it("detects missing skills", async () => { await writeFile( @@ -285,6 +285,27 @@ describe("runSync", () => { expect(missingIssues[0]!.name).toBe("pdf"); }); + it("detects missing plugins as errors", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[plugins]] +name = "review-tools" +source = "path:plugin-source/review-tools" +`, + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + expect(result.issues).toEqual([ + { + type: "missing", + name: "review-tools", + message: `Plugin "review-tools" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, + }, + ]); + }); + it("reports no issues when everything is in sync", async () => { await writeFile( join(projectRoot, "agents.toml"), @@ -359,7 +380,32 @@ describe("runSync", () => { join(projectRoot, ".agents", ".gitignore"), "utf-8", ); - expect(gitignore).toContain("/skills/pdf/"); + 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 () => { @@ -621,6 +667,160 @@ 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([]); + 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.', + }); + 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 }); + await mkdir(join(projectRoot, ".agents", "plugins", "local-unmanaged"), { recursive: true }); + await writeFile( + join(projectRoot, ".agents", "plugins", "local-unmanaged", "plugin.json"), + JSON.stringify({ name: "local-unmanaged" }, null, 2), + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "stale-managed": { + source: "getsentry/plugins", + resolved_url: "https://github.com/getsentry/plugins.git", + resolved_path: "stale-managed", + }, + }, + }); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.pluginsRepaired).toBe(1); + expect(existsSync(join(projectRoot, ".agents", "plugins", "stale-managed"))).toBe(false); + expect(existsSync(join(projectRoot, ".agents", "plugins", "local-unmanaged"))).toBe(true); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.plugins).toEqual({}); + }); + it("does not auto-create root .gitignore", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 21c8ce1..55a4bac 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"; @@ -15,13 +16,15 @@ import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolve import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../targets/hook-writer.js"; import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../subagents/writer.js"; import { loadInstalledSubagents, pruneInstalledSubagents } from "../../subagents/store.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins, pruneInstalledPlugins } from "../../plugins/store.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"; 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 +42,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 +71,34 @@ 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 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({ + 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.`, + }); + } + 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)) { @@ -100,6 +132,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 +149,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 +184,34 @@ export async function runSync(opts: SyncOptions): Promise { managedSubagentNames.add(name); } } + const managedPluginNames = new Set(config.plugins + .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); + } + } + } + const installedPluginsForGitignore = await loadInstalledPlugins( + pluginsDir, + runtimePluginConfigs.filter((plugin) => existsSync(join(pluginsDir, plugin.name))), + ); await writeAgentsGitignore( agentsDir, - managedNames, + [ + ...managedNames, + ...await filterManagedPluginSkillNames( + await projectedPiSkillNames(config.agents, installedPluginsForGitignore.plugins), + config, + skillsDir, + pluginsDir, + ), + ], [...managedSubagentNames], + [...managedPluginNames], ); gitignoreUpdated = true; @@ -161,6 +232,15 @@ export async function runSync(opts: SyncOptions): Promise { }); } } + for (const plugin of runtimePluginConfigs) { + if (!existsSync(join(pluginsDir, plugin.name))) { + issues.push({ + type: "missing", + 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 +382,49 @@ export async function runSync(opts: SyncOptions): Promise { } } + // 8. Verify and repair plugin runtime projections + let pluginsRepaired = 0; + const installedPluginConfigs = runtimePluginConfigs.filter((plugin) => existsSync(join(pluginsDir, plugin.name))); + const installedPluginResult = await loadInstalledPlugins(pluginsDir, installedPluginConfigs); + const pluginDecls = installedPluginResult.plugins; + const prunedInstalledPlugins = await pruneInstalledPlugins(pluginsDir, staleManagedPluginNames); + 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, + staleManagedPluginNames.map((name) => join(pluginsDir, name)), + ); + pluginsRepaired = pluginResult.written + prunedPluginOutputs.length + prunedInstalledPlugins.length; + pluginIssues = await verifyPluginOutputs(config.agents, pluginDecls, scope.root); + + 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,6 +434,7 @@ export async function runSync(opts: SyncOptions): Promise { mcpRepaired, hooksRepaired, subagentsRepaired, + pluginsRepaired, }; } @@ -364,6 +488,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 +502,7 @@ export default async function sync(_args: string[], flags?: { user?: boolean }): case "mcp": case "hooks": case "subagents": + case "plugins": console.log(chalk.yellow(` warn: ${issue.message}`)); break; case "missing": diff --git a/packages/dotagents/src/cli/commands/trust.test.ts b/packages/dotagents/src/cli/commands/trust.test.ts index 6ee0aad..bd20dd7 100644 --- a/packages/dotagents/src/cli/commands/trust.test.ts +++ b/packages/dotagents/src/cli/commands/trust.test.ts @@ -33,6 +33,7 @@ describe("trust", () => { root: projectRoot, agentsDir: join(projectRoot, ".agents"), skillsDir: join(projectRoot, ".agents", "skills"), + pluginsDir: join(projectRoot, ".agents", "plugins"), configPath: join(projectRoot, "agents.toml"), lockPath: join(projectRoot, "agents.lock"), }; diff --git a/packages/dotagents/src/cli/ensure-user-scope.test.ts b/packages/dotagents/src/cli/ensure-user-scope.test.ts index fc0ffcb..769bf29 100644 --- a/packages/dotagents/src/cli/ensure-user-scope.test.ts +++ b/packages/dotagents/src/cli/ensure-user-scope.test.ts @@ -28,6 +28,7 @@ describe("ensureUserScopeBootstrapped", () => { configPath: join(home, "agents.toml"), lockPath: join(home, "agents.lock"), skillsDir: join(home, "skills"), + pluginsDir: join(home, "plugins"), }; } @@ -82,6 +83,7 @@ describe("ensureUserScopeBootstrapped", () => { configPath: join(tmpDir, "agents.toml"), lockPath: join(tmpDir, "agents.lock"), skillsDir: join(tmpDir, ".agents", "skills"), + pluginsDir: join(tmpDir, ".agents", "plugins"), }; await ensureUserScopeBootstrapped(scope); diff --git a/packages/dotagents/src/cli/index.ts b/packages/dotagents/src/cli/index.ts index 84cb7ee..e180caf 100644 --- a/packages/dotagents/src/cli/index.ts +++ b/packages/dotagents/src/cli/index.ts @@ -30,9 +30,9 @@ Commands: init Initialize agents.toml and .agents/skills/ install Install dependencies from agents.toml add Add a skill dependency - remove Remove a skill or all skills from a source + remove Remove a skill, plugin, or source sync Reconcile state offline and repair generated config - list Show installed skills + list Show declared skills and plugins mcp Manage MCP server declarations trust Manage trusted sources doctor Check project health and fix issues diff --git a/packages/dotagents/src/config/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/config/loader.test.ts b/packages/dotagents/src/config/loader.test.ts index 8ab52e0..a6ea1fd 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", "pi"] + +[[plugins]] +name = "review-tools" +source = "getsentry/plugins" +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", "pi"]); + expect(config.plugins[0]!.source).toBe("getsentry/plugins"); + }); + + it("rejects unknown plugin targets", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[plugins]] +name = "review-tools" +source = "getsentry/plugins" +targets = ["emacs"] +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/Unknown plugin target/); + }); + + it("rejects duplicate plugin names", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[plugins]] +name = "review-tools" +source = "getsentry/plugins" + +[[plugins]] +name = "review-tools" +source = "getsentry/plugins" +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/Duplicate plugin/); + }); + + it("rejects HTTPS well-known plugin sources", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[plugins]] +name = "review-tools" +source = "https://plugins.example.com" +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/unsupported HTTPS well-known source/); + }); }); diff --git a/packages/dotagents/src/config/loader.ts b/packages/dotagents/src/config/loader.ts index 8385721..6d63bfc 100644 --- a/packages/dotagents/src/config/loader.ts +++ b/packages/dotagents/src/config/loader.ts @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"; import { parse as parseTOML } from "smol-toml"; import { agentsConfigSchema, isWildcardDep, type AgentsConfig } from "./schema.js"; import { allAgentIds } from "../targets/registry.js"; +import { allPluginOnlyAgentIds } from "../plugins/targets.js"; import { applyDefaultRepositorySource, parseSource } from "@sentry/dotagents-lib"; export class ConfigError extends Error { @@ -36,7 +37,8 @@ export async function loadConfig(filePath: string): Promise { } // Post-parse validation: reject unknown agent IDs - const validIds = allAgentIds(); + 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( @@ -47,13 +49,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 +84,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 +120,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..07b9d63 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", "pi"], + plugins: [ + { + name: "review-tools", + source: "getsentry/plugins", + targets: ["claude", "codex", "cursor", "grok", "opencode", "pi"], + }, + ], + }); + 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/config/writer.ts b/packages/dotagents/src/config/writer.ts index 74c2fc5..a024538 100644 --- a/packages/dotagents/src/config/writer.ts +++ b/packages/dotagents/src/config/writer.ts @@ -48,6 +48,15 @@ export async function removeSkillFromConfig( await writeFile(filePath, removeBlockByHeader(content, "[[skills]]", name), "utf-8"); } +/** Removes a plugin entry from agents.toml by name. */ +export async function removePluginFromConfig( + filePath: string, + name: string, +): Promise { + const content = await readFile(filePath, "utf-8"); + await writeFile(filePath, removeBlockByHeader(content, "[[plugins]]", name), "utf-8"); +} + /** * Remove all [[skills]] blocks whose source matches the given source. * Handles both explicit and wildcard entries. @@ -55,6 +64,23 @@ export async function removeSkillFromConfig( export async function removeSkillBlocksBySource( filePath: string, source: string, +): Promise { + await removeBlocksBySource(filePath, "[[skills]]", source); +} + +/** Removes all plugin blocks whose source matches the given source. */ +export async function removePluginBlocksBySource( + filePath: string, + source: string, +): Promise { + await removeBlocksBySource(filePath, "[[plugins]]", source); +} + +/** Removes array-of-table blocks for a header when their `source` matches. */ +async function removeBlocksBySource( + filePath: string, + header: string, + source: string, ): Promise { const content = await readFile(filePath, "utf-8"); const lines = content.split("\n"); @@ -62,7 +88,7 @@ export async function removeSkillBlocksBySource( let i = 0; while (i < lines.length) { - if (lines[i]!.trim() === "[[skills]]") { + if (lines[i]!.trim() === header) { const headerLine = lines[i]!; i++; const { blockLines, nextIndex } = collectBlockLines(lines, i); diff --git a/packages/dotagents/src/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/packages/dotagents/src/gitignore/writer.test.ts b/packages/dotagents/src/gitignore/writer.test.ts index 153e56b..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 () => { @@ -44,6 +44,15 @@ describe("writeAgentsGitignore", () => { expect(content).toContain("/agents/test-runner.md"); }); + 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).not.toContain("/plugins/marketplace.json"); + }); + it("sorts skill names alphabetically", async () => { const agentsDir = join(dir, ".agents"); await writeAgentsGitignore(agentsDir, ["zebra", "alpha", "middle"]); @@ -51,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 8323902..0d46283 100644 --- a/packages/dotagents/src/gitignore/writer.ts +++ b/packages/dotagents/src/gitignore/writer.ts @@ -13,14 +13,18 @@ export async function writeAgentsGitignore( agentsDir: string, managedSkillNames: string[], managedSubagentNames: string[] = [], + managedPluginNames: string[] = [], ): 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`); } + for (const name of managedPluginNames.toSorted()) { + lines.push(`/plugins/${name}/`); + } lines.push(""); // trailing newline await writeFile(join(agentsDir, ".gitignore"), lines.join("\n"), "utf-8"); diff --git a/packages/dotagents/src/index.test.ts b/packages/dotagents/src/index.test.ts index ca0c551..b83f633 100644 --- a/packages/dotagents/src/index.test.ts +++ b/packages/dotagents/src/index.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, expectTypeOf, beforeEach, afterEach } from "vitest"; import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { discoverSkill, discoverAllSkills } from "./index.js"; +import { discoverSkill, discoverAllSkills, type PluginConfig, type LockedPlugin } from "./index.js"; const SKILL_MD = (name: string) => `---\nname: ${name}\ndescription: ${name} skill\n---\n`; @@ -56,4 +56,9 @@ describe("host re-exports preserve dotagents scan dirs", () => { const names = results.map((r) => r.meta.name).toSorted(); expect(names).toEqual(["commit", "lint"]); }); + + it("exports public plugin types", () => { + expectTypeOf().toMatchTypeOf<{ name: string; source: string }>(); + expectTypeOf().toMatchTypeOf<{ source: string }>(); + }); }); diff --git a/packages/dotagents/src/index.ts b/packages/dotagents/src/index.ts index b32c1d4..c31fd8f 100644 --- a/packages/dotagents/src/index.ts +++ b/packages/dotagents/src/index.ts @@ -7,6 +7,7 @@ export type { SkillSource, McpConfig, SubagentConfig, + PluginConfig, TrustConfig, } from "./config/index.js"; @@ -34,7 +35,7 @@ export { writeAgentsGitignore, ensureRootGitignoreEntries } from "./gitignore/in export { ensureSkillsSymlink, verifySymlinks } from "./symlinks/index.js"; export { lockfileSchema, loadLockfile, LockfileError, writeLockfile } from "./lockfile/index.js"; -export type { Lockfile, LockedSkill, LockedSubagent } from "./lockfile/index.js"; +export type { Lockfile, LockedSkill, LockedSubagent, LockedPlugin } from "./lockfile/index.js"; // --------------------------------------------------------------------------- // Re-exports from @sentry/dotagents-lib. diff --git a/packages/dotagents/src/lockfile/index.ts b/packages/dotagents/src/lockfile/index.ts index cd4d865..6ed43f7 100644 --- a/packages/dotagents/src/lockfile/index.ts +++ b/packages/dotagents/src/lockfile/index.ts @@ -1,4 +1,4 @@ export { lockfileSchema } from "./schema.js"; -export type { Lockfile, LockedSkill, LockedSubagent } from "./schema.js"; +export type { Lockfile, LockedSkill, LockedSubagent, LockedPlugin } from "./schema.js"; export { loadLockfile, LockfileError } from "./loader.js"; export { writeLockfile } from "./writer.js"; diff --git a/packages/dotagents/src/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/plugins/runtime/component-paths.ts b/packages/dotagents/src/plugins/runtime/component-paths.ts new file mode 100644 index 0000000..ef56c11 --- /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 (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/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..9c5b18d --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/manifests.ts @@ -0,0 +1,275 @@ +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"; +import { isSafeComponentPath } from "./component-paths.js"; + +type ComponentManifestKey = + | "skills" + | "agents" + | "commands" + | "rules" + | "hooks" + | "mcpServers" + | "lspServers" + | "apps" + | "monitors" + | "bin"; + +const COMPONENT_KEYS: ComponentManifestKey[] = [ + "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, + 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, warnings); + 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, warnings); + 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, warnings); + return writeJsonIfChanged(filePath, stableJson(manifest)); +} + +/** Builds the managed Claude manifest projection using Claude-native paths. */ +function claudeRuntimeManifest(plugin: PluginDeclaration, warnings: PluginWriteWarning[]): 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, "skills", warnings) && existsSync(join(plugin.pluginDir, "skills"))) { + manifest["skills"] = "./skills"; + } + if (!copyRuntimeComponentField(plugin, manifest, "commands", warnings) && existsSync(join(plugin.pluginDir, "commands"))) { + manifest["commands"] = "./commands"; + } + if (!copyRuntimeComponentField(plugin, manifest, "hooks", warnings) && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { + manifest["hooks"] = "./hooks/hooks.json"; + } + if (!copyRuntimeComponentField(plugin, manifest, "mcpServers", warnings) && existsSync(join(plugin.pluginDir, ".mcp.json"))) { + manifest["mcpServers"] = "./.mcp.json"; + } + copyRuntimeComponentField(plugin, manifest, "lspServers", warnings); + copyRuntimeComponentField(plugin, manifest, "monitors", warnings); + copyRuntimeComponentField(plugin, manifest, "bin", warnings); + 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, warnings: PluginWriteWarning[]): 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, "skills", warnings) && existsSync(join(plugin.pluginDir, "skills"))) { + manifest["skills"] = "./skills"; + } + if (!copyRuntimeComponentField(plugin, manifest, "agents", warnings) && existsSync(join(plugin.pluginDir, "agents"))) { + manifest["agents"] = "./agents"; + } + if (!copyRuntimeComponentField(plugin, manifest, "commands", warnings) && existsSync(join(plugin.pluginDir, "commands"))) { + manifest["commands"] = "./commands"; + } + if (!copyRuntimeComponentField(plugin, manifest, "rules", warnings) && existsSync(join(plugin.pluginDir, "rules"))) { + manifest["rules"] = "./rules"; + } + if (!copyRuntimeComponentField(plugin, manifest, "hooks", warnings) && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { + manifest["hooks"] = "./hooks/hooks.json"; + } + const hasExplicitMcpServers = copyRuntimeComponentField(plugin, manifest, "mcpServers", warnings); + 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, "bin", warnings); + 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, 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 (plugin.manifest["skills"] === undefined && !manifest["skills"] && existsSync(join(plugin.pluginDir, "skills"))) { + manifest["skills"] = "./skills"; + } + if (plugin.manifest["agents"] === undefined && !manifest["agents"] && existsSync(join(plugin.pluginDir, "agents"))) { + manifest["agents"] = "./agents"; + } + if (plugin.manifest["commands"] === undefined && !manifest["commands"] && existsSync(join(plugin.pluginDir, "commands"))) { + manifest["commands"] = "./commands"; + } + if (plugin.manifest["hooks"] === undefined && !manifest["hooks"] && existsSync(join(plugin.pluginDir, "hooks", "hooks.json"))) { + manifest["hooks"] = "./hooks/hooks.json"; + } + if (plugin.manifest["mcpServers"] === undefined && !manifest["mcpServers"] && existsSync(join(plugin.pluginDir, ".mcp.json"))) { + manifest["mcpServers"] = "./.mcp.json"; + } + if (plugin.manifest["lspServers"] === undefined && !manifest["lspServers"] && existsSync(join(plugin.pluginDir, ".lsp.json"))) { + manifest["lspServers"] = "./.lsp.json"; + } + if (plugin.manifest["apps"] === undefined && !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( + plugin: PluginDeclaration, + dest: Record, + key: ComponentManifestKey, + warnings: PluginWriteWarning[], +): boolean { + const value = plugin.manifest[key]; + if (typeof value === "string") { + if (isSafeComponentPath(value)) { + dest[key] = runtimePath(value); + } else { + warnUnsafeComponentPath(plugin, key, value, warnings); + } + return true; + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + const paths = value.flatMap((item) => { + if (isSafeComponentPath(item)) {return [runtimePath(item)];} + warnUnsafeComponentPath(plugin, key, item, warnings); + return []; + }); + if (paths.length > 0) {dest[key] = paths;} + return true; + } + return false; +} + +function warnUnsafeComponentPath( + plugin: PluginDeclaration, + key: ComponentManifestKey, + 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/marketplace.ts b/packages/dotagents/src/plugins/runtime/marketplace.ts new file mode 100644 index 0000000..194cbe3 --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/marketplace.ts @@ -0,0 +1,131 @@ +import { dirname, 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) { + const filePath = join(projectRoot, ".claude-plugin", "marketplace.json"); + outputs.push({ + agent: "claude", + filePath, + content: stableJson(pathMarketplace(filePath, "dotagents", claudePlugins)), + }); + } + if (cursorPlugins.length > 0) { + const filePath = join(projectRoot, ".cursor-plugin", "marketplace.json"); + outputs.push({ + agent: "cursor", + filePath, + content: stableJson(pathMarketplace(filePath, "dotagents", cursorPlugins)), + }); + } + if (codexPlugins.length > 0) { + const filePath = join(projectRoot, ".agents", "plugins", "marketplace.json"); + outputs.push({ + agent: "codex", + filePath, + content: stableJson(codexMarketplace(filePath, "dotagents-local", codexPlugins)), + }); + } + + return outputs; +} + +function pathMarketplace( + marketplaceFile: 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(marketplaceFile, 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( + marketplaceFile: string, + plugin: PluginDeclaration, +): Record { + const entry: Record = { + name: plugin.name, + source: relativePath(dirname(marketplaceFile), 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( + marketplaceFile: 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(marketplaceFile, plugin)), + }; +} + +function codexMarketplaceEntry( + marketplaceFile: string, + plugin: PluginDeclaration, +): Record { + const entry: Record = { + category: manifestString(plugin.manifest, "category") ?? "Productivity", + name: plugin.name, + source: { + path: relativePath(dirname(marketplaceFile), 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/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts new file mode 100644 index 0000000..28d3fa8 --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -0,0 +1,610 @@ +import { existsSync } from "node:fs"; +import { lstat, mkdtemp, mkdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { PluginDeclaration } from "../store.js"; +import { + prunePluginOutputs, + projectedPiSkillNames, + verifyPluginOutputs, + writePluginOutputs, +} from "./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 }); + await mkdir(join(pluginDir, "agents"), { 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, + }; + } + + 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"); + + const result = await writePluginOutputs( + ["cursor", "codex", "claude"], + [beta, alpha], + root, + ); + + expect(result.warnings).toEqual([]); + 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: { + 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: "./alpha-tools", + source: "local", + }, + version: "1.0.0", + }, + { + category: "Coding", + description: "Tools for beta-tools", + name: "beta-tools", + source: { + path: "./beta-tools", + source: "local", + }, + version: "1.0.0", + }, + ], + }); + const codexPlugin = (codexMarketplace["plugins"] as Array<{ source: { path: string } }>)[0]!; + expect(resolve(join(root, ".agents", "plugins"), codexPlugin["source"].path)).toBe(alpha.pluginDir); + + const claudeMarketplaceJson = await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8"); + expect(claudeMarketplaceJson).toBe(`{ + "metadata": { + "managedBy": "dotagents" + }, + "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" + } + ] +} +`); + const claudeMarketplace = JSON.parse(claudeMarketplaceJson) as { plugins: Array<{ source: string }> }; + expect(resolve(join(root, ".claude-plugin"), claudeMarketplace["plugins"][0]!["source"])).toBe(alpha.pluginDir); + expect(await readFile(join(root, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(claudeMarketplaceJson); + + const claudeManifest = JSON.parse(await readFile(join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"), "utf-8")) as Record; + expect(claudeManifest["skills"]).toBe("./skills"); + expect(claudeManifest["commands"]).toBe("./commands"); + expect(claudeManifest["agents"]).toBeUndefined(); + 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["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", + developerName: "Sentry", + displayName: "Alpha Tools", + shortDescription: "Tools for alpha-tools", + }); + + expect(await verifyPluginOutputs(["cursor", "codex", "claude"], [beta, alpha], root)).toEqual([]); + }); + + it("projects explicit runtime 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"]).toBeUndefined(); + 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("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("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 }); + await writeFile(join(root, ".claude-plugin", "marketplace.json"), "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs(["claude"], [alpha], root); + + expect(result.written).toBe(1); + 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"); + 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.each([ + ["codex", "Codex", ".codex-plugin"], + ["claude", "Claude", ".claude-plugin"], + ["cursor", "Cursor", ".cursor-plugin"], + ] as const)("does not overwrite unmanaged %s plugin manifests", async (agent, label, manifestDir) => { + const alpha = await plugin("alpha-tools"); + const manifestPath = join(alpha.pluginDir, manifestDir, "plugin.json"); + await mkdir(join(alpha.pluginDir, manifestDir), { recursive: true }); + await writeFile(manifestPath, "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs([agent], [alpha], root); + + expect(result.warnings).toEqual([ + { + agent, + name: "alpha-tools", + message: `${label} plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", manifestDir, "plugin.json")}`, + }, + ]); + expect(await readFile(manifestPath, "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + }); + + it("does not generate runtime outputs when no agent targets are selected", async () => { + const alpha = await plugin("alpha-tools"); + + 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("projects plugin skills and agents into OpenCode native locations", 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", + ); + + const result = await writePluginOutputs(["opencode"], [alpha], root); + + expect(result.warnings).toEqual([]); + 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("projects explicit plugin component paths into OpenCode native locations", async () => { + const alpha = await plugin("alpha-tools", { + manifest: { skills: "components/skills", agents: "components/agents" }, + }); + 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); + + expect(result.warnings).toEqual([]); + 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("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("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"); + 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); + + expect(result).toEqual({ + written: 0, + warnings: [ + { + agent: "opencode", + name: "alpha-tools", + message: `OpenCode plugin skill projection exists and is not managed by dotagents: ${join(root, ".opencode", "skills", "plugin-qa")}`, + }, + ], + }); + }); + + 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 result = await writePluginOutputs(["opencode"], [alpha], root); + + expect(result.warnings).toEqual([]); + expect(result.written).toBe(1); + await expectSymlinkTarget( + join(root, ".opencode", "skills", "plugin-qa"), + join(alpha.pluginDir, "skills", "plugin-qa"), + ); + }); + + 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.', + }, + ], + }); + 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 writePluginOutputs(["claude", "cursor", "codex", "grok", "opencode", "pi"], [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", "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"), + ]); + 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", "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); + }); + + it("does not prune arbitrary component links into canonical plugin sources", async () => { + const localPlugin = join(root, ".agents", "plugins", "local-tools"); + await mkdir(join(localPlugin, "skills", "local-qa"), { recursive: true }); + await mkdir(join(root, ".opencode", "skills"), { recursive: true }); + await symlink( + relative(join(root, ".opencode", "skills"), join(localPlugin, "skills", "local-qa")), + join(root, ".opencode", "skills", "local-qa"), + ); + + const pruned = await prunePluginOutputs([], [], root); + + expect(pruned).toEqual([]); + await expectSymlinkTarget( + join(root, ".opencode", "skills", "local-qa"), + join(localPlugin, "skills", "local-qa"), + ); + }); + + it("does not rewrite unchanged managed Grok projections", async () => { + const alpha = await plugin("alpha-tools"); + + const first = await writePluginOutputs(["grok"], [alpha], root); + const second = await writePluginOutputs(["grok"], [alpha], root); + + 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/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts new file mode 100644 index 0000000..fb8a28a --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -0,0 +1,682 @@ +import { existsSync } from "node:fs"; +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"; +import { + type PluginVerifyIssue, + type PluginWriteResult, + type PluginWriteWarning, + type RuntimeOutput, +} from "./types.js"; +import { + isManagedJsonFile, + isNotFoundError, + 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. +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]+)*$/; +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( + 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)) { + if (SKILL_NAME_PATTERN.test(name)) {names.add(name);} + } + } + } + return [...names]; +} + +/** 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++; + } + } + + written += await writeComponentProjections("opencode", agentIds, selected, projectRoot, warnings); + written += await writeComponentProjections("pi", agentIds, selected, projectRoot, 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}` }); + } + } + } + + 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; +} + +/** Removes stale dotagents-managed plugin runtime artifacts. + * + * `extraManagedPluginRoots` lets callers prune component symlinks for plugin + * bundles that were just removed from the canonical install tree. + */ +export async function prunePluginOutputs( + agentIds: string[], + plugins: PluginDeclaration[], + projectRoot: string, + extraManagedPluginRoots: string[] = [], +): Promise { + const pruned: string[] = []; + const managedPluginRoots = [ + ...plugins.map((plugin) => plugin.pluginDir), + ...extraManagedPluginRoots, + ]; + const desiredMarketplacePaths = new Set( + marketplaceOutputs(agentIds, projectRoot, plugins).map((output) => output.filePath), + ); + 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 desiredOpenCodeLinks = new Set( + (await desiredComponentLinks("opencode", agentIds, plugins, projectRoot, [])).map((link) => link.destPath), + ); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".opencode", "skills"), desiredOpenCodeLinks, managedPluginRoots)); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".opencode", "agents"), desiredOpenCodeLinks, managedPluginRoots)); + + const desiredPiLinks = new Set( + (await desiredComponentLinks("pi", agentIds, plugins, projectRoot, [])).map((link) => link.destPath), + ); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".agents", "skills"), desiredPiLinks, managedPluginRoots)); + + 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; +} + +async function writeComponentProjections( + agent: ComponentProjectionAgent, + agentIds: string[], + plugins: PluginDeclaration[], + projectRoot: string, + warnings: PluginWriteWarning[], +): Promise { + let written = 0; + 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", agent, warnings)) { + 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", agent, warnings)) { + 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, + name: plugin.name, + message: `Plugin skill is invalid for ${displayName(agent)} projection: ${err instanceof Error ? err.message : String(err)}`, + }); + continue; + } + + 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; + } + 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, + 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 names; +} + +async function markdownComponentLinks( + agent: ComponentProjectionAgent, + plugin: PluginDeclaration, + 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, + sourcePath: join(agentsDir, entry.name), + destPath: join(destRoot, entry.name), + }); + } + + return links; +} + +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 }); + } + + 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, + agent?: ComponentProjectionAgent, + warnings: PluginWriteWarning[] = [], +): string[] { + 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, + plugin: PluginDeclaration, + manifestKey: keyof Pick, + 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: collect([value]), + }; + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return { + present: true, + paths: collect(value), + }; + } + return { present: false, paths: [] }; +} + +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 legacy OpenCode JS projections from earlier dotagents plugin support. */ +/** Prunes stale component symlinks whose targets resolve under managed plugin roots. */ +async function pruneManagedComponentLinks( + dir: string, + desiredPaths: Set, + managedPluginRoots: 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, managedPluginRoots)) {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, managedRoot: string | string[]): Promise { + try { + const stat = await lstat(filePath); + if (!stat.isSymbolicLink()) {return false;} + const target = resolve(dirname(filePath), await readlink(filePath)); + const roots = Array.isArray(managedRoot) + ? managedRoot + : [join(managedRoot, ".agents", "plugins")]; + return roots.some((root) => isInside(target, root)); + } catch { + return false; + } +} + +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;} + + 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/plugins/schema.test.ts b/packages/dotagents/src/plugins/schema.test.ts new file mode 100644 index 0000000..2f51c2c --- /dev/null +++ b/packages/dotagents/src/plugins/schema.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { + parsePluginManifest, + parsePluginMarketplace, + pluginManifestSchema, + pluginMarketplaceSchema, +} from "./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"], + "x-runtime": { + plugins: ["runtime/plugin.ts"], + runtime: "bun", + }, + "x-dotagents": { + stable: true, + }, + }, + "plugin.json", + ); + + expect(manifest.name).toBe("review-tools"); + expect(manifest["x-runtime"]).toEqual({ + plugins: ["runtime/plugin.ts"], + runtime: "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({ skills: "https://example.com/skills" }).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("accepts marketplace-file-relative local entries", () => { + const marketplace = parsePluginMarketplace( + { + name: "dotagents", + plugins: [ + { + name: "review-tools", + source: "../.agents/plugins/review-tools", + }, + { + name: "codex-tools", + source: { + source: "local", + path: "./codex-tools", + }, + }, + ], + }, + "marketplace.json", + ); + + expect(marketplace.plugins[0]!.source).toBe("../.agents/plugins/review-tools"); + expect(marketplace.plugins[1]!.source).toEqual({ + source: "local", + path: "./codex-tools", + }); + }); + + it("accepts unsupported extension source objects without local paths", () => { + const marketplace = parsePluginMarketplace( + { + name: "dotagents", + plugins: [ + { + name: "external-tools", + source: { + source: "github", + repo: "org/external-tools", + }, + }, + ], + }, + "marketplace.json", + ); + + expect(marketplace.plugins[0]!.source).toEqual({ + source: "github", + repo: "org/external-tools", + }); + }); + + it("rejects unsafe marketplace paths", () => { + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: "https://example.com/plugin.git" }], + }).success).toBe(false); + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: { source: "github", path: "../outside" } }], + }).success).toBe(false); + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + metadata: { pluginRoot: "../plugins" }, + plugins: [{ name: "bad", source: "./bad" }], + }).success).toBe(false); + }); + + it("rejects local marketplace source objects without a path selector", () => { + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: { source: "local" } }], + }).success).toBe(false); + }); +}); diff --git a/packages/dotagents/src/plugins/schema.ts b/packages/dotagents/src/plugins/schema.ts new file mode 100644 index 0000000..0681d97 --- /dev/null +++ b/packages/dotagents/src/plugins/schema.ts @@ -0,0 +1,131 @@ +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 filesystem paths and must not contain '..'"), +); + +const marketplacePathSchema = z.string().check( + z.refine((value) => { + if (value.length === 0) {return false;} + if (value.startsWith("/") || value.startsWith("\\")) {return false;} + if (/^[a-zA-Z]:[\\/]/.test(value)) {return false;} + return !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value); + }, "Marketplace paths must be relative filesystem paths"), +); + +const pluginAuthorSchema = z.object({ + name: z.string().optional(), + email: z.string().optional(), + url: z.string().optional(), +}).passthrough(); + +const pluginPathOrPathsSchema = z.union([ + pluginPathSchema, + z.array(pluginPathSchema), +]); + +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(), +}).passthrough(); + +export type PluginManifest = z.infer; + +const localMarketplaceSourceSchema = z.object({ + source: z.literal("local"), + path: marketplacePathSchema, +}).passthrough(); + +const extensionMarketplaceSourceSchema = z.object({ + source: z.string().optional(), + path: pluginPathSchema.optional(), +}).passthrough().check( + z.refine((value) => value.source !== "local", "Local marketplace sources must include a path"), +); + +export const marketplaceSourceSchema = z.union([ + marketplacePathSchema, + 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/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts new file mode 100644 index 0000000..b4fec7c --- /dev/null +++ b/packages/dotagents/src/plugins/store.test.ts @@ -0,0 +1,386 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, mkdir, readdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { installPluginBundle, isSameProjectPluginConfig, lockEntryForPlugin, resolvePlugin, type ResolvedPlugin } from "./store.js"; + +vi.mock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { + ...actual, + realpath: vi.fn(actual.realpath), + rename: vi.fn(actual.rename), + }; +}); + +describe("plugin store", () => { + afterEach(async () => { + const actual = await vi.importActual("node:fs/promises"); + vi.mocked(realpath).mockImplementation(actual.realpath); + vi.mocked(rename).mockImplementation(actual.rename); + }); + + it("preserves an empty resolved path for root git plugins", () => { + const resolved = { + type: "git", + 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", + }); + }); + + 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("keeps the backup when replacing an installed plugin fails and rollback also fails", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceDir = join(projectRoot, "source", "review-tools"); + const pluginsDir = join(projectRoot, ".agents", "plugins"); + const destDir = join(pluginsDir, "review-tools"); + await mkdir(sourceDir, { recursive: true }); + await mkdir(destDir, { recursive: true }); + await writeFile( + join(sourceDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "New plugin" }), + "utf-8", + ); + await writeFile( + join(destDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Original plugin" }), + "utf-8", + ); + + const actual = await vi.importActual("node:fs/promises"); + vi.mocked(rename).mockImplementation(async (oldPath, newPath) => { + const oldString = String(oldPath); + const newString = String(newPath); + if (oldString.includes(".tmp-") && newString === destDir) { + throw new Error("destination rename failed"); + } + if (oldString.includes(".backup-") && newString === destDir) { + throw new Error("rollback failed"); + } + await actual.rename(oldPath, newPath); + }); + + await expect(installPluginBundle(pluginsDir, { + type: "local", + plugin: { + name: "review-tools", + source: "path:source/review-tools", + pluginDir: sourceDir, + manifest: { name: "review-tools", description: "New plugin" }, + }, + })).rejects.toThrow("destination rename failed"); + + expect(existsSync(destDir)).toBe(false); + const backupName = (await readdir(pluginsDir)).find((name) => name.startsWith(".review-tools.backup-")); + expect(backupName).toBeDefined(); + const backupManifest = JSON.parse(await readFile(join(pluginsDir, backupName!, "plugin.json"), "utf-8")); + expect(backupManifest.description).toBe("Original plugin"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("detects same-project plugins without requiring the explicit source path to exist", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + const pluginsDir = join(projectRoot, ".agents", "plugins"); + 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); + }); + + 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("reports a controlled error when the plugin source root disappears during realpath checks", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const pluginDir = join(sourceRoot, "plugins", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "review-tools" }), + "utf-8", + ); + + const actual = await vi.importActual("node:fs/promises"); + const missingRoot = new Error("missing source root") as NodeJS.ErrnoException; + missingRoot.code = "ENOENT"; + vi.mocked(realpath).mockImplementation(async (path, options) => { + if (String(path) === sourceRoot) { + throw missingRoot; + } + return actual.realpath(path, options); + }); + + let error: unknown; + try { + await resolvePlugin( + { name: "review-tools", source: "path:source", path: "plugins/review-tools" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Plugin path source root does not exist: plugins/review-tools"); + expect((error as Error).cause).toBe(missingRoot); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("rejects explicit plugin path symlinks that escape the source root", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + 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 { + 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 }); + } + }); + + it("resolves marketplace paths relative to nested marketplace files", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const pluginDir = join(sourceRoot, ".agents", "plugins", "marketplace-review-tools"); + await mkdir(join(sourceRoot, ".claude-plugin"), { recursive: true }); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Nested marketplace plugin" }), + "utf-8", + ); + await writeFile( + join(sourceRoot, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + plugins: [ + { + name: "review-tools", + source: "../.agents/plugins/marketplace-review-tools", + }, + ], + }), + "utf-8", + ); + + const fromMarketplace = await resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + expect(fromMarketplace.plugin.pluginDir).toBe(pluginDir); + expect(fromMarketplace.plugin.manifest.description).toBe("Nested marketplace plugin"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("skips unsupported marketplace entries without dropping local entries", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const pluginDir = join(sourceRoot, "marketplace", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Local marketplace plugin" }), + "utf-8", + ); + await writeFile( + join(sourceRoot, "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + plugins: [ + { + name: "review-tools", + source: { + source: "github", + repo: "org/review-tools", + }, + }, + { + name: "review-tools", + source: { source: "local", path: "marketplace/review-tools" }, + }, + ], + }), + "utf-8", + ); + + const fromMarketplace = await resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + expect(fromMarketplace.plugin.pluginDir).toBe(pluginDir); + expect(fromMarketplace.plugin.manifest.description).toBe("Local marketplace plugin"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("rejects marketplace paths that traverse outside the source root", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const outsideDir = join(projectRoot, "outside", "review-tools"); + await mkdir(join(sourceRoot, ".claude-plugin"), { recursive: true }); + await mkdir(outsideDir, { recursive: true }); + await writeFile( + join(outsideDir, "plugin.json"), + JSON.stringify({ name: "review-tools" }), + "utf-8", + ); + await writeFile( + join(sourceRoot, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + plugins: [ + { + name: "review-tools", + source: "../../outside/review-tools", + }, + ], + }), + "utf-8", + ); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow(/Marketplace plugin source resolves outside source/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("rejects conventional plugin discovery symlinks that escape the source root", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const outsideDir = join(projectRoot, "outside", "review-tools"); + await mkdir(join(sourceRoot, "plugins"), { recursive: true }); + await mkdir(outsideDir, { recursive: true }); + await writeFile( + join(outsideDir, "plugin.json"), + JSON.stringify({ name: "review-tools" }), + "utf-8", + ); + await symlink(outsideDir, join(sourceRoot, "plugins", "review-tools")); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow(/Plugin source resolves outside source/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts new file mode 100644 index 0000000..400e317 --- /dev/null +++ b/packages/dotagents/src/plugins/store.ts @@ -0,0 +1,646 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, 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 "./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 interface PluginDeclaration { + name: string; + source: string; + pluginDir: string; + manifest: PluginManifest; + targets?: string[]; +} + +interface ResolvedLocalPlugin { + type: "local"; + plugin: PluginDeclaration; +} + +interface ResolvedGitPlugin { + type: "git"; + resolvedUrl: string; + resolvedPath: string; + resolvedRef?: string; + commit: string; + plugin: PluginDeclaration; +} + +export type ResolvedPlugin = ResolvedLocalPlugin | ResolvedGitPlugin; + +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; + +export const DOTAGENTS_MANAGED_PLUGIN_MARKER = ".dotagents-managed"; + +let tempInstallCounter = 0; + +/** Resolves a plugin declaration to a trusted local or cached git source bundle. */ +export async function resolvePlugin( + config: PluginConfig, + 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", + 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", + 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 }; + const tempDir = join( + pluginsDir, + `.${resolved.plugin.name}.tmp-${process.pid}-${Date.now()}-${tempInstallCounter++}`, + ); + const backupDir = join( + pluginsDir, + `.${resolved.plugin.name}.backup-${process.pid}-${Date.now()}-${tempInstallCounter++}`, + ); + + try { + await copyDir(resolved.plugin.pluginDir, tempDir); + const staged = { ...resolved.plugin, pluginDir: tempDir }; + await ensureCanonicalManifest(staged); + await writeManagedMarker(tempDir); + + if (existsSync(destDir)) { + await rename(destDir, backupDir); + try { + await rename(tempDir, destDir); + } catch (err) { + await rename(backupDir, destDir).catch(() => {}); + throw err; + } + await rm(backupDir, { recursive: true, force: true }); + } else { + await rename(tempDir, destDir); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + + 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; +} + +/** Returns true when an existing plugin bundle was previously installed by dotagents. */ +export function isManagedPluginInstall(pluginDir: string): boolean { + return existsSync(join(pluginDir, DOTAGENTS_MANAGED_PLUGIN_MARKER)); +} + +/** Converts a resolved plugin to its lockfile entry. */ +export function lockEntryForPlugin(resolved: ResolvedPlugin): LockedPlugin { + if (resolved.type === "local") { + return { source: resolved.plugin.source }; + } + return { + source: resolved.plugin.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. */ +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)); +} + +/** 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;} + + try { + const parsed = parseSource(plugin.source); + if (parsed.type !== "local" || !parsed.path) {return false;} + const sourceDir = resolve(projectRoot, parsed.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); + } catch { + return false; + } +} + +async function discoverPlugin( + sourceDir: string, + config: PluginConfig, +): Promise { + if (config.path) { + const dir = await resolveInside(sourceDir, config.path, "Plugin path"); + return loadPluginCandidate(sourceDir, dir, { name: config.name }); + } + + const matches: PluginCandidate[] = []; + 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; + } + + const unsupportedMarketplaceSources: string[] = []; + const fromMarketplace = await discoverFromMarketplaces(sourceDir, config.name, unsupportedMarketplaceSources); + if (fromMarketplace) {return fromMarketplace;} + + for (const dir of [sourceDir, join(sourceDir, "plugins", config.name)]) { + 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 = 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(", ")}`, + ); + } + if (unique.length === 0 && unsupportedMarketplaceSources.length > 0) { + throw new Error( + `Plugin "${config.name}" not found in ${config.source}. Matching marketplace entries use unsupported source types: ${[...new Set(unsupportedMarketplaceSources)].join(", ")}. Only local marketplace sources are supported.`, + ); + } + return unique[0] ?? null; +} + +/** + * 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, + unsupportedSources: string[], +): Promise { + for (const marketplacePath of MARKETPLACE_PATHS) { + const filePath = join(sourceDir, marketplacePath); + if (!existsSync(filePath)) {continue;} + + let marketplace: ReturnType; + try { + marketplace = parsePluginMarketplace(await readJson(filePath), filePath); + } catch { + continue; + } + 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); + if (!path) { + unsupportedSources.push(marketplaceSourceType(entry)); + continue; + } + + const marketplaceRoot = dirname(filePath); + const pluginDir = await resolveMarketplaceSource(sourceDir, marketplaceRoot, join(root, path), "Marketplace plugin source"); + const candidate = await loadPluginCandidate(sourceDir, pluginDir, marketplaceManifestOverlay(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;} + await assertInsideSourceRoot(sourceRoot, pluginDir, "Plugin source"); + + const manifest = await loadManifest(pluginDir); + if (!manifest && !await hasPluginComponents(pluginDir)) {return null;} + + 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 { + dir: pluginDir, + path: relativePath(sourceRoot, pluginDir), + manifest: combined, + }; +} + +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); + 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"); +} + +async function writeManagedMarker(pluginDir: string): Promise { + await writeFile(join(pluginDir, DOTAGENTS_MANAGED_PLUGIN_MARKER), "managedBy=dotagents\n", "utf-8"); +} + +function toDeclaration( + config: PluginConfig, + candidate: PluginCandidate, +): 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; +} + +/** 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[] = []; + for (const candidate of candidates) { + const key = resolve(candidate.dir); + if (seen.has(key)) {continue;} + seen.add(key); + result.push(candidate); + } + return result; +} + +/** Returns local marketplace paths; unsupported extension sources are skipped. */ +function localMarketplacePath(entry: MarketplacePluginEntry): string | null { + const { source } = entry; + if (typeof source === "string") {return stripDotSlash(source);} + + if (source.source === "local" && typeof source.path === "string") { + return stripDotSlash(source.path); + } + return null; +} + +function marketplaceSourceType(entry: MarketplacePluginEntry): string { + const { source } = entry; + if (typeof source === "string") {return "local";} + return source.source ?? "extension"; +} + +function stripDotSlash(path: string): string { + return path.replace(/^\.\//, ""); +} + +/** Resolves a selector path while preserving the source-root containment boundary. */ +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)) { + await assertInsideSourceRoot(rootPath, filePath, label, childPath); + } + return filePath; +} + +async function resolveMarketplaceSource( + sourceRoot: string, + marketplaceRoot: string, + childPath: string, + label: string, +): Promise { + const filePath = resolve(marketplaceRoot, childPath); + const sourceRootPath = resolve(sourceRoot); + const relPath = relative(sourceRootPath, filePath); + if (relPath.startsWith("..") || isAbsolute(relPath)) { + throw new Error(`${label} resolves outside source: ${childPath}`); + } + if (existsSync(filePath)) { + await assertInsideSourceRoot(sourceRootPath, filePath, label, childPath); + } + return filePath; +} + +async function assertInsideSourceRoot( + root: string, + filePath: string, + label: string, + displayPath = relativePath(root, filePath), +): Promise { + let rootRealPath: string; + try { + rootRealPath = await realpath(root); + } catch (err) { + if (isNotFoundError(err)) { + throw new Error(`${label} source root does not exist: ${displayPath}`, { cause: err }); + } + throw err; + } + const fileRealPath = await realpath(filePath); + const realRelPath = relative(rootRealPath, fileRealPath); + if (realRelPath.startsWith("..") || isAbsolute(realRelPath)) { + throw new Error(`${label} resolves outside source: ${displayPath}`); + } +} + +function isNotFoundError(err: unknown): boolean { + return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; +} + +function managedPluginPath(pluginsDir: string, name: string): string | null { + const rootPath = resolve(pluginsDir); + const pluginPath = resolve(rootPath, name); + 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/plugins/targets.ts b/packages/dotagents/src/plugins/targets.ts new file mode 100644 index 0000000..e104458 --- /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", "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. */ +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/scope.ts b/packages/dotagents/src/scope.ts index afd2086..055a2f8 100644 --- a/packages/dotagents/src/scope.ts +++ b/packages/dotagents/src/scope.ts @@ -16,6 +16,8 @@ export interface ScopeRoot { lockPath: string; /** skills/ directory */ skillsDir: string; + /** plugins/ directory */ + pluginsDir: string; } /** @@ -34,6 +36,7 @@ export function resolveScope(scope: Scope, projectRoot?: string): ScopeRoot { configPath: join(home, "agents.toml"), lockPath: join(home, "agents.lock"), skillsDir: join(home, "skills"), + pluginsDir: join(home, "plugins"), }; } @@ -46,6 +49,7 @@ export function resolveScope(scope: Scope, projectRoot?: string): ScopeRoot { configPath: join(root, "agents.toml"), lockPath: join(root, "agents.lock"), skillsDir: join(agentsDir, "skills"), + pluginsDir: join(agentsDir, "plugins"), }; } diff --git a/packages/dotagents/src/subagents/writer.ts b/packages/dotagents/src/subagents/writer.ts index c69ff8d..6f77edc 100644 --- a/packages/dotagents/src/subagents/writer.ts +++ b/packages/dotagents/src/subagents/writer.ts @@ -6,6 +6,8 @@ import { hasDotagentsMarkdownSubagentMarker, hasDotagentsTomlSubagentMarker } fr import { generatedSubagentIdentity, readSubagentFileIdentity } from "./identity.js"; import type { SubagentConfigSpec, SubagentDeclaration } from "./types.js"; +// Owns runtime-specific subagent projection. Managed markers protect generated +// files, while identity checks prevent overwriting user-authored subagents. export interface SubagentResolvedTarget { dirPath: string; } @@ -38,18 +40,21 @@ interface DesiredDir { files: Set; } +/** Resolves project-scope runtime subagent directories relative to a project root. */ export function projectSubagentResolver(projectRoot: string): SubagentTargetResolver { return (_id: string, spec: SubagentConfigSpec) => ({ dirPath: join(projectRoot, spec.projectDir), }); } +/** Resolves user-scope runtime subagent directories from each target definition. */ export function userSubagentResolver(): SubagentTargetResolver { return (_id: string, spec: SubagentConfigSpec) => ({ dirPath: spec.userDir, }); } +/** Writes managed runtime subagent configs for all configured target agents. */ export async function writeSubagentConfigs( agentIds: string[], subagents: SubagentDeclaration[], @@ -122,6 +127,7 @@ export async function writeSubagentConfigs( return { warnings, written }; } +/** Prunes managed runtime subagent files that are no longer desired. */ export async function pruneSubagentConfigs( agentIds: string[], desiredSubagents: Pick[], @@ -130,6 +136,7 @@ export async function pruneSubagentConfigs( return pruneManagedFiles(initDesiredDirs(agentIds, desiredSubagents, resolveTarget)); } +/** Verifies that managed runtime subagent files exist and still match desired output. */ export async function verifySubagentConfigs( agentIds: string[], subagents: SubagentDeclaration[], diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index 4e24dcc..169aed3 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -35,9 +35,11 @@ Write down the QA target before running commands: Read the targeted reference before running runtime-specific QA: - Core install/sync example: [references/core-agentic-qa.md](references/core-agentic-qa.md) -- Codex custom agents and runtime caveats: [references/codex.md](references/codex.md) -- Claude Code files and runtime caveats: [references/claude.md](references/claude.md) +- 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) +- Grok Build files/runtime caveats: [references/grok.md](references/grok.md) - OpenCode files/runtime caveats: [references/opencode.md](references/opencode.md) Run focused Vitest coverage for logic bugs. Use this skill for end-to-end QA @@ -58,8 +60,12 @@ 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. Keep runtime credentials out of +fixtures and retained `/qa-out` artifacts. Use an interactive container so the QA steps stay change-specific: @@ -145,6 +151,9 @@ 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/` +- Codex repo-scoped plugin marketplace under `.agents/plugins/marketplace.json` +- 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 @@ -224,6 +233,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 +280,18 @@ 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 .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 -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 grep -q "Generated by dotagents" .codex/agents/code-reviewer.toml ``` @@ -278,11 +301,25 @@ 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/skills/plugin-qa .opencode/agents/plugin-reviewer.md "${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 .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 -L .opencode/skills/plugin-qa +test -L .opencode/agents/plugin-reviewer.md ``` For user-scope changes: @@ -329,6 +366,10 @@ 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. Before claiming model-backed +runtime proof, run the check inside Docker and report the exact provider config +used with secret values redacted. + ## 7. Report Evidence Report: diff --git a/skills/dotagents-qa/references/claude.md b/skills/dotagents-qa/references/claude.md index 37f9796..a63e58e 100644 --- a/skills/dotagents-qa/references/claude.md +++ b/skills/dotagents-qa/references/claude.md @@ -22,10 +22,42 @@ DOTAGENTS_HOME="$TMP/user-home" Then assert Claude runtime files under `$HOME/.claude/agents/` and user skills under `$DOTAGENTS_HOME/skills/`. +## Plugin Checks + +Use these checks when a branch affects plugin output for Claude. Run them inside +the Docker QA container, against a retained QA project: + +```bash +node skills/dotagents-qa/scripts/qa-example.mjs plugin-claude --keep +``` + +The task installs the full example, asserts the generated files, then runs +`claude plugin validate` against the generated plugin bundle and marketplace. +The first validation proves the generated native plugin manifest is acceptable +to Claude Code. The second proves the generated marketplace points at a valid +plugin bundle. + +Claude Code 2.1.x rejects an `agents` field in plugin manifests. dotagents +therefore omits plugin agents from the generated Claude manifest even when the +canonical bundle contains an `agents/` directory for other runtimes. + +Expected warning today: Claude Code accepts the files but warns that +`metadata.managedBy` is unknown. That warning is acceptable because dotagents +uses the marker for overwrite protection and Claude ignores unknown metadata. + +If Claude reports `No manifest found in directory`, verify the plugin bundle +contains `.claude-plugin/plugin.json`; the marketplace alone is not enough. If +Claude reports `plugins.0.source: Invalid input`, verify marketplace entries +use relative string sources like `"./.agents/plugins/qa-tools"`. + ## Runtime Proof There is no cheap dry-run in this QA skill that proves Claude Code loads custom agents. Do not claim Claude runtime discovery from file-level checks alone. +For authenticated runtime proof, pass only the required provider variables into +Docker, keep credentials out of retained fixtures, and keep `HOME` isolated to +the temp container home. + If a branch specifically requires Claude runtime proof, run an explicit Claude Code invocation only when auth/model cost is acceptable, keep it isolated to a temp project, and report: - exact command diff --git a/skills/dotagents-qa/references/codex.md b/skills/dotagents-qa/references/codex.md index 311f069..a4dce60 100644 --- a/skills/dotagents-qa/references/codex.md +++ b/skills/dotagents-qa/references/codex.md @@ -40,6 +40,11 @@ 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 gateway-backed Codex proof, put provider config in the isolated +`CODEX_HOME/config.toml`; Codex ignores provider redirects in project +`.codex/config.toml`. Pass provider credentials 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..356f58b 100644 --- a/skills/dotagents-qa/references/cursor.md +++ b/skills/dotagents-qa/references/cursor.md @@ -20,4 +20,9 @@ For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated Curso This QA skill does not currently include an automated Cursor desktop/runtime proof. Do not claim Cursor runtime discovery from file-level checks alone. +Cursor runtime proof uses Cursor auth. Use `CURSOR_API_KEY` or browser login +for Cursor CLI/headless checks. Do not claim generic gateway proof for Cursor +unless current Cursor docs or observed local behavior proves a compatible +endpoint. + If a branch specifically requires Cursor runtime proof, use a real Cursor session or a documented headless path if one exists in the local environment. Report the exact interaction and evidence. Otherwise report file-level Cursor wiring only. diff --git a/skills/dotagents-qa/references/grok.md b/skills/dotagents-qa/references/grok.md new file mode 100644 index 0000000..fc0f41a --- /dev/null +++ b/skills/dotagents-qa/references/grok.md @@ -0,0 +1,36 @@ +# 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 runtime proof, use a temp Grok config and run `grok inspect` +before any paid prompt to prove Grok discovered the temp project config, skills, +plugins, hooks, and MCP servers. + +If a branch specifically requires Grok runtime proof, install or make `grok` +available inside the Docker container and report: + +- 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..5bd3312 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/skills/*`, or +OpenCode user-scope paths. ## File-Level Checks @@ -9,14 +11,73 @@ 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 Component 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/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 -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: + +- `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 skill` should include projected plugin skills under + `.opencode/skills/*`; verify from raw output instead of assuming it is + stable across OpenCode versions + +For authenticated runtime proof, use a temp `opencode.json` provider +configuration and keep credentials in the environment, not in retained fixture +files. + +With provider credentials forwarded into Docker, a minimal proof is: + +- `opencode auth list` reports the OpenRouter environment credential +- `opencode models openrouter` lists the chosen model +- `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: diff --git a/skills/dotagents-qa/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md new file mode 100644 index 0000000..6e6bf6f --- /dev/null +++ b/skills/dotagents-qa/references/plugin-runtime.md @@ -0,0 +1,177 @@ +# 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 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`, +`plugin-codex`, and `plugin-opencode` tasks when the branch needs native +plugin-management or runtime-projection evidence. + +## 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 the generated bundle's available components, + 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: + +- 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` + +## 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: + +`node skills/dotagents-qa/scripts/qa-example.mjs plugin-opencode` installs the +fixture, confirms `.opencode/skills/plugin-qa` and +`.opencode/agents/plugin-reviewer.md` are symlinks into the installed plugin +bundle, then runs: + +```bash +opencode debug skill +opencode agent list +``` + +Expected evidence: + +- `debug skill` includes `plugin-qa` and `DOTAGENTS_PLUGIN_QA_FIXTURE` +- `agent list` includes `plugin-reviewer` + +Manual final check with model auth: + +- Use a configured provider key +- Run `opencode run --model "$OPENCODE_QA_MODEL" --format json ""` +- For generated subagents, ask the primary agent to call the `task` tool with + `subagent_type = "code-reviewer"`; do not pass the subagent to `--agent` + +## 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 37bda4d..779bc32 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -36,6 +36,9 @@ const taskGroups = { const tasks = { "install-files": runInstallFiles, "sync-repair": runSyncRepair, + "plugin-claude": runClaudePluginProof, + "plugin-codex": runCodexPluginProof, + "plugin-opencode": runOpenCodePluginProof, "codex-runtime": runCodexRuntimeProof, }; @@ -110,6 +113,9 @@ Tasks: all Run install-files and sync-repair install-files Install the full example and assert generated files sync-repair Delete representative generated files and assert sync repairs them + plugin-claude Validate generated Claude plugin and marketplace with Claude Code + plugin-codex Add/list/install generated Codex marketplace with Codex CLI + plugin-opencode Assert generated OpenCode plugin skill and agent projections codex-runtime Paid proof that Codex can spawn the generated custom agent Compatibility: @@ -127,11 +133,85 @@ async function runSyncRepair() { rmSync(join(projectDir, ".claude", "skills"), { force: true, recursive: true }); rmSync(join(projectDir, ".claude", "agents", "code-reviewer.md"), { force: true }); rmSync(join(projectDir, ".codex", "agents", "code-reviewer.toml"), { force: true }); + rmSync(join(projectDir, ".agents", "plugins", "marketplace.json"), { force: true }); + rmSync(join(projectDir, ".claude-plugin", "marketplace.json"), { force: true }); + rmSync(join(projectDir, ".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", "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"); assertFile(".claude/agents/code-reviewer.md"); assertFile(".codex/agents/code-reviewer.toml"); + assertPluginOutputs(); +} + +async function runClaudePluginProof() { + await installAndAssert(); + execFileSync("claude", ["plugin", "validate", join(projectDir, ".agents", "plugins", "qa-tools")], { + cwd: projectDir, + env: fixtureEnv, + stdio: "inherit", + }); + execFileSync("claude", ["plugin", "validate", join(projectDir, ".claude-plugin", "marketplace.json")], { + cwd: projectDir, + env: fixtureEnv, + stdio: "inherit", + }); +} + +async function runCodexPluginProof() { + await installAndAssert(); + rmSync(codexHomeDir, { recursive: true, force: true }); + mkdirSync(codexHomeDir, { recursive: true }); + const env = { ...fixtureEnv, CODEX_HOME: codexHomeDir }; + + const add = execJson("codex", ["plugin", "marketplace", "add", projectDir, "--json"], env); + if (add.marketplaceName !== "dotagents-local") { + throw new Error("Codex marketplace add did not return dotagents-local"); + } + + const available = execJson("codex", ["plugin", "list", "--available", "--json"], env); + if (!available.available?.some((plugin) => plugin.pluginId === "qa-tools@dotagents-local")) { + throw new Error("Codex available plugin list did not include qa-tools@dotagents-local"); + } + + const installed = execJson("codex", ["plugin", "add", "qa-tools@dotagents-local", "--json"], env); + if (installed.pluginId !== "qa-tools@dotagents-local") { + throw new Error("Codex plugin add did not install qa-tools@dotagents-local"); + } + + const list = execJson("codex", ["plugin", "list", "--json"], env); + if (!list.installed?.some((plugin) => plugin.pluginId === "qa-tools@dotagents-local" && plugin.enabled === true)) { + throw new Error("Codex installed plugin list did not include enabled qa-tools@dotagents-local"); + } +} + +async function runOpenCodePluginProof() { + await installAndAssert(); + const skills = execFileSync("opencode", ["debug", "skill"], { + cwd: projectDir, + env: fixtureEnv, + encoding: "utf-8", + }); + 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"); + } } async function runCodexRuntimeProof() { @@ -163,6 +243,7 @@ async function installAndAssert() { assertFile(".claude/settings.json"); assertFile(".cursor/hooks.json"); assertSubagentOutputs(); + assertPluginOutputs(); } function runCli(cliArgs) { @@ -174,6 +255,16 @@ function runCli(cliArgs) { }); } +function execJson(command, args, env) { + const output = execFileSync(command, args, { + cwd: projectDir, + env, + encoding: "utf-8", + stdio: ["ignore", "pipe", "inherit"], + }); + return JSON.parse(output); +} + async function listSkills() { const [{ runList }, { resolveScope }] = await Promise.all([ import(pathToFileURL(join(repoRoot, "packages", "dotagents", "dist", "cli", "commands", "list.js")).href), @@ -274,6 +365,50 @@ 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/.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"'); + 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"'); + 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"); + + 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"'); + 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"); + + assertSymlink(".opencode/skills/plugin-qa"); + assertSymlink(".opencode/agents/plugin-reviewer.md"); +} + function assertFile(relativePath) { const path = join(projectDir, relativePath); if (!existsSync(path)) { @@ -281,6 +416,13 @@ function assertFile(relativePath) { } } +function assertFileDoesNotExist(relativePath) { + const path = join(projectDir, relativePath); + if (existsSync(path)) { + throw new Error(`expected file not to exist: ${relativePath}`); + } +} + function assertSymlink(relativePath) { const path = join(projectDir, relativePath); if (!existsSync(path) || !lstatSync(path).isSymbolicLink()) { @@ -303,6 +445,16 @@ function assertSkillStatus(statuses, name) { } } +function assertSameFile(actualPath, expectedPath) { + assertFile(actualPath); + assertFile(expectedPath); + const actual = readFileSync(join(projectDir, actualPath), "utf-8"); + const expected = readFileSync(join(projectDir, expectedPath), "utf-8"); + if (actual !== expected) { + throw new Error(`${actualPath} should match ${expectedPath}`); + } +} + function assertIncludes(value, expected, message) { if (!value.includes(expected)) { throw new Error(message); diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index 2fd8a0c..5fd5536 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -1,9 +1,9 @@ --- name: dotagents -description: Manage agent skill dependencies with dotagents. Use when asked to "add a skill", "install skills", "remove a skill", "dotagents init", "agents.toml", "agents.lock", "sync skills", "list skills", "set up dotagents", "configure trust", "add MCP server", "add hook", "wildcard skills", "user scope", "dotagents doctor", or any dotagents-related task. +description: Manage dotagents dependencies and runtime config. Use when asked to "add a skill", "install skills", "remove a skill", "configure plugins", "configure subagents", "dotagents init", "agents.toml", "agents.lock", "sync skills", "list skills", "set up dotagents", "configure trust", "add MCP server", "add hook", "wildcard skills", "user scope", "dotagents doctor", or any dotagents-related task. --- -Manage agent skill dependencies declared in `agents.toml`. dotagents resolves, installs, and symlinks skills so multiple agent tools (Claude Code, Cursor, Codex, VS Code, OpenCode) discover them from `.agents/skills/`. +Manage dependencies declared in `agents.toml`. dotagents resolves skills, subagents, plugins, MCP servers, and hooks so agent tools (Claude Code, Cursor, Codex, Grok, VS Code, OpenCode, Pi) can use shared project config. ## Running dotagents @@ -49,11 +49,11 @@ npx @sentry/dotagents list | Command | Description | |---------|-------------| | `npx @sentry/dotagents init` | Initialize `agents.toml` and `.agents/` directory | -| `npx @sentry/dotagents install` | Install all skills from `agents.toml` | +| `npx @sentry/dotagents install` | Install all dependencies from `agents.toml` | | `npx @sentry/dotagents add ` | Add a skill dependency | -| `npx @sentry/dotagents remove ` | Remove a skill | -| `npx @sentry/dotagents sync` | Reconcile state (adopt orphans, repair symlinks, fix configs) | -| `npx @sentry/dotagents list` | Show installed skills and their status | +| `npx @sentry/dotagents remove ` | Remove a skill or plugin | +| `npx @sentry/dotagents sync` | Reconcile state (adopt orphans, prune stale managed artifacts, repair configs) | +| `npx @sentry/dotagents list` | Show declared skills, plugins, and status | | `npx @sentry/dotagents mcp` | Add, remove, or list MCP server declarations | | `npx @sentry/dotagents trust` | Add, remove, or list trusted sources | | `npx @sentry/dotagents doctor` | Check project health and fix issues | @@ -76,12 +76,14 @@ For full options and flags, read [references/cli-reference.md](references/cli-re ## Key Concepts -- **`.agents/skills/`** is the canonical home for all installed skills -- **`agents.toml`** declares dependencies; **`agents.lock`** tracks managed skills +- **`.agents/skills/`** is the canonical home for skills; **`.agents/plugins/`** is the canonical home for plugins +- **`agents.toml`** declares dependencies; **`agents.lock`** tracks managed skills, subagents, and plugins - **Symlinks**: `.claude/skills/`, `.cursor/skills/` point to `.agents/skills/` - **Wildcards**: `name = "*"` installs all skills from a source, with optional `exclude` list - **Trust**: Optional `[trust]` section restricts which sources are allowed - **Hooks**: `[[hooks]]` declarations write tool-event hooks to each agent's config -- **Gitignore**: Managed skills are always gitignored; custom in-place skills are tracked -- **User scope**: `--user` flag manages skills in `~/.agents/` shared across all projects -- **Updates**: Run `npx @sentry/dotagents install` to refresh managed skills; there is no `update` command +- **Subagents**: `[[subagents]]` declarations install portable or native subagent files +- **Plugins**: `[[plugins]]` declarations install canonical bundles and generate runtime-specific plugin outputs +- **Gitignore**: Managed skills, subagents, and plugin bundles are gitignored; custom in-place sources are tracked +- **User scope**: `--user` flag manages skills in `~/.agents/` shared across all projects; plugins are project-scope only +- **Updates**: Run `npx @sentry/dotagents install` to refresh managed skills, subagents, and plugins; there is no `update` command diff --git a/skills/dotagents/references/cli-reference.md b/skills/dotagents/references/cli-reference.md index 59b4c2e..5ae778e 100644 --- a/skills/dotagents/references/cli-reference.md +++ b/skills/dotagents/references/cli-reference.md @@ -39,7 +39,7 @@ npx @sentry/dotagents --user init ### `install` -Install or refresh skill dependencies declared in `agents.toml`. There is no separate `update` command. +Install or refresh skill, subagent, and plugin dependencies declared in `agents.toml`. There is no separate `update` command. ```bash npx @sentry/dotagents install @@ -47,14 +47,16 @@ npx @sentry/dotagents install **Workflow:** 1. Load config and lockfile -2. Expand wildcard entries (discover all skills from source) -3. Validate trust for each skill source -4. Resolve skills (refreshing sources through the cache) -5. Copy skills into `.agents/skills//` +2. Expand wildcard skill entries +3. Validate trust for each skill, subagent, and plugin source +4. Resolve skills, subagents, and plugins +5. Copy canonical artifacts into `.agents/skills/`, `.agents/agents/`, and `.agents/plugins/` 6. Write/update lockfile 7. Generate `.agents/.gitignore` 8. Create/verify agent symlinks -9. Write MCP and hook configs +9. Write MCP, hook, subagent, and plugin runtime configs + +`dotagents --user install` rejects `[[plugins]]` because plugin runtime projections are project-scoped. ### `add [skill...]` @@ -96,21 +98,21 @@ When adding multiple skills, already-existing entries are skipped with a warning `--all` and `--name`/positional args are mutually exclusive. -### `remove ` +### `remove ` -Remove a skill dependency. +Remove a skill or plugin dependency. ```bash npx @sentry/dotagents remove find-bugs ``` -Removes from `agents.toml`, deletes `.agents/skills//`, updates the lockfile, and regenerates `.agents/.gitignore`. +Removes from `agents.toml`, deletes managed installed files, updates the lockfile, prunes generated plugin outputs when needed, and regenerates `.agents/.gitignore`. Passing a source removes all matching skills and plugins from that source. For skills sourced from a wildcard entry (`name = "*"`), interactively prompts whether to add the skill to the wildcard's `exclude` list. If declined, the removal is cancelled. ### `sync` -Reconcile project state without network access: adopt local orphans, prune stale managed skills, and repair symlinks and configs. +Reconcile project state without network access: adopt local orphans, prune stale managed skills/subagents/plugins, and repair symlinks and generated configs. ```bash npx @sentry/dotagents sync @@ -119,13 +121,14 @@ npx @sentry/dotagents sync **Actions performed:** 1. Adopt orphaned skills (installed but not declared in config) 2. Regenerate `.agents/.gitignore` -3. Prune stale managed skills removed from config -4. Check for missing skills +3. Prune stale managed skills, subagents, and plugins removed from config +4. Check for missing skills and plugins 5. Repair agent symlinks 6. Verify/repair MCP configs 7. Verify/repair hook configs +8. Verify/repair subagent and plugin runtime configs -Reports issues as warnings (missing MCP/hook configs) or errors (missing skills). +Reports issues as warnings or errors, including user-scope plugin declarations and same-project plugin declarations. ### `doctor` @@ -140,13 +143,13 @@ npx @sentry/dotagents doctor --fix |------|-------------| | `--fix` | Auto-fix issues where possible | -**Checks:** gitignore setup, legacy config fields, installed skills, symlinks, `.agents/.gitignore`. +**Checks:** gitignore setup, legacy config fields, installed skills/plugins, symlinks, generated runtime configs, `.agents/.gitignore`. Useful when migrating to a new version of dotagents. ### `list` -Show installed skills and their status. +Show declared skills and plugins with their status. ```bash npx @sentry/dotagents list @@ -164,6 +167,8 @@ npx @sentry/dotagents list --json Skills from wildcard entries are marked with a wildcard indicator. +JSON output contains separate `skills` and `plugins` arrays. + ### `mcp` Manage MCP (Model Context Protocol) server declarations in `agents.toml`. diff --git a/skills/dotagents/references/config-schema.md b/skills/dotagents/references/config-schema.md index a94394f..235af85 100644 --- a/skills/dotagents/references/config-schema.md +++ b/skills/dotagents/references/config-schema.md @@ -14,6 +14,8 @@ minimum_release_age_exclude = ["getsentry/*"] # Optional [[skills]] # Optional, array of skill entries [[mcp]] # Optional, array of MCP servers [[hooks]] # Optional, array of hook declarations +[[subagents]] # Optional, array of subagent dependencies +[[plugins]] # Optional, array of plugin dependencies ``` ## Top-Level Fields @@ -22,8 +24,8 @@ minimum_release_age_exclude = ["getsentry/*"] # Optional |-------|------|----------|---------|-------------| | `version` | integer | Yes | -- | Schema version, must be `1` | | `defaultRepositorySource` | string | No | `github` | Host for shorthand `owner/repo` sources. Valid values: `github`, `gitlab` | -| `agents` | string[] | No | `[]` | Agent targets: `claude`, `cursor`, `codex`, `vscode`, `opencode` | -| `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill can install | +| `agents` | string[] | No | `[]` | Agent targets: `claude`, `cursor`, `codex`, `vscode`, `grok`, `opencode`, `pi` | +| `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install | | `minimum_release_age_exclude` | string[] | No | `[]` | Sources that bypass `minimum_release_age` | ## Project Section @@ -144,6 +146,44 @@ command = "my-lint-check" # Required | `matcher` | string | No | Tool name to match (omit for all tools) | | `command` | string | Yes | Shell command to execute | +## Subagents Section + +```toml +[[subagents]] +name = "code-reviewer" # Required, unique subagent identifier +source = "getsentry/agent-pack" # Required, source repository or path +ref = "v1.0.0" # Optional, pin to tag/branch/commit +path = "agents/code-reviewer.md" # Optional, artifact path within source +targets = ["claude", "codex"] # Optional, subset of configured agents +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique subagent identifier | +| `source` | string | Yes | `owner/repo`, `owner/repo@ref`, GitHub/GitLab URL, `git:url`, or `path:relative` | +| `ref` | string | No | Tag, branch, or commit SHA to pin | +| `path` | string | No | Subagent artifact path within the source repo | +| `targets` | string[] | No | Runtime targets for generated subagent files | + +## Plugins Section + +```toml +[[plugins]] +name = "review-tools" # Required, unique plugin identifier +source = "getsentry/agent-plugins" # Required, source repository or path +ref = "v1.0.0" # Optional, pin to tag/branch/commit +path = "plugins/review-tools" # Optional, plugin directory within source +targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique plugin identifier | +| `source` | string | Yes | `owner/repo`, `owner/repo@ref`, GitHub/GitLab URL, `git:url`, or `path:relative`; well-known HTTPS sources are not supported for plugins | +| `ref` | string | No | Tag, branch, or commit SHA to pin | +| `path` | string | No | Plugin directory path within the source repo | +| `targets` | string[] | No | Plugin runtime targets; defaults to configured `agents` | + ## Lockfile (agents.lock) Auto-generated. Do not edit manually. Gitignored automatically. @@ -157,6 +197,18 @@ resolved_url = "https://github.com/getsentry/skills.git" resolved_path = "plugins/sentry-skills/skills/find-bugs" resolved_ref = "v1.0.0" resolved_commit = "0123456789abcdef0123456789abcdef01234567" + +[subagents.code-reviewer] +source = "getsentry/agent-pack" +resolved_url = "https://github.com/getsentry/agent-pack.git" +resolved_path = "agents/code-reviewer.md" +resolved_commit = "0123456789abcdef0123456789abcdef01234567" + +[plugins.review-tools] +source = "getsentry/agent-plugins" +resolved_url = "https://github.com/getsentry/agent-plugins.git" +resolved_path = "plugins/review-tools" +resolved_commit = "0123456789abcdef0123456789abcdef01234567" ``` | Field | Type | Description | @@ -167,7 +219,7 @@ resolved_commit = "0123456789abcdef0123456789abcdef01234567" | `resolved_ref` | string | Ref that was resolved (omitted for default branch) | | `resolved_commit` | string | Full commit SHA that was installed. Informational only | -Local path skills have `source` only. +Local path skills, subagents, and plugins have `source` only. ## Environment Variables diff --git a/skills/dotagents/references/configuration.md b/skills/dotagents/references/configuration.md index a3abd4e..31c8535 100644 --- a/skills/dotagents/references/configuration.md +++ b/skills/dotagents/references/configuration.md @@ -129,12 +129,38 @@ Hook configs are written per-agent: - `UserPromptSubmit` -> `beforeSubmitPrompt` - `Stop` -> `stop` +## Subagents + +Declare portable or native subagent artifacts with `[[subagents]]`. dotagents installs canonical managed files under `.agents/agents/` and writes runtime-specific files for supported agents. + +```toml +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +path = "agents/code-reviewer.md" +targets = ["claude", "codex", "opencode"] +``` + +## Plugins + +Declare plugin bundles with `[[plugins]]`. dotagents installs canonical bundles under `.agents/plugins//` and generates runtime-specific plugin outputs for configured targets. + +```toml +[[plugins]] +name = "review-tools" +source = "getsentry/agent-plugins" +path = "plugins/review-tools" +targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +``` + +Plugin declarations are project-scope only. User-scope plugin declarations are rejected. + ## Agents The `agents` array controls which agent tools get symlinks and configs. ```toml -agents = ["claude", "cursor", "codex", "vscode", "opencode"] +agents = ["claude", "cursor", "codex", "vscode", "grok", "opencode", "pi"] ``` Each agent gets: @@ -142,6 +168,7 @@ Each agent gets: - Or native discovery from `.agents/skills/` (Codex, VS Code, OpenCode) - MCP server configs in the agent's config file - Hook configs (where supported) +- Subagent and plugin runtime outputs (where supported) ## Scopes @@ -175,11 +202,11 @@ Use `minimum_release_age_exclude` for trusted sources that can bypass the age ga ## Gitignore -dotagents always manages gitignore. It generates `.agents/.gitignore` listing managed (remote) skills. In-place skills (`path:.agents/skills/...`) are never gitignored since they must be tracked in git. +dotagents always manages gitignore. It generates `.agents/.gitignore` listing managed skills, subagents, and plugins. In-place skills (`path:.agents/skills/...`) and in-place plugin sources are not gitignored since they must be tracked in git. Two files are added to the root `.gitignore` during `init`: -- `agents.lock` — tracks managed skills -- `.agents/.gitignore` — excludes managed skill directories +- `agents.lock` — tracks managed skills, subagents, and plugins +- `.agents/.gitignore` — excludes managed skill directories, subagent files, and plugin bundles If these entries are missing, `install` and `sync` warn. Run `npx @sentry/dotagents doctor --fix` to add them. diff --git a/specs/SPEC.md b/specs/SPEC.md index f44ff7b..917046d 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 @@ -30,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. @@ -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", "pi"] ``` ### 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`, `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, 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 (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,37 @@ 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 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. + +| 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`; `.agents/plugins//.claude-plugin/plugin.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 | Plugin `skills/` symlinked into `.opencode/skills/`; plugin Markdown `agents/` symlinked into `.opencode/agents/` | +| Pi | Plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target | + +Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated runtime marketplaces and generated Claude/Cursor/Codex plugin manifests are overwritten or pruned only when they carry `metadata.managedBy = "dotagents"`. Managed Grok copies and OpenCode/Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Existing plugin install destinations are overwritten only when `agents.lock` proves they are managed by dotagents. + +Plugins are currently project-scope only. `install --user` rejects `[[plugins]]` entries because user-scope runtime plugin projections are not generated yet. + #### Supported Agents | ID | Tool | Config Dir | MCP File | MCP Format | Subagents | @@ -238,10 +277,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 +419,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 +452,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 +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. 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 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. 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 ` @@ -497,25 +562,27 @@ Positional skill names and `--skill` flags cannot be mixed. ### `dotagents remove [-y]` -Remove a skill dependency, or all skills from a source. +Remove a skill or plugin dependency, or all skills and plugins from a source. ``` dotagents remove [-y] ``` -**Behavior (single skill):** -1. Remove `[[skills]]` entry from `agents.toml` -2. Delete `.agents/skills//` -3. Remove entry from `agents.lock` -4. Regenerate `.agents/.gitignore` +**Behavior (single dependency):** +1. Remove matching `[[skills]]` or `[[plugins]]` entry from `agents.toml` +2. Delete the managed installed artifact (`.agents/skills//` for skills or `.agents/plugins//` for managed plugins) +3. Remove entry from the relevant `agents.lock` section +4. Prune generated plugin runtime outputs when removing a plugin +5. Regenerate `.agents/.gitignore` **Behavior (source removal):** -When the argument matches a source specifier (e.g. `owner/repo`, a URL) rather than a skill name, removes all skills from that source: -1. Find all skills from the source (explicit entries from config + wildcard-expanded entries from lockfile) +When the argument matches a source specifier (e.g. `owner/repo`, a URL) rather than a dependency name, removes all skills and plugins from that source: +1. Find all skills and plugins from the source (explicit entries from config + wildcard-expanded skill entries from lockfile) 2. Confirm with the user (unless `-y` is passed) -3. Remove all matching `[[skills]]` entries from `agents.toml` (both explicit and wildcard) -4. Delete skill directories and lockfile entries -5. Regenerate `.agents/.gitignore` +3. Remove all matching `[[skills]]` and `[[plugins]]` entries from `agents.toml` +4. Delete managed installed artifacts and lockfile entries +5. Prune generated plugin runtime outputs for removed managed plugins +6. Regenerate `.agents/.gitignore` **Flags:** - `-y`, `--yes`: Skip confirmation prompt @@ -538,6 +605,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 +661,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 +680,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 +736,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/.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 ### 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 +749,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 +819,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 @@ -788,6 +858,11 @@ dotagents/ store.ts # Canonical installed subagent loading/writing writer.ts # Runtime subagent config generation format.ts # Subagent serialization and managed markers + plugins/ + schema.ts # Plugin manifest schema + store.ts # Canonical installed plugin loading/writing + targets.ts # Plugin target selection and validation + runtime/ # Target-specific plugin runtime projection agents/ index.ts # Compatibility re-export barrel config/ # agents.toml schema, loader, writer diff --git a/specs/plugins.md b/specs/plugins.md new file mode 100644 index 0000000..6c0f40e --- /dev/null +++ b/specs/plugins.md @@ -0,0 +1,312 @@ +# 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`, `.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 + +The canonical inputs are tightly defined but forward-compatible: + +1. `agents.toml` `[[plugins]]` declarations are strict operational config. Unknown fields are rejected. +2. `.agents/plugins/marketplace.json` must be a JSON object with `name` and `plugins[]`. Each plugin entry must have `name` and `source`. dotagents resolves local plugin selectors only when `source` is a relative path string or `{ "source": "local", "path": "" }`; selectors are anchored at the marketplace file directory and rejected when the resolved path escapes the source root. Runtime extension source objects may be accepted when their known path fields are safe, but dotagents must report them as unsupported instead of guessing how to resolve them. +3. `.agents/plugins//plugin.json` must be a JSON object. Known component path fields are validated as relative filesystem paths without `..` or URL/scheme prefixes when present. Unknown fields are allowed and preserved so native runtimes and future dotagents versions can add metadata without breaking older installs. +4. All component paths in canonical manifests and marketplace component fields must be relative filesystem paths and must not contain `..`, URL/scheme prefixes, absolute POSIX paths, absolute Windows paths, or backslash-rooted paths. +5. The portable plugin `name` in `agents.toml` is authoritative. If a discovered manifest also declares `name`, it must match the configured name. + +Generated outputs are deterministic: + +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. +6. Existing `.agents/plugins//` install destinations are overwritten only when `agents.lock` proves they are managed by dotagents. + +## 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`, 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` 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, 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. + +## 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/ +``` + +Example canonical marketplace: + +```json +{ + "name": "dotagents-local", + "interface": { + "displayName": "Dotagents Plugins" + }, + "owner": { + "name": "dotagents" + }, + "plugins": [ + { + "name": "my-plugin", + "source": { + "source": "local", + "path": "./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" +} +``` + +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. + +## Native Formats + +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 | +|---------|-----------------|---------------------|----------------------|-------| +| 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/`. | +| 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 + +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 file directory, optionally applying the marketplace's `metadata.pluginRoot` prefix when present. If a marketplace entry points at a remote source object that dotagents cannot resolve yet, it should produce an unsupported-source warning rather than guessing. + +## Component Handling + +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 | 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 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. + +## 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` and `.agents/plugins//.claude-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `../.agents/plugins/` sources relative to `.claude-plugin/marketplace.json`, and each targeted plugin gets a Claude-native manifest. | +| Cursor | `.cursor-plugin/marketplace.json` and `.agents/plugins//.cursor-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `../.agents/plugins/` sources relative to `.cursor-plugin/marketplace.json`, and each targeted plugin gets a Cursor-native manifest. | +| Codex | `.agents/plugins/marketplace.json` and generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | Generated marketplace uses deterministic `{ "source": "local", "path": "./" }` entries relative to `.agents/plugins/marketplace.json`. | +| Grok Build | `.grok/plugins/` for targeted plugins | Not generated yet | The projection is a managed copy of the canonical plugin bundle with a `.dotagents-managed` marker. | +| OpenCode | Plugin `skills/` symlinked into `.opencode/skills/`; plugin Markdown `agents/` symlinked into `.opencode/agents/` | Not generated yet | dotagents exposes bundle components through OpenCode's native resource directories and skips collisions with user-authored files. | +| Pi | Plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target | Not generated yet | Pi reads agentskills from `.agents/skills/`; only plugin skills are projected. | + +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. + +## 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, or binaries. + +dotagents should: + +1. Apply existing trust policy before network access. +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 without bypassing runtime-native trust prompts. + +## Non-goals + +dotagents should not: + +1. Standardize a universal hook event model across all runtimes. +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. +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 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.