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/AGENTS.md b/AGENTS.md index 9fe8388..ac2ccc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Use **pnpm**: `pnpm install`, `pnpm build`, `pnpm test` ## What This Project Is -dotagents is shared tooling for coding agents. It manages skills, MCP servers, and hooks declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code can be configured from a single source of truth. +dotagents is shared tooling for coding agents. It manages skills, subagents, plugins, MCP servers, and hooks declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code can be configured from a single source of truth. See `specs/SPEC.md` for the full design. @@ -21,7 +21,10 @@ packages/ │ ├── index.ts # Library entry point (re-exports lib symbols with @deprecated) │ ├── scope.ts # Project/user scope resolution │ ├── cli/ # CLI entry point + commands (init, install, add, remove, sync, list, mcp, doctor, trust) -│ ├── agents/ # Agent definitions, MCP/hook config writers +│ ├── targets/ # Target agent definitions plus MCP/hook config writers +│ ├── subagents/ # Subagent identity, store, and runtime writer +│ ├── plugins/ # Plugin schema/store and target-specific runtime projection +│ ├── agents/ # Compatibility re-export barrel for older internal imports │ ├── config/ # agents.toml schema, loader, writer │ ├── lockfile/ # agents.lock schema, loader, writer │ ├── symlinks/ # Symlink creation/management diff --git a/README.md b/README.md index 770456e..19cadbb 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # dotagents -Shared tooling for coding agents. Declare skills, MCP servers, hooks, and subagents in `agents.toml` — dotagents wires them into every agent tool on your team. +Shared tooling for coding agents. Declare skills, MCP servers, hooks, subagents, and plugins in `agents.toml` — dotagents wires them into every agent tool on your team. ## Why dotagents? **One source of truth.** Skills live in `.agents/skills/` and symlink into `.claude/skills/` or wherever your tools expect them. Cursor shares Claude-compatible skills. No copy-pasting between directories. -**One command to install.** `agents.toml` is committed, managed skills and canonical installed subagents under `.agents/` are gitignored. Collaborators run `dotagents install` to fetch or refresh local agent state. +**One command to install.** `agents.toml` is committed, managed skills, canonical installed subagents, and managed plugin bundles under `.agents/` are gitignored. Collaborators run `dotagents install` to fetch or refresh local agent state. **Shareable.** Skills are directories with a `SKILL.md`. Host them in any git repo, discover them automatically, install with one command. -**Multi-agent.** Configure Claude, Cursor, Codex, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, hooks, and subagents where supported. Pi reads `.agents/skills/` directly. +**Multi-agent.** Configure Claude, Cursor, Codex, Grok, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, hooks, subagents, and plugins where supported. Pi reads `.agents/skills/` directly. ## Quick Start @@ -31,9 +31,9 @@ npx @sentry/dotagents add getsentry/skills find-bugs code-review commit npx @sentry/dotagents add getsentry/skills --all ``` -This creates an `agents.toml` at your project root and an `agents.lock` tracking installed skills and subagents. +This creates an `agents.toml` at your project root and an `agents.lock` tracking installed skills, subagents, and plugins. -After cloning a project that already has `agents.toml`, run `install` to fetch skills and subagents. Run it again to refresh managed local state: +After cloning a project that already has `agents.toml`, run `install` to fetch skills, subagents, and plugins. Run it again to refresh managed local state: ```bash npx @sentry/dotagents install @@ -47,7 +47,7 @@ npx @sentry/dotagents install | `add [skills...]` | Add skill dependencies | | `remove [-y]` | Remove a skill or all skills from a source | | `install` | Install all dependencies from `agents.toml` | -| `list` | Show installed skills and their status | +| `list` | Show declared skills, plugins, and their status | | `sync` | Reconcile state offline: adopt local skills, prune stale managed ones, repair configs | | `mcp` | Manage MCP server declarations | | `trust` | Manage trusted sources | @@ -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. + +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..f25f7fd 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. + +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 @@ -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 | |--------|---------| @@ -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/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/package.json b/package.json index 47bbb24..76cdf50 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "pnpm -r typecheck", "check": "pnpm lint && pnpm typecheck && pnpm test", "dev": "pnpm --filter @sentry/dotagents dev", - "smoke:examples": "pnpm build && node scripts/smoke-examples.mjs" + "qa:example": "pnpm build && node skills/dotagents-qa/scripts/qa-example.mjs" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/packages/dotagents-lib/src/skills/resolver.integration.test.ts b/packages/dotagents-lib/src/skills/resolver.integration.test.ts index 9f58b63..5b215c1 100644 --- a/packages/dotagents-lib/src/skills/resolver.integration.test.ts +++ b/packages/dotagents-lib/src/skills/resolver.integration.test.ts @@ -5,6 +5,13 @@ import { tmpdir } from "node:os"; import { resolveSkill, resolveWildcardSkills } from "./resolver.js"; import { exec } from "../utils/exec.js"; +async function initTestGitRepo(repoDir: string): Promise { + await exec("git", ["init"], { cwd: repoDir }); + await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); +} + /** * Integration tests that use real git operations. * These create local git repos to test the full resolve pipeline. @@ -27,9 +34,7 @@ describe("resolveSkill integration", () => { // Point cache to temp dir // Create a local git repo that looks like a skill repository await mkdir(repoDir, { recursive: true }); - await exec("git", ["init"], { cwd: repoDir }); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await initTestGitRepo(repoDir); // Create a skill at the root level await mkdir(join(repoDir, "pdf"), { recursive: true }); @@ -179,9 +184,7 @@ describe("resolveWildcardSkills integration", () => { // Create a local git repo with multiple skills await mkdir(repoDir, { recursive: true }); - await exec("git", ["init"], { cwd: repoDir }); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await initTestGitRepo(repoDir); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile( diff --git a/packages/dotagents-lib/src/sources/cache.test.ts b/packages/dotagents-lib/src/sources/cache.test.ts index 1a2596c..d303a3f 100644 --- a/packages/dotagents-lib/src/sources/cache.test.ts +++ b/packages/dotagents-lib/src/sources/cache.test.ts @@ -5,6 +5,12 @@ import { tmpdir } from "node:os"; import { ensureCached, validateCacheKey, CacheError } from "./cache.js"; import { exec } from "../utils/exec.js"; +async function configureTestGitRepo(repoDir: string): Promise { + await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); +} + describe("validateCacheKey", () => { it.each([ ["empty string", ""], @@ -45,8 +51,7 @@ describe("ensureCached", () => { await exec("git", ["init", "--bare", "--initial-branch=main", remoteDir]); await exec("git", ["clone", remoteDir, repoDir]); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await configureTestGitRepo(repoDir); }); afterEach(async () => { diff --git a/packages/dotagents/src/agents/index.ts b/packages/dotagents/src/agents/index.ts index 72d00fc..30dc346 100644 --- a/packages/dotagents/src/agents/index.ts +++ b/packages/dotagents/src/agents/index.ts @@ -1,39 +1,39 @@ -export { getAgent, allAgentIds } from "./registry.js"; -export { writeMcpConfigs, verifyMcpConfigs, toMcpDeclarations, projectMcpResolver } from "./mcp-writer.js"; -export type { McpTargetResolver, McpResolvedTarget } from "./mcp-writer.js"; -export { writeHookConfigs, verifyHookConfigs, toHookDeclarations, projectHookResolver } from "./hook-writer.js"; -export type { HookTargetResolver, HookResolvedTarget } from "./hook-writer.js"; +export { getAgent, allAgentIds } from "../targets/registry.js"; +export { writeMcpConfigs, verifyMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../targets/mcp-writer.js"; +export type { McpTargetResolver, McpResolvedTarget } from "../targets/mcp-writer.js"; +export { writeHookConfigs, verifyHookConfigs, toHookDeclarations, projectHookResolver } from "../targets/hook-writer.js"; +export type { HookTargetResolver, HookResolvedTarget } from "../targets/hook-writer.js"; export { writeSubagentConfigs, pruneSubagentConfigs, verifySubagentConfigs, projectSubagentResolver, userSubagentResolver, -} from "./subagent-writer.js"; +} from "../subagents/writer.js"; export { resolveSubagent, writeInstalledSubagents, loadInstalledSubagents, pruneInstalledSubagents, lockEntryForSubagent, -} from "./subagent-store.js"; +} from "../subagents/store.js"; export type { SubagentTargetResolver, SubagentResolvedTarget, SubagentWriteWarning, SubagentWriteResult, SubagentVerifyIssue, -} from "./subagent-writer.js"; +} from "../subagents/writer.js"; export type { SubagentResolveOptions, ResolvedSubagent, ResolvedSubagentType, InstalledSubagentLoadIssue, -} from "./subagent-store.js"; +} from "../subagents/store.js"; export type { LockedSubagent } from "../lockfile/schema.js"; -export { UnsupportedFeature } from "./errors.js"; -export { getUserMcpTarget, userMcpResolver } from "./paths.js"; -export type { UserMcpTarget } from "./paths.js"; +export { UnsupportedFeature } from "../targets/errors.js"; +export { getUserMcpTarget, userMcpResolver } from "../targets/paths.js"; +export type { UserMcpTarget } from "../targets/paths.js"; export type { AgentDefinition, McpDeclaration, @@ -42,6 +42,8 @@ export type { HookDeclaration, HookConfigSpec, HookSerializer, +} from "../targets/types.js"; +export type { SubagentDeclaration, NativeSubagentConfig, NativeSubagentContent, @@ -49,4 +51,4 @@ export type { SubagentConfigSpec, SubagentIdentityStrategy, SubagentSerializer, -} from "./types.js"; +} from "../subagents/types.js"; diff --git a/packages/dotagents/src/cli/commands/add.test.ts b/packages/dotagents/src/cli/commands/add.test.ts index a2fe789..bfabd1f 100644 --- a/packages/dotagents/src/cli/commands/add.test.ts +++ b/packages/dotagents/src/cli/commands/add.test.ts @@ -39,6 +39,7 @@ describe("runAdd", () => { cwd: repoDir, }); await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); @@ -235,6 +236,7 @@ describe("runAdd", () => { cwd: singleRepo, }); await exec("git", ["config", "user.name", "Test"], { cwd: singleRepo }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: singleRepo }); await mkdir(join(singleRepo, "only-skill"), { recursive: true }); await writeFile( join(singleRepo, "only-skill", "SKILL.md"), @@ -439,6 +441,7 @@ describe("add() CLI parsing", () => { cwd: repoDir, }); await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 4471cb9..1b81d59 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -102,12 +102,88 @@ 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 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"); execSync("git init", { cwd: projectRoot, stdio: "ignore" }); execSync("git config user.email test@test.com", { cwd: projectRoot, stdio: "ignore" }); execSync("git config user.name test", { cwd: projectRoot, stdio: "ignore" }); + execSync("git config commit.gpgsign false", { cwd: projectRoot, stdio: "ignore" }); await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); await writeFile(join(projectRoot, "agents.lock"), "version = 1\n"); @@ -201,6 +277,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 af6a9b1..5089cd5 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -4,16 +4,19 @@ 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"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { verifySymlinks } from "../../symlinks/manager.js"; -import { getAgent } from "../../agents/registry.js"; +import { getAgent } from "../../targets/registry.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { exec } from "@sentry/dotagents-lib"; import { isInPlaceSkill } from "../../utils/fs.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig, 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,44 @@ 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); + if (userScopePlugins.length > 0) { + checks.push({ + name: "installed plugins", + status: "error", + message: `${userScopePlugins.length} plugin(s) declared in user scope: ${userScopePlugins.join(", ")}. User-scope plugins are not supported yet; declare plugins in a project agents.toml instead.`, + }); + } else if (sameProjectPlugins.length > 0) { + checks.push({ + name: "installed plugins", + status: "error", + message: `${sameProjectPlugins.length} plugin(s) resolve inside this project's .agents/plugins/ tree: ${sameProjectPlugins.join(", ")}. Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.`, + }); + } else if (missingPlugins.length > 0) { + checks.push({ + name: "installed plugins", + status: "error", + message: `${missingPlugins.length} plugin(s) not installed: ${missingPlugins.join(", ")}. Run 'npx @sentry/dotagents install'.`, + }); + } 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 +349,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/init.test.ts b/packages/dotagents/src/cli/commands/init.test.ts index 51f3674..0f9ccb3 100644 --- a/packages/dotagents/src/cli/commands/init.test.ts +++ b/packages/dotagents/src/cli/commands/init.test.ts @@ -8,7 +8,7 @@ import { runInit, InitError, installPostMergeHook } from "./init.js"; import { loadConfig } from "../../config/loader.js"; vi.mock("./install.js", () => ({ - runInstall: vi.fn().mockResolvedValue({ installed: [], skipped: [], hookWarnings: [] }), + runInstall: vi.fn().mockResolvedValue({ installed: [], hookWarnings: [] }), })); describe("runInit", () => { diff --git a/packages/dotagents/src/cli/commands/init.ts b/packages/dotagents/src/cli/commands/init.ts index f8e730c..86ff0c8 100644 --- a/packages/dotagents/src/cli/commands/init.ts +++ b/packages/dotagents/src/cli/commands/init.ts @@ -6,7 +6,7 @@ import { generateDefaultConfig } from "../../config/writer.js"; import { writeAgentsGitignore, ensureRootGitignoreEntries } from "../../gitignore/writer.js"; import { ensureSkillsSymlink } from "../../symlinks/manager.js"; import { loadConfig } from "../../config/loader.js"; -import { getAgent, allAgentIds, allAgents } from "../../agents/registry.js"; +import { getAgent, allAgentIds, allAgents } from "../../targets/registry.js"; import { parseArgs } from "node:util"; import * as clack from "@clack/prompts"; import { resolveScope, isInsideGitRepo, findGitDir, type ScopeRoot } from "../../scope.js"; diff --git a/packages/dotagents/src/cli/commands/install-user.test.ts b/packages/dotagents/src/cli/commands/install-user.test.ts new file mode 100644 index 0000000..aeb3a0f --- /dev/null +++ b/packages/dotagents/src/cli/commands/install-user.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { mkdtemp, mkdir, readFile, readlink, rm, writeFile, lstat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, relative } from "node:path"; +import { tmpdir } from "node:os"; + +const SKILL_MD = `--- +name: pdf +description: Test skill pdf +--- + +# pdf +`; + +describe("runInstall user scope", () => { + let tmpDir: string | undefined; + const previousHome = process.env["HOME"]; + const previousDotagentsHome = process.env["DOTAGENTS_HOME"]; + const previousStateDir = process.env["DOTAGENTS_STATE_DIR"]; + + afterEach(async () => { + if (previousHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = previousHome; + } + if (previousDotagentsHome === undefined) { + delete process.env["DOTAGENTS_HOME"]; + } else { + process.env["DOTAGENTS_HOME"] = previousDotagentsHome; + } + if (previousStateDir === undefined) { + delete process.env["DOTAGENTS_STATE_DIR"]; + } else { + process.env["DOTAGENTS_STATE_DIR"] = previousStateDir; + } + vi.resetModules(); + + if (tmpDir) { + await rm(tmpDir, { recursive: true }); + tmpDir = undefined; + } + }); + + it("installs a user-scope path skill and writes the user agent skill symlink", async () => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-user-install-")); + const homeDir = join(tmpDir, "home"); + const dotagentsHome = join(tmpDir, "agents"); + const stateDir = join(tmpDir, "state"); + const sourceDir = join(dotagentsHome, "skill-source", "pdf"); + + process.env["HOME"] = homeDir; + process.env["DOTAGENTS_HOME"] = dotagentsHome; + process.env["DOTAGENTS_STATE_DIR"] = stateDir; + vi.resetModules(); + + const [{ runInstall }, { resolveScope }, { loadLockfile }] = await Promise.all([ + import("./install.js"), + import("../../scope.js"), + import("../../lockfile/loader.js"), + ]); + + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD); + const scope = resolveScope("user"); + await mkdir(scope.root, { recursive: true }); + await writeFile( + scope.configPath, + `version = 1 +agents = ["claude"] + +[[skills]] +name = "pdf" +source = "path:skill-source/pdf" +`, + ); + + const result = await runInstall({ scope }); + + expect(result.installed).toEqual(["pdf"]); + expect(existsSync(join(scope.skillsDir, "pdf", "SKILL.md"))).toBe(true); + expect(await readFile(join(scope.skillsDir, "pdf", "SKILL.md"), "utf-8")).toBe(SKILL_MD); + + const skillsLink = join(homeDir, ".claude", "skills"); + const stat = await lstat(skillsLink); + expect(stat.isSymbolicLink()).toBe(true); + expect(await readlink(skillsLink)).toBe(relative(join(homeDir, ".claude"), scope.skillsDir)); + + const lockfile = await loadLockfile(scope.lockPath); + expect(lockfile!.skills["pdf"]).toEqual({ source: "path:skill-source/pdf" }); + }); +}); diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 17bd773..1b2f8cd 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access, chmod } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -8,8 +8,9 @@ import { runSync } from "./sync.js"; import { exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; +import type { Lockfile } from "../../lockfile/schema.js"; import { resolveScope } from "../../scope.js"; -import { DOTAGENTS_SUBAGENT_MARKER } from "../../agents/definitions/helpers.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "../../subagents/format.js"; const SKILL_MD = (name: string) => `--- name: ${name} @@ -27,6 +28,13 @@ description: Review code for correctness. Review the current diff. `; +async function initTestGitRepo(repoDir: string): Promise { + await exec("git", ["init"], { cwd: repoDir }); + await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); +} + describe("runInstall", () => { let tmpDir: string; let stateDir: string; @@ -52,9 +60,7 @@ describe("runInstall", () => { if (repoInitialized) {return;} await mkdir(repoDir, { recursive: true }); - await exec("git", ["init"], { cwd: repoDir }); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await initTestGitRepo(repoDir); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); @@ -114,6 +120,743 @@ 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: "./.agents/plugins/review-tools", + source: "local", + }, + version: "1.0.0", + }, + ], + }); + expect(await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ + "metadata": { + "managedBy": "dotagents" + }, + "name": "dotagents", + "owner": { + "name": "dotagents" + }, + "plugins": [ + { + "description": "Review workflow helpers", + "name": "review-tools", + "source": "./.agents/plugins/review-tools", + "version": "1.0.0" + } + ] +} +`); + expect(await readFile(join(projectRoot, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(await readFile(join(projectRoot, ".claude-plugin", "marketplace.json"), "utf-8")); + + const 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 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: "plugins/marketplace-review-tools", + repo: "org/review-tools", + }, + }, + ], + }, null, 2), + ); + const marketplaceOnlyDir = join(sourceRoot, "plugins", "marketplace-review-tools"); + await mkdir(marketplaceOnlyDir, { recursive: true }); + await writeFile( + join(marketplaceOnlyDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Marketplace-only plugin", + }, null, 2), + ); + const pluginDir = join(sourceRoot, "plugins", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ + name: "review-tools", + description: "Fallback local plugin", + }, null, 2), + ); + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +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("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("excludes in-place plugin lock entries from gitignore in frozen mode", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex"] +`, + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + plugins: { + "local-tools": { source: "path:.agents/plugins/local-tools" }, + "managed-tools": { source: "path:external-source" }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope, frozen: true }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/plugins/local-tools/"); + expect(gitignore).toContain("/plugins/managed-tools/"); + }); + it("installs multiple skills", async () => { await writeFile( join(projectRoot, "agents.toml"), @@ -140,7 +883,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 () => { @@ -418,7 +1161,7 @@ path = "code-reviewer.md" expect(syncResult.adopted).toEqual([]); }); - it("updates skill lock entries 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")); @@ -433,7 +1176,7 @@ path = "code-reviewer.md" "hand-written subagent\n", "utf-8", ); - await writeLockfile(join(projectRoot, "agents.lock"), { + const originalLockfile: Lockfile = { version: 1, skills: {}, subagents: { @@ -447,7 +1190,9 @@ path = "code-reviewer.md" source: "path:old-agents", }, }, - }); + plugins: {}, + }; + await writeLockfile(join(projectRoot, "agents.lock"), originalLockfile); await writeFile( join(projectRoot, "agents.toml"), @@ -470,102 +1215,22 @@ path = "code-reviewer.md" ); const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); - expect(lockfile!.skills["pdf"]).toBeDefined(); - expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); - expect(lockfile!.subagents["old-reviewer"]).toBeUndefined(); + expect(lockfile).toEqual({ + version: 1, + skills: { + pdf: { source: "path:local-skills/pdf" }, + }, + subagents: { + "code-reviewer": { + source: "path:agents", + }, + }, + plugins: {}, + }); expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true); }); - it("preserves the original install error when fallback lockfile write fails", async () => { - const skillSourceDir = join(projectRoot, "local-skills", "pdf"); - await mkdir(skillSourceDir, { recursive: true }); - await writeFile(join(skillSourceDir, "SKILL.md"), SKILL_MD("pdf")); - - const sourceDir = join(projectRoot, "agents"); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); - - await mkdir(join(projectRoot, ".agents", "agents"), { recursive: true }); - await writeFile( - join(projectRoot, ".agents", "agents", "code-reviewer.md"), - "hand-written subagent\n", - "utf-8", - ); - - const lockPath = join(projectRoot, "agents.lock"); - await writeLockfile(lockPath, { version: 1, skills: {}, subagents: {} }); - await chmod(lockPath, 0o400); - - await writeFile( - join(projectRoot, "agents.toml"), - `version = 1 -[[skills]] -name = "pdf" -source = "path:local-skills/pdf" - -[[subagents]] -name = "code-reviewer" -source = "path:agents" -path = "code-reviewer.md" -`, - ); - - const scope = resolveScope("project", projectRoot); - - try { - await expect(runInstall({ scope })).rejects.toThrow( - /Subagent file exists and is not managed by dotagents/, - ); - } finally { - await chmod(lockPath, 0o600).catch(() => {}); - } - }); - - it("does not commit subagent lock entries when stale subagent pruning fails", async () => { - 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(() => {}); - } - - const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); - expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); - expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(true); - }); - - it("does not commit subagent lock entries when runtime subagent writes fail", async () => { + it("keeps lock entries when runtime subagent writes fail", async () => { const skillSourceDir = join(projectRoot, "local-skills", "pdf"); await mkdir(skillSourceDir, { recursive: true }); await writeFile(join(skillSourceDir, "SKILL.md"), SKILL_MD("pdf")); @@ -611,7 +1276,7 @@ path = "code-reviewer.md" const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); expect(lockfile!.skills["pdf"]).toBeDefined(); - expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); + expect(lockfile!.subagents["code-reviewer"]).toEqual({ source: "path:agents" }); expect(existsSync(join(projectRoot, ".agents", "agents", "code-reviewer.md"))).toBe(true); }); @@ -902,9 +1567,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 () => { @@ -980,6 +1645,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"), @@ -990,17 +1684,15 @@ 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 () => { // Create a second repo that also has a "pdf" skill const repoDir2 = join(tmpDir, "repo2"); await mkdir(repoDir2, { recursive: true }); - await exec("git", ["init"], { cwd: repoDir2 }); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir2 }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir2 }); + await initTestGitRepo(repoDir2); await mkdir(join(repoDir2, "pdf"), { recursive: true }); await writeFile(join(repoDir2, "pdf", "SKILL.md"), SKILL_MD("pdf")); await exec("git", ["add", "."], { cwd: repoDir2 }); @@ -1055,9 +1747,7 @@ path = "reviewer.md" // Create a second repo with a "helper" skill const repoDir2 = join(tmpDir, "repo2"); await mkdir(repoDir2, { recursive: true }); - await exec("git", ["init"], { cwd: repoDir2 }); - await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir2 }); - await exec("git", ["config", "user.name", "Test"], { cwd: repoDir2 }); + await initTestGitRepo(repoDir2); await mkdir(join(repoDir2, "helper"), { recursive: true }); await writeFile(join(repoDir2, "helper", "SKILL.md"), SKILL_MD("helper")); await exec("git", ["add", "."], { cwd: repoDir2 }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 1a2b441..992be3f 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -1,57 +1,32 @@ -import { join, resolve } from "node:path"; -import { mkdir, rm } from "node:fs/promises"; +import { resolve } from "node:path"; import { parseArgs } from "node:util"; import chalk from "chalk"; +import { GitError, TrustError } from "@sentry/dotagents-lib"; import { loadConfig } from "../../config/loader.js"; -import { - isWildcardDep, - type RepositorySource, - type SkillDependency, - type SubagentConfig, -} from "../../config/schema.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; -import { type Lockfile, type LockedSkill, type LockedSubagent } from "../../lockfile/schema.js"; -import { - applyDefaultRepositorySource, - resolveSkill, - resolveWildcardSkills, - sourcesMatch, - type ResolvedSkill, - validateTrustedSource, - TrustError, - GitError, - copyDir, -} from "@sentry/dotagents-lib"; -import { isInPlaceSkill, managedSkillPath } from "../../utils/fs.js"; -import { getCacheStateDir, HOST_SCAN_DIRS } from "../cache.js"; -import { formatGitError, formatTrustError } from "../errors.js"; -import { writeAgentsGitignore, checkRootGitignoreEntries } from "../../gitignore/writer.js"; -import { ensureSkillsSymlink } from "../../symlinks/manager.js"; -import { getAgent } from "../../agents/registry.js"; -import { writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js"; -import { writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js"; -import { pruneSubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; -import { - InstalledSubagentWriteError, - lockEntryForSubagent, - loadInstalledSubagents, - pruneInstalledSubagents, - resolveSubagent, - writeInstalledSubagents, -} from "../../agents/subagent-store.js"; -import { userMcpResolver } from "../../agents/paths.js"; -import type { SubagentDeclaration } from "../../agents/types.js"; +import type { Lockfile } from "../../lockfile/schema.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; - -export class InstallError extends Error { - constructor(message: string) { - super(message); - this.name = "InstallError"; - } -} - +import { formatGitError, formatTrustError } from "../errors.js"; +import { InstallError } from "./install/errors.js"; +import { installSkills } from "./install/skills.js"; +import { installSubagents, writeCanonicalSubagents } from "./install/subagents.js"; +import { installPlugins } from "./install/plugins.js"; +import { writeInstallGitignore } from "./install/gitignore.js"; +import { + writeHookRuntime, + writeMcpRuntime, + writePluginRuntime, + writeSkillSymlinks, + writeSubagentRuntime, +} from "./install/agent-runtime.js"; + +export { InstallError }; + +// Owns install orchestration only: concern modules resolve/copy/prune +// canonical artifacts, this command assembles the lockfile once, then writes +// runtime projections and user-facing CLI output. export interface InstallOptions { scope: ScopeRoot; frozen?: boolean; @@ -59,453 +34,65 @@ 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 }[]; -} - -/** Expanded skill ready for install — either from an explicit entry or a wildcard */ -interface ExpandedSkill { - name: string; - dep: SkillDependency; - resolved?: ResolvedSkill; -} - -/** - * Expand config skills into a flat list, resolving wildcards. - * Explicit entries always win over wildcard-discovered skills. - */ -async function expandSkills( - config: { - skills: SkillDependency[]; - trust?: Parameters[1]; - defaultRepositorySource: RepositorySource; - }, - lockfile: Lockfile | null, - opts: { frozen?: boolean; projectRoot: string; minimumReleaseAge?: number; minimumReleaseAgeExclude?: string[] }, -): Promise { - const regularDeps = config.skills.filter((d) => !isWildcardDep(d)); - const wildcardDeps = config.skills.filter(isWildcardDep); - const explicitNames = new Set(regularDeps.map((d) => d.name)); - - const expanded: ExpandedSkill[] = []; - - // Add regular deps - for (const dep of regularDeps) { - expanded.push({ name: dep.name, dep }); - } - - // Expand wildcards - const wildcardNames = new Map(); // name → source (for conflict detection) - for (const wDep of wildcardDeps) { - const wildcardSourceForTrust = applyDefaultRepositorySource( - wDep.source, - config.defaultRepositorySource, - ); - validateTrustedSource(wildcardSourceForTrust, config.trust); - const excludeSet = new Set(wDep.exclude); - - if (opts.frozen) { - // In frozen mode, expand from lockfile — no network needed - if (!lockfile) {continue;} - for (const [name, locked] of Object.entries(lockfile.skills)) { - if (!sourcesMatch(locked.source, wDep.source)) {continue;} - if (explicitNames.has(name)) {continue;} - if (excludeSet.has(name)) {continue;} - - expanded.push({ name, dep: wDep }); - } - } else { - let named; - try { - named = await resolveWildcardSkills(wDep, { - stateDir: getCacheStateDir(), - scanDirs: HOST_SCAN_DIRS, - projectRoot: opts.projectRoot, - defaultRepositorySource: config.defaultRepositorySource, - minimumReleaseAge: opts.minimumReleaseAge, - minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude, - }); - } catch (err) { - // Let GitError and TrustError bubble — they carry structured details - // (auth-required SSH hint, allowed-source list) the outer handler renders. - if (err instanceof GitError || err instanceof TrustError) {throw err;} - const msg = err instanceof Error ? err.message : String(err); - throw new InstallError(`Failed to resolve wildcard source "${wDep.source}": ${msg}`); - } - for (const { name, resolved } of named) { - if (explicitNames.has(name)) {continue;} // explicit wins - - // Check for conflicts between different wildcards - const existingSource = wildcardNames.get(name); - if (existingSource && !sourcesMatch(existingSource, wDep.source)) { - throw new InstallError( - `Skill "${name}" found in both wildcard sources: "${existingSource}" and "${wDep.source}". ` + - `Use an explicit [[skills]] entry or add it to one source's exclude list.`, - ); - } - wildcardNames.set(name, wDep.source); - - expanded.push({ name, dep: wDep, resolved }); - } - } - } - - return expanded; -} - -function validateFrozenSubagents( - subagents: SubagentConfig[], - lockfile: Lockfile | null, -): void { - if (subagents.length === 0) {return;} - if (!lockfile) { - throw new InstallError("--frozen requires agents.lock to exist."); - } - - for (const subagent of subagents) { - if (!lockfile.subagents[subagent.name]) { - throw new InstallError( - `--frozen: subagent "${subagent.name}" is in agents.toml but missing from agents.lock.`, - ); - } - } -} - -function optionalSubagentLockValue( - entry: LockedSubagent, - key: "resolved_url" | "resolved_path" | "resolved_ref" | "resolved_commit", -): string | undefined { - switch (key) { - case "resolved_url": - return "resolved_url" in entry ? entry.resolved_url : undefined; - case "resolved_path": - return "resolved_path" in entry ? entry.resolved_path : undefined; - case "resolved_ref": - return "resolved_ref" in entry ? entry.resolved_ref : undefined; - case "resolved_commit": - return "resolved_commit" in entry ? entry.resolved_commit : undefined; - } -} - -function subagentLockEntriesEqual(a: LockedSubagent, b: LockedSubagent): boolean { - return a.source === b.source - && optionalSubagentLockValue(a, "resolved_url") === optionalSubagentLockValue(b, "resolved_url") - && optionalSubagentLockValue(a, "resolved_path") === optionalSubagentLockValue(b, "resolved_path") - && optionalSubagentLockValue(a, "resolved_ref") === optionalSubagentLockValue(b, "resolved_ref") - && optionalSubagentLockValue(a, "resolved_commit") === optionalSubagentLockValue(b, "resolved_commit"); -} - -function unchangedSubagentLockEntries( - current: Lockfile | null, - next: Lockfile, -): Lockfile["subagents"] { - if (!current) {return {};} - - const unchanged: Lockfile["subagents"] = {}; - for (const [name, entry] of Object.entries(current.subagents)) { - const nextEntry = next.subagents[name]; - if (!nextEntry) {continue;} - if (!subagentLockEntriesEqual(entry, nextEntry)) {continue;} - unchanged[name] = entry; - } - return unchanged; + pluginWarnings: { agent: string; name: string; message: string }[]; } export async function runInstall(opts: InstallOptions): Promise { const { scope, frozen } = opts; - const { configPath, lockPath, agentsDir, skillsDir } = scope; - const subagentsDir = join(agentsDir, "agents"); - - // 1. Read config - const config = await loadConfig(configPath); - const lockfile = await loadLockfile(lockPath); - const newLock: Lockfile = { version: 1, skills: {}, subagents: {} }; - const installed: string[] = []; - const skipped: string[] = []; - const pruned: string[] = []; - - // Ensure skills/ exists (needed for symlinks even without skills) - await mkdir(skillsDir, { recursive: true }); - - // 2. Resolve and install skills (if any declared) - if (config.skills.length > 0) { - if (frozen && !lockfile) { - throw new InstallError("--frozen requires agents.lock to exist."); - } - - const minimumReleaseAge = config.minimum_release_age; - const minimumReleaseAgeExclude = config.minimum_release_age_exclude; - const expanded = await expandSkills( - { - skills: config.skills, - trust: config.trust, - defaultRepositorySource: config.defaultRepositorySource, - }, - lockfile, - { frozen, projectRoot: scope.root, minimumReleaseAge, minimumReleaseAgeExclude }, - ); - - if (frozen) { - for (const { name } of expanded) { - if (!lockfile!.skills[name]) { - throw new InstallError( - `--frozen: skill "${name}" is in agents.toml but missing from agents.lock.`, - ); - } - } - } - - for (const item of expanded) { - const { name, dep } = item; - - // Validate trust before any network work - const sourceForTrust = applyDefaultRepositorySource( - dep.source, - config.defaultRepositorySource, - ); - validateTrustedSource(sourceForTrust, config.trust); - - const resolveOpts = { - stateDir: getCacheStateDir(), - scanDirs: HOST_SCAN_DIRS, - projectRoot: scope.root, - defaultRepositorySource: config.defaultRepositorySource, - minimumReleaseAge, - minimumReleaseAgeExclude, - }; - - let resolved: ResolvedSkill; - if (item.resolved) { - resolved = item.resolved; - } else { - try { - resolved = await resolveSkill(name, dep, resolveOpts); - } catch (err) { - if (err instanceof GitError || err instanceof TrustError) {throw err;} - const msg = err instanceof Error ? err.message : String(err); - throw new InstallError(`Failed to resolve skill "${name}": ${msg}`); - } - } - - const destDir = join(skillsDir, name); - - // Skip copy when source resolves to the install destination (in-place skills) - if (resolve(resolved.skillDir) !== resolve(destDir)) { - await copyDir(resolved.skillDir, destDir); - } - - let lockEntry: LockedSkill; - if (resolved.type === "git") { - lockEntry = { - source: dep.source, - resolved_url: resolved.resolvedUrl, - resolved_path: resolved.resolvedPath, - ...(resolved.resolvedRef ? { resolved_ref: resolved.resolvedRef } : {}), - resolved_commit: resolved.commit, - }; - } else if (resolved.type === "well-known") { - lockEntry = { - source: dep.source, - resolved_url: resolved.resolvedUrl, - }; - } else { - lockEntry = { source: dep.source }; - } - - newLock.skills[name] = lockEntry; - installed.push(name); - } - - // Prune stale managed skills (skip in frozen mode to avoid disk/lockfile inconsistency) - if (!frozen && lockfile) { - for (const [name, locked] of Object.entries(lockfile.skills)) { - if (newLock.skills[name]) {continue;} // still tracked - if (isInPlaceSkill(locked.source)) {continue;} - const skillPath = managedSkillPath(skillsDir, name); - if (!skillPath) {continue;} - await rm(skillPath, { recursive: true, force: true }); - pruned.push(name); - } - } - } else if (!frozen && lockfile) { - for (const [name, locked] of Object.entries(lockfile.skills)) { - if (isInPlaceSkill(locked.source)) {continue;} - const skillPath = managedSkillPath(skillsDir, name); - if (!skillPath) {continue;} - await rm(skillPath, { recursive: true, force: true }); - pruned.push(name); - } - } - - // 3. Resolve and install subagent markdown files - const installedSubagents: SubagentDeclaration[] = []; - if (frozen) { - validateFrozenSubagents(config.subagents, lockfile); - if (config.subagents.length > 0) { - const loaded = await loadInstalledSubagents(subagentsDir, config.subagents); - if (loaded.issues.length > 0) { - throw new InstallError(loaded.issues.map((issue) => issue.issue).join("\n")); - } - installedSubagents.push(...loaded.subagents); - } - } else if (config.subagents.length > 0) { - for (const subagentConfig of config.subagents) { - let resolved: Awaited>; - try { - resolved = await resolveSubagent(subagentConfig, { - stateDir: getCacheStateDir(), - projectRoot: scope.root, - defaultRepositorySource: config.defaultRepositorySource, - minimumReleaseAge: config.minimum_release_age, - minimumReleaseAgeExclude: config.minimum_release_age_exclude, - trust: config.trust, - }); - } catch (err) { - if (err instanceof GitError || err instanceof TrustError) {throw err;} - const msg = err instanceof Error ? err.message : String(err); - throw new InstallError(`Failed to resolve subagent "${subagentConfig.name}": ${msg}`); - } - installedSubagents.push(resolved.subagent); - newLock.subagents[resolved.subagent.name] = lockEntryForSubagent(resolved); - } - } - - const shouldWriteLockfile = !frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0); - let installedSubagentsSynced = false; - - try { - if (!frozen) { - await writeInstalledSubagents(subagentsDir, installedSubagents); - await pruneInstalledSubagents(subagentsDir, config.subagents); - installedSubagentsSynced = true; - } - } catch (err) { - if (shouldWriteLockfile) { - try { - await writeLockfile(lockPath, { - ...newLock, - subagents: installedSubagentsSynced - ? newLock.subagents - : unchangedSubagentLockEntries(lockfile, newLock), - }); - } catch { - // Preserve the original install failure; this recovery write is best-effort. - } - } - if (err instanceof InstalledSubagentWriteError) { - throw new InstallError(err.message); - } - throw err; - } - - if (shouldWriteLockfile) { - await writeLockfile(lockPath, { - ...newLock, - subagents: unchangedSubagentLockEntries(lockfile, newLock), - }); - } - - // 4. Gitignore (skip for user scope — ~/.agents/ is not a git repo) - if (scope.scope === "project") { - // For wildcard entries all expanded skills are managed (wildcards can't be in-place) - const managedNames = installed.filter((name) => { - const dep = config.skills.find((s) => s.name === name); - // Wildcard-sourced skills are always managed - if (!dep || isWildcardDep(dep)) {return true;} - return !isInPlaceSkill(dep.source); - }); - const managedSubagentNames = frozen - ? Object.keys(lockfile?.subagents ?? {}) - : installedSubagents.map((subagent) => subagent.name); - await writeAgentsGitignore( - agentsDir, - managedNames, - managedSubagentNames, - ); - - // Health check: warn if agents.lock and .agents/.gitignore are not in root .gitignore - const missing = await checkRootGitignoreEntries(scope.root); - if (missing.length > 0) { - console.log(chalk.yellow(`Warning: ${missing.join(", ")} should be in .gitignore. Run 'npx @sentry/dotagents doctor --fix' to fix.`)); - } - } - - // 5. Symlinks — create per-agent symlinks so each agent discovers skills - if (scope.scope === "user") { - const seen = new Set(); - for (const agentId of config.agents) { - const agent = getAgent(agentId); - if (!agent?.userSkillsParentDirs) {continue;} - for (const dir of agent.userSkillsParentDirs) { - if (seen.has(dir)) {continue;} - seen.add(dir); - await ensureSkillsSymlink(agentsDir, dir); - } - } - } else { - const targets = config.symlinks?.targets ?? []; - for (const target of targets) { - await ensureSkillsSymlink(agentsDir, join(scope.root, target)); - } - - const seenParentDirs = new Set(targets); - for (const agentId of config.agents) { - const agent = getAgent(agentId); - if (!agent?.skillsParentDir) {continue;} - if (seenParentDirs.has(agent.skillsParentDir)) {continue;} - seenParentDirs.add(agent.skillsParentDir); - await ensureSkillsSymlink(agentsDir, join(scope.root, agent.skillsParentDir)); - } - } - - // 6. Write MCP config files - const mcpResolver = scope.scope === "user" ? userMcpResolver() : projectMcpResolver(scope.root); - await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), mcpResolver); - - // 7. Write hook config files (skip for user scope) - let hookWarnings: { agent: string; message: string }[] = []; - if (scope.scope === "project") { - hookWarnings = await writeHookConfigs( - config.agents, - toHookDeclarations(config.hooks), - projectHookResolver(scope.root), + 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.", ); } - // 8. Write custom subagent files - const subagentResolver = scope.scope === "user" - ? userSubagentResolver() - : projectSubagentResolver(scope.root); - let subagentResult: Awaited>; - try { - subagentResult = await writeSubagentConfigs( - config.agents, - installedSubagents, - subagentResolver, - ); - if (!frozen) { - await pruneSubagentConfigs(config.agents, installedSubagents, subagentResolver); - } - if (shouldWriteLockfile) { - await writeLockfile(lockPath, newLock); - } - } catch (err) { - if (shouldWriteLockfile) { - try { - await writeLockfile(lockPath, { - ...newLock, - subagents: unchangedSubagentLockEntries(lockfile, newLock), - }); - } catch { - // Preserve the runtime config failure; this recovery write is best-effort. - } - } - throw err; - } - - return { installed, skipped, pruned, hookWarnings, subagentWarnings: subagentResult.warnings }; + 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, + }; + + const writeLock = !frozen && ( + !!lockfile || + config.skills.length > 0 || + config.subagents.length > 0 || + config.plugins.length > 0 + ); + if (writeLock) { + // Preserve resolved dependency state even if later canonical file writes fail. + await writeLockfile(scope.lockPath, newLock); + } + await writeCanonicalSubagents(config, scope, subagents.subagents, frozen); + + await writeInstallGitignore(config, lockfile, scope, { + installedSkillNames: skills.installed, + subagents: subagents.subagents, + plugins: plugins.plugins, + }, frozen); + await writeSkillSymlinks(config, scope); + await writeMcpRuntime(config, scope); + const hookWarnings = await writeHookRuntime(config, scope); + const subagentWarnings = await writeSubagentRuntime(config, scope, subagents.subagents, frozen); + const pluginWarnings = await writePluginRuntime(config, scope, plugins.plugins, frozen); + + return { + installed: skills.installed, + pruned: skills.pruned, + prunedPlugins: plugins.pruned, + hookWarnings, + subagentWarnings, + pluginWarnings, + }; } export default async function install(args: string[], flags?: { user?: boolean }): Promise { @@ -539,12 +126,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 new file mode 100644 index 0000000..a231d3d --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/agent-runtime.ts @@ -0,0 +1,107 @@ +import { join } from "node:path"; +import type { AgentsConfig } from "../../../config/schema.js"; +import type { ScopeRoot } from "../../../scope.js"; +import { getAgent } from "../../../targets/registry.js"; +import { ensureSkillsSymlink } from "../../../symlinks/manager.js"; +import { projectMcpResolver, toMcpDeclarations, writeMcpConfigs } from "../../../targets/mcp-writer.js"; +import { projectHookResolver, toHookDeclarations, writeHookConfigs } from "../../../targets/hook-writer.js"; +import { userMcpResolver } from "../../../targets/paths.js"; +import { + pruneSubagentConfigs, + projectSubagentResolver, + userSubagentResolver, + writeSubagentConfigs, +} from "../../../subagents/writer.js"; +import { prunePluginOutputs, writePluginOutputs } from "../../../plugins/runtime/writer.js"; +import type { PluginDeclaration } from "../../../plugins/store.js"; +import type { SubagentDeclaration } from "../../../subagents/types.js"; + +/** Writes agent skill symlinks after canonical install artifacts are ready. */ +export async function writeSkillSymlinks( + config: AgentsConfig, + scope: ScopeRoot, +): Promise { + if (scope.scope === "user") { + const seen = new Set(); + for (const agentId of config.agents) { + const agent = getAgent(agentId); + if (!agent?.userSkillsParentDirs) {continue;} + for (const dir of agent.userSkillsParentDirs) { + if (seen.has(dir)) {continue;} + seen.add(dir); + await ensureSkillsSymlink(scope.agentsDir, dir); + } + } + return; + } + + const targets = config.symlinks?.targets ?? []; + for (const target of targets) { + await ensureSkillsSymlink(scope.agentsDir, join(scope.root, target)); + } + + const seenParentDirs = new Set(targets); + for (const agentId of config.agents) { + const agent = getAgent(agentId); + if (!agent?.skillsParentDir) {continue;} + if (seenParentDirs.has(agent.skillsParentDir)) {continue;} + seenParentDirs.add(agent.skillsParentDir); + await ensureSkillsSymlink(scope.agentsDir, join(scope.root, agent.skillsParentDir)); + } +} + +/** Writes MCP runtime config for configured agents. */ +export async function writeMcpRuntime( + config: AgentsConfig, + scope: ScopeRoot, +): Promise { + const resolver = scope.scope === "user" + ? userMcpResolver() + : projectMcpResolver(scope.root); + await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), resolver); +} + +/** Writes project-scoped hook runtime config for configured agents. */ +export async function writeHookRuntime( + config: AgentsConfig, + scope: ScopeRoot, +): Promise<{ agent: string; message: string }[]> { + if (scope.scope !== "project") {return [];} + return writeHookConfigs( + config.agents, + toHookDeclarations(config.hooks), + projectHookResolver(scope.root), + ); +} + +/** Writes agent-specific subagent runtime projections. */ +export async function writeSubagentRuntime( + config: AgentsConfig, + scope: ScopeRoot, + subagents: SubagentDeclaration[], + frozen?: boolean, +): Promise<{ agent: string; name: string; message: string }[]> { + const resolver = scope.scope === "user" + ? userSubagentResolver() + : projectSubagentResolver(scope.root); + const result = await writeSubagentConfigs(config.agents, subagents, resolver); + if (!frozen) { + await pruneSubagentConfigs(config.agents, subagents, resolver); + } + return result.warnings; +} + +/** Writes project-scoped plugin runtime projections. */ +export async function writePluginRuntime( + config: AgentsConfig, + scope: ScopeRoot, + plugins: PluginDeclaration[], + frozen?: boolean, +): Promise<{ agent: string; name: string; message: string }[]> { + if (scope.scope !== "project") {return [];} + const result = await writePluginOutputs(config.agents, plugins, scope.root); + if (!frozen) { + await prunePluginOutputs(config.agents, plugins, scope.root); + } + return result.warnings; +} diff --git a/packages/dotagents/src/cli/commands/install/errors.ts b/packages/dotagents/src/cli/commands/install/errors.ts new file mode 100644 index 0000000..fb54901 --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/errors.ts @@ -0,0 +1,6 @@ +export class InstallError extends Error { + constructor(message: string) { + super(message); + this.name = "InstallError"; + } +} diff --git a/packages/dotagents/src/cli/commands/install/gitignore.ts b/packages/dotagents/src/cli/commands/install/gitignore.ts new file mode 100644 index 0000000..267c3de --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/gitignore.ts @@ -0,0 +1,84 @@ +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( + config: AgentsConfig, + installed: string[], +): string[] { + return installed.filter((name) => { + const dep = config.skills.find((s) => s.name === name); + if (!dep || isWildcardDep(dep)) {return true;} + return !isInPlaceSkill(dep.source); + }); +} + +function managedSubagentNames( + lockfile: Lockfile | null, + subagents: SubagentDeclaration[], + frozen?: boolean, +): string[] { + return frozen + ? Object.keys(lockfile?.subagents ?? {}) + : subagents.map((subagent) => subagent.name); +} + +function managedPluginNames( + lockfile: Lockfile | null, + plugins: PluginDeclaration[], + frozen?: boolean, +): string[] { + return frozen + ? Object.entries(lockfile?.plugins ?? {}) + .filter(([, locked]) => !isInPlacePluginSource(locked.source)) + .map(([name]) => name) + : plugins + .filter((plugin) => !isInPlacePluginSource(plugin.source)) + .map((plugin) => plugin.name); +} + +/** Regenerates project `.agents/.gitignore` from install results and lockfile state. */ +export async function writeInstallGitignore( + config: AgentsConfig, + lockfile: Lockfile | null, + scope: ScopeRoot, + artifacts: InstallGitignoreArtifacts, + frozen?: boolean, +): Promise { + if (scope.scope !== "project") {return;} + + const managedSkills = [ + ...managedSkillNames(config, artifacts.installedSkillNames), + ...await filterManagedPluginSkillNames( + await projectedPiSkillNames(config.agents, artifacts.plugins), + config, + scope.skillsDir, + scope.pluginsDir, + ), + ]; + + await writeAgentsGitignore( + scope.agentsDir, + managedSkills, + managedSubagentNames(lockfile, artifacts.subagents, frozen), + managedPluginNames(lockfile, artifacts.plugins, frozen), + ); + + const missing = await checkRootGitignoreEntries(scope.root); + if (missing.length > 0) { + console.log(chalk.yellow(`Warning: ${missing.join(", ")} should be in .gitignore. Run 'npx @sentry/dotagents doctor --fix' to fix.`)); + } +} diff --git a/packages/dotagents/src/cli/commands/install/plugins.ts b/packages/dotagents/src/cli/commands/install/plugins.ts new file mode 100644 index 0000000..cb05ca1 --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/plugins.ts @@ -0,0 +1,132 @@ +import { mkdir } from "node:fs/promises"; +import type { AgentsConfig, PluginConfig } from "../../../config/schema.js"; +import type { Lockfile } from "../../../lockfile/schema.js"; +import type { ScopeRoot } from "../../../scope.js"; +import { + installPluginBundle, + isInPlacePluginSource, + isProjectPluginSource, + isSameProjectPluginConfig, + loadInstalledPlugins, + lockEntryForPlugin, + type PluginDeclaration, + pruneInstalledPlugins, + resolvePlugin, +} from "../../../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 assertNoSameProjectPluginConfigs( + plugins: PluginConfig[], + pluginsDir: string, + projectRoot: string, +): void { + for (const plugin of plugins) { + if (isSameProjectPluginConfig(plugin, pluginsDir, projectRoot)) { + throw new InstallError( + `Plugin "${plugin.name}" source resolves inside this project's .agents/plugins/ tree. ` + + "Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.", + ); + } + } +} + +function staleManagedPluginNames( + current: Lockfile | null, + nextPlugins: Lockfile["plugins"], +): string[] { + if (!current) {return [];} + return Object.entries(current.plugins) + .filter(([name, locked]) => !nextPlugins[name] && !isInPlacePluginSource(locked.source)) + .map(([name]) => name); +} + +/** Resolves, installs, and prunes canonical plugin bundles for install. */ +export async function installPlugins( + config: AgentsConfig, + lockfile: Lockfile | null, + scope: ScopeRoot, + frozen?: boolean, +): Promise { + const plugins: PluginDeclaration[] = []; + const pruned: string[] = []; + const lockEntries: Lockfile["plugins"] = {}; + + if (frozen) { + validateFrozenPlugins(config.plugins, lockfile); + if (scope.scope === "project") { + assertNoSameProjectPluginConfigs(config.plugins, scope.pluginsDir, scope.root); + } + if (config.plugins.length > 0) { + const loaded = await loadInstalledPlugins(scope.pluginsDir, config.plugins); + if (loaded.issues.length > 0) { + throw new InstallError(loaded.issues.join("\n")); + } + plugins.push(...loaded.plugins); + } + return { plugins, pruned, lockEntries }; + } + + if (config.plugins.length > 0) { + await mkdir(scope.pluginsDir, { recursive: true }); + for (const pluginConfig of config.plugins) { + let resolved: Awaited>; + try { + resolved = await resolvePlugin(pluginConfig, { + stateDir: getCacheStateDir(), + projectRoot: scope.root, + defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge: config.minimum_release_age, + minimumReleaseAgeExclude: config.minimum_release_age_exclude, + trust: config.trust, + }); + } catch (err) { + if (err instanceof GitError || err instanceof TrustError) {throw err;} + const msg = err instanceof Error ? err.message : String(err); + throw new InstallError(`Failed to resolve plugin "${pluginConfig.name}": ${msg}`); + } + if (isProjectPluginSource(resolved.plugin.pluginDir, scope.pluginsDir)) { + throw new InstallError( + `Plugin "${resolved.plugin.name}" source resolves inside this project's .agents/plugins/ tree. ` + + "Same-project plugins cannot be installed into the same project; use an external source path or a separate repo.", + ); + } + plugins.push(await installPluginBundle(scope.pluginsDir, resolved)); + lockEntries[resolved.plugin.name] = lockEntryForPlugin(resolved); + } + } + + if (lockfile) { + pruned.push(...await pruneInstalledPlugins( + scope.pluginsDir, + staleManagedPluginNames(lockfile, lockEntries), + )); + } + + return { plugins, pruned, lockEntries }; +} diff --git a/packages/dotagents/src/cli/commands/install/skills.ts b/packages/dotagents/src/cli/commands/install/skills.ts new file mode 100644 index 0000000..694a774 --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/skills.ts @@ -0,0 +1,225 @@ +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"; +import type { ScopeRoot } from "../../../scope.js"; +import { isInPlaceSkill, managedSkillPath } from "../../../utils/fs.js"; +import { + applyDefaultRepositorySource, + copyDir, + GitError, + resolveSkill, + resolveWildcardSkills, + sourcesMatch, + TrustError, + type ResolvedSkill, + validateTrustedSource, +} from "@sentry/dotagents-lib"; +import { getCacheStateDir, HOST_SCAN_DIRS } from "../../cache.js"; +import { InstallError } from "./errors.js"; + +/** Expanded skill ready for install: either an explicit entry or wildcard result. */ +interface ExpandedSkill { + name: string; + dep: SkillDependency; + resolved?: ResolvedSkill; +} + +export interface InstallSkillsResult { + installed: string[]; + pruned: string[]; + lockEntries: Lockfile["skills"]; +} + +/** + * Expand config skills into a flat list, resolving wildcards. + * Explicit entries always win over wildcard-discovered skills. + */ +async function expandSkills( + config: { + skills: SkillDependency[]; + trust?: Parameters[1]; + defaultRepositorySource: RepositorySource; + }, + lockfile: Lockfile | null, + opts: { frozen?: boolean; projectRoot: string; minimumReleaseAge?: number; minimumReleaseAgeExclude?: string[] }, +): Promise { + const regularDeps = config.skills.filter((d) => !isWildcardDep(d)); + const wildcardDeps = config.skills.filter(isWildcardDep); + const explicitNames = new Set(regularDeps.map((d) => d.name)); + + const expanded: ExpandedSkill[] = []; + for (const dep of regularDeps) { + expanded.push({ name: dep.name, dep }); + } + + const wildcardNames = new Map(); + for (const wDep of wildcardDeps) { + const wildcardSourceForTrust = applyDefaultRepositorySource( + wDep.source, + config.defaultRepositorySource, + ); + validateTrustedSource(wildcardSourceForTrust, config.trust); + const excludeSet = new Set(wDep.exclude); + + if (opts.frozen) { + if (!lockfile) {continue;} + for (const [name, locked] of Object.entries(lockfile.skills)) { + if (!sourcesMatch(locked.source, wDep.source)) {continue;} + if (explicitNames.has(name)) {continue;} + if (excludeSet.has(name)) {continue;} + expanded.push({ name, dep: wDep }); + } + continue; + } + + let named; + try { + named = await resolveWildcardSkills(wDep, { + stateDir: getCacheStateDir(), + scanDirs: HOST_SCAN_DIRS, + projectRoot: opts.projectRoot, + defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge: opts.minimumReleaseAge, + minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude, + }); + } catch (err) { + if (err instanceof GitError || err instanceof TrustError) {throw err;} + const msg = err instanceof Error ? err.message : String(err); + throw new InstallError(`Failed to resolve wildcard source "${wDep.source}": ${msg}`); + } + + for (const { name, resolved } of named) { + if (explicitNames.has(name)) {continue;} + const existingSource = wildcardNames.get(name); + if (existingSource && !sourcesMatch(existingSource, wDep.source)) { + throw new InstallError( + `Skill "${name}" found in both wildcard sources: "${existingSource}" and "${wDep.source}". ` + + "Use an explicit [[skills]] entry or add it to one source's exclude list.", + ); + } + wildcardNames.set(name, wDep.source); + expanded.push({ name, dep: wDep, resolved }); + } + } + + return expanded; +} + +function lockEntryForSkill(dep: SkillDependency, resolved: ResolvedSkill): LockedSkill { + if (resolved.type === "git") { + return { + source: dep.source, + resolved_url: resolved.resolvedUrl, + resolved_path: resolved.resolvedPath, + ...(resolved.resolvedRef ? { resolved_ref: resolved.resolvedRef } : {}), + resolved_commit: resolved.commit, + }; + } + if (resolved.type === "well-known") { + return { + source: dep.source, + resolved_url: resolved.resolvedUrl, + }; + } + return { source: dep.source }; +} + +/** Resolves, copies, and prunes canonical skill directories for install. */ +export async function installSkills( + config: AgentsConfig, + lockfile: Lockfile | null, + scope: ScopeRoot, + frozen?: boolean, +): Promise { + const lockEntries: Lockfile["skills"] = {}; + const installed: string[] = []; + const pruned: string[] = []; + + await mkdir(scope.skillsDir, { recursive: true }); + + if (config.skills.length > 0) { + if (frozen && !lockfile) { + throw new InstallError("--frozen requires agents.lock to exist."); + } + + const expanded = await expandSkills( + { + skills: config.skills, + trust: config.trust, + defaultRepositorySource: config.defaultRepositorySource, + }, + lockfile, + { + frozen, + projectRoot: scope.root, + minimumReleaseAge: config.minimum_release_age, + minimumReleaseAgeExclude: config.minimum_release_age_exclude, + }, + ); + + if (frozen) { + for (const { name } of expanded) { + if (!lockfile!.skills[name]) { + throw new InstallError( + `--frozen: skill "${name}" is in agents.toml but missing from agents.lock.`, + ); + } + } + } + + for (const item of expanded) { + const { name, dep } = item; + const sourceForTrust = applyDefaultRepositorySource( + dep.source, + config.defaultRepositorySource, + ); + validateTrustedSource(sourceForTrust, config.trust); + + 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; + } else { + try { + resolved = await resolveSkill(name, dep, { + stateDir: getCacheStateDir(), + scanDirs: HOST_SCAN_DIRS, + projectRoot: scope.root, + defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge: config.minimum_release_age, + minimumReleaseAgeExclude: config.minimum_release_age_exclude, + }); + } catch (err) { + if (err instanceof GitError || err instanceof TrustError) {throw err;} + const msg = err instanceof Error ? err.message : String(err); + throw new InstallError(`Failed to resolve skill "${name}": ${msg}`); + } + } + + if (resolve(resolved.skillDir) !== resolve(destDir)) { + await copyDir(resolved.skillDir, destDir); + } + + lockEntries[name] = lockEntryForSkill(dep, resolved); + installed.push(name); + } + } + + if (!frozen && lockfile) { + for (const [name, locked] of Object.entries(lockfile.skills)) { + if (lockEntries[name]) {continue;} + if (isInPlaceSkill(locked.source)) {continue;} + const skillPath = managedSkillPath(scope.skillsDir, name); + if (!skillPath) {continue;} + await rm(skillPath, { recursive: true, force: true }); + pruned.push(name); + } + } + + return { installed, pruned, lockEntries }; +} diff --git a/packages/dotagents/src/cli/commands/install/subagents.ts b/packages/dotagents/src/cli/commands/install/subagents.ts new file mode 100644 index 0000000..43a3841 --- /dev/null +++ b/packages/dotagents/src/cli/commands/install/subagents.ts @@ -0,0 +1,106 @@ +import { join } from "node:path"; +import type { AgentsConfig, SubagentConfig } from "../../../config/schema.js"; +import type { Lockfile } from "../../../lockfile/schema.js"; +import type { ScopeRoot } from "../../../scope.js"; +import type { SubagentDeclaration } from "../../../subagents/types.js"; +import { + InstalledSubagentWriteError, + lockEntryForSubagent, + loadInstalledSubagents, + pruneInstalledSubagents, + resolveSubagent, + writeInstalledSubagents, +} from "../../../subagents/store.js"; +import { GitError, TrustError } from "@sentry/dotagents-lib"; +import { getCacheStateDir } from "../../cache.js"; +import { InstallError } from "./errors.js"; + +export interface InstallSubagentsResult { + subagents: SubagentDeclaration[]; + lockEntries: Lockfile["subagents"]; +} + +function validateFrozenSubagents( + subagents: SubagentConfig[], + lockfile: Lockfile | null, +): void { + if (subagents.length === 0) {return;} + if (!lockfile) { + throw new InstallError("--frozen requires agents.lock to exist."); + } + + for (const subagent of subagents) { + if (!lockfile.subagents[subagent.name]) { + throw new InstallError( + `--frozen: subagent "${subagent.name}" is in agents.toml but missing from agents.lock.`, + ); + } + } +} + +/** Resolves subagent declarations and lock entries without writing runtime files. */ +export async function installSubagents( + config: AgentsConfig, + lockfile: Lockfile | null, + scope: ScopeRoot, + frozen?: boolean, +): Promise { + const subagentsDir = join(scope.agentsDir, "agents"); + const subagents: SubagentDeclaration[] = []; + const lockEntries: Lockfile["subagents"] = {}; + + if (frozen) { + validateFrozenSubagents(config.subagents, lockfile); + if (config.subagents.length > 0) { + const loaded = await loadInstalledSubagents(subagentsDir, config.subagents); + if (loaded.issues.length > 0) { + throw new InstallError(loaded.issues.map((issue) => issue.issue).join("\n")); + } + subagents.push(...loaded.subagents); + } + return { subagents, lockEntries }; + } + + for (const subagentConfig of config.subagents) { + let resolved: Awaited>; + try { + resolved = await resolveSubagent(subagentConfig, { + stateDir: getCacheStateDir(), + projectRoot: scope.root, + defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge: config.minimum_release_age, + minimumReleaseAgeExclude: config.minimum_release_age_exclude, + trust: config.trust, + }); + } catch (err) { + if (err instanceof GitError || err instanceof TrustError) {throw err;} + const msg = err instanceof Error ? err.message : String(err); + throw new InstallError(`Failed to resolve subagent "${subagentConfig.name}": ${msg}`); + } + + subagents.push(resolved.subagent); + lockEntries[resolved.subagent.name] = lockEntryForSubagent(resolved); + } + + return { subagents, lockEntries }; +} + +/** Writes canonical `.agents/agents` markdown files before the lockfile commit. */ +export async function writeCanonicalSubagents( + config: AgentsConfig, + scope: ScopeRoot, + subagents: SubagentDeclaration[], + frozen?: boolean, +): Promise { + if (frozen) {return;} + const subagentsDir = join(scope.agentsDir, "agents"); + try { + await writeInstalledSubagents(subagentsDir, subagents); + await pruneInstalledSubagents(subagentsDir, config.subagents); + } catch (err) { + if (err instanceof InstalledSubagentWriteError) { + throw new InstallError(err.message); + } + throw err; + } +} 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 06df0f5..f23b027 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -38,6 +38,7 @@ describe("runRemove", () => { await exec("git", ["init"], { cwd: repoDir }); await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); @@ -97,10 +98,135 @@ describe("runRemove", () => { await runRemove({ scope, skillName: "pdf" }); const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); - expect(gitignore).not.toContain("/skills/pdf/"); + expect(gitignore).not.toContain("/skills/pdf"); expect(gitignore).toContain("/agents/old-reviewer.md"); }); + it("keeps managed plugins in .agents/.gitignore after removing a skill", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[skills]] +name = "pdf" +source = "path:local-skills/pdf" + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + await mkdir(join(projectRoot, ".agents", "skills", "pdf"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"), SKILL_MD("pdf")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + pdf: { + source: "path:local-skills/pdf", + }, + }, + plugins: { + "review-tools": { + source: "path:plugins/review-tools", + }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await runRemove({ scope, skillName: "pdf" }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/skills/pdf"); + expect(gitignore).toContain("/plugins/review-tools/"); + expect(gitignore).not.toContain("/plugins/marketplace.json"); + }); + + it("does not gitignore same-project canonical plugins after removing a skill", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[skills]] +name = "pdf" +source = "path:local-skills/pdf" + +[[plugins]] +name = "local-tools" +source = "path:." +`, + ); + await mkdir(join(projectRoot, ".agents", "skills", "pdf"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"), SKILL_MD("pdf")); + const pluginDir = join(projectRoot, ".agents", "plugins", "local-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "local-tools" }, null, 2)); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + pdf: { + source: "path:local-skills/pdf", + }, + }, + plugins: { + "local-tools": { + source: "path:plugins/local-tools", + }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await runRemove({ scope, skillName: "pdf" }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/skills/pdf"); + expect(gitignore).not.toContain("/plugins/local-tools/"); + }); + + it("does not gitignore orphan skills that collide with Pi plugin projections after removing a skill", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["pi"] + +[[skills]] +name = "pdf" +source = "path:local-skills/pdf" + +[[plugins]] +name = "review-tools" +source = "path:plugins/review-tools" +`, + ); + await mkdir(join(projectRoot, ".agents", "skills", "pdf"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"), SKILL_MD("pdf")); + await mkdir(join(projectRoot, ".agents", "skills", "review"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "review", "SKILL.md"), SKILL_MD("review")); + const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(join(pluginDir, "skills", "review"), { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ name: "review-tools" }, null, 2)); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), SKILL_MD("review")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + pdf: { + source: "path:local-skills/pdf", + }, + }, + plugins: { + "review-tools": { + source: "path:plugins/review-tools", + }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await runRemove({ scope, skillName: "pdf" }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/skills/review"); + expect(gitignore).toContain("/plugins/review-tools/"); + }); + it("throws RemoveError for skill not in config", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); const scope = resolveScope("project", projectRoot); @@ -179,6 +305,7 @@ describe("runRemoveSource", () => { await exec("git", ["init"], { cwd: repoDir }); await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); await exec("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); await mkdir(join(repoDir, "pdf"), { recursive: true }); await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); @@ -194,6 +321,7 @@ describe("runRemoveSource", () => { await exec("git", ["init"], { cwd: otherRepoDir }); await exec("git", ["config", "user.email", "test@test.com"], { cwd: otherRepoDir }); await exec("git", ["config", "user.name", "Test"], { cwd: otherRepoDir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: otherRepoDir }); await mkdir(join(otherRepoDir, "deploy"), { recursive: true }); await writeFile(join(otherRepoDir, "deploy", "SKILL.md"), SKILL_MD("deploy")); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index ec3dd93..55d4ea3 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -8,11 +8,14 @@ import { isWildcardDep } from "../../config/schema.js"; import { removeSkillFromConfig, removeSkillBlocksBySource, addExcludeToWildcard } from "../../config/writer.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; +import { filterManagedPluginSkillNames } from "../../gitignore/skills.js"; import { writeAgentsGitignore } from "../../gitignore/writer.js"; import { sourcesMatch, parseOwnerRepoShorthand, isExplicitSourceSpecifier } from "@sentry/dotagents-lib"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { isInPlaceSkill } from "../../utils/fs.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins } from "../../plugins/store.js"; +import { projectedPiSkillNames } from "../../plugins/runtime/writer.js"; export class RemoveError extends Error { constructor(message: string) { @@ -162,10 +165,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], ); } diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 7c7df5a..e3fe2b1 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -10,7 +10,7 @@ import { writeLockfile } from "../../lockfile/writer.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { loadConfig } from "../../config/loader.js"; import { resolveScope } from "../../scope.js"; -import { DOTAGENTS_SUBAGENT_MARKER } from "../../agents/definitions/helpers.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "../../subagents/format.js"; import { exec } from "@sentry/dotagents-lib"; const SKILL_MD = (name: string) => `--- @@ -199,6 +199,7 @@ describe("runSync", () => { await exec("git", ["init"], { cwd: skillRepo }); await exec("git", ["config", "user.email", "test@example.com"], { cwd: skillRepo }); await exec("git", ["config", "user.name", "Test User"], { cwd: skillRepo }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: skillRepo }); await mkdir(join(skillRepo, "pdf"), { recursive: true }); await writeFile(join(skillRepo, "pdf", "SKILL.md"), SKILL_MD("pdf")); await exec("git", ["add", "."], { cwd: skillRepo }); @@ -209,6 +210,7 @@ describe("runSync", () => { await exec("git", ["init", "-b", "main"], { cwd: projectSeed }); await exec("git", ["config", "user.email", "test@example.com"], { cwd: projectSeed }); await exec("git", ["config", "user.name", "Test User"], { cwd: projectSeed }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: projectSeed }); await writeFile( join(projectSeed, "agents.toml"), `version = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${skillRepo}"\n`, @@ -222,8 +224,10 @@ describe("runSync", () => { await exec("git", ["clone", "--branch", "main", projectOrigin, bobRepo], { cwd: tmpDir }); await exec("git", ["config", "user.email", "alice@example.com"], { cwd: aliceRepo }); await exec("git", ["config", "user.name", "Alice"], { cwd: aliceRepo }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: aliceRepo }); await exec("git", ["config", "user.email", "bob@example.com"], { cwd: bobRepo }); await exec("git", ["config", "user.name", "Bob"], { cwd: bobRepo }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: bobRepo }); try { process.env["DOTAGENTS_STATE_DIR"] = aliceStateDir; @@ -267,7 +271,7 @@ describe("runSync", () => { process.env["DOTAGENTS_STATE_DIR"] = previousStateDir; } } - }, 30_000); + }, 90_000); it("detects missing skills", async () => { await writeFile( @@ -281,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"), @@ -355,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 () => { @@ -617,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 7228845..ee6c5b8 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -8,20 +8,23 @@ 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 "../../agents/registry.js"; -import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js"; -import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js"; -import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; -import { loadInstalledSubagents, pruneInstalledSubagents } from "../../agents/subagent-store.js"; -import { userMcpResolver } from "../../agents/paths.js"; +import { getAgent } from "../../targets/registry.js"; +import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../targets/mcp-writer.js"; +import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../targets/hook-writer.js"; +import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../subagents/writer.js"; +import { loadInstalledSubagents, pruneInstalledSubagents } from "../../subagents/store.js"; +import { isInPlacePluginSource, isSameProjectPluginConfig, loadInstalledPlugins, pruneInstalledPlugins } from "../../plugins/store.js"; +import { 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,44 @@ 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); + 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 +429,7 @@ export async function runSync(opts: SyncOptions): Promise { mcpRepaired, hooksRepaired, subagentsRepaired, + pluginsRepaired, }; } @@ -364,6 +483,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 +497,7 @@ export default async function sync(_args: string[], flags?: { user?: boolean }): case "mcp": case "hooks": case "subagents": + case "plugins": console.log(chalk.yellow(` warn: ${issue.message}`)); break; case "missing": diff --git a/packages/dotagents/src/cli/commands/trust.test.ts b/packages/dotagents/src/cli/commands/trust.test.ts index 6ee0aad..bd20dd7 100644 --- a/packages/dotagents/src/cli/commands/trust.test.ts +++ b/packages/dotagents/src/cli/commands/trust.test.ts @@ -33,6 +33,7 @@ describe("trust", () => { root: projectRoot, agentsDir: join(projectRoot, ".agents"), skillsDir: join(projectRoot, ".agents", "skills"), + pluginsDir: join(projectRoot, ".agents", "plugins"), configPath: join(projectRoot, "agents.toml"), lockPath: join(projectRoot, "agents.lock"), }; diff --git a/packages/dotagents/src/cli/ensure-user-scope.test.ts b/packages/dotagents/src/cli/ensure-user-scope.test.ts index ef03e7a..769bf29 100644 --- a/packages/dotagents/src/cli/ensure-user-scope.test.ts +++ b/packages/dotagents/src/cli/ensure-user-scope.test.ts @@ -4,7 +4,7 @@ import { readFile, rm, mkdtemp } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { ensureUserScopeBootstrapped } from "./ensure-user-scope.js"; -import { allAgentIds } from "../agents/registry.js"; +import { allAgentIds } from "../targets/registry.js"; import type { ScopeRoot } from "../scope.js"; describe("ensureUserScopeBootstrapped", () => { @@ -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/ensure-user-scope.ts b/packages/dotagents/src/cli/ensure-user-scope.ts index 04399fc..e5eddf2 100644 --- a/packages/dotagents/src/cli/ensure-user-scope.ts +++ b/packages/dotagents/src/cli/ensure-user-scope.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import chalk from "chalk"; import { generateDefaultConfig } from "../config/writer.js"; -import { allAgentIds } from "../agents/registry.js"; +import { allAgentIds } from "../targets/registry.js"; import type { ScopeRoot } from "../scope.js"; /** diff --git a/packages/dotagents/src/cli/index.ts b/packages/dotagents/src/cli/index.ts index 84cb7ee..a61bdfb 100644 --- a/packages/dotagents/src/cli/index.ts +++ b/packages/dotagents/src/cli/index.ts @@ -32,7 +32,7 @@ Commands: add Add a skill dependency remove Remove a skill or all skills from a source sync Reconcile state offline and repair generated config - list Show installed skills + list Show installed skills and plugins mcp Manage MCP server declarations trust Manage trusted sources doctor Check project health and fix issues diff --git a/packages/dotagents/src/config/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 3b92ed4..6d63bfc 100644 --- a/packages/dotagents/src/config/loader.ts +++ b/packages/dotagents/src/config/loader.ts @@ -1,7 +1,8 @@ import { readFile } from "node:fs/promises"; import { parse as parseTOML } from "smol-toml"; import { agentsConfigSchema, isWildcardDep, type AgentsConfig } from "./schema.js"; -import { allAgentIds } from "../agents/registry.js"; +import { allAgentIds } from "../targets/registry.js"; +import { allPluginOnlyAgentIds } from "../plugins/targets.js"; import { applyDefaultRepositorySource, parseSource } from "@sentry/dotagents-lib"; export class ConfigError extends Error { @@ -36,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/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..b20691b --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/marketplace.ts @@ -0,0 +1,128 @@ +import { join } from "node:path"; +import type { PluginDeclaration } from "../store.js"; +import { selectedAgentIds } from "../targets.js"; +import { DOTAGENTS_METADATA, stableJson } from "./files.js"; +import { manifestString, relativePath } from "./manifest-values.js"; +import type { RuntimeOutput } from "./types.js"; + +/** Lists managed plugin marketplace files that may be generated or pruned. */ +export function marketplaceOutputPaths(projectRoot: string): string[] { + return [ + join(projectRoot, ".agents", "plugins", "marketplace.json"), + join(projectRoot, ".claude-plugin", "marketplace.json"), + join(projectRoot, ".cursor-plugin", "marketplace.json"), + ]; +} + +/** Builds target-specific marketplace JSON outputs for selected plugins. */ +export function marketplaceOutputs( + agentIds: string[], + projectRoot: string, + plugins: PluginDeclaration[], +): RuntimeOutput[] { + if (plugins.length === 0) {return [];} + + const outputs: RuntimeOutput[] = []; + const claudePlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); + const cursorPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); + const codexPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); + + if (claudePlugins.length > 0) { + outputs.push({ + agent: "claude", + filePath: join(projectRoot, ".claude-plugin", "marketplace.json"), + content: stableJson(pathMarketplace(projectRoot, "dotagents", claudePlugins)), + }); + } + if (cursorPlugins.length > 0) { + outputs.push({ + agent: "cursor", + filePath: join(projectRoot, ".cursor-plugin", "marketplace.json"), + content: stableJson(pathMarketplace(projectRoot, "dotagents", cursorPlugins)), + }); + } + if (codexPlugins.length > 0) { + outputs.push({ + agent: "codex", + filePath: join(projectRoot, ".agents", "plugins", "marketplace.json"), + content: stableJson(codexMarketplace(projectRoot, "dotagents-local", codexPlugins)), + }); + } + + return outputs; +} + +function pathMarketplace( + projectRoot: string, + name: string, + plugins: PluginDeclaration[], +): Record { + return { + name, + owner: { + name: "dotagents", + }, + metadata: DOTAGENTS_METADATA, + plugins: plugins + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((plugin) => pathMarketplaceEntry(projectRoot, plugin)), + }; +} + +/** + * Claude and Cursor marketplace projections use path strings instead of Codex's + * structured local source objects, so keep this projection format separate. + */ +function pathMarketplaceEntry( + projectRoot: string, + plugin: PluginDeclaration, +): Record { + const entry: Record = { + name: plugin.name, + source: `./${relativePath(projectRoot, plugin.pluginDir)}`, + }; + const description = manifestString(plugin.manifest, "description"); + if (description) {entry["description"] = description;} + const version = manifestString(plugin.manifest, "version"); + if (version) {entry["version"] = version;} + return entry; +} + +function codexMarketplace( + projectRoot: string, + name: string, + plugins: PluginDeclaration[], +): Record { + return { + interface: { + displayName: "Dotagents Plugins", + }, + metadata: DOTAGENTS_METADATA, + name, + owner: { + name: "dotagents", + }, + plugins: plugins + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((plugin) => codexMarketplaceEntry(projectRoot, plugin)), + }; +} + +function codexMarketplaceEntry( + projectRoot: string, + plugin: PluginDeclaration, +): Record { + const entry: Record = { + category: manifestString(plugin.manifest, "category") ?? "Productivity", + name: plugin.name, + source: { + path: `./${relativePath(projectRoot, plugin.pluginDir)}`, + source: "local", + }, + }; + const description = manifestString(plugin.manifest, "description"); + if (description) {entry["description"] = description;} + const version = manifestString(plugin.manifest, "version"); + if (version) {entry["version"] = version;} + return entry; +} diff --git a/packages/dotagents/src/plugins/runtime/types.ts b/packages/dotagents/src/plugins/runtime/types.ts new file mode 100644 index 0000000..b4a2e9b --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/types.ts @@ -0,0 +1,22 @@ +export interface PluginWriteWarning { + agent: string; + name: string; + message: string; +} + +export interface PluginWriteResult { + warnings: PluginWriteWarning[]; + written: number; +} + +export interface PluginVerifyIssue { + agent: string; + name: string; + issue: string; +} + +export interface RuntimeOutput { + agent: string; + filePath: string; + content: string; +} diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts new file mode 100644 index 0000000..b6c175d --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -0,0 +1,623 @@ +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: "./.agents/plugins/alpha-tools", + source: "local", + }, + version: "1.0.0", + }, + { + category: "Coding", + description: "Tools for beta-tools", + name: "beta-tools", + source: { + path: "./.agents/plugins/beta-tools", + source: "local", + }, + version: "1.0.0", + }, + ], + }); + expect(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")).toBe(`{ + "metadata": { + "managedBy": "dotagents" + }, + "name": "dotagents", + "owner": { + "name": "dotagents" + }, + "plugins": [ + { + "description": "Tools for alpha-tools", + "name": "alpha-tools", + "source": "./.agents/plugins/alpha-tools", + "version": "1.0.0" + }, + { + "description": "Tools for beta-tools", + "name": "beta-tools", + "source": "./.agents/plugins/beta-tools", + "version": "1.0.0" + } + ] +} +`); + expect(await readFile(join(root, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(await readFile(join(root, ".claude-plugin", "marketplace.json"), "utf-8")); + + const 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("does not overwrite unmanaged Codex plugin manifests", async () => { + const alpha = await plugin("alpha-tools"); + await mkdir(join(alpha.pluginDir, ".codex-plugin"), { recursive: true }); + await writeFile(join(alpha.pluginDir, ".codex-plugin", "plugin.json"), "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs(["codex"], [alpha], root); + + expect(result.warnings).toEqual([ + { + agent: "codex", + name: "alpha-tools", + message: `Codex plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json")}`, + }, + ]); + expect(await readFile(join(alpha.pluginDir, ".codex-plugin", "plugin.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + }); + + it("does not overwrite unmanaged Claude plugin manifests", async () => { + const alpha = await plugin("alpha-tools"); + await mkdir(join(alpha.pluginDir, ".claude-plugin"), { recursive: true }); + await writeFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs(["claude"], [alpha], root); + + expect(result.warnings).toEqual([ + { + agent: "claude", + name: "alpha-tools", + message: `Claude plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json")}`, + }, + ]); + expect(await readFile(join(alpha.pluginDir, ".claude-plugin", "plugin.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + }); + + it("does not overwrite unmanaged Cursor plugin manifests", async () => { + const alpha = await plugin("alpha-tools"); + await mkdir(join(alpha.pluginDir, ".cursor-plugin"), { recursive: true }); + await writeFile(join(alpha.pluginDir, ".cursor-plugin", "plugin.json"), "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs(["cursor"], [alpha], root); + + expect(result.warnings).toEqual([ + { + agent: "cursor", + name: "alpha-tools", + message: `Cursor plugin manifest exists and is not managed by dotagents: ${join(root, ".agents", "plugins", "alpha-tools", ".cursor-plugin", "plugin.json")}`, + }, + ]); + expect(await readFile(join(alpha.pluginDir, ".cursor-plugin", "plugin.json"), "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + }); + + it("does not generate runtime outputs when no agent targets are selected", async () => { + const alpha = await plugin("alpha-tools"); + + 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 mkdir(join(root, ".opencode", "plugins"), { recursive: true }); + await writeFile( + join(root, ".opencode", "plugins", "alpha-tools.ts"), + `// Generated by dotagents. Do not edit.\nexport { default } from "../.agents/plugins/alpha-tools/opencode/plugin.ts";\n`, + "utf-8", + ); + await writePluginOutputs(["claude", "cursor", "codex", "grok", "opencode", "pi"], [alpha], root); + + const pruned = await prunePluginOutputs([], [alpha], root); + + expect(pruned).toEqual([ + join(root, ".agents", "plugins", "marketplace.json"), + join(root, ".claude-plugin", "marketplace.json"), + join(root, ".cursor-plugin", "marketplace.json"), + join(root, ".grok", "plugins", "alpha-tools"), + join(root, ".opencode", "plugins", "alpha-tools.ts"), + join(root, ".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", "plugins", "alpha-tools.ts"))).toBe(false); + expect(existsSync(join(root, ".opencode", "skills", "plugin-qa"))).toBe(false); + expect(existsSync(join(root, ".opencode", "agents", "plugin-reviewer.md"))).toBe(false); + expect(existsSync(join(root, ".agents", "skills", "plugin-qa"))).toBe(false); + expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"))).toBe(false); + expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".cursor-plugin", "plugin.json"))).toBe(false); + expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(false); + }); + + 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..b04f348 --- /dev/null +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -0,0 +1,695 @@ +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. */ +export async function prunePluginOutputs( + agentIds: string[], + plugins: PluginDeclaration[], + projectRoot: string, +): Promise { + const pruned: string[] = []; + const desiredMarketplacePaths = new Set( + marketplaceOutputs(agentIds, projectRoot, plugins).map((output) => output.filePath), + ); + for (const filePath of marketplaceOutputPaths(projectRoot)) { + if (desiredMarketplacePaths.has(filePath)) {continue;} + if (!existsSync(filePath)) {continue;} + if (!await isManagedJsonFile(filePath)) {continue;} + await rm(filePath, { force: true }); + pruned.push(filePath); + } + + const desiredGrok = new Set( + plugins + .filter((plugin) => selectedAgentIds(agentIds, plugin).includes("grok")) + .map((plugin) => plugin.name), + ); + const grokDir = join(projectRoot, ".grok", "plugins"); + if (existsSync(grokDir)) { + const entries = await readdir(grokDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) {continue;} + if (desiredGrok.has(entry.name)) {continue;} + const path = join(grokDir, entry.name); + if (!await isManagedProjection(path)) {continue;} + await rm(path, { recursive: true, force: true }); + pruned.push(path); + } + } + + pruned.push(...await pruneLegacyOpenCodeModules(projectRoot)); + + const desiredOpenCodeLinks = new Set( + (await desiredComponentLinks("opencode", agentIds, plugins, projectRoot, [])).map((link) => link.destPath), + ); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".opencode", "skills"), desiredOpenCodeLinks, projectRoot)); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".opencode", "agents"), desiredOpenCodeLinks, projectRoot)); + + const desiredPiLinks = new Set( + (await desiredComponentLinks("pi", agentIds, plugins, projectRoot, [])).map((link) => link.destPath), + ); + pruned.push(...await pruneManagedComponentLinks(join(projectRoot, ".agents", "skills"), desiredPiLinks, projectRoot)); + + const canonicalPluginDir = join(projectRoot, ".agents", "plugins"); + const desiredClaude = new Set( + 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. */ +async function isManagedOpenCodeModule(filePath: string): Promise { + try { + return (await readFile(filePath, "utf-8")).startsWith("// Generated by dotagents."); + } catch { + return false; + } +} + +async function pruneLegacyOpenCodeModules(projectRoot: string): Promise { + const opencodeDir = join(projectRoot, ".opencode", "plugins"); + if (!existsSync(opencodeDir)) {return [];} + + const pruned: string[] = []; + const entries = await readdir(opencodeDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() && !entry.isSymbolicLink()) {continue;} + const path = join(opencodeDir, entry.name); + if (!await isManagedOpenCodeModule(path)) {continue;} + await rm(path, { force: true }); + pruned.push(path); + } + return pruned; +} + +async function pruneManagedComponentLinks( + dir: string, + desiredPaths: Set, + projectRoot: string, +): Promise { + if (!existsSync(dir)) {return [];} + + const pruned: string[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (desiredPaths.has(path)) {continue;} + if (!await isManagedComponentLink(path, projectRoot)) {continue;} + await rm(path, { force: true }); + pruned.push(path); + } + return pruned; +} + +async function symlinkPointsTo(filePath: string, expectedTarget: string): Promise { + try { + const stat = await lstat(filePath); + if (!stat.isSymbolicLink()) {return false;} + const target = await readlink(filePath); + return resolve(dirname(filePath), target) === resolve(expectedTarget); + } catch { + return false; + } +} + +async function pathExists(filePath: string): Promise { + try { + await lstat(filePath); + return true; + } catch (err) { + if (isNotFoundError(err)) {return false;} + throw err; + } +} + +async function isManagedComponentLink(filePath: string, projectRoot: string): Promise { + try { + const stat = await lstat(filePath); + if (!stat.isSymbolicLink()) {return false;} + const target = await readlink(filePath); + return isInside(resolve(dirname(filePath), target), join(projectRoot, ".agents", "plugins")); + } catch { + return false; + } +} + +function isInside(path: string, root: string): boolean { + const relPath = relative(resolve(root), resolve(path)); + return relPath === "" || (!relPath.startsWith("..") && !isAbsolute(relPath)); +} + +function displayName(agent: ComponentProjectionAgent): string { + return agent === "opencode" ? "OpenCode" : "Pi"; +} + +async function directoriesMatch(source: string, dest: string, ignoredNames = new Set()): Promise { + if (!existsSync(source) || !existsSync(dest)) {return false;} + + 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..18b367c --- /dev/null +++ b/packages/dotagents/src/plugins/schema.test.ts @@ -0,0 +1,105 @@ +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("rejects unsafe marketplace paths", () => { + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: "../bad" }], + }).success).toBe(false); + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: "https://example.com/plugin.git" }], + }).success).toBe(false); + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + metadata: { pluginRoot: "../plugins" }, + plugins: [{ name: "bad", source: "./bad" }], + }).success).toBe(false); + }); + + it("rejects marketplace source objects without a path selector", () => { + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: { source: "local" } }], + }).success).toBe(false); + expect(pluginMarketplaceSchema.safeParse({ + name: "dotagents", + plugins: [{ name: "bad", source: { url: "https://example.com/plugin.git" } }], + }).success).toBe(false); + }); +}); diff --git a/packages/dotagents/src/plugins/schema.ts b/packages/dotagents/src/plugins/schema.ts new file mode 100644 index 0000000..0c06e9c --- /dev/null +++ b/packages/dotagents/src/plugins/schema.ts @@ -0,0 +1,120 @@ +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 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: pluginPathSchema, +}).passthrough(); + +const extensionMarketplaceSourceSchema = z.object({ + source: z.string().optional(), + path: pluginPathSchema, +}).passthrough(); + +export const marketplaceSourceSchema = z.union([ + pluginPathSchema, + localMarketplaceSourceSchema, + extensionMarketplaceSourceSchema, +]); + +export const marketplacePluginEntrySchema = z.object({ + name: z.string(), + source: marketplaceSourceSchema, + description: z.string().optional(), + version: z.string().optional(), + category: z.string().optional(), + policy: z.object({ + installation: z.string().optional(), + authentication: z.string().optional(), + }).passthrough().optional(), + skills: z.array(pluginPathSchema).optional(), +}).passthrough(); + +export type MarketplacePluginEntry = z.infer; + +export const pluginMarketplaceSchema = z.object({ + name: z.string(), + interface: z.object({ + displayName: z.string().optional(), + }).passthrough().optional(), + owner: z.object({ + name: z.string().optional(), + }).passthrough().optional(), + metadata: z.object({ + pluginRoot: pluginPathSchema.optional(), + }).passthrough().optional(), + plugins: z.array(marketplacePluginEntrySchema), +}).passthrough(); + +export type PluginMarketplace = z.infer; + +/** Parses an external plugin manifest and annotates schema errors with its file path. */ +export function parsePluginManifest( + value: unknown, + filePath: string, +): PluginManifest { + const parsed = pluginManifestSchema.safeParse(value); + if (!parsed.success) { + throw new Error(`Invalid plugin manifest ${filePath}: ${parsed.error.message}`); + } + return parsed.data; +} + +/** Parses an external plugin marketplace and annotates schema errors with its file path. */ +export function parsePluginMarketplace( + value: unknown, + filePath: string, +): PluginMarketplace { + const parsed = pluginMarketplaceSchema.safeParse(value); + if (!parsed.success) { + throw new Error(`Invalid plugin marketplace ${filePath}: ${parsed.error.message}`); + } + return parsed.data; +} diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts new file mode 100644 index 0000000..9c46407 --- /dev/null +++ b/packages/dotagents/src/plugins/store.test.ts @@ -0,0 +1,183 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { isSameProjectPluginConfig, lockEntryForPlugin, resolvePlugin, type ResolvedPlugin } from "./store.js"; + +describe("plugin store", () => { + 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("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("skips unsupported marketplace sources during discovery", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const pluginDir = join(projectRoot, "plugins", "review-tools"); + const marketplaceOnlyDir = join(projectRoot, "plugins", "marketplace-review-tools"); + await mkdir(pluginDir, { recursive: true }); + await mkdir(marketplaceOnlyDir, { recursive: true }); + await writeFile( + join(projectRoot, "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + plugins: [ + { + name: "review-tools", + source: { source: "github", path: "plugins/marketplace-review-tools" }, + }, + ], + }), + "utf-8", + ); + await writeFile( + join(marketplaceOnlyDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Marketplace-only plugin" }), + "utf-8", + ); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Fallback local plugin" }), + "utf-8", + ); + + const resolved = await resolvePlugin( + { name: "review-tools", source: "path:." }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + + expect(resolved.plugin.pluginDir).toBe(pluginDir); + expect(resolved.plugin.manifest.description).toBe("Fallback local plugin"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("rejects canonical plugin discovery symlinks that escape the source root", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const outsideDir = join(projectRoot, "outside", "review-tools"); + await mkdir(join(sourceRoot, ".agents", "plugins"), { recursive: true }); + await mkdir(outsideDir, { recursive: true }); + await writeFile( + join(outsideDir, "plugin.json"), + JSON.stringify({ name: "review-tools" }), + "utf-8", + ); + await symlink(outsideDir, join(sourceRoot, ".agents", "plugins", "review-tools")); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow(/Canonical plugin source resolves outside source/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("rejects 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 }); + } + }); +}); diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts new file mode 100644 index 0000000..2dcd3d6 --- /dev/null +++ b/packages/dotagents/src/plugins/store.ts @@ -0,0 +1,545 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile, realpath, 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; + +/** 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 }; + + await copyDir(resolved.plugin.pluginDir, destDir); + + await ensureCanonicalManifest(installed); + return installed; +} + +/** Loads installed plugin bundles declared in config and reports recoverable issues. */ +export async function loadInstalledPlugins( + pluginsDir: string, + configs: PluginConfig[], +): Promise<{ plugins: PluginDeclaration[]; issues: string[] }> { + const plugins: PluginDeclaration[] = []; + const issues: string[] = []; + + for (const config of configs) { + const pluginDir = join(pluginsDir, config.name); + if (!existsSync(pluginDir)) { + issues.push(`Plugin "${config.name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`); + continue; + } + try { + const manifest = await loadManifest(pluginDir) ?? { name: config.name }; + assertPluginName(config.name, manifest, pluginDir); + plugins.push({ + name: config.name, + source: config.source, + pluginDir, + manifest: normalizeManifest(config.name, manifest), + targets: config.targets, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + issues.push(`Failed to load installed plugin "${config.name}": ${message}`); + } + } + + return { plugins, issues }; +} + +/** Removes installed plugin bundles by lockfile name using path-safe names only. */ +export async function pruneInstalledPlugins( + pluginsDir: string, + names: Iterable, +): Promise { + if (!existsSync(pluginsDir)) {return [];} + + const pruned: string[] = []; + for (const name of names) { + const pluginPath = managedPluginPath(pluginsDir, name); + if (!pluginPath || !existsSync(pluginPath)) {continue;} + await rm(pluginPath, { recursive: true, force: true }); + pruned.push(name); + } + return pruned; +} + +/** Converts a resolved plugin to its lockfile entry. */ +export function lockEntryForPlugin(resolved: ResolvedPlugin): LockedPlugin { + 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 fromMarketplace = await discoverFromMarketplaces(sourceDir, config.name); + if (fromMarketplace) {return fromMarketplace;} + + for (const dir of [sourceDir, join(sourceDir, "plugins", config.name)]) { + const candidate = await loadPluginCandidate(sourceDir, dir); + if (candidate && candidateMatches(config.name, candidate)) {matches.push(candidate);} + } + + const recursive = await scanPluginDirectories(sourceDir, join(sourceDir, "plugins"), config.name); + matches.push(...recursive); + + const unique = 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(", ")}`, + ); + } + 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, +): Promise { + for (const marketplacePath of MARKETPLACE_PATHS) { + const filePath = join(sourceDir, marketplacePath); + if (!existsSync(filePath)) {continue;} + + const marketplace = parsePluginMarketplace(await readJson(filePath), filePath); + const root = typeof marketplace.metadata?.pluginRoot === "string" + ? marketplace.metadata.pluginRoot + : "."; + for (const entry of marketplace.plugins) { + if (entry.name !== name) {continue;} + + const path = localMarketplacePath(entry.source); + if (!path) { + continue; + } + + const marketplaceRoot = dirname(filePath); + const pluginDir = await resolveInside(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;} + + 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"); +} + +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; +} + +function localMarketplacePath(source: MarketplacePluginEntry["source"]): string | null { + if (typeof source === "string") {return stripDotSlash(source);} + + if (source.source === "local" && typeof source.path === "string") { + return stripDotSlash(source.path); + } + return null; +} + +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)) { + const rootRealPath = await realpath(rootPath); + const fileRealPath = await realpath(filePath); + const realRelPath = relative(rootRealPath, fileRealPath); + if (realRelPath.startsWith("..") || isAbsolute(realRelPath)) { + throw new Error(`${label} resolves outside source: ${childPath}`); + } + } + return filePath; +} + +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/agents/definitions/helpers.test.ts b/packages/dotagents/src/subagents/format.test.ts similarity index 56% rename from packages/dotagents/src/agents/definitions/helpers.test.ts rename to packages/dotagents/src/subagents/format.test.ts index 1fe4efa..6bae52a 100644 --- a/packages/dotagents/src/agents/definitions/helpers.test.ts +++ b/packages/dotagents/src/subagents/format.test.ts @@ -1,104 +1,12 @@ import { describe, it, expect } from "vitest"; import { parseMarkdownFrontmatterContent } from "@sentry/dotagents-lib"; import { - interpolateEnvRefs, - interpolateHeaders, - extractCodexHeaders, - markManagedMarkdownSubagent, + DOTAGENTS_SUBAGENT_MARKER, hasDotagentsMarkdownSubagentMarker, hasDotagentsTomlSubagentMarker, + markManagedMarkdownSubagent, serializeMarkdownSubagent, - DOTAGENTS_SUBAGENT_MARKER, -} from "./helpers.js"; - -const cursorTpl = (k: string) => `\${env:${k}}`; - -describe("interpolateEnvRefs", () => { - - it("passes through strings with no refs", () => { - expect(interpolateEnvRefs("Bearer tok", cursorTpl)).toBe("Bearer tok"); - }); - - it("replaces a single ref", () => { - expect(interpolateEnvRefs("${API_KEY}", cursorTpl)).toBe("${env:API_KEY}"); - }); - - it("replaces ref in a mixed string", () => { - expect(interpolateEnvRefs("Bearer ${TOKEN}", cursorTpl)).toBe("Bearer ${env:TOKEN}"); - }); - - it("replaces multiple refs", () => { - expect(interpolateEnvRefs("${A}:${B}", cursorTpl)).toBe("${env:A}:${env:B}"); - }); - - it("ignores non-matching patterns", () => { - // No braces, empty braces, leading digit, hyphen in name - for (const s of ["$NOBRACES", "${}", "${123}", "${foo-bar}"]) { - expect(interpolateEnvRefs(s, cursorTpl)).toBe(s); - } - }); - - it("replaces underscore-prefixed and numeric-suffixed vars", () => { - expect(interpolateEnvRefs("${_FOO_2}", cursorTpl)).toBe("${env:_FOO_2}"); - }); -}); - -describe("interpolateHeaders", () => { - it("transforms all header values", () => { - const headers = { "X-Key": "${KEY}", "Authorization": "Bearer ${TOKEN}" }; - const result = interpolateHeaders(headers, (k) => `{env:${k}}`); - expect(result).toEqual({ "X-Key": "{env:KEY}", "Authorization": "Bearer {env:TOKEN}" }); - }); - - it("returns undefined for undefined input", () => { - const noHeaders: Record | undefined = undefined; - expect(interpolateHeaders(noHeaders, (k) => k)).toBeUndefined(); - }); -}); - -describe("extractCodexHeaders", () => { - it("maps pure env ref to envHttpHeaders", () => { - const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ - "X-Api-Key": "${GOOGLE_API_KEY}", - }); - expect(httpHeaders).toBeUndefined(); - expect(envHttpHeaders).toEqual({ GOOGLE_API_KEY: "X-Api-Key" }); - }); - - it("keeps static values in httpHeaders", () => { - const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ - Authorization: "Bearer tok", - }); - expect(httpHeaders).toEqual({ Authorization: "Bearer tok" }); - expect(envHttpHeaders).toBeUndefined(); - }); - - it("keeps mixed values in httpHeaders as literal fallback", () => { - const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ - Authorization: "Bearer ${TOKEN}", - }); - expect(httpHeaders).toEqual({ Authorization: "Bearer ${TOKEN}" }); - expect(envHttpHeaders).toBeUndefined(); - }); - - it("splits mixed and pure refs correctly", () => { - const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ - "X-Api-Key": "${API_KEY}", - Authorization: "Bearer tok", - "X-Mixed": "prefix ${VAR} suffix", - }); - expect(envHttpHeaders).toEqual({ API_KEY: "X-Api-Key" }); - expect(httpHeaders).toEqual({ - Authorization: "Bearer tok", - "X-Mixed": "prefix ${VAR} suffix", - }); - }); - - it("returns empty object for undefined input", () => { - const noHeaders: Record | undefined = undefined; - expect(extractCodexHeaders(noHeaders)).toEqual({}); - }); -}); +} from "./format.js"; describe("serializeMarkdownSubagent", () => { it("serializes simple frontmatter fields", () => { diff --git a/packages/dotagents/src/agents/definitions/helpers.ts b/packages/dotagents/src/subagents/format.ts similarity index 55% rename from packages/dotagents/src/agents/definitions/helpers.ts rename to packages/dotagents/src/subagents/format.ts index 14f3b5c..be526d9 100644 --- a/packages/dotagents/src/agents/definitions/helpers.ts +++ b/packages/dotagents/src/subagents/format.ts @@ -1,5 +1,4 @@ import { stringify as tomlStringify } from "smol-toml"; -import type { McpDeclaration, HookDeclaration } from "../types.js"; export const DOTAGENTS_SUBAGENT_MARKER = "Generated by dotagents. Edit agents.toml instead."; const DOTAGENTS_SUBAGENT_MARKER_RE = escapeRegExp(DOTAGENTS_SUBAGENT_MARKER); @@ -10,98 +9,7 @@ const MANAGED_TOML_SUBAGENT_RE = new RegExp( `^\\uFEFF?# ${DOTAGENTS_SUBAGENT_MARKER_RE}\\r?\\n`, ); -export function envRecord( - env: string[] | undefined, - template: (key: string) => string, -): Record | undefined { - if (!env || env.length === 0) {return undefined;} - const rec: Record = {}; - for (const key of env) {rec[key] = template(key);} - return rec; -} - -const ENV_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; - -export function interpolateEnvRefs( - value: string, - template: (varName: string) => string, -): string { - return value.replace(ENV_REF_RE, (_match, varName: string) => template(varName)); -} - -export function interpolateHeaders( - headers: Record | undefined, - template: (varName: string) => string, -): Record | undefined { - if (!headers) {return undefined;} - const result: Record = {}; - for (const [key, value] of Object.entries(headers)) { - result[key] = interpolateEnvRefs(value, template); - } - return result; -} - -/** - * Split headers for Codex's model: pure `${VAR}` refs go to `envHttpHeaders` - * (mapping env var name → header name), everything else stays in `httpHeaders`. - * Mixed values like `"Bearer ${TOKEN}"` can't be represented in Codex's - * env_http_headers format and fall through as literal strings in httpHeaders. - */ -export function extractCodexHeaders( - headers: Record | undefined, -): { httpHeaders?: Record; envHttpHeaders?: Record } { - if (!headers) {return {};} - let httpHeaders: Record | undefined; - let envHttpHeaders: Record | undefined; - const pureRefRe = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; - for (const [key, value] of Object.entries(headers)) { - const match = pureRefRe.exec(value); - if (match) { - (envHttpHeaders ??= {})[match[1]!] = key; - } else { - (httpHeaders ??= {})[key] = value; - } - } - return { httpHeaders, envHttpHeaders }; -} - -export function httpServer( - s: McpDeclaration, - type?: string, - template?: (varName: string) => string, -): [string, unknown] { - const tpl = template ?? ((k: string) => `\${${k}}`); - return [ - s.name, - { - ...(type && { type }), - url: interpolateEnvRefs(s.url!, tpl), - ...(s.headers && { headers: interpolateHeaders(s.headers, tpl) }), - }, - ]; -} - -/** - * Serialize hooks into Claude Code / VS Code settings.json format. - * - * Output shape: - * { - * "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "..." }] }], - * "Stop": [{ "hooks": [{ "type": "command", "command": "..." }] }] - * } - */ -export function serializeClaudeHooks(hooks: HookDeclaration[]): Record { - const result: Record = {}; - for (const h of hooks) { - const entry = { - ...(h.matcher && { matcher: h.matcher }), - hooks: [{ type: "command", command: h.command }], - }; - (result[h.event] ??= []).push(entry); - } - return result; -} - +/** Serializes a managed Markdown subagent with marker-bearing frontmatter. */ export function serializeMarkdownSubagent( fields: Record, instructions: string, @@ -140,6 +48,7 @@ export function hasDotagentsTomlSubagentMarker(content: string): boolean { return MANAGED_TOML_SUBAGENT_RE.test(content); } +/** Serializes a managed Codex TOML subagent with the dotagents marker header. */ export function serializeCodexSubagent( fields: Record, ): string { @@ -153,6 +62,7 @@ export function serializeCodexSubagent( return `# ${DOTAGENTS_SUBAGENT_MARKER}\n${tomlStringify(doc)}`; } +/** Inserts the dotagents marker into existing TOML subagent content. */ export function markManagedTomlSubagent(content: string): string { if (hasDotagentsTomlSubagentMarker(content)) {return content;} return `# ${DOTAGENTS_SUBAGENT_MARKER}\n${content}`; diff --git a/packages/dotagents/src/agents/subagent-identity.ts b/packages/dotagents/src/subagents/identity.ts similarity index 100% rename from packages/dotagents/src/agents/subagent-identity.ts rename to packages/dotagents/src/subagents/identity.ts diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/subagents/store.test.ts similarity index 99% rename from packages/dotagents/src/agents/subagent-store.test.ts rename to packages/dotagents/src/subagents/store.test.ts index 1d8044c..af56687 100644 --- a/packages/dotagents/src/agents/subagent-store.test.ts +++ b/packages/dotagents/src/subagents/store.test.ts @@ -9,8 +9,8 @@ import { pruneInstalledSubagents, resolveSubagent, writeInstalledSubagents, -} from "./subagent-store.js"; -import { DOTAGENTS_SUBAGENT_MARKER, markManagedMarkdownSubagent } from "./definitions/helpers.js"; +} from "./store.js"; +import { DOTAGENTS_SUBAGENT_MARKER, markManagedMarkdownSubagent } from "./format.js"; import type { SubagentConfig } from "../config/schema.js"; const SUBAGENT_MD = (name: string) => `--- diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/subagents/store.ts similarity index 96% rename from packages/dotagents/src/agents/subagent-store.ts rename to packages/dotagents/src/subagents/store.ts index 87e7ab1..9a9b3d9 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/subagents/store.ts @@ -14,9 +14,9 @@ import { type RepositorySource, type TrustPolicy, } from "@sentry/dotagents-lib"; -import { hasDotagentsMarkdownSubagentMarker, serializeMarkdownSubagent } from "./definitions/helpers.js"; -import { getAgent } from "./registry.js"; -import { subagentIdentityFromMarkdownMeta } from "./subagent-identity.js"; +import { hasDotagentsMarkdownSubagentMarker, serializeMarkdownSubagent } from "./format.js"; +import { getAgent } from "../targets/registry.js"; +import { subagentIdentityFromMarkdownMeta } from "./identity.js"; import { SUBAGENT_NAME_PATTERN, type SubagentConfig } from "../config/schema.js"; import type { LockedSubagent } from "../lockfile/schema.js"; import type { @@ -26,6 +26,8 @@ import type { SubagentIdentityStrategy, } from "./types.js"; +// Owns installed subagent sources under `.agents/agents/`. Runtime-specific +// files are generated later by `subagents/writer.ts` and guarded by markers. const DOTAGENTS_NATIVE_FIELD = "dotagents_native"; const NATIVE_SUBAGENT_TARGETS = ["claude", "cursor", "codex", "opencode"] satisfies NativeSubagentTarget[]; let tempFileCounter = 0; @@ -85,6 +87,7 @@ export class InstalledSubagentWriteError extends Error { } } +/** Resolves a configured subagent source into a validated declaration and lock metadata. */ export async function resolveSubagent( config: SubagentConfig, opts: SubagentResolveOptions, @@ -140,6 +143,7 @@ export async function resolveSubagent( }; } +/** Writes installed managed subagent source files atomically, rolling back partial writes. */ export async function writeInstalledSubagents( subagentsDir: string, subagents: SubagentDeclaration[], @@ -191,6 +195,7 @@ export async function writeInstalledSubagents( return written; } +/** Loads installed source subagents declared in agents.toml and reports stale or invalid files. */ export async function loadInstalledSubagents( subagentsDir: string, configs: SubagentConfig[], @@ -225,6 +230,7 @@ export async function loadInstalledSubagents( return { subagents, issues }; } +/** Removes managed installed subagent source files that are no longer configured. */ export async function pruneInstalledSubagents( subagentsDir: string, configs: SubagentConfig[], @@ -233,6 +239,7 @@ export async function pruneInstalledSubagents( return pruneManagedMarkdownFiles(subagentsDir, desired); } +/** Converts a resolved subagent into its durable agents.lock representation. */ export function lockEntryForSubagent(resolved: ResolvedSubagent): LockedSubagent { return { source: resolved.source, diff --git a/packages/dotagents/src/subagents/types.ts b/packages/dotagents/src/subagents/types.ts new file mode 100644 index 0000000..9bc39f2 --- /dev/null +++ b/packages/dotagents/src/subagents/types.ts @@ -0,0 +1,49 @@ +import type { SubagentConfig } from "../config/schema.js"; + +/** + * Universal subagent declaration loaded from an installed subagent Markdown file. + */ +export interface SubagentDeclaration { + name: string; + description: string; + instructions: string; + targets?: SubagentConfig["targets"]; + native?: NativeSubagentContent; +} + +export type NativeSubagentTarget = "claude" | "cursor" | "codex" | "opencode"; + +/** Raw source content in the runtime's native subagent format. */ +export type NativeSubagentConfig = string; + +export type NativeSubagentContent = Partial>; + +export type SubagentIdentityStrategy = + | "frontmatter-name" + | "frontmatter-name-or-filename" + | "filename" + | "toml-name"; + +/** + * Describes where an agent stores custom subagent definitions. + */ +export interface SubagentConfigSpec { + /** Project-scope directory, relative to project root */ + projectDir: string; + /** User-scope directory, absolute path */ + userDir: string; + /** Generated file extension, including the leading dot */ + fileExtension: ".md" | ".toml"; + /** Runtime-specific rule for identifying a subagent artifact */ + identity: SubagentIdentityStrategy; + /** Transforms a universal subagent declaration into an agent-specific file */ + serialize: SubagentSerializer; +} + +/** + * Transforms a universal SubagentDeclaration into a runtime-specific file. + */ +export type SubagentSerializer = (subagent: SubagentDeclaration) => { + fileName: string; + content: string; +}; diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/subagents/writer.test.ts similarity index 99% rename from packages/dotagents/src/agents/subagent-writer.test.ts rename to packages/dotagents/src/subagents/writer.test.ts index b9b55cf..ab124bb 100644 --- a/packages/dotagents/src/agents/subagent-writer.test.ts +++ b/packages/dotagents/src/subagents/writer.test.ts @@ -9,8 +9,8 @@ import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, -} from "./subagent-writer.js"; -import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; +} from "./writer.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "./format.js"; import type { SubagentDeclaration } from "./types.js"; const SUBAGENT: SubagentDeclaration = { diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/subagents/writer.ts similarity index 94% rename from packages/dotagents/src/agents/subagent-writer.ts rename to packages/dotagents/src/subagents/writer.ts index 3f16e91..6f77edc 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/subagents/writer.ts @@ -1,11 +1,13 @@ import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { getAgent } from "./registry.js"; -import { hasDotagentsMarkdownSubagentMarker, hasDotagentsTomlSubagentMarker } from "./definitions/helpers.js"; -import { generatedSubagentIdentity, readSubagentFileIdentity } from "./subagent-identity.js"; +import { getAgent } from "../targets/registry.js"; +import { hasDotagentsMarkdownSubagentMarker, hasDotagentsTomlSubagentMarker } from "./format.js"; +import { generatedSubagentIdentity, readSubagentFileIdentity } from "./identity.js"; import type { SubagentConfigSpec, SubagentDeclaration } from "./types.js"; +// Owns runtime-specific subagent projection. Managed markers protect generated +// files, while identity checks prevent overwriting user-authored subagents. export interface SubagentResolvedTarget { dirPath: string; } @@ -38,18 +40,21 @@ interface DesiredDir { files: Set; } +/** Resolves project-scope runtime subagent directories relative to a project root. */ export function projectSubagentResolver(projectRoot: string): SubagentTargetResolver { return (_id: string, spec: SubagentConfigSpec) => ({ dirPath: join(projectRoot, spec.projectDir), }); } +/** Resolves user-scope runtime subagent directories from each target definition. */ export function userSubagentResolver(): SubagentTargetResolver { return (_id: string, spec: SubagentConfigSpec) => ({ dirPath: spec.userDir, }); } +/** Writes managed runtime subagent configs for all configured target agents. */ export async function writeSubagentConfigs( agentIds: string[], subagents: SubagentDeclaration[], @@ -122,6 +127,7 @@ export async function writeSubagentConfigs( return { warnings, written }; } +/** Prunes managed runtime subagent files that are no longer desired. */ export async function pruneSubagentConfigs( agentIds: string[], desiredSubagents: Pick[], @@ -130,6 +136,7 @@ export async function pruneSubagentConfigs( return pruneManagedFiles(initDesiredDirs(agentIds, desiredSubagents, resolveTarget)); } +/** Verifies that managed runtime subagent files exist and still match desired output. */ export async function verifySubagentConfigs( agentIds: string[], subagents: SubagentDeclaration[], diff --git a/packages/dotagents/src/symlinks/manager.test.ts b/packages/dotagents/src/symlinks/manager.test.ts index 2237ee0..6afa6df 100644 --- a/packages/dotagents/src/symlinks/manager.test.ts +++ b/packages/dotagents/src/symlinks/manager.test.ts @@ -107,6 +107,7 @@ describe("symlinks", () => { cwd: dir, }); await exec("git", ["config", "user.name", "Test"], { cwd: dir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: dir }); // Create a real skills directory with a committed file const targetDir = join(dir, ".claude"); diff --git a/packages/dotagents/src/agents/definitions/claude.ts b/packages/dotagents/src/targets/definitions/claude.ts similarity index 88% rename from packages/dotagents/src/agents/definitions/claude.ts rename to packages/dotagents/src/targets/definitions/claude.ts index d9e5a13..33fa492 100644 --- a/packages/dotagents/src/agents/definitions/claude.ts +++ b/packages/dotagents/src/targets/definitions/claude.ts @@ -1,7 +1,8 @@ import { join } from "node:path"; import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; -import { envRecord, httpServer, markManagedMarkdownSubagent, serializeClaudeHooks, serializeMarkdownSubagent } from "./helpers.js"; +import { markManagedMarkdownSubagent, serializeMarkdownSubagent } from "../../subagents/format.js"; +import { envRecord, httpServer, serializeClaudeHooks } from "./helpers.js"; const claude: AgentDefinition = { id: "claude", diff --git a/packages/dotagents/src/agents/definitions/codex.ts b/packages/dotagents/src/targets/definitions/codex.ts similarity index 91% rename from packages/dotagents/src/agents/definitions/codex.ts rename to packages/dotagents/src/targets/definitions/codex.ts index e02bc0a..75fcfd1 100644 --- a/packages/dotagents/src/agents/definitions/codex.ts +++ b/packages/dotagents/src/targets/definitions/codex.ts @@ -3,7 +3,8 @@ import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; import { UnsupportedFeature } from "../errors.js"; import claude from "./claude.js"; -import { envRecord, extractCodexHeaders, markManagedTomlSubagent, serializeCodexSubagent } from "./helpers.js"; +import { markManagedTomlSubagent, serializeCodexSubagent } from "../../subagents/format.js"; +import { envRecord, extractCodexHeaders } from "./helpers.js"; const codex: AgentDefinition = { ...claude, diff --git a/packages/dotagents/src/agents/definitions/cursor.ts b/packages/dotagents/src/targets/definitions/cursor.ts similarity index 94% rename from packages/dotagents/src/agents/definitions/cursor.ts rename to packages/dotagents/src/targets/definitions/cursor.ts index a3eaf90..0d285aa 100644 --- a/packages/dotagents/src/agents/definitions/cursor.ts +++ b/packages/dotagents/src/targets/definitions/cursor.ts @@ -3,7 +3,8 @@ import { homedir } from "node:os"; import type { AgentDefinition, HookDeclaration } from "../types.js"; import type { HookEvent } from "../../config/schema.js"; import claude from "./claude.js"; -import { httpServer, markManagedMarkdownSubagent, serializeMarkdownSubagent } from "./helpers.js"; +import { markManagedMarkdownSubagent, serializeMarkdownSubagent } from "../../subagents/format.js"; +import { httpServer } from "./helpers.js"; /** * Maps universal hook events to Cursor event names. diff --git a/packages/dotagents/src/targets/definitions/helpers.test.ts b/packages/dotagents/src/targets/definitions/helpers.test.ts new file mode 100644 index 0000000..a0675eb --- /dev/null +++ b/packages/dotagents/src/targets/definitions/helpers.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { + interpolateEnvRefs, + interpolateHeaders, + extractCodexHeaders, +} from "./helpers.js"; + +const cursorTpl = (k: string) => `\${env:${k}}`; + +describe("interpolateEnvRefs", () => { + + it("passes through strings with no refs", () => { + expect(interpolateEnvRefs("Bearer tok", cursorTpl)).toBe("Bearer tok"); + }); + + it("replaces a single ref", () => { + expect(interpolateEnvRefs("${API_KEY}", cursorTpl)).toBe("${env:API_KEY}"); + }); + + it("replaces ref in a mixed string", () => { + expect(interpolateEnvRefs("Bearer ${TOKEN}", cursorTpl)).toBe("Bearer ${env:TOKEN}"); + }); + + it("replaces multiple refs", () => { + expect(interpolateEnvRefs("${A}:${B}", cursorTpl)).toBe("${env:A}:${env:B}"); + }); + + it("ignores non-matching patterns", () => { + // No braces, empty braces, leading digit, hyphen in name + for (const s of ["$NOBRACES", "${}", "${123}", "${foo-bar}"]) { + expect(interpolateEnvRefs(s, cursorTpl)).toBe(s); + } + }); + + it("replaces underscore-prefixed and numeric-suffixed vars", () => { + expect(interpolateEnvRefs("${_FOO_2}", cursorTpl)).toBe("${env:_FOO_2}"); + }); +}); + +describe("interpolateHeaders", () => { + it("transforms all header values", () => { + const headers = { "X-Key": "${KEY}", "Authorization": "Bearer ${TOKEN}" }; + const result = interpolateHeaders(headers, (k) => `{env:${k}}`); + expect(result).toEqual({ "X-Key": "{env:KEY}", "Authorization": "Bearer {env:TOKEN}" }); + }); + + it("returns undefined for undefined input", () => { + const noHeaders: Record | undefined = undefined; + expect(interpolateHeaders(noHeaders, (k) => k)).toBeUndefined(); + }); +}); + +describe("extractCodexHeaders", () => { + it("maps pure env ref to envHttpHeaders", () => { + const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ + "X-Api-Key": "${GOOGLE_API_KEY}", + }); + expect(httpHeaders).toBeUndefined(); + expect(envHttpHeaders).toEqual({ GOOGLE_API_KEY: "X-Api-Key" }); + }); + + it("keeps static values in httpHeaders", () => { + const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ + Authorization: "Bearer tok", + }); + expect(httpHeaders).toEqual({ Authorization: "Bearer tok" }); + expect(envHttpHeaders).toBeUndefined(); + }); + + it("keeps mixed values in httpHeaders as literal fallback", () => { + const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ + Authorization: "Bearer ${TOKEN}", + }); + expect(httpHeaders).toEqual({ Authorization: "Bearer ${TOKEN}" }); + expect(envHttpHeaders).toBeUndefined(); + }); + + it("splits mixed and pure refs correctly", () => { + const { httpHeaders, envHttpHeaders } = extractCodexHeaders({ + "X-Api-Key": "${API_KEY}", + Authorization: "Bearer tok", + "X-Mixed": "prefix ${VAR} suffix", + }); + expect(envHttpHeaders).toEqual({ API_KEY: "X-Api-Key" }); + expect(httpHeaders).toEqual({ + Authorization: "Bearer tok", + "X-Mixed": "prefix ${VAR} suffix", + }); + }); + + it("returns empty object for undefined input", () => { + const noHeaders: Record | undefined = undefined; + expect(extractCodexHeaders(noHeaders)).toEqual({}); + }); +}); diff --git a/packages/dotagents/src/targets/definitions/helpers.ts b/packages/dotagents/src/targets/definitions/helpers.ts new file mode 100644 index 0000000..970530a --- /dev/null +++ b/packages/dotagents/src/targets/definitions/helpers.ts @@ -0,0 +1,93 @@ +import type { McpDeclaration, HookDeclaration } from "../types.js"; + +export function envRecord( + env: string[] | undefined, + template: (key: string) => string, +): Record | undefined { + if (!env || env.length === 0) {return undefined;} + const rec: Record = {}; + for (const key of env) {rec[key] = template(key);} + return rec; +} + +const ENV_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +export function interpolateEnvRefs( + value: string, + template: (varName: string) => string, +): string { + return value.replace(ENV_REF_RE, (_match, varName: string) => template(varName)); +} + +export function interpolateHeaders( + headers: Record | undefined, + template: (varName: string) => string, +): Record | undefined { + if (!headers) {return undefined;} + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + result[key] = interpolateEnvRefs(value, template); + } + return result; +} + +/** + * Split headers for Codex's model: pure `${VAR}` refs go to `envHttpHeaders` + * (mapping env var name → header name), everything else stays in `httpHeaders`. + * Mixed values like `"Bearer ${TOKEN}"` can't be represented in Codex's + * env_http_headers format and fall through as literal strings in httpHeaders. + */ +export function extractCodexHeaders( + headers: Record | undefined, +): { httpHeaders?: Record; envHttpHeaders?: Record } { + if (!headers) {return {};} + let httpHeaders: Record | undefined; + let envHttpHeaders: Record | undefined; + const pureRefRe = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; + for (const [key, value] of Object.entries(headers)) { + const match = pureRefRe.exec(value); + if (match) { + (envHttpHeaders ??= {})[match[1]!] = key; + } else { + (httpHeaders ??= {})[key] = value; + } + } + return { httpHeaders, envHttpHeaders }; +} + +export function httpServer( + s: McpDeclaration, + type?: string, + template?: (varName: string) => string, +): [string, unknown] { + const tpl = template ?? ((k: string) => `\${${k}}`); + return [ + s.name, + { + ...(type && { type }), + url: interpolateEnvRefs(s.url!, tpl), + ...(s.headers && { headers: interpolateHeaders(s.headers, tpl) }), + }, + ]; +} + +/** + * Serialize hooks into Claude Code / VS Code settings.json format. + * + * Output shape: + * { + * "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "..." }] }], + * "Stop": [{ "hooks": [{ "type": "command", "command": "..." }] }] + * } + */ +export function serializeClaudeHooks(hooks: HookDeclaration[]): Record { + const result: Record = {}; + for (const h of hooks) { + const entry = { + ...(h.matcher && { matcher: h.matcher }), + hooks: [{ type: "command", command: h.command }], + }; + (result[h.event] ??= []).push(entry); + } + return result; +} diff --git a/packages/dotagents/src/agents/definitions/opencode.ts b/packages/dotagents/src/targets/definitions/opencode.ts similarity index 91% rename from packages/dotagents/src/agents/definitions/opencode.ts rename to packages/dotagents/src/targets/definitions/opencode.ts index 7e8b231..02a5665 100644 --- a/packages/dotagents/src/agents/definitions/opencode.ts +++ b/packages/dotagents/src/targets/definitions/opencode.ts @@ -2,7 +2,8 @@ import { join } from "node:path"; import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; import { UnsupportedFeature } from "../errors.js"; -import { envRecord, httpServer, markManagedMarkdownSubagent, serializeMarkdownSubagent } from "./helpers.js"; +import { markManagedMarkdownSubagent, serializeMarkdownSubagent } from "../../subagents/format.js"; +import { envRecord, httpServer } from "./helpers.js"; const opencode: AgentDefinition = { id: "opencode", diff --git a/packages/dotagents/src/agents/definitions/vscode.ts b/packages/dotagents/src/targets/definitions/vscode.ts similarity index 100% rename from packages/dotagents/src/agents/definitions/vscode.ts rename to packages/dotagents/src/targets/definitions/vscode.ts diff --git a/packages/dotagents/src/agents/errors.ts b/packages/dotagents/src/targets/errors.ts similarity index 100% rename from packages/dotagents/src/agents/errors.ts rename to packages/dotagents/src/targets/errors.ts diff --git a/packages/dotagents/src/agents/hook-writer.test.ts b/packages/dotagents/src/targets/hook-writer.test.ts similarity index 100% rename from packages/dotagents/src/agents/hook-writer.test.ts rename to packages/dotagents/src/targets/hook-writer.test.ts diff --git a/packages/dotagents/src/agents/hook-writer.ts b/packages/dotagents/src/targets/hook-writer.ts similarity index 100% rename from packages/dotagents/src/agents/hook-writer.ts rename to packages/dotagents/src/targets/hook-writer.ts diff --git a/packages/dotagents/src/agents/mcp-writer.test.ts b/packages/dotagents/src/targets/mcp-writer.test.ts similarity index 100% rename from packages/dotagents/src/agents/mcp-writer.test.ts rename to packages/dotagents/src/targets/mcp-writer.test.ts diff --git a/packages/dotagents/src/agents/mcp-writer.ts b/packages/dotagents/src/targets/mcp-writer.ts similarity index 100% rename from packages/dotagents/src/agents/mcp-writer.ts rename to packages/dotagents/src/targets/mcp-writer.ts diff --git a/packages/dotagents/src/agents/paths.test.ts b/packages/dotagents/src/targets/paths.test.ts similarity index 100% rename from packages/dotagents/src/agents/paths.test.ts rename to packages/dotagents/src/targets/paths.test.ts diff --git a/packages/dotagents/src/agents/paths.ts b/packages/dotagents/src/targets/paths.ts similarity index 100% rename from packages/dotagents/src/agents/paths.ts rename to packages/dotagents/src/targets/paths.ts diff --git a/packages/dotagents/src/agents/registry.test.ts b/packages/dotagents/src/targets/registry.test.ts similarity index 100% rename from packages/dotagents/src/agents/registry.test.ts rename to packages/dotagents/src/targets/registry.test.ts diff --git a/packages/dotagents/src/agents/registry.ts b/packages/dotagents/src/targets/registry.ts similarity index 100% rename from packages/dotagents/src/agents/registry.ts rename to packages/dotagents/src/targets/registry.ts diff --git a/packages/dotagents/src/agents/types.ts b/packages/dotagents/src/targets/types.ts similarity index 67% rename from packages/dotagents/src/agents/types.ts rename to packages/dotagents/src/targets/types.ts index 9518aec..bef9c70 100644 --- a/packages/dotagents/src/agents/types.ts +++ b/packages/dotagents/src/targets/types.ts @@ -1,4 +1,5 @@ -import type { HookEvent, SubagentConfig } from "../config/schema.js"; +import type { HookEvent } from "../config/schema.js"; +import type { SubagentConfigSpec } from "../subagents/types.js"; /** * Universal MCP server declaration from agents.toml [[mcp]] sections. @@ -73,54 +74,6 @@ export interface HookConfigSpec { */ export type HookSerializer = (hooks: HookDeclaration[]) => unknown; -/** - * Universal subagent declaration loaded from an installed subagent Markdown file. - */ -export interface SubagentDeclaration { - name: string; - description: string; - instructions: string; - targets?: SubagentConfig["targets"]; - native?: NativeSubagentContent; -} - -export type NativeSubagentTarget = "claude" | "cursor" | "codex" | "opencode"; - -/** Raw source content in the runtime's native subagent format. */ -export type NativeSubagentConfig = string; - -export type NativeSubagentContent = Partial>; - -export type SubagentIdentityStrategy = - | "frontmatter-name" - | "frontmatter-name-or-filename" - | "filename" - | "toml-name"; - -/** - * Describes where an agent stores custom subagent definitions. - */ -export interface SubagentConfigSpec { - /** Project-scope directory, relative to project root */ - projectDir: string; - /** User-scope directory, absolute path */ - userDir: string; - /** Generated file extension, including the leading dot */ - fileExtension: ".md" | ".toml"; - /** Runtime-specific rule for identifying a subagent artifact */ - identity: SubagentIdentityStrategy; - /** Transforms a universal subagent declaration into an agent-specific file */ - serialize: SubagentSerializer; -} - -/** - * Transforms a universal SubagentDeclaration into a runtime-specific file. - */ -export type SubagentSerializer = (subagent: SubagentDeclaration) => { - fileName: string; - content: string; -}; - /** * Definition of an agent tool that dotagents manages. */ diff --git a/scripts/smoke-examples.mjs b/scripts/smoke-examples.mjs deleted file mode 100644 index 24bd009..0000000 --- a/scripts/smoke-examples.mjs +++ /dev/null @@ -1,255 +0,0 @@ -#!/usr/bin/env node -// Owns local dotagents example QA. The default path proves file wiring with an -// isolated HOME/state; --codex-runtime additionally proves Codex can spawn the -// generated project agent and always scrubs copied Codex auth/config. - -import { execFileSync } from "node:child_process"; -import { - cpSync, - existsSync, - lstatSync, - mkdirSync, - mkdtempSync, - realpathSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(__dirname, ".."); -const cliPath = join(repoRoot, "packages", "dotagents", "dist", "cli", "index.js"); -const exampleRoot = join(repoRoot, "examples", "full"); -const sentinel = "DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c"; -const args = new Set(process.argv.slice(2)); -const keep = args.has("--keep"); -const runCodexRuntime = args.has("--codex-runtime"); - -if (!existsSync(cliPath)) { - console.error(`smoke-examples: missing built CLI at ${cliPath}`); - console.error("Run `pnpm build` first."); - process.exit(1); -} - -const tmp = mkdtempSync(join(tmpdir(), "dotagents-example-")); -const projectDir = join(tmp, "project"); -const homeDir = join(tmp, "home"); -const stateDir = join(tmp, "state"); -const dotagentsHomeDir = join(tmp, "dotagents-home"); -const codexHomeDir = join(tmp, "codex-home"); -mkdirSync(homeDir, { recursive: true }); -mkdirSync(stateDir, { recursive: true }); -mkdirSync(dotagentsHomeDir, { recursive: true }); -cpSync(exampleRoot, projectDir, { recursive: true }); - -const fixtureEnv = { - ...process.env, - HOME: homeDir, - DOTAGENTS_HOME: dotagentsHomeDir, - DOTAGENTS_STATE_DIR: stateDir, -}; - -try { - console.log(`smoke-examples: project=${projectDir}`); - runCli(["install"]); - const list = runCli(["list"]); - writeFileSync(join(tmp, "list.out"), list); - runCli(["doctor", "--fix"]); - runCli(["doctor"]); - assertIncludes(list, "review", "list output should include review"); - assertIncludes(list, "commit", "list output should include commit"); - - assertFile(".agents/skills/review/SKILL.md"); - assertFile(".agents/skills/commit/SKILL.md"); - assertFileIncludes(".agents/skills/review/SKILL.md", "name: review"); - assertFileIncludes(".agents/skills/review/SKILL.md", "Review fixture."); - assertFileIncludes(".agents/skills/commit/SKILL.md", "name: commit"); - assertFileIncludes(".agents/skills/commit/SKILL.md", "Commit fixture."); - assertSymlink(".claude/skills"); - assertFile(".mcp.json"); - assertFile(".cursor/mcp.json"); - assertFile(".codex/config.toml"); - assertFile("opencode.json"); - assertFile(".claude/settings.json"); - assertFile(".cursor/hooks.json"); - assertSubagentOutputs(); - - rmSync(join(projectDir, ".mcp.json"), { force: true }); - rmSync(join(projectDir, ".claude", "skills"), { force: true, recursive: true }); - rmSync(join(projectDir, ".claude", "agents", "code-reviewer.md"), { force: true }); - rmSync(join(projectDir, ".codex", "agents", "code-reviewer.toml"), { force: true }); - runCli(["sync"]); - assertFile(".mcp.json"); - assertSymlink(".claude/skills"); - assertFile(".claude/agents/code-reviewer.md"); - assertFile(".codex/agents/code-reviewer.toml"); - - if (runCodexRuntime) { - proveCodexRuntime(); - } - - console.log("smoke-examples: ok"); -} catch (err) { - console.error("smoke-examples: failed"); - console.error(err instanceof Error ? err.message : String(err)); - console.error(`smoke-examples: project kept at ${projectDir}`); - process.exitCode = 1; - throw err; -} finally { - rmSync(codexHomeDir, { recursive: true, force: true }); - if (!keep && process.exitCode !== 1) { - rmSync(tmp, { recursive: true, force: true }); - } -} - -function runCli(cliArgs) { - return execFileSync("node", [cliPath, ...cliArgs], { - cwd: projectDir, - env: fixtureEnv, - encoding: "utf-8", - stdio: ["ignore", "pipe", "inherit"], - }); -} - -function proveCodexRuntime() { - if (!existsSync(join(projectDir, ".git"))) { - execFileSync("git", ["init"], { - cwd: projectDir, - stdio: ["ignore", "ignore", "inherit"], - }); - } - - const realProjectDir = realpathSync(projectDir); - const sourceCodexHome = process.env.CODEX_HOME ?? join(process.env.HOME ?? "", ".codex"); - const sourceAuth = join(sourceCodexHome, "auth.json"); - const sourceConfig = join(sourceCodexHome, "config.toml"); - if (!existsSync(sourceAuth)) { - throw new Error(`Codex runtime smoke requires auth.json at ${sourceAuth}`); - } - - mkdirSync(codexHomeDir, { recursive: true }); - cpSync(sourceAuth, join(codexHomeDir, "auth.json")); - const config = existsSync(sourceConfig) ? readFileSync(sourceConfig, "utf-8") : ""; - writeFileSync( - join(codexHomeDir, "config.toml"), - `${config.trimEnd()}\n\n[projects.${JSON.stringify(realProjectDir)}]\ntrust_level = "trusted"\n`, - ); - - const outputPath = join(tmp, "codex-runtime.jsonl"); - const lastMessagePath = join(tmp, "codex-runtime.out"); - const stderrPath = join(tmp, "codex-runtime.stderr"); - const prompt = [ - "Spawn the custom agent named code-reviewer, wait for it, and return only its exact response.", - "Return only the subagent's exact response.", - "Do not inspect files or answer from project files yourself.", - ].join(" "); - let output; - try { - output = execFileSync( - "codex", - [ - "exec", - "--json", - "-C", - realProjectDir, - "--output-last-message", - lastMessagePath, - prompt, - ], - { - cwd: realProjectDir, - env: { ...process.env, CODEX_HOME: codexHomeDir }, - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], - }, - ); - } catch (err) { - if (err && typeof err === "object" && "stdout" in err && typeof err.stdout === "string") { - writeFileSync(outputPath, err.stdout); - } - if (err && typeof err === "object" && "stderr" in err && typeof err.stderr === "string") { - writeFileSync(stderrPath, err.stderr); - } - throw err; - } - writeFileSync(outputPath, output); - const lastMessage = readFileSync(lastMessagePath, "utf-8"); - assertIncludes(lastMessage, sentinel, "Codex runtime final message should include the subagent sentinel"); - assertCodexRuntimeEvents(output); -} - -function assertSubagentOutputs() { - assertFile(".agents/agents/code-reviewer.md"); - assertFile(".claude/agents/code-reviewer.md"); - assertFile(".cursor/agents/code-reviewer.md"); - assertFile(".codex/agents/code-reviewer.toml"); - assertFile(".opencode/agents/code-reviewer.md"); - assertFileIncludes("agents.lock", "code-reviewer"); - assertFileIncludes(".claude/agents/code-reviewer.md", "Generated by dotagents"); - assertFileIncludes(".claude/agents/code-reviewer.md", "name: \"code-reviewer\""); - assertFileIncludes(".claude/agents/code-reviewer.md", "A proof-only reviewer."); - assertFileIncludes(".claude/agents/code-reviewer.md", sentinel); - assertFileIncludes(".cursor/agents/code-reviewer.md", "Generated by dotagents"); - assertFileIncludes(".cursor/agents/code-reviewer.md", "name: \"code-reviewer\""); - assertFileIncludes(".cursor/agents/code-reviewer.md", "A proof-only reviewer."); - assertFileIncludes(".cursor/agents/code-reviewer.md", sentinel); - assertFileIncludes(".codex/agents/code-reviewer.toml", "Generated by dotagents"); - assertFileIncludes(".codex/agents/code-reviewer.toml", 'name = "code-reviewer"'); - assertFileIncludes(".codex/agents/code-reviewer.toml", 'description = "A proof-only reviewer.'); - assertFileIncludes(".codex/agents/code-reviewer.toml", "developer_instructions = "); - assertFileIncludes(".codex/agents/code-reviewer.toml", sentinel); - assertFileIncludes(".opencode/agents/code-reviewer.md", "Generated by dotagents"); - assertFileIncludes(".opencode/agents/code-reviewer.md", "A proof-only reviewer."); - assertFileIncludes(".opencode/agents/code-reviewer.md", sentinel); -} - -function assertFile(relativePath) { - const path = join(projectDir, relativePath); - if (!existsSync(path)) { - throw new Error(`expected file to exist: ${relativePath}`); - } -} - -function assertSymlink(relativePath) { - const path = join(projectDir, relativePath); - if (!existsSync(path) || !lstatSync(path).isSymbolicLink()) { - throw new Error(`expected symlink to exist: ${relativePath}`); - } -} - -function assertFileIncludes(relativePath, expected) { - assertFile(relativePath); - assertIncludes(readFileSync(join(projectDir, relativePath), "utf-8"), expected, `${relativePath} should include ${expected}`); -} - -function assertIncludes(value, expected, message) { - if (!value.includes(expected)) { - throw new Error(message); - } -} - -/** Verifies Codex spawned and waited on a child agent that returned the sentinel. */ -function assertCodexRuntimeEvents(output) { - assertIncludes(output, '"tool":"spawn_agent"', "Codex runtime JSONL should include a spawn_agent event"); - assertIncludes(output, '"tool":"wait"', "Codex runtime JSONL should include a wait event"); - if (output.includes("unknown agent_type")) { - throw new Error("Codex runtime JSONL reported an unknown custom agent type"); - } - - for (const line of output.split(/\r?\n/)) { - if (!line.trim()) {continue;} - const event = JSON.parse(line); - const states = event.item?.agents_states; - if (!states || typeof states !== "object") {continue;} - for (const state of Object.values(states)) { - if (state?.message?.includes(sentinel)) { - return; - } - } - } - - throw new Error("Codex runtime JSONL should include a waited child-agent response with the sentinel"); -} diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index 6e5fdcd..4687320 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -34,10 +34,13 @@ Write down the QA target before running commands: broken state, user-scope state, or remote source. Read the targeted reference before running runtime-specific QA: -- Core install/sync example: [references/core-smoke.md](references/core-smoke.md) -- Codex custom agents: [references/codex.md](references/codex.md) -- Claude Code files/runtime caveats: [references/claude.md](references/claude.md) +- Core install/sync example: [references/core-agentic-qa.md](references/core-agentic-qa.md) +- Runtime auth, gateway, and `.env.qa.local` handling: [references/runtime-auth.md](references/runtime-auth.md) +- Plugin runtime verification matrix: [references/plugin-runtime.md](references/plugin-runtime.md) +- Codex custom agents and plugin marketplace/install proof: [references/codex.md](references/codex.md) +- Claude Code files, plugin validation, and runtime caveats: [references/claude.md](references/claude.md) - Cursor files/runtime caveats: [references/cursor.md](references/cursor.md) +- 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 +61,15 @@ docker build \ The image installs the latest npm-published Codex, Claude Code, and OpenCode CLIs (`codex`, `claude`, `opencode`). Use them for version checks, help-output checks, and optional isolated runtime probes. Their presence does not prove -runtime discovery by itself; authenticated model-backed checks are still -explicit opt-ins. +runtime discovery by itself; for plugins, prefer native dry-run/management +commands where available: `claude plugin validate` for Claude plugin and +marketplace shape, and `codex plugin marketplace add/list` plus +`codex plugin add/list` for Codex marketplace installation. Authenticated +model-backed checks are still explicit opt-ins. When a model-backed check needs +secrets, put them in host `.env.qa.local`, keep that file out of git, and pass +only the specific variables required for the check into Docker; do not copy the +env file into the fixture or retained `/qa-out` artifacts. See +[references/runtime-auth.md](references/runtime-auth.md). Use an interactive container so the QA steps stay change-specific: @@ -119,7 +129,7 @@ su -s /bin/bash node -c ' pnpm install --frozen-lockfile pnpm build pnpm check - pnpm smoke:examples + pnpm qa:example ' ``` @@ -128,15 +138,15 @@ the check is already known to be unrelated. If `build` or `check` fails, treat that as a QA finding and stop before fixture work unless you are explicitly isolating the playbook mechanics. If skipped or bypassed, report why. -## 3. Prefer The Checked-In Smoke +## 3. Prefer The Checked-In Agentic QA -Use the checked-in example smoke for ordinary install/sync QA: +Use the checked-in example QA for ordinary install/sync QA: ```bash -pnpm smoke:examples +pnpm qa:example ``` -The smoke builds the local CLI, copies `examples/full/` to a temp project, and +The QA runner builds the local CLI, copies `examples/full/` to a temp project, and asserts: - `install`, `list`, `doctor --fix`, and `doctor` - managed skills under `.agents/skills/` @@ -145,17 +155,20 @@ 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 scripts/smoke-examples.mjs --keep` when you need to inspect the temp -project; the script prints the retained path. +Use `node skills/dotagents-qa/scripts/qa-example.mjs all --keep` when you need +to inspect the temp project; the script prints the retained path. For paid Codex runtime proof of generated custom agents, run the runtime proof outside Docker only when the branch affects Codex custom agents or when reporting that Codex itself works: ```bash -node scripts/smoke-examples.mjs --codex-runtime --keep +node skills/dotagents-qa/scripts/qa-example.mjs codex-runtime --keep ``` That mode copies Codex auth/config into a temp `CODEX_HOME`, marks only the @@ -224,6 +237,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 +284,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 +305,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: @@ -320,15 +361,22 @@ opencode --version ``` Codex subagents need real runtime proof before claiming Codex loaded them. Use -`node scripts/smoke-examples.mjs --codex-runtime --keep`; `codex debug -prompt-input` is not enough unless it visibly includes the generated agent name -or instructions. Project-scoped `.codex/agents/` load only when Codex trusts -the project. See [references/codex.md](references/codex.md). +`node skills/dotagents-qa/scripts/qa-example.mjs codex-runtime --keep`; +`codex debug prompt-input` is not enough unless it visibly includes the +generated agent name or instructions. Project-scoped `.codex/agents/` load only +when Codex trusts the project. See [references/codex.md](references/codex.md). Claude has no cheap dry-run skill list. If auth/network/model cost is acceptable, run a minimal non-interactive prompt from the temp project; otherwise report it as skipped. +Provider and gateway checks are runtime-specific. OpenRouter can be used where +the runtime supports an Anthropic-compatible or OpenAI-compatible custom +provider, but Cursor's documented runtime auth is Cursor login/API-key based. +Before claiming runtime proof through a gateway, read +[references/runtime-auth.md](references/runtime-auth.md), run the check inside +Docker, and report the exact provider config used with secret values redacted. + ## 7. Report Evidence Report: diff --git a/skills/dotagents-qa/SOURCES.md b/skills/dotagents-qa/SOURCES.md index e2c548b..a23a422 100644 --- a/skills/dotagents-qa/SOURCES.md +++ b/skills/dotagents-qa/SOURCES.md @@ -7,20 +7,26 @@ | `AGENTS.md` | pnpm and validation conventions | | `package.json` | `pnpm build` and `pnpm check` scripts | | `specs/SPEC.md` | canonical `.agents/skills/`, agent symlink, MCP, hook, install, sync, list, and doctor behavior | -| `packages/dotagents/src/agents/definitions/claude.ts` | Claude project skills and generated config paths | -| `packages/dotagents/src/agents/definitions/cursor.ts` | Cursor shares `.claude/skills` and writes Cursor-specific MCP/hook config | -| `packages/dotagents/src/agents/definitions/vscode.ts` | VS Code reads `.agents/skills` natively and writes `.vscode/mcp.json` plus shared Claude-style hooks | -| `packages/dotagents/src/agents/definitions/codex.ts` | Codex reads `.agents/skills` natively and writes `.codex/config.toml` | -| `packages/dotagents/src/agents/definitions/opencode.ts` | OpenCode reads `.agents/skills` natively and writes `opencode.json` | +| `packages/dotagents/src/targets/definitions/claude.ts` | Claude project skills and generated config paths | +| `packages/dotagents/src/targets/definitions/cursor.ts` | Cursor shares `.claude/skills` and writes Cursor-specific MCP/hook config | +| `packages/dotagents/src/targets/definitions/vscode.ts` | VS Code reads `.agents/skills` natively and writes `.vscode/mcp.json` plus shared Claude-style hooks | +| `packages/dotagents/src/targets/definitions/codex.ts` | Codex reads `.agents/skills` natively and writes `.codex/config.toml` | +| `packages/dotagents/src/targets/definitions/opencode.ts` | OpenCode reads `.agents/skills` natively and writes `opencode.json` | | `packages/dotagents/src/cli/cache.ts` | `DOTAGENTS_STATE_DIR` cache isolation | | `packages/dotagents/src/cli/update-notifier.ts` | `HOME` isolation for update-check cache | | `skills/dotagents/SKILL.md` | sibling skill layout | | `/Users/dcramer/src/sentry-mcp/.agents/skills/mcp-qa/SKILL.md` | Numbered QA flow, primary path guidance, optional client checks, and pass criteria structure | | local `codex debug prompt-input` probe | verifies Codex can expose `.agents/skills` metadata without a model call | +| Claude Code LLM gateway docs | Anthropic-compatible gateway env vars for authenticated runtime QA | +| Codex manual | Custom model provider and `CODEX_HOME` auth/config isolation | +| Cursor CLI auth and BYOK docs | Cursor-specific auth limits and OpenRouter caveat | +| Grok Build docs | Custom model provider config shape | +| OpenCode provider docs | OpenAI-compatible provider config shape | +| OpenRouter API docs | OpenAI-compatible and Anthropic Messages endpoint shapes | ## Decisions -- Keep the runtime skill as a guided QA playbook rather than a broad QA matrix or fixed smoke harness. +- Keep the runtime skill as a guided QA playbook rather than a broad QA matrix or fixed all-in-one harness. - Provide a repo-local Dockerfile for the sandbox/toolchain only; keep fixture design and assertions manual. - Keep the Dockerfile's prepared pnpm version aligned with the root `packageManager`. - Mount the host checkout read-only and do dependency install/build inside Docker to avoid host system-file writes and host binary assumptions. @@ -31,6 +37,9 @@ - Require inspection of generated files and command output that demonstrate the changed behavior, not only exit codes. - Expose the skill through `.agents/skills/dotagents-qa` so existing Claude/Cursor symlinks discover it. - Keep agent CLI registration and remote-source checks optional because they add auth, network, or tool-version variance. +- Keep authenticated runtime QA opt-in, pass only explicit env vars into Docker, + and document runtime-specific gateway support instead of assuming one API key + works across every client. ## Trigger Notes diff --git a/skills/dotagents-qa/SPEC.md b/skills/dotagents-qa/SPEC.md index 782c9d5..41756ca 100644 --- a/skills/dotagents-qa/SPEC.md +++ b/skills/dotagents-qa/SPEC.md @@ -14,12 +14,14 @@ In scope: - optionally checking agent CLI registration when discovery paths changed - optionally using `getsentry/skills` for remote source behavior - isolating home and cache state from the host +- optionally forwarding specific runtime API keys into Docker for model-backed + proof, with secrets kept out of fixtures and retained artifacts Out of scope: - release publishing - network-backed source testing for ordinary install-location changes - replacing focused Vitest regression coverage for logic bugs -- treating a fixed smoke script as sufficient QA for behavior-specific changes +- treating a fixed all-in-one QA task as sufficient for behavior-specific changes ## Runtime Contract @@ -31,11 +33,13 @@ Out of scope: - Run the built CLI from inside the container fixture project so project scope resolves correctly. - Inspect generated files and command output that demonstrate the changed behavior, not just exit codes. - Keep `HOME`, `DOTAGENTS_STATE_DIR`, and `DOTAGENTS_HOME` inside Docker for user-scope checks. +- Keep runtime credentials in host `.env.qa.local`, pass only explicit variables + into Docker, and isolate runtime config such as `CODEX_HOME`. - Report fixture shape, commands, assertions, skipped checks, and residual risk. ## Maintenance - Keep `SKILL.md` focused on guided, change-specific Docker QA. -- Keep examples editable and illustrative; do not turn the skill into a fixed test harness. +- Keep examples editable and illustrative; do not turn the skill into a fixed all-in-one harness. - Keep the Dockerfile pnpm version aligned with the root `packageManager`. - Update examples when dotagents changes config fields, generated file locations, or supported agents. diff --git a/skills/dotagents-qa/references/claude.md b/skills/dotagents-qa/references/claude.md index 72561dc..e54951d 100644 --- a/skills/dotagents-qa/references/claude.md +++ b/skills/dotagents-qa/references/claude.md @@ -1,10 +1,10 @@ # Claude Code QA -Use this reference when changes affect Claude Code skill symlinks, `.claude/settings.json`, `.claude/agents/*.md`, hooks, MCP config, or user-scope Claude paths. +Use this reference when changes affect Claude Code skill symlinks, `.claude/settings.json`, `.claude/agents/*.md`, hooks, MCP config, plugin marketplace output, plugin manifests, or user-scope Claude paths. ## File-Level Checks -The core smoke asserts: +The core agentic QA asserts: - `.claude/skills` is a symlink to `.agents/skills` - `.mcp.json` exists for Claude MCP config @@ -22,10 +22,43 @@ 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 or OpenRouter-backed runtime proof, read +[runtime-auth.md](runtime-auth.md) first. Keep credentials in `.env.qa.local`, +pass only `OPENROUTER_API_KEY` or `ANTHROPIC_*` variables into Docker, and keep +`HOME` isolated to the temp container home. + If a branch specifically requires Claude runtime proof, run an explicit Claude Code invocation only when auth/model cost is acceptable, keep it isolated to a temp project, and report: - exact command diff --git a/skills/dotagents-qa/references/codex.md b/skills/dotagents-qa/references/codex.md index 7e7c294..afc5490 100644 --- a/skills/dotagents-qa/references/codex.md +++ b/skills/dotagents-qa/references/codex.md @@ -1,10 +1,10 @@ # Codex QA -Use this reference when changes affect Codex config generation, `.codex/agents/*.toml`, Codex MCP config, Codex user scope, or claims that generated Codex subagents work in Codex. +Use this reference when changes affect Codex config generation, `.codex/agents/*.toml`, Codex MCP config, Codex plugin marketplace output, Codex user scope, or claims that generated Codex subagents work in Codex. ## File-Level Checks -The core smoke asserts: +The core agentic QA asserts: - `.codex/config.toml` exists for MCP config - `.codex/agents/code-reviewer.toml` exists @@ -14,15 +14,42 @@ The core smoke asserts: These checks prove dotagents wrote the expected files. They do not prove Codex loaded the agent. +## Plugin Checks + +Use these checks when a branch affects plugin output for Codex. Run them inside +the Docker QA container with an isolated `CODEX_HOME`, against a retained QA +project: + +```bash +node skills/dotagents-qa/scripts/qa-example.mjs plugin-codex --keep +``` + +This proves Codex can consume the generated repo-scoped marketplace, discover +the plugin as available, install it into the isolated plugin cache, and list it +as installed/enabled. + +Codex expects repo-scoped marketplaces at +`$REPO_ROOT/.agents/plugins/marketplace.json`. Pass the project root to +`codex plugin marketplace add`, not the `.agents/plugins` directory. Marketplace +entries must use structured local sources with `source = "local"` and +`path = "./.agents/plugins/"`; Codex resolves `source.path` relative to +the marketplace root/project root. + +If `codex plugin marketplace add "$project/.agents/plugins"` fails with +`marketplace root does not contain a supported manifest`, retry with +`"$project"`. If `codex plugin list --available --json` is empty, inspect +`.agents/plugins/marketplace.json` and confirm its `name` matches the selector +used for `codex plugin add @`. + ## Runtime Proof Run the paid runtime proof when the branch affects Codex custom agents or when reporting that Codex itself works: ```bash -node scripts/smoke-examples.mjs --codex-runtime --keep +node skills/dotagents-qa/scripts/qa-example.mjs codex-runtime --keep ``` -The script: +The task: - copies `examples/full/` to a temp project - runs the built local dotagents CLI @@ -40,6 +67,12 @@ With `--keep`, inspect: - `codex-runtime.jsonl` for `spawn_agent`, `wait`, and child-agent response events - `project/.codex/agents/code-reviewer.toml` for the generated file Codex loaded +For OpenRouter or other gateway-backed Codex proof, read +[runtime-auth.md](runtime-auth.md) first. Put provider config in the isolated +`CODEX_HOME/config.toml`; Codex ignores provider redirects in project +`.codex/config.toml`. Pass `OPENROUTER_API_KEY` into Docker only for the +runtime invocation. + ## Important Caveats Project-scoped `.codex/` layers load only when Codex trusts the project. A one-off `-c projects."".trust_level="trusted"` override did not prove sufficient in local testing; the reliable path is a temp `CODEX_HOME/config.toml` with the canonical real project path marked trusted. @@ -48,4 +81,4 @@ Project-scoped `.codex/` layers load only when Codex trusts the project. A one-o If Codex reports `unknown agent_type`, check project trust and the canonical path (`pwd -P`) before assuming the generated TOML schema is wrong. -Do not leave copied Codex auth in retained temp directories. The smoke script scrubs its temp `codex-home`; if you run manual experiments, remove copied auth/config before reporting. +Do not leave copied Codex auth in retained temp directories. The QA task scrubs its temp `codex-home`; if you run manual experiments, remove copied auth/config before reporting. diff --git a/skills/dotagents-qa/references/core-smoke.md b/skills/dotagents-qa/references/core-agentic-qa.md similarity index 74% rename from skills/dotagents-qa/references/core-smoke.md rename to skills/dotagents-qa/references/core-agentic-qa.md index 17be8b9..49b7733 100644 --- a/skills/dotagents-qa/references/core-smoke.md +++ b/skills/dotagents-qa/references/core-agentic-qa.md @@ -1,4 +1,4 @@ -# Core Smoke +# Core Agentic QA Use this reference for ordinary dotagents install/sync QA before drilling into a specific runtime. @@ -7,7 +7,7 @@ Use this reference for ordinary dotagents install/sync QA before drilling into a Run: ```bash -pnpm smoke:examples +pnpm qa:example ``` This builds the local CLI, copies `examples/full/` to a temp project, and verifies: @@ -19,9 +19,13 @@ This builds the local CLI, copies `examples/full/` to a temp project, and verifi - hook files for Claude and Cursor - canonical installed subagent under `.agents/agents/` - generated subagent runtime files for Claude, Cursor, Codex, and OpenCode +- canonical installed plugin bundle under `.agents/plugins/` +- Codex repo-scoped plugin marketplace under `.agents/plugins/marketplace.json` +- generated plugin runtime files for Claude, Cursor, Codex, Grok, and OpenCode - `sync` repair after deleting representative generated files -Use `node scripts/smoke-examples.mjs --keep` to keep the temp project for inspection. The script prints the project path. +Use `node skills/dotagents-qa/scripts/qa-example.mjs all --keep` to keep the +temp project for inspection. The script prints the project path. ## What This Proves diff --git a/skills/dotagents-qa/references/cursor.md b/skills/dotagents-qa/references/cursor.md index 0503885..c45d27c 100644 --- a/skills/dotagents-qa/references/cursor.md +++ b/skills/dotagents-qa/references/cursor.md @@ -4,7 +4,7 @@ Use this reference when changes affect Cursor skill sharing, `.cursor/mcp.json`, ## File-Level Checks -The core smoke asserts: +The core agentic QA asserts: - Cursor shares Claude-compatible skills through `.claude/skills` - `.cursor/mcp.json` exists for Cursor MCP config @@ -20,4 +20,10 @@ For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated Curso This QA skill does not currently include an automated Cursor desktop/runtime proof. Do not claim Cursor runtime discovery from file-level checks alone. +Cursor runtime proof uses Cursor auth, not the generic OpenRouter path. Read +[runtime-auth.md](runtime-auth.md) before using secrets. Use `CURSOR_API_KEY` +or browser login for Cursor CLI/headless checks. Do not claim OpenRouter proof +for Cursor unless current Cursor docs or observed local behavior proves an +OpenRouter-compatible endpoint. + If a branch specifically requires Cursor runtime proof, use a real Cursor session or a documented headless path if one exists in the local environment. Report the exact interaction and evidence. Otherwise report file-level Cursor wiring only. diff --git a/skills/dotagents-qa/references/grok.md b/skills/dotagents-qa/references/grok.md new file mode 100644 index 0000000..266c802 --- /dev/null +++ b/skills/dotagents-qa/references/grok.md @@ -0,0 +1,38 @@ +# Grok Build QA + +Use this reference when changes affect Grok plugin projections, `.grok/` +runtime files, or claims that generated dotagents plugins load in Grok Build. + +## File-Level Checks + +The core agentic QA asserts: + +- `.grok/plugins//.dotagents-managed` exists for each targeted plugin +- the projected plugin bundle contains the canonical plugin files +- `sync` repairs deleted `.grok/plugins/` projections + +These checks prove dotagents wrote the expected Grok plugin files. They do not +prove Grok loaded the plugin. + +## Runtime Proof + +This QA skill does not currently include an automated Grok runtime proof, and +the QA Docker image does not install `grok` yet. Do not claim Grok runtime +discovery from file-level checks alone. + +For authenticated or OpenRouter-backed runtime proof, read +[runtime-auth.md](runtime-auth.md) first. Use a temp Grok config with a custom +model provider and `env_key = "OPENROUTER_API_KEY"` or `env_key = +"XAI_API_KEY"`, then run `grok inspect` before any paid prompt to prove Grok +discovered the temp project config, skills, plugins, hooks, and MCP servers. + +If a branch specifically requires Grok runtime proof, install or make `grok` +available inside the Docker container and report: + +- exact command or interaction +- temp project path +- generated `.grok/plugins/` path +- `grok inspect` evidence for discovered plugin paths +- evidence that Grok loaded or invoked the expected plugin/skill + +Otherwise report file-level Grok wiring only. diff --git a/skills/dotagents-qa/references/opencode.md b/skills/dotagents-qa/references/opencode.md index 4465b07..32c50d6 100644 --- a/skills/dotagents-qa/references/opencode.md +++ b/skills/dotagents-qa/references/opencode.md @@ -1,22 +1,85 @@ # 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 -The core smoke asserts: +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 or OpenRouter-backed runtime proof, read +[runtime-auth.md](runtime-auth.md) first. Use a temp `opencode.json` provider +configuration with `@ai-sdk/openai-compatible`, `options.baseURL`, and +`options.apiKey` referencing `{env:OPENROUTER_API_KEY}`. Keep credentials in +the environment, not in retained fixture files. + +With `OPENROUTER_API_KEY` forwarded into Docker, a minimal proof is: + +- `opencode auth list` reports the OpenRouter environment credential +- `opencode models openrouter` lists the chosen model +- `opencode run --model --format json ""` returns the + sentinel + +This proves OpenCode can call the model provider. To prove generated subagent +invocation, do not pass the generated subagent to `--agent`; OpenCode rejects a +subagent there because `--agent` expects a primary agent. Instead, ask the +primary agent to use the `task` tool with `subagent_type = "code-reviewer"`: + +```bash +opencode run \ + --model "$OPENCODE_QA_MODEL" \ + --format json \ + "Use the task tool to delegate to the code-reviewer subagent. Tell code-reviewer to return exactly DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c and nothing else. Wait for the subagent result, then return only the exact subagent result." +``` + +The JSON output should include a `tool_use` part with: + +- `"tool":"task"` +- `"subagent_type":"code-reviewer"` +- `"status":"completed"` +- task output containing `DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c` + +It should also end with a text part containing only the sentinel. If a branch specifically requires OpenCode runtime proof, use the installed OpenCode CLI/app and report: diff --git a/skills/dotagents-qa/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md new file mode 100644 index 0000000..318cca4 --- /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 `OPENROUTER_API_KEY` or another configured provider key +- Run `opencode run --model "$OPENCODE_QA_MODEL" --format json ""` +- For generated subagents, ask the primary agent to call the `task` tool with + `subagent_type = "code-reviewer"`; do not pass the subagent to `--agent` + +## Cursor + +Best current proof: + +- File-level generated marketplace exists at `.cursor-plugin/marketplace.json` +- The marketplace points at `./.agents/plugins/` +- Cursor-native manifest exists at + `.agents/plugins//.cursor-plugin/plugin.json` +- Generated manifest carries `metadata.managedBy = "dotagents"` and lists the + expected component paths such as `skills`, `commands`, `agents`, `rules`, + `hooks`, or `mcpServers` when those files exist + +Manual final check with the Cursor client: + +```bash +mkdir -p ~/.cursor/plugins/local +ln -s "$PROJECT/.agents/plugins/qa-tools" ~/.cursor/plugins/local/qa-tools +``` + +Then restart Cursor or run **Developer: Reload Window**. Verify: + +- plugin appears in Cursor settings/marketplace UI +- plugin rules/skills/agents/commands/MCP servers appear in their respective + Cursor settings surfaces +- invoking the plugin skill or command yields `DOTAGENTS_PLUGIN_QA_FIXTURE` + +## Grok Build + +Best automated proof depends on the `grok` CLI, which is not installed in the +QA Docker image today. + +With `grok` available in Docker or locally isolated: + +```bash +grok inspect +grok -p "Use the qa-tools plugin skill and return DOTAGENTS_PLUGIN_QA_FIXTURE" \ + -m "$GROK_QA_MODEL" \ + --output-format streaming-json +``` + +Expected evidence: + +- `grok inspect` lists `.grok/plugins/qa-tools` +- `grok inspect` or the TUI extensions modal lists plugin skills/components +- model-backed output returns `DOTAGENTS_PLUGIN_QA_FIXTURE` + +Manual final check: + +- Open the retained project in Grok +- Open the extensions modal with `/plugins`, `/hooks`, `/skills`, or `/mcps` +- Verify the generated plugin and its components are listed +- Invoke the plugin skill or command and check for `DOTAGENTS_PLUGIN_QA_FIXTURE` + +## What Counts As Verified + +- File generated: dotagents writer works, not runtime load. +- Native validate/list/install/details command passes: runtime can parse and + register the plugin. +- Runtime debug config shows a plugin-injected value: plugin code loaded and + executed. +- Model/UI invocation returns the sentinel: end-user behavior is verified. diff --git a/skills/dotagents-qa/references/runtime-auth.md b/skills/dotagents-qa/references/runtime-auth.md new file mode 100644 index 0000000..d698251 --- /dev/null +++ b/skills/dotagents-qa/references/runtime-auth.md @@ -0,0 +1,230 @@ +# Runtime Auth And Gateway QA + +Use this reference when a dotagents change needs model-backed proof from a real +agent client, or when forwarding API keys into the Docker QA sandbox. + +## Secret Handling + +Keep runtime credentials in host `.env.qa.local`. The file is intentionally +gitignored and must stay out of copied fixtures, retained temp projects, and +`/qa-out` evidence. + +Load secrets on the host, then pass only the specific variables needed for the +check into Docker: + +```bash +set -a +source .env.qa.local +set +a + +docker run --rm -i \ + -e OPENROUTER_API_KEY \ + -e ANTHROPIC_API_KEY \ + -e XAI_API_KEY \ + -e CURSOR_API_KEY \ + -v "$REPO:/host-repo:ro" \ + -v "$OUT:/qa-out" \ + dotagents-qa:local +``` + +A useful `.env.qa.local` template is: + +```bash +# General gateway key used by OpenCode and optional Claude/Codex/Grok probes. +OPENROUTER_API_KEY=... + +# Claude Code direct Anthropic auth or OpenRouter-over-Anthropic gateway. +ANTHROPIC_API_KEY=... +ANTHROPIC_AUTH_TOKEN=${OPENROUTER_API_KEY} +ANTHROPIC_BASE_URL=https://openrouter.ai/api +ANTHROPIC_MODEL=anthropic/claude-sonnet-4 +ANTHROPIC_CUSTOM_MODEL_OPTION=anthropic/claude-sonnet-4 +ANTHROPIC_DEFAULT_SONNET_MODEL=anthropic/claude-sonnet-4 + +# Codex direct OpenAI auth or custom-provider experiments. +OPENAI_API_KEY=... +CODEX_API_KEY=... +CODEX_ACCESS_TOKEN=... + +# Grok and Cursor runtime-specific auth. +XAI_API_KEY=... +CURSOR_API_KEY=... + +# Optional model override for manual OpenCode runtime QA. +OPENCODE_QA_MODEL=openrouter/anthropic/claude-haiku-4.5 +``` + +Only set aliases like `ANTHROPIC_AUTH_TOKEN=${OPENROUTER_API_KEY}` when you +intend that runtime to use the gateway. For first-party provider proof, set the +provider's real key instead and leave the gateway overrides unset. + +Inside Docker, keep client state isolated with temp homes such as `HOME`, +`CODEX_HOME`, `DOTAGENTS_HOME`, and any runtime-specific config directory the +client supports. Do not use or mount host runtime homes for ordinary QA. + +Report the provider config shape and model IDs, but redact tokens. If a check +requires network or paid model calls and is skipped, say exactly which runtime +was skipped and what file-level or dry-run proof was completed instead. + +## Runtime Matrix + +| Runtime | OpenRouter or custom gateway path | QA status | +| --- | --- | --- | +| Claude Code | Supported through Anthropic Messages gateways. Use `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, and an explicit model override. | Good candidate for model-backed plugin/skill discovery proof. | +| Codex | Supported through user-level custom model providers in `CODEX_HOME/config.toml`. Use `base_url`, `env_key`, and a model that the gateway supports. | Good candidate, but provider config must live in isolated `CODEX_HOME`, not project `.codex/config.toml`. | +| Grok Build | Supported through custom model config with `model`, `base_url`, `name`, and `env_key`. | Candidate once the Grok CLI is available in the QA image or installed in the container. | +| OpenCode | Supported through custom providers using `@ai-sdk/openai-compatible`, `options.baseURL`, and `options.apiKey`. | Candidate once provider config is written to isolated `opencode.json`. | +| Cursor | Cursor CLI documents browser login, `CURSOR_API_KEY`, and `--endpoint`. Cursor BYOK docs cover named providers such as OpenAI, Anthropic, Google, Azure, and Bedrock, not arbitrary OpenRouter-compatible provider config. | Do not claim OpenRouter proof for Cursor unless a documented endpoint or local runtime behavior is verified. Use Cursor API-key/login proof separately. | + +## OpenRouter Baseline + +OpenRouter exposes an OpenAI-compatible chat endpoint at: + +```text +https://openrouter.ai/api/v1/chat/completions +``` + +It also exposes an Anthropic Messages endpoint at: + +```text +https://openrouter.ai/api/v1/messages +``` + +Use `OPENROUTER_API_KEY` as a bearer token. Model IDs include provider prefixes, +for example `anthropic/claude-sonnet-4` or `openai/gpt-5.2`. Pick a cheap, +tool-capable model for QA unless the runtime requires a particular family. + +## Claude Code With OpenRouter + +Claude Code supports LLM gateways that expose Anthropic Messages APIs and sends +`ANTHROPIC_AUTH_TOKEN` as a bearer token. A minimal OpenRouter probe should set: + +```bash +export ANTHROPIC_BASE_URL="https://openrouter.ai/api" +export ANTHROPIC_AUTH_TOKEN="$OPENROUTER_API_KEY" +export ANTHROPIC_MODEL="anthropic/claude-sonnet-4" +``` + +If the model is not available in the picker or aliases resolve incorrectly, add: + +```bash +export ANTHROPIC_CUSTOM_MODEL_OPTION="anthropic/claude-sonnet-4" +export ANTHROPIC_DEFAULT_SONNET_MODEL="anthropic/claude-sonnet-4" +``` + +Keep `HOME` pointed at the container temp home. For plugin QA, install the +example, validate generated plugin files first, then run the smallest prompt +that can prove the desired skill/plugin behavior. + +## Codex With OpenRouter + +Codex custom provider settings must be in user-level `CODEX_HOME/config.toml`. +Project `.codex/config.toml` cannot set provider redirects such as +`model_provider`, `model_providers`, or `openai_base_url`. + +For OpenRouter, use an isolated `CODEX_HOME` and write a provider config like: + +```toml +model = "openai/gpt-5.2" +model_provider = "openrouter" + +[model_providers.openrouter] +name = "OpenRouter" +base_url = "https://openrouter.ai/api/v1" +env_key = "OPENROUTER_API_KEY" +``` + +If the selected model requires the Responses API and the gateway supports it, +add: + +```toml +wire_api = "responses" +``` + +Then run `codex exec` inside Docker with `CODEX_HOME` and +`OPENROUTER_API_KEY` set. Keep the existing `codex-runtime` task as the +reference pattern for trusting only the temp project and removing copied auth. + +## Grok Build With OpenRouter + +Grok Build documents custom models with `model`, `base_url`, `name`, and +`env_key`. An OpenRouter candidate config is: + +```toml +[model.openrouter-claude] +model = "anthropic/claude-sonnet-4" +base_url = "https://openrouter.ai/api/v1" +name = "Claude via OpenRouter" +env_key = "OPENROUTER_API_KEY" + +[models] +default = "openrouter-claude" +``` + +Run `grok inspect` before any paid prompt to confirm the config, skills, +plugins, hooks, and MCP servers discovered in the temp project. Use +`grok -p "..." -m openrouter-claude` for a model-backed proof. If `grok` is not +installed in the QA image, report Grok file-level plugin output plus the +missing CLI runtime proof. + +## OpenCode With OpenRouter + +OpenCode supports custom OpenAI-compatible providers through +`@ai-sdk/openai-compatible`, `options.baseURL`, model definitions, and env +interpolation in `options.apiKey`. + +Use a temp project `opencode.json` or isolated config that includes: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openrouter": { + "npm": "@ai-sdk/openai-compatible", + "name": "OpenRouter", + "options": { + "baseURL": "https://openrouter.ai/api/v1", + "apiKey": "{env:OPENROUTER_API_KEY}" + }, + "models": { + "anthropic/claude-sonnet-4": { + "name": "Claude Sonnet via OpenRouter" + } + } + } + } +} +``` + +Run `opencode auth list` or a cheap prompt from the temp project only after the +file-level plugin component projection passes. Report whether OpenCode sees +the generated `.opencode/skills/*` or `.opencode/agents/*` component separately +from whether the model call succeeded. + +## Cursor + +Cursor runtime QA is not the same as gateway QA. The CLI documents +`CURSOR_API_KEY` or browser login, and exposes `--endpoint` for endpoint issues. +The Cursor BYOK UI documents provider-specific API keys, but not arbitrary +OpenRouter-compatible provider configuration. + +For now: + +- Use file-level checks for `.cursor/mcp.json`, `.cursor/hooks.json`, and + `.cursor/agents/*.md`. +- Use `CURSOR_API_KEY` only for Cursor CLI/headless proof when the test target + is Cursor runtime behavior. +- Do not represent `OPENROUTER_API_KEY` as sufficient Cursor proof unless a + current Cursor doc or an observed local command proves the endpoint shape. + +## Sources + +- Claude Code LLM gateway configuration: https://code.claude.com/docs/en/llm-gateway +- Claude Code model configuration: https://code.claude.com/docs/en/model-config +- Codex custom model providers and environment variables: https://developers.openai.com/codex/codex-manual.md +- Cursor CLI authentication: https://cursor.com/docs/cli/reference/authentication.md +- Cursor BYOK: https://cursor.com/help/models-and-usage/api-keys.md +- Grok Build getting started and custom models: https://docs.x.ai/build/overview +- OpenCode providers: https://opencode.ai/docs/providers/ +- OpenRouter API overview: https://openrouter.ai/docs/api/reference/overview +- OpenRouter Anthropic Messages endpoint: https://openrouter.ai/docs/api/api-reference/anthropic-messages/create-messages diff --git a/skills/dotagents-qa/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs new file mode 100644 index 0000000..297535b --- /dev/null +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -0,0 +1,459 @@ +#!/usr/bin/env node +// Task-oriented agentic QA for the checked-in dotagents example. Keep this +// script with the dotagents-qa skill so runtime proof stays beside its docs. + +import { execFileSync } from "node:child_process"; +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + realpathSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../../.."); +const cliPath = join(repoRoot, "packages", "dotagents", "dist", "cli", "index.js"); +const exampleRoot = join(repoRoot, "examples", "full"); +const sentinel = "DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c"; + +const rawArgs = process.argv.slice(2); +const flags = new Set(rawArgs.filter((arg) => arg.startsWith("--"))); +const keep = flags.has("--keep"); +const task = taskName(rawArgs); + +const taskGroups = { + all: ["install-files", "sync-repair"], +}; + +const tasks = { + "install-files": runInstallFiles, + "sync-repair": runSyncRepair, + "plugin-claude": runClaudePluginProof, + "plugin-codex": runCodexPluginProof, + "plugin-opencode": runOpenCodePluginProof, + "codex-runtime": runCodexRuntimeProof, +}; + +if (flags.has("--help") || flags.has("-h")) { + printUsage(); + process.exit(0); +} + +if (!existsSync(cliPath)) { + console.error(`qa-example: missing built CLI at ${cliPath}`); + console.error("Run `pnpm build` first."); + process.exit(1); +} + +if (!tasks[task] && !taskGroups[task]) { + console.error(`qa-example: unknown task "${task}"`); + printUsage(); + process.exit(1); +} + +const tmp = mkdtempSync(join(tmpdir(), "dotagents-example-")); +const projectDir = join(tmp, "project"); +const homeDir = join(tmp, "home"); +const stateDir = join(tmp, "state"); +const dotagentsHomeDir = join(tmp, "dotagents-home"); +const codexHomeDir = join(tmp, "codex-home"); +mkdirSync(homeDir, { recursive: true }); +mkdirSync(stateDir, { recursive: true }); +mkdirSync(dotagentsHomeDir, { recursive: true }); +cpSync(exampleRoot, projectDir, { recursive: true }); + +const fixtureEnv = { + ...process.env, + HOME: homeDir, + DOTAGENTS_HOME: dotagentsHomeDir, + DOTAGENTS_STATE_DIR: stateDir, +}; + +try { + console.log(`qa-example: project=${projectDir}`); + for (const name of expandedTasks(task)) { + console.log(`qa-example: task=${name}`); + tasks[name](); + } + console.log("qa-example: ok"); +} catch (err) { + console.error("qa-example: failed"); + console.error(err instanceof Error ? err.message : String(err)); + console.error(`qa-example: project kept at ${projectDir}`); + process.exitCode = 1; + throw err; +} finally { + rmSync(codexHomeDir, { recursive: true, force: true }); + if (!keep && process.exitCode !== 1) { + rmSync(tmp, { recursive: true, force: true }); + } +} + +function taskName(args) { + if (flags.has("--codex-runtime")) {return "codex-runtime";} + return args.find((arg) => !arg.startsWith("--")) ?? "all"; +} + +function expandedTasks(name) { + return taskGroups[name] ?? [name]; +} + +function printUsage() { + console.error(`Usage: node skills/dotagents-qa/scripts/qa-example.mjs [--keep] + +Tasks: + all Run install-files and sync-repair + install-files Install the full example and assert generated files + sync-repair Delete representative generated files and assert sync repairs them + plugin-claude Validate generated Claude plugin and marketplace with Claude Code + plugin-codex Add/list/install generated Codex marketplace with Codex CLI + plugin-opencode Assert generated OpenCode plugin skill and agent projections + codex-runtime Paid proof that Codex can spawn the generated custom agent + +Compatibility: + --codex-runtime Alias for the codex-runtime task +`); +} + +function runInstallFiles() { + installAndAssert(); +} + +function runSyncRepair() { + installAndAssert(); + rmSync(join(projectDir, ".mcp.json"), { force: true }); + rmSync(join(projectDir, ".claude", "skills"), { force: true, recursive: true }); + rmSync(join(projectDir, ".claude", "agents", "code-reviewer.md"), { force: true }); + rmSync(join(projectDir, ".codex", "agents", "code-reviewer.toml"), { force: true }); + rmSync(join(projectDir, ".agents", "plugins", "marketplace.json"), { force: true }); + rmSync(join(projectDir, ".claude-plugin", "marketplace.json"), { force: true }); + rmSync(join(projectDir, ".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(); +} + +function runClaudePluginProof() { + installAndAssert(); + execFileSync("claude", ["plugin", "validate", join(projectDir, ".agents", "plugins", "qa-tools")], { + cwd: projectDir, + env: fixtureEnv, + stdio: "inherit", + }); + execFileSync("claude", ["plugin", "validate", join(projectDir, ".claude-plugin", "marketplace.json")], { + cwd: projectDir, + env: fixtureEnv, + stdio: "inherit", + }); +} + +function runCodexPluginProof() { + installAndAssert(); + rmSync(codexHomeDir, { recursive: true, force: true }); + mkdirSync(codexHomeDir, { recursive: true }); + const env = { ...fixtureEnv, CODEX_HOME: codexHomeDir }; + + const add = execJson("codex", ["plugin", "marketplace", "add", projectDir, "--json"], env); + if (add.marketplaceName !== "dotagents-local") { + throw new Error("Codex marketplace add did not return dotagents-local"); + } + + const available = execJson("codex", ["plugin", "list", "--available", "--json"], env); + if (!available.available?.some((plugin) => plugin.pluginId === "qa-tools@dotagents-local")) { + throw new Error("Codex available plugin list did not include qa-tools@dotagents-local"); + } + + const installed = execJson("codex", ["plugin", "add", "qa-tools@dotagents-local", "--json"], env); + if (installed.pluginId !== "qa-tools@dotagents-local") { + throw new Error("Codex plugin add did not install qa-tools@dotagents-local"); + } + + const list = execJson("codex", ["plugin", "list", "--json"], env); + if (!list.installed?.some((plugin) => plugin.pluginId === "qa-tools@dotagents-local" && plugin.enabled === true)) { + throw new Error("Codex installed plugin list did not include enabled qa-tools@dotagents-local"); + } +} + +function runOpenCodePluginProof() { + installAndAssert(); + 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"); + } +} + +function runCodexRuntimeProof() { + installAndAssert(); + proveCodexRuntime(); +} + +function installAndAssert() { + runCli(["install"]); + const list = runCli(["list"]); + writeFileSync(join(tmp, "list.out"), list); + runCli(["doctor", "--fix"]); + runCli(["doctor"]); + assertIncludes(list, "review", "list output should include review"); + assertIncludes(list, "commit", "list output should include commit"); + assertIncludes(list, "qa-tools", "list output should include qa-tools plugin"); + + assertFile(".agents/skills/review/SKILL.md"); + assertFile(".agents/skills/commit/SKILL.md"); + assertFileIncludes(".agents/skills/review/SKILL.md", "name: review"); + assertFileIncludes(".agents/skills/review/SKILL.md", "Review fixture."); + assertFileIncludes(".agents/skills/commit/SKILL.md", "name: commit"); + assertFileIncludes(".agents/skills/commit/SKILL.md", "Commit fixture."); + assertSymlink(".claude/skills"); + assertFile(".mcp.json"); + assertFile(".cursor/mcp.json"); + assertFile(".codex/config.toml"); + assertFile("opencode.json"); + assertFile(".claude/settings.json"); + assertFile(".cursor/hooks.json"); + assertSubagentOutputs(); + assertPluginOutputs(); +} + +function runCli(cliArgs) { + return execFileSync("node", [cliPath, ...cliArgs], { + cwd: projectDir, + env: fixtureEnv, + encoding: "utf-8", + stdio: ["ignore", "pipe", "inherit"], + }); +} + +function execJson(command, args, env) { + const output = execFileSync(command, args, { + cwd: projectDir, + env, + encoding: "utf-8", + stdio: ["ignore", "pipe", "inherit"], + }); + return JSON.parse(output); +} + +function proveCodexRuntime() { + if (!existsSync(join(projectDir, ".git"))) { + execFileSync("git", ["init"], { + cwd: projectDir, + stdio: ["ignore", "ignore", "inherit"], + }); + } + + const realProjectDir = realpathSync(projectDir); + const sourceCodexHome = process.env.CODEX_HOME ?? join(process.env.HOME ?? "", ".codex"); + const sourceAuth = join(sourceCodexHome, "auth.json"); + const sourceConfig = join(sourceCodexHome, "config.toml"); + if (!existsSync(sourceAuth)) { + throw new Error(`Codex runtime QA requires auth.json at ${sourceAuth}`); + } + + mkdirSync(codexHomeDir, { recursive: true }); + cpSync(sourceAuth, join(codexHomeDir, "auth.json")); + const config = existsSync(sourceConfig) ? readFileSync(sourceConfig, "utf-8") : ""; + writeFileSync( + join(codexHomeDir, "config.toml"), + `${config.trimEnd()}\n\n[projects.${JSON.stringify(realProjectDir)}]\ntrust_level = "trusted"\n`, + ); + + const outputPath = join(tmp, "codex-runtime.jsonl"); + const lastMessagePath = join(tmp, "codex-runtime.out"); + const stderrPath = join(tmp, "codex-runtime.stderr"); + const prompt = [ + "Spawn the custom agent named code-reviewer, wait for it, and return only its exact response.", + "Return only the subagent's exact response.", + "Do not inspect files or answer from project files yourself.", + ].join(" "); + let output; + try { + output = execFileSync( + "codex", + [ + "exec", + "--json", + "-C", + realProjectDir, + "--output-last-message", + lastMessagePath, + prompt, + ], + { + cwd: realProjectDir, + env: { ...process.env, CODEX_HOME: codexHomeDir }, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } catch (err) { + if (err && typeof err === "object" && "stdout" in err && typeof err.stdout === "string") { + writeFileSync(outputPath, err.stdout); + } + if (err && typeof err === "object" && "stderr" in err && typeof err.stderr === "string") { + writeFileSync(stderrPath, err.stderr); + } + throw err; + } + writeFileSync(outputPath, output); + const lastMessage = readFileSync(lastMessagePath, "utf-8"); + assertIncludes(lastMessage, sentinel, "Codex runtime final message should include the subagent sentinel"); + assertCodexRuntimeEvents(output); +} + +function assertSubagentOutputs() { + assertFile(".agents/agents/code-reviewer.md"); + assertFile(".claude/agents/code-reviewer.md"); + assertFile(".cursor/agents/code-reviewer.md"); + assertFile(".codex/agents/code-reviewer.toml"); + assertFile(".opencode/agents/code-reviewer.md"); + assertFileIncludes("agents.lock", "code-reviewer"); + assertFileIncludes(".claude/agents/code-reviewer.md", "Generated by dotagents"); + assertFileIncludes(".claude/agents/code-reviewer.md", "name: \"code-reviewer\""); + assertFileIncludes(".claude/agents/code-reviewer.md", "A proof-only reviewer."); + assertFileIncludes(".claude/agents/code-reviewer.md", sentinel); + assertFileIncludes(".cursor/agents/code-reviewer.md", "Generated by dotagents"); + assertFileIncludes(".cursor/agents/code-reviewer.md", "name: \"code-reviewer\""); + assertFileIncludes(".cursor/agents/code-reviewer.md", "A proof-only reviewer."); + assertFileIncludes(".cursor/agents/code-reviewer.md", sentinel); + assertFileIncludes(".codex/agents/code-reviewer.toml", "Generated by dotagents"); + assertFileIncludes(".codex/agents/code-reviewer.toml", 'name = "code-reviewer"'); + assertFileIncludes(".codex/agents/code-reviewer.toml", 'description = "A proof-only reviewer.'); + assertFileIncludes(".codex/agents/code-reviewer.toml", "developer_instructions = "); + assertFileIncludes(".codex/agents/code-reviewer.toml", sentinel); + assertFileIncludes(".opencode/agents/code-reviewer.md", "Generated by dotagents"); + assertFileIncludes(".opencode/agents/code-reviewer.md", "A proof-only reviewer."); + 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)) { + throw new Error(`expected file to exist: ${relativePath}`); + } +} + +function assertSymlink(relativePath) { + const path = join(projectDir, relativePath); + if (!existsSync(path) || !lstatSync(path).isSymbolicLink()) { + throw new Error(`expected symlink to exist: ${relativePath}`); + } +} + +function assertFileIncludes(relativePath, expected) { + assertFile(relativePath); + assertIncludes(readFileSync(join(projectDir, relativePath), "utf-8"), expected, `${relativePath} should include ${expected}`); +} + +function assertSameFile(actualPath, expectedPath) { + assertFile(actualPath); + assertFile(expectedPath); + const actual = readFileSync(join(projectDir, actualPath), "utf-8"); + const expected = readFileSync(join(projectDir, expectedPath), "utf-8"); + if (actual !== expected) { + throw new Error(`${actualPath} should match ${expectedPath}`); + } +} + +function assertIncludes(value, expected, message) { + if (!value.includes(expected)) { + throw new Error(message); + } +} + +function assertCodexRuntimeEvents(output) { + assertIncludes(output, '"tool":"spawn_agent"', "Codex runtime JSONL should include a spawn_agent event"); + assertIncludes(output, '"tool":"wait"', "Codex runtime JSONL should include a wait event"); + if (output.includes("unknown agent_type")) { + throw new Error("Codex runtime JSONL reported an unknown custom agent type"); + } + + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) {continue;} + const event = JSON.parse(line); + const states = event.item?.agents_states; + if (!states || typeof states !== "object") {continue;} + for (const state of Object.values(states)) { + if (state?.message?.includes(sentinel)) { + return; + } + } + } + + throw new Error("Codex runtime JSONL should include a waited child-agent response with the sentinel"); +} diff --git a/specs/SPEC.md b/specs/SPEC.md index 9771504..2fa4d2d 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. + +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 ` @@ -538,6 +603,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 +659,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 +678,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 +734,12 @@ The YAML frontmatter is parsed with the `yaml` package. `allowed-tools` can be a ## Gitignore Strategy dotagents always manages gitignore. Two files are added to the root `.gitignore` during `init`: -- `agents.lock` — tracks managed skills and subagents +- `agents.lock` — tracks managed skills, subagents, and plugins - `.agents/.gitignore` — excludes managed skill directories and canonical installed subagent files from git ### How It Works -Managed (external) skills and canonical installed subagent files are gitignored. Custom (local) skills are tracked. dotagents generates `.agents/.gitignore` listing every managed skill and installed subagent: +Managed (external) skills, canonical installed subagent files, and managed copied plugin bundles are gitignored. Custom local skills and project-authored plugin source directories in `.agents/plugins//` are tracked when they are not installed dependencies. dotagents generates `.agents/.gitignore` listing every managed skill, installed subagent, and managed plugin bundle: ```gitignore # Auto-generated by dotagents. Do not edit. @@ -680,9 +747,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 +817,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 @@ -774,7 +842,7 @@ dotagents/ mcp.ts trust.ts doctor.ts - agents/ + targets/ types.ts # McpDeclaration, AgentDefinition interfaces registry.ts # Agent registry (claude, cursor, codex, vscode, opencode) definitions/ # Per-agent definitions @@ -782,6 +850,10 @@ dotagents/ hook-writer.ts # Hook config file generation per agent paths.ts # Agent config path resolution errors.ts # Agent-specific error types + subagents/ # Subagent identity, store, formatting, and runtime writer + plugins/ # Plugin schema/store plus target-specific runtime projection + agents/ + index.ts # Compatibility re-export barrel for older internal imports config/ # agents.toml schema, loader, writer lockfile/ # agents.lock schema, loader, writer symlinks/ # Symlink create/verify/repair diff --git a/specs/plugins.md b/specs/plugins.md new file mode 100644 index 0000000..777f5a9 --- /dev/null +++ b/specs/plugins.md @@ -0,0 +1,311 @@ +# 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": "" }`. Runtime extension source objects may be accepted when their known path fields are safe, but dotagents must report them as unsupported instead of guessing how to resolve them. +3. `.agents/plugins//plugin.json` must be a JSON object. Known component path fields are validated as relative filesystem paths without `..` or URL/scheme prefixes when present. Unknown fields are allowed and preserved so native runtimes and future dotagents versions can add metadata without breaking older installs. +4. All component paths in canonical manifests and marketplaces must be relative filesystem paths and must not contain `..`, URL/scheme prefixes, absolute POSIX paths, absolute Windows paths, or backslash-rooted paths. +5. The portable plugin `name` in `agents.toml` is authoritative. If a discovered manifest also declares `name`, it must match the configured name. + +Generated outputs are deterministic: + +1. JSON output is pretty-printed with two-space indentation, sorted object keys, sorted plugin entries by name, and exactly one trailing newline. +2. Runtime marketplace outputs are overwritten only when they carry `metadata.managedBy = "dotagents"`. Hand-written runtime marketplaces are left untouched and reported as warnings. +3. Managed generated marketplace files are pruned when no configured plugin targets that runtime anymore. +4. Remote or copied plugin bundles under `.agents/plugins//` are treated as dotagents-managed and are listed in `.agents/.gitignore`. +5. Plugin sources that resolve to the same project's `.agents/plugins//` install destination are rejected. dotagents must not install a same-repo plugin onto itself. + +## Documentation Sources + +This design is based on the public plugin documentation current as of 2026-06-12: + +| Runtime | Documentation | +|---------|---------------| +| Claude Code | https://code.claude.com/docs/en/plugins, https://code.claude.com/docs/en/plugins-reference.md, and https://code.claude.com/docs/en/plugin-marketplaces | +| Cursor | https://cursor.com/docs/plugins.md and https://cursor.com/docs/reference/plugins.md | +| Codex | https://developers.openai.com/codex/plugins and https://developers.openai.com/codex/plugins/build | +| Grok Build | https://docs.x.ai/build/features/skills-plugins-marketplaces and https://docs.x.ai/build/overview | +| OpenCode | https://opencode.ai/docs/plugins | +| `plugins` npm package | https://www.npmjs.com/package/plugins and https://github.com/vercel-labs/plugins | + +The npm `plugins` package is an interoperability reference, not a proposed dotagents dependency. Its public metadata describes an "open-plugin format" installer for agent tools, and its current package scans `.plugin/`, `.claude-plugin/`, `.cursor-plugin/`, and `.codex-plugin/` manifests. `.plugin/` is not a native directory for the runtimes dotagents targets, so dotagents should support it only as an import compatibility format, not as the canonical authoring or install location. + +## Configuration + +Plugins should be declared in `agents.toml`: + +```toml +[[plugins]] +name = "sentry-tools" +source = "getsentry/agent-plugins" +ref = "v1.2.0" +path = "plugins/sentry-tools" +targets = ["claude", "cursor", "codex", "grok"] +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Portable dotagents ID. Must start with lowercase `a-z`, 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": "./.agents/plugins/my-plugin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} +``` + +The canonical catalog may include vendor-specific extension fields, but dotagents should keep the required core small: marketplace `name`, optional display metadata, and `plugins[]` entries with `name` and `source`. Writers may emit simpler relative string sources for runtimes that prefer them, such as Claude and Cursor. + +Example dotagents manifest: + +```json +{ + "name": "my-plugin", + "version": "1.0.0", + "description": "Shared agent workflows", + "author": { "name": "Sentry" }, + "skills": "./skills", + "agents": "./agents", + "commands": "./commands", + "rules": "./rules", + "hooks": "./hooks/hooks.json", + "mcpServers": "./.mcp.json", + "lspServers": "./.lsp.json" +} +``` + +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 root, optionally applying the marketplace's `metadata.pluginRoot` prefix when present. If a marketplace entry points at a remote source object that dotagents cannot resolve yet, it should produce an unsupported-source warning rather than guessing. + +## Component Handling + +dotagents should handle plugin components in three buckets: + +| Bucket | Components | Behavior | +|--------|------------|----------| +| Portable existing dotagents concepts | skills, MCP servers, hooks, subagents/agents | Load through existing parsers where possible and generate runtime configs using existing agent writers. Preserve native plugin copies for matching runtimes. | +| Runtime-specific files | Cursor rules, Claude/Codex/Grok LSP servers, Codex apps, Claude monitors, Claude settings, binaries | 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 and each targeted plugin gets a Claude-native manifest. | +| Cursor | `.cursor-plugin/marketplace.json` and `.agents/plugins//.cursor-plugin/plugin.json` | Not generated yet | Generated marketplace uses deterministic `./.agents/plugins/` sources and each targeted plugin gets a Cursor-native manifest. | +| Codex | `.agents/plugins/marketplace.json` and generated `.codex-plugin/plugin.json` in installed bundle | Not generated yet | Generated marketplace uses deterministic `{ "source": "local", "path": "./.agents/plugins/" }` entries relative to the project root. | +| 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.