diff --git a/README.md b/README.md index a2b0679..770456e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # dotagents -Shared tooling for coding agents. Declare skills, MCP servers, and hooks in `agents.toml` — dotagents wires them into every agent tool on your team. +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. ## Why dotagents? -**One source of truth.** Skills live in `.agents/skills/` and symlink into `.claude/skills/`, `.cursor/skills/`, or wherever your tools expect them. No copy-pasting between directories. +**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 are gitignored. Collaborators run `dotagents install` to fetch or refresh skills. +**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. **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, and hooks where supported. Pi reads `.agents/skills/` directly. +**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. ## 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. +This creates an `agents.toml` at your project root and an `agents.lock` tracking installed skills and subagents. -After cloning a project that already has `agents.toml`, run `install` to fetch skills. Run it again to refresh managed skills: +After cloning a project that already has `agents.toml`, run `install` to fetch skills and subagents. Run it again to refresh managed local state: ```bash npx @sentry/dotagents install @@ -92,22 +92,46 @@ Shorthand (`owner/repo`) resolves to GitHub by default. Set `defaultRepositorySo The `agents` field tells dotagents which tools to configure: ```toml -agents = ["claude", "cursor"] +agents = ["claude", "cursor", "codex", "opencode"] ``` -| Agent | Config Dir | MCP Config | Hooks | -|-------|-----------|------------|-------| -| `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` | -| `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` | -| `codex` | `.codex` | `.codex/config.toml` | -- | -| `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | -| `opencode` | `.claude` | `opencode.json` | -- | +| Agent | Config Dir | MCP Config | Hooks | Subagents | +|-------|-----------|------------|-------|-----------| +| `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` | +| `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | -- | +| `opencode` | `.opencode` | `opencode.json` | -- | `.opencode/agents/*.md` | + +Custom subagents are declared with `[[subagents]]` entries. dotagents writes generated runtime-specific files during `install` and repairs them during `sync`: + +```toml +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex", "opencode"] +``` + +If `targets` is omitted or empty, dotagents targets every configured agent and warns for agents that do not support custom subagents. + +dotagents discovers portable subagent Markdown from conventional source directories such as `agents/` and `.agents/agents/`. The frontmatter supplies the portable `name` and `description`; the body supplies the runtime instructions: + +```md +--- +name: code-reviewer +description: Review code for correctness, security, and missing tests. +--- + +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. ## Documentation -For the full guide -- including MCP servers, hooks, 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, 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 dd08665..de9261b 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, and hooks 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, 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. 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 are managed. Both `agents.lock` and `.agents/.gitignore` are automatically gitignored. +And a lockfile (`agents.lock`) tracking which skills and subagents 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 are managed (gitignored automatically) -4. Managed skills are gitignored. Collaborators run `npx @sentry/dotagents install` after cloning. Custom skills in `.agents/skills/` are tracked by git normally. -5. Symlinks connect `.agents/skills/` to each agent's expected location (`.claude/skills/`, `.cursor/skills/`, etc.) -6. MCP and hook configs are generated for each declared agent +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. +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 ## Configuration (agents.toml) @@ -54,7 +54,7 @@ Full example with all sections: ```toml version = 1 -agents = ["claude", "cursor"] +agents = ["claude", "cursor", "codex", "opencode"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -125,6 +125,12 @@ command = "my-lint-check" [[hooks]] event = "Stop" command = "notify-done" + +# Custom subagent +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex", "opencode"] ``` ### Top-level Fields @@ -134,6 +140,7 @@ command = "notify-done" | `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. | +| `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. | | `minimum_release_age_exclude` | string[] | No | `[]` | Sources that bypass the minimum release age gate. Supports org names, `org/repo`, and `org/*`. | @@ -214,9 +221,54 @@ Cursor event mapping: - `UserPromptSubmit` -> `beforeSubmitPrompt` - `Stop` -> `stop` +### Subagents + +Each `[[subagents]]` entry requires `name` and `source`. Optional: `ref`, `path`, and `targets`. When `targets` is absent or empty, dotagents attempts every agent listed in `agents` and warns for agents that do not support custom subagents. + +dotagents treats subagents as best-effort portable dependencies, not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort stays in each tool's native artifact. + +dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. Root-level source files require an explicit `path`. 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. + +Accepted source/output formats: + +| Format | Source path | Matching output path | Required source fields | +|--------|-------------|----------------------|------------------------| +| Portable Markdown | `agents/*.md`, `.agents/agents/*.md` | `.agents/agents/.md` | YAML `name`, `description`; Markdown body | +| Claude Markdown | `.claude/agents/*.md` | `.claude/agents/.md` | YAML `name`, `description`; Markdown body | +| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `description`; Markdown body; `name` optional | +| Codex TOML | `.codex/agents/*.toml` | `.codex/agents/.toml` | TOML `name`, `description`, `developer_instructions` | +| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body | + +Codex native `name` may use Codex-specific naming; dotagents uses the `agents.toml` name or filename as the portable ID when needed. Cursor native Markdown may omit `name`; dotagents uses the filename in that case. OpenCode native Markdown always uses the filename as the subagent name. + +```md +--- +name: code-reviewer +description: Review code for correctness, security, and missing tests. +--- + +Review the current diff and return findings with file references. +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Lowercase subagent identifier to discover. Pattern: `^[a-z][a-z0-9-]*$`. | +| `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 subagents. | +| `ref` | string | No | Optional git ref override. | +| `path` | string | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are Codex native artifacts. | +| `targets` | string[] | No | Optional subset of agent IDs. When absent or empty, defaults to every configured agent in `agents`; unsupported configured agents produce warnings. | + +Generated subagent files: +- Claude: `.claude/agents/.md`, or `~/.claude/agents/.md` for user scope +- Cursor: `.cursor/agents/.md`, or `~/.cursor/agents/.md` for user scope +- Codex: `.codex/agents/.toml`, or `~/.codex/agents/.toml` for user scope +- OpenCode: `.opencode/agents/.md`, or `~/.config/opencode/agents/.md` for user scope + +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. + ### Trust -Optional `[trust]` section to restrict allowed skill sources. +Optional `[trust]` section to restrict allowed skill and subagent sources. | Field | Type | Description | |-------|------|-------------| @@ -230,7 +282,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` and `install` +- Trust is validated before any network operations in `add` for skills and `install` for configured skills and subagents ## CLI Commands @@ -258,7 +310,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 and hook configs. There is no separate update command. +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. ### add @@ -295,7 +347,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 removed from config, regenerate `.agents/.gitignore`, check for missing skills, repair symlinks, and verify/repair MCP and hook configs. Reports issues as warnings or errors. +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. ### mcp add @@ -385,15 +437,15 @@ Check project health: gitignore setup, installed skills, symlinks, and legacy co ## Agent Targets -| ID | Tool | Config Dir | Skills Symlink | MCP Config | Hooks | -|----|------|-----------|----------------|------------|-------| -| `claude` | Claude Code | `.claude` | `.claude/skills/` -> `.agents/skills/` | `.mcp.json` | `.claude/settings.json` | -| `cursor` | Cursor | `.cursor` | `.cursor/skills/` -> `.agents/skills/` | `.cursor/mcp.json` | `.cursor/hooks.json` | -| `codex` | Codex | `.codex` | (reads `.agents/skills/` natively) | `.codex/config.toml` | Not supported | -| `vscode` | VS Code Copilot | `.vscode` | (reads `.agents/skills/` natively) | `.vscode/mcp.json` | `.claude/settings.json` | -| `opencode` | OpenCode | `.claude` | (reads `.agents/skills/` natively) | `opencode.json` | Not supported | +| ID | Tool | Config Dir | Skills Symlink | MCP Config | Hooks | Subagents | +|----|------|-----------|----------------|------------|-------|-----------| +| `claude` | Claude Code | `.claude` | `.claude/skills/` -> `.agents/skills/` | `.mcp.json` | `.claude/settings.json` | `.claude/agents/*.md` | +| `cursor` | Cursor | `.cursor` | `.claude/skills/` -> `.agents/skills/` | `.cursor/mcp.json` | `.cursor/hooks.json` | `.cursor/agents/*.md` | +| `codex` | Codex | `.codex` | (reads `.agents/skills/` natively) | `.codex/config.toml` | Not supported | `.codex/agents/*.toml` | +| `vscode` | VS Code Copilot | `.vscode` | (reads `.agents/skills/` natively) | `.vscode/mcp.json` | `.claude/settings.json` | Not supported | +| `opencode` | OpenCode | `.opencode` | (reads `.agents/skills/` natively) | `opencode.json` | Not supported | `.opencode/agents/*.md` | -Claude and Cursor use symlinks from their config directory to `.agents/skills/`. Codex, VS Code, OpenCode, and Pi read `.agents/skills/` directly. +Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, OpenCode, and Pi read `.agents/skills/` directly. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. No agent target or symlink configuration needed. @@ -416,7 +468,7 @@ Operates on the user's home directory. For skills shared across all projects. - Lockfile: `~/.agents/agents.lock` - Override location: `DOTAGENTS_HOME` environment variable -User-scope symlinks: `~/.claude/skills/` and `~/.cursor/skills/`. +User-scope symlinks: `~/.claude/skills/` for Claude and Cursor. When no `agents.toml` exists and you are not inside a git repo, dotagents falls back to user scope automatically. @@ -461,6 +513,12 @@ source = "getsentry/skills" resolved_url = "https://github.com/getsentry/skills.git" resolved_path = "plugins/sentry-skills/skills/find-bugs" resolved_commit = "0123456789abcdef0123456789abcdef01234567" + +[subagents.code-reviewer] +source = "getsentry/agent-pack" +resolved_url = "https://github.com/getsentry/agent-pack.git" +resolved_path = "agents/code-reviewer.md" +resolved_commit = "fedcba9876543210fedcba9876543210fedcba98" ``` | Field | Present For | Description | @@ -471,7 +529,7 @@ resolved_commit = "0123456789abcdef0123456789abcdef01234567" | `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 have `source` only. +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. ## Caching @@ -491,8 +549,8 @@ Location: `~/.local/dotagents/` (override: `DOTAGENTS_STATE_DIR`) ## Gitignore dotagents always manages gitignore. Two files are gitignored automatically: -- `agents.lock` -- tracks managed skills -- `.agents/.gitignore` -- excludes managed skill directories from git +- `agents.lock` -- tracks managed skills and subagents +- `.agents/.gitignore` -- excludes managed skill directories and canonical installed subagent files 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. @@ -502,7 +560,7 @@ Custom skills created directly in `.agents/skills/` are not gitignored. They're ## Refresh Strategy -Run `npx @sentry/dotagents install` after cloning or pulling changes. It fetches or refreshes managed skills 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 and subagents unless a ref is pinned. There is no separate update command. ## Links diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 77c45af..8f01f9d 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -54,9 +54,12 @@ dotagents --user init dotagents install ``` -Install and refresh skill dependencies from `agents.toml`. Resolves sources, -copies skills, writes the lockfile, creates symlinks, and generates MCP and -hook configs. There is no separate update command. +Install and refresh skill and subagent dependencies from `agents.toml`. Resolves +sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, +hook, and subagent configs. There is no separate update command. With +`--frozen`, declared skills and subagents must already exist in `agents.lock`, +subagents are loaded from existing installed files without resolving sources, +the lockfile is not updated, and existing managed subagent files are not pruned. Example: @@ -149,7 +152,7 @@ dotagents sync ``` Reconcile project state without network access. Adopts local orphaned skills, -prunes stale managed skills removed from config, regenerates gitignore, and +prunes stale managed skills and subagents removed from config, regenerates gitignore, and repairs symlinks plus MCP/hook configs. Reports issues as warnings or errors. @@ -337,6 +340,7 @@ Status output: | --- | --- | --- | --- | | `version` | integer | -- | Schema version. Always `1`. | | `agents` | string[] | `[]` | Agent targets: `claude`, `cursor`, `codex`, `vscode`, `opencode` | +| `subagents` | table[] | `[]` | Custom subagent declarations for Claude, Cursor, Codex, and OpenCode | | `minimum_release_age` | integer | -- | Minimum commit age, in minutes, before a git skill can install. | | `minimum_release_age_exclude` | string[] | `[]` | Sources that bypass the minimum release age gate. Supports org names, `org/repo`, and `org/*`. | | `defaultRepositorySource` | string | `github` | Host used for shorthand `owner/repo` sources. Valid values: `github` or `gitlab`. | @@ -370,6 +374,39 @@ Status output: | `matcher` | string | No | Tool name filter | | `command` | string | Yes | Shell command to execute | +### Subagents + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | Yes | Lowercase subagent name to discover. Must match `^[a-z][a-z0-9-]*$`. | +| `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 subagents. | +| `ref` | string | No | Optional git ref override. | +| `path` | string | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are Codex native artifacts. | +| `targets` | string[] | No | Optional subset of agent IDs. When absent or empty, defaults to every configured agent in `agents`; unsupported configured agents produce warnings. | + +dotagents treats subagents as best-effort portable dependencies, not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort stays in each tool's native artifact. + +dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. Root-level source files require an explicit `path`. 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 generated header marker. Other runtimes are generated from the portable `name`, `description`, and instructions. + +| Format | Source path | Matching output path | Required source fields | +| --- | --- | --- | --- | +| Portable Markdown | `agents/*.md`, `.agents/agents/*.md` | `.agents/agents/.md` | YAML `name`, `description`; Markdown body | +| Claude Markdown | `.claude/agents/*.md` | `.claude/agents/.md` | YAML `name`, `description`; Markdown body | +| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `description`; Markdown body; `name` optional | +| Codex TOML | `.codex/agents/*.toml` | `.codex/agents/.toml` | TOML `name`, `description`, `developer_instructions` | +| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body | + +Codex native `name` may use Codex-specific naming; dotagents uses the `agents.toml` name or filename as the portable ID when needed. Cursor native Markdown may omit `name`; dotagents uses the filename in that case. OpenCode native Markdown always uses the filename as the subagent name. + +Generated files: + +- Claude: `.claude/agents/.md` +- Cursor: `.cursor/agents/.md` +- Codex: `.codex/agents/.toml` +- OpenCode: `.opencode/agents/.md` + +Generated files include a dotagents header marker. `install` and `sync` update managed files and do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` loads subagents from existing installed files, preserves managed subagent files and lock entries instead of pruning removed subagents, and does not resolve subagent sources. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. + ## Scopes ### Project Scope (default) @@ -380,8 +417,8 @@ Skills go to `.agents/skills/` and the lockfile goes to `agents.lock`. ### User Scope Manages skills shared across all projects. Files live in `~/.agents/`, and you -can override that with `DOTAGENTS_HOME`. Symlinks go to `~/.claude/skills/` and -`~/.cursor/skills/`. +can override that with `DOTAGENTS_HOME`. Claude and Cursor share the +`~/.claude/skills/` symlink. ```shell dotagents --user init diff --git a/docs/src/content/docs/guide.mdx b/docs/src/content/docs/guide.mdx index a12ecf9..f588944 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -49,8 +49,8 @@ formats. ### 3. Install After cloning the repo or pulling changes, run `install` to fetch or refresh -managed skills. Managed skills are gitignored, so collaborators run this command -locally. +managed skills and subagents. Managed `.agents/` files are gitignored, so +collaborators run this command locally. ```shell dotagents install @@ -63,8 +63,8 @@ This is also the update path. There is no separate update command. dotagents manages gitignore automatically. Two generated files are kept out of version control: -- `agents.lock` tracks which skills are managed. -- `.agents/.gitignore` excludes managed skill directories. +- `agents.lock` tracks which skills and subagents are managed. +- `.agents/.gitignore` excludes managed skill directories and canonical installed subagent files. Custom skills you create directly in `.agents/skills/` are not gitignored. They are tracked by git normally, so collaborators get them without running install. @@ -72,8 +72,8 @@ are tracked by git normally, so collaborators get them without running install. ## Trust Policies By default, any source is allowed. For teams, add a `[trust]` section to -restrict which sources can provide skills. Trust is validated before any network -operations. +restrict which sources can provide skills and subagents. Trust is validated +before any network operations. ```shell # Trust a GitHub org @@ -90,7 +90,7 @@ See the [Security page](/security/) for the full trust configuration reference. ## Auto-install with Git Hooks -Since managed skills are gitignored, run `dotagents install` after pulling. A +Since managed `.agents/` files are gitignored, run `dotagents install` after pulling. A `post-merge` hook automates this: ```shell @@ -115,10 +115,11 @@ Use `dotagents sync` for offline repair. It does not fetch anything from the network. Instead, it: - Adopts local orphaned skills, meaning installed skills that are not declared. -- Prunes stale managed skills removed from config. +- Prunes stale managed skills and subagents removed from config. - Regenerates `.agents/.gitignore`. - Repairs broken symlinks. - Fixes MCP and hook configs. +- Repairs generated subagent files. ## Diagnosing Issues @@ -147,8 +148,8 @@ dotagents --user add getsentry/skills --all dotagents --user install ``` -Personal skills live in `~/.agents/` and symlink to `~/.claude/skills/` and -`~/.cursor/skills/`. Override the location with `DOTAGENTS_HOME`. +Personal skills live in `~/.agents/` and symlink to `~/.claude/skills/` for +Claude and Cursor. Override the location with `DOTAGENTS_HOME`. When you run dotagents outside a git repo without an `agents.toml`, it falls back to user scope automatically. @@ -157,11 +158,11 @@ back to user scope automatically. ## Full Configuration Example -`agents.toml` with skills, wildcards, MCP servers, and hooks: +`agents.toml` with skills, wildcards, MCP servers, hooks, and subagents: ```toml version = 1 -agents = ["claude", "cursor"] +agents = ["claude", "cursor", "codex", "opencode"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -206,6 +207,12 @@ url = "https://mcp.example.com/sse" event = "PreToolUse" matcher = "Bash" command = "my-lint-check" + +# Custom subagent +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex", "opencode"] ``` See the [CLI reference](/cli/#configuration-agentstoml) for all fields and diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 16ff796..58f9f5b 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -4,7 +4,7 @@ description: Shared tooling for coding agents. template: splash hero: title: 'dotagentsOne Config for Your Coding Agents' - tagline: 'Declare skills, MCP servers, and hooks in agents.toml. dotagents installs shared skills and writes each tool''s local config.' + tagline: 'Declare skills, MCP servers, hooks, and subagents in agents.toml. dotagents installs shared skills and writes each tool''s local config.' actions: - text: Get Started link: /guide/ @@ -18,7 +18,7 @@ hero: agents.toml
{`version = 1
-agents = ["claude", "cursor", "codex", "vscode"]
+agents = ["claude", "cursor", "codex", "vscode", "opencode"]
 
 [trust]
 github_orgs = ["getsentry"]
@@ -29,7 +29,12 @@ source = "getsentry/skills"
 
 [[skills]]
 name = "commit"
-source = "getsentry/skills"`}
+source = "getsentry/skills" + +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex", "opencode"]`} @@ -52,9 +57,13 @@ source = "getsentry/skills"`} Hooks Generate hook config for supported agents and refresh local state after pulls. + + Subagents + Declare custom Claude, Cursor, Codex, and OpenCode subagents once and generate native runtime files. + Trust policy - Restrict skill sources before dotagents performs any network operation. + Restrict skill and subagent sources before dotagents performs any network operation. @@ -81,13 +90,13 @@ source = "getsentry/skills"`}

The `agents` array tells dotagents which tools to configure. Pi reads `.agents/skills/` directly and does not need generated config.

