diff --git a/README.md b/README.md index 877c42b6..7b554bfc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Shared tooling for coding agents. Declare skills, MCP servers, hooks, subagents, **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, Grok, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, hooks, subagents, and plugins where supported. Pi reads `.agents/skills/` directly. +**Multi-agent.** Configure Claude, Cursor, Codex, GitHub Copilot CLI, Grok, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, hooks, subagents, and plugins where supported. Pi reads `.agents/skills/` directly. ## Quick Start: Global by Default @@ -115,7 +115,7 @@ Shorthand (`owner/repo`) resolves to GitHub by default. Set `defaultRepositorySo The `agents` field tells dotagents which tools to configure: ```toml -agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` | Agent | Config Dir | MCP Config | Hooks | Subagents | @@ -123,6 +123,7 @@ agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] | `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` | +| `copilot` | `.copilot` | `.mcp.json` or `.github/mcp.json` | -- | -- | | `grok` | `.grok` | -- | -- | -- | | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | -- | | `opencode` | `.opencode` | `.opencode/opencode.jsonc` | -- | `.opencode/agents/*.md` | @@ -153,19 +154,19 @@ dotagents can also import native runtime subagent files from `.claude/agents/`, OpenCode reuses an existing project config from `.opencode/opencode.jsonc`, `.opencode/opencode.json`, `opencode.jsonc`, or `opencode.json`, in that order. New projects use `.opencode/opencode.jsonc`. -Plugins are declared with `[[plugins]]` entries. In project scope, dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.claude-plugin/marketplace.json`, `.agents/plugins//.claude-plugin/plugin.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.cursor-plugin/plugin.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/plugins//`, `.opencode/skills//`, OpenCode MCP entries, and Pi skill links under `.agents/skills//` where supported. During legacy migration, generalized bundles can also project Markdown agents into `.opencode/agents/`; standard extension agents are preserved but are not projected yet: +Plugins are declared with `[[plugins]]` entries. In project scope, dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.claude-plugin/marketplace.json`, `.github/plugin/marketplace.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, native Claude, Cursor, and Codex manifests, `.grok/plugins//`, `.opencode/skills//`, OpenCode MCP entries, and Pi skill links under `.agents/skills//`. During legacy migration, generalized bundles can also project Markdown agents into `.opencode/agents/`; standard extension agents are preserved but are not projected yet: ```toml [[plugins]] name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" -targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +targets = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` The canonical portable format is an [Agent Plugins](https://agent-plugins.org/) v1 bundle: required `plugin.json`, optional `skills/`, optional `mcp.json`, and reverse-domain client extensions. dotagents preserves those portable source files under `.agents/plugins//` and generates isolated target harnesses. OpenCode receives portable MCP servers under managed keys such as `plugin..`; `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` are expanded into the installed bundle and persistent `.agents/plugin-data/` paths. Generated JSON uses adjacent ownership sidecars, while component symlinks use markers in reserved `.dotagents-managed/` directories, so client-owned JSON remains unchanged. Legacy generalized and native Claude/Cursor/Codex manifests remain discoverable during migration. A valid standard root may also coexist with authored native manifests as a hybrid compatibility bundle: the portable root remains the source of truth, reproducible native manifests are ignored in favor of portable generation, and manifests with behavior an adapter cannot represent are retained byte-for-byte only as matching-client fallbacks. Generated adapters are disposable output and are never imported back into the portable core. Native commands, agents, hooks, MCP, and other resources never leak into unrelated targets. Invalid standard roots still fail instead of falling back to legacy parsing. -Global plugins install canonical bundles under `~/.agents/plugins/`. Claude and Cursor marketplaces are generated under `~/.agents/`, the Codex marketplace is generated at `~/.agents/plugins/marketplace.json`, Grok plugins are copied into `~/.grok/plugins/`, OpenCode skills are linked into `~/.config/opencode/skills/`, portable MCP servers are merged into `~/.config/opencode/opencode.json`, and Pi skills are linked into `~/.agents/skills/`. `--user` remains a compatibility alias for `--global`. +Global plugins install canonical bundles under `~/.agents/plugins/`. Claude and Cursor marketplaces are generated under `~/.agents/`. Copilot uses `~/.agents/.github/plugin/marketplace.json`, and Codex uses `~/.agents/plugins/marketplace.json`. Grok plugins are copied into `~/.grok/plugins/`. OpenCode skills are linked into `~/.config/opencode/skills/`, and portable MCP servers are merged into `~/.config/opencode/opencode.json`. Pi skills are linked into `~/.agents/skills/`. `--user` remains a compatibility alias for `--global`. Pi plugin targets are global skill projections rather than isolated plugin installs: a Pi-targeted plugin skill is added to `.agents/skills/` and is therefore visible to other clients that consume that shared directory. diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 25b13fe4..e255c583 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -2,7 +2,7 @@ > Shared tooling for coding agents -dotagents manages agent skills, MCP servers, hooks, subagents, and plugins declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code, Cursor, Codex, Grok, VS Code, and OpenCode are configured from a single source of truth. +dotagents manages agent skills, MCP servers, hooks, subagents, and plugins declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code, Cursor, Codex, GitHub Copilot CLI, Grok, VS Code, and OpenCode are configured from a single source of truth. Install: `npm install -g @sentry/dotagents` Run without installing: `npx @sentry/dotagents ` @@ -66,7 +66,7 @@ Full example with all sections: ```toml version = 1 -agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -149,7 +149,7 @@ targets = ["claude", "codex", "opencode"] name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" -targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +targets = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` ### Top-level Fields @@ -158,9 +158,9 @@ targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] |-------|------|----------|---------|-------------| | `version` | integer | Yes | -- | Schema version. Always `1`. | | `defaultRepositorySource` | string | No | `github` | Host used for shorthand `owner/repo` skill sources. Valid values: `github`, `gitlab`. | -| `agents` | string[] | No | `[]` | Agent tool IDs: `claude`, `cursor`, `codex`, `grok`, `vscode`, `opencode`, `pi`. Creates symlinks and config files for each where supported. `grok` and `pi` are plugin-only targets. | +| `agents` | string[] | No | `[]` | Agent tool IDs: `claude`, `cursor`, `codex`, `copilot`, `grok`, `vscode`, `opencode`, `pi`. Creates symlinks and config files for each where supported. `grok` and `pi` are plugin-only targets. | | `subagents` | table[] | No | `[]` | Custom subagent declarations. Generates runtime-specific files for Claude, Cursor, Codex, and OpenCode. | -| `plugins` | table[] | No | `[]` | Plugin declarations. Installs canonical bundles into `.agents/plugins/` and generates runtime plugin outputs for Claude, Cursor, Codex, Grok, OpenCode, and Pi skill projection where supported. | +| `plugins` | table[] | No | `[]` | Plugin declarations. Installs canonical bundles into `.agents/plugins/` and generates runtime plugin outputs for Claude, Cursor, Codex, Copilot, Grok, OpenCode, and Pi skill projection where supported. | | `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install. | | `minimum_release_age_exclude` | string[] | No | `[]` | Sources that bypass the minimum release age gate. Supports org names, `org/repo`, and `org/*`. | @@ -211,7 +211,7 @@ Each `[[mcp]]` entry requires `name` and either `command` (stdio) or `url` (Stre | `headers` | table | No | HTTP headers (url servers only). Supports `${VAR}` syntax for env var interpolation. | | `env` | string[] | No | Environment variable names to pass through | -Use `${VAR}` in header values and `url` to reference secrets from the environment. Write `${VAR}` in `agents.toml` — dotagents translates it to each agent's native syntax when generating config files. Claude keeps `${VAR}`, Cursor and VS Code use `${env:VAR}`, OpenCode uses `{env:VAR}`, and Codex splits pure refs into a separate `env_http_headers` field (mixed values like `"Bearer ${TOKEN}"` stay as literals). +Use `${VAR}` in header values and `url` to reference secrets from the environment. Write `${VAR}` in `agents.toml`. Dotagents keeps this syntax for Claude and GitHub Copilot. Cursor and VS Code use `${env:VAR}`, OpenCode uses `{env:VAR}`, and Codex moves pure references to `env_http_headers`. Mixed Codex values such as `"Bearer ${TOKEN}"` stay as literals. Config files generated per agent: - Claude: `.mcp.json` (JSON) @@ -219,6 +219,9 @@ Config files generated per agent: - Codex: `.codex/config.toml` (TOML, shared with other Codex config) - VS Code: `.vscode/mcp.json` (JSON) - OpenCode: `.opencode/opencode.jsonc` by default (JSONC, shared). Existing `.opencode/opencode.json`, `opencode.jsonc`, or `opencode.json` files are reused in precedence order. +- GitHub Copilot: `.mcp.json` by default (JSON). An existing `.github/mcp.json` is reused when `.mcp.json` is absent. + +Copilot accepts both bare server maps and `mcpServers` documents. Global MCP uses `$COPILOT_HOME/mcp-config.json` (default `~/.copilot/mcp-config.json`). ### Hooks @@ -303,15 +306,16 @@ dotagents installs canonical plugin bundles under `.agents/plugins//`. New Generated project-scope plugin outputs: - Claude: `.claude-plugin/marketplace.json` and `.agents/plugins//.claude-plugin/plugin.json` +- GitHub Copilot: `.github/plugin/marketplace.json`; Copilot consumes the canonical `.agents/plugins//plugin.json` - Cursor: `.cursor-plugin/marketplace.json` and `.agents/plugins//.cursor-plugin/plugin.json` - Codex: `.agents/plugins/marketplace.json` and `.agents/plugins//.codex-plugin/plugin.json` - Grok: `.grok/plugins//` managed copy - OpenCode: plugin `skills/` symlinked into `.opencode/skills/`; portable `mcp.json` servers merged into `.opencode/opencode.jsonc` under `plugin..` keys; generalized legacy plugin Markdown `agents/` symlinked into `.opencode/agents/`. Standard extension agents are preserved but not projected yet. - Pi: plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target -Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated marketplaces and Claude/Cursor/Codex manifests use adjacent `.dotagents-managed` sidecars so client-owned JSON remains schema-native; legacy `metadata.managedBy` output remains recognizable during migration. Managed Grok copies and OpenCode/Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Existing plugin install destinations are overwritten only when their on-disk `.dotagents-managed` marker proves ownership. +Generated plugin JSON is deterministic: object keys and plugin entries are sorted, output is two-space indented, and files end with one trailing newline. Generated marketplaces and Claude, Cursor, and Codex manifests use adjacent `.dotagents-managed` sidecars so client-owned JSON remains schema-native; legacy `metadata.managedBy` output remains recognizable during migration. Managed Grok copies and OpenCode and Pi component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Existing plugin install destinations are overwritten only when their on-disk `.dotagents-managed` marker proves ownership. -Global plugins install under `~/.agents/plugins/`. Claude and Cursor marketplaces are generated below `~/.agents/`, Codex uses `~/.agents/plugins/marketplace.json` with paths rooted at the user's home, Grok plugins are copied into `~/.grok/plugins/`, OpenCode skills use `~/.config/opencode/skills/`, portable plugin MCP servers are merged into `~/.config/opencode/opencode.json`, and Pi skill projections use `~/.agents/skills/`. +Global plugins install under `~/.agents/plugins/`. Claude and Cursor marketplaces are generated below `~/.agents/`. Copilot uses `~/.agents/.github/plugin/marketplace.json`. Codex uses `~/.agents/plugins/marketplace.json` with paths rooted at the user's home. Grok plugins are copied into `~/.grok/plugins/`. OpenCode skills use `~/.config/opencode/skills/`, portable plugin MCP servers use `~/.config/opencode/opencode.json`, and Pi skill projections use `~/.agents/skills/`. ### Trust @@ -499,6 +503,7 @@ Check selected-scope health: gitignore setup where applicable, installed skills | `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` | +| `copilot` | GitHub Copilot CLI | `.copilot` | Project: reads `.agents/skills/`; global: `$COPILOT_HOME/skills/` symlink | `.mcp.json` or `.github/mcp.json` | Not supported | Not supported | | `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/opencode.jsonc` by default | Not supported | `.opencode/agents/*.md` | @@ -609,6 +614,7 @@ Location: `~/.local/dotagents/` (override: `DOTAGENTS_STATE_DIR`) |----------|-------------| | `DOTAGENTS_STATE_DIR` | Override cache location (default: `~/.local/dotagents`) | | `DOTAGENTS_HOME` | Override global-scope location (default: `~/.agents`) | +| `COPILOT_HOME` | Override Copilot's global skill and MCP location with a non-empty absolute path (default when unset: `~/.copilot`) | ## Gitignore diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 1b68ae56..44fe30b7 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -43,7 +43,7 @@ agents can discover CLI guidance. It then runs `install` best-effort. Options: - `--agents ` comma-separated agent targets (`claude`, `cursor`, `codex`, - `vscode`, `opencode`, `grok`, `pi`). + `copilot`, `vscode`, `opencode`, `grok`, `pi`). - `--force` overwrites existing `agents.toml`. Examples: @@ -371,9 +371,9 @@ Status output: | Field | Type | Default | Description | | --- | --- | --- | --- | | `version` | integer | -- | Schema version. Always `1`. | -| `agents` | string[] | `[]` | Agent targets: `claude`, `cursor`, `codex`, `grok`, `vscode`, `opencode`, `pi`. `grok` and `pi` are plugin-only targets. | +| `agents` | string[] | `[]` | Agent targets: `claude`, `cursor`, `codex`, `copilot`, `grok`, `vscode`, `opencode`, `pi`. `grok` and `pi` are plugin-only targets. | | `subagents` | table[] | `[]` | Custom subagent declarations for Claude, Cursor, Codex, and OpenCode | -| `plugins` | table[] | `[]` | Plugin declarations for Claude, Cursor, Codex, Grok, OpenCode, and Pi. | +| `plugins` | table[] | `[]` | Plugin declarations for Claude, Cursor, Codex, Copilot, Grok, OpenCode, and Pi. | | `minimum_release_age` | integer | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin 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`. | @@ -399,6 +399,9 @@ Status output: | `headers` | table | No | HTTP headers for URL servers, not needed with OAuth | | `env` | string[] | No | Environment variable names to pass through | +Copilot MCP uses `.mcp.json` or an existing `.github/mcp.json` in project scope +and `$COPILOT_HOME/mcp-config.json` (default `~/.copilot/mcp-config.json`) globally. + ### Hooks | Field | Type | Required | Description | @@ -482,16 +485,7 @@ reinterpreting generated adapters as source input. | `path` | string | No | Optional explicit plugin path inside the source. | | `targets` | string[] | No | Optional subset of configured agents. | -Generated plugin outputs include Claude/Cursor/Codex marketplaces and native -manifests, Grok plugin directories, OpenCode skill links and portable MCP -entries, and Pi skill links. OpenCode MCP keys use -`plugin..`; dotagents expands `${PLUGIN_ROOT}` and -`${PLUGIN_DATA}` and preserves unrelated entries in the shared config. -Generalized legacy bundles can also project Markdown agents into OpenCode; -standard extension agents are preserved but not projected yet. dotagents rejects -plugin sources that resolve to the same project's -`.agents/plugins//` install destination. Global plugins use -`~/.agents/plugins//` and global harness projections. +Generated plugin outputs include Claude, Cursor, Codex, and Copilot marketplaces, native Claude, Cursor, and Codex manifests, Grok plugin directories, OpenCode skill links and portable MCP entries, and Pi skill links. OpenCode MCP keys use `plugin..`; dotagents expands `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` and preserves unrelated entries in the shared config. Generalized legacy bundles can also project Markdown agents into OpenCode; standard extension agents are preserved but not projected yet. dotagents rejects plugin sources that resolve to the same project's `.agents/plugins//` install destination. Global plugins use `~/.agents/plugins//` and global harness projections. ## Scopes @@ -507,11 +501,9 @@ dotagents add getsentry/agent-plugins review-tools dotagents install ``` -Global plugins install into `~/.agents/plugins/`. Claude and Cursor marketplaces -are generated below `~/.agents/`, Codex uses `~/.agents/plugins/marketplace.json`, -Grok uses `~/.grok/plugins/`, OpenCode skills use `~/.config/opencode/skills/` -and plugin MCP entries use `~/.config/opencode/opencode.json`, and Pi skills use -`~/.agents/skills/`. +Global plugins install into `~/.agents/plugins/`. Claude and Cursor marketplaces are generated below `~/.agents/`. Copilot uses `~/.agents/.github/plugin/marketplace.json`, and Codex uses `~/.agents/plugins/marketplace.json`. Grok uses `~/.grok/plugins/`. OpenCode skills use `~/.config/opencode/skills/`, and plugin MCP entries use `~/.config/opencode/opencode.json`. Pi skills use `~/.agents/skills/`. + +Register the selected scope root with Copilot by running `copilot plugin marketplace add `. Then run `copilot plugin install @dotagents`. ### Project Scope (`--project`) @@ -531,3 +523,4 @@ dotagents --project install | --- | --- | | `DOTAGENTS_STATE_DIR` | Override cache location. Default: `~/.local/dotagents`. | | `DOTAGENTS_HOME` | Override global-scope location. Default: `~/.agents`. | +| `COPILOT_HOME` | Override Copilot's global skills and MCP location with a non-empty absolute path. Default when unset: `~/.copilot`. | diff --git a/docs/src/content/docs/guide.mdx b/docs/src/content/docs/guide.mdx index 82658fa6..85423576 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -146,8 +146,8 @@ version. ## Global Paths Global config, skills, plugins, and the lockfile live under `~/.agents/`. Plugin -outputs are written to the global Claude, Cursor, Codex, Grok, OpenCode, and Pi -locations. Override the dotagents root with `DOTAGENTS_HOME`. +outputs are written to the global Claude, Cursor, Codex, Copilot, Grok, +OpenCode, and Pi locations. Override the dotagents root with `DOTAGENTS_HOME`. `--global` selects global scope explicitly. `--user` remains a compatibility alias. @@ -160,7 +160,7 @@ alias. ```toml version = 1 -agents = ["claude", "cursor", "codex", "opencode"] +agents = ["claude", "cursor", "codex", "copilot", "opencode"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -217,7 +217,7 @@ targets = ["claude", "codex", "opencode"] [[plugins]] name = "review-tools" source = "getsentry/agent-pack" -targets = ["claude", "cursor", "codex", "opencode"] +targets = ["claude", "cursor", "codex", "copilot", "opencode"] ``` Portable Agent Plugins may coexist with authored Claude, Cursor, or Codex diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 3d620f22..a7c56bfc 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -18,7 +18,7 @@ hero: agents.toml
{`version = 1
-agents = ["claude", "cursor", "codex", "vscode", "opencode"]
+agents = ["claude", "cursor", "codex", "copilot", "vscode", "opencode"]
 
 [trust]
 github_orgs = ["getsentry"]
@@ -40,7 +40,7 @@ targets = ["claude", "codex", "opencode"]
 name = "review-tools"
 source = "getsentry/agent-plugins"
 path = "plugins/review-tools"
-targets = ["claude", "cursor", "codex", "opencode"]`}
+targets = ["claude", "cursor", "codex", "copilot", "opencode"]`} @@ -105,6 +105,7 @@ targets = ["claude", "cursor", "codex", "opencode"]`} | `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` | `.claude/agents/*.md` | | `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` | `.cursor/agents/*.md` | | `codex` | `.codex` | `.codex/config.toml` | None | `.codex/agents/*.toml` | + | `copilot` | `.copilot` | `.mcp.json` or `.github/mcp.json` | None | None | | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | None | | `opencode` | `.opencode` | `.opencode/opencode.jsonc` | None | `.opencode/agents/*.md` | diff --git a/examples/full/agents.toml b/examples/full/agents.toml index a5d50677..fcc2e21f 100644 --- a/examples/full/agents.toml +++ b/examples/full/agents.toml @@ -1,5 +1,5 @@ version = 1 -agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] [[skills]] name = "review" diff --git a/examples/full/local-plugins/qa-tools/mcp.json b/examples/full/local-plugins/qa-tools/mcp.json index 6549000e..181b116c 100644 --- a/examples/full/local-plugins/qa-tools/mcp.json +++ b/examples/full/local-plugins/qa-tools/mcp.json @@ -4,7 +4,7 @@ "fixture-stdio": { "type": "stdio", "command": "node", - "args": ["${PLUGIN_ROOT}/server.mjs"], + "args": ["${PLUGIN_ROOT}/runtime/server.mjs"], "env": { "FIXTURE_CACHE": "${PLUGIN_DATA}/cache" } diff --git a/examples/full/local-plugins/qa-tools/server.mjs b/examples/full/local-plugins/qa-tools/runtime/server.mjs similarity index 100% rename from examples/full/local-plugins/qa-tools/server.mjs rename to examples/full/local-plugins/qa-tools/runtime/server.mjs diff --git a/packages/dotagents/src/cli/commands/install-user.test.ts b/packages/dotagents/src/cli/commands/install-user.test.ts index 1c4ee71f..a75812bf 100644 --- a/packages/dotagents/src/cli/commands/install-user.test.ts +++ b/packages/dotagents/src/cli/commands/install-user.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -import { mkdtemp, mkdir, readFile, readlink, rm, writeFile, lstat } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readlink, rm, writeFile, lstat, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; @@ -17,6 +17,7 @@ describe("runInstall user scope", () => { const previousHome = process.env["HOME"]; const previousDotagentsHome = process.env["DOTAGENTS_HOME"]; const previousStateDir = process.env["DOTAGENTS_STATE_DIR"]; + const previousCopilotHome = process.env["COPILOT_HOME"]; afterEach(async () => { if (previousHome === undefined) { @@ -34,6 +35,11 @@ describe("runInstall user scope", () => { } else { process.env["DOTAGENTS_STATE_DIR"] = previousStateDir; } + if (previousCopilotHome === undefined) { + delete process.env["COPILOT_HOME"]; + } else { + process.env["COPILOT_HOME"] = previousCopilotHome; + } vi.resetModules(); if (tmpDir) { @@ -47,11 +53,13 @@ describe("runInstall user scope", () => { const homeDir = join(tmpDir, "home"); const dotagentsHome = join(tmpDir, "agents"); const stateDir = join(tmpDir, "state"); + const copilotHome = join(tmpDir, "copilot"); const sourceDir = join(dotagentsHome, "skill-source", "pdf"); process.env["HOME"] = homeDir; process.env["DOTAGENTS_HOME"] = dotagentsHome; process.env["DOTAGENTS_STATE_DIR"] = stateDir; + process.env["COPILOT_HOME"] = copilotHome; vi.resetModules(); const [{ runInstall }, { resolveScope }, { loadLockfile }] = await Promise.all([ @@ -62,6 +70,7 @@ describe("runInstall user scope", () => { await mkdir(sourceDir, { recursive: true }); await mkdir(homeDir, { recursive: true }); + await mkdir(copilotHome, { recursive: true }); await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD); await writeFile( join(homeDir, ".claude.json"), @@ -73,12 +82,16 @@ describe("runInstall user scope", () => { }, }), ); + await writeFile( + join(copilotHome, "mcp-config.json"), + JSON.stringify({ mcpServers: { manual: { command: "manual" } } }), + ); const scope = resolveScope("user"); await mkdir(scope.root, { recursive: true }); await writeFile( scope.configPath, `version = 1 -agents = ["claude"] +agents = ["claude", "copilot"] [[skills]] name = "pdf" @@ -98,10 +111,14 @@ args = ["server.js"] expect(await readFile(join(scope.skillsDir, "pdf", "SKILL.md"), "utf-8")).toBe(SKILL_MD); const skillsLink = join(homeDir, ".claude", "skills"); - const stat = await lstat(skillsLink); - expect(stat.isSymbolicLink()).toBe(true); + const skillsLinkStat = await lstat(skillsLink); + expect(skillsLinkStat.isSymbolicLink()).toBe(true); expect(await readlink(skillsLink)).toBe(relative(join(homeDir, ".claude"), scope.skillsDir)); + const copilotSkillsLink = join(copilotHome, "skills"); + expect((await lstat(copilotSkillsLink)).isSymbolicLink()).toBe(true); + expect(await readlink(copilotSkillsLink)).toBe(relative(copilotHome, scope.skillsDir)); + expect(JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"))).toEqual({ theme: "dark", mcpServers: { @@ -109,13 +126,22 @@ args = ["server.js"] fixture: { command: "node", args: ["server.js"] }, }, }); + expect(JSON.parse(await readFile(join(copilotHome, "mcp-config.json"), "utf-8"))).toEqual({ + mcpServers: { + manual: { command: "manual" }, + fixture: { command: "node", args: ["server.js"] }, + }, + }); + if (process.platform !== "win32") { + expect((await stat(join(copilotHome, "mcp-config.json"))).mode & 0o777).toBe(0o600); + } const mcpPath = join(homeDir, ".claude.json"); const beforeEmptyInstall = await readFile(mcpPath, "utf-8"); await writeFile( scope.configPath, `version = 1 -agents = ["claude"] +agents = ["claude", "copilot"] [[skills]] name = "pdf" diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index bee63198..8ad665dd 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -316,7 +316,7 @@ source = "path:plugin-source/review-tools" expect(agentsGitignore).toContain("/plugins/review-tools/"); }); - it.each(["claude", "cursor", "codex", "grok", "opencode", "pi"] as const)( + it.each(["claude", "copilot", "cursor", "codex", "grok", "opencode", "pi"] as const)( "compiles an Agent Plugins v1 bundle into the complete %s harness", async (target) => { const sourceDir = join(projectRoot, "plugin-source", "portable-tools"); @@ -417,8 +417,12 @@ source = "path:plugin-source/portable-tools" if (target === "codex") {addNativeManifest(".codex-plugin", codexManifest);} await expectHarnessFiles(projectRoot, [".agents/plugins/portable-tools"], canonicalFiles); - if (target === "claude" || target === "cursor") { - const marketplaceDir = target === "claude" ? ".claude-plugin" : ".cursor-plugin"; + if (target === "claude" || target === "copilot" || target === "cursor") { + const marketplaceDir = target === "claude" + ? ".claude-plugin" + : target === "copilot" + ? ".github/plugin" + : ".cursor-plugin"; await expectHarnessFiles(projectRoot, [marketplaceDir], { [`${marketplaceDir}/marketplace.json`]: { json: { description: "Generated by dotagents", @@ -477,6 +481,7 @@ source = "path:plugin-source/portable-tools" const targetOutputPaths = { claude: [".claude-plugin"], + copilot: [".github/plugin"], cursor: [".cursor-plugin"], codex: [".agents/plugins/marketplace.json"], grok: [".grok/plugins/portable-tools"], @@ -645,6 +650,41 @@ source = "path:plugin-source/invalid-tools" expect(existsSync(join(projectRoot, ".claude-plugin", "marketplace.json"))).toBe(false); }); + it("preflights a Copilot manifest shadow before canonical or lockfile mutations", async () => { + const sourceDir = join(projectRoot, "plugin-source", "portable-tools"); + await mkdir(join(sourceDir, ".plugin"), { recursive: true }); + await writeFile(join(sourceDir, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "portable-tools", + })); + await writeFile(join(sourceDir, ".plugin", "plugin.json"), JSON.stringify({ + name: "portable-tools", + })); + const originalLock: Lockfile = { + version: 1, + skills: {}, + subagents: {}, + plugins: { previous: { source: "path:previous" } }, + }; + await writeLockfile(join(projectRoot, "agents.lock"), originalLock); + await writeFile(join(projectRoot, "agents.toml"), `version = 1 +agents = ["copilot"] + +[[plugins]] +name = "portable-tools" +source = "path:plugin-source/portable-tools" +`); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow( + ".plugin/plugin.json would shadow the canonical plugin.json for Copilot", + ); + + expect(existsSync(join(scope.pluginsDir, "portable-tools"))).toBe(false); + expect(await loadLockfile(scope.lockPath)).toEqual(originalLock); + expect(existsSync(join(projectRoot, ".github", "plugin", "marketplace.json"))).toBe(false); + }); + it("preserves and warns about a malformed unselected native interface", async () => { const sourceDir = join(projectRoot, "plugin-source", "hybrid-tools"); await mkdir(join(sourceDir, ".claude-plugin"), { recursive: true }); @@ -1401,8 +1441,14 @@ source = "path:./.agents/plugins/local-tools/source" const userHome = join(tmpDir, "home"); process.env["DOTAGENTS_HOME"] = dotagentsHome; process.env["HOME"] = userHome; + vi.resetModules(); try { - const scope = resolveScope("user"); + const [{ runInstall: runUserInstall }, { resolveScope: resolveUserScope }] = + await Promise.all([ + import("./install.js"), + import("../../scope.js"), + ]); + const scope = resolveUserScope("user"); const sourceDir = join(scope.root, "plugin-source", "review-tools"); await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); await writeFile( @@ -1426,7 +1472,7 @@ source = "path:./.agents/plugins/local-tools/source" await writeFile( scope.configPath, `version = 1 -agents = ["claude", "codex", "opencode", "pi"] +agents = ["claude", "codex", "copilot", "opencode", "pi"] [[plugins]] name = "review-tools" @@ -1434,12 +1480,17 @@ source = "path:plugin-source/review-tools" `, ); - const result = await runInstall({ scope }); + const result = await runUserInstall({ scope }); expect(result.installedPlugins).toEqual(["review-tools"]); expect(existsSync(join(scope.pluginsDir, "review-tools", "plugin.json"))).toBe(true); expect(existsSync(join(scope.root, ".claude-plugin", "marketplace.json"))).toBe(true); + expect(existsSync(join(scope.root, ".github", "plugin", "marketplace.json"))).toBe(true); expect(existsSync(join(scope.root, ".agents", "plugins", "marketplace.json"))).toBe(true); expect(await readlink(join(scope.skillsDir, "review"))).toBe("../plugins/review-tools/skills/review"); + expect((await lstat(join(userHome, ".claude", "skills"))).isSymbolicLink()).toBe(true); + expect(await readlink(join(userHome, ".claude", "skills"))).toBe( + relative(join(userHome, ".claude"), scope.skillsDir), + ); expect(await readlink(join(userHome, ".config", "opencode", "skills", "review"))).toContain( join("user-agents", "plugins", "review-tools", "skills", "review"), ); @@ -1472,6 +1523,7 @@ source = "path:plugin-source/review-tools" } else { process.env["HOME"] = previousOsHome; } + vi.resetModules(); } }); diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index c5fbfaef..9fde6ab8 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -9,6 +9,7 @@ import { loadLockfile } from "../../lockfile/loader.js"; import { loadConfig } from "../../config/loader.js"; import { resolveScope } from "../../scope.js"; import { DOTAGENTS_SUBAGENT_MARKER } from "../../subagents/format.js"; +import { AGENT_PLUGIN_SCHEMA } from "../../plugins/schema.js"; const SKILL_MD = (name: string) => `--- name: ${name} @@ -90,6 +91,33 @@ describe("runSync", () => { expect((await loadConfig(join(projectRoot, "agents.toml"))).skills).toEqual([]); }); + it("reports a Copilot marketplace precedence conflict once", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "review-tools", + })); + await writeFile(join(projectRoot, "marketplace.json"), JSON.stringify({ name: "mine" })); + await writeFile(join(projectRoot, "agents.toml"), `version = 1 +agents = ["copilot"] + +[[plugins]] +name = "review-tools" +source = "getsentry/plugins" +`); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + const conflicts = result.issues.filter( + (issue) => issue.type === "plugins" + && issue.message.includes("higher-priority marketplace exists"), + ); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0]?.message).toContain(join(projectRoot, "marketplace.json")); + expect(existsSync(join(projectRoot, ".github", "plugin", "marketplace.json"))).toBe(false); + }); + it("prunes stale managed skills removed from config instead of re-adopting them", 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 848a2f4d..5319e404 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -377,6 +377,12 @@ export async function runSync(opts: SyncOptions): Promise { }); } for (const issue of pluginIssues) { + const alreadyReported = issues.some( + (candidate) => candidate.type === "plugins" + && candidate.name === issue.name + && candidate.message === issue.issue, + ); + if (alreadyReported) {continue;} issues.push({ type: "plugins", name: issue.name, diff --git a/packages/dotagents/src/config/loader.test.ts b/packages/dotagents/src/config/loader.test.ts index 4c211520..d9c9795a 100644 --- a/packages/dotagents/src/config/loader.test.ts +++ b/packages/dotagents/src/config/loader.test.ts @@ -20,7 +20,7 @@ describe("loadConfig", () => { await writeFile( configPath, `version = 1 -agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] [symlinks] targets = [".legacy"] @@ -53,7 +53,7 @@ targets = ["claude", "codex", "opencode"] [[plugins]] name = "review-tools" source = "getsentry/plugins" -targets = ["claude", "codex", "cursor", "grok", "opencode", "pi"] +targets = ["claude", "codex", "copilot", "cursor", "grok", "opencode", "pi"] `, ); @@ -64,6 +64,7 @@ targets = ["claude", "codex", "cursor", "grok", "opencode", "pi"] "claude", "cursor", "codex", + "copilot", "grok", "opencode", "pi", @@ -101,7 +102,7 @@ targets = ["claude", "codex", "cursor", "grok", "opencode", "pi"] { name: "review-tools", source: "getsentry/plugins", - targets: ["claude", "codex", "cursor", "grok", "opencode", "pi"], + targets: ["claude", "codex", "copilot", "cursor", "grok", "opencode", "pi"], }, ]); }); diff --git a/packages/dotagents/src/plugins/runtime/layout.ts b/packages/dotagents/src/plugins/runtime/layout.ts index 4087f7b0..9b6cf39f 100644 --- a/packages/dotagents/src/plugins/runtime/layout.ts +++ b/packages/dotagents/src/plugins/runtime/layout.ts @@ -6,9 +6,11 @@ import { isString } from "../../utils/type-guards.js"; export interface PluginRuntimeLayout { claudeMarketplaceRoot: string; + copilotMarketplaceRoot: string; cursorMarketplaceRoot: string; codexMarketplaceRoot: string; claudeMarketplacePath: string; + copilotMarketplacePath: string; cursorMarketplacePath: string; codexMarketplacePath: string; canonicalPluginsDir: string; @@ -32,9 +34,11 @@ export function projectPluginRuntimeLayout(root: string): PluginRuntimeLayout { ]; return { claudeMarketplaceRoot: root, + copilotMarketplaceRoot: root, cursorMarketplaceRoot: root, codexMarketplaceRoot: root, claudeMarketplacePath: join(root, ".claude-plugin", "marketplace.json"), + copilotMarketplacePath: join(root, ".github", "plugin", "marketplace.json"), cursorMarketplacePath: join(root, ".cursor-plugin", "marketplace.json"), codexMarketplacePath: join(root, ".agents", "plugins", "marketplace.json"), canonicalPluginsDir: join(root, ".agents", "plugins"), @@ -54,9 +58,11 @@ export function userPluginRuntimeLayout(root: string): PluginRuntimeLayout { const usesDefaultRoot = resolve(root) === resolve(defaultRoot); return { claudeMarketplaceRoot: root, + copilotMarketplaceRoot: root, cursorMarketplaceRoot: root, codexMarketplaceRoot: usesDefaultRoot ? home : root, claudeMarketplacePath: join(root, ".claude-plugin", "marketplace.json"), + copilotMarketplacePath: join(root, ".github", "plugin", "marketplace.json"), cursorMarketplacePath: join(root, ".cursor-plugin", "marketplace.json"), codexMarketplacePath: usesDefaultRoot ? join(root, "plugins", "marketplace.json") diff --git a/packages/dotagents/src/plugins/runtime/marketplace.ts b/packages/dotagents/src/plugins/runtime/marketplace.ts index fb6c1752..48208e2c 100644 --- a/packages/dotagents/src/plugins/runtime/marketplace.ts +++ b/packages/dotagents/src/plugins/runtime/marketplace.ts @@ -1,4 +1,5 @@ -import { relative } from "node:path"; +import { existsSync, realpathSync } from "node:fs"; +import { join, relative } from "node:path"; import type { PluginDeclaration } from "../types.js"; import { selectedAgentIds } from "../targets.js"; import { stableJson } from "../managed-files.js"; @@ -13,10 +14,40 @@ export function marketplaceOutputPaths(root: PluginRuntimeRoot): string[] { return [ layout.codexMarketplacePath, layout.claudeMarketplacePath, + layout.copilotMarketplacePath, layout.cursorMarketplacePath, ]; } +/** Returns the first Copilot catalog that takes precedence over dotagents output. */ +export function copilotMarketplaceConflict(root: PluginRuntimeRoot): string | undefined { + const layout = normalizePluginRuntimeLayout(root); + const managedRealPath = tryRealpath(layout.copilotMarketplacePath); + return [ + join(layout.copilotMarketplaceRoot, "marketplace.json"), + join(layout.copilotMarketplaceRoot, ".plugin", "marketplace.json"), + ].find((filePath) => ( + existsSync(filePath) && + (managedRealPath === undefined || tryRealpath(filePath) !== managedRealPath) + )); +} + +function tryRealpath(filePath: string): string | undefined { + try { + return realpathSync(filePath); + } catch { + return undefined; + } +} + +export function copilotMarketplaceConflictMessage( + root: PluginRuntimeRoot, + conflictPath: string, +): string { + const layout = normalizePluginRuntimeLayout(root); + return `Copilot will ignore the dotagents marketplace at ${layout.copilotMarketplacePath} because a higher-priority marketplace exists: ${conflictPath}. Remove or rename the higher-priority file, or exclude "copilot" from the plugin targets.`; +} + /** Builds target-specific marketplace JSON outputs for selected plugins. */ export function marketplaceOutputs( agentIds: string[], @@ -28,6 +59,7 @@ export function marketplaceOutputs( const outputs: RuntimeOutput[] = []; const claudePlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("claude")); + const copilotPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("copilot")); const cursorPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("cursor")); const codexPlugins = plugins.filter((plugin) => selectedAgentIds(agentIds, plugin).includes("codex")); @@ -39,6 +71,14 @@ export function marketplaceOutputs( content: stableJson(pathMarketplace(layout.claudeMarketplaceRoot, "dotagents", claudePlugins)), }); } + if (copilotPlugins.length > 0) { + const filePath = layout.copilotMarketplacePath; + outputs.push({ + agent: "copilot", + filePath, + content: stableJson(pathMarketplace(layout.copilotMarketplaceRoot, "dotagents", copilotPlugins)), + }); + } if (cursorPlugins.length > 0) { const filePath = layout.cursorMarketplacePath; outputs.push({ @@ -77,8 +117,8 @@ function pathMarketplace( } /** - * Claude and Cursor marketplace projections use path strings instead of Codex's - * structured local source objects, so keep this projection format separate. + * Claude, Copilot, and Cursor marketplace projections use path strings instead + * of Codex's structured local source objects, so keep this format separate. */ function pathMarketplaceEntry( projectRoot: string, diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index 2a47e059..9a1162d7 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -112,13 +112,13 @@ describe("plugin writer", () => { const beta = await plugin("beta-tools"); const result = await writePluginOutputs( - ["cursor", "codex", "claude"], + ["cursor", "codex", "claude", "copilot"], [beta, alpha], root, ); expect(result.warnings).toEqual([]); - expect(result.written).toBe(9); + expect(result.written).toBe(10); const codexMarketplace = parseJsonObject(await readFile(join(root, ".agents", "plugins", "marketplace.json"), "utf-8")); expect(codexMarketplace).toEqual({ interface: { @@ -188,6 +188,7 @@ describe("plugin writer", () => { const claudeMarketplace = parseJsonObject(claudeMarketplaceJson); const claudePlugin = objectValue(arrayField(claudeMarketplace, "plugins")[0]); expect(resolve(root, String(claudePlugin["source"]))).toBe(alpha.pluginDir); + expect(await readFile(join(root, ".github", "plugin", "marketplace.json"), "utf-8")).toBe(claudeMarketplaceJson); expect(await readFile(join(root, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(claudeMarketplaceJson); const claudeManifest = parseJsonObject(await readFile(join(root, ".agents", "plugins", "alpha-tools", ".claude-plugin", "plugin.json"), "utf-8")); @@ -215,7 +216,7 @@ describe("plugin writer", () => { shortDescription: "Tools for alpha-tools", }); - expect(await verifyPluginOutputs(["cursor", "codex", "claude"], [beta, alpha], root)).toEqual([]); + expect(await verifyPluginOutputs(["cursor", "codex", "claude", "copilot"], [beta, alpha], root)).toEqual([]); }); it("uses default Codex categories for empty legacy category values", async () => { @@ -688,6 +689,21 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(true); }); + it("warns about a higher-priority Copilot marketplace and prunes stale output", async () => { + const alpha = await plugin("alpha-tools"); + const generatedPath = join(root, ".github", "plugin", "marketplace.json"); + const blockingPath = join(root, ".plugin", "marketplace.json"); + await writePluginOutputs(["copilot"], [alpha], root); + await mkdir(dirname(blockingPath), { recursive: true }); + await writeFile(blockingPath, "{ \"name\": \"mine\" }\n", "utf-8"); + + const reconciled = await reconcilePluginOutputs(["copilot"], [alpha], root); + + expect(reconciled.pruned).toEqual([generatedPath]); + expect(reconciled.result.warnings[0]?.message).toContain(blockingPath); + expect(existsSync(generatedPath)).toBe(false); + }); + it.each([ ["codex", "Codex", ".codex-plugin"], ["claude", "Claude", ".claude-plugin"], @@ -723,6 +739,7 @@ describe("plugin writer", () => { }); expect(existsSync(join(root, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".claude-plugin", "marketplace.json"))).toBe(false); + expect(existsSync(join(root, ".github", "plugin", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".cursor-plugin", "marketplace.json"))).toBe(false); }); @@ -1315,13 +1332,14 @@ describe("plugin writer", () => { "---\ndescription: Plugin reviewer\n---\nReview plugin output.\n", "utf-8", ); - await writePluginOutputs(["claude", "cursor", "codex", "grok", "opencode", "pi"], [alpha], root); + await writePluginOutputs(["claude", "copilot", "cursor", "codex", "grok", "opencode", "pi"], [alpha], root); const pruned = await prunePluginOutputs([], [alpha], root); expect(pruned).toEqual([ join(root, ".agents", "plugins", "marketplace.json"), join(root, ".claude-plugin", "marketplace.json"), + join(root, ".github", "plugin", "marketplace.json"), join(root, ".cursor-plugin", "marketplace.json"), join(root, ".grok", "plugins", "alpha-tools"), join(root, ".opencode", "skills", "plugin-qa"), @@ -1333,6 +1351,7 @@ describe("plugin writer", () => { ]); expect(existsSync(join(root, ".agents", "plugins", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".claude-plugin", "marketplace.json"))).toBe(false); + expect(existsSync(join(root, ".github", "plugin", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".cursor-plugin", "marketplace.json"))).toBe(false); expect(existsSync(join(root, ".grok", "plugins", "alpha-tools"))).toBe(false); expect(existsSync(join(root, ".opencode", "skills", "plugin-qa"))).toBe(false); diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index b56adf29..60d93b9a 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -17,7 +17,12 @@ import { targetWarnings, usesLegacyPluginComponents, } from "../targets.js"; -import { marketplaceOutputPaths, marketplaceOutputs } from "./marketplace.js"; +import { + copilotMarketplaceConflict, + copilotMarketplaceConflictMessage, + marketplaceOutputPaths, + marketplaceOutputs, +} from "./marketplace.js"; import { type PluginVerifyIssue, type PluginWriteResult, @@ -110,7 +115,19 @@ export async function writePluginOutputs( warnings.push(warning); } - for (const output of marketplaceOutputs(agentIds, layout, selected)) { + const desiredMarketplaces = marketplaceOutputs(agentIds, layout, selected); + const copilotConflict = desiredMarketplaces.some((output) => output.agent === "copilot") + ? copilotMarketplaceConflict(layout) + : undefined; + for (const output of desiredMarketplaces) { + if (output.agent === "copilot" && copilotConflict) { + warnings.push({ + agent: "copilot", + name: "marketplace", + message: copilotMarketplaceConflictMessage(layout, copilotConflict), + }); + continue; + } if (await writeManagedJsonOutput(output, warnings)) {written++;} } @@ -177,7 +194,19 @@ export async function verifyPluginOutputs( loadedMcp.set(plugin.name, await loadStandardMcp(plugin, mcpWarnings)); } - for (const output of marketplaceOutputs(agentIds, layout, selected)) { + const desiredMarketplaces = marketplaceOutputs(agentIds, layout, selected); + const copilotConflict = desiredMarketplaces.some((output) => output.agent === "copilot") + ? copilotMarketplaceConflict(layout) + : undefined; + for (const output of desiredMarketplaces) { + if (output.agent === "copilot" && copilotConflict) { + issues.push({ + agent: "copilot", + name: "marketplace", + issue: copilotMarketplaceConflictMessage(layout, copilotConflict), + }); + continue; + } if (!existsSync(output.filePath)) { issues.push({ agent: output.agent, name: "marketplace", issue: `Plugin marketplace missing: ${output.filePath}` }); continue; @@ -244,9 +273,13 @@ export async function prunePluginOutputs( ): Promise { const layout = normalizePluginRuntimeLayout(root); const pruned: string[] = []; - const desiredMarketplacePaths = new Set( - marketplaceOutputs(agentIds, layout, plugins).map((output) => output.filePath), - ); + const desiredMarketplaces = marketplaceOutputs(agentIds, layout, plugins); + const copilotConflict = desiredMarketplaces.some((output) => output.agent === "copilot") + ? copilotMarketplaceConflict(layout) + : undefined; + const desiredMarketplacePaths = new Set(desiredMarketplaces + .filter((output) => output.agent !== "copilot" || !copilotConflict) + .map((output) => output.filePath)); for (const filePath of marketplaceOutputPaths(layout)) { if (desiredMarketplacePaths.has(filePath)) {continue;} if (!await isManagedJsonFile(filePath)) {continue;} diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 3186490d..47100dc4 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -386,6 +386,235 @@ describe("plugin store", () => { } }); + it("rejects only the manifest locator that shadows a portable plugin in Copilot", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-shadow-")); + try { + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "portable-name", + })); + const plugin = { + name: "portable-name", + source: "path:.", + pluginDir: sourceRoot, + manifest: { + $schema: AGENT_PLUGIN_SCHEMA, + name: "portable-name", + }, + } as const; + await mkdir(join(sourceRoot, ".plugin"), { recursive: true }); + await writeFile(join(sourceRoot, ".plugin", "plugin.json"), JSON.stringify({ + name: "portable-name", + })); + + expect(() => preparePluginForTargets(plugin, ["copilot"])).toThrow( + '.plugin/plugin.json would shadow the canonical plugin.json for Copilot', + ); + expect(() => preparePluginForTargets(plugin, ["claude"])).not.toThrow(); + + await rm(join(sourceRoot, ".plugin"), { recursive: true }); + for (const allowedPath of [ + join(".github", "plugin", "plugin.json"), + join(".claude-plugin", "plugin.json"), + ]) { + await mkdir(dirname(join(sourceRoot, allowedPath)), { recursive: true }); + await writeFile(join(sourceRoot, allowedPath), JSON.stringify({ name: "portable-name" })); + expect(() => preparePluginForTargets(plugin, ["copilot"])).not.toThrow(); + } + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it("canonicalizes a .plugin/plugin.json-only source without leaving a Copilot shadow", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-source-")); + try { + const sourceRoot = join(projectRoot, "source"); + const pluginsDir = join(projectRoot, "installed"); + await mkdir(join(sourceRoot, ".plugin"), { recursive: true }); + await mkdir(pluginsDir, { recursive: true }); + await writeFile(join(sourceRoot, ".plugin", "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "copilot-tools", + description: "Copilot locator source", + })); + + const resolved = await resolvePlugin( + { name: "copilot-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + const prepared = preparePluginForTargets(resolved.plugin, ["copilot"]); + const installed = await installPluginBundle(pluginsDir, { + ...resolved, + plugin: prepared, + }); + + expect(existsSync(join(installed.pluginDir, "plugin.json"))).toBe(true); + expect(existsSync(join(installed.pluginDir, ".plugin", "plugin.json"))).toBe(false); + const reloaded = await loadInstalledPlugins( + pluginsDir, + [{ name: "copilot-tools", source: "path:source" }], + "dotagents install", + ["copilot"], + ); + expect(reloaded.issues).toEqual([]); + expect(reloaded.plugins).toHaveLength(1); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("rejects a .github Copilot manifest when a native fallback was imported", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-precedence-")); + try { + const sourceRoot = join(projectRoot, "source"); + await mkdir(join(sourceRoot, ".claude-plugin"), { recursive: true }); + await mkdir(join(sourceRoot, ".github", "plugin"), { recursive: true }); + await writeFile( + join(sourceRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "native-tools" }), + ); + await writeFile( + join(sourceRoot, ".github", "plugin", "plugin.json"), + JSON.stringify({ name: "native-tools" }), + ); + + const resolved = await resolvePlugin( + { name: "native-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).toThrow( + "but Copilot would load .github/plugin/plugin.json instead", + ); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("rejects active legacy Copilot component fields", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-components-")); + try { + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + name: "legacy-tools", + skills: "./skills", + mcpServers: "./mcp.json", + commands: "./commands", + hooks: "./hooks/hooks.json", + })); + const resolved = await resolvePlugin( + { name: "legacy-tools", source: "path:." }, + { stateDir: join(sourceRoot, "state"), projectRoot: sourceRoot }, + ); + + expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).toThrow( + "legacy plugin.json declares unsupported components: commands, hooks", + ); + expect(() => preparePluginForTargets(resolved.plugin, ["claude"])).not.toThrow(); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it("preserves inert extension data in standard Copilot manifests", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-components-")); + try { + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "standard-tools", + extensions: { + "com.github.copilot": { agents: "./custom-agents" }, + "com.example.client": { enabled: true }, + }, + })); + const resolved = await resolvePlugin( + { name: "standard-tools", source: "path:." }, + { stateDir: join(sourceRoot, "state"), projectRoot: sourceRoot }, + ); + + expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).not.toThrow(); + expect(() => preparePluginForTargets(resolved.plugin, ["claude"])).not.toThrow(); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it("rejects implicitly discovered top-level resources in legacy Copilot bundles", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-resources-")); + try { + await mkdir(join(sourceRoot, "commands"), { recursive: true }); + await writeFile(join(sourceRoot, "commands", "hidden.md"), "# hidden\n"); + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + name: "resource-tools", + })); + const resolved = await resolvePlugin( + { name: "resource-tools", source: "path:." }, + { stateDir: join(sourceRoot, "state"), projectRoot: sourceRoot }, + ); + + expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).toThrow( + "bundle contains unsupported top-level resources: commands", + ); + expect(() => preparePluginForTargets(resolved.plugin, ["claude"])).not.toThrow(); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it("rejects Copilot's physical extension namespace in standard bundles", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-resources-")); + try { + await mkdir(join(sourceRoot, "com.github.copilot", "commands"), { recursive: true }); + await writeFile( + join(sourceRoot, "com.github.copilot", "commands", "hidden.md"), + "# hidden\n", + ); + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "resource-tools", + })); + const resolved = await resolvePlugin( + { name: "resource-tools", source: "path:." }, + { stateDir: join(sourceRoot, "state"), projectRoot: sourceRoot }, + ); + + expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).toThrow( + "bundle contains unsupported top-level resources: com.github.copilot", + ); + expect(() => preparePluginForTargets(resolved.plugin, ["claude"])).not.toThrow(); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it("allows standard Copilot MCP implementations under bin", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-bin-")); + try { + await mkdir(join(sourceRoot, "bin"), { recursive: true }); + await writeFile(join(sourceRoot, "bin", "server.mjs"), "process.exit(0);\n"); + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: "mcp-tools", + })); + await writeFile(join(sourceRoot, "mcp.json"), JSON.stringify({ + mcpServers: { + local: { + type: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/bin/server.mjs"], + }, + }, + })); + const resolved = await resolvePlugin( + { name: "mcp-tools", source: "path:." }, + { stateDir: join(sourceRoot, "state"), projectRoot: sourceRoot }, + ); + + expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).not.toThrow(); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + it("never falls back from an invalid standard root to a valid native manifest", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-hybrid-")); try { @@ -686,6 +915,90 @@ describe("plugin store", () => { } }); + it("resolves and canonicalizes a .github-manifest-only plugin from a Copilot marketplace", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const pluginDir = join(sourceRoot, "plugins", "review-tools"); + const pluginsDir = join(projectRoot, "installed"); + await mkdir(join(sourceRoot, ".github", "plugin"), { recursive: true }); + await mkdir(join(pluginDir, ".github", "plugin"), { recursive: true }); + await writeFile( + join(pluginDir, ".github", "plugin", "plugin.json"), + JSON.stringify({ name: "review-tools", description: "Copilot marketplace plugin" }), + "utf-8", + ); + await writeFile( + join(sourceRoot, ".github", "plugin", "marketplace.json"), + JSON.stringify({ + name: "test-marketplace", + owner: { name: "test" }, + plugins: [{ name: "review-tools", source: "./plugins/review-tools" }], + }), + "utf-8", + ); + + const fromMarketplace = await resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + expect(fromMarketplace.plugin.pluginDir).toBe(pluginDir); + expect(fromMarketplace.plugin.manifest.description).toBe("Copilot marketplace plugin"); + + const installed = await installPluginBundle(pluginsDir, { + ...fromMarketplace, + plugin: preparePluginForTargets(fromMarketplace.plugin, ["copilot"]), + }); + expect(existsSync(join(installed.pluginDir, "plugin.json"))).toBe(true); + expect(existsSync(join(installed.pluginDir, ".github", "plugin", "plugin.json"))).toBe(true); + + const reloaded = await loadInstalledPlugins( + pluginsDir, + [{ name: "review-tools", source: "path:source" }], + "dotagents install", + ["copilot"], + ); + expect(reloaded.issues).toEqual([]); + expect(reloaded.plugins).toHaveLength(1); + expect(reloaded.plugins[0]?.manifest.description).toBe("Copilot marketplace plugin"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("does not expose a lower-priority Copilot catalog", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const higherPlugin = join(sourceRoot, "catalog", "higher"); + const lowerPlugin = join(sourceRoot, "catalog", "lower"); + await mkdir(join(sourceRoot, ".github", "plugin"), { recursive: true }); + await mkdir(higherPlugin, { recursive: true }); + await mkdir(lowerPlugin, { recursive: true }); + await writeFile(join(higherPlugin, "plugin.json"), JSON.stringify({ name: "other-tools" })); + await writeFile(join(lowerPlugin, "plugin.json"), JSON.stringify({ name: "review-tools" })); + await writeFile(join(sourceRoot, "marketplace.json"), JSON.stringify({ + name: "higher", + owner: { name: "test" }, + plugins: [{ name: "other-tools", source: "./catalog/higher" }], + })); + await writeFile( + join(sourceRoot, ".github", "plugin", "marketplace.json"), + JSON.stringify({ + name: "lower", + owner: { name: "test" }, + plugins: [{ name: "review-tools", source: "./catalog/lower" }], + }), + ); + + expect((await discoverPlugins(sourceRoot)).map((candidate) => candidate.name)).toEqual([ + "other-tools", + ]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + it("prefers repository-root paths in nested Claude marketplaces", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); try { diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts index 760c03ce..5e75dfd1 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -119,6 +119,13 @@ const MARKETPLACE_PATHS = [ ".cursor-plugin/marketplace.json", ".codex-plugin/marketplace.json", ".plugin/marketplace.json", + ".github/plugin/marketplace.json", +] as const; + +const COPILOT_MARKETPLACE_PATH = ".github/plugin/marketplace.json"; +const COPILOT_HIGHER_PRIORITY_MARKETPLACES = [ + "marketplace.json", + ".plugin/marketplace.json", ] as const; const FALLBACK_MANIFEST_PATHS: ReadonlyArray<{ path: string; nativeSource?: NativePluginSource }> = [ @@ -126,8 +133,11 @@ const FALLBACK_MANIFEST_PATHS: ReadonlyArray<{ path: string; nativeSource?: Nati { path: ".claude-plugin/plugin.json", nativeSource: "claude" }, { path: ".cursor-plugin/plugin.json", nativeSource: "cursor" }, { path: ".plugin/plugin.json" }, + { path: ".github/plugin/plugin.json" }, ] as const; +const COPILOT_GITHUB_MANIFEST_PATH = ".github/plugin/plugin.json"; + const NATIVE_MANIFEST_PATHS: ReadonlyArray<{ source: NativePluginSource; path: string; @@ -158,6 +168,26 @@ export const HYBRID_LEGACY_ROOTS = [ export const DOTAGENTS_MANAGED_PLUGIN_MARKER = ".dotagents-managed"; export const DOTAGENTS_NATIVE_FALLBACKS_MARKER = ".dotagents-native-fallbacks"; const DOTAGENTS_NATIVE_SOURCE_MARKER = ".dotagents-native-source"; +const COPILOT_UNSUPPORTED_LEGACY_FIELDS = [ + "agents", + "commands", + "hooks", + "lspServers", + "extensions", +] as const; +const COPILOT_UNSUPPORTED_STANDARD_RESOURCE_PATHS = [ + "com.github.copilot", +] as const; +const COPILOT_UNSUPPORTED_LEGACY_RESOURCE_PATHS = [ + ...COPILOT_UNSUPPORTED_STANDARD_RESOURCE_PATHS, + "agents", + "commands", + "hooks.json", + "hooks/hooks.json", + ".lsp.json", + "lsp.json", + ".github/lsp.json", +] as const; let tempInstallCounter = 0; @@ -622,6 +652,15 @@ async function discoverFromMarketplaces( const issues: PluginCatalog["issues"] = []; const referencedDirs = new Set(); for (const marketplacePath of MARKETPLACE_PATHS) { + // The Copilot-specific .github locator is invisible whenever either + // higher-priority native locator exists. Other generic discovery inputs + // retain their existing union behavior. + if ( + marketplacePath === COPILOT_MARKETPLACE_PATH && + COPILOT_HIGHER_PRIORITY_MARKETPLACES.some((path) => existsSync(join(sourceDir, path))) + ) { + continue; + } const filePath = join(sourceDir, marketplacePath); if (!existsSync(filePath)) {continue;} @@ -1032,6 +1071,11 @@ async function ensureCanonicalManifest(plugin: PluginDeclaration): Promise const filePath = join(plugin.pluginDir, "plugin.json"); if (existsSync(filePath)) {return;} await writeFile(filePath, `${JSON.stringify(plugin.manifest, null, 2)}\n`, "utf-8"); + // Only .plugin/plugin.json outranks the new canonical root. Copilot's + // lower-priority .github/plugin/plugin.json locator remains preserved. + if (!plugin.nativeSource) { + await rm(join(plugin.pluginDir, ".plugin", "plugin.json"), { force: true }); + } } async function writeManagedMarker(pluginDir: string): Promise { @@ -1151,6 +1195,56 @@ export function preparePluginForTargets( ): PluginDeclaration { const interfaces = plugin.authoredNativeInterfaces ?? {}; const selectedTargets = new Set(selectedAgentIds(agentIds, plugin)); + const canonicalManifestPath = join(plugin.pluginDir, "plugin.json"); + const copilotShadowPath = join(plugin.pluginDir, ".plugin", "plugin.json"); + const copilotGitHubManifestPath = join(plugin.pluginDir, COPILOT_GITHUB_MANIFEST_PATH); + const importedFromCopilotLocator = !existsSync(canonicalManifestPath) && + plugin.nativeSource === undefined; + if ( + selectedTargets.has("copilot") && + existsSync(copilotShadowPath) && + !importedFromCopilotLocator + ) { + throw new Error( + `Plugin "${plugin.name}" cannot target Copilot because .plugin/plugin.json would shadow the canonical plugin.json for Copilot: ${copilotShadowPath}. Remove or rename the shadow manifest, or exclude "copilot" from this plugin's targets.`, + ); + } + if ( + selectedTargets.has("copilot") && + existsSync(copilotGitHubManifestPath) && + !existsSync(canonicalManifestPath) && + !existsSync(copilotShadowPath) && + plugin.nativeSource !== undefined + ) { + throw new Error( + `Plugin "${plugin.name}" cannot target Copilot because dotagents selected its ${nativeDisplayName(plugin.nativeSource)} native manifest, but Copilot would load ${COPILOT_GITHUB_MANIFEST_PATH} instead. Remove one manifest or exclude "copilot" from this plugin's targets.`, + ); + } + if (selectedTargets.has("copilot")) { + const unsupportedPaths = isStandardPluginManifest(plugin.manifest) + ? COPILOT_UNSUPPORTED_STANDARD_RESOURCE_PATHS + : COPILOT_UNSUPPORTED_LEGACY_RESOURCE_PATHS; + const unsupportedRoots = unsupportedPaths.filter( + (path) => existsSync(join(plugin.pluginDir, path)), + ); + if (unsupportedRoots.length > 0) { + throw new Error( + `Plugin "${plugin.name}" cannot target Copilot because its bundle contains unsupported top-level resources: ${unsupportedRoots.join(", ")}. The Copilot projection supports skills and MCP servers, not resources Copilot would activate natively; remove those resources or exclude "copilot" from this plugin's targets.`, + ); + } + } + if (selectedTargets.has("copilot")) { + const unsupported = isStandardPluginManifest(plugin.manifest) + ? [] + : COPILOT_UNSUPPORTED_LEGACY_FIELDS.filter( + (field) => plugin.manifest[field] !== undefined, + ); + if (unsupported.length > 0) { + throw new Error( + `Plugin "${plugin.name}" cannot target Copilot because its legacy plugin.json declares unsupported components: ${unsupported.join(", ")}. The Copilot projection supports skills and MCP servers, not components Copilot would activate natively; remove those fields or exclude "copilot" from this plugin's targets.`, + ); + } + } if (isStandardPluginManifest(plugin.manifest)) { assertNativeInterfaceNames(plugin.name, interfaces, plugin.pluginDir, selectedTargets); } diff --git a/packages/dotagents/src/plugins/targets.ts b/packages/dotagents/src/plugins/targets.ts index 253d3f1b..d30ea4c6 100644 --- a/packages/dotagents/src/plugins/targets.ts +++ b/packages/dotagents/src/plugins/targets.ts @@ -6,7 +6,7 @@ import { isSerializedObject, type SerializedObject, type SerializedValue } from import { isString } from "../utils/type-guards.js"; const PLUGIN_ONLY_AGENT_IDS = ["grok", "pi"]; -const PLUGIN_AGENT_IDS = ["claude", "cursor", "codex", "grok", "opencode", "pi"]; +const PLUGIN_AGENT_IDS = ["claude", "copilot", "cursor", "codex", "grok", "opencode", "pi"]; const SUPPORTED_PLUGIN_AGENT_IDS = new Set(allPluginAgentIds()); const GENERATED_NATIVE_FIELDS = { claude: new Set(["$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills"]), diff --git a/packages/dotagents/src/symlinks/manager.test.ts b/packages/dotagents/src/symlinks/manager.test.ts index 6afa6df8..6602cd9d 100644 --- a/packages/dotagents/src/symlinks/manager.test.ts +++ b/packages/dotagents/src/symlinks/manager.test.ts @@ -6,9 +6,11 @@ import { symlink, writeFile, lstat, + readFile, readlink, readdir, } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { ensureSkillsSymlink, verifySymlinks } from "./manager.js"; @@ -64,6 +66,39 @@ describe("symlinks", () => { expect(result.created).toBe(false); }); + it("does not replace canonical skills when the target aliases the agents directory", async () => { + const targetDir = join(dir, "agents-alias"); + const skillDir = join(agentsDir, "skills", "keep-me"); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), "keep me"); + await symlink(agentsDir, targetDir, process.platform === "win32" ? "junction" : "dir"); + + const result = await ensureSkillsSymlink(agentsDir, targetDir); + + expect(result).toEqual({ created: false, migrated: [] }); + expect((await lstat(join(agentsDir, "skills"))).isDirectory()).toBe(true); + expect(await readFile(join(skillDir, "SKILL.md"), "utf-8")).toBe("keep me"); + }); + + it("rejects a Copilot home that aliases the canonical skills target", async () => { + const externalSkills = join(dir, "external-skills"); + const canonicalSkills = join(agentsDir, "skills"); + await rm(canonicalSkills, { recursive: true }); + await mkdir(externalSkills, { recursive: true }); + await symlink( + externalSkills, + canonicalSkills, + process.platform === "win32" ? "junction" : "dir", + ); + + await expect(ensureSkillsSymlink(agentsDir, externalSkills)).rejects.toThrow( + "paths overlap", + ); + + expect((await lstat(canonicalSkills)).isSymbolicLink()).toBe(true); + expect(existsSync(join(externalSkills, "skills"))).toBe(false); + }); + it("replaces wrong symlink", async () => { const targetDir = join(dir, ".claude"); await mkdir(targetDir, { recursive: true }); @@ -100,6 +135,44 @@ describe("symlinks", () => { expect(stat.isSymbolicLink()).toBe(true); }); + it("recreates a missing canonical skills directory before migration", async () => { + const targetDir = join(dir, ".copilot"); + const nativeSkill = join(targetDir, "skills", "native-only"); + const canonicalSkills = join(agentsDir, "skills"); + await mkdir(nativeSkill, { recursive: true }); + await writeFile(join(nativeSkill, "SKILL.md"), "native skill"); + await rm(canonicalSkills, { recursive: true }); + + const result = await ensureSkillsSymlink(agentsDir, targetDir); + + expect(result).toEqual({ created: true, migrated: ["native-only"] }); + expect(await readFile(join(canonicalSkills, "native-only", "SKILL.md"), "utf-8")) + .toBe("native skill"); + expect((await lstat(join(targetDir, "skills"))).isSymbolicLink()).toBe(true); + }); + + it("fails before moving or deleting skills when migration names conflict", async () => { + const targetDir = join(dir, ".copilot"); + const nativeSkills = join(targetDir, "skills"); + const canonicalSkills = join(agentsDir, "skills"); + await mkdir(join(nativeSkills, "unique"), { recursive: true }); + await mkdir(join(nativeSkills, "shared"), { recursive: true }); + await mkdir(join(canonicalSkills, "shared"), { recursive: true }); + await writeFile(join(nativeSkills, "unique", "SKILL.md"), "native unique"); + await writeFile(join(nativeSkills, "shared", "SKILL.md"), "native shared"); + await writeFile(join(canonicalSkills, "shared", "SKILL.md"), "canonical shared"); + + await expect(ensureSkillsSymlink(agentsDir, targetDir)).rejects.toThrow( + "these entries already exist", + ); + + expect((await lstat(nativeSkills)).isDirectory()).toBe(true); + expect(await readFile(join(nativeSkills, "unique", "SKILL.md"), "utf-8")).toBe("native unique"); + expect(await readFile(join(nativeSkills, "shared", "SKILL.md"), "utf-8")).toBe("native shared"); + expect(await readFile(join(canonicalSkills, "shared", "SKILL.md"), "utf-8")).toBe("canonical shared"); + expect(existsSync(join(canonicalSkills, "unique"))).toBe(false); + }); + it("removes migrated files from git index", async () => { // Initialize a git repo in the temp dir await exec("git", ["init"], { cwd: dir }); diff --git a/packages/dotagents/src/symlinks/manager.ts b/packages/dotagents/src/symlinks/manager.ts index 28594458..aedecd0c 100644 --- a/packages/dotagents/src/symlinks/manager.ts +++ b/packages/dotagents/src/symlinks/manager.ts @@ -1,6 +1,7 @@ -import { symlink, readlink, unlink, mkdir, lstat, readdir, rename, rm } from "node:fs/promises"; -import { join, relative } from "node:path"; +import { symlink, readlink, unlink, mkdir, lstat, readdir, realpath, rename, rmdir } from "node:fs/promises"; +import { isAbsolute, join, relative, sep } from "node:path"; import { exec } from "@sentry/dotagents-lib"; +import { hasErrorCode } from "../utils/type-guards.js"; export class SymlinkError extends Error { constructor(message: string) { @@ -19,11 +20,40 @@ export async function ensureSkillsSymlink( ): Promise<{ created: boolean; migrated: string[] }> { const skillsSource = join(agentsDir, "skills"); const skillsLink = join(targetDir, "skills"); - const relativeTarget = relative(targetDir, skillsSource); // Ensure parent directory exists await mkdir(targetDir, { recursive: true }); + const [physicalAgentsDir, physicalTargetDir] = await Promise.all([ + realpath(agentsDir), + realpath(targetDir), + ]); + let physicalSkillsSource: string; + try { + physicalSkillsSource = await realpath(skillsSource); + } catch (err) { + if (!hasErrorCode(err, "ENOENT")) {throw err;} + physicalSkillsSource = join(physicalAgentsDir, "skills"); + } + const physicalSkillsLink = join(physicalTargetDir, "skills"); + // Relative link text is interpreted from the physical parent directory, + // even when targetDir itself is a symlinked home alias. + const relativeTarget = relative(physicalTargetDir, physicalSkillsSource); + + // Homes may be aliases or nested inside one another. Detect that from the + // physical parent directories without following an existing skills link. + if (physicalSkillsSource === physicalSkillsLink) { + return { created: false, migrated: [] }; + } + if ( + isStrictDescendant(physicalSkillsSource, physicalSkillsLink) + || isStrictDescendant(physicalSkillsLink, physicalSkillsSource) + ) { + throw new SymlinkError( + `Cannot link ${skillsLink} to ${skillsSource} because the paths overlap. Choose non-nested agent home directories.`, + ); + } + // Check if skills path already exists let stat; try { @@ -36,9 +66,12 @@ export async function ensureSkillsSymlink( // Already a symlink - check if it points to the right place if (stat.isSymbolicLink()) { - const currentTarget = await readlink(skillsLink); - if (currentTarget === relativeTarget) { - return { created: false, migrated: [] }; + try { + if (await realpath(skillsLink) === physicalSkillsSource) { + return { created: false, migrated: [] }; + } + } catch { + // Broken or recursive links are replaced below. } // Wrong target, replace await unlink(skillsLink); @@ -50,7 +83,8 @@ export async function ensureSkillsSymlink( if (stat.isDirectory()) { const migrated = await migrateDirectory(skillsLink, skillsSource); await removeFromGitIndex(targetDir, "skills"); - await rm(skillsLink, { recursive: true }); + // Fail safely if another process adds a native skill after migration. + await rmdir(skillsLink); await symlink(relativeTarget, skillsLink); return { created: true, migrated }; } @@ -60,25 +94,41 @@ export async function ensureSkillsSymlink( ); } +function isStrictDescendant(path: string, parent: string): boolean { + const pathFromParent = relative(parent, path); + return pathFromParent !== "" + && pathFromParent !== ".." + && !pathFromParent.startsWith(`..${sep}`) + && !isAbsolute(pathFromParent); +} + async function migrateDirectory( from: string, to: string, ): Promise { + await mkdir(to, { recursive: true }); const entries = await readdir(from, { withFileTypes: true }); - const migrated: string[] = []; + const conflicts: string[] = []; for (const entry of entries) { - const srcPath = join(from, entry.name); - const destPath = join(to, entry.name); - - // Skip if destination already exists try { - await lstat(destPath); - continue; - } catch { - // Doesn't exist, proceed with migration + await lstat(join(to, entry.name)); + conflicts.push(entry.name); + } catch (err) { + if (!hasErrorCode(err, "ENOENT")) {throw err;} } + } + if (conflicts.length > 0) { + throw new SymlinkError( + `Cannot migrate ${from} because these entries already exist in ${to}: ${conflicts.join(", ")}. Resolve the conflicts before retrying.`, + ); + } + + const migrated: string[] = []; + for (const entry of entries) { + const srcPath = join(from, entry.name); + const destPath = join(to, entry.name); await rename(srcPath, destPath); migrated.push(entry.name); } @@ -111,10 +161,18 @@ export async function verifySymlinks( ): Promise<{ target: string; issue: string }[]> { const issues: { target: string; issue: string }[] = []; const skillsSource = join(agentsDir, "skills"); + let physicalSkillsSource: string; + try { + physicalSkillsSource = await realpath(skillsSource); + } catch { + return targets.map((target) => ({ + target, + issue: `${skillsSource} does not resolve`, + })); + } for (const target of targets) { const skillsLink = join(target, "skills"); - const relativeTarget = relative(target, skillsSource); try { const stat = await lstat(skillsLink); @@ -123,10 +181,20 @@ export async function verifySymlinks( continue; } const currentTarget = await readlink(skillsLink); - if (currentTarget !== relativeTarget) { + let resolvedTarget: string; + try { + resolvedTarget = await realpath(skillsLink); + } catch { + issues.push({ + target, + issue: `${skillsLink} points to ${currentTarget}, which does not resolve`, + }); + continue; + } + if (resolvedTarget !== physicalSkillsSource) { issues.push({ target, - issue: `${skillsLink} points to ${currentTarget}, expected ${relativeTarget}`, + issue: `${skillsLink} resolves to ${resolvedTarget}, expected ${physicalSkillsSource}`, }); } } catch { diff --git a/packages/dotagents/src/targets/definitions/claude.ts b/packages/dotagents/src/targets/definitions/claude.ts index fd238602..38ff9966 100644 --- a/packages/dotagents/src/targets/definitions/claude.ts +++ b/packages/dotagents/src/targets/definitions/claude.ts @@ -12,6 +12,7 @@ const claude: AgentDefinition = { userSkillsParentDirs: [join(homedir(), ".claude")], mcp: { filePath: ".mcp.json", + recognizesBareServerMap: true, rootKey: "mcpServers", format: "json", shared: false, diff --git a/packages/dotagents/src/targets/definitions/copilot.ts b/packages/dotagents/src/targets/definitions/copilot.ts new file mode 100644 index 00000000..0991a1f8 --- /dev/null +++ b/packages/dotagents/src/targets/definitions/copilot.ts @@ -0,0 +1,32 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { AgentDefinition } from "../types.js"; +import { UnsupportedFeature } from "../errors.js"; +import claude from "./claude.js"; + +const copilotHome = process.env["COPILOT_HOME"] || join(homedir(), ".copilot"); + +const copilot: AgentDefinition = { + id: "copilot", + displayName: "GitHub Copilot", + configDir: ".copilot", + // Reads project .agents/skills/ natively. Global discovery follows COPILOT_HOME. + skillsParentDir: undefined, + userSkillsParentDirs: [copilotHome], + mcp: { + filePath: ".mcp.json", + fallbackFilePaths: [".github/mcp.json"], + acceptsBareServerMap: true, + rootKey: "mcpServers", + format: "json", + shared: false, + }, + // Copilot accepts Claude's MCP shape, and both clients can share project .mcp.json. + serializeServer: claude.serializeServer, + hooks: undefined, + serializeHooks() { + throw new UnsupportedFeature("copilot", "hooks"); + }, +}; + +export default copilot; diff --git a/packages/dotagents/src/targets/mcp-writer.test.ts b/packages/dotagents/src/targets/mcp-writer.test.ts index 4d34e697..dde06ac2 100644 --- a/packages/dotagents/src/targets/mcp-writer.test.ts +++ b/packages/dotagents/src/targets/mcp-writer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, readFile, writeFile, rm, stat, symlink } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { existsSync } from "node:fs"; @@ -239,6 +239,60 @@ describe("writeMcpConfigs", () => { expect(existsSync(join(dir, ".opencode", "opencode.jsonc"))).toBe(false); }); + it.each([ + [["copilot"], false], + [["claude", "copilot"], true], + ] as const)("reconciles a bare .mcp.json for %s", async (agents, rooted) => { + const filePath = join(dir, ".mcp.json"); + await writeFile(filePath, JSON.stringify({ + manual: { command: "manual", args: [] }, + github: { command: "old", args: [] }, + })); + + await writeMcpConfigs([...agents], [STDIO_SERVER], projectMcpResolver(dir)); + + const content = JSON.parse(await readFile(filePath, "utf-8")); + const servers = rooted ? content.mcpServers : content; + expect(Object.hasOwn(content, "mcpServers")).toBe(rooted); + expect(servers.manual).toEqual({ command: "manual", args: [] }); + expect(servers.github.command).toBe("npx"); + }); + + it("keeps a bare Copilot fallback in place", async () => { + const filePath = join(dir, ".github", "mcp.json"); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify({ + manual: { command: "manual", args: [] }, + })); + + await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); + + const content = JSON.parse(await readFile(filePath, "utf-8")); + expect(existsSync(join(dir, ".mcp.json"))).toBe(false); + expect(content.manual.command).toBe("manual"); + expect(content.github.command).toBe("npx"); + }); + + it.each([ + ["claude", "copilot"], + ["copilot", "claude"], + ] as const)("seeds shared .mcp.json from the fallback for %s first", async (first, second) => { + const preferredPath = join(dir, ".mcp.json"); + const fallbackPath = join(dir, ".github", "mcp.json"); + const fallback = { + manual: { command: "manual", args: [] }, + }; + await mkdir(dirname(fallbackPath), { recursive: true }); + await writeFile(fallbackPath, JSON.stringify(fallback)); + + await writeMcpConfigs([first, second], [STDIO_SERVER], projectMcpResolver(dir)); + + const content = JSON.parse(await readFile(preferredPath, "utf-8")); + expect(content.mcpServers.manual).toEqual(fallback.manual); + expect(content.mcpServers.github.command).toBe("npx"); + expect(JSON.parse(await readFile(fallbackPath, "utf-8"))).toEqual(fallback); + }); + it("handles multiple servers", async () => { await writeMcpConfigs(["claude"], [STDIO_SERVER, HTTP_SERVER], projectMcpResolver(dir)); @@ -424,6 +478,61 @@ describe("writeMcpConfigs", () => { expect(second.mtimeNs).toBe(first.mtimeNs); }); + it.skipIf(process.platform === "win32")( + "repairs a restrictive user config mode before reconciliation", + async () => { + const filePath = join(dir, "copilot", "mcp-config.json"); + const resolver = () => ({ filePath, shared: false, mode: 0o600 }); + + await writeMcpConfigs(["copilot"], [STDIO_SERVER], resolver); + expect((await stat(filePath)).mode & 0o777).toBe(0o600); + await writeFile(filePath, JSON.stringify({ + mcpServers: { github: { command: "old", args: [] } }, + })); + await chmod(filePath, 0o000); + + const result = await reconcileMcpConfigs( + ["copilot"], + [STDIO_SERVER], + resolver, + "apply", + ); + + expect(result.unresolved).toEqual([]); + expect(result.written).toEqual([filePath]); + expect((await stat(filePath)).mode & 0o777).toBe(0o600); + expect(JSON.parse(await readFile(filePath, "utf-8")).mcpServers.github.command).toBe("npx"); + }, + ); + + it.skipIf(process.platform === "win32")( + "does not chmod or overwrite a config through a symlink", + async () => { + const filePath = join(dir, "copilot", "mcp-config.json"); + const unrelatedPath = join(dir, "unrelated-config.json"); + const resolver = () => ({ filePath, shared: false, mode: 0o600 }); + const original = JSON.stringify({ mcpServers: { github: { command: "unmanaged" } } }); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(unrelatedPath, original); + await chmod(unrelatedPath, 0o644); + await symlink(unrelatedPath, filePath); + + const result = await reconcileMcpConfigs( + ["copilot"], + [STDIO_SERVER], + resolver, + "apply", + ); + + expect(result.unresolved).toEqual([ + expect.objectContaining({ issue: expect.stringContaining("not a regular file") }), + ]); + expect(result.written).toEqual([]); + expect((await stat(unrelatedPath)).mode & 0o777).toBe(0o644); + expect(await readFile(unrelatedPath, "utf-8")).toBe(original); + }, + ); + it("interpolates env refs in claude HTTP headers/URL with ${VAR} syntax", async () => { await writeMcpConfigs(["claude"], [HTTP_SERVER_WITH_ENV_REFS], projectMcpResolver(dir)); diff --git a/packages/dotagents/src/targets/mcp-writer.ts b/packages/dotagents/src/targets/mcp-writer.ts index ace47f67..00b0b767 100644 --- a/packages/dotagents/src/targets/mcp-writer.ts +++ b/packages/dotagents/src/targets/mcp-writer.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import { chmod, lstat, readFile, writeFile, mkdir, rm } from "node:fs/promises"; import { join, dirname } from "node:path"; import { existsSync } from "node:fs"; import { isDeepStrictEqual } from "node:util"; @@ -23,6 +23,10 @@ import { hasErrorCode, isObject, isString } from "../utils/type-guards.js"; export interface McpResolvedTarget { filePath: string; shared: boolean; + mode?: number; + preferredFilePath?: string; + acceptsBareServerMap?: boolean; + recognizesBareServerMap?: boolean; } export type McpTargetResolver = (agentId: string, spec: McpConfigSpec) => McpResolvedTarget; @@ -74,9 +78,16 @@ export function projectMcpResolver(projectRoot: string): McpTargetResolver { const candidates = [spec.filePath, ...(spec.fallbackFilePaths ?? [])]; const relativePath = candidates.find((candidate) => existsSync(join(projectRoot, candidate))) ?? spec.filePath; + const filePath = join(projectRoot, relativePath); + const preferredFilePath = join(projectRoot, spec.filePath); return { - filePath: join(projectRoot, relativePath), + filePath, shared: spec.shared, + ...(filePath !== preferredFilePath && { preferredFilePath }), + ...(spec.acceptsBareServerMap && { acceptsBareServerMap: true }), + ...((spec.acceptsBareServerMap || spec.recognizesBareServerMap) && { + recognizesBareServerMap: true, + }), }; }; } @@ -122,32 +133,106 @@ export async function reconcileMcpConfigs( const normalized = servers.map(normalizeMcpDeclaration); if (normalized.length === 0) {return { issues, unresolved, written };} - for (const id of agentIds) { + const initialTargets = agentIds.flatMap((id) => { const agent = getAgent(id); - if (!agent) {continue;} + if (!agent) {return [];} + return [{ id, agent, target: resolveTarget(id, agent.mcp) }]; + }); + const claimedPaths = new Set(initialTargets.map(({ target }) => target.filePath)); + const promotedFallbacks = new Map(); + const targets = initialTargets.map((entry) => { + const preferredFilePath = entry.target.preferredFilePath; + if (!preferredFilePath || !claimedPaths.has(preferredFilePath)) {return entry;} + promotedFallbacks.set(preferredFilePath, entry.target); + return Object.assign({}, entry, { + target: Object.assign({}, entry.target, { filePath: preferredFilePath }), + }); + }); + const pathFormats = new Map(); + for (const { target } of targets) { + const format = pathFormats.get(target.filePath) ?? { + recognizesBare: false, + requiresRoot: false, + }; + format.recognizesBare ||= target.recognizesBareServerMap === true || + target.acceptsBareServerMap === true; + format.requiresRoot ||= target.acceptsBareServerMap !== true; + pathFormats.set(target.filePath, format); + } + for (const { id, agent, target } of targets) { const { mcp } = agent; - const { filePath } = resolveTarget(id, mcp); + const { filePath } = target; + const pathFormat = pathFormats.get(filePath)!; if (seen.has(filePath)) {continue;} seen.add(filePath); const expectedServers = renderServers(agent.serializeServer, normalized); const expected = { [mcp.rootKey]: expectedServers }; + const modeCheck = await desiredModeIssue(id, filePath, target.mode); + if (modeCheck && !modeCheck.missing && !modeCheck.directRegularFile) { + issues.push(modeCheck.issue); + unresolved.push(modeCheck.issue); + continue; + } if (!existsSync(filePath)) { issues.push({ agent: id, issue: `MCP config missing: ${filePath}` }); if (mode === "apply") { - await writeDocument(filePath, mcp, expected); + const fallbackTarget = promotedFallbacks.get(filePath); + if (fallbackTarget) { + let fallback: SerializedObject; + let fallbackRoot: McpServerRoot; + try { + fallback = await readExisting(fallbackTarget.filePath, mcp); + fallbackRoot = readServerRootOrBare( + fallback, + mcp.rootKey, + fallbackTarget.filePath, + fallbackTarget.recognizesBareServerMap === true || + fallbackTarget.acceptsBareServerMap === true, + ); + } catch { + const issue = { agent: id, issue: `Failed to read MCP config: ${fallbackTarget.filePath}` }; + issues.push(issue); + unresolved.push(issue); + continue; + } + await writeDocument( + filePath, + mcp, + mergeServerDocument( + fallback, + mcp.rootKey, + fallbackRoot, + expectedServers, + !pathFormat.requiresRoot, + ), + target.mode, + ); + } else { + await writeDocument(filePath, mcp, expected, target.mode); + } written.push(filePath); } continue; } + if (modeCheck) {issues.push(modeCheck.issue);} + if (mode === "apply") { + await repairModeBeforeRead(filePath, modeCheck, target.mode); + } + let existing: SerializedObject; - let existingServers: SerializedObject; + let existingRoot: McpServerRoot; try { existing = await readExisting(filePath, mcp); - existingServers = readServerRoot(existing, mcp.rootKey, filePath); + existingRoot = readServerRootOrBare( + existing, + mcp.rootKey, + filePath, + pathFormat.recognizesBare, + ); } catch { const issue = { agent: id, issue: `Failed to read MCP config: ${filePath}` }; issues.push(issue); @@ -156,15 +241,28 @@ export async function reconcileMcpConfigs( continue; } - const targetIssues = desiredIssues(id, filePath, existingServers, expectedServers); + const targetIssues = desiredIssues(id, filePath, existingRoot.servers, expectedServers); issues.push(...targetIssues); - - if (mode === "apply" && targetIssues.length > 0) { - const next = { - ...existing, - [mcp.rootKey]: { ...existingServers, ...expectedServers }, - }; - await writeReconciledDocument(filePath, mcp, next, expectedServers); + const envelopeIssue = existingRoot.bare && pathFormat.requiresRoot + ? { + agent: id, + issue: `MCP config bare server map must be nested under "${mcp.rootKey}" to share ${filePath}`, + } + : undefined; + if (envelopeIssue) {issues.push(envelopeIssue);} + const contentChanged = targetIssues.length > 0 || envelopeIssue !== undefined; + if (mode === "apply" && (contentChanged || modeCheck)) { + const next = mergeServerDocument( + existing, + mcp.rootKey, + existingRoot, + expectedServers, + !pathFormat.requiresRoot, + ); + if (contentChanged) { + await writeReconciledDocument(filePath, mcp, next, expectedServers, target.mode); + } + await enforceMode(filePath, mcp, next, modeCheck, target.mode); written.push(filePath); } } @@ -204,6 +302,12 @@ export async function reconcileManagedMcpConfig( const written: string[] = []; const removed: string[] = []; const skipped: McpReconcileIssue[] = []; + const modeCheck = await desiredModeIssue(agentId, target.filePath, target.mode); + if (modeCheck && !modeCheck.missing && !modeCheck.directRegularFile) { + issues.push(modeCheck.issue); + unresolved.push(modeCheck.issue); + return { issues, unresolved, written, managed: [], removed, skipped }; + } if (!existsSync(target.filePath)) { const managed = Object.keys(desired).filter((name) => !protectedNames.has(name)).toSorted(); @@ -218,7 +322,12 @@ export async function reconcileManagedMcpConfig( issues.push({ agent: agentId, issue: `MCP config missing: ${target.filePath}` }); if (mode === "apply") { const expected = Object.fromEntries(managed.map((name) => [name, desired[name]])); - await writeDocument(target.filePath, agent.mcp, { [agent.mcp.rootKey]: expected }); + await writeDocument( + target.filePath, + agent.mcp, + { [agent.mcp.rootKey]: expected }, + target.mode, + ); written.push(target.filePath); if (await writeManagedMcpState(statePath, managed)) {written.push(statePath);} } @@ -229,6 +338,11 @@ export async function reconcileManagedMcpConfig( return { issues, unresolved, written, managed, removed, skipped }; } + if (modeCheck) {issues.push(modeCheck.issue);} + if (mode === "apply") { + await repairModeBeforeRead(target.filePath, modeCheck, target.mode); + } + let existing: SerializedObject; let existingServers: SerializedObject; try { @@ -267,11 +381,15 @@ export async function reconcileManagedMcpConfig( issues.push({ agent: agentId, issue: `Managed MCP server "${name}" is stale in ${target.filePath}` }); } } - issues.push(...desiredIssues(agentId, target.filePath, existingServers, expected)); - + const targetIssues = desiredIssues(agentId, target.filePath, existingServers, expected); + issues.push(...targetIssues); if (mode === "apply") { const targetChanged = stale.some((name) => name in existingServers) || - desiredIssues(agentId, target.filePath, existingServers, expected).length > 0; + targetIssues.length > 0; + const nextServers = { ...existingServers }; + for (const name of stale) {delete nextServers[name];} + Object.assign(nextServers, expected); + const next = { ...existing, [agent.mcp.rootKey]: nextServers }; if (targetChanged) { await writeManagedReconciledDocument( target.filePath, @@ -280,10 +398,12 @@ export async function reconcileManagedMcpConfig( existingServers, expected, stale, + target.mode, ); - written.push(target.filePath); removed.push(...stale.filter((name) => name in existingServers)); } + await enforceMode(target.filePath, agent.mcp, next, modeCheck, target.mode); + if (targetChanged || modeCheck) {written.push(target.filePath);} if (managed.length > 0) { if (await writeManagedMcpState(statePath, managed)) {written.push(statePath);} } else if (stateResult.state) { @@ -360,13 +480,56 @@ function readServerRoot( return root; } +interface McpServerRoot { + servers: SerializedObject; + bare: boolean; +} + +function readServerRootOrBare( + document: SerializedObject, + rootKey: string, + filePath: string, + acceptsBare: boolean, +): McpServerRoot { + if ( + document[rootKey] === undefined && + acceptsBare && + isBareMcpServerMap(document) + ) { + return { servers: document, bare: true }; + } + return { servers: readServerRoot(document, rootKey, filePath), bare: false }; +} + +function isBareMcpServerMap(document: SerializedObject): boolean { + return Object.values(document).every((value) => ( + isSerializedObject(value) && + (isString(value["command"]) || isString(value["url"])) + )); +} + +function mergeServerDocument( + document: SerializedObject, + rootKey: string, + root: McpServerRoot, + expectedServers: SerializedObject, + preserveBare: boolean, +): SerializedObject { + const servers = { ...root.servers, ...expectedServers }; + if (root.bare) { + return preserveBare ? servers : { [rootKey]: servers }; + } + return { ...document, [rootKey]: servers }; +} + async function writeDocument( filePath: string, spec: McpConfigSpec, doc: SerializedObject, + mode?: number, ): Promise { await mkdir(dirname(filePath), { recursive: true }); - await writeFileIfChanged(filePath, serialize(doc, spec.format)); + await writeFileIfChanged(filePath, serialize(doc, spec.format), mode); } async function readExisting( @@ -404,20 +567,28 @@ async function writeReconciledDocument( spec: McpConfigSpec, doc: SerializedObject, expectedServers: SerializedObject, + mode?: number, ): Promise { if (spec.format !== "jsonc") { - await writeDocument(filePath, spec, doc); + await writeDocument(filePath, spec, doc, mode); return; } - let raw = await readFile(filePath, "utf-8"); + let raw: string; + try { + raw = await readFile(filePath, "utf-8"); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + await writeDocument(filePath, spec, doc, mode); + return; + } for (const [name, server] of Object.entries(expectedServers)) { const edits = modifyJsonc(raw, [spec.rootKey, name], server, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, }); raw = applyJsoncEdits(raw, edits); } - await writeFileIfChanged(filePath, raw.endsWith("\n") ? raw : `${raw}\n`); + await writeFileIfChanged(filePath, raw.endsWith("\n") ? raw : `${raw}\n`, mode); } async function writeManagedReconciledDocument( @@ -427,16 +598,26 @@ async function writeManagedReconciledDocument( existingServers: SerializedObject, expectedServers: SerializedObject, removedNames: string[], + mode?: number, ): Promise { + const servers = { ...existingServers }; + for (const name of removedNames) {delete servers[name];} + Object.assign(servers, expectedServers); + const next = { ...document, [spec.rootKey]: servers }; + if (spec.format !== "jsonc") { - const servers = { ...existingServers }; - for (const name of removedNames) {delete servers[name];} - Object.assign(servers, expectedServers); - await writeDocument(filePath, spec, { ...document, [spec.rootKey]: servers }); + await writeDocument(filePath, spec, next, mode); return; } - let raw = await readFile(filePath, "utf-8"); + let raw: string; + try { + raw = await readFile(filePath, "utf-8"); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + await writeDocument(filePath, spec, next, mode); + return; + } for (const name of removedNames) { raw = applyJsoncEdits(raw, modifyJsonc(raw, [spec.rootKey, name], undefined, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, @@ -447,7 +628,7 @@ async function writeManagedReconciledDocument( formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, })); } - await writeFileIfChanged(filePath, raw.endsWith("\n") ? raw : `${raw}\n`); + await writeFileIfChanged(filePath, raw.endsWith("\n") ? raw : `${raw}\n`, mode); } async function readManagedMcpState( @@ -497,14 +678,98 @@ function serialize(doc: SerializedObject, format: "json" | "jsonc" | "toml"): st return `${JSON.stringify(doc, null, 2)}\n`; } -async function writeFileIfChanged(filePath: string, content: string): Promise { +async function writeFileIfChanged( + filePath: string, + content: string, + mode?: number, +): Promise { try { if ((await readFile(filePath, "utf-8")) === content) {return;} } catch (err) { if (!isNotFoundError(err)) {throw err;} } - await writeFile(filePath, content, "utf-8"); + try { + await writeFile(filePath, content, { encoding: "utf-8", mode }); + } catch (err) { + if ( + mode === undefined + || (!hasErrorCode(err, "EACCES") && !hasErrorCode(err, "EPERM")) + ) { + throw err; + } + try { + await chmod(filePath, mode); + } catch (chmodError) { + if (!isNotFoundError(chmodError)) {throw chmodError;} + } + await writeFile(filePath, content, { encoding: "utf-8", mode }); + } +} + +async function desiredModeIssue( + agent: string, + filePath: string, + expectedMode?: number, +): Promise<{ issue: McpReconcileIssue; missing: boolean; directRegularFile: boolean } | undefined> { + if (expectedMode === undefined) {return undefined;} + let fileStat: Awaited>; + try { + fileStat = await lstat(filePath); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + return { + issue: { agent, issue: `MCP config missing: ${filePath}` }, + missing: true, + directRegularFile: false, + }; + } + if (!fileStat.isFile()) { + return { + issue: { agent, issue: `MCP config is not a regular file: ${filePath}` }, + missing: false, + directRegularFile: false, + }; + } + const actualMode = fileStat.mode & 0o777; + if (actualMode === expectedMode) {return undefined;} + return { + issue: { + agent, + issue: `MCP config mode is ${actualMode.toString(8)}, expected ${expectedMode.toString(8)}: ${filePath}`, + }, + missing: false, + directRegularFile: true, + }; +} + +async function repairModeBeforeRead( + filePath: string, + modeCheck: Awaited>, + expectedMode?: number, +): Promise { + if (!modeCheck || modeCheck.missing || !modeCheck.directRegularFile || expectedMode === undefined) {return;} + try { + await chmod(filePath, expectedMode); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + } +} + +async function enforceMode( + filePath: string, + spec: McpConfigSpec, + document: SerializedObject, + modeCheck: Awaited>, + expectedMode?: number, +): Promise { + if (!modeCheck || expectedMode === undefined) {return;} + try { + await chmod(filePath, expectedMode); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + await writeDocument(filePath, spec, document, expectedMode); + } } function isNotFoundError(err: ErrorValue): boolean { diff --git a/packages/dotagents/src/targets/paths.test.ts b/packages/dotagents/src/targets/paths.test.ts index de834574..5ae474a4 100644 --- a/packages/dotagents/src/targets/paths.test.ts +++ b/packages/dotagents/src/targets/paths.test.ts @@ -38,6 +38,55 @@ describe("getUserMcpTarget", () => { expect(t.shared).toBe(true); }); + it("copilot targets ~/.copilot/mcp-config.json by default", () => { + const previous = process.env["COPILOT_HOME"]; + delete process.env["COPILOT_HOME"]; + try { + const t = getUserMcpTarget("copilot"); + expect(t.filePath).toBe(join(home, ".copilot", "mcp-config.json")); + expect(t.shared).toBe(false); + expect(t.mode).toBe(process.platform === "win32" ? undefined : 0o600); + } finally { + if (previous === undefined) { + delete process.env["COPILOT_HOME"]; + } else { + process.env["COPILOT_HOME"] = previous; + } + } + }); + + it("copilot honors COPILOT_HOME", () => { + const previous = process.env["COPILOT_HOME"]; + process.env["COPILOT_HOME"] = join(home, "custom-copilot"); + try { + expect(getUserMcpTarget("copilot").filePath).toBe( + join(home, "custom-copilot", "mcp-config.json"), + ); + } finally { + if (previous === undefined) { + delete process.env["COPILOT_HOME"]; + } else { + process.env["COPILOT_HOME"] = previous; + } + } + }); + + it("copilot treats an empty COPILOT_HOME as unset", () => { + const previous = process.env["COPILOT_HOME"]; + process.env["COPILOT_HOME"] = ""; + try { + expect(getUserMcpTarget("copilot").filePath).toBe( + join(home, ".copilot", "mcp-config.json"), + ); + } finally { + if (previous === undefined) { + delete process.env["COPILOT_HOME"]; + } else { + process.env["COPILOT_HOME"] = previous; + } + } + }); + it("throws for unknown agent", () => { expect(() => getUserMcpTarget("emacs")).toThrow("Unknown agent"); }); @@ -77,6 +126,14 @@ describe("skill discovery paths", () => { expect(agent.skillsParentDir).toBeUndefined(); expect(agent.userSkillsParentDirs).toBeUndefined(); }); + + it("copilot reads project skills natively and projects global skills into its home", () => { + const agent = getAgent("copilot")!; + expect(agent.skillsParentDir).toBeUndefined(); + expect(agent.userSkillsParentDirs).toEqual([ + process.env["COPILOT_HOME"] || join(home, ".copilot"), + ]); + }); }); describe("subagent paths", () => { @@ -109,4 +166,8 @@ describe("subagent paths", () => { it("vscode does not support custom subagents", () => { expect(getAgent("vscode")!.subagents).toBeUndefined(); }); + + it("copilot does not support custom subagents", () => { + expect(getAgent("copilot")!.subagents).toBeUndefined(); + }); }); diff --git a/packages/dotagents/src/targets/paths.ts b/packages/dotagents/src/targets/paths.ts index a69e48c7..3f32b8e8 100644 --- a/packages/dotagents/src/targets/paths.ts +++ b/packages/dotagents/src/targets/paths.ts @@ -5,6 +5,7 @@ import type { McpTargetResolver } from "./mcp-writer.js"; export interface UserMcpTarget { filePath: string; shared: boolean; + mode?: number; } /** @@ -25,6 +26,14 @@ export function getUserMcpTarget(agentId: string): UserMcpTarget { return { filePath: vscodeMcpPath(), shared: false }; case "opencode": return { filePath: join(home, ".config", "opencode", "opencode.json"), shared: true }; + case "copilot": { + const target: UserMcpTarget = { + filePath: join(process.env["COPILOT_HOME"] || join(home, ".copilot"), "mcp-config.json"), + shared: false, + }; + if (process.platform !== "win32") {target.mode = 0o600;} + return target; + } default: throw new Error(`Unknown agent for user-scope MCP: ${agentId}`); } diff --git a/packages/dotagents/src/targets/registry.ts b/packages/dotagents/src/targets/registry.ts index 8bffe056..b45f7c39 100644 --- a/packages/dotagents/src/targets/registry.ts +++ b/packages/dotagents/src/targets/registry.ts @@ -4,8 +4,9 @@ import cursor from "./definitions/cursor.js"; import codex from "./definitions/codex.js"; import vscode from "./definitions/vscode.js"; import opencode from "./definitions/opencode.js"; +import copilot from "./definitions/copilot.js"; -const ALL_AGENTS: AgentDefinition[] = [claude, cursor, codex, vscode, opencode]; +const ALL_AGENTS: AgentDefinition[] = [claude, cursor, codex, vscode, opencode, copilot]; const AGENT_REGISTRY = new Map( ALL_AGENTS.map((a) => [a.id, a]), diff --git a/packages/dotagents/src/targets/skill-symlinks.test.ts b/packages/dotagents/src/targets/skill-symlinks.test.ts index c6ce124f..71c1e786 100644 --- a/packages/dotagents/src/targets/skill-symlinks.test.ts +++ b/packages/dotagents/src/targets/skill-symlinks.test.ts @@ -20,16 +20,19 @@ describe("skillSymlinkTargets", () => { ]); }); - it("returns deduplicated user targets and skips native readers", () => { + it("returns deduplicated user targets and skips only global native readers", () => { const scope = resolveScope("user"); expect( skillSymlinkTargets( scope, - ["claude", "cursor", "codex", "vscode", "opencode"], + ["claude", "cursor", "codex", "vscode", "opencode", "copilot"], [".legacy"], ), - ).toEqual([join(homedir(), ".claude")]); + ).toEqual([ + join(homedir(), ".claude"), + process.env["COPILOT_HOME"] || join(homedir(), ".copilot"), + ]); }); it("returns absolute project targets for a relative scope root", () => { diff --git a/packages/dotagents/src/targets/skill-symlinks.ts b/packages/dotagents/src/targets/skill-symlinks.ts index dc7420c1..5d4c0996 100644 --- a/packages/dotagents/src/targets/skill-symlinks.ts +++ b/packages/dotagents/src/targets/skill-symlinks.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { join, resolve } from "node:path"; import type { ScopeRoot } from "../scope.js"; import { getAgent } from "./registry.js"; @@ -28,6 +29,7 @@ export function skillSymlinkTargets( for (const agentId of agentIds) { for (const target of getAgent(agentId)?.userSkillsParentDirs ?? []) { + if (pathsReferToSameEntry(target, scope.agentsDir)) {continue;} if (seen.has(target)) {continue;} seen.add(target); targets.push(target); @@ -35,3 +37,12 @@ export function skillSymlinkTargets( } return targets; } + +function pathsReferToSameEntry(left: string, right: string): boolean { + if (resolve(left) === resolve(right)) {return true;} + try { + return realpathSync(left) === realpathSync(right); + } catch { + return false; + } +} diff --git a/packages/dotagents/src/targets/types.ts b/packages/dotagents/src/targets/types.ts index 6c343fbd..278fe2a2 100644 --- a/packages/dotagents/src/targets/types.ts +++ b/packages/dotagents/src/targets/types.ts @@ -54,6 +54,10 @@ export interface McpConfigSpec { filePath: string; /** Existing legacy paths to use before creating the preferred path */ fallbackFilePaths?: string[]; + /** Existing project files may use a bare server map instead of the root key. */ + acceptsBareServerMap?: boolean; + /** Existing bare server maps are recognized for migration but rewritten under the root key. */ + recognizesBareServerMap?: boolean; /** Top-level key in the config file under which servers live */ rootKey: string; /** File format */ diff --git a/skills/dotagents-qa/Dockerfile b/skills/dotagents-qa/Dockerfile index 90c899f4..d9227a4b 100644 --- a/skills/dotagents-qa/Dockerfile +++ b/skills/dotagents-qa/Dockerfile @@ -21,10 +21,12 @@ RUN mkdir -p "$PNPM_HOME" "$COREPACK_HOME" \ RUN npm install -g --no-audit --no-fund \ @anthropic-ai/claude-code@latest \ + @github/copilot@latest \ @openai/codex@latest \ @earendil-works/pi-coding-agent@latest \ opencode-ai@latest \ && claude --version \ + && copilot --version \ && codex --version \ && pi --version \ && opencode --version diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index 13e2b0ef..4f21c272 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -1,6 +1,6 @@ --- name: dotagents-qa -description: QA dotagents changes and published releases in Docker, including CLI lifecycles, user/global scope, real plugins, and Claude, Codex, OpenCode, or Pi projections. Use when behavior, packaging, scopes, or harness integration needs runtime proof. +description: QA dotagents changes and published releases in Docker, including CLI lifecycles, user/global scope, real plugins, and Claude, Copilot, Codex, OpenCode, or Pi projections. Use when behavior, packaging, scopes, or harness integration needs runtime proof. spec_hash: eda48b96deb3 --- @@ -26,7 +26,7 @@ Read the relevant references before acting: - Ordinary install/sync behavior: [references/core-agentic-qa.md](references/core-agentic-qa.md) - Real plugins, full lifecycle, native clients, and user/global scope: [references/release-plugin-matrix.md](references/release-plugin-matrix.md) - Plugin adapters and automated proof: [references/plugin-runtime.md](references/plugin-runtime.md) -- Harness details: [Claude](references/claude.md), [Codex](references/codex.md), [OpenCode](references/opencode.md), [Pi](references/pi.md), [Cursor](references/cursor.md), [Grok](references/grok.md) +- Harness details: [Claude](references/claude.md), [Copilot](references/copilot.md), [Codex](references/codex.md), [OpenCode](references/opencode.md), [Pi](references/pi.md), [Cursor](references/cursor.md), [Grok](references/grok.md) Planning is part of acting: read the relevant references before proposing a command sequence, not only before executing it. @@ -40,6 +40,7 @@ Run package and runtime work as a non-root user. Keep these inside Docker or dis export HOME=/sandbox/home export DOTAGENTS_STATE_DIR=/sandbox/state export DOTAGENTS_HOME=/sandbox/user-agents +export COPILOT_HOME=/sandbox/copilot-home export CODEX_HOME=/sandbox/codex-home export CLAUDE_CONFIG_DIR=/sandbox/claude-home ``` @@ -102,6 +103,7 @@ Use a fresh project per source. Record source commits. Do not guess repository n Keep per-harness fixtures isolated. In particular, do not enable Pi in the OpenCode proof: OpenCode can read Pi's shared `.agents/skills` links and create a false pass. - Claude: validate generated plugin and marketplace manifests, then marketplace add, install, list, and details. +- Copilot: add the generated marketplace, browse it, install the plugin, and list the installed plugin. - Codex: add the project or user marketplace root, list available plugins, install, and list enabled plugins. - OpenCode: run `opencode debug skill` and `opencode debug config`; assert exact projected skill names and locations plus every portable plugin MCP entry under `plugin..` with expanded paths and environment. - Pi: in a separate Pi-only fixture, verify expected skill links, resolved targets, and `.dotagents-managed/` ownership markers. diff --git a/skills/dotagents-qa/SOURCES.md b/skills/dotagents-qa/SOURCES.md index a36030da..07391e85 100644 --- a/skills/dotagents-qa/SOURCES.md +++ b/skills/dotagents-qa/SOURCES.md @@ -14,7 +14,7 @@ | `packages/dotagents/src/plugins/store.ts` | plugin discovery, source resolution, canonical install, and installed-bundle loading | | `packages/dotagents/src/plugins/runtime/layout.ts` | project versus user/global runtime destinations | | `packages/dotagents/src/plugins/runtime/{marketplace,opencode-mcp,writer}.ts` | generated marketplaces, manifests, OpenCode MCP projection, verification, and pruning | -| `skills/dotagents-qa/Dockerfile` | non-root QA image and installed Claude, Codex, OpenCode, and Pi CLIs | +| `skills/dotagents-qa/Dockerfile` | non-root QA image and installed Claude, Copilot, Codex, OpenCode, and Pi CLIs | | `skills/dotagents-qa/scripts/qa-example.mjs` | checked-in deterministic example and plugin harness assertions | ## Decisions @@ -29,7 +29,7 @@ - Require default-global and explicit-project lifecycle coverage when scope behavior changes, including aliases, conflicts, and cross-scope isolation. - Test Sentry, Vercel, and one selected Anthropic marketplace plugin as the high-signal compatibility set. - Keep OpenCode and Pi proofs isolated because both can observe `.agents/skills` and contaminate one another. -- Use native no-auth Claude/Codex management commands, OpenCode skill and MCP resource discovery, and Pi link inspection without claiming model invocation. +- Use native no-auth Claude/Copilot/Codex management commands, OpenCode skill and MCP resource discovery, and Pi link inspection without claiming model invocation. - Run focused regressions plus `pnpm check`, `pnpm qa:example`, and `pnpm qa:plugins` for plugin/runtime changes. - Require inspection of generated files and command output that demonstrate the changed behavior, not only exit codes. - Keep authentication out of ordinary QA; model-backed invocation is a separate, explicitly authorized layer. diff --git a/skills/dotagents-qa/references/copilot.md b/skills/dotagents-qa/references/copilot.md new file mode 100644 index 00000000..5d83fc76 --- /dev/null +++ b/skills/dotagents-qa/references/copilot.md @@ -0,0 +1,70 @@ +# GitHub Copilot QA + +Use this reference for Copilot skill, MCP, or plugin adapter changes. Keep +`COPILOT_HOME` inside the disposable Docker filesystem and run Copilot as the +non-root QA user. + +## Version and isolation + +```bash +copilot --version +export COPILOT_HOME=/sandbox/copilot-home +export COPILOT_ALLOW_ALL=true +mkdir -p "$COPILOT_HOME" +``` + +Do not reuse the host Copilot home or credentials. The checks below require no +model authentication. `COPILOT_ALLOW_ALL=true` is only for the disposable QA +container; it makes headless inventory commands include workspace MCP sources +without an interactive folder-trust prompt. + +## Project skills and MCP + +Run a Copilot-only project install, then use native inventory commands: + +```bash +dotagents --project install +copilot skill list --json +copilot mcp list --json +``` + +Assert that project skills resolve from `.agents/skills/`. Test both rooted +`{"mcpServers": {...}}` and bare `{...}` project MCP files. A Copilot-only +reconcile preserves the existing form; sharing `.mcp.json` with Claude promotes +a bare map under `mcpServers` so neither client hides unmanaged servers. + +## Global skills and MCP + +Use an isolated global config and verify: + +- `$COPILOT_HOME/skills` links to the selected dotagents global skills directory; +- `$COPILOT_HOME/mcp-config.json` preserves unmanaged entries; +- the MCP file has mode `0600` on POSIX; and +- an empty `COPILOT_HOME` falls back to `~/.copilot` for MCP, while native skill + inventory demonstrates why global skill users must unset the variable instead. + +## Plugins + +```bash +node skills/dotagents-qa/scripts/qa-example.mjs plugin-copilot +``` + +This adds the generated local marketplace, browses it, installs the plugin, +and verifies the live plugin skill and MCP inventory. It is native management +and resource-discovery proof, not model-backed invocation. + +Copilot prefers `marketplace.json` and `.plugin/marketplace.json` over +`.github/plugin/marketplace.json`; include a conflict case that proves dotagents +warns and removes stale managed output. Copilot resolves plugin manifests in +`.plugin`, root, `.github/plugin`, then `.claude-plugin` order. Sources with +only a `.plugin` or `.github/plugin` manifest are canonicalized to the portable +root. Conflicting locators must fail preflight when they would hide the +canonical source or make dotagents and Copilot select different manifests. In +legacy manifests, allow skills, MCP servers, and +cross-client fields Copilot leaves inert. Reject native agent, command, hook, +LSP, and executable-extension fields, plus active conventional paths even when +the manifest does not declare them. +For standard manifests, preserve arbitrary `extensions` data but reject a +physical top-level `com.github.copilot/` namespace. Copilot 1.0.83 loads agents, +commands, hooks, rules, LSP, and executable extensions from that namespace even +without a matching manifest entry. diff --git a/skills/dotagents-qa/references/docker-sandbox.md b/skills/dotagents-qa/references/docker-sandbox.md index ff2414ff..b0ee43bc 100644 --- a/skills/dotagents-qa/references/docker-sandbox.md +++ b/skills/dotagents-qa/references/docker-sandbox.md @@ -13,7 +13,7 @@ docker build --pull \ skills/dotagents-qa ``` -The image includes Node, pnpm, Git, jq, ripgrep, Claude Code, Codex, OpenCode, and Pi. Record their versions before client-specific claims. +The image includes Node, pnpm, Git, jq, ripgrep, Claude Code, GitHub Copilot CLI, Codex, OpenCode, and Pi. Record their versions before client-specific claims. ## Isolate the checkout and homes @@ -36,11 +36,12 @@ export CI=1 export HOME=/sandbox/home export DOTAGENTS_STATE_DIR=/sandbox/state export DOTAGENTS_HOME=/sandbox/user-agents +export COPILOT_HOME=/sandbox/copilot-home export CODEX_HOME=/sandbox/codex-home export CLAUDE_CONFIG_DIR=/sandbox/claude-home mkdir -p "$HOME" "$DOTAGENTS_STATE_DIR" "$DOTAGENTS_HOME" \ - "$CODEX_HOME" "$CLAUDE_CONFIG_DIR" /sandbox/repo + "$COPILOT_HOME" "$CODEX_HOME" "$CLAUDE_CONFIG_DIR" /sandbox/repo tar -C /host-repo \ --exclude=.git \ --exclude=node_modules \ diff --git a/skills/dotagents-qa/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md index 2d8e9005..85d24752 100644 --- a/skills/dotagents-qa/references/plugin-runtime.md +++ b/skills/dotagents-qa/references/plugin-runtime.md @@ -24,6 +24,7 @@ files and repair behavior. - Claude validates, adds, installs, and lists the generated plugin, then checks the skill and MCP component inventory. +- Copilot adds the generated marketplace, installs the plugin, and checks the skill and MCP component inventory. - Codex adds the marketplace, lists the plugin as available, installs it, and lists it as enabled. - Grok Build runs `plugin list` and `plugin details` when its CLI is installed. @@ -75,6 +76,16 @@ Manual final check with model auth: Codex plugin install proof is strong; plugin component invocation still needs a model-backed prompt because the plugin management CLI does not execute skills. +## GitHub Copilot + +Automated proof: + +```bash +node skills/dotagents-qa/scripts/qa-example.mjs plugin-copilot +``` + +This command adds the generated marketplace to an isolated `COPILOT_HOME`. It installs and lists the plugin. Copilot 1.0.83 loads a local plugin from its marketplace directory without a copied cache. The proof uses native commands to inspect the live plugin, its skill, and both MCP servers. + ## OpenCode projection (not native plugin E2E) ```bash diff --git a/skills/dotagents-qa/references/release-plugin-matrix.md b/skills/dotagents-qa/references/release-plugin-matrix.md index d156614c..8ac96618 100644 --- a/skills/dotagents-qa/references/release-plugin-matrix.md +++ b/skills/dotagents-qa/references/release-plugin-matrix.md @@ -6,9 +6,9 @@ Use this reference for release QA, plugin CLI changes, real plugin compatibility Inspect manifests before testing; do not guess names or layouts. The default high-signal set is: -- `getsentry/agent-plugin` — standalone Agent Plugins v1 bundle -- `vercel/vercel-plugin` — large native Claude plugin with skills, hooks, MCP, commands, and agents -- `anthropics/claude-plugins-official` — select one high-signal plugin from its current nested marketplace after inspection +- `getsentry/agent-plugin`: Standalone Agent Plugins v1 bundle +- `vercel/vercel-plugin`: Large native Claude plugin with skills, hooks, MCP, commands, and agents +- `anthropics/claude-plugins-official`: Select one high-signal plugin from its current nested marketplace after inspection Confirm current manifests and repository activity with `gh api`, `gh search code`, or cloned files. Use fewer plugins only when a source is unavailable and report that limitation. @@ -60,6 +60,19 @@ codex plugin list --json The local marketplace source must be the project directory, not the marketplace JSON file or its containing `.agents/plugins` directory. +### GitHub Copilot + +```bash +export COPILOT_HOME=/sandbox/copilot-home +mkdir -p "$COPILOT_HOME" +copilot plugin marketplace add ./ +copilot plugin marketplace browse dotagents +copilot plugin install @dotagents +copilot plugin list +``` + +The marketplace source must be the project directory. Copilot reads `.github/plugin/marketplace.json`. Copilot 1.0.83 loads local plugins from that directory and records enabled state under `COPILOT_HOME`. Use `copilot skill list --json` and `copilot mcp list --json` to inspect live resources. + ### OpenCode Use an OpenCode-only plugin target, then run: @@ -101,6 +114,7 @@ Expected default-home outputs: - canonical bundle: `$HOME/.agents/plugins//` - Claude marketplace: `$HOME/.agents/.claude-plugin/marketplace.json` +- Copilot marketplace: `$HOME/.agents/.github/plugin/marketplace.json` - Codex marketplace: `$HOME/.agents/plugins/marketplace.json` - OpenCode skills: `$HOME/.config/opencode/skills/` - OpenCode MCP config: `$HOME/.config/opencode/opencode.json` @@ -115,6 +129,10 @@ cd "$DOTAGENTS_HOME" claude plugin marketplace add ./ --scope user claude plugin install @dotagents --scope user +export COPILOT_HOME="$HOME/.copilot" +copilot plugin marketplace add "$DOTAGENTS_HOME" +copilot plugin install @dotagents + cd "$HOME" codex plugin marketplace add ./ --json codex plugin add @dotagents-local --json diff --git a/skills/dotagents-qa/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs index d25e4d26..1fd8239b 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -11,6 +11,7 @@ import { mkdtempSync, realpathSync, readFileSync, + renameSync, rmSync, writeFileSync, } from "node:fs"; @@ -37,6 +38,7 @@ const tasks = { "install-files": runInstallFiles, "sync-repair": runSyncRepair, "plugin-claude": runClaudePluginProof, + "plugin-copilot": runCopilotPluginProof, "plugin-codex": runCodexPluginProof, "plugin-grok": runGrokPluginProof, "opencode-projections": runOpenCodePluginProof, @@ -66,6 +68,7 @@ const projectDir = join(tmp, "project"); const homeDir = join(tmp, "home"); const stateDir = join(tmp, "state"); const dotagentsHomeDir = join(tmp, "dotagents-home"); +const copilotHomeDir = join(homeDir, ".copilot"); const codexHomeDir = join(tmp, "codex-home"); mkdirSync(homeDir, { recursive: true }); mkdirSync(stateDir, { recursive: true }); @@ -74,6 +77,7 @@ cpSync(exampleRoot, projectDir, { recursive: true }); const fixtureEnv = { ...process.env, + COPILOT_HOME: copilotHomeDir, HOME: homeDir, DOTAGENTS_HOME: dotagentsHomeDir, DOTAGENTS_STATE_DIR: stateDir, @@ -116,6 +120,7 @@ Tasks: install-files Install the full example and assert generated files sync-repair Delete representative generated files and assert sync repairs them plugin-claude Validate generated Claude plugin and marketplace with Claude Code + plugin-copilot Add, install, and list the generated marketplace with Copilot CLI plugin-codex Add/list/install generated Codex marketplace with Codex CLI plugin-grok Confirm Grok Build discovers the generated project plugin opencode-projections Assert generated OpenCode resource projections @@ -139,6 +144,7 @@ async function runSyncRepair() { rmSync(join(projectDir, ".codex", "agents", "code-reviewer.toml"), { force: true }); rmSync(join(projectDir, ".agents", "plugins", "marketplace.json"), { force: true }); rmSync(join(projectDir, ".claude-plugin", "marketplace.json"), { force: true }); + rmSync(join(projectDir, ".github", "plugin", "marketplace.json"), { force: true }); rmSync(join(projectDir, ".cursor-plugin", "marketplace.json"), { force: true }); rmSync(join(projectDir, ".agents", "plugins", "qa-tools", ".claude-plugin", "plugin.json"), { force: true }); rmSync(join(projectDir, ".agents", "plugins", "qa-tools", ".cursor-plugin", "plugin.json"), { force: true }); @@ -199,6 +205,7 @@ async function runClaudePluginProof() { async function runAvailablePluginClientProofs() { const proofs = [ ["claude", runClaudePluginProof], + ["copilot", runCopilotPluginProof], ["codex", runCodexPluginProof], ["grok", runGrokPluginProof], ]; @@ -217,6 +224,105 @@ async function runAvailablePluginClientProofs() { } } +async function runCopilotPluginProof() { + prepareClientHarness("copilot"); + rmSync(copilotHomeDir, { recursive: true, force: true }); + mkdirSync(copilotHomeDir, { recursive: true }); + const env = { + ...fixtureEnv, + COPILOT_HOME: copilotHomeDir, + COPILOT_ALLOW_ALL: "true", + }; + + execFileSync("copilot", ["plugin", "marketplace", "add", projectDir], { + cwd: projectDir, + env, + stdio: "inherit", + }); + const marketplaces = execFileSync("copilot", ["plugin", "marketplace", "list"], { + cwd: projectDir, + env, + encoding: "utf-8", + }); + assertIncludes(marketplaces, "dotagents", "Copilot marketplace list should include dotagents"); + assertIncludes(marketplaces, projectDir, "Copilot marketplace list should include the local project path"); + const available = execFileSync( + "copilot", + ["plugin", "marketplace", "browse", "dotagents"], + { cwd: projectDir, env, encoding: "utf-8" }, + ); + assertIncludes(available, "qa-tools", "Copilot marketplace should include qa-tools"); + + const install = execFileSync( + "copilot", + ["plugin", "install", "qa-tools@dotagents"], + { cwd: projectDir, env, encoding: "utf-8" }, + ); + assertIncludes(install, 'Plugin "qa-tools" installed successfully.', "Copilot should install qa-tools"); + const installed = execFileSync("copilot", ["plugin", "list"], { + cwd: projectDir, + env, + encoding: "utf-8", + }); + assertIncludes(installed, "qa-tools@dotagents", "Copilot plugin list should include qa-tools@dotagents"); + + const realProjectDir = realpathSync(projectDir); + const pluginRoot = realpathSync(join(projectDir, ".agents", "plugins", "qa-tools")); + const pluginState = execJson("copilot", ["plugins", "list", "--json"], env); + const livePlugin = pluginState.plugins?.find((plugin) => plugin.name === "qa-tools"); + if ( + livePlugin?.enabled !== true + || livePlugin.source !== "live-marketplace:dotagents" + || !isStringValue(livePlugin.installedFrom) + || realpathSync(livePlugin.installedFrom) !== realProjectDir + ) { + throw new Error("Copilot did not report enabled qa-tools from the live local marketplace"); + } + + const skills = execJson("copilot", ["skill", "list", "--json"], env); + const skill = skills.find((entry) => entry.name === "plugin-qa"); + if ( + skill?.source !== "plugin" + || skill.enabled !== true + || realpathSync(skill.path) !== realpathSync(join(pluginRoot, "skills", "plugin-qa")) + ) { + throw new Error("Copilot did not discover plugin-qa from the live local plugin"); + } + + const mcpServers = execJson("copilot", ["mcp", "list", "--json"], env).mcpServers ?? {}; + const pluginMcpNames = Object.entries(mcpServers) + .filter(([, server]) => server.source === "plugin" && server.sourcePlugin === "qa-tools") + .map(([name]) => name) + .toSorted(); + if (JSON.stringify(pluginMcpNames) !== JSON.stringify(["fixture-http", "fixture-stdio"])) { + throw new Error(`Copilot plugin MCP servers were unexpected: ${pluginMcpNames.join(", ")}`); + } + const local = mcpServers["fixture-stdio"]; + if ( + local?.source !== "plugin" + || local.sourcePlugin !== "qa-tools" + || local.enabled !== true + || local.type !== "stdio" + || local.command !== "node" + || JSON.stringify(local.args) !== JSON.stringify(["${PLUGIN_ROOT}/runtime/server.mjs"]) + || !isStringValue(local.env?.PLUGIN_ROOT) + || realpathSync(local.env.PLUGIN_ROOT) !== pluginRoot + ) { + throw new Error("Copilot did not discover the live plugin stdio MCP server"); + } + const remote = mcpServers["fixture-http"]; + if ( + remote?.source !== "plugin" + || remote.sourcePlugin !== "qa-tools" + || remote.enabled !== true + || remote.type !== "http" + || remote.url !== "https://example.com/${DEPLOYMENT}/mcp" + || !Object.hasOwn(remote.headers ?? {}, "X-Fixture") + ) { + throw new Error("Copilot did not discover the live plugin HTTP MCP server"); + } +} + async function runGrokPluginProof() { prepareClientHarness("grok"); const list = execFileSync("grok", ["plugin", "list"], { @@ -288,7 +394,7 @@ async function runOpenCodePluginProof() { const pluginData = realpathSync(join(projectDir, ".agents", "plugin-data", "qa-tools")); if (JSON.stringify(local) !== JSON.stringify({ type: "local", - command: ["node", join(pluginRoot, "server.mjs")], + command: ["node", join(pluginRoot, "runtime", "server.mjs")], cwd: pluginRoot, environment: { PLUGIN_ROOT: pluginRoot, @@ -310,6 +416,15 @@ async function runOpenCodePluginProof() { function prepareClientHarness(agent) { rmSync(projectDir, { recursive: true, force: true }); cpSync(exampleRoot, projectDir, { recursive: true }); + if (agent === "copilot") { + const sourcePluginDir = join(projectDir, "local-plugins", "qa-tools"); + const copilotManifestDir = join(sourcePluginDir, ".github", "plugin"); + mkdirSync(copilotManifestDir, { recursive: true }); + renameSync( + join(sourcePluginDir, "plugin.json"), + join(copilotManifestDir, "plugin.json"), + ); + } const configPath = join(projectDir, "agents.toml"); const config = readFileSync(configPath, "utf-8").replace(/^agents = .*$/m, `agents = ["${agent}"]`); writeFileSync(configPath, config); @@ -483,7 +598,7 @@ function assertPluginOutputs() { assertFile(".agents/plugins/qa-tools/mcp.json"); assertFileIncludes(".agents/plugins/qa-tools/mcp.json", '"fixture-stdio"'); assertFileIncludes(".agents/plugins/qa-tools/mcp.json", '"fixture-http"'); - assertFile(".agents/plugins/qa-tools/server.mjs"); + assertFile(".agents/plugins/qa-tools/runtime/server.mjs"); assertFile(".agents/plugins/qa-tools/skills/plugin-qa/SKILL.md"); assertFile(".agents/plugins/qa-tools/com.example.client/commands/plugin-qa.md"); assertFile(".agents/plugins/qa-tools/com.example.client/agents/plugin-reviewer.md"); @@ -508,6 +623,9 @@ function assertPluginOutputs() { assertFileExcludes(".claude-plugin/marketplace.json", '"managedBy"'); assertFileIncludes(".claude-plugin/marketplace.json", '"name": "qa-tools"'); assertFileIncludes(".claude-plugin/marketplace.json", '"source": "./.agents/plugins/qa-tools"'); + assertFile(".github/plugin/marketplace.json"); + assertFile(".github/plugin/marketplace.json.dotagents-managed"); + assertSameFile(".github/plugin/marketplace.json", ".claude-plugin/marketplace.json"); assertFile(".cursor-plugin/marketplace.json"); assertFile(".cursor-plugin/marketplace.json.dotagents-managed"); assertSameFile(".cursor-plugin/marketplace.json", ".claude-plugin/marketplace.json"); @@ -534,7 +652,7 @@ function assertPluginOutputs() { assertFile(".grok/plugins/qa-tools/.dotagents-managed"); assertFile(".grok/plugins/qa-tools/plugin.json"); - assertFile(".grok/plugins/qa-tools/server.mjs"); + assertFile(".grok/plugins/qa-tools/runtime/server.mjs"); assertFile(".grok/plugins/qa-tools/com.example.client/commands/plugin-qa.md"); assertFile(".grok/plugins/qa-tools/com.example.client/agents/plugin-reviewer.md"); assertFileIncludes(".grok/plugins/qa-tools/skills/plugin-qa/SKILL.md", "DOTAGENTS_PLUGIN_QA_FIXTURE"); diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index fee3b32d..34a66e6c 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -4,7 +4,7 @@ description: Manage dotagents dependencies and runtime config. Use when asked to spec_hash: 98920b9b8a05 --- -Manage dependencies declared in `agents.toml`. dotagents resolves skills, subagents, plugins, MCP servers, and hooks so agent tools (Claude Code, Cursor, Codex, Grok, VS Code, OpenCode, Pi) can use shared global or project config. +Manage dependencies declared in `agents.toml`. dotagents resolves skills, subagents, plugins, MCP servers, and hooks so agent tools (Claude Code, Cursor, Codex, GitHub Copilot, Grok, VS Code, OpenCode, Pi) can use shared global or project config. ## Running dotagents diff --git a/skills/dotagents/references/cli-reference.md b/skills/dotagents/references/cli-reference.md index f6564784..c1a4ce19 100644 --- a/skills/dotagents/references/cli-reference.md +++ b/skills/dotagents/references/cli-reference.md @@ -43,7 +43,7 @@ npx @sentry/dotagents --project init | Flag | Description | |------|-------------| -| `--agents ` | Comma-separated agent targets (claude, cursor, codex, vscode, grok, opencode, pi) | +| `--agents ` | Comma-separated agent targets (claude, cursor, codex, copilot, vscode, grok, opencode, pi) | | `--force` | Overwrite existing `agents.toml` | **Interactive mode** (when TTY is available): diff --git a/skills/dotagents/references/config-schema.md b/skills/dotagents/references/config-schema.md index 4af1d6cf..91f4711e 100644 --- a/skills/dotagents/references/config-schema.md +++ b/skills/dotagents/references/config-schema.md @@ -4,7 +4,7 @@ ```toml version = 1 # Required, must be 1 -agents = ["claude", "cursor"] # Optional, agent targets +agents = ["claude", "cursor", "copilot"] # Optional, agent targets defaultRepositorySource = "github" # Optional, github or gitlab minimum_release_age = 60 # Optional, minutes minimum_release_age_exclude = ["getsentry/*"] # Optional @@ -24,7 +24,7 @@ minimum_release_age_exclude = ["getsentry/*"] # Optional |-------|------|----------|---------|-------------| | `version` | integer | Yes | -- | Schema version, must be `1` | | `defaultRepositorySource` | string | No | `github` | Host for shorthand `owner/repo` sources. Valid values: `github`, `gitlab` | -| `agents` | string[] | No | `[]` | Agent targets: `claude`, `cursor`, `codex`, `vscode`, `grok`, `opencode`, `pi` | +| `agents` | string[] | No | `[]` | Agent targets: `claude`, `cursor`, `codex`, `copilot`, `vscode`, `grok`, `opencode`, `pi` | | `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install | | `minimum_release_age_exclude` | string[] | No | `[]` | Sources that bypass `minimum_release_age` | @@ -175,7 +175,7 @@ name = "review-tools" # Required, unique plugin identifier source = "getsentry/agent-plugins" # Required, source repository or path ref = "v1.0.0" # Optional, pin to tag/branch/commit path = "plugins/review-tools" # Optional, plugin directory within source -targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +targets = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` | Field | Type | Required | Description | @@ -229,3 +229,4 @@ Local path skills, subagents, and plugins have `source` only. |----------|---------| | `DOTAGENTS_STATE_DIR` | Override cache location (default: `~/.local/dotagents`) | | `DOTAGENTS_HOME` | Override global-scope location (default: `~/.agents`) | +| `COPILOT_HOME` | Override Copilot global skills and MCP location with a non-empty absolute path (default when unset: `~/.copilot`) | diff --git a/skills/dotagents/references/configuration.md b/skills/dotagents/references/configuration.md index 4463f5d1..b87f8abb 100644 --- a/skills/dotagents/references/configuration.md +++ b/skills/dotagents/references/configuration.md @@ -104,6 +104,7 @@ MCP configs are written per-agent in the appropriate format: - Codex: `.codex/config.toml` (TOML, shared with other Codex config) - VS Code: `.vscode/mcp.json` (JSON) - OpenCode: `.opencode/opencode.jsonc` by default (JSONC, shared); existing nested or root OpenCode config files are reused. +- GitHub Copilot: `.mcp.json` or an existing `.github/mcp.json`; global scope uses `$COPILOT_HOME/mcp-config.json` (default `~/.copilot/mcp-config.json`). ## Hooks @@ -151,25 +152,26 @@ Declare plugin bundles with `[[plugins]]`. dotagents installs canonical bundles name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" -targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +targets = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` Plugin declarations work in global and project scope. Canonical bundles and runtime projections use the selected scope's paths. Global bundles live under `~/.agents/plugins/`. Runtime outputs use global -Claude, Cursor, Codex, Grok (`~/.grok/plugins/`), OpenCode, and Pi locations. +Claude, Cursor, Codex, Copilot, Grok (`~/.grok/plugins/`), OpenCode, and Pi +locations. ## Agents The `agents` array controls which agent tools get symlinks and configs. ```toml -agents = ["claude", "cursor", "codex", "vscode", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "vscode", "grok", "opencode", "pi"] ``` Each agent gets: - A `/skills/` symlink pointing to the selected scope's managed skills directory (Claude, Cursor) -- Or native discovery from the selected scope's managed skills directory (Codex, VS Code, OpenCode) +- Or native project discovery from the selected scope's managed skills directory (Codex, GitHub Copilot, VS Code, OpenCode) - MCP server configs in the agent's config file - Hook configs (where supported) - Subagent and plugin runtime outputs (where supported) @@ -186,7 +188,8 @@ npx @sentry/dotagents add getsentry/agent-plugins review-tools npx @sentry/dotagents install ``` -Global symlinks include `~/.claude/skills/` for Claude and Cursor. +Global skill links include `~/.claude/skills/` for Claude and Cursor and +`$COPILOT_HOME/skills/` for Copilot (default `~/.copilot/skills/`). ### Project Scope (`--project`) diff --git a/specs/SPEC.md b/specs/SPEC.md index 96a65354..d16d41e9 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -31,7 +31,7 @@ The manifest file. Lives at the selected scope root: `~/.agents/agents.toml` by ```toml version = 1 -agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] [project] name = "my-project" # Optional. For display purposes. @@ -82,7 +82,7 @@ targets = ["claude", "codex", "opencode"] name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" -targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +targets = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` ### Fields @@ -93,14 +93,14 @@ targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] |-------|----------|-------------| | `version` | Yes | Schema version. Always `1`. | | `defaultRepositorySource` | No | Host used for shorthand `owner/repo` skill sources. Valid values: `github`, `gitlab`. Defaults to `github`. | -| `agents` | No | Array of agent tool IDs. Valid: `claude`, `cursor`, `codex`, `vscode`, `grok`, `opencode`, `pi`. Defaults to `[]`. When set, dotagents creates skills symlinks and runtime config files for each agent where supported. `grok` and `pi` are plugin-only targets. | +| `agents` | No | Array of agent tool IDs. Valid: `claude`, `cursor`, `codex`, `copilot`, `vscode`, `grok`, `opencode`, `pi`. Defaults to `[]`. When set, dotagents creates skills symlinks and runtime config files for each agent where supported. `grok` and `pi` are plugin-only targets. | | `project` | No | Project metadata. | | `symlinks` | No | Symlink configuration (legacy — prefer `agents` for new projects). | | `skills` | No | Skill dependencies (array of tables). | | `mcp` | No | MCP server declarations (array of tables). Generates agent-specific config files during install/sync. | | `hooks` | No | Hook declarations (array of tables). Generates agent-specific hook config files during install/sync for agents that support hooks. | | `subagents` | No | Custom subagent declarations (array of tables). Generates runtime-specific subagent files during install/sync for Claude, Cursor, Codex, and OpenCode. | -| `plugins` | No | Plugin declarations (array of tables). Installs canonical bundles into `.agents/plugins/` and generates runtime-specific plugin outputs during install/sync for Claude, Cursor, Codex, Grok, OpenCode, and Pi skill projection. | +| `plugins` | No | Plugin declarations (array of tables). Installs canonical bundles into `.agents/plugins/` and generates runtime-specific plugin outputs during install/sync for Claude, Cursor, Codex, Copilot, Grok, OpenCode, and Pi skill projection. | | `trust` | No | Trusted source restrictions. When absent, all sources allowed. See `[trust]` below. | | `minimum_release_age` | No | Minimum age in **minutes** a commit must have before it's eligible for install. Applies to all git skills, subagents, and plugins (pinned and unpinned). For unpinned sources, resolves to the newest qualifying commit. For pinned sources (`ref`), rejects if the pinned commit is too new. Install fails with an error if no qualifying commit exists. When absent, always uses HEAD. | | `minimum_release_age_exclude` | No | Sources excluded from the age gate. Accepts org names (`"myorg"` matches all repos), org/repo (`"myorg/skills"` exact match), or org wildcards (`"myorg/*"`). Defaults to `[]`. | @@ -186,6 +186,7 @@ A server must have either `command` (stdio) or `url` (Streamable HTTP), but not | Agent | Output syntax | |-------|---------------| | Claude Code | `${VAR}` (unchanged) | +| GitHub Copilot | `${VAR}` (unchanged) | | Cursor | `${env:VAR}` | | VS Code | `${env:VAR}` | | OpenCode | `{env:VAR}` | @@ -261,15 +262,16 @@ compatibility implementation (see the remaining gaps in `specs/plugins.md`): | Agent | Project Scope Output | |-------|----------------------| | Claude Code | `.claude-plugin/marketplace.json`; `.agents/plugins//.claude-plugin/plugin.json` | +| GitHub Copilot | `.github/plugin/marketplace.json`; canonical `.agents/plugins//plugin.json` | | Cursor | `.cursor-plugin/marketplace.json`; `.agents/plugins//.cursor-plugin/plugin.json` | | Codex | `.agents/plugins/marketplace.json`; `.agents/plugins//.codex-plugin/plugin.json` | | Grok Build | `.grok/plugins//` managed copy | | OpenCode | Plugin `skills/` symlinked into `.opencode/skills/`; portable `mcp.json` servers merged into `.opencode/opencode.jsonc` under `plugin..` keys; generalized legacy plugin Markdown `agents/` symlinked into `.opencode/agents/`. Standard extension agents are preserved but not projected yet. | | Pi | Plugin `skills/` symlinked into `.agents/skills/` when `pi` is a configured plugin target | -Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated marketplaces and Claude/Cursor/Codex manifests use adjacent `.dotagents-managed` sidecars; OpenCode/Pi component symlinks use marker files in reserved sibling `.dotagents-managed/` directories. This keeps ownership explicit without changing client-owned JSON or consuming a valid component name. Legacy `metadata.managedBy` output remains recognizable during migration. Managed Grok copies and component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Existing plugin install destinations are overwritten only when their on-disk `.dotagents-managed` marker proves ownership. +Generated plugin JSON is stable: keys are sorted, plugin entries are sorted by name, and files end with one trailing newline. Generated marketplaces and Claude, Cursor, and Codex manifests use adjacent `.dotagents-managed` sidecars; OpenCode and Pi component symlinks use marker files in reserved sibling `.dotagents-managed/` directories. This keeps ownership explicit without changing client-owned JSON or consuming a valid component name. Legacy `metadata.managedBy` output remains recognizable during migration. Managed Grok copies and component symlinks are pruned when their plugin or target is removed. Plugin sources that resolve to this project's `.agents/plugins//` install destination are rejected so dotagents never installs a same-repo plugin onto itself. Existing plugin install destinations are overwritten only when their on-disk `.dotagents-managed` marker proves ownership. -Global scope installs canonical plugins into `~/.agents/plugins//`. It generates Claude and Cursor marketplaces below `~/.agents/`, a Codex marketplace at `~/.agents/plugins/marketplace.json` whose local paths are rooted at the user's home, OpenCode skill and legacy-agent projections below `~/.config/opencode/`, portable plugin MCP entries in `~/.config/opencode/opencode.json`, and Pi skill projections below `~/.agents/skills/`. +Global scope installs canonical plugins into `~/.agents/plugins//`. It generates Claude and Cursor marketplaces below `~/.agents/`, a Copilot marketplace at `~/.agents/.github/plugin/marketplace.json`, and a Codex marketplace at `~/.agents/plugins/marketplace.json`. Codex local paths start at the user's home. OpenCode skill and legacy-agent projections use `~/.config/opencode/`. Portable plugin MCP entries use `~/.config/opencode/opencode.json`. Pi skill projections use `~/.agents/skills/`. #### Supported Agents @@ -278,12 +280,18 @@ Global scope installs canonical plugins into `~/.agents/plugins//`. It gen | `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` | +| `copilot` | GitHub Copilot CLI | `.copilot` | `.mcp.json` or `.github/mcp.json` | JSON | Not supported | | `grok` | Grok Build | `.grok` | Not generated | Not generated | Not generated | | `vscode` | VS Code Copilot | `.vscode` | `.vscode/mcp.json` | JSON | Not supported | | `opencode` | OpenCode | `.opencode` | `.opencode/opencode.jsonc` | JSONC (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`. Grok is currently supported for plugin projections only. +Copilot reads project skills from `.agents/skills/` and links global skills into +`$COPILOT_HOME/skills/` (default `~/.copilot/skills/`). Its project MCP accepts +bare server maps and `mcpServers` documents; global MCP uses +`$COPILOT_HOME/mcp-config.json` and mode `0600` on POSIX. + ### Source Types The source format is inferred from the value. Shorthand `owner/repo` resolves using `defaultRepositorySource` (default: GitHub). @@ -893,7 +901,7 @@ dotagents/ doctor.ts targets/ types.ts # Target agent interfaces and MCP/hook declarations - registry.ts # Target registry (claude, cursor, codex, vscode, opencode) + registry.ts # Target registry (claude, cursor, codex, vscode, opencode, copilot) definitions/ # Per-target definitions mcp-writer.ts # MCP config file generation per target hook-writer.ts # Hook config file generation per target diff --git a/specs/plugins.md b/specs/plugins.md index fc322a3b..0854ee1b 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -276,7 +276,7 @@ name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" ref = "v1.0.0" -targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +targets = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` | Field | Required | Description | @@ -429,10 +429,21 @@ Agent Plugin bundle | Claude Code | Keep `plugin.json`, `skills/`, and `mcp.json` intact | Read only namespaces registered to the Claude adapter | Generate the project marketplace with `./`-prefixed project-root-relative plugin sources and, only when required by Claude's loader, a managed `.claude-plugin/plugin.json` adapter derived from core metadata plus its registered extension. | | Cursor | Keep portable core intact | Read only namespaces registered to the Cursor adapter | Generate the project marketplace with the same project-root-relative source contract and, only when required, a managed `.cursor-plugin/plugin.json` adapter derived from core metadata plus its registered extension. | | Codex | Keep portable core intact | Read only namespaces registered to the Codex adapter | Generate `.agents/plugins/marketplace.json` with local source paths resolved from the project root, plus a managed `.codex-plugin/plugin.json` adapter only for Codex-only metadata the portable manifest cannot express. | +| GitHub Copilot | Keep portable core intact | Read only namespaces registered to the Copilot adapter | Generate `.github/plugin/marketplace.json` with project-root-relative plugin sources. Copilot reads the canonical Agent Plugins manifest directly. | | Grok Build | Copy the validated bundle without changing portable files | Read only namespaces registered to the Grok adapter | Generate `.grok/plugins//` as a managed copy until Grok can consume the canonical bundle directly. | | OpenCode | Project plugin skills and merge normalized MCP servers into OpenCode config when needed | Read only namespaces registered to the OpenCode adapter | Symlink skills into `.opencode/skills/`; generalized legacy bundles may project Markdown agents, while standard extension resources are preserved but not projected yet; do not generate JavaScript or TypeScript plugin modules. | | Pi | Project supported skills | Read only namespaces registered to the Pi adapter | Symlink skills into `.agents/skills/`; ignore unsupported MCP or extension components with warnings. | +Copilot follows its native precedence for marketplaces (`marketplace.json`, +`.plugin/marketplace.json`, then `.github/plugin/marketplace.json`) and plugin +manifests (`.plugin`, root, `.github/plugin`, then `.claude-plugin`). Dotagents +canonicalizes a lone alternate manifest, but rejects shadowing locators and +warns while pruning stale managed output behind a higher-priority marketplace. +Legacy Copilot targets allow skills and MCP only; native agents, commands, +hooks, LSP, executables, implicit component paths, and a physical +`com.github.copilot/` extension are rejected. Standard extension metadata is +preserved. These are harness constraints, not additions to the portable format. + For Claude, Cursor, and Codex, a retained matching native fallback replaces the generated-manifest step for that target only. A reproducible authored manifest does not. For Grok, hybrid compatibility copies exclude `.claude-plugin`, @@ -508,13 +519,13 @@ review-tools/ `-- review.mdc ``` -With the current adapters, an install for -`targets = ["claude", "cursor", "codex", "opencode", "pi"]` produces: +With the current adapters, an install for `targets = ["claude", "cursor", "codex", "copilot", "opencode", "pi"]` produces: ```text .agents/plugins/review-tools/ # portable source files unchanged; managed adapter dirs added .claude-plugin/marketplace.json # generated registration .agents/plugins/review-tools/.claude-plugin/plugin.json +.github/plugin/marketplace.json # generated Copilot registration .cursor-plugin/marketplace.json # generated registration .agents/plugins/review-tools/.cursor-plugin/plugin.json .agents/plugins/marketplace.json # generated Codex registration