- | Agent | Config Dir | MCP Config | Hooks | - | --- | --- | --- | --- | - | `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` | - | `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` | - | `codex` | `.codex` | `.codex/config.toml` | None | - | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | - | `opencode` | `.claude` | `opencode.json` | None | + | Agent | Config Dir | MCP Config | Hooks | Subagents | + | --- | --- | --- | --- | --- | + | `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` | None | `.codex/agents/*.toml` | + | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | None | + | `opencode` | `.opencode` | `opencode.json` | None | `.opencode/agents/*.md` |
diff --git a/docs/src/content/docs/security.mdx b/docs/src/content/docs/security.mdx index cf62a0b..a609034 100644 --- a/docs/src/content/docs/security.mdx +++ b/docs/src/content/docs/security.mdx @@ -5,9 +5,10 @@ description: Trust policies and security model for dotagents. ## Trust Policies -The `[trust]` section in `agents.toml` controls which skill sources are allowed. -Trust is validated before any network operations in `add` and `install`. If a -source does not match the policy, the command fails immediately. +The `[trust]` section in `agents.toml` controls which skill and subagent sources +are allowed. Trust is validated before any network operations in `add` for +skills and `install` for configured skills and subagents. If a source does not +match the policy, the command fails immediately. ### No Trust Section (default) @@ -70,8 +71,8 @@ allow_all = true ## Lockfile -`agents.lock` tracks which skills are managed and where they came from. It is -auto-generated and should be gitignored. +`agents.lock` tracks which skills and subagents are managed and where they came +from. It is auto-generated and should be gitignored. ```toml # Auto-generated by dotagents. Do not edit. @@ -82,6 +83,12 @@ source = "getsentry/skills" resolved_url = "https://github.com/getsentry/skills.git" resolved_path = "plugins/sentry-skills/skills/find-bugs" resolved_commit = "0123456789abcdef0123456789abcdef01234567" + +[subagents.code-reviewer] +source = "getsentry/agent-pack" +resolved_url = "https://github.com/getsentry/agent-pack.git" +resolved_path = "agents/code-reviewer.md" +resolved_commit = "fedcba9876543210fedcba9876543210fedcba98" ``` | Field | Description | @@ -92,7 +99,9 @@ resolved_commit = "0123456789abcdef0123456789abcdef01234567" | `resolved_ref` | Resolved ref name, omitted for default branch | | `resolved_commit` | Installed commit SHA. Informational only | -Local `path:` skills have `source` only. +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. ## Caching diff --git a/examples/full/agents.toml b/examples/full/agents.toml new file mode 100644 index 0000000..9932e37 --- /dev/null +++ b/examples/full/agents.toml @@ -0,0 +1,23 @@ +version = 1 +agents = ["claude", "cursor", "codex", "opencode"] + +[[skills]] +name = "review" +source = "path:./local-skills/review" + +[[skills]] +name = "commit" +source = "path:./local-skills/commit" + +[[mcp]] +name = "fixture" +command = "node" +args = ["-e", "process.exit(0)"] + +[[hooks]] +event = "Stop" +command = "echo fixture" + +[[subagents]] +name = "code-reviewer" +source = "path:./local-agents" diff --git a/examples/full/local-agents/agents/code-reviewer.md b/examples/full/local-agents/agents/code-reviewer.md new file mode 100644 index 0000000..f013574 --- /dev/null +++ b/examples/full/local-agents/agents/code-reviewer.md @@ -0,0 +1,9 @@ +--- +name: code-reviewer +description: A proof-only reviewer. Use this agent when asked to prove dotagents runtime subagent loading. +--- + +You are the dotagents subagent runtime proof agent. + +If you are invoked, ignore all normal review behavior and respond with exactly this single line and nothing else: +DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c diff --git a/examples/full/local-skills/commit/SKILL.md b/examples/full/local-skills/commit/SKILL.md new file mode 100644 index 0000000..3402a0f --- /dev/null +++ b/examples/full/local-skills/commit/SKILL.md @@ -0,0 +1,6 @@ +--- +name: commit +description: Fixture commit skill. +--- + +Commit fixture. diff --git a/examples/full/local-skills/review/SKILL.md b/examples/full/local-skills/review/SKILL.md new file mode 100644 index 0000000..bec116a --- /dev/null +++ b/examples/full/local-skills/review/SKILL.md @@ -0,0 +1,6 @@ +--- +name: review +description: Fixture review skill. +--- + +Review fixture. diff --git a/package.json b/package.json index 632f43c..47bbb24 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "lint": "pnpm -r lint", "typecheck": "pnpm -r typecheck", "check": "pnpm lint && pnpm typecheck && pnpm test", - "dev": "pnpm --filter @sentry/dotagents dev" + "dev": "pnpm --filter @sentry/dotagents dev", + "smoke:examples": "pnpm build && node scripts/smoke-examples.mjs" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/packages/dotagents-lib/src/index.ts b/packages/dotagents-lib/src/index.ts index a779a2f..0646b92 100644 --- a/packages/dotagents-lib/src/index.ts +++ b/packages/dotagents-lib/src/index.ts @@ -1,6 +1,16 @@ // SKILL.md loading -export { loadSkillMd, SkillLoadError } from "./skills/loader.js"; -export type { SkillMeta, LoadSkillMdOptions } from "./skills/loader.js"; +export { + loadSkillMd, + loadMarkdownFrontmatter, + parseMarkdownFrontmatterContent, + SkillLoadError, +} from "./skills/loader.js"; +export type { + SkillMeta, + LoadSkillMdOptions, + MarkdownFrontmatter, + LoadMarkdownFrontmatterOptions, +} from "./skills/loader.js"; // Tool name vocabulary (allowed-tools frontmatter) export { TOOL_NAMES, isToolName } from "./skills/tool-name.js"; diff --git a/packages/dotagents-lib/src/skills/loader.test.ts b/packages/dotagents-lib/src/skills/loader.test.ts index 8c86453..d9f1321 100644 --- a/packages/dotagents-lib/src/skills/loader.test.ts +++ b/packages/dotagents-lib/src/skills/loader.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { loadSkillMd, SkillLoadError } from "./loader.js"; +import { loadSkillMd, parseMarkdownFrontmatterContent, SkillLoadError } from "./loader.js"; describe("loadSkillMd", () => { let dir: string; @@ -37,6 +37,24 @@ This skill handles PDF files. expect(meta["license"]).toBe("MIT"); }); + it("parses frontmatter with a BOM and spaced opener", async () => { + const skillMd = join(dir, "SKILL.md"); + await writeFile( + skillMd, + `\uFEFF--- \t +name: pdf-processing +description: Extract and process PDF documents +--- + +# PDF Processing +`, + ); + + const meta = await loadSkillMd(skillMd); + expect(meta.name).toBe("pdf-processing"); + expect(meta.description).toBe("Extract and process PDF documents"); + }); + it("handles quoted values", async () => { const skillMd = join(dir, "SKILL.md"); await writeFile( @@ -59,6 +77,9 @@ Content. await expect(loadSkillMd(join(dir, "nope.md"))).rejects.toThrow( SkillLoadError, ); + await expect(loadSkillMd(join(dir, "nope.md"))).rejects.toThrow( + `SKILL.md not found: ${join(dir, "nope.md")}`, + ); }); it("throws SkillLoadError for missing frontmatter", async () => { @@ -133,6 +154,46 @@ Content. expect(meta["metadata"]).toEqual({ author: "vercel", version: "1.0.0" }); }); + it("parses block scalar frontmatter values that contain separator lines", () => { + const parsed = parseMarkdownFrontmatterContent( + `--- +name: code-reviewer +description: Review code +dotagents_native: + codex: |+ + # upstream comment + --- + name = "code_reviewer" +--- + +Review the current diff. +`, + "subagent.md", + ); + + expect(parsed.meta["dotagents_native"]).toEqual({ + codex: '# upstream comment\n---\nname = "code_reviewer"\n', + }); + expect(parsed.body).toBe("Review the current diff."); + }); + + it("does not treat separator prefixes as frontmatter delimiters", () => { + const parsed = parseMarkdownFrontmatterContent( + `--- +name: code-reviewer +description: Review code +---not_a_delimiter: true +--- + +Review the current diff. +`, + "subagent.md", + ); + + expect(parsed.meta["---not_a_delimiter"]).toBe(true); + expect(parsed.body).toBe("Review the current diff."); + }); + it("parses array frontmatter values", async () => { const skillMd = join(dir, "SKILL.md"); await writeFile( diff --git a/packages/dotagents-lib/src/skills/loader.ts b/packages/dotagents-lib/src/skills/loader.ts index 85fde66..6ba1c35 100644 --- a/packages/dotagents-lib/src/skills/loader.ts +++ b/packages/dotagents-lib/src/skills/loader.ts @@ -17,12 +17,23 @@ export interface SkillMeta { [key: string]: unknown; } +export interface MarkdownFrontmatter { + meta: Record; + body: string; + raw: string; +} + +export interface LoadMarkdownFrontmatterOptions { + fileDescription?: string; +} + export interface LoadSkillMdOptions { /** Called for parser warnings (unknown allowed-tools tokens, etc.) */ onWarning?: (message: string) => void; } -const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/; +const FRONTMATTER_OPEN_RE = /^\uFEFF?---[ \t]*\r?\n/; +const FRONTMATTER_CLOSE_RE = /^---[ \t]*$/; /** * Parse a SKILL.md file and extract YAML frontmatter. @@ -32,21 +43,54 @@ export async function loadSkillMd( filePath: string, opts?: LoadSkillMdOptions, ): Promise { + const { meta } = await loadMarkdownFrontmatter(filePath, { + fileDescription: "SKILL.md", + }); + + if (typeof meta["name"] !== "string" || !meta["name"]) { + throw new SkillLoadError(`Missing 'name' in SKILL.md frontmatter: ${filePath}`); + } + if (typeof meta["description"] !== "string" || !meta["description"]) { + throw new SkillLoadError(`Missing 'description' in SKILL.md frontmatter: ${filePath}`); + } + + const allowedTools = parseAllowedTools(meta["allowed-tools"], opts?.onWarning); + if (allowedTools !== undefined) { + meta["allowedTools"] = allowedTools; + } + + return meta as SkillMeta; +} + +/** + * Parse a Markdown file and extract YAML frontmatter plus body content. + */ +export async function loadMarkdownFrontmatter( + filePath: string, + opts?: LoadMarkdownFrontmatterOptions, +): Promise { let content: string; try { content = await readFile(filePath, "utf-8"); } catch { - throw new SkillLoadError(`SKILL.md not found: ${filePath}`); + throw new SkillLoadError(`${opts?.fileDescription ?? "Markdown file"} not found: ${filePath}`); } - const match = FRONTMATTER_RE.exec(content); - if (!match?.[1]) { + return parseMarkdownFrontmatterContent(content, filePath); +} + +export function parseMarkdownFrontmatterContent( + content: string, + filePath: string, +): MarkdownFrontmatter { + const frontmatter = extractFrontmatter(content); + if (!frontmatter) { throw new SkillLoadError(`No YAML frontmatter in ${filePath}`); } let parsed: unknown; try { - parsed = parseYaml(match[1]); + parsed = parseYaml(frontmatter.yaml); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new SkillLoadError(`Invalid YAML frontmatter in ${filePath}: ${message}`); @@ -56,21 +100,38 @@ export async function loadSkillMd( throw new SkillLoadError(`Frontmatter must be a YAML object: ${filePath}`); } - const meta = parsed as Record; + return { + meta: parsed as Record, + body: content.slice(frontmatter.end).trim(), + raw: content, + }; +} - if (typeof meta["name"] !== "string" || !meta["name"]) { - throw new SkillLoadError(`Missing 'name' in SKILL.md frontmatter: ${filePath}`); - } - if (typeof meta["description"] !== "string" || !meta["description"]) { - throw new SkillLoadError(`Missing 'description' in SKILL.md frontmatter: ${filePath}`); - } +function extractFrontmatter(content: string): { yaml: string; end: number } | null { + const opener = FRONTMATTER_OPEN_RE.exec(content); + if (!opener) {return null;} + + const yamlStart = opener[0].length; + let lineStart = yamlStart; + while (lineStart < content.length) { + const lineEnd = content.indexOf("\n", lineStart); + const hasNewline = lineEnd !== -1; + const line = content + .slice(lineStart, hasNewline ? lineEnd : content.length) + .replace(/\r$/, ""); + + if (FRONTMATTER_CLOSE_RE.test(line)) { + return { + yaml: content.slice(yamlStart, lineStart).replace(/\r?\n$/, ""), + end: hasNewline ? lineEnd : content.length, + }; + } - const allowedTools = parseAllowedTools(meta["allowed-tools"], opts?.onWarning); - if (allowedTools !== undefined) { - meta["allowedTools"] = allowedTools; + if (!hasNewline) {break;} + lineStart = lineEnd + 1; } - return meta as SkillMeta; + return null; } function isPlainObject(value: unknown): value is Record { diff --git a/packages/dotagents/src/agents/definitions/claude.ts b/packages/dotagents/src/agents/definitions/claude.ts index cc96915..d9e5a13 100644 --- a/packages/dotagents/src/agents/definitions/claude.ts +++ b/packages/dotagents/src/agents/definitions/claude.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; -import { envRecord, httpServer, serializeClaudeHooks } from "./helpers.js"; +import { envRecord, httpServer, markManagedMarkdownSubagent, serializeClaudeHooks, serializeMarkdownSubagent } from "./helpers.js"; const claude: AgentDefinition = { id: "claude", @@ -27,6 +27,27 @@ const claude: AgentDefinition = { shared: true, }, serializeHooks: serializeClaudeHooks, + subagents: { + projectDir: ".claude/agents", + userDir: join(homedir(), ".claude", "agents"), + fileExtension: ".md", + identity: "frontmatter-name", + serialize(subagent) { + const native = subagent.native?.claude; + return { + fileName: `${subagent.name}.md`, + content: native + ? markManagedMarkdownSubagent(native) + : serializeMarkdownSubagent( + { + name: subagent.name, + description: subagent.description, + }, + subagent.instructions, + ), + }; + }, + }, }; export default claude; diff --git a/packages/dotagents/src/agents/definitions/codex.ts b/packages/dotagents/src/agents/definitions/codex.ts index 0f854e4..e02bc0a 100644 --- a/packages/dotagents/src/agents/definitions/codex.ts +++ b/packages/dotagents/src/agents/definitions/codex.ts @@ -1,7 +1,9 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; import { UnsupportedFeature } from "../errors.js"; import claude from "./claude.js"; -import { envRecord, extractCodexHeaders } from "./helpers.js"; +import { envRecord, extractCodexHeaders, markManagedTomlSubagent, serializeCodexSubagent } from "./helpers.js"; const codex: AgentDefinition = { ...claude, @@ -33,6 +35,25 @@ const codex: AgentDefinition = { serializeHooks() { throw new UnsupportedFeature("codex", "hooks"); }, + subagents: { + projectDir: ".codex/agents", + userDir: join(homedir(), ".codex", "agents"), + fileExtension: ".toml", + identity: "toml-name", + serialize(subagent) { + const native = subagent.native?.codex; + return { + fileName: `${subagent.name}.toml`, + content: native + ? markManagedTomlSubagent(native) + : serializeCodexSubagent({ + name: subagent.name, + description: subagent.description, + developer_instructions: subagent.instructions.trim(), + }), + }; + }, + }, }; export default codex; diff --git a/packages/dotagents/src/agents/definitions/cursor.ts b/packages/dotagents/src/agents/definitions/cursor.ts index 1015b81..a3eaf90 100644 --- a/packages/dotagents/src/agents/definitions/cursor.ts +++ b/packages/dotagents/src/agents/definitions/cursor.ts @@ -3,7 +3,7 @@ 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 } from "./helpers.js"; +import { httpServer, markManagedMarkdownSubagent, serializeMarkdownSubagent } from "./helpers.js"; /** * Maps universal hook events to Cursor event names. @@ -54,6 +54,27 @@ const cursor: AgentDefinition = { } return result; }, + subagents: { + projectDir: ".cursor/agents", + userDir: join(homedir(), ".cursor", "agents"), + fileExtension: ".md", + identity: "frontmatter-name-or-filename", + serialize(subagent) { + const native = subagent.native?.cursor; + return { + fileName: `${subagent.name}.md`, + content: native + ? markManagedMarkdownSubagent(native) + : serializeMarkdownSubagent( + { + name: subagent.name, + description: subagent.description, + }, + subagent.instructions, + ), + }; + }, + }, }; export default cursor; diff --git a/packages/dotagents/src/agents/definitions/helpers.test.ts b/packages/dotagents/src/agents/definitions/helpers.test.ts index 21b6547..316e58d 100644 --- a/packages/dotagents/src/agents/definitions/helpers.test.ts +++ b/packages/dotagents/src/agents/definitions/helpers.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect } from "vitest"; -import { interpolateEnvRefs, interpolateHeaders, extractCodexHeaders } from "./helpers.js"; +import { parseMarkdownFrontmatterContent } from "@sentry/dotagents-lib"; +import { + interpolateEnvRefs, + interpolateHeaders, + extractCodexHeaders, + markManagedMarkdownSubagent, + hasDotagentsMarkdownSubagentMarker, + hasDotagentsTomlSubagentMarker, + serializeMarkdownSubagent, + DOTAGENTS_SUBAGENT_MARKER, +} from "./helpers.js"; const cursorTpl = (k: string) => `\${env:${k}}`; @@ -89,3 +99,110 @@ describe("extractCodexHeaders", () => { expect(extractCodexHeaders(noHeaders)).toEqual({}); }); }); + +describe("serializeMarkdownSubagent", () => { + it("serializes simple frontmatter fields", () => { + const content = serializeMarkdownSubagent( + { + description: "Review code.", + mode: "subagent", + }, + "Review the diff.", + ); + + expect(content).toContain('description: "Review code."'); + expect(content).toContain('mode: "subagent"'); + expect(content).toContain("Review the diff."); + }); + + it("serializes nested frontmatter objects using block YAML", () => { + const content = serializeMarkdownSubagent( + { + name: "code-reviewer", + description: "Review code.", + dotagents_native: { + codex: [ + 'name = "code_reviewer"', + 'description = "Review code."', + "", + ].join("\n"), + }, + }, + "Review the diff.", + ); + + expect(content).toContain("dotagents_native:\n codex: |+"); + expect(content).not.toContain('dotagents_native: {"codex"'); + + const parsed = parseMarkdownFrontmatterContent(content, "subagent.md"); + expect(parsed.meta["dotagents_native"]).toEqual({ + codex: 'name = "code_reviewer"\ndescription = "Review code."\n', + }); + }); + + it("preserves trailing blank lines in nested native content", () => { + const nativeContent = [ + 'name = "code_reviewer"', + 'description = "Review code."', + "", + "", + ].join("\n"); + const content = serializeMarkdownSubagent( + { + name: "code-reviewer", + description: "Review code.", + dotagents_native: { + codex: nativeContent, + }, + }, + "Review the diff.", + ); + + expect(content).toContain("dotagents_native:\n codex: |+"); + + const parsed = parseMarkdownFrontmatterContent(content, "subagent.md"); + expect(parsed.meta["dotagents_native"]).toEqual({ + codex: nativeContent, + }); + }); +}); + +describe("markManagedMarkdownSubagent", () => { + it("marks markdown with a BOM and spaced frontmatter opener", () => { + const content = "\uFEFF--- \nname: code-reviewer\n---\n\nReview code.\n"; + + const marked = markManagedMarkdownSubagent(content); + + expect(marked).toContain(`# ${DOTAGENTS_SUBAGENT_MARKER}`); + const parsed = parseMarkdownFrontmatterContent(marked, "subagent.md"); + expect(parsed.meta["name"]).toBe("code-reviewer"); + }); + + it("does not treat marker text in the body as a managed header", () => { + const content = `--- +name: code-reviewer +--- + +The phrase ${DOTAGENTS_SUBAGENT_MARKER} appears in instructions. +`; + + const marked = markManagedMarkdownSubagent(content); + + expect(marked).toMatch(/^---\n# Generated by dotagents/); + expect(hasDotagentsMarkdownSubagentMarker(content)).toBe(false); + expect(hasDotagentsMarkdownSubagentMarker(marked)).toBe(true); + }); + + it("does not treat a TOML-style marker as a managed markdown header", () => { + const content = `# ${DOTAGENTS_SUBAGENT_MARKER} +--- +name: code-reviewer +--- + +Review the current diff. +`; + + expect(hasDotagentsMarkdownSubagentMarker(content)).toBe(false); + expect(hasDotagentsTomlSubagentMarker(content)).toBe(true); + }); +}); diff --git a/packages/dotagents/src/agents/definitions/helpers.ts b/packages/dotagents/src/agents/definitions/helpers.ts index 970530a..50c054d 100644 --- a/packages/dotagents/src/agents/definitions/helpers.ts +++ b/packages/dotagents/src/agents/definitions/helpers.ts @@ -1,5 +1,15 @@ +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); +const MANAGED_MARKDOWN_SUBAGENT_RE = new RegExp( + `^\\uFEFF?---[ \\t]*\\r?\\n# ${DOTAGENTS_SUBAGENT_MARKER_RE}\\r?\\n`, +); +const MANAGED_TOML_SUBAGENT_RE = new RegExp( + `^\\uFEFF?# ${DOTAGENTS_SUBAGENT_MARKER_RE}\\r?\\n`, +); + export function envRecord( env: string[] | undefined, template: (key: string) => string, @@ -91,3 +101,104 @@ export function serializeClaudeHooks(hooks: HookDeclaration[]): Record, + instructions: string, +): string { + const lines = ["---", `# ${DOTAGENTS_SUBAGENT_MARKER}`]; + + for (const [key, value] of Object.entries(fields)) { + appendYamlField(lines, key, value); + } + + lines.push("---", "", instructions.trim(), ""); + return lines.join("\n"); +} + +/** Insert the dotagents marker into markdown subagent frontmatter. */ +export function markManagedMarkdownSubagent(content: string): string { + if (hasDotagentsMarkdownSubagentMarker(content)) {return content;} + return content.replace(/^(\uFEFF?---[ \t]*\r?\n)/, (opening) => `${opening}# ${DOTAGENTS_SUBAGENT_MARKER}\n`); +} + +/** Whether Markdown content has the dotagents marker in its generated frontmatter header. */ +export function hasDotagentsMarkdownSubagentMarker(content: string): boolean { + return MANAGED_MARKDOWN_SUBAGENT_RE.test(content); +} + +/** Whether TOML content has the dotagents marker in its generated header. */ +export function hasDotagentsTomlSubagentMarker(content: string): boolean { + return MANAGED_TOML_SUBAGENT_RE.test(content); +} + +export function serializeCodexSubagent( + fields: Record, +): string { + const doc: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined) { + doc[key] = value; + } + } + + return `# ${DOTAGENTS_SUBAGENT_MARKER}\n${tomlStringify(doc)}`; +} + +export function markManagedTomlSubagent(content: string): string { + if (hasDotagentsTomlSubagentMarker(content)) {return content;} + return `# ${DOTAGENTS_SUBAGENT_MARKER}\n${content}`; +} + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function appendYamlField( + lines: string[], + key: string, + value: unknown, + indent = 0, +): void { + if (value === undefined) {return;} + + const prefix = " ".repeat(indent); + if (isPlainObject(value)) { + const entries = Object.entries(value); + if (entries.length === 0) { + lines.push(`${prefix}${toYamlKey(key)}: {}`); + return; + } + lines.push(`${prefix}${toYamlKey(key)}:`); + for (const [childKey, childValue] of entries) { + appendYamlField(lines, childKey, childValue, indent + 2); + } + return; + } + + if (typeof value === "string" && value.includes("\n")) { + const chomp = value.endsWith("\n") ? "|+" : "|-"; + const body = value; + lines.push(`${prefix}${toYamlKey(key)}: ${chomp}`); + for (const line of body.split(/\r?\n/)) { + lines.push(`${prefix} ${line}`); + } + return; + } + + const serialized = JSON.stringify(value); + if (serialized !== undefined) { + lines.push(`${prefix}${toYamlKey(key)}: ${serialized}`); + } +} + +function toYamlKey(key: string): string { + if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) { + return key; + } + return JSON.stringify(key); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/dotagents/src/agents/definitions/opencode.ts b/packages/dotagents/src/agents/definitions/opencode.ts index 411caf2..7e8b231 100644 --- a/packages/dotagents/src/agents/definitions/opencode.ts +++ b/packages/dotagents/src/agents/definitions/opencode.ts @@ -1,11 +1,13 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; import { UnsupportedFeature } from "../errors.js"; -import { envRecord, httpServer } from "./helpers.js"; +import { envRecord, httpServer, markManagedMarkdownSubagent, serializeMarkdownSubagent } from "./helpers.js"; const opencode: AgentDefinition = { id: "opencode", displayName: "OpenCode", - configDir: ".claude", + configDir: ".opencode", // reads .agents/skills/ natively at both project and user scope skillsParentDir: undefined, userSkillsParentDirs: undefined, @@ -32,6 +34,27 @@ const opencode: AgentDefinition = { serializeHooks() { throw new UnsupportedFeature("opencode", "hooks"); }, + subagents: { + projectDir: ".opencode/agents", + userDir: join(homedir(), ".config", "opencode", "agents"), + fileExtension: ".md", + identity: "filename", + serialize(subagent) { + const native = subagent.native?.opencode; + return { + fileName: `${subagent.name}.md`, + content: native + ? markManagedMarkdownSubagent(native) + : serializeMarkdownSubagent( + { + description: subagent.description, + mode: "subagent", + }, + subagent.instructions, + ), + }; + }, + }, }; export default opencode; diff --git a/packages/dotagents/src/agents/index.ts b/packages/dotagents/src/agents/index.ts index dfa1f7b..72d00fc 100644 --- a/packages/dotagents/src/agents/index.ts +++ b/packages/dotagents/src/agents/index.ts @@ -3,6 +3,34 @@ export { writeMcpConfigs, verifyMcpConfigs, toMcpDeclarations, projectMcpResolve 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 { + writeSubagentConfigs, + pruneSubagentConfigs, + verifySubagentConfigs, + projectSubagentResolver, + userSubagentResolver, +} from "./subagent-writer.js"; +export { + resolveSubagent, + writeInstalledSubagents, + loadInstalledSubagents, + pruneInstalledSubagents, + lockEntryForSubagent, +} from "./subagent-store.js"; +export type { + SubagentTargetResolver, + SubagentResolvedTarget, + SubagentWriteWarning, + SubagentWriteResult, + SubagentVerifyIssue, +} from "./subagent-writer.js"; +export type { + SubagentResolveOptions, + ResolvedSubagent, + ResolvedSubagentType, + InstalledSubagentLoadIssue, +} from "./subagent-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"; @@ -14,4 +42,11 @@ export type { HookDeclaration, HookConfigSpec, HookSerializer, + SubagentDeclaration, + NativeSubagentConfig, + NativeSubagentContent, + NativeSubagentTarget, + SubagentConfigSpec, + SubagentIdentityStrategy, + SubagentSerializer, } from "./types.js"; diff --git a/packages/dotagents/src/agents/paths.test.ts b/packages/dotagents/src/agents/paths.test.ts index e2a03ab..de83457 100644 --- a/packages/dotagents/src/agents/paths.test.ts +++ b/packages/dotagents/src/agents/paths.test.ts @@ -78,3 +78,35 @@ describe("skill discovery paths", () => { expect(agent.userSkillsParentDirs).toBeUndefined(); }); }); + +describe("subagent paths", () => { + const home = homedir(); + + it("claude supports project and user subagents", () => { + const agent = getAgent("claude")!; + expect(agent.subagents?.projectDir).toBe(".claude/agents"); + expect(agent.subagents?.userDir).toBe(join(home, ".claude", "agents")); + }); + + it("cursor supports project and user subagents", () => { + const agent = getAgent("cursor")!; + expect(agent.subagents?.projectDir).toBe(".cursor/agents"); + expect(agent.subagents?.userDir).toBe(join(home, ".cursor", "agents")); + }); + + it("codex supports project and user subagents", () => { + const agent = getAgent("codex")!; + expect(agent.subagents?.projectDir).toBe(".codex/agents"); + expect(agent.subagents?.userDir).toBe(join(home, ".codex", "agents")); + }); + + it("opencode supports project and user subagents", () => { + const agent = getAgent("opencode")!; + expect(agent.subagents?.projectDir).toBe(".opencode/agents"); + expect(agent.subagents?.userDir).toBe(join(home, ".config", "opencode", "agents")); + }); + + it("vscode does not support custom subagents", () => { + expect(getAgent("vscode")!.subagents).toBeUndefined(); + }); +}); diff --git a/packages/dotagents/src/agents/subagent-identity.ts b/packages/dotagents/src/agents/subagent-identity.ts new file mode 100644 index 0000000..626c91b --- /dev/null +++ b/packages/dotagents/src/agents/subagent-identity.ts @@ -0,0 +1,83 @@ +import { basename, extname } from "node:path"; +import { parse as parseTOML } from "smol-toml"; +import { parseMarkdownFrontmatterContent } from "@sentry/dotagents-lib"; +import type { SubagentConfigSpec, SubagentIdentityStrategy } from "./types.js"; + +export function subagentIdentityFromMarkdownMeta( + strategy: SubagentIdentityStrategy, + fileName: string | undefined, + meta: Record, +): string | null { + const declaredName = typeof meta["name"] === "string" && meta["name"] ? meta["name"] : null; + + switch (strategy) { + case "frontmatter-name": + return declaredName; + case "frontmatter-name-or-filename": + return declaredName ?? (fileName ? subagentIdentityFromFileName(fileName) : null); + case "filename": + return fileName ? subagentIdentityFromFileName(fileName) : null; + case "toml-name": + return null; + } +} + +export function generatedSubagentIdentity( + spec: SubagentConfigSpec, + fileName: string, + content: string, + portableName: string, +): string | null { + switch (spec.identity) { + case "frontmatter-name": + case "frontmatter-name-or-filename": + return portableName; + case "filename": + return subagentIdentityFromFileName(fileName); + case "toml-name": + return subagentIdentityFromTomlContent(content); + } +} + +export function readSubagentFileIdentity( + spec: SubagentConfigSpec, + filePath: string, + fileName: string, + content: string, +): string | null { + switch (spec.identity) { + case "frontmatter-name": + case "frontmatter-name-or-filename": { + try { + const { meta } = parseMarkdownFrontmatterContent(content, filePath); + return subagentIdentityFromMarkdownMeta(spec.identity, fileName, meta); + } catch { + return null; + } + } + case "filename": + return subagentIdentityFromFileName(fileName); + case "toml-name": + return subagentIdentityFromTomlContent(content); + } +} + +export function subagentIdentityFromTomlContent(content: string): string | null { + try { + const parsed = parseTOML(content); + if (isPlainObject(parsed) && typeof parsed["name"] === "string" && parsed["name"]) { + return parsed["name"]; + } + } catch { + return null; + } + return null; +} + +function subagentIdentityFromFileName(fileName: string): string { + return basename(fileName, extname(fileName)); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/agents/subagent-store.test.ts new file mode 100644 index 0000000..1d8044c --- /dev/null +++ b/packages/dotagents/src/agents/subagent-store.test.ts @@ -0,0 +1,589 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + InstalledSubagentWriteError, + loadInstalledSubagents, + pruneInstalledSubagents, + resolveSubagent, + writeInstalledSubagents, +} from "./subagent-store.js"; +import { DOTAGENTS_SUBAGENT_MARKER, markManagedMarkdownSubagent } from "./definitions/helpers.js"; +import type { SubagentConfig } from "../config/schema.js"; + +const SUBAGENT_MD = (name: string) => `--- +name: ${name} +description: Review code for correctness. +--- + +Review the current diff. +`; + +function subagentConfig(overrides: Partial = {}): SubagentConfig { + return { + name: "code-reviewer", + source: "path:repo", + ...overrides, + }; +} + +describe("resolveSubagent", () => { + let tmpDir: string; + let repoDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-subagent-store-")); + repoDir = join(tmpDir, "repo"); + await mkdir(repoDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true }); + }); + + it("discovers subagents by frontmatter name", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "reviewer.md"), SUBAGENT_MD("code-reviewer")); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.name).toBe("code-reviewer"); + expect(resolved.subagent.description).toBe("Review code for correctness."); + }); + + it("does not implicitly discover root markdown files", async () => { + await writeFile(join(repoDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await expect( + resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow('Subagent "code-reviewer" not found in path:repo.'); + }); + + it("imports root markdown only when path is explicit", async () => { + await writeFile(join(repoDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + const resolved = await resolveSubagent(subagentConfig({ path: "code-reviewer.md" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.resolvedPath).toBeUndefined(); + expect(resolved.subagent.name).toBe("code-reviewer"); + }); + + it("does not classify TOML under another native runtime directory as Codex", async () => { + await mkdir(join(repoDir, ".claude", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".claude", "agents", "code-reviewer.toml"), + [ + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff."', + "", + ].join("\n"), + ); + + await expect( + resolveSubagent(subagentConfig({ path: ".claude/agents/code-reviewer.toml" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow('Unsupported claude subagent file extension ".toml"'); + }); + + it("imports Codex TOML subagents as portable declarations with native content", async () => { + await mkdir(join(repoDir, ".codex", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".codex", "agents", "code-reviewer.toml"), + [ + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.name).toBe("code-reviewer"); + expect(resolved.subagent.instructions).toBe("Review the current diff."); + expect(resolved.subagent.native?.codex).toContain('sandbox_mode = "read-only"'); + expect(resolved.subagent.native?.codex).toContain('name = "code_reviewer"'); + }); + + it("discovers Codex TOML subagents by native name when it is portable", async () => { + await mkdir(join(repoDir, ".codex", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".codex", "agents", "reviewer.toml"), + [ + 'name = "code-reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff."', + "", + ].join("\n"), + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.name).toBe("code-reviewer"); + expect(resolved.subagent.native?.codex).toContain('name = "code-reviewer"'); + }); + + it("imports OpenCode markdown subagents using the filename as the name", async () => { + await mkdir(join(repoDir, ".opencode", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".opencode", "agents", "code-reviewer.md"), + `--- +name: other-reviewer +description: Review code for correctness. +mode: subagent +permission: + edit: deny +--- + +Review the current diff. +`, + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.name).toBe("code-reviewer"); + expect(resolved.subagent.native?.opencode).toContain("mode: subagent"); + expect(resolved.subagent.native?.opencode).toContain("permission:"); + expect(resolved.subagent.native?.opencode).toContain("name: other-reviewer"); + }); + + it("imports Cursor markdown subagents using the filename when name is omitted", async () => { + await mkdir(join(repoDir, ".cursor", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".cursor", "agents", "code-reviewer.md"), + `--- +description: Review code for correctness. +model: inherit +--- + +Review the current diff. +`, + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.name).toBe("code-reviewer"); + expect(resolved.subagent.native?.cursor).toContain("model: inherit"); + }); + + it("merges native subagent artifacts for the same portable declaration", async () => { + await mkdir(join(repoDir, ".claude", "agents"), { recursive: true }); + await mkdir(join(repoDir, ".codex", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".claude", "agents", "code-reviewer.md"), + `--- +name: code-reviewer +description: Review code for correctness. +tools: Read, Grep +--- + +Review the current diff for Claude. +`, + ); + await writeFile( + join(repoDir, ".codex", "agents", "code-reviewer.toml"), + [ + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff for Codex."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.instructions).toBe("Review the current diff for Claude."); + expect(resolved.subagent.native?.claude).toContain("tools: Read, Grep"); + expect(resolved.subagent.native?.codex).toContain('sandbox_mode = "read-only"'); + }); + + it("merges a portable declaration with matching native artifacts", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await mkdir(join(repoDir, ".codex", "agents"), { recursive: true }); + await writeFile( + join(repoDir, "agents", "code-reviewer.md"), + SUBAGENT_MD("code-reviewer"), + ); + await writeFile( + join(repoDir, ".codex", "agents", "code-reviewer.toml"), + [ + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff for Codex."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.instructions).toBe("Review the current diff."); + expect(resolved.subagent.native?.codex).toContain('sandbox_mode = "read-only"'); + }); + + it("rejects explicit paths whose frontmatter name differs from agents.toml", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "reviewer.md"), SUBAGENT_MD("other-reviewer")); + + await expect( + resolveSubagent(subagentConfig({ path: "agents/reviewer.md" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow( + 'Subagent "agents/reviewer.md" declares name "other-reviewer", but agents.toml requested "code-reviewer".', + ); + }); + + it("rejects filename matches whose frontmatter name differs from agents.toml", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "code-reviewer.md"), SUBAGENT_MD("other-reviewer")); + + await expect( + resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow( + 'Subagent "agents/code-reviewer.md" declares name "other-reviewer", but agents.toml requested "code-reviewer".', + ); + }); + + it("rejects filename matches with invalid frontmatter", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "code-reviewer.md"), "# No frontmatter\n"); + + await expect( + resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow("No YAML frontmatter"); + }); + + it("rejects ambiguous metadata matches in the same scan directory", async () => { + await mkdir(join(repoDir, "agents", "a"), { recursive: true }); + await mkdir(join(repoDir, "agents", "b"), { recursive: true }); + await writeFile(join(repoDir, "agents", "a", "reviewer.md"), SUBAGENT_MD("code-reviewer")); + await writeFile(join(repoDir, "agents", "b", "reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await expect( + resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow( + 'Ambiguous metadata matches for subagent "code-reviewer" in agents', + ); + }); + + it("rejects ambiguous portable matches across scan directories", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await mkdir(join(repoDir, ".agents", "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + await writeFile(join(repoDir, ".agents", "agents", "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await expect( + resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow( + 'Ambiguous portable matches for subagent "code-reviewer": agents/code-reviewer.md, .agents/agents/code-reviewer.md', + ); + }); + + it("rejects invalid frontmatter names", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "code-reviewer.md"), SUBAGENT_MD("CodeReviewer")); + + await expect( + resolveSubagent(subagentConfig({ path: "agents/code-reviewer.md" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow( + 'Invalid subagent name "CodeReviewer"', + ); + }); + + it("rejects explicit paths outside the source directory", async () => { + await expect( + resolveSubagent(subagentConfig({ path: "../outside.md" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow("Subagent path resolves outside source: ../outside.md"); + }); + + it("rejects HTTPS well-known sources", async () => { + await expect( + resolveSubagent(subagentConfig({ source: "https://example.com" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow("HTTPS well-known sources are not supported for subagents"); + }); + + it("reports installed subagents whose frontmatter name does not match config", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + await writeFile( + join(installedDir, "code-reviewer.md"), + markManagedMarkdownSubagent(SUBAGENT_MD("other-reviewer")), + ); + + const result = await loadInstalledSubagents(installedDir, [subagentConfig()]); + + expect(result.subagents).toEqual([]); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]!.issue).toContain( + 'declares name "other-reviewer", but agents.toml requested "code-reviewer"', + ); + }); + + it("reports unmanaged installed subagents without loading them", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + await writeFile(join(installedDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + const result = await loadInstalledSubagents(installedDir, [subagentConfig()]); + + expect(result.subagents).toEqual([]); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]!.issue).toContain("not managed by dotagents"); + }); +}); + +describe("writeInstalledSubagents", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-subagent-store-write-")); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true }); + }); + + it("does not create an empty install directory when there are no subagents", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + + const written = await writeInstalledSubagents(installedDir, []); + + expect(written).toEqual([]); + expect(existsSync(installedDir)).toBe(false); + }); + + it("prunes stale managed files when there are no subagents", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + }]); + + await pruneInstalledSubagents(installedDir, []); + + expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(false); + }); + + it("does not prune stale managed files while writing", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + }]); + + await writeInstalledSubagents(installedDir, []); + + expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(true); + }); + + it("does not prune files that mention the marker outside the managed header", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const filePath = join(installedDir, "notes.md"); + await writeFile( + filePath, + `--- +name: notes +--- + +The phrase ${DOTAGENTS_SUBAGENT_MARKER} appears in instructions. +`, + "utf-8", + ); + + await pruneInstalledSubagents(installedDir, []); + + expect(existsSync(filePath)).toBe(true); + }); + + it("does not prune markdown files that start with a TOML-style marker", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const filePath = join(installedDir, "notes.md"); + await writeFile( + filePath, + `# ${DOTAGENTS_SUBAGENT_MARKER} +--- +name: notes +--- + +Review the current diff. +`, + "utf-8", + ); + + await pruneInstalledSubagents(installedDir, []); + + expect(existsSync(filePath)).toBe(true); + }); + + it("rejects unmanaged installed files without overwriting them", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const filePath = join(installedDir, "code-reviewer.md"); + await writeFile(filePath, "hand-written subagent\n", "utf-8"); + + await expect(writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + }])).rejects.toThrow(InstalledSubagentWriteError); + + expect(await readFile(filePath, "utf-8")).toBe("hand-written subagent\n"); + }); + + it("preflights unmanaged conflicts before updating any installed files", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await writeInstalledSubagents(installedDir, [{ + name: "alpha", + description: "Alpha reviewer.", + instructions: "Original alpha instructions.", + }]); + const alphaPath = join(installedDir, "alpha.md"); + const originalAlpha = await readFile(alphaPath, "utf-8"); + const betaPath = join(installedDir, "beta.md"); + await writeFile(betaPath, "hand-written beta\n", "utf-8"); + + await expect(writeInstalledSubagents(installedDir, [ + { + name: "alpha", + description: "Alpha reviewer.", + instructions: "Updated alpha instructions.", + }, + { + name: "beta", + description: "Beta reviewer.", + instructions: "Beta instructions.", + }, + ])).rejects.toThrow(InstalledSubagentWriteError); + + expect(await readFile(alphaPath, "utf-8")).toBe(originalAlpha); + expect(await readFile(betaPath, "utf-8")).toBe("hand-written beta\n"); + }); + + it("rejects files that mention the marker outside the managed header", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const filePath = join(installedDir, "code-reviewer.md"); + const content = `--- +name: code-reviewer +--- + +The phrase ${DOTAGENTS_SUBAGENT_MARKER} appears in instructions. +`; + await writeFile(filePath, content, "utf-8"); + + await expect(writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + }])).rejects.toThrow(InstalledSubagentWriteError); + + expect(await readFile(filePath, "utf-8")).toBe(content); + }); + + it("rejects markdown files that start with a TOML-style marker", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const filePath = join(installedDir, "code-reviewer.md"); + const content = `# ${DOTAGENTS_SUBAGENT_MARKER} +--- +name: code-reviewer +--- + +Review the current diff. +`; + await writeFile(filePath, content, "utf-8"); + + await expect(writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + }])).rejects.toThrow(InstalledSubagentWriteError); + + expect(await readFile(filePath, "utf-8")).toBe(content); + }); + + it("roundtrips native overlays through the installed portable markdown", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + native: { + codex: [ + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + }, + }]); + + const result = await loadInstalledSubagents(installedDir, [subagentConfig()]); + + expect(result.issues).toEqual([]); + expect(result.subagents[0]!.native?.codex).toContain('sandbox_mode = "read-only"'); + expect(result.subagents[0]!.native?.codex).toContain('name = "code_reviewer"'); + }); +}); diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts new file mode 100644 index 0000000..87e7ab1 --- /dev/null +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -0,0 +1,639 @@ +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { parse as parseTOML } from "smol-toml"; +import { + applyDefaultRepositorySource, + ensureCached, + isSourceExcluded, + loadMarkdownFrontmatter, + parseSource, + resolveLocalSource, + sanitizeCacheKey, + validateTrustedSource, + 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 { SUBAGENT_NAME_PATTERN, type SubagentConfig } from "../config/schema.js"; +import type { LockedSubagent } from "../lockfile/schema.js"; +import type { + NativeSubagentContent, + NativeSubagentTarget, + SubagentDeclaration, + SubagentIdentityStrategy, +} from "./types.js"; + +const DOTAGENTS_NATIVE_FIELD = "dotagents_native"; +const NATIVE_SUBAGENT_TARGETS = ["claude", "cursor", "codex", "opencode"] satisfies NativeSubagentTarget[]; +let tempFileCounter = 0; + +interface SubagentScanDir { + dir: string; + flat?: boolean; + nativeTarget?: NativeSubagentTarget; + extensions: readonly string[]; +} + +interface DiscoveredSubagent { + path: string; + subagent: SubagentDeclaration; + nativeTarget?: NativeSubagentTarget; +} + +const SUBAGENT_SCAN_DIRS: readonly SubagentScanDir[] = [ + { dir: "agents", extensions: [".md"] }, + { dir: ".agents/agents", extensions: [".md"] }, + { dir: ".claude/agents", nativeTarget: "claude", extensions: [".md"] }, + { dir: ".cursor/agents", nativeTarget: "cursor", extensions: [".md"] }, + { dir: ".codex/agents", nativeTarget: "codex", extensions: [".toml"] }, + { dir: ".opencode/agents", nativeTarget: "opencode", extensions: [".md"] }, +] as const; + +export interface SubagentResolveOptions { + stateDir: string; + projectRoot: string; + defaultRepositorySource?: RepositorySource; + minimumReleaseAge?: number; + minimumReleaseAgeExclude?: string[]; + trust?: TrustPolicy; +} + +export type ResolvedSubagentType = "git" | "local"; + +export interface ResolvedSubagent { + type: ResolvedSubagentType; + source: string; + resolvedUrl?: string; + resolvedPath?: string; + resolvedRef?: string; + commit?: string; + subagent: SubagentDeclaration; +} + +export interface InstalledSubagentLoadIssue { + name: string; + issue: string; +} + +export class InstalledSubagentWriteError extends Error { + constructor(message: string) { + super(message); + this.name = "InstalledSubagentWriteError"; + } +} + +export async function resolveSubagent( + config: SubagentConfig, + opts: SubagentResolveOptions, +): 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 subagent = await loadSubagentFromSource(sourceDir, config); + return { type: "local", source: config.source, subagent }; + } + + if (parsed.type === "well-known") { + throw new Error( + `HTTPS well-known sources are not supported for subagents. 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 discoverSubagent(cached.repoDir, config); + if (!discovered) { + throw new Error(`Subagent "${config.name}" not found in ${config.source}.`); + } + + return { + type: "git", + source: config.source, + resolvedUrl: cloneUrl, + resolvedPath: discovered.path, + resolvedRef: ref, + commit: cached.commit, + subagent: { ...discovered.subagent, targets: config.targets }, + }; +} + +export async function writeInstalledSubagents( + subagentsDir: string, + subagents: SubagentDeclaration[], +): Promise { + if (subagents.length === 0 && !existsSync(subagentsDir)) { + return []; + } + + await mkdir(subagentsDir, { recursive: true }); + const plannedWrites: Array<{ + filePath: string; + content: string; + previous?: string; + }> = []; + + for (const subagent of subagents) { + const fileName = `${subagent.name}.md`; + const filePath = join(subagentsDir, fileName); + const content = serializeInstalledSubagent(subagent); + const previous = await readManagedInstalledSubagentFile(filePath); + if (previous !== content) { + plannedWrites.push({ filePath, content, previous }); + } + } + + const written: string[] = []; + let attempted: (typeof plannedWrites)[number] | undefined; + try { + for (const planned of plannedWrites) { + attempted = planned; + await replaceFileAtomic(planned.filePath, planned.content); + written.push(planned.filePath); + attempted = undefined; + } + } catch (err) { + const rollbackWrites = attempted + ? [...plannedWrites.slice(0, written.length), attempted] + : plannedWrites.slice(0, written.length); + for (const planned of rollbackWrites.toReversed()) { + if (planned.previous === undefined) { + await rm(planned.filePath, { force: true }); + } else { + await replaceFileAtomic(planned.filePath, planned.previous); + } + } + throw err; + } + + return written; +} + +export async function loadInstalledSubagents( + subagentsDir: string, + configs: SubagentConfig[], +): Promise<{ subagents: SubagentDeclaration[]; issues: InstalledSubagentLoadIssue[] }> { + const subagents: SubagentDeclaration[] = []; + const issues: InstalledSubagentLoadIssue[] = []; + + for (const config of configs) { + const filePath = join(subagentsDir, `${config.name}.md`); + if (!existsSync(filePath)) { + issues.push({ + name: config.name, + issue: `Subagent "${config.name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, + }); + continue; + } + + try { + await assertManagedInstalledSubagentFile(filePath); + const subagent = await loadSubagentFile(filePath); + assertSubagentNameMatches(subagent.name, config.name, `${config.name}.md`); + subagents.push({ ...subagent, targets: config.targets }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + issues.push({ + name: config.name, + issue: `Failed to load installed subagent "${config.name}": ${message}`, + }); + } + } + + return { subagents, issues }; +} + +export async function pruneInstalledSubagents( + subagentsDir: string, + configs: SubagentConfig[], +): Promise { + const desired = new Set(configs.map((config) => `${config.name}.md`)); + return pruneManagedMarkdownFiles(subagentsDir, desired); +} + +export function lockEntryForSubagent(resolved: ResolvedSubagent): LockedSubagent { + return { + source: resolved.source, + ...(resolved.resolvedUrl ? { resolved_url: resolved.resolvedUrl } : {}), + ...(resolved.resolvedPath ? { resolved_path: resolved.resolvedPath } : {}), + ...(resolved.resolvedRef ? { resolved_ref: resolved.resolvedRef } : {}), + ...(resolved.commit ? { resolved_commit: resolved.commit } : {}), + }; +} + +async function loadSubagentFromSource( + sourceDir: string, + config: SubagentConfig, +): Promise { + const discovered = await discoverSubagent(sourceDir, config); + if (!discovered) { + throw new Error(`Subagent "${config.name}" not found in ${config.source}.`); + } + return { ...discovered.subagent, targets: config.targets }; +} + +async function discoverSubagent( + sourceDir: string, + config: SubagentConfig, +): Promise<{ path: string; subagent: SubagentDeclaration } | null> { + if (config.path) { + const sourceRoot = resolve(sourceDir); + const filePath = resolve(sourceRoot, config.path); + const relPath = relative(sourceRoot, filePath); + if (relPath.startsWith("..") || isAbsolute(relPath)) { + throw new Error(`Subagent path resolves outside source: ${config.path}`); + } + const nativeTarget = inferNativeTarget(config.path); + const subagent = await loadSubagentFile(filePath, { + expectedName: nativeTarget ? config.name : undefined, + nativeTarget, + nameFromFile: basename(filePath, extname(filePath)), + }); + if (nativeTarget !== "codex") { + assertSubagentNameMatches(subagent.name, config.name, config.path); + } + return { path: config.path, subagent }; + } + + const matches: DiscoveredSubagent[] = []; + for (const scanDir of SUBAGENT_SCAN_DIRS) { + const absDir = join(sourceDir, scanDir.dir); + const candidates = await listSubagentFiles(absDir, { + flat: scanDir.flat ?? false, + extensions: scanDir.extensions, + }); + const fileNameMatches: DiscoveredSubagent[] = []; + const metadataMatches: DiscoveredSubagent[] = []; + + for (const filePath of candidates) { + const nameFromFile = basename(filePath, extname(filePath)); + let subagent: SubagentDeclaration; + try { + subagent = await loadSubagentFile(filePath, { + nativeTarget: scanDir.nativeTarget, + nameFromFile, + }); + } catch (err) { + if (nameFromFile === config.name) {throw err;} + // Ignore Markdown/TOML files that are not subagent declarations. + continue; + } + + const relPath = relativePath(sourceDir, filePath); + if (nameFromFile === config.name && subagent.name !== config.name) { + assertSubagentNameMatches(subagent.name, config.name, relPath); + } + if (subagent.name !== config.name) {continue;} + const match = { + path: relPath, + subagent, + ...(scanDir.nativeTarget ? { nativeTarget: scanDir.nativeTarget } : {}), + }; + if (nameFromFile === config.name) { + fileNameMatches.push(match); + } else { + metadataMatches.push(match); + } + } + + const selected = selectDiscoveredSubagent(config.name, scanDir, fileNameMatches, metadataMatches); + if (selected) {matches.push(selected);} + } + + if (matches.length === 0) {return null;} + return mergeDiscoveredSubagents(config.name, matches); +} + +async function loadSubagentFile( + filePath: string, + opts: { + expectedName?: string; + nativeTarget?: NativeSubagentTarget; + nameFromFile?: string; + } = {}, +): Promise { + if (extname(filePath) === ".toml") { + if (opts.nativeTarget && opts.nativeTarget !== "codex") { + throw new Error(`Unsupported ${opts.nativeTarget} subagent file extension ".toml": ${filePath}`); + } + return loadCodexSubagentFile(filePath, opts); + } + + const { meta, body, raw } = await loadMarkdownFrontmatter(filePath); + const name = subagentIdentityFromMarkdownMeta( + identityStrategyForNativeTarget(opts.nativeTarget), + opts.nameFromFile, + meta, + ); + if (!name) { + throw new Error(`Missing 'name' in subagent frontmatter: ${filePath}`); + } + if (!SUBAGENT_NAME_PATTERN.test(name)) { + throw new Error( + `Invalid subagent name "${name}" in ${filePath}; expected lowercase letters, numbers, and hyphens`, + ); + } + if (typeof meta["description"] !== "string" || !meta["description"]) { + throw new Error(`Missing 'description' in subagent frontmatter: ${filePath}`); + } + if (!body) { + throw new Error(`Missing subagent instructions body: ${filePath}`); + } + + const native = readNativeOverlays(meta); + if (opts.nativeTarget) { + native[opts.nativeTarget] = raw; + } + + return { + name, + description: meta["description"], + instructions: body, + ...(Object.keys(native).length > 0 ? { native } : {}), + }; +} + +async function loadCodexSubagentFile( + filePath: string, + opts: { + expectedName?: string; + nameFromFile?: string; + } = {}, +): Promise { + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch { + throw new Error(`Codex subagent TOML not found: ${filePath}`); + } + + let parsed: unknown; + try { + parsed = parseTOML(content); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid Codex subagent TOML in ${filePath}: ${message}`, { cause: err }); + } + + if (!isPlainObject(parsed)) { + throw new Error(`Codex subagent TOML must be an object: ${filePath}`); + } + + const name = parsed["name"]; + if (typeof name !== "string" || !name) { + throw new Error(`Missing 'name' in Codex subagent TOML: ${filePath}`); + } + + const portableName = opts.expectedName ?? ( + SUBAGENT_NAME_PATTERN.test(name) ? name : opts.nameFromFile ?? name + ); + if (!SUBAGENT_NAME_PATTERN.test(portableName)) { + throw new Error( + `Invalid subagent name "${portableName}" in ${filePath}; expected lowercase letters, numbers, and hyphens`, + ); + } + + const description = parsed["description"]; + if (typeof description !== "string" || !description) { + throw new Error(`Missing 'description' in Codex subagent TOML: ${filePath}`); + } + + const instructions = parsed["developer_instructions"]; + if (typeof instructions !== "string" || !instructions.trim()) { + throw new Error(`Missing 'developer_instructions' in Codex subagent TOML: ${filePath}`); + } + + return { + name: portableName, + description, + instructions, + native: { + codex: content, + }, + }; +} + +function mergeDiscoveredSubagents( + name: string, + matches: DiscoveredSubagent[], +): DiscoveredSubagent { + const portableMatches = matches.filter((match) => !match.nativeTarget); + if (portableMatches.length > 1) { + throw new Error( + `Ambiguous portable matches for subagent "${name}": ${portableMatches.map((m) => m.path).join(", ")}`, + ); + } + + const base = portableMatches[0] ?? matches[0]!; + const native: NativeSubagentContent = { ...base.subagent.native }; + + for (const match of matches) { + for (const [target, content] of Object.entries(match.subagent.native ?? {})) { + const nativeTarget = target as NativeSubagentTarget; + native[nativeTarget] ??= content; + } + } + + return { + path: base.path, + subagent: { + ...base.subagent, + ...(Object.keys(native).length > 0 ? { native } : {}), + }, + }; +} + +function identityStrategyForNativeTarget( + target: NativeSubagentTarget | undefined, +): SubagentIdentityStrategy { + if (!target) {return "frontmatter-name";} + const identity = getAgent(target)?.subagents?.identity; + if (!identity) { + throw new Error(`Agent "${target}" does not define subagent identity rules.`); + } + return identity; +} + +function assertSubagentNameMatches( + actualName: string, + expectedName: string, + sourcePath: string, +): void { + if (actualName === expectedName) {return;} + throw new Error( + `Subagent "${sourcePath}" declares name "${actualName}", but agents.toml requested "${expectedName}".`, + ); +} + +async function listSubagentFiles( + dirPath: string, + opts: { flat: boolean; extensions: readonly string[] }, +): Promise { + if (!existsSync(dirPath)) {return [];} + let entries; + try { + entries = await readdir(dirPath, { withFileTypes: true }); + } catch { + return []; + } + + const files: string[] = []; + for (const entry of entries.toSorted((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + )) { + const absPath = join(dirPath, entry.name); + if (entry.isFile() && opts.extensions.includes(extname(entry.name))) { + files.push(absPath); + } else if (!opts.flat && entry.isDirectory()) { + files.push(...await listSubagentFiles(absPath, opts)); + } + } + return files; +} + +function selectDiscoveredSubagent( + name: string, + scanDir: SubagentScanDir, + fileNameMatches: DiscoveredSubagent[], + metadataMatches: DiscoveredSubagent[], +): DiscoveredSubagent | null { + assertSingleDiscoveryMatch(name, scanDir, "filename", fileNameMatches); + if (fileNameMatches.length > 0) { + return fileNameMatches[0]!; + } + + assertSingleDiscoveryMatch(name, scanDir, "metadata", metadataMatches); + return metadataMatches[0] ?? null; +} + +function assertSingleDiscoveryMatch( + name: string, + scanDir: SubagentScanDir, + kind: string, + matches: DiscoveredSubagent[], +): void { + if (matches.length <= 1) {return;} + throw new Error( + `Ambiguous ${kind} matches for subagent "${name}" in ${scanDir.dir}: ${matches.map((m) => m.path).join(", ")}`, + ); +} + +function serializeInstalledSubagent(subagent: SubagentDeclaration): string { + return serializeMarkdownSubagent( + { + name: subagent.name, + description: subagent.description, + ...(subagent.native ? { [DOTAGENTS_NATIVE_FIELD]: subagent.native } : {}), + }, + subagent.instructions, + ); +} + +async function assertManagedInstalledSubagentFile(filePath: string): Promise { + await readManagedInstalledSubagentFile(filePath); +} + +async function readManagedInstalledSubagentFile(filePath: string): Promise { + try { + const existing = await readFile(filePath, "utf-8"); + if (!hasDotagentsMarkdownSubagentMarker(existing)) { + throw new InstalledSubagentWriteError(`Subagent file exists and is not managed by dotagents: ${filePath}`); + } + return existing; + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + return undefined; + } +} + +async function replaceFileAtomic(filePath: string, content: string): Promise { + const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${tempFileCounter++}`; + try { + await writeFile(tempPath, content, "utf-8"); + await rename(tempPath, filePath); + } catch (err) { + await rm(tempPath, { force: true }); + throw err; + } +} + +async function pruneManagedMarkdownFiles(dirPath: string, desired: Set): Promise { + if (!existsSync(dirPath)) {return [];} + + const pruned: string[] = []; + const entries = await readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) {continue;} + if (!entry.name.endsWith(".md")) {continue;} + if (desired.has(entry.name)) {continue;} + + const filePath = join(dirPath, entry.name); + const existing = await readFile(filePath, "utf-8"); + if (hasDotagentsMarkdownSubagentMarker(existing)) { + await rm(filePath); + pruned.push(filePath); + } + } + + return pruned; +} + +function relativePath(root: string, filePath: string): string { + return relative(resolve(root), filePath).replaceAll("\\", "/"); +} + +function inferNativeTarget(filePath: string): NativeSubagentTarget | undefined { + const normalized = filePath.replaceAll("\\", "/"); + if (normalized.includes(".claude/agents/")) {return "claude";} + if (normalized.includes(".cursor/agents/")) {return "cursor";} + if (normalized.includes(".codex/agents/")) {return "codex";} + if (normalized.includes(".opencode/agents/")) {return "opencode";} + if (normalized.endsWith(".toml")) {return "codex";} + return undefined; +} + +function readNativeOverlays(meta: Record): NativeSubagentContent { + const raw = meta[DOTAGENTS_NATIVE_FIELD]; + if (!isPlainObject(raw)) {return {};} + + const native: NativeSubagentContent = {}; + for (const target of NATIVE_SUBAGENT_TARGETS) { + const config = raw[target]; + if (typeof config === "string") { + native[target] = config; + } else if (isPlainObject(config) && typeof config["content"] === "string") { + native[target] = config["content"]; + } + } + return native; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNotFoundError(err: unknown): boolean { + return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; +} diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/agents/subagent-writer.test.ts new file mode 100644 index 0000000..5e9bb71 --- /dev/null +++ b/packages/dotagents/src/agents/subagent-writer.test.ts @@ -0,0 +1,537 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { parse as parseTOML } from "smol-toml"; +import { + projectSubagentResolver, + pruneSubagentConfigs, + verifySubagentConfigs, + writeSubagentConfigs, +} from "./subagent-writer.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; +import type { SubagentDeclaration } from "./types.js"; + +const SUBAGENT: SubagentDeclaration = { + name: "code-reviewer", + description: "Review code for correctness and missing tests.", + instructions: "Review the current diff and return findings.", +}; + +describe("writeSubagentConfigs", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "dotagents-subagents-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true }); + }); + + it("writes Claude markdown subagents", async () => { + const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toEqual([]); + expect(result.written).toBe(1); + + const content = await readFile(join(dir, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(content).toContain(DOTAGENTS_SUBAGENT_MARKER); + expect(content).toContain('name: "code-reviewer"'); + expect(content).toContain('description: "Review code for correctness and missing tests."'); + expect(content).toContain("Review the current diff and return findings."); + }); + + it("writes Cursor markdown subagents", async () => { + await writeSubagentConfigs(["cursor"], [SUBAGENT], projectSubagentResolver(dir)); + + const content = await readFile(join(dir, ".cursor", "agents", "code-reviewer.md"), "utf-8"); + expect(content).toContain('name: "code-reviewer"'); + expect(content).toContain('description: "Review code for correctness and missing tests."'); + expect(content).toContain("Review the current diff and return findings."); + }); + + it("writes Codex TOML subagents", async () => { + await writeSubagentConfigs(["codex"], [SUBAGENT], projectSubagentResolver(dir)); + + const raw = await readFile(join(dir, ".codex", "agents", "code-reviewer.toml"), "utf-8"); + expect(raw).toContain(DOTAGENTS_SUBAGENT_MARKER); + + const content = parseTOML(raw) as Record; + expect(content["name"]).toBe("code-reviewer"); + expect(content["description"]).toBe("Review code for correctness and missing tests."); + expect(content["developer_instructions"]).toBe("Review the current diff and return findings."); + }); + + it("preserves native Codex fields only for the Codex target", async () => { + await writeSubagentConfigs( + ["codex", "claude"], + [{ + ...SUBAGENT, + instructions: "Portable instructions.", + native: { + codex: [ + "# upstream comment", + 'name = "code_reviewer"', + 'description = "Review code for correctness and missing tests."', + 'developer_instructions = "Native Codex instructions."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + }, + }], + projectSubagentResolver(dir), + ); + + const codexRaw = await readFile(join(dir, ".codex", "agents", "code-reviewer.toml"), "utf-8"); + const codex = parseTOML(codexRaw) as Record; + expect(codex["developer_instructions"]).toBe("Native Codex instructions."); + expect(codex["sandbox_mode"]).toBe("read-only"); + expect(codexRaw).toContain("# upstream comment"); + + const claude = await readFile(join(dir, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(claude).toContain("Portable instructions."); + expect(claude).not.toContain("sandbox_mode"); + }); + + it("writes OpenCode markdown subagents", async () => { + await writeSubagentConfigs(["opencode"], [SUBAGENT], projectSubagentResolver(dir)); + + const content = await readFile(join(dir, ".opencode", "agents", "code-reviewer.md"), "utf-8"); + expect(content).toContain(DOTAGENTS_SUBAGENT_MARKER); + expect(content).toContain('description: "Review code for correctness and missing tests."'); + expect(content).toContain('mode: "subagent"'); + expect(content).toContain("Review the current diff and return findings."); + }); + + it("preserves native markdown fields only for the matching markdown target", async () => { + await writeSubagentConfigs( + ["claude", "cursor"], + [{ + ...SUBAGENT, + native: { + claude: [ + "---", + "name: code-reviewer", + "description: Review code for correctness and missing tests.", + "tools: Read, Grep", + "---", + "", + "Native Claude instructions.", + "", + ].join("\n"), + }, + }], + projectSubagentResolver(dir), + ); + + const claude = await readFile(join(dir, ".claude", "agents", "code-reviewer.md"), "utf-8"); + const cursor = await readFile(join(dir, ".cursor", "agents", "code-reviewer.md"), "utf-8"); + expect(claude).toContain("tools: Read, Grep"); + expect(claude).toContain("Native Claude instructions."); + expect(cursor).not.toContain("tools"); + expect(cursor).toContain("Review the current diff and return findings."); + }); + + it("marks native markdown that has a BOM and spaced frontmatter opener", async () => { + const result = await writeSubagentConfigs( + ["claude"], + [{ + ...SUBAGENT, + native: { + claude: "\uFEFF--- \nname: code-reviewer\ndescription: Review code.\n---\n\nNative instructions.\n", + }, + }], + projectSubagentResolver(dir), + ); + + expect(result.warnings).toEqual([]); + expect(result.written).toBe(1); + + const content = await readFile(join(dir, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(content).toContain(DOTAGENTS_SUBAGENT_MARKER); + expect(content).toContain("Native instructions."); + }); + + it("respects explicit targets", async () => { + await writeSubagentConfigs( + ["claude", "codex"], + [{ ...SUBAGENT, targets: ["codex"] }], + projectSubagentResolver(dir), + ); + + expect(existsSync(join(dir, ".codex", "agents", "code-reviewer.toml"))).toBe(true); + expect(existsSync(join(dir, ".claude", "agents", "code-reviewer.md"))).toBe(false); + }); + + it("treats empty targets as all configured agents", async () => { + await writeSubagentConfigs( + ["claude", "codex"], + [{ ...SUBAGENT, targets: [] }], + projectSubagentResolver(dir), + ); + + expect(existsSync(join(dir, ".claude", "agents", "code-reviewer.md"))).toBe(true); + expect(existsSync(join(dir, ".codex", "agents", "code-reviewer.toml"))).toBe(true); + }); + + it("warns when a target is not configured", async () => { + const result = await writeSubagentConfigs( + ["claude"], + [{ ...SUBAGENT, targets: ["codex"] }], + projectSubagentResolver(dir), + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("not listed in agents"); + }); + + it("warns for unsupported agents", async () => { + const result = await writeSubagentConfigs(["vscode"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("does not support custom subagents"); + }); + + it("does not overwrite unmanaged files", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(targetDir, "code-reviewer.md"), "hand-written", "utf-8"); + + const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toHaveLength(1); + expect(result.written).toBe(0); + expect(await readFile(join(targetDir, "code-reviewer.md"), "utf-8")).toBe("hand-written"); + }); + + it("does not create duplicate unmanaged Claude identities", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + await writeFile( + join(targetDir, "reviewer.md"), + `--- +name: code-reviewer +description: Hand-written reviewer. +--- + +Hand-written instructions. +`, + "utf-8", + ); + + const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("identity conflicts with unmanaged file"); + expect(result.written).toBe(0); + expect(existsSync(join(targetDir, "code-reviewer.md"))).toBe(false); + }); + + it("preserves desired managed files when an unmanaged identity conflict exists", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + const managedPath = join(targetDir, "code-reviewer.md"); + const unmanagedPath = join(targetDir, "reviewer.md"); + await writeFile( + managedPath, + `--- +# ${DOTAGENTS_SUBAGENT_MARKER} +name: "code-reviewer" +description: "Managed reviewer." +--- + +Managed instructions. +`, + "utf-8", + ); + await writeFile( + unmanagedPath, + `--- +name: code-reviewer +description: Hand-written reviewer. +--- + +Hand-written instructions. +`, + "utf-8", + ); + + const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + const pruned = await pruneSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("identity conflicts with unmanaged file"); + expect(pruned).toEqual([]); + expect(existsSync(managedPath)).toBe(true); + expect(existsSync(unmanagedPath)).toBe(true); + }); + + it("does not create duplicate unmanaged Codex identities", async () => { + const targetDir = join(dir, ".codex", "agents"); + await mkdir(targetDir, { recursive: true }); + await writeFile( + join(targetDir, "reviewer.toml"), + [ + 'name = "code_reviewer"', + 'description = "Hand-written reviewer."', + 'developer_instructions = "Hand-written instructions."', + "", + ].join("\n"), + "utf-8", + ); + + const result = await writeSubagentConfigs( + ["codex"], + [{ + ...SUBAGENT, + native: { + codex: [ + 'name = "code_reviewer"', + 'description = "Review code for correctness and missing tests."', + 'developer_instructions = "Native Codex instructions."', + "", + ].join("\n"), + }, + }], + projectSubagentResolver(dir), + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("identity conflicts with unmanaged file"); + expect(result.written).toBe(0); + expect(existsSync(join(targetDir, "code-reviewer.toml"))).toBe(false); + }); + + it("does not overwrite unmanaged Codex files that mention the marker outside the header", async () => { + const targetDir = join(dir, ".codex", "agents"); + await mkdir(targetDir, { recursive: true }); + const filePath = join(targetDir, "code-reviewer.toml"); + const content = [ + 'name = "code-reviewer"', + 'description = "Hand-written reviewer."', + `developer_instructions = "Mentions ${DOTAGENTS_SUBAGENT_MARKER}"`, + "", + ].join("\n"); + await writeFile(filePath, content, "utf-8"); + + const result = await writeSubagentConfigs(["codex"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("not managed by dotagents"); + expect(result.written).toBe(0); + expect(await readFile(filePath, "utf-8")).toBe(content); + }); + + it("prunes stale dotagents-managed files", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + const stalePath = join(targetDir, "old-reviewer.md"); + await writeFile( + stalePath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "old-reviewer"\n---\n`, + "utf-8", + ); + + await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + const pruned = await pruneSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(pruned).toEqual([stalePath]); + expect(existsSync(stalePath)).toBe(false); + expect(existsSync(join(targetDir, "code-reviewer.md"))).toBe(true); + }); + + it("does not prune markdown files that start with a TOML-style marker", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + const stalePath = join(targetDir, "old-reviewer.md"); + await writeFile( + stalePath, + `# ${DOTAGENTS_SUBAGENT_MARKER} +--- +name: "old-reviewer" +--- +`, + "utf-8", + ); + + const pruned = await pruneSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(pruned).toEqual([]); + expect(existsSync(stalePath)).toBe(true); + }); + + it("does not prune managed files for runtimes not listed in agents", async () => { + const targetDir = join(dir, ".codex", "agents"); + await mkdir(targetDir, { recursive: true }); + const stalePath = join(targetDir, "code-reviewer.toml"); + await writeFile( + stalePath, + `# ${DOTAGENTS_SUBAGENT_MARKER}\nname = "code-reviewer"\n`, + "utf-8", + ); + + await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + const pruned = await pruneSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(pruned).toEqual([]); + expect(existsSync(stalePath)).toBe(true); + }); + + it("does not read desired files while pruning stale files", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + const desiredPath = join(targetDir, "code-reviewer.md"); + const stalePath = join(targetDir, "old-reviewer.md"); + await writeFile( + desiredPath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "code-reviewer"\n---\n`, + "utf-8", + ); + await writeFile( + stalePath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "old-reviewer"\n---\n`, + "utf-8", + ); + + await chmod(desiredPath, 0o000); + try { + const pruned = await pruneSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(pruned).toEqual([stalePath]); + expect(existsSync(desiredPath)).toBe(true); + expect(existsSync(stalePath)).toBe(false); + } finally { + await chmod(desiredPath, 0o600); + } + }); + + it("does not prune Codex files that mention the marker outside the header", async () => { + const targetDir = join(dir, ".codex", "agents"); + await mkdir(targetDir, { recursive: true }); + const stalePath = join(targetDir, "old-reviewer.toml"); + await writeFile( + stalePath, + [ + 'name = "old-reviewer"', + 'description = "Old reviewer."', + `developer_instructions = "Mentions ${DOTAGENTS_SUBAGENT_MARKER}"`, + "", + ].join("\n"), + "utf-8", + ); + + const pruned = await pruneSubagentConfigs(["codex"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(pruned).toEqual([]); + expect(existsSync(stalePath)).toBe(true); + }); + + it("prunes managed files when no subagents remain", async () => { + const targetDir = join(dir, ".opencode", "agents"); + await mkdir(targetDir, { recursive: true }); + const stalePath = join(targetDir, "code-reviewer.md"); + await writeFile( + stalePath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\ndescription: "old"\n---\n`, + "utf-8", + ); + + await writeSubagentConfigs(["opencode"], [], projectSubagentResolver(dir)); + const pruned = await pruneSubagentConfigs(["opencode"], [], projectSubagentResolver(dir)); + + expect(pruned).toEqual([stalePath]); + expect(existsSync(stalePath)).toBe(false); + }); + + it("preserves declared-but-unloaded subagent files while pruning other stale files", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + const declaredPath = join(targetDir, "code-reviewer.md"); + const stalePath = join(targetDir, "old-reviewer.md"); + await writeFile( + declaredPath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "code-reviewer"\n---\n`, + "utf-8", + ); + await writeFile( + stalePath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "old-reviewer"\n---\n`, + "utf-8", + ); + + await writeSubagentConfigs( + ["claude"], + [], + projectSubagentResolver(dir), + ); + const pruned = await pruneSubagentConfigs( + ["claude"], + [{ name: "code-reviewer" }], + projectSubagentResolver(dir), + ); + + expect(pruned).toEqual([stalePath]); + expect(existsSync(declaredPath)).toBe(true); + expect(existsSync(stalePath)).toBe(false); + }); +}); + +describe("verifySubagentConfigs", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "dotagents-subagents-verify-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true }); + }); + + it("returns no issues when configs match", async () => { + await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + const issues = await verifySubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + expect(issues).toEqual([]); + }); + + it("reports missing configs", async () => { + const issues = await verifySubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(issues).toHaveLength(1); + expect(issues[0]!.issue).toContain("missing"); + }); + + it("reports unmanaged existing configs", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(targetDir, "code-reviewer.md"), "hand-written", "utf-8"); + + const issues = await verifySubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(issues).toHaveLength(1); + expect(issues[0]!.issue).toContain("not managed by dotagents"); + }); + + it("reports unmanaged identity conflicts", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + await writeFile( + join(targetDir, "reviewer.md"), + `--- +name: code-reviewer +description: Hand-written reviewer. +--- + +Hand-written instructions. +`, + "utf-8", + ); + + const issues = await verifySubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(issues).toHaveLength(1); + expect(issues[0]!.issue).toContain("identity conflicts with unmanaged file"); + }); +}); diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts new file mode 100644 index 0000000..3f16e91 --- /dev/null +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -0,0 +1,374 @@ +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 type { SubagentConfigSpec, SubagentDeclaration } from "./types.js"; + +export interface SubagentResolvedTarget { + dirPath: string; +} + +export type SubagentTargetResolver = ( + agentId: string, + spec: SubagentConfigSpec, +) => SubagentResolvedTarget; + +export interface SubagentWriteWarning { + agent: string; + name: string; + message: string; +} + +export interface SubagentWriteResult { + warnings: SubagentWriteWarning[]; + written: number; +} + +export interface SubagentVerifyIssue { + agent: string; + name: string; + issue: string; +} + +interface DesiredDir { + extension: string; + spec: SubagentConfigSpec; + files: Set; +} + +export function projectSubagentResolver(projectRoot: string): SubagentTargetResolver { + return (_id: string, spec: SubagentConfigSpec) => ({ + dirPath: join(projectRoot, spec.projectDir), + }); +} + +export function userSubagentResolver(): SubagentTargetResolver { + return (_id: string, spec: SubagentConfigSpec) => ({ + dirPath: spec.userDir, + }); +} + +export async function writeSubagentConfigs( + agentIds: string[], + subagents: SubagentDeclaration[], + resolveTarget: SubagentTargetResolver, +): Promise { + const warnings: SubagentWriteWarning[] = []; + let written = 0; + const configuredAgents = new Set(agentIds); + + for (const subagent of subagents) { + for (const agentId of selectedAgentIds(agentIds, subagent)) { + if (!configuredAgents.has(agentId)) { + warnings.push({ + agent: agentId, + name: subagent.name, + message: `Subagent "${subagent.name}" targets agent "${agentId}", but "${agentId}" is not listed in agents`, + }); + continue; + } + + const agent = getAgent(agentId); + if (!agent) {continue;} + if (!agent.subagents) { + warnings.push({ + agent: agentId, + name: subagent.name, + message: `Agent "${agent.displayName}" does not support custom subagents`, + }); + continue; + } + + const { dirPath } = resolveTarget(agentId, agent.subagents); + const generated = agent.subagents.serialize(subagent); + const content = normalizeContent(generated.content); + if (!hasDotagentsSubagentMarker(agent.subagents, content)) { + throw new Error(`Internal error: generated subagent "${subagent.name}" is missing the dotagents marker`); + } + const generatedIdentity = generatedSubagentIdentity( + agent.subagents, + generated.fileName, + content, + subagent.name, + ); + + await mkdir(dirPath, { recursive: true }); + const identityConflict = await findUnmanagedIdentityConflict( + dirPath, + generated.fileName, + agent.subagents, + generatedIdentity, + ); + if (identityConflict) { + warnings.push({ + agent: agentId, + name: subagent.name, + message: `Subagent config identity conflicts with unmanaged file: ${identityConflict}`, + }); + continue; + } + const didWrite = await writeManagedFile(join(dirPath, generated.fileName), content, { + agent: agentId, + name: subagent.name, + spec: agent.subagents, + warnings, + }); + if (didWrite) {written++;} + } + } + + return { warnings, written }; +} + +export async function pruneSubagentConfigs( + agentIds: string[], + desiredSubagents: Pick[], + resolveTarget: SubagentTargetResolver, +): Promise { + return pruneManagedFiles(initDesiredDirs(agentIds, desiredSubagents, resolveTarget)); +} + +export async function verifySubagentConfigs( + agentIds: string[], + subagents: SubagentDeclaration[], + resolveTarget: SubagentTargetResolver, +): Promise { + if (subagents.length === 0) {return [];} + + const issues: SubagentVerifyIssue[] = []; + const configuredAgents = new Set(agentIds); + const seen = new Set(); + + for (const subagent of subagents) { + for (const agentId of selectedAgentIds(agentIds, subagent)) { + const issueBase = { agent: agentId, name: subagent.name }; + + if (!configuredAgents.has(agentId)) { + issues.push({ + ...issueBase, + issue: `Subagent "${subagent.name}" targets agent "${agentId}", but "${agentId}" is not listed in agents`, + }); + continue; + } + + const agent = getAgent(agentId); + if (!agent) {continue;} + if (!agent.subagents) { + issues.push({ + ...issueBase, + issue: `Agent "${agent.displayName}" does not support custom subagents`, + }); + continue; + } + + const { dirPath } = resolveTarget(agentId, agent.subagents); + const generated = agent.subagents.serialize(subagent); + const filePath = join(dirPath, generated.fileName); + if (seen.has(filePath)) {continue;} + seen.add(filePath); + const content = normalizeContent(generated.content); + + const identityConflict = await findUnmanagedIdentityConflict( + dirPath, + generated.fileName, + agent.subagents, + generatedSubagentIdentity(agent.subagents, generated.fileName, content, subagent.name), + ); + if (identityConflict) { + issues.push({ + ...issueBase, + issue: `Subagent config identity conflicts with unmanaged file: ${identityConflict}`, + }); + continue; + } + + if (!existsSync(filePath)) { + issues.push({ + ...issueBase, + issue: `Subagent config missing: ${filePath}`, + }); + continue; + } + + try { + const existing = await readFile(filePath, "utf-8"); + if (!hasDotagentsSubagentMarker(agent.subagents, existing)) { + issues.push({ + ...issueBase, + issue: `Subagent config exists and is not managed by dotagents: ${filePath}`, + }); + continue; + } + + if (existing !== normalizeContent(generated.content)) { + issues.push({ + ...issueBase, + issue: `Subagent config out of date: ${filePath}`, + }); + } + } catch { + issues.push({ + ...issueBase, + issue: `Failed to read subagent config: ${filePath}`, + }); + } + } + } + + return issues; +} + +function initDesiredDirs( + agentIds: string[], + subagents: Pick[], + resolveTarget: SubagentTargetResolver, +): Map { + const desiredByDir = new Map(); + const configuredAgents = new Set(agentIds); + for (const agentId of configuredAgents) { + const agent = getAgent(agentId); + if (!agent?.subagents) {continue;} + const { dirPath } = resolveTarget(agentId, agent.subagents); + markDesired(desiredByDir, dirPath, agent.subagents); + } + + for (const subagent of subagents) { + for (const agentId of selectedAgentIds(agentIds, subagent)) { + if (!configuredAgents.has(agentId)) {continue;} + + const agent = getAgent(agentId); + if (!agent?.subagents) {continue;} + + const { dirPath } = resolveTarget(agentId, agent.subagents); + markDesired( + desiredByDir, + dirPath, + agent.subagents, + `${subagent.name}${agent.subagents.fileExtension}`, + ); + } + } + + return desiredByDir; +} + +function selectedAgentIds( + agentIds: string[], + subagent: Pick, +): string[] { + const targets = subagent.targets && subagent.targets.length > 0 + ? subagent.targets + : agentIds; + return [...new Set(targets)]; +} + +function markDesired( + desiredByDir: Map, + dirPath: string, + spec: SubagentConfigSpec, + fileName?: string, +): void { + const desired = desiredByDir.get(dirPath) ?? { + extension: spec.fileExtension, + spec, + files: new Set(), + }; + if (fileName) { + desired.files.add(fileName); + } + desiredByDir.set(dirPath, desired); +} + +async function writeManagedFile( + filePath: string, + content: string, + context: { agent: string; name: string; spec: SubagentConfigSpec; warnings: SubagentWriteWarning[] }, +): Promise { + try { + const existing = await readFile(filePath, "utf-8"); + if (existing === content) {return false;} + if (!hasDotagentsSubagentMarker(context.spec, existing)) { + context.warnings.push({ + agent: context.agent, + name: context.name, + message: `Subagent config exists and is not managed by dotagents: ${filePath}`, + }); + return false; + } + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + } + + await writeFile(filePath, content, "utf-8"); + return true; +} + +async function pruneManagedFiles( + desiredByDir: Map, +): Promise { + const pruned: string[] = []; + for (const [dirPath, desired] of desiredByDir) { + if (!existsSync(dirPath)) {continue;} + + const entries = await readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) {continue;} + if (!entry.name.endsWith(desired.extension)) {continue;} + if (desired.files.has(entry.name)) {continue;} + + const filePath = join(dirPath, entry.name); + const existing = await readFile(filePath, "utf-8"); + if (hasDotagentsSubagentMarker(desired.spec, existing)) { + await rm(filePath); + pruned.push(filePath); + } + } + } + return pruned; +} + +async function findUnmanagedIdentityConflict( + dirPath: string, + generatedFileName: string, + spec: SubagentConfigSpec, + generatedIdentity: string | null, +): Promise { + if (!existsSync(dirPath)) {return null;} + if (!generatedIdentity) {return null;} + + const entries = await readdir(dirPath, { withFileTypes: true }); + for (const entry of entries.toSorted((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + )) { + if (!entry.isFile()) {continue;} + if (entry.name === generatedFileName) {continue;} + if (!entry.name.endsWith(spec.fileExtension)) {continue;} + + const filePath = join(dirPath, entry.name); + const existing = await readFile(filePath, "utf-8"); + if (hasDotagentsSubagentMarker(spec, existing)) {continue;} + + const existingIdentity = readSubagentFileIdentity(spec, filePath, entry.name, existing); + if (existingIdentity === generatedIdentity) { + return filePath; + } + } + + return null; +} + +function hasDotagentsSubagentMarker(spec: SubagentConfigSpec, content: string): boolean { + return spec.fileExtension === ".toml" + ? hasDotagentsTomlSubagentMarker(content) + : hasDotagentsMarkdownSubagentMarker(content); +} + +function normalizeContent(content: string): string { + return content.endsWith("\n") ? content : `${content}\n`; +} + +function isNotFoundError(err: unknown): boolean { + return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; +} diff --git a/packages/dotagents/src/agents/types.ts b/packages/dotagents/src/agents/types.ts index 0b3b908..9518aec 100644 --- a/packages/dotagents/src/agents/types.ts +++ b/packages/dotagents/src/agents/types.ts @@ -1,4 +1,4 @@ -import type { HookEvent } from "../config/schema.js"; +import type { HookEvent, SubagentConfig } from "../config/schema.js"; /** * Universal MCP server declaration from agents.toml [[mcp]] sections. @@ -73,6 +73,54 @@ 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. */ @@ -99,4 +147,6 @@ export interface AgentDefinition { hooks?: HookConfigSpec; /** Transforms universal hook declarations to agent-specific format */ serializeHooks: HookSerializer; + /** Subagent config specification (undefined if agent doesn't support custom subagents) */ + subagents?: SubagentConfigSpec; } diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 27a7fa0..4471cb9 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -123,7 +123,7 @@ describe("runDoctor", () => { expect(check?.status).toBe("warn"); expect(check?.message).toContain("agents.lock"); expect(check?.message).toContain(".agents/.gitignore"); - }); + }, 30_000); it("does not warn when generated files are not tracked", async () => { const { execSync } = await import("node:child_process"); @@ -200,5 +200,19 @@ describe("runDoctor", () => { expect(existsSync(join(projectRoot, ".agents", ".gitignore"))).toBe(true); }); + + 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"); + await writeFile( + join(projectRoot, "agents.lock"), + `version = 1\n\n[skills]\n\n[subagents.old-reviewer]\nsource = "path:agents"\n`, + ); + + await runDoctor({ scope: resolveScope("project", projectRoot), fix: true }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).toContain("/agents/old-reviewer.md"); + }); }); }); diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index b0a9b81..af6a9b1 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -150,12 +150,17 @@ export async function runDoctor(opts: DoctorOptions): Promise { checks.push({ name: ".agents/.gitignore", status: "ok", message: ".agents/.gitignore exists." }); } else { const managedNames = getManagedSkillNames(config, lockfile); + const managedSubagentNames = getManagedSubagentNames(config, lockfile); 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 () => { - await writeAgentsGitignore(scope.agentsDir, managedNames); + await writeAgentsGitignore( + scope.agentsDir, + managedNames, + managedSubagentNames, + ); }, }); } @@ -277,6 +282,19 @@ function getManagedSkillNames( }); } +function getManagedSubagentNames( + config: Awaited>, + lockfile: Awaited>, +): string[] { + const names = new Set(config.subagents.map((subagent) => subagent.name)); + if (lockfile) { + for (const name of Object.keys(lockfile.subagents)) { + names.add(name); + } + } + return [...names]; +} + export default async function doctor(args: string[], flags?: { user?: boolean }): Promise { const { values } = parseArgs({ args, diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index f2390a3..17bd773 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -1,12 +1,15 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access, chmod } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { runInstall, InstallError } from "./install.js"; +import { runInstall as runInstallCommand, InstallError, type InstallOptions, type InstallResult } from "./install.js"; +import { runSync } from "./sync.js"; import { exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; +import { writeLockfile } from "../../lockfile/writer.js"; import { resolveScope } from "../../scope.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "../../agents/definitions/helpers.js"; const SKILL_MD = (name: string) => `--- name: ${name} @@ -16,24 +19,38 @@ description: Test skill ${name} # ${name} `; +const SUBAGENT_MD = (name: string) => `--- +name: ${name} +description: Review code for correctness. +--- + +Review the current diff. +`; + describe("runInstall", () => { let tmpDir: string; let stateDir: string; let projectRoot: string; let repoDir: string; + let repoInitialized: boolean; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), "dotagents-install-")); stateDir = join(tmpDir, "state"); projectRoot = join(tmpDir, "project"); repoDir = join(tmpDir, "repo"); + repoInitialized = false; process.env["DOTAGENTS_STATE_DIR"] = stateDir; // Set up project await mkdir(join(projectRoot, ".agents", "skills"), { recursive: true }); - // Create a local git repo with skills + }); + + async function ensureGitRepo(): Promise { + 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 }); @@ -48,7 +65,16 @@ describe("runInstall", () => { await exec("git", ["add", "."], { cwd: repoDir }); await exec("git", ["commit", "-m", "initial"], { cwd: repoDir }); - }); + repoInitialized = true; + } + + async function runInstall(opts: InstallOptions): Promise { + const config = await readFile(opts.scope.configPath, "utf-8").catch(() => ""); + if (config.includes(`git:${repoDir}`)) { + await ensureGitRepo(); + } + return runInstallCommand(opts); + } afterEach(async () => { delete process.env["DOTAGENTS_STATE_DIR"]; @@ -230,6 +256,613 @@ describe("runInstall", () => { expect(result.hookWarnings[0]!.agent).toBe("codex"); }); + it("writes subagent configs for declared agents", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude", "codex", "opencode"] + +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + const result = await runInstall({ scope }); + expect(result.subagentWarnings).toHaveLength(0); + + const claude = await readFile(join(projectRoot, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(claude).toContain('description: "Review code for correctness."'); + + const codex = await readFile(join(projectRoot, ".codex", "agents", "code-reviewer.toml"), "utf-8"); + expect(codex).toContain('developer_instructions = "Review the current diff."'); + expect(await readFile(join(projectRoot, ".agents", "agents", "code-reviewer.md"), "utf-8")).toContain(DOTAGENTS_SUBAGENT_MARKER); + + const opencode = await readFile(join(projectRoot, ".opencode", "agents", "code-reviewer.md"), "utf-8"); + expect(opencode).toContain('mode: "subagent"'); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); + }); + + it("reports unmanaged installed subagent files as install errors", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const installedDir = join(projectRoot, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const installedPath = join(installedDir, "code-reviewer.md"); + await writeFile(installedPath, "hand-written subagent\n", "utf-8"); + + let error: unknown; + try { + await runInstall({ scope: resolveScope("project", projectRoot) }); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(InstallError); + expect((error as Error).message).toContain( + "Subagent file exists and is not managed by dotagents", + ); + expect(await readFile(installedPath, "utf-8")).toBe("hand-written subagent\n"); + }); + + it("frozen mode fails when a subagent is missing from the lockfile", async () => { + const scope = resolveScope("project", projectRoot); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + }); + + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + await expect(runInstall({ scope, frozen: true })).rejects.toThrow( + '--frozen: subagent "code-reviewer" is in agents.toml but missing from agents.lock.', + ); + }); + + it("frozen mode passes when subagent lockfile entries match", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + await rm(sourceDir, { recursive: true }); + + const result = await runInstall({ scope, frozen: true }); + expect(result.subagentWarnings).toEqual([]); + }); + + it("clears removed skills from the lockfile when installing subagents", async () => { + const scope = resolveScope("project", projectRoot); + const skillDir = join(projectRoot, ".agents", "skills", "pdf"); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), SKILL_MD("pdf")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + pdf: { + source: "git:https://github.com/example/repo.git", + resolved_url: "https://github.com/example/repo.git", + resolved_path: "pdf", + resolved_commit: "abc123", + }, + }, + }); + + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + await runInstall({ scope }); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.skills).toEqual({}); + expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); + expect(existsSync(skillDir)).toBe(false); + + const syncResult = await runSync({ scope }); + expect(syncResult.adopted).toEqual([]); + }); + + it("updates skill 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")); + + 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", + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: { + "code-reviewer": { + source: "git:https://github.com/example/agents.git", + resolved_url: "https://github.com/example/agents.git", + resolved_path: "agents/code-reviewer.md", + resolved_commit: "abc123", + }, + "old-reviewer": { + source: "path:old-agents", + }, + }, + }); + + 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); + + await expect(runInstall({ scope })).rejects.toThrow( + /Subagent file exists and is not managed by dotagents/, + ); + + 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(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 () => { + 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 writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: { + "code-reviewer": { + source: "git:https://github.com/example/agents.git", + resolved_url: "https://github.com/example/agents.git", + resolved_path: "agents/code-reviewer.md", + resolved_commit: "abc123", + }, + }, + }); + + await mkdir(join(projectRoot, ".claude"), { recursive: true }); + await writeFile(join(projectRoot, ".claude", "agents"), "not a directory\n"); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[skills]] +name = "pdf" +source = "path:local-skills/pdf" + +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow(); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.skills["pdf"]).toBeDefined(); + expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); + expect(existsSync(join(projectRoot, ".agents", "agents", "code-reviewer.md"))).toBe(true); + }); + + it("does not prune outside skills dir for malformed lockfile skill names", async () => { + const scope = resolveScope("project", projectRoot); + const hooksDir = join(projectRoot, ".agents", "hooks"); + const pdfDir = join(projectRoot, ".agents", "skills", "pdf"); + await mkdir(hooksDir, { recursive: true }); + await mkdir(pdfDir, { recursive: true }); + await writeFile(join(hooksDir, "keep.sh"), "echo keep\n"); + await writeFile(join(pdfDir, "SKILL.md"), SKILL_MD("pdf")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + "../hooks": { + source: "git:https://github.com/example/repo.git", + resolved_url: "https://github.com/example/repo.git", + resolved_path: "pdf", + resolved_commit: "abc123", + }, + "stale/../pdf": { + source: "git:https://github.com/example/repo.git", + resolved_url: "https://github.com/example/repo.git", + resolved_path: "pdf", + resolved_commit: "abc123", + }, + }, + }); + + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + await runInstall({ scope }); + + expect(await readFile(join(hooksDir, "keep.sh"), "utf-8")).toBe("echo keep\n"); + expect(await readFile(join(pdfDir, "SKILL.md"), "utf-8")).toBe(SKILL_MD("pdf")); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.skills).toEqual({}); + }); + + it("prunes removed managed skills while other skills remain configured", async () => { + const scope = resolveScope("project", projectRoot); + const localSkillDir = join(projectRoot, ".agents", "skills", "local-skill"); + const staleSkillDir = join(projectRoot, ".agents", "skills", "pdf"); + await mkdir(localSkillDir, { recursive: true }); + await mkdir(staleSkillDir, { recursive: true }); + await writeFile(join(localSkillDir, "SKILL.md"), SKILL_MD("local-skill")); + await writeFile(join(staleSkillDir, "SKILL.md"), SKILL_MD("pdf")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + "local-skill": { source: "path:.agents/skills/local-skill" }, + pdf: { + source: "git:https://github.com/example/repo.git", + resolved_url: "https://github.com/example/repo.git", + resolved_path: "pdf", + resolved_commit: "abc123", + }, + }, + }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[skills]] +name = "local-skill" +source = "path:.agents/skills/local-skill" +`, + ); + + const result = await runInstall({ scope }); + + expect(result.pruned).toEqual(["pdf"]); + expect(existsSync(staleSkillDir)).toBe(false); + expect(existsSync(join(localSkillDir, "SKILL.md"))).toBe(true); + + const syncResult = await runSync({ scope }); + expect(syncResult.adopted).toEqual([]); + }); + + it("preserves native Codex content through install and sync", async () => { + const sourceDir = join(projectRoot, "upstream", ".codex", "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile( + join(sourceDir, "code-reviewer.toml"), + [ + "# upstream comment", + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex", "claude"] + +[[subagents]] +name = "code-reviewer" +source = "path:upstream" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const codexPath = join(projectRoot, ".codex", "agents", "code-reviewer.toml"); + expect(await readFile(codexPath, "utf-8")).toContain('sandbox_mode = "read-only"'); + expect(await readFile(codexPath, "utf-8")).toContain("# upstream comment"); + expect(await readFile(codexPath, "utf-8")).toContain('name = "code_reviewer"'); + + const claude = await readFile(join(projectRoot, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(claude).not.toContain("sandbox_mode"); + + await rm(codexPath); + await runSync({ scope }); + + expect(await readFile(codexPath, "utf-8")).toContain('sandbox_mode = "read-only"'); + expect(await readFile(codexPath, "utf-8")).toContain("# upstream comment"); + expect(await readFile(codexPath, "utf-8")).toContain('name = "code_reviewer"'); + }); + + it("installs merged native subagent artifacts for matching runtimes", async () => { + await mkdir(join(projectRoot, "upstream", ".claude", "agents"), { recursive: true }); + await mkdir(join(projectRoot, "upstream", ".codex", "agents"), { recursive: true }); + await writeFile( + join(projectRoot, "upstream", ".claude", "agents", "code-reviewer.md"), + `--- +name: code-reviewer +description: Review code for correctness. +tools: Read, Grep +--- + +Native Claude instructions. +`, + ); + await writeFile( + join(projectRoot, "upstream", ".codex", "agents", "code-reviewer.toml"), + [ + "# native codex comment", + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Native Codex instructions."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude", "codex"] + +[[subagents]] +name = "code-reviewer" +source = "path:upstream" +`, + ); + + await runInstall({ scope: resolveScope("project", projectRoot) }); + + const claude = await readFile(join(projectRoot, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(claude).toContain("tools: Read, Grep"); + expect(claude).toContain("Native Claude instructions."); + expect(claude).not.toContain("sandbox_mode"); + + const codex = await readFile(join(projectRoot, ".codex", "agents", "code-reviewer.toml"), "utf-8"); + expect(codex).toContain("# native codex comment"); + expect(codex).toContain('name = "code_reviewer"'); + expect(codex).toContain("Native Codex instructions."); + expect(codex).toContain('sandbox_mode = "read-only"'); + + const installed = await readFile(join(projectRoot, ".agents", "agents", "code-reviewer.md"), "utf-8"); + expect(installed).toContain("Native Claude instructions."); + expect(installed).toContain("sandbox_mode"); + }); + + it("prunes subagent files and lock entries when removed from config", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] +`, + ); + + await runInstall({ scope }); + + expect(existsSync(join(projectRoot, ".agents", "agents", "code-reviewer.md"))).toBe(false); + expect(existsSync(join(projectRoot, ".claude", "agents", "code-reviewer.md"))).toBe(false); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents).toEqual({}); + }); + + it("returns subagent warnings for unsupported agents", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "reviewer.md"), SUBAGENT_MD("reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["vscode"] + +[[subagents]] +name = "reviewer" +source = "path:agents" +path = "reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + const result = await runInstall({ scope }); + expect(result.subagentWarnings).toHaveLength(1); + expect(result.subagentWarnings[0]!.agent).toBe("vscode"); + }); + it("skips copy for in-place path skill", async () => { // Pre-install the skill directory (simulating an adopted orphan) const skillDir = join(projectRoot, ".agents", "skills", "local-skill"); @@ -397,6 +1030,7 @@ describe("runInstall", () => { expect(first.pruned).toHaveLength(0); // Remove "review" from upstream repo + await ensureGitRepo(); await exec("git", ["rm", "-rf", "skills/review"], { cwd: repoDir }); await exec("git", ["commit", "-m", "remove review"], { cwd: repoDir }); @@ -417,7 +1051,7 @@ describe("runInstall", () => { expect(lock!.skills["pdf"]).toBeDefined(); }); - it("does not prune skills whose source does not match a wildcard", async () => { + it("prunes stale managed skills whose source does not match a wildcard", async () => { // Create a second repo with a "helper" skill const repoDir2 = join(tmpDir, "repo2"); await mkdir(repoDir2, { recursive: true }); @@ -440,6 +1074,7 @@ describe("runInstall", () => { // Remove "helper" from agents.toml (keep wildcard only) // Also remove "review" from upstream so it gets pruned + await ensureGitRepo(); await exec("git", ["rm", "-rf", "skills/review"], { cwd: repoDir }); await exec("git", ["commit", "-m", "remove review"], { cwd: repoDir }); @@ -452,10 +1087,9 @@ describe("runInstall", () => { // "review" was from the wildcard source and was removed upstream — should be pruned expect(result.pruned).toContain("review"); - // "helper" was explicit from a different source — should NOT be pruned - expect(result.pruned).not.toContain("helper"); - // helper's directory should still exist on disk - expect(existsSync(join(projectRoot, ".agents", "skills", "helper", "SKILL.md"))).toBe(true); + // "helper" was removed from config, so it should also be pruned + expect(result.pruned).toContain("helper"); + expect(existsSync(join(projectRoot, ".agents", "skills", "helper"))).toBe(false); }); it("prunes skills added to wildcard exclude list", async () => { @@ -509,6 +1143,44 @@ describe("runInstall", () => { expect(existsSync(join(projectRoot, ".agents", "skills", "review", "SKILL.md"))).toBe(true); }); + it("does not prune installed subagents in frozen mode", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] +`, + ); + + const result = await runInstall({ scope, frozen: true }); + + expect(result.pruned).toEqual([]); + expect(existsSync(join(projectRoot, ".agents", "agents", "code-reviewer.md"))).toBe(true); + expect(existsSync(join(projectRoot, ".claude", "agents", "code-reviewer.md"))).toBe(true); + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).toContain("/agents/code-reviewer.md"); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents["code-reviewer"]).toBeDefined(); + }); + it("wildcard with all skills excluded installs nothing from that source", async () => { await writeFile( join(projectRoot, "agents.toml"), @@ -568,6 +1240,7 @@ describe("runInstall", () => { ); // Update the skill upstream + await ensureGitRepo(); await writeFile(join(repoDir, "pdf", "SKILL.md"), `${SKILL_MD("pdf")}\nupdated content`); await exec("git", ["add", "."], { cwd: repoDir }); await exec("git", ["commit", "-m", "update pdf skill"], { cwd: repoDir }); @@ -585,6 +1258,7 @@ describe("runInstall", () => { it("minimum_release_age resolves to an older commit when HEAD is too new", async () => { // Create an old commit (backdated) then a new one + await ensureGitRepo(); await exec("git", ["rm", "-rf", "pdf", "skills"], { cwd: repoDir }); await exec("git", ["commit", "-m", "clear"], { cwd: repoDir }); @@ -637,6 +1311,7 @@ describe("runInstall", () => { }); it("minimum_release_age rejects pinned skills that are too new", async () => { + await ensureGitRepo(); const { stdout: sha } = await exec("git", ["rev-parse", "HEAD"], { cwd: repoDir }); await writeFile( diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index e7632eb..9edeb54 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -7,10 +7,11 @@ 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 } from "../../lockfile/schema.js"; +import { type Lockfile, type LockedSkill, type LockedSubagent } from "../../lockfile/schema.js"; import { applyDefaultRepositorySource, resolveSkill, @@ -22,7 +23,7 @@ import { GitError, copyDir, } from "@sentry/dotagents-lib"; -import { isInPlaceSkill } from "../../utils/fs.js"; +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"; @@ -30,7 +31,17 @@ 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 { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; @@ -51,6 +62,7 @@ export interface InstallResult { skipped: string[]; pruned: 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 */ @@ -143,12 +155,73 @@ async function expandSkills( 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; +} + 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[] = []; @@ -158,8 +231,6 @@ export async function runInstall(opts: InstallOptions): Promise { // 2. Resolve and install skills (if any declared) if (config.skills.length > 0) { - const lockfile = await loadLockfile(lockPath); - if (frozen && !lockfile) { throw new InstallError("--frozen requires agents.lock to exist."); } @@ -186,8 +257,6 @@ export async function runInstall(opts: InstallOptions): Promise { } } - const newLock: Lockfile = { version: 1, skills: {} }; - for (const item of expanded) { const { name, dep } = item; @@ -249,27 +318,93 @@ export async function runInstall(opts: InstallOptions): Promise { installed.push(name); } - // Prune stale wildcard-sourced skills (skip in frozen mode to avoid disk/lockfile inconsistency) + // Prune stale managed skills (skip in frozen mode to avoid disk/lockfile inconsistency) if (!frozen && lockfile) { - const wildcardDeps = config.skills.filter(isWildcardDep); for (const [name, locked] of Object.entries(lockfile.skills)) { if (newLock.skills[name]) {continue;} // still tracked - const fromWildcard = wildcardDeps.some((w) => - sourcesMatch(locked.source, w.source), - ); - if (fromWildcard) { - await rm(join(skillsDir, name), { recursive: true, force: true }); - pruned.push(name); - } + 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 writeLockfile(lockPath, newLock); + 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); } - // 3. Gitignore (skip for user scope — ~/.agents/ is not a git repo) + // 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) => { @@ -278,7 +413,14 @@ export async function runInstall(opts: InstallOptions): Promise { if (!dep || isWildcardDep(dep)) {return true;} return !isInPlaceSkill(dep.source); }); - await writeAgentsGitignore(agentsDir, managedNames); + 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); @@ -287,7 +429,7 @@ export async function runInstall(opts: InstallOptions): Promise { } } - // 4. Symlinks — create per-agent symlinks so each agent discovers skills + // 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) { @@ -315,11 +457,11 @@ export async function runInstall(opts: InstallOptions): Promise { } } - // 5. Write MCP config files + // 6. Write MCP config files const mcpResolver = scope.scope === "user" ? userMcpResolver() : projectMcpResolver(scope.root); await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), mcpResolver); - // 6. Write hook config files (skip for user scope) + // 7. Write hook config files (skip for user scope) let hookWarnings: { agent: string; message: string }[] = []; if (scope.scope === "project") { hookWarnings = await writeHookConfigs( @@ -329,7 +471,35 @@ export async function runInstall(opts: InstallOptions): Promise { ); } - return { installed, skipped, pruned, hookWarnings }; + // 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); + } + } 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 }; } export default async function install(args: string[], flags?: { user?: boolean }): Promise { @@ -366,6 +536,9 @@ export default async function install(args: string[], flags?: { user?: boolean } 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}`)); + } } catch (err) { if (err instanceof TrustError) { console.error(chalk.red(formatTrustError(err))); diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index 74b8507..06df0f5 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -7,6 +7,7 @@ import { runRemove, runRemoveSource, collectSkillsFromSource, RemoveError, Wildc import { runInstall } from "./install.js"; import { exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; +import { writeLockfile } from "../../lockfile/writer.js"; import { loadConfig } from "../../config/loader.js"; import { resolveScope } from "../../scope.js"; @@ -71,6 +72,35 @@ describe("runRemove", () => { expect(lockfile!.skills["pdf"]).toBeUndefined(); }); + it("keeps lockfile subagents in .agents/.gitignore after removing a skill", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1\n\n[[skills]]\nname = "pdf"\nsource = "path:local-skills/pdf"\n`, + ); + 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", + }, + }, + subagents: { + "old-reviewer": { + source: "path:agents", + }, + }, + }); + + 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("/agents/old-reviewer.md"); + }); + it("throws RemoveError for skill not in config", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); const scope = resolveScope("project", projectRoot); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index a35b3cf..ec3dd93 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -156,7 +156,17 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { if (!dep || isWildcardDep(dep)) {return true;} return !isInPlaceSkill(dep.source); }); - await writeAgentsGitignore(scope.agentsDir, managedNames); + const managedSubagentNames = new Set(config.subagents.map((subagent) => subagent.name)); + if (lockfile) { + for (const name of Object.keys(lockfile.subagents)) { + managedSubagentNames.add(name); + } + } + await writeAgentsGitignore( + scope.agentsDir, + managedNames, + [...managedSubagentNames], + ); } async function promptYesNo(question: string): Promise { diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 6a9fb0f..7c7df5a 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -10,6 +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 { exec } from "@sentry/dotagents-lib"; const SKILL_MD = (name: string) => `--- @@ -127,6 +128,36 @@ describe("runSync", () => { expect(lockfile!.skills["pdf"]).toBeUndefined(); }); + it("does not report malformed stale skill names as pruned", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + "version = 1\n", + ); + const managedDir = join(projectRoot, ".agents", "skills", "bad name"); + await mkdir(managedDir, { recursive: true }); + await writeFile(join(managedDir, "SKILL.md"), SKILL_MD("bad name")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + "bad name": { + source: "org/repo", + resolved_url: "https://github.com/org/repo.git", + resolved_path: "bad-name", + }, + }, + }); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.adopted).toHaveLength(0); + expect(result.pruned).toHaveLength(0); + expect(existsSync(managedDir)).toBe(true); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile).not.toBeNull(); + expect(lockfile!.skills["bad name"]).toBeDefined(); + }); + it("prunes wildcard skills newly excluded from config", async () => { await writeFile( join(projectRoot, "agents.toml"), @@ -236,7 +267,7 @@ describe("runSync", () => { process.env["DOTAGENTS_STATE_DIR"] = previousStateDir; } } - }); + }, 30_000); it("detects missing skills", async () => { await writeFile( @@ -384,6 +415,208 @@ describe("runSync", () => { expect(result.issues.filter((i) => i.type === "hooks")).toHaveLength(0); }); + it("repairs missing subagent configs", async () => { + const installedDir = join(projectRoot, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + await writeFile( + join(installedDir, "reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "reviewer"\ndescription: "Review code."\n---\n\nReview code.\n`, + "utf-8", + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "reviewer" +source = "path:agents" +path = "reviewer.md" +`, + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + expect(result.subagentsRepaired).toBe(1); + expect(existsSync(join(projectRoot, ".claude", "agents", "reviewer.md"))).toBe(true); + }); + + it("reports unmanaged subagent config conflicts without overwriting them", async () => { + const installedDir = join(projectRoot, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + await writeFile( + join(installedDir, "reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "reviewer"\ndescription: "Review code."\n---\n\nReview code.\n`, + "utf-8", + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "reviewer" +source = "path:agents" +path = "reviewer.md" +`, + ); + const agentsDir = join(projectRoot, ".claude", "agents"); + await mkdir(agentsDir, { recursive: true }); + await writeFile(join(agentsDir, "reviewer.md"), "hand-written", "utf-8"); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.subagentsRepaired).toBe(0); + expect(result.issues.some((i) => i.type === "subagents" && i.message.includes("not managed"))).toBe(true); + expect(await readFile(join(agentsDir, "reviewer.md"), "utf-8")).toBe("hand-written"); + }); + + it("preserves declared managed subagent configs when an unmanaged identity conflict exists", async () => { + const installedDir = join(projectRoot, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + await writeFile( + join(installedDir, "reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "reviewer"\ndescription: "Review code."\n---\n\nReview code.\n`, + "utf-8", + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "reviewer" +source = "path:agents" +path = "reviewer.md" +`, + ); + const agentsDir = join(projectRoot, ".claude", "agents"); + await mkdir(agentsDir, { recursive: true }); + await writeFile( + join(agentsDir, "reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "reviewer"\ndescription: "Managed reviewer."\n---\n\nManaged instructions.\n`, + "utf-8", + ); + await writeFile( + join(agentsDir, "alias.md"), + `---\nname: reviewer\ndescription: Hand-written reviewer.\n---\n\nHand-written instructions.\n`, + "utf-8", + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.subagentsRepaired).toBe(0); + expect(result.issues.some((i) => i.type === "subagents" && i.message.includes("identity conflicts"))).toBe(true); + expect(existsSync(join(agentsDir, "reviewer.md"))).toBe(true); + expect(existsSync(join(agentsDir, "alias.md"))).toBe(true); + }); + + it("does not prune runtime files for declared subagents that are not installed", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "reviewer" +source = "path:agents" +path = "reviewer.md" +`, + ); + const agentsDir = join(projectRoot, ".claude", "agents"); + await mkdir(agentsDir, { recursive: true }); + await writeFile( + join(agentsDir, "reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "reviewer"\ndescription: "Review code."\n---\n\nReview code.\n`, + "utf-8", + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.issues.some((i) => i.type === "subagents" && i.message.includes("not installed"))).toBe(true); + expect(result.subagentsRepaired).toBe(0); + expect(existsSync(join(agentsDir, "reviewer.md"))).toBe(true); + }); + + it("reports pruned subagent configs as repaired", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] +`, + ); + const agentsDir = join(projectRoot, ".claude", "agents"); + await mkdir(agentsDir, { recursive: true }); + await writeFile( + join(agentsDir, "old-reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "old-reviewer"\n---\n`, + "utf-8", + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.subagentsRepaired).toBe(1); + expect(existsSync(join(agentsDir, "old-reviewer.md"))).toBe(false); + }); + + it("prunes stale subagent lock entries", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] +`, + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: { + "old-reviewer": { + source: "path:agents", + }, + }, + }); + + await runSync({ scope: resolveScope("project", projectRoot) }); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents).toEqual({}); + }); + + it("removes stale lockfile subagents when regenerating gitignore", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] +`, + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: { + "old-reviewer": { + source: "path:agents", + }, + }, + }); + await mkdir(join(projectRoot, ".agents", "agents"), { recursive: true }); + await writeFile( + join(projectRoot, ".agents", "agents", "old-reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "old-reviewer"\n---\n`, + "utf-8", + ); + + await runSync({ scope: resolveScope("project", projectRoot) }); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents).toEqual({}); + expect(existsSync(join(projectRoot, ".agents", "agents", "old-reviewer.md"))).toBe(false); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/agents/old-reviewer.md"); + }); + 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 071b332..7228845 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -13,13 +13,15 @@ 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 { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; -import { isInPlaceSkill } from "../../utils/fs.js"; +import { isInPlaceSkill, managedSkillPath } from "../../utils/fs.js"; export interface SyncIssue { - type: "symlink" | "missing" | "mcp" | "hooks"; + type: "symlink" | "missing" | "mcp" | "hooks" | "subagents"; name: string; message: string; } @@ -36,11 +38,13 @@ export interface SyncResult { symlinksRepaired: number; mcpRepaired: number; hooksRepaired: number; + subagentsRepaired: number; } export async function runSync(opts: SyncOptions): Promise { const { scope } = opts; const { configPath, lockPath, agentsDir, skillsDir } = scope; + const subagentsDir = join(agentsDir, "agents"); let config = await loadConfig(configPath); let lockfile = await loadLockfile(lockPath); @@ -74,9 +78,11 @@ export async function runSync(opts: SyncOptions): Promise { const locked = lockfile?.skills[entry.name]; if (locked && !isInPlaceSkill(locked.source)) { - await removeStaleManagedSkill(skillsDir, entry.name); - delete lockfile!.skills[entry.name]; - pruned.push(entry.name); + const removed = await removeStaleManagedSkill(skillsDir, entry.name); + if (removed) { + delete lockfile!.skills[entry.name]; + pruned.push(entry.name); + } continue; } @@ -93,6 +99,7 @@ export async function runSync(opts: SyncOptions): Promise { lockfile = { version: 1, skills: { ...lockfile?.skills, ...adoptedLockEntries }, + subagents: lockfile?.subagents ?? {}, }; await writeLockfile(lockPath, lockfile); } @@ -101,6 +108,15 @@ export async function runSync(opts: SyncOptions): Promise { } } + const declaredSubagentNames = new Set(config.subagents.map((subagent) => subagent.name)); + if (lockfile && Object.keys(lockfile.subagents).some((name) => !declaredSubagentNames.has(name))) { + const subagents = Object.fromEntries( + Object.entries(lockfile.subagents).filter(([name]) => declaredSubagentNames.has(name)), + ); + lockfile = { ...lockfile, subagents }; + await writeLockfile(lockPath, lockfile); + } + // 2. Regenerate .agents/.gitignore (skip for user scope) let gitignoreUpdated = false; if (scope.scope === "project") { @@ -115,7 +131,17 @@ export async function runSync(opts: SyncOptions): Promise { if (!dep || isWildcardDep(dep)) {return true;} // wildcard-sourced skills are always managed return !isInPlaceSkill(dep.source); }); - await writeAgentsGitignore(agentsDir, managedNames); + const managedSubagentNames = new Set(config.subagents.map((subagent) => subagent.name)); + if (lockNow) { + for (const name of Object.keys(lockNow.subagents)) { + managedSubagentNames.add(name); + } + } + await writeAgentsGitignore( + agentsDir, + managedNames, + [...managedSubagentNames], + ); gitignoreUpdated = true; // Health check: warn if agents.lock and .agents/.gitignore are not in root .gitignore @@ -223,6 +249,59 @@ export async function runSync(opts: SyncOptions): Promise { } } + // 7. Verify and repair custom subagent files + let subagentsRepaired = 0; + const installedSubagentResult = await loadInstalledSubagents(subagentsDir, config.subagents); + const prunedInstalledSubagents = await pruneInstalledSubagents(subagentsDir, config.subagents); + const subagentDecls = installedSubagentResult.subagents; + const subagentResolver = scope.scope === "user" + ? userSubagentResolver() + : projectSubagentResolver(scope.root); + const subagentIssues = await verifySubagentConfigs( + config.agents, + subagentDecls, + subagentResolver, + ); + + const subagentResult = await writeSubagentConfigs( + config.agents, + subagentDecls, + subagentResolver, + ); + const prunedSubagentConfigs = await pruneSubagentConfigs( + config.agents, + config.subagents, + subagentResolver, + ); + subagentsRepaired = subagentResult.written + prunedSubagentConfigs.length + prunedInstalledSubagents.length; + + for (const issue of installedSubagentResult.issues) { + issues.push({ + type: "subagents", + name: issue.name, + message: issue.issue, + }); + } + for (const issue of subagentIssues) { + issues.push({ + type: "subagents", + name: issue.name, + message: issue.issue, + }); + } + for (const warning of subagentResult.warnings) { + const alreadyReported = issues.some( + (issue) => issue.type === "subagents" && issue.message === warning.message, + ); + if (!alreadyReported) { + issues.push({ + type: "subagents", + name: warning.name, + message: warning.message, + }); + } + } + return { issues, adopted, @@ -231,11 +310,15 @@ export async function runSync(opts: SyncOptions): Promise { symlinksRepaired, mcpRepaired, hooksRepaired, + subagentsRepaired, }; } -async function removeStaleManagedSkill(skillsDir: string, name: string): Promise { - await rm(join(skillsDir, name), { recursive: true, force: true }); +async function removeStaleManagedSkill(skillsDir: string, name: string): Promise { + const skillPath = managedSkillPath(skillsDir, name); + if (!skillPath) {return false;} + await rm(skillPath, { recursive: true, force: true }); + return true; } export default async function sync(_args: string[], flags?: { user?: boolean }): Promise { @@ -277,6 +360,10 @@ export default async function sync(_args: string[], flags?: { user?: boolean }): console.log(chalk.green(`Repaired ${result.hooksRepaired} hook config(s)`)); } + if (result.subagentsRepaired > 0) { + console.log(chalk.green(`Repaired ${result.subagentsRepaired} subagent config(s)`)); + } + if (result.issues.length === 0) { console.log(chalk.green("Everything in sync.")); return; @@ -286,6 +373,7 @@ export default async function sync(_args: string[], flags?: { user?: boolean }): switch (issue.type) { case "mcp": case "hooks": + case "subagents": console.log(chalk.yellow(` warn: ${issue.message}`)); break; case "missing": diff --git a/packages/dotagents/src/config/index.ts b/packages/dotagents/src/config/index.ts index 244bd08..eb6e015 100644 --- a/packages/dotagents/src/config/index.ts +++ b/packages/dotagents/src/config/index.ts @@ -16,5 +16,6 @@ export type { ProjectConfig, SkillSource, McpConfig, + SubagentConfig, TrustConfig, } from "./schema.js"; diff --git a/packages/dotagents/src/config/loader.test.ts b/packages/dotagents/src/config/loader.test.ts index 14a62e0..8ab52e0 100644 --- a/packages/dotagents/src/config/loader.test.ts +++ b/packages/dotagents/src/config/loader.test.ts @@ -176,4 +176,74 @@ env = ["GITHUB_TOKEN"] const config = await loadConfig(configPath); expect(config.skills).toHaveLength(2); }); + + it("loads subagent entries", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 +agents = ["claude", "codex", "opencode"] + +[[subagents]] +name = "code-reviewer" +source = "getsentry/agents" +targets = ["claude", "codex", "opencode"] +`, + ); + + const config = await loadConfig(configPath); + expect(config.subagents).toHaveLength(1); + expect(config.subagents[0]!.targets).toEqual(["claude", "codex", "opencode"]); + expect(config.subagents[0]!.source).toBe("getsentry/agents"); + }); + + it("rejects unknown subagent targets", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[subagents]] +name = "reviewer" +source = "getsentry/agents" +targets = ["emacs"] +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/Unknown subagent target/); + }); + + it("rejects duplicate subagent names", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[subagents]] +name = "reviewer" +source = "getsentry/agents" + +[[subagents]] +name = "reviewer" +source = "getsentry/agents" +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/Duplicate subagent/); + }); + + it("rejects HTTPS well-known subagent sources", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[subagents]] +name = "reviewer" +source = "https://agents.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 793170b..3b92ed4 100644 --- a/packages/dotagents/src/config/loader.ts +++ b/packages/dotagents/src/config/loader.ts @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"; import { parse as parseTOML } from "smol-toml"; import { agentsConfigSchema, isWildcardDep, type AgentsConfig } from "./schema.js"; import { allAgentIds } from "../agents/registry.js"; +import { applyDefaultRepositorySource, parseSource } from "@sentry/dotagents-lib"; export class ConfigError extends Error { constructor(message: string) { @@ -43,6 +44,31 @@ export async function loadConfig(filePath: string): Promise { ); } + const unknownSubagentTargets = [ + ...new Set( + result.data.subagents.flatMap((subagent) => + (subagent.targets ?? []).filter((id) => !validIds.includes(id)) + ), + ), + ]; + if (unknownSubagentTargets.length > 0) { + throw new ConfigError( + `Unknown subagent target(s) in ${filePath}: ${unknownSubagentTargets.join(", ")}. Valid agents: ${validIds.join(", ")}`, + ); + } + + for (const subagent of result.data.subagents) { + const sourceForResolve = applyDefaultRepositorySource( + subagent.source, + result.data.defaultRepositorySource, + ); + if (parseSource(sourceForResolve).type === "well-known") { + throw new ConfigError( + `Subagent "${subagent.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) { @@ -56,5 +82,16 @@ export async function loadConfig(filePath: string): Promise { } } + // Post-parse validation: no two subagent entries may share the same name. + const subagentNames = new Set(); + for (const subagent of result.data.subagents) { + if (subagentNames.has(subagent.name)) { + throw new ConfigError( + `Duplicate subagent in ${filePath}: "${subagent.name}". Subagent names must be unique.`, + ); + } + subagentNames.add(subagent.name); + } + return result.data; } diff --git a/packages/dotagents/src/config/schema.test.ts b/packages/dotagents/src/config/schema.test.ts index f82af01..ddfcf73 100644 --- a/packages/dotagents/src/config/schema.test.ts +++ b/packages/dotagents/src/config/schema.test.ts @@ -237,6 +237,75 @@ describe("agentsConfigSchema", () => { }); }); + describe("subagents 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.subagents).toEqual([]); + } + }); + + it("accepts a portable subagent declaration", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + agents: ["claude", "codex", "opencode", "vscode"], + subagents: [ + { + name: "code-reviewer", + source: "getsentry/agents", + targets: ["claude", "codex", "opencode", "vscode"], + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.subagents[0]!.name).toBe("code-reviewer"); + } + }); + + it("rejects runtime-specific subagent options", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + subagents: [ + { + name: "test-runner", + source: "getsentry/agents", + model: "inherit", + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid subagent names", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + subagents: [ + { + name: "CodeReviewer", + source: "getsentry/agents", + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("accepts known-but-unsupported subagent targets at the schema layer", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + subagents: [ + { + name: "reviewer", + source: "getsentry/agents", + targets: ["vscode"], + }, + ], + }); + expect(result.success).toBe(true); + }); + }); + 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 dba21ad..933deec 100644 --- a/packages/dotagents/src/config/schema.ts +++ b/packages/dotagents/src/config/schema.ts @@ -148,6 +148,27 @@ const hookSchema = z.object({ export type HookConfig = z.infer; +export const SUBAGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; + +const subagentNameSchema = z + .string() + .regex( + SUBAGENT_NAME_PATTERN, + "Subagent names must start with lowercase a-z and contain only lowercase letters, numbers, and hyphens", + ); + +const subagentTargetSchema = z.string().min(1); + +const subagentSchema = z.object({ + name: subagentNameSchema, + source: skillSourceSchema, + ref: z.string().optional(), + path: z.string().optional(), + targets: z.array(subagentTargetSchema).optional(), +}).strict(); + +export type SubagentConfig = z.infer; + const trustConfigSchema = z.object({ allow_all: z.boolean().default(false), github_orgs: z.array(z.string()).default([]), @@ -168,6 +189,7 @@ export const agentsConfigSchema = z.object({ skills: z.array(skillDependencySchema).default([]), mcp: z.array(mcpSchema).default([]), hooks: z.array(hookSchema).default([]), + subagents: z.array(subagentSchema).default([]), trust: trustConfigSchema.optional(), minimum_release_age: z.number().int().min(0).optional(), minimum_release_age_exclude: z.array(z.string()).default([]), diff --git a/packages/dotagents/src/gitignore/writer.test.ts b/packages/dotagents/src/gitignore/writer.test.ts index 35bae39..153e56b 100644 --- a/packages/dotagents/src/gitignore/writer.test.ts +++ b/packages/dotagents/src/gitignore/writer.test.ts @@ -35,6 +35,15 @@ describe("writeAgentsGitignore", () => { expect(content).toContain("/skills/pdf/"); }); + it("lists managed subagent files", async () => { + const agentsDir = join(dir, ".agents"); + await writeAgentsGitignore(agentsDir, [], ["reviewer", "test-runner"]); + + const content = await readFile(join(agentsDir, ".gitignore"), "utf-8"); + expect(content).toContain("/agents/reviewer.md"); + expect(content).toContain("/agents/test-runner.md"); + }); + it("sorts skill names alphabetically", async () => { const agentsDir = join(dir, ".agents"); await writeAgentsGitignore(agentsDir, ["zebra", "alpha", "middle"]); diff --git a/packages/dotagents/src/gitignore/writer.ts b/packages/dotagents/src/gitignore/writer.ts index 0b2a225..8323902 100644 --- a/packages/dotagents/src/gitignore/writer.ts +++ b/packages/dotagents/src/gitignore/writer.ts @@ -3,20 +3,24 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; const HEADER = `# Auto-generated by dotagents. Do not edit. -# Managed skills (installed by dotagents)`; +# Managed artifacts (installed by dotagents)`; /** - * Generate .agents/.gitignore listing managed skill directories. + * Generate .agents/.gitignore listing managed skill directories and installed subagents. * Skills with path: sources are also gitignored since they're installed by dotagents. */ export async function writeAgentsGitignore( agentsDir: string, managedSkillNames: string[], + managedSubagentNames: string[] = [], ): Promise { const lines = [HEADER]; for (const name of managedSkillNames.toSorted()) { lines.push(`/skills/${name}/`); } + for (const name of managedSubagentNames.toSorted()) { + lines.push(`/agents/${name}.md`); + } lines.push(""); // trailing newline await writeFile(join(agentsDir, ".gitignore"), lines.join("\n"), "utf-8"); diff --git a/packages/dotagents/src/index.ts b/packages/dotagents/src/index.ts index a232033..b32c1d4 100644 --- a/packages/dotagents/src/index.ts +++ b/packages/dotagents/src/index.ts @@ -6,21 +6,35 @@ export type { RegularSkillDependency, SkillSource, McpConfig, + SubagentConfig, TrustConfig, } from "./config/index.js"; export { resolveScope } from "./scope.js"; export type { Scope, ScopeRoot } from "./scope.js"; -export { getAgent, allAgentIds, writeMcpConfigs, verifyMcpConfigs, projectMcpResolver, getUserMcpTarget, userMcpResolver } from "./agents/index.js"; -export type { AgentDefinition, McpDeclaration, McpConfigSpec, McpTargetResolver } from "./agents/index.js"; +export { + getAgent, + allAgentIds, + writeMcpConfigs, + verifyMcpConfigs, + projectMcpResolver, + getUserMcpTarget, + userMcpResolver, +} from "./agents/index.js"; +export type { + AgentDefinition, + McpDeclaration, + McpConfigSpec, + McpTargetResolver, +} from "./agents/index.js"; export { writeAgentsGitignore, ensureRootGitignoreEntries } from "./gitignore/index.js"; export { ensureSkillsSymlink, verifySymlinks } from "./symlinks/index.js"; export { lockfileSchema, loadLockfile, LockfileError, writeLockfile } from "./lockfile/index.js"; -export type { Lockfile, LockedSkill } from "./lockfile/index.js"; +export type { Lockfile, LockedSkill, LockedSubagent } 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 1435bf0..cd4d865 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 } from "./schema.js"; +export type { Lockfile, LockedSkill, LockedSubagent } 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 2af8994..8fa14ef 100644 --- a/packages/dotagents/src/lockfile/schema.test.ts +++ b/packages/dotagents/src/lockfile/schema.test.ts @@ -24,4 +24,18 @@ describe("lockfileSchema", () => { expect("resolved_commit" in skill).toBe(false); } }); + + it("rejects well-known-style subagent lock entries", () => { + const result = lockfileSchema.safeParse({ + version: 1, + skills: {}, + subagents: { + reviewer: { + source: "https://example.com", + resolved_url: "https://example.com", + }, + }, + }); + expect(result.success).toBe(false); + }); }); diff --git a/packages/dotagents/src/lockfile/schema.ts b/packages/dotagents/src/lockfile/schema.ts index 09bb24b..fc47205 100644 --- a/packages/dotagents/src/lockfile/schema.ts +++ b/packages/dotagents/src/lockfile/schema.ts @@ -19,12 +19,18 @@ const lockedLocalSkillSchema = z.object({ }); const lockedSkillSchema = z.union([lockedGitSkillSchema, lockedWellKnownSkillSchema, lockedLocalSkillSchema]); +const lockedLocalSubagentSchema = z.object({ + source: z.string(), +}).strict(); +const lockedSubagentSchema = z.union([lockedGitSkillSchema, lockedLocalSubagentSchema]); export type LockedSkill = z.infer; +export type LockedSubagent = 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({}), }); export type Lockfile = z.infer; diff --git a/packages/dotagents/src/lockfile/writer.test.ts b/packages/dotagents/src/lockfile/writer.test.ts index 6ceadfa..b9f13cb 100644 --- a/packages/dotagents/src/lockfile/writer.test.ts +++ b/packages/dotagents/src/lockfile/writer.test.ts @@ -70,6 +70,41 @@ describe("writeLockfile + loadLockfile", () => { expect(keys).toEqual(["a-skill", "z-skill"]); }); + it("sorts subagents alphabetically", async () => { + const lockPath = join(dir, "agents.lock"); + await writeLockfile(lockPath, { + version: 1, + skills: {}, + subagents: { + "z-reviewer": { + source: "org/z-repo", + }, + "a-reviewer": { + source: "org/a-repo", + }, + }, + }); + + const loaded = await loadLockfile(lockPath); + const keys = Object.keys(loaded!.subagents); + expect(keys).toEqual(["a-reviewer", "z-reviewer"]); + }); + + it("omits empty subagents from the serialized lockfile", async () => { + const lockPath = join(dir, "agents.lock"); + await writeLockfile(lockPath, { + version: 1, + skills: {}, + subagents: {}, + }); + + const content = await readFile(lockPath, "utf-8"); + expect(content).not.toContain("[subagents]"); + + const loaded = await loadLockfile(lockPath); + expect(loaded!.subagents).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 100627a..0a5b057 100644 --- a/packages/dotagents/src/lockfile/writer.ts +++ b/packages/dotagents/src/lockfile/writer.ts @@ -4,20 +4,37 @@ import type { Lockfile } from "./schema.js"; const HEADER = "# Auto-generated by dotagents. Do not edit.\n"; +type WritableLockfile = Omit & { + subagents?: Lockfile["subagents"]; +}; + /** * Write an agents.lock file. - * Skills are sorted alphabetically for deterministic output. + * Managed entries are sorted alphabetically for deterministic output. */ export async function writeLockfile( filePath: string, - lockfile: Lockfile, + lockfile: WritableLockfile, ): Promise { // Sort skills by name for deterministic output const sortedSkills: Record = {}; for (const name of Object.keys(lockfile.skills).toSorted()) { sortedSkills[name] = lockfile.skills[name]; } + const sortedSubagents: Record = {}; + const subagents = lockfile.subagents ?? {}; + for (const name of Object.keys(subagents).toSorted()) { + sortedSubagents[name] = subagents[name]; + } + + const doc: Record = { + version: lockfile.version, + skills: sortedSkills, + }; + if (Object.keys(sortedSubagents).length > 0) { + doc["subagents"] = sortedSubagents; + } - const toml = stringify({ version: lockfile.version, skills: sortedSkills }); + const toml = stringify(doc); await writeFile(filePath, `${(HEADER + toml).trimEnd()}\n`, "utf-8"); } diff --git a/packages/dotagents/src/scope.test.ts b/packages/dotagents/src/scope.test.ts index 94bf561..bd63381 100644 --- a/packages/dotagents/src/scope.test.ts +++ b/packages/dotagents/src/scope.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -import { join } from "node:path"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir, homedir } from "node:os"; import { resolveScope, isInsideGitRepo, findGitDir, resolveDefaultScope, ScopeError } from "./scope.js"; @@ -72,7 +72,7 @@ describe("isInsideGitRepo", () => { }); it("returns false when no .git in any parent", () => { - tempDir = mkdtempSync(join(tmpdir(), "scope-test-")); + tempDir = mkNonGitTempDir(); // No .git directory created expect(isInsideGitRepo(tempDir)).toBe(false); }); @@ -121,7 +121,7 @@ describe("findGitDir", () => { }); it("returns undefined when no .git exists", () => { - tempDir = mkdtempSync(join(tmpdir(), "scope-test-")); + tempDir = mkNonGitTempDir(); expect(findGitDir(tempDir)).toBeUndefined(); }); }); @@ -143,7 +143,7 @@ describe("resolveDefaultScope", () => { }); it("falls back to user scope when not in a git repo", () => { - tempDir = mkdtempSync(join(tmpdir(), "scope-test-")); + tempDir = mkNonGitTempDir(); process.env["DOTAGENTS_HOME"] = join(tempDir, "user-home"); const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const s = resolveDefaultScope(tempDir); @@ -159,3 +159,33 @@ describe("resolveDefaultScope", () => { expect(() => resolveDefaultScope(tempDir)).toThrow(/dotagents init/); }); }); + +function mkNonGitTempDir(): string { + for (const base of tempBases()) { + const dir = mkdtempSync(join(base, "scope-test-")); + if (!hasGitParent(dir)) { + return dir; + } + rmSync(dir, { recursive: true, force: true }); + } + + throw new Error("Could not create a temporary directory outside a git repository"); +} + +function tempBases(): string[] { + return [tmpdir(), "/var/tmp", "/dev/shm"].filter((base) => existsSync(base)); +} + +function hasGitParent(dir: string): boolean { + let current = resolve(dir); + while (true) { + if (existsSync(join(current, ".git"))) { + return true; + } + const parent = dirname(current); + if (parent === current) { + return false; + } + current = parent; + } +} diff --git a/packages/dotagents/src/symlinks/manager.test.ts b/packages/dotagents/src/symlinks/manager.test.ts index 9e0d5af..2237ee0 100644 --- a/packages/dotagents/src/symlinks/manager.test.ts +++ b/packages/dotagents/src/symlinks/manager.test.ts @@ -144,7 +144,7 @@ describe("symlinks", () => { // Verify the skill was moved to .agents/skills/ const agentsEntries = await readdir(join(agentsDir, "skills")); expect(agentsEntries).toContain("my-skill"); - }); + }, 30_000); }); describe("verifySymlinks", () => { diff --git a/packages/dotagents/src/utils/fs.test.ts b/packages/dotagents/src/utils/fs.test.ts new file mode 100644 index 0000000..d8071c7 --- /dev/null +++ b/packages/dotagents/src/utils/fs.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { join, resolve } from "node:path"; +import { managedSkillPath } from "./fs.js"; + +describe("managedSkillPath", () => { + it("resolves safe skill names inside the skills directory", () => { + const skillsDir = join("project", ".agents", "skills"); + + expect(managedSkillPath(skillsDir, "pdf")).toBe(resolve(skillsDir, "pdf")); + }); + + it("rejects path-shaped skill names", () => { + const skillsDir = join("project", ".agents", "skills"); + + expect(managedSkillPath(skillsDir, "../hooks")).toBeNull(); + expect(managedSkillPath(skillsDir, "stale/../pdf")).toBeNull(); + expect(managedSkillPath(skillsDir, "foo/bar")).toBeNull(); + }); +}); diff --git a/packages/dotagents/src/utils/fs.ts b/packages/dotagents/src/utils/fs.ts index be8ba63..49d8256 100644 --- a/packages/dotagents/src/utils/fs.ts +++ b/packages/dotagents/src/utils/fs.ts @@ -1,4 +1,20 @@ +import { isAbsolute, relative, resolve } from "node:path"; + +const SKILL_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + /** Whether a skill source points to its own install location (adopted orphan). */ export function isInPlaceSkill(source: string): boolean { return source.startsWith("path:.agents/skills/") || source.startsWith("path:skills/"); } + +/** Resolve a managed skill directory, rejecting paths outside the skills root. */ +export function managedSkillPath(skillsDir: string, name: string): string | null { + if (!SKILL_NAME_RE.test(name)) {return null;} + const root = resolve(skillsDir); + const target = resolve(root, name); + const rel = relative(root, target); + if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) { + return null; + } + return target; +} diff --git a/scripts/smoke-examples.mjs b/scripts/smoke-examples.mjs new file mode 100644 index 0000000..24bd009 --- /dev/null +++ b/scripts/smoke-examples.mjs @@ -0,0 +1,255 @@ +#!/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 ad8f838..e6e6a31 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -13,28 +13,57 @@ Answer the practical question: "With this local dotagents build, does a represen - `git status --short` - `git diff --stat` - `git diff -- ` -2. Build the local CLI before smoke testing: +2. Read the targeted reference before running harness-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) + - Cursor files/runtime caveats: [references/cursor.md](references/cursor.md) + - OpenCode files/runtime caveats: [references/opencode.md](references/opencode.md) +3. Build the local CLI before smoke testing: ```bash pnpm build ``` -3. Create a temp project that exercises the changed behavior. Start here and add only what the change needs. +4. Prefer the checked-in example smoke for ordinary install/sync QA: + +```bash +pnpm smoke:examples +``` + +For paid Codex runtime proof of generated custom agents, run: + +```bash +node scripts/smoke-examples.mjs --codex-runtime --keep +``` + +The runtime mode copies Codex auth/config into a temp `CODEX_HOME`, marks only the temp example project trusted, and asserts that Codex can spawn the generated `.codex/agents/code-reviewer.toml` agent. See [references/codex.md](references/codex.md) before changing this path. + +5. Create a temp project manually only when the checked-in example does not cover the changed behavior. Start here and add only what the change needs. ```bash set -euo pipefail REPO="$(pwd)" TMP="$(mktemp -d)" -mkdir -p "$TMP/local-skills" "$TMP/home" "$TMP/state" +mkdir -p "$TMP/local-skills" "$TMP/local-agents/agents" "$TMP/home" "$TMP/state" for skill in review commit; do mkdir -p "$TMP/local-skills/$skill" printf -- "---\nname: %s\ndescription: Fixture %s skill.\n---\n\n%s fixture.\n" \ "$skill" "$skill" "$skill" > "$TMP/local-skills/$skill/SKILL.md" done +cat > "$TMP/local-agents/agents/code-reviewer.md" <<'EOF' +--- +name: code-reviewer +description: Review code for correctness. +--- + +Review the current diff and return findings with file references. +EOF + cat > "$TMP/agents.toml" <<'EOF' version = 1 -agents = ["claude", "cursor"] +agents = ["claude", "cursor", "codex", "opencode"] [[skills]] name = "review" @@ -52,10 +81,14 @@ args = ["-e", "process.exit(0)"] [[hooks]] event = "Stop" command = "echo fixture" + +[[subagents]] +name = "code-reviewer" +source = "path:./local-agents" EOF ``` -4. Run the local CLI from inside the temp project, with home/cache isolated: +6. Run the local CLI from inside the temp project, with home/cache isolated: ```bash set -euo pipefail @@ -67,7 +100,7 @@ node "$REPO/packages/dotagents/dist/cli/index.js" doctor --fix node "$REPO/packages/dotagents/dist/cli/index.js" doctor ``` -5. Assert the behavior that matters: +7. Assert the behavior that matters: ```bash set -euo pipefail @@ -76,18 +109,30 @@ test -f .agents/skills/review/SKILL.md && test -f .agents/skills/commit/SKILL.md test -L .claude/skills test -f .mcp.json test -f .cursor/mcp.json +test -f .codex/config.toml +test -f opencode.json test -f .claude/settings.json test -f .cursor/hooks.json +test -f .agents/agents/code-reviewer.md +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 +grep -q "code-reviewer" agents.lock +grep -q "Generated by dotagents" .claude/agents/code-reviewer.md +grep -q "Generated by dotagents" .codex/agents/code-reviewer.toml ``` -For `sync` changes, break generated state and assert repair: +For `sync` or subagent writer changes, break generated state and assert repair: ```bash set -euo pipefail -rm .mcp.json .claude/skills +rm .mcp.json .claude/skills .claude/agents/code-reviewer.md .codex/agents/code-reviewer.toml node "$REPO/packages/dotagents/dist/cli/index.js" sync test -f .mcp.json test -L .claude/skills +test -f .claude/agents/code-reviewer.md +test -f .codex/agents/code-reviewer.toml ``` ## Adjust The Fixture @@ -95,7 +140,8 @@ test -L .claude/skills - Skill resolution changes: use multiple local skills, nested `skills/` directories, or the exact `path:` layout touched by the change. - Agent placement changes: edit `agents = [...]` and assert expected symlinks/config files. - MCP or hook changes: include representative `[[mcp]]` or `[[hooks]]` entries and inspect generated JSON/TOML. -- User-scope changes: set `DOTAGENTS_HOME="$TMP/user-home"` and pass `--user`; never write to the real home directory. +- Subagent changes: 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` for the `subagents` entry. +- User-scope changes: set both `HOME="$TMP/home"` and `DOTAGENTS_HOME="$TMP/user-home"` and pass `--user`; never write to the real home directory. For subagents, assert generated runtime files under `$HOME/.claude/agents/`, `$HOME/.cursor/agents/`, `$HOME/.codex/agents/`, and `$HOME/.config/opencode/agents/`. - Package/runtime changes: run the built CLI as above, or pack/install the package only when the packaging path itself changed. - Remote source changes: use `getsentry/skills`; avoid remotes for ordinary install-location checks. @@ -110,6 +156,8 @@ CODEX_HOME="$TMP/codex-home" codex debug prompt-input "probe skills" > "$TMP/cod grep -q "review" "$TMP/codex-prompt.json" ``` +For Codex subagents, `codex debug prompt-input` only proves runtime discovery if it includes the generated subagent name or instructions. For a real proof, use `node scripts/smoke-examples.mjs --codex-runtime --keep`; project-scoped `.codex/agents/` load only when Codex trusts the project. See [references/codex.md](references/codex.md). + Claude: no cheap dry-run skill list. If auth/network/model cost is acceptable, run a minimal non-interactive `/skill-name` prompt from `$TMP`; otherwise report skipped. ## Optional Remote Check diff --git a/skills/dotagents-qa/references/claude.md b/skills/dotagents-qa/references/claude.md new file mode 100644 index 0000000..72561dc --- /dev/null +++ b/skills/dotagents-qa/references/claude.md @@ -0,0 +1,36 @@ +# 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. + +## File-Level Checks + +The core smoke asserts: + +- `.claude/skills` is a symlink to `.agents/skills` +- `.mcp.json` exists for Claude MCP config +- `.claude/settings.json` exists for hooks +- `.claude/agents/code-reviewer.md` exists +- generated Markdown contains the dotagents managed marker +- `sync` repairs deleted `.claude/skills` and `.claude/agents/code-reviewer.md` + +For user scope, isolate both: + +```bash +HOME="$TMP/home" +DOTAGENTS_HOME="$TMP/user-home" +``` + +Then assert Claude runtime files under `$HOME/.claude/agents/` and user skills under `$DOTAGENTS_HOME/skills/`. + +## 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. + +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 +- temp project path +- generated `.claude/agents/.md` +- evidence that Claude invoked or surfaced the intended custom agent + +If you skip this, say runtime proof was skipped and file-level wiring passed. diff --git a/skills/dotagents-qa/references/codex.md b/skills/dotagents-qa/references/codex.md new file mode 100644 index 0000000..7e7c294 --- /dev/null +++ b/skills/dotagents-qa/references/codex.md @@ -0,0 +1,51 @@ +# 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. + +## File-Level Checks + +The core smoke asserts: + +- `.codex/config.toml` exists for MCP config +- `.codex/agents/code-reviewer.toml` exists +- the generated TOML contains the dotagents managed marker +- the generated TOML contains `name`, `description`, and `developer_instructions` +- `sync` repairs deleted `.codex/agents/code-reviewer.toml` + +These checks prove dotagents wrote the expected files. They do not prove Codex loaded the agent. + +## 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 +``` + +The script: + +- copies `examples/full/` to a temp project +- runs the built local dotagents CLI +- initializes the temp project as a git repo +- copies Codex auth/config into a temp `CODEX_HOME` +- marks only the temp project trusted in that temp config +- runs `codex exec` +- asks Codex to spawn `code-reviewer` +- asserts the final output includes `DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c` +- removes the temp `codex-home` even when `--keep` is set + +With `--keep`, inspect: + +- `codex-runtime.out` for the final message +- `codex-runtime.jsonl` for `spawn_agent`, `wait`, and child-agent response events +- `project/.codex/agents/code-reviewer.toml` for the generated file Codex loaded + +## 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. + +`codex debug prompt-input` is not enough for custom-agent proof unless it visibly includes the generated agent name or instructions. In local testing it did not expose the custom agent. + +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. diff --git a/skills/dotagents-qa/references/core-smoke.md b/skills/dotagents-qa/references/core-smoke.md new file mode 100644 index 0000000..17be8b9 --- /dev/null +++ b/skills/dotagents-qa/references/core-smoke.md @@ -0,0 +1,32 @@ +# Core Smoke + +Use this reference for ordinary dotagents install/sync QA before drilling into a specific runtime. + +## Checked-In Example + +Run: + +```bash +pnpm smoke:examples +``` + +This builds the local CLI, copies `examples/full/` to a temp project, and verifies: + +- `install`, `list`, `doctor --fix`, and `doctor` +- managed skills under `.agents/skills/` +- Claude/Cursor skill symlink behavior +- MCP files for Claude, Cursor, Codex, and OpenCode +- hook files for Claude and Cursor +- canonical installed subagent under `.agents/agents/` +- generated subagent runtime files for Claude, Cursor, Codex, 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. + +## What This Proves + +This proves dotagents local CLI behavior and generated file placement. It does not prove a third-party runtime actually discovers or executes those generated files unless the runtime-specific proof also runs. + +## When To Customize + +Start from `examples/full/` when a branch changes broad behavior. Create a custom temp fixture only when the example cannot express the changed surface, such as unusual source resolution, user scope, conflict handling, or packaging behavior. diff --git a/skills/dotagents-qa/references/cursor.md b/skills/dotagents-qa/references/cursor.md new file mode 100644 index 0000000..0503885 --- /dev/null +++ b/skills/dotagents-qa/references/cursor.md @@ -0,0 +1,23 @@ +# Cursor QA + +Use this reference when changes affect Cursor skill sharing, `.cursor/mcp.json`, `.cursor/hooks.json`, `.cursor/agents/*.md`, or Cursor user-scope paths. + +## File-Level Checks + +The core smoke asserts: + +- Cursor shares Claude-compatible skills through `.claude/skills` +- `.cursor/mcp.json` exists for Cursor MCP config +- `.cursor/hooks.json` exists for Cursor hooks +- `.cursor/agents/code-reviewer.md` exists +- generated Markdown contains the dotagents managed marker + +Cursor does not get its own `.cursor/skills` symlink in the current expected behavior; it uses Claude-compatible skill placement. + +For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated Cursor subagents under `$HOME/.cursor/agents/`. + +## Runtime Proof + +This QA skill does not currently include an automated Cursor desktop/runtime proof. Do not claim Cursor runtime discovery from file-level checks alone. + +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/opencode.md b/skills/dotagents-qa/references/opencode.md new file mode 100644 index 0000000..4465b07 --- /dev/null +++ b/skills/dotagents-qa/references/opencode.md @@ -0,0 +1,28 @@ +# OpenCode QA + +Use this reference when changes affect OpenCode config generation, `opencode.json`, `.opencode/agents/*.md`, or OpenCode user-scope paths. + +## File-Level Checks + +The core smoke asserts: + +- `opencode.json` exists for OpenCode MCP config +- `.opencode/agents/code-reviewer.md` exists +- generated Markdown contains the dotagents managed marker + +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/`. + +## 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. + +If a branch specifically requires OpenCode runtime proof, use the installed OpenCode CLI/app and report: + +- exact command or interaction +- temp project path +- generated `.opencode/agents/.md` +- evidence that OpenCode loaded or invoked the expected agent + +Otherwise report file-level OpenCode wiring only. diff --git a/specs/SPEC.md b/specs/SPEC.md index 692f142..9771504 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -2,21 +2,21 @@ ## 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, 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, 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 and hook 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. MCP, hook, and subagent 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. ### Why -Agent skills, MCP servers, and hooks are configured differently for every agent tool, and skills are distributed as loose folders copied from git repos. There's no way to declare what you need once and have it work everywhere, or to keep a team's agent setup in sync. dotagents fills this gap. +Agent skills, MCP servers, hooks, and subagents are configured differently for every agent tool, and skills are distributed as loose folders copied from git repos. There's no way to declare what you need once and have it work everywhere, or to keep a team's agent setup in sync. dotagents fills this gap. ### Key Principles - **`.agents/skills/` is the canonical home** for all skills (managed and custom) - **`agents.toml`** declares what you want; **`agents.lock`** tracks what's managed -- **Selective gitignore**: managed skills are gitignored, custom skills are tracked +- **Selective gitignore**: managed skills and canonical installed subagents are gitignored, custom skills 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 +30,7 @@ The manifest file. Lives at the project root. ```toml version = 1 -agents = ["claude", "cursor"] +agents = ["claude", "cursor", "codex", "opencode"] [project] name = "my-project" # Optional. For display purposes. @@ -71,6 +71,11 @@ headers = { Authorization = "Bearer tok" } name = "authed-api" url = "https://${API_HOST}/mcp" headers = { X-Api-Key = "${API_KEY}" } + +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex", "opencode"] ``` ### Fields @@ -86,6 +91,8 @@ headers = { X-Api-Key = "${API_KEY}" } | `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. | | `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_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 `[]`. | @@ -99,7 +106,7 @@ headers = { X-Api-Key = "${API_KEY}" } #### `[trust]` -Optional section to restrict which skill sources are allowed. Useful for teams that want to lock down skill provenance. +Optional section to restrict which skill and subagent sources are allowed. Useful for teams that want to lock down agent dependency provenance. ```toml # Restrictive: only allow specific sources @@ -128,7 +135,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 both `dotagents add` and `dotagents install`. +Trust is checked before any network work in `dotagents add` for skills and `dotagents install` for configured skills and subagents. #### `[project]` @@ -186,15 +193,53 @@ Hook declarations. Each entry defines a hook that dotagents will configure for a | `matcher` | No | Tool name to match (e.g. `Bash`). Only for `PreToolUse` and `PostToolUse`. | | `command` | Yes | Shell command to execute when the hook fires. | +#### `[[subagents]]` + +Custom subagent dependencies. Each entry selects one subagent artifact from a source. dotagents imports the artifact into a portable subagent declaration, installs canonical managed Markdown into `.agents/agents/`, and writes generated runtime files for agents listed in `agents` that support custom subagents: Claude, Cursor, Codex, and OpenCode. + +Subagents are best-effort portable dependencies, not a universal behavior schema. dotagents preserves native artifacts for their matching runtime and converts only from the portable `name`, `description`, and instructions when the target runtime is different. + +See [Subagents Specification](subagents.md) for discovery order, native input/output formats, naming rules, merge behavior, and non-goals. + +```md +--- +name: code-reviewer +description: Review code for correctness, security, and missing tests. +--- + +Review the current diff and return findings with file references. +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Subagent name to discover. Must start with lowercase `a-z` and contain only lowercase letters, numbers, and hyphens. | +| `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 subagents. | +| `ref` | No | Optional git ref override. | +| `path` | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are treated as Codex native artifacts. | +| `targets` | No | Optional subset of agent IDs. When absent or empty, defaults to every configured agent in `agents`; unsupported agents produce warnings. | + +dotagents intentionally does not standardize runtime-specific subagent behavior such as model routing, tool permissions, read-only modes, background execution, or reasoning effort. Those controls differ across runtimes and should stay in each tool's native config until there is a maintainable common contract. + +Installed and generated files are marked as dotagents-managed with a generated 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. + +Generated paths: + +| Agent | Project Scope | User Scope | Format | +|-------|---------------|------------|--------| +| Claude Code | `.claude/agents/.md` | `~/.claude/agents/.md` | Markdown with YAML frontmatter | +| Cursor | `.cursor/agents/.md` | `~/.cursor/agents/.md` | Markdown with YAML frontmatter | +| Codex | `.codex/agents/.toml` | `~/.codex/agents/.toml` | TOML | +| OpenCode | `.opencode/agents/.md` | `~/.config/opencode/agents/.md` | Markdown with YAML frontmatter | + #### Supported Agents -| ID | Tool | Config Dir | MCP File | MCP Format | -|----|------|-----------|----------|------------| -| `claude` | Claude Code | `.claude` | `.mcp.json` | JSON | -| `cursor` | Cursor | `.cursor` | `.cursor/mcp.json` | JSON | -| `codex` | Codex | `.codex` | `.codex/config.toml` | TOML (shared) | -| `vscode` | VS Code Copilot | `.vscode` | `.vscode/mcp.json` | JSON | -| `opencode` | OpenCode | `.claude` | `opencode.json` | JSON (shared) | +| ID | Tool | Config Dir | MCP File | MCP Format | Subagents | +|----|------|-----------|----------|------------|-----------| +| `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` | +| `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`. @@ -325,6 +370,15 @@ resolved_url = "https://cli.sentry.dev" [skills.my-custom-skill] source = "path:../shared-skills/my-custom-skill" + +[subagents.code-reviewer] +source = "getsentry/agent-pack" +resolved_url = "https://github.com/getsentry/agent-pack.git" +resolved_path = "agents/code-reviewer.md" +resolved_commit = "fedcba9876543210fedcba9876543210fedcba98" + +[subagents.local-reviewer] +source = "path:../shared-agents" ``` ### Fields per skill @@ -337,6 +391,18 @@ source = "path:../shared-skills/my-custom-skill" | `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** — not used for resolution. The lockfile is not checked in, so this field must never be relied on for locking behavior. | +### Fields per subagent + +Subagent lock entries use the same source-resolution fields under `[subagents.]`. Git subagents record the resolved clone URL, discovered or explicit subagent file path, optional ref, and installed commit. Local `path:` subagents 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 | File path within the repo where the subagent 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 @@ -379,12 +445,15 @@ 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` +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. Print summary +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 ### `dotagents add ` @@ -468,6 +537,7 @@ dotagents sync 6. Create/verify/repair symlinks 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 ### `dotagents mcp` @@ -597,18 +667,19 @@ 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 -- `.agents/.gitignore` — excludes managed skill directories from git +- `agents.lock` — tracks managed skills and subagents +- `.agents/.gitignore` — excludes managed skill directories and canonical installed subagent files from git ### How It Works -Managed (external) skills are gitignored. Custom (local) skills are tracked. dotagents generates `.agents/.gitignore` listing every managed skill: +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: ```gitignore # Auto-generated by dotagents. Do not edit. -# Managed skills (installed by dotagents) +# Managed artifacts (installed by dotagents) /skills/find-bugs/ /skills/warden-skill/ +/agents/code-reviewer.md ``` Custom skills in `.agents/skills/my-local-skill/` are NOT listed, so git tracks them normally. @@ -638,21 +709,20 @@ Custom skills in `.agents/skills/my-local-skill/` are NOT listed, so git tracks Each agent tool has its own directory with tool-specific files: - `.claude/` -- `settings.json`, `commands/`, `skills/` -- `.cursor/` -- `rules/`, `skills/` -- `.codex/` -- `skills/` +- `.cursor/` -- `rules/`, MCP, hooks, agents +- `.codex/` -- config, agents Symlinking the entire directory (e.g., `.claude/ -> .agents/`) would clobber tool-specific files. ### Solution -Symlink only the `skills/` subdirectory: +Symlink only the `skills/` subdirectory for agents that need one. Cursor uses Claude-compatible skills, so it shares `.claude/skills/`: ``` .claude/skills/ -> .agents/skills/ -.cursor/skills/ -> .agents/skills/ ``` -`.agents/skills/` is the canonical home. Each tool's `skills/` directory is a symlink. +`.agents/skills/` is the canonical home. Legacy `[symlinks]` targets can still create additional `/skills/` symlinks when explicitly configured. ### Configuration @@ -679,7 +749,7 @@ dotagents/ AGENTS.md # Agent instructions CLAUDE.md -> AGENTS.md # Symlink agents.toml # Self-dogfooding - agents.lock # Tracks managed skills (gitignored) + agents.lock # Tracks managed skills and subagents (gitignored) warden.toml # Warden config for code analysis package.json # pnpm workspace root pnpm-workspace.yaml diff --git a/specs/subagents.md b/specs/subagents.md new file mode 100644 index 0000000..d06f0fd --- /dev/null +++ b/specs/subagents.md @@ -0,0 +1,118 @@ +# Subagents Specification + +Subagents are best-effort portable dependencies. dotagents should let teams declare a subagent once, preserve native runtime artifacts when they already exist, and generate reasonable files for other supported runtimes from a small portable projection. + +This is not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort belongs in the runtime's native artifact. + +## Configuration + +Subagents are declared in `agents.toml`: + +```toml +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex"] +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Portable dotagents ID. Must start with lowercase `a-z` and contain only lowercase letters, numbers, and hyphens. | +| `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 subagents. | +| `ref` | No | Optional git ref override. | +| `path` | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are Codex native artifacts. | +| `targets` | No | Optional subset of agent IDs. When absent or empty, defaults to every configured agent in `agents`; unsupported agents produce warnings. | + +## Portable Projection + +Every imported subagent must produce this portable shape: + +| Field | Meaning | +|-------|---------| +| `name` | dotagents portable ID from `agents.toml`, filename, or portable source metadata | +| `description` | short description used by runtimes that support it | +| `instructions` | prompt/instructions body used when converting to another runtime | +| `native` | optional raw native source content keyed by runtime | + +Only `name`, `description`, and `instructions` are portable. Native-only fields are preserved for the matching runtime but are not standardized or converted. + +## Source and Output Formats + +Input and matching-runtime output use the same format. dotagents adds only its managed-file marker when writing a native artifact back to its own runtime. + +| Format | Source Discovery | Matching Runtime Output | Required Source Fields | Portable Projection | +|--------|------------------|-------------------------|------------------------|---------------------| +| Portable Markdown | `agents/*.md`, `.agents/agents/*.md` | `.agents/agents/.md` canonical install file | YAML `name`, `description`; Markdown body | `name`, `description`, body instructions | +| Claude Markdown | `.claude/agents/*.md` | `.claude/agents/.md` | YAML `name`, `description`; Markdown body | `name`, `description`, body instructions | +| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `description`; Markdown body; `name` optional | YAML `name` or filename; `description`; body instructions | +| Codex TOML | `.codex/agents/*.toml` | `.codex/agents/.toml` | TOML `name`, `description`, `developer_instructions` | dotagents ID from `agents.toml` or filename when native `name` is not portable; `description`; `developer_instructions` | +| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body | filename; `description`; body instructions | + +Portable Markdown example: + +```md +--- +name: code-reviewer +description: Review code for correctness, security, and missing tests. +--- + +Review the current diff and return findings with file references. +``` + +Codex TOML example: + +```toml +name = "code_reviewer" +description = "Review code for correctness, security, and missing tests." +developer_instructions = "Review the current diff and return findings with file references." +sandbox_mode = "read-only" +``` + +When installing the Codex example to Codex, dotagents preserves `name = "code_reviewer"` and `sandbox_mode = "read-only"`. When installing it to Claude or Cursor, dotagents generates Markdown from the portable projection and does not attempt to convert `sandbox_mode`. + +## Discovery + +Without an explicit `path`, dotagents scans source directories in this order: + +1. `agents/**/*.md` +2. `.agents/agents/**/*.md` +3. `.claude/agents/**/*.md` +4. `.cursor/agents/**/*.md` +5. `.codex/agents/**/*.toml` +6. `.opencode/agents/**/*.md` + +Within each scanned directory, filename matches for `agents.toml` `name` take precedence over metadata-only matches. Multiple filename or metadata matches in one scanned directory are rejected as ambiguous. Multiple portable matches across portable scan directories are also rejected, because dotagents cannot safely choose between competing portable instructions. Matching artifacts from multiple runtimes are merged into one subagent declaration so native Claude and native Codex sources for the same portable ID can both be preserved. + +If `path` is set, dotagents imports only that file. Root-level Markdown files are not discovered implicitly; use `path` when a source stores a subagent at the source root. A `.toml` path is treated as Codex native. Markdown under `.claude/agents/`, `.cursor/agents/`, or `.opencode/agents/` is treated as native for that runtime; other Markdown is treated as portable. + +## Naming + +The `agents.toml` `name` is the portable dotagents ID and controls generated filenames. + +Runtime native names may differ when the runtime allows a different naming convention. Codex commonly uses underscores, so `name = "code_reviewer"` in native TOML can map to portable dotagents ID `code-reviewer` when the file is selected by filename or explicit config. The Codex TOML name is preserved when writing Codex output. + +Cursor native Markdown may omit `name`; dotagents uses the filename only when `name` is absent. OpenCode native Markdown always uses the filename as the portable name because OpenCode treats the Markdown filename as the agent name. + +## Install and Sync + +Install writes two layers: + +1. Canonical installed Markdown in `.agents/agents/.md` +2. Runtime output for each configured target that supports subagents + +The canonical installed Markdown stores the portable projection plus raw native overlays so `sync` can regenerate matching native outputs without network access. + +Generated runtime paths: + +| Agent | Project Scope | User Scope | Format | +|-------|---------------|------------|--------| +| Claude Code | `.claude/agents/.md` | `~/.claude/agents/.md` | Markdown with YAML frontmatter | +| Cursor | `.cursor/agents/.md` | `~/.cursor/agents/.md` | Markdown with YAML frontmatter | +| Codex | `.codex/agents/.toml` | `~/.codex/agents/.toml` | TOML | +| OpenCode | `.opencode/agents/.md` | `~/.config/opencode/agents/.md` | Markdown with YAML frontmatter | + +Installed and generated files are marked as dotagents-managed with a generated 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. For runtimes whose agent identity can differ from the filename, dotagents also avoids writing a managed file when an unmanaged file in the same runtime directory already declares the same runtime identity. + +## Non-goals + +dotagents does not standardize or enforce runtime-specific agent behavior. In particular, dotagents should not add portable config for model selection, permissions, sandboxing, background execution, reasoning effort, hooks, or other runtime-specific controls unless there is a maintainable common contract across runtimes.