From 056e9db553c9b6a5c15c6addc3c31b2edf2958b1 Mon Sep 17 00:00:00 2001 From: Misha Milovidov <42074547+mishamilovidov@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:09:49 -0700 Subject: [PATCH 01/10] feat: add github copilot target Add user and project MCP configuration for GitHub Copilot CLI and Desktop while preserving unmanaged native entries. Reuse Claude-compatible project serialization and protect the user MCP file with mode 0600 on POSIX systems. --- README.md | 7 +- docs/public/llms.txt | 14 +- docs/src/content/docs/cli.mdx | 4 +- docs/src/content/docs/index.mdx | 5 +- .../src/cli/commands/install-user.test.ts | 75 ++++++++++- .../src/cli/commands/install.test.ts | 15 ++- .../src/targets/definitions/copilot.ts | 27 ++++ .../dotagents/src/targets/mcp-writer.test.ts | 123 +++++++++++++++++- packages/dotagents/src/targets/mcp-writer.ts | 75 ++++++++--- packages/dotagents/src/targets/paths.test.ts | 43 ++++++ packages/dotagents/src/targets/paths.ts | 7 + .../dotagents/src/targets/registry.test.ts | 1 + packages/dotagents/src/targets/registry.ts | 3 +- .../src/targets/skill-symlinks.test.ts | 2 +- specs/SPEC.md | 10 +- 15 files changed, 373 insertions(+), 38 deletions(-) create mode 100644 packages/dotagents/src/targets/definitions/copilot.ts diff --git a/README.md b/README.md index e353caf4..7eb3e40d 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/Desktop, 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 | @@ -126,6 +126,7 @@ agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] | `grok` | `.grok` | -- | -- | -- | | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | -- | | `opencode` | `.opencode` | `.opencode/opencode.jsonc` | -- | `.opencode/agents/*.md` | +| `copilot` | `.copilot` | `.mcp.json` or `.github/mcp.json` | -- | -- | Custom subagents are declared with `[[subagents]]` entries. dotagents writes generated runtime-specific files during `install` and repairs them during `sync`: @@ -153,6 +154,8 @@ 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`. +GitHub Copilot reads project skills from `.agents/skills/` without a symlink. New project MCP configuration uses `.mcp.json`. An existing `.github/mcp.json` is reused when `.mcp.json` is absent. Global MCP configuration uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs the global file with mode `0600`, which matches Copilot CLI. + 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: ```toml diff --git a/docs/public/llms.txt b/docs/public/llms.txt index b3cf0673..c92e292a 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/Desktop, 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/*"] @@ -158,7 +158,7 @@ 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. | | `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install. | @@ -211,7 +211,7 @@ Each `[[mcp]]` entry requires `name` and either `command` (stdio) or `url` (HTTP | `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. + +GitHub Copilot accepts the same implicit stdio shape as Claude, so both targets produce stable shared `.mcp.json` output. In user scope, GitHub Copilot uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs this file with mode `0600`. ### Hooks @@ -501,8 +504,9 @@ Check selected-scope health: gitignore setup where applicable, installed skills | `codex` | Codex | `.codex` | (reads `.agents/skills/` natively) | `.codex/config.toml` | Not supported | `.codex/agents/*.toml` | | `vscode` | VS Code Copilot | `.vscode` | (reads `.agents/skills/` natively) | `.vscode/mcp.json` | `.claude/settings.json` | Not supported | | `opencode` | OpenCode | `.opencode` | (reads `.agents/skills/` natively) | `.opencode/opencode.jsonc` by default | Not supported | `.opencode/agents/*.md` | +| `copilot` | GitHub Copilot CLI/Desktop | `.copilot` | (reads `.agents/skills/` natively) | `.mcp.json` or `.github/mcp.json` | Not supported | Not supported | -Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, and OpenCode read `.agents/skills/` directly. +Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, OpenCode, and GitHub Copilot read `.agents/skills/` directly. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. Normal skills need no Pi-specific target or symlink configuration; plugin bundles can target `pi` when their `skills/` components should be exposed there. diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 5a645107..9305ec53 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: @@ -369,7 +369,7 @@ 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. | | `minimum_release_age` | integer | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install. | diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 3d620f22..4f78ed67 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -97,7 +97,7 @@ targets = ["claude", "cursor", "codex", "opencode"]`}

Supported Agents

-

The `agents` array tells dotagents which tools to configure. Grok and Pi are plugin-only targets; Pi also reads `.agents/skills/` directly.

+

The `agents` array tells dotagents which tools to configure. Grok and Pi are plugin-only targets. Copilot and Pi read `.agents/skills/` directly.

| Agent | Config Dir | MCP Config | Hooks | Subagents | @@ -105,9 +105,12 @@ 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` | None | None | | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | None | | `opencode` | `.opencode` | `.opencode/opencode.jsonc` | None | `.opencode/agents/*.md` | + GitHub Copilot reads user MCP configuration from `~/.copilot/mcp-config.json`. Projects use `.mcp.json`, or reuse `.github/mcp.json` when it exists. + OpenCode reuses existing project config from `.opencode/opencode.jsonc`, `.opencode/opencode.json`, `opencode.jsonc`, or `opencode.json`, in that order. New projects use `.opencode/opencode.jsonc`.
diff --git a/packages/dotagents/src/cli/commands/install-user.test.ts b/packages/dotagents/src/cli/commands/install-user.test.ts index 1c4ee71f..09c787a5 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) { @@ -128,4 +134,71 @@ source = "path:skill-source/pdf" const lockfile = await loadLockfile(scope.lockPath); expect(lockfile!.skills["pdf"]).toEqual({ source: "path:skill-source/pdf" }); }); + + it("writes copilot MCP config without a skill symlink", async () => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-user-copilot-")); + 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 }] = await Promise.all([ + import("./install.js"), + import("../../scope.js"), + ]); + + await mkdir(sourceDir, { recursive: true }); + await mkdir(copilotHome, { recursive: true }); + await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD); + await writeFile( + join(copilotHome, "mcp-config.json"), + JSON.stringify({ + note: "keep", + mcpServers: { + manual: { command: "manual", args: [] }, + fixture: { command: "old", args: [] }, + }, + }), + ); + + const scope = resolveScope("user"); + await mkdir(scope.root, { recursive: true }); + await writeFile( + scope.configPath, + `version = 1 +agents = ["copilot"] + +[[skills]] +name = "pdf" +source = "path:skill-source/pdf" + +[[mcp]] +name = "fixture" +command = "node" +args = ["server.js"] +`, + ); + + await runInstall({ scope }); + + expect(existsSync(join(scope.skillsDir, "pdf", "SKILL.md"))).toBe(true); + expect(existsSync(join(copilotHome, "skills"))).toBe(false); + expect(JSON.parse(await readFile(join(copilotHome, "mcp-config.json"), "utf-8"))).toEqual({ + note: "keep", + mcpServers: { + manual: { command: "manual", args: [] }, + fixture: { command: "node", args: ["server.js"] }, + }, + }); + if (process.platform !== "win32") { + expect((await stat(join(copilotHome, "mcp-config.json"))).mode & 0o777).toBe(0o600); + } + }); }); diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 86f94823..e309cd9a 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -1087,8 +1087,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( @@ -1120,12 +1126,16 @@ 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, ".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"), ); @@ -1158,6 +1168,7 @@ source = "path:plugin-source/review-tools" } else { process.env["HOME"] = previousOsHome; } + vi.resetModules(); } }); diff --git a/packages/dotagents/src/targets/definitions/copilot.ts b/packages/dotagents/src/targets/definitions/copilot.ts new file mode 100644 index 00000000..e41ab360 --- /dev/null +++ b/packages/dotagents/src/targets/definitions/copilot.ts @@ -0,0 +1,27 @@ +import type { AgentDefinition } from "../types.js"; +import { UnsupportedFeature } from "../errors.js"; +import claude from "./claude.js"; + +const copilot: AgentDefinition = { + id: "copilot", + displayName: "GitHub Copilot", + configDir: ".copilot", + // reads .agents/skills/ natively at both project and user scope + skillsParentDir: undefined, + userSkillsParentDirs: undefined, + mcp: { + filePath: ".mcp.json", + fallbackFilePaths: [".github/mcp.json"], + 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 0b977bb3..797b1cba 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 } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { existsSync } from "node:fs"; @@ -93,6 +93,17 @@ describe("writeMcpConfigs", () => { expect(raw).toContain("github"); }); + it("writes copilot .mcp.json with environment references", async () => { + await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); + + const content = JSON.parse(await readFile(join(dir, ".mcp.json"), "utf-8")); + expect(content.mcpServers.github).toEqual({ + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }); + }); + it("writes .opencode/opencode.jsonc by default", async () => { await writeMcpConfigs(["opencode"], [STDIO_SERVER], projectMcpResolver(dir)); @@ -221,6 +232,69 @@ describe("writeMcpConfigs", () => { expect(existsSync(join(dir, ".opencode", "opencode.jsonc"))).toBe(false); }); + it("reuses .github/mcp.json for copilot when .mcp.json is absent", async () => { + const filePath = join(dir, ".github", "mcp.json"); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify({ + note: "keep", + mcpServers: { manual: { command: "manual", args: [] } }, + })); + + await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(existsSync(join(dir, ".mcp.json"))).toBe(false); + expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ + note: "keep", + mcpServers: { + manual: { command: "manual", args: [] }, + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }, + }); + }); + + it("prefers copilot .mcp.json when both project paths exist", async () => { + const preferredPath = join(dir, ".mcp.json"); + const fallbackPath = join(dir, ".github", "mcp.json"); + await mkdir(dirname(fallbackPath), { recursive: true }); + await writeFile(preferredPath, JSON.stringify({ mcpServers: {} })); + await writeFile(fallbackPath, JSON.stringify({ + mcpServers: { fallback: { command: "fallback", args: [] } }, + })); + + await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(JSON.parse(await readFile(preferredPath, "utf-8")).mcpServers.github).toBeDefined(); + expect(JSON.parse(await readFile(fallbackPath, "utf-8"))).toEqual({ + mcpServers: { fallback: { command: "fallback", args: [] } }, + }); + }); + + it("writes the same shared .mcp.json for claude and copilot in either order", async () => { + const firstDir = join(dir, "first"); + const secondDir = join(dir, "second"); + await mkdir(firstDir); + await mkdir(secondDir); + + await writeMcpConfigs( + ["claude", "copilot"], + [STDIO_SERVER, HTTP_SERVER], + projectMcpResolver(firstDir), + ); + await writeMcpConfigs( + ["copilot", "claude"], + [STDIO_SERVER, HTTP_SERVER], + projectMcpResolver(secondDir), + ); + + expect(await readFile(join(firstDir, ".mcp.json"), "utf-8")).toBe( + await readFile(join(secondDir, ".mcp.json"), "utf-8"), + ); + }); + it("handles multiple servers", async () => { await writeMcpConfigs(["claude"], [STDIO_SERVER, HTTP_SERVER], projectMcpResolver(dir)); @@ -406,6 +480,42 @@ describe("writeMcpConfigs", () => { expect(second.mtimeNs).toBe(first.mtimeNs); }); + it.skipIf(process.platform === "win32")( + "creates, repairs, and preserves a secure user config mode", + 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 chmod(filePath, 0o644); + const before = await stat(filePath, { bigint: true }); + const repaired = await reconcileMcpConfigs( + ["copilot"], + [STDIO_SERVER], + resolver, + "apply", + ); + const after = await stat(filePath, { bigint: true }); + expect(repaired.written).toEqual([filePath]); + expect(repaired.issues).toEqual([ + expect.objectContaining({ issue: expect.stringContaining("expected 600") }), + ]); + expect(after.mode & 0o777n).toBe(0o600n); + expect(after.mtimeNs).toBe(before.mtimeNs); + + const unchanged = await reconcileMcpConfigs( + ["copilot"], + [STDIO_SERVER], + resolver, + "apply", + ); + expect(unchanged.issues).toEqual([]); + expect(unchanged.written).toEqual([]); + }, + ); + it("interpolates env refs in claude HTTP headers/URL with ${VAR} syntax", async () => { await writeMcpConfigs(["claude"], [HTTP_SERVER_WITH_ENV_REFS], projectMcpResolver(dir)); @@ -451,6 +561,17 @@ describe("writeMcpConfigs", () => { }); }); + it("interpolates env refs in copilot HTTP headers/URL with ${VAR} syntax", async () => { + await writeMcpConfigs(["copilot"], [HTTP_SERVER_WITH_ENV_REFS], projectMcpResolver(dir)); + + const content = JSON.parse(await readFile(join(dir, ".mcp.json"), "utf-8")); + expect(content.mcpServers["authed-api"]).toEqual({ + type: "http", + url: "https://${API_HOST}/mcp", + headers: { "X-Api-Key": "${API_KEY}", Authorization: "Bearer ${TOKEN}" }, + }); + }); + it("preserves literal HTTP placeholder-like values for adapter declarations", async () => { const literal = { ...HTTP_SERVER_WITH_ENV_REFS, interpolateEnvRefs: false }; await writeMcpConfigs(["opencode", "codex"], [literal], projectMcpResolver(dir)); diff --git a/packages/dotagents/src/targets/mcp-writer.ts b/packages/dotagents/src/targets/mcp-writer.ts index 15899cf7..78df1bbe 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, readFile, writeFile, mkdir, rm, stat } from "node:fs/promises"; import { join, dirname } from "node:path"; import { existsSync } from "node:fs"; import { isDeepStrictEqual } from "node:util"; @@ -22,6 +22,7 @@ import { isSerializedObject, type SerializedObject } from "@sentry/dotagents-lib export interface McpResolvedTarget { filePath: string; shared: boolean; + mode?: number; } export type McpTargetResolver = (agentId: string, spec: McpConfigSpec) => McpResolvedTarget; @@ -126,7 +127,8 @@ export async function reconcileMcpConfigs( if (!agent) {continue;} const { mcp } = agent; - const { filePath } = resolveTarget(id, mcp); + const target = resolveTarget(id, mcp); + const { filePath } = target; if (seen.has(filePath)) {continue;} seen.add(filePath); @@ -136,7 +138,7 @@ export async function reconcileMcpConfigs( if (!existsSync(filePath)) { issues.push({ agent: id, issue: `MCP config missing: ${filePath}` }); if (mode === "apply") { - await writeDocument(filePath, mcp, expected); + await writeDocument(filePath, mcp, expected, target.mode); written.push(filePath); } continue; @@ -157,13 +159,18 @@ export async function reconcileMcpConfigs( const targetIssues = desiredIssues(id, filePath, existingServers, expectedServers); issues.push(...targetIssues); - - if (mode === "apply" && targetIssues.length > 0) { - const next = { - ...existing, - [mcp.rootKey]: { ...existingServers, ...expectedServers }, - }; - await writeReconciledDocument(filePath, mcp, next, expectedServers); + const modeIssue = await desiredModeIssue(id, filePath, target.mode); + if (modeIssue) {issues.push(modeIssue);} + + if (mode === "apply" && (targetIssues.length > 0 || modeIssue)) { + if (targetIssues.length > 0) { + const next = { + ...existing, + [mcp.rootKey]: { ...existingServers, ...expectedServers }, + }; + await writeReconciledDocument(filePath, mcp, next, expectedServers, target.mode); + } + if (modeIssue && target.mode !== undefined) {await chmod(filePath, target.mode);} written.push(filePath); } } @@ -217,7 +224,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);} } @@ -267,6 +279,8 @@ export async function reconcileManagedMcpConfig( } } issues.push(...desiredIssues(agentId, target.filePath, existingServers, expected)); + const modeIssue = await desiredModeIssue(agentId, target.filePath, target.mode); + if (modeIssue) {issues.push(modeIssue);} if (mode === "apply") { const targetChanged = stale.some((name) => name in existingServers) || @@ -279,10 +293,12 @@ export async function reconcileManagedMcpConfig( existingServers, expected, stale, + target.mode, ); - written.push(target.filePath); removed.push(...stale.filter((name) => name in existingServers)); } + if (modeIssue && target.mode !== undefined) {await chmod(target.filePath, target.mode);} + if (targetChanged || modeIssue) {written.push(target.filePath);} if (managed.length > 0) { if (await writeManagedMcpState(statePath, managed)) {written.push(statePath);} } else if (stateResult.state) { @@ -363,9 +379,10 @@ 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( @@ -403,9 +420,10 @@ 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; } @@ -416,7 +434,7 @@ async function writeReconciledDocument( }); 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( @@ -426,12 +444,13 @@ async function writeManagedReconciledDocument( existingServers: SerializedObject, expectedServers: SerializedObject, removedNames: string[], + mode?: number, ): Promise { 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, { ...document, [spec.rootKey]: servers }, mode); return; } @@ -446,7 +465,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( @@ -489,14 +508,32 @@ 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"); + await writeFile(filePath, content, { encoding: "utf-8", mode }); +} + +async function desiredModeIssue( + agent: string, + filePath: string, + expectedMode?: number, +): Promise { + if (expectedMode === undefined) {return undefined;} + const actualMode = (await stat(filePath)).mode & 0o777; + if (actualMode === expectedMode) {return undefined;} + return { + agent, + issue: `MCP config mode is ${actualMode.toString(8)}, expected ${expectedMode.toString(8)}: ${filePath}`, + }; } function isNotFoundError(err: unknown): boolean { diff --git a/packages/dotagents/src/targets/paths.test.ts b/packages/dotagents/src/targets/paths.test.ts index de834574..69b72e07 100644 --- a/packages/dotagents/src/targets/paths.test.ts +++ b/packages/dotagents/src/targets/paths.test.ts @@ -38,6 +38,39 @@ 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("throws for unknown agent", () => { expect(() => getUserMcpTarget("emacs")).toThrow("Unknown agent"); }); @@ -77,6 +110,12 @@ describe("skill discovery paths", () => { expect(agent.skillsParentDir).toBeUndefined(); expect(agent.userSkillsParentDirs).toBeUndefined(); }); + + it("copilot reads .agents/skills/ natively", () => { + const agent = getAgent("copilot")!; + expect(agent.skillsParentDir).toBeUndefined(); + expect(agent.userSkillsParentDirs).toBeUndefined(); + }); }); describe("subagent paths", () => { @@ -109,4 +148,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..9dfb5d77 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,12 @@ 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": + return { + filePath: join(process.env["COPILOT_HOME"] ?? join(home, ".copilot"), "mcp-config.json"), + shared: false, + ...(process.platform === "win32" ? {} : { mode: 0o600 }), + }; default: throw new Error(`Unknown agent for user-scope MCP: ${agentId}`); } diff --git a/packages/dotagents/src/targets/registry.test.ts b/packages/dotagents/src/targets/registry.test.ts index 5adeec1d..5152c9b7 100644 --- a/packages/dotagents/src/targets/registry.test.ts +++ b/packages/dotagents/src/targets/registry.test.ts @@ -9,6 +9,7 @@ describe("allAgentIds", () => { expect(ids).toContain("codex"); expect(ids).toContain("vscode"); expect(ids).toContain("opencode"); + expect(ids).toContain("copilot"); }); }); 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..7ba21b6a 100644 --- a/packages/dotagents/src/targets/skill-symlinks.test.ts +++ b/packages/dotagents/src/targets/skill-symlinks.test.ts @@ -26,7 +26,7 @@ describe("skillSymlinkTargets", () => { expect( skillSymlinkTargets( scope, - ["claude", "cursor", "codex", "vscode", "opencode"], + ["claude", "cursor", "codex", "vscode", "opencode", "copilot"], [".legacy"], ), ).toEqual([join(homedir(), ".claude")]); diff --git a/specs/SPEC.md b/specs/SPEC.md index e67967b6..3fd39b80 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. @@ -93,7 +93,7 @@ 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). | @@ -186,6 +186,7 @@ A server must have either `command` (stdio) or `url` (HTTP), but not both. | Agent | Output syntax | |-------|---------------| | Claude Code | `${VAR}` (unchanged) | +| GitHub Copilot | `${VAR}` (unchanged) | | Cursor | `${env:VAR}` | | VS Code | `${env:VAR}` | | OpenCode | `{env:VAR}` | @@ -281,9 +282,12 @@ Global scope installs canonical plugins into `~/.agents/plugins//`. It gen | `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` | +| `copilot` | GitHub Copilot CLI/Desktop | `.copilot` | `.mcp.json` or `.github/mcp.json` | JSON | Not supported | 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. +GitHub Copilot accepts the same implicit stdio shape as Claude, so both targets produce stable shared `.mcp.json` output. For user scope, GitHub Copilot uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs this file with mode `0600`. + ### Source Types The source format is inferred from the value. Shorthand `owner/repo` resolves using `defaultRepositorySource` (default: GitHub). @@ -892,7 +896,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 From 3c3871e3f0ca5f1c8a326ffdc310f25fbe109ae2 Mon Sep 17 00:00:00 2001 From: Misha Milovidov <42074547+mishamilovidov@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:45:29 -0700 Subject: [PATCH 02/10] fix: harden mcp reconciliation Preserve fallback MCP entries when Claude and Copilot converge on `.mcp.json`. Recover if a mode check races with external file deletion. --- .../dotagents/src/targets/mcp-writer.test.ts | 29 +++++ packages/dotagents/src/targets/mcp-writer.ts | 120 ++++++++++++++---- 2 files changed, 125 insertions(+), 24 deletions(-) diff --git a/packages/dotagents/src/targets/mcp-writer.test.ts b/packages/dotagents/src/targets/mcp-writer.test.ts index 797b1cba..7e8d3002 100644 --- a/packages/dotagents/src/targets/mcp-writer.test.ts +++ b/packages/dotagents/src/targets/mcp-writer.test.ts @@ -273,6 +273,35 @@ describe("writeMcpConfigs", () => { }); }); + it.each([ + ["claude", "copilot"], + ["copilot", "claude"], + ])("seeds shared .mcp.json from the copilot fallback for %s first", async (first, second) => { + const preferredPath = join(dir, ".mcp.json"); + const fallbackPath = join(dir, ".github", "mcp.json"); + const fallback = { + note: "keep", + mcpServers: { manual: { command: "manual", args: [] } }, + }; + await mkdir(dirname(fallbackPath), { recursive: true }); + await writeFile(fallbackPath, JSON.stringify(fallback)); + + await writeMcpConfigs([first, second], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(JSON.parse(await readFile(preferredPath, "utf-8"))).toEqual({ + note: "keep", + mcpServers: { + manual: { command: "manual", args: [] }, + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }, + }); + expect(JSON.parse(await readFile(fallbackPath, "utf-8"))).toEqual(fallback); + }); + it("writes the same shared .mcp.json for claude and copilot in either order", async () => { const firstDir = join(dir, "first"); const secondDir = join(dir, "second"); diff --git a/packages/dotagents/src/targets/mcp-writer.ts b/packages/dotagents/src/targets/mcp-writer.ts index 78df1bbe..d41ffb84 100644 --- a/packages/dotagents/src/targets/mcp-writer.ts +++ b/packages/dotagents/src/targets/mcp-writer.ts @@ -23,6 +23,7 @@ export interface McpResolvedTarget { filePath: string; shared: boolean; mode?: number; + preferredFilePath?: string; } export type McpTargetResolver = (agentId: string, spec: McpConfigSpec) => McpResolvedTarget; @@ -74,9 +75,12 @@ 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 }), }; }; } @@ -122,12 +126,24 @@ 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.filePath); + return Object.assign({}, entry, { + target: Object.assign({}, entry.target, { filePath: preferredFilePath }), + }); + }); + for (const { id, agent, target } of targets) { const { mcp } = agent; - const target = resolveTarget(id, mcp); const { filePath } = target; if (seen.has(filePath)) {continue;} seen.add(filePath); @@ -138,7 +154,24 @@ export async function reconcileMcpConfigs( if (!existsSync(filePath)) { issues.push({ agent: id, issue: `MCP config missing: ${filePath}` }); if (mode === "apply") { - await writeDocument(filePath, mcp, expected, target.mode); + const fallbackPath = promotedFallbacks.get(filePath); + if (fallbackPath) { + try { + const fallback = await readExisting(fallbackPath, mcp); + const fallbackServers = readServerRoot(fallback, mcp.rootKey, fallbackPath); + await writeDocument(filePath, mcp, { + ...fallback, + [mcp.rootKey]: { ...fallbackServers, ...expectedServers }, + }, target.mode); + } catch { + const issue = { agent: id, issue: `Failed to read MCP config: ${fallbackPath}` }; + issues.push(issue); + unresolved.push(issue); + continue; + } + } else { + await writeDocument(filePath, mcp, expected, target.mode); + } written.push(filePath); } continue; @@ -159,18 +192,29 @@ export async function reconcileMcpConfigs( const targetIssues = desiredIssues(id, filePath, existingServers, expectedServers); issues.push(...targetIssues); - const modeIssue = await desiredModeIssue(id, filePath, target.mode); - if (modeIssue) {issues.push(modeIssue);} - - if (mode === "apply" && (targetIssues.length > 0 || modeIssue)) { + const modeCheck = await desiredModeIssue(id, filePath, target.mode); + if (modeCheck) {issues.push(modeCheck.issue);} + + if (mode === "apply" && (targetIssues.length > 0 || modeCheck)) { + const next = { + ...existing, + [mcp.rootKey]: { ...existingServers, ...expectedServers }, + }; if (targetIssues.length > 0) { - const next = { - ...existing, - [mcp.rootKey]: { ...existingServers, ...expectedServers }, - }; await writeReconciledDocument(filePath, mcp, next, expectedServers, target.mode); } - if (modeIssue && target.mode !== undefined) {await chmod(filePath, target.mode);} + if (modeCheck && target.mode !== undefined) { + if (modeCheck.missing && targetIssues.length === 0) { + await writeDocument(filePath, mcp, next, target.mode); + } else if (!modeCheck.missing) { + try { + await chmod(filePath, target.mode); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + await writeDocument(filePath, mcp, next, target.mode); + } + } + } written.push(filePath); } } @@ -278,13 +322,14 @@ 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 modeIssue = await desiredModeIssue(agentId, target.filePath, target.mode); - if (modeIssue) {issues.push(modeIssue);} + const targetIssues = desiredIssues(agentId, target.filePath, existingServers, expected); + issues.push(...targetIssues); + const modeCheck = await desiredModeIssue(agentId, target.filePath, target.mode); + if (modeCheck) {issues.push(modeCheck.issue);} if (mode === "apply") { const targetChanged = stale.some((name) => name in existingServers) || - desiredIssues(agentId, target.filePath, existingServers, expected).length > 0; + targetIssues.length > 0; if (targetChanged) { await writeManagedReconciledDocument( target.filePath, @@ -297,8 +342,23 @@ export async function reconcileManagedMcpConfig( ); removed.push(...stale.filter((name) => name in existingServers)); } - if (modeIssue && target.mode !== undefined) {await chmod(target.filePath, target.mode);} - if (targetChanged || modeIssue) {written.push(target.filePath);} + if (modeCheck && target.mode !== undefined) { + const nextServers = { ...existingServers }; + for (const name of stale) {delete nextServers[name];} + Object.assign(nextServers, expected); + const next = { ...existing, [agent.mcp.rootKey]: nextServers }; + if (modeCheck.missing && !targetChanged) { + await writeDocument(target.filePath, agent.mcp, next, target.mode); + } else if (!modeCheck.missing) { + try { + await chmod(target.filePath, target.mode); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + await writeDocument(target.filePath, agent.mcp, next, 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) { @@ -526,13 +586,25 @@ async function desiredModeIssue( agent: string, filePath: string, expectedMode?: number, -): Promise { +): Promise<{ issue: McpReconcileIssue; missing: boolean } | undefined> { if (expectedMode === undefined) {return undefined;} - const actualMode = (await stat(filePath)).mode & 0o777; + let actualMode: number; + try { + actualMode = (await stat(filePath)).mode & 0o777; + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + return { + issue: { agent, issue: `MCP config missing: ${filePath}` }, + missing: true, + }; + } if (actualMode === expectedMode) {return undefined;} return { - agent, - issue: `MCP config mode is ${actualMode.toString(8)}, expected ${expectedMode.toString(8)}: ${filePath}`, + issue: { + agent, + issue: `MCP config mode is ${actualMode.toString(8)}, expected ${expectedMode.toString(8)}: ${filePath}`, + }, + missing: false, }; } From 2f451cdc2b02a3a7c6780e1dc4283db7b7cde80e Mon Sep 17 00:00:00 2001 From: Misha Milovidov <42074547+mishamilovidov@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:54:39 -0700 Subject: [PATCH 03/10] fix: preserve mcp write failures Propagate preferred-file write errors instead of reporting them as fallback read failures. Recreate JSONC state if the config file disappears during reconciliation. --- packages/dotagents/src/targets/mcp-writer.ts | 42 ++++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/dotagents/src/targets/mcp-writer.ts b/packages/dotagents/src/targets/mcp-writer.ts index d41ffb84..66e6d39f 100644 --- a/packages/dotagents/src/targets/mcp-writer.ts +++ b/packages/dotagents/src/targets/mcp-writer.ts @@ -156,19 +156,21 @@ export async function reconcileMcpConfigs( if (mode === "apply") { const fallbackPath = promotedFallbacks.get(filePath); if (fallbackPath) { + let fallback: SerializedObject; + let fallbackServers: SerializedObject; try { - const fallback = await readExisting(fallbackPath, mcp); - const fallbackServers = readServerRoot(fallback, mcp.rootKey, fallbackPath); - await writeDocument(filePath, mcp, { - ...fallback, - [mcp.rootKey]: { ...fallbackServers, ...expectedServers }, - }, target.mode); + fallback = await readExisting(fallbackPath, mcp); + fallbackServers = readServerRoot(fallback, mcp.rootKey, fallbackPath); } catch { const issue = { agent: id, issue: `Failed to read MCP config: ${fallbackPath}` }; issues.push(issue); unresolved.push(issue); continue; } + await writeDocument(filePath, mcp, { + ...fallback, + [mcp.rootKey]: { ...fallbackServers, ...expectedServers }, + }, target.mode); } else { await writeDocument(filePath, mcp, expected, target.mode); } @@ -487,7 +489,14 @@ async function writeReconciledDocument( 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" }, @@ -506,15 +515,24 @@ async function writeManagedReconciledDocument( 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 }, mode); + 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" }, From aaacfd9b646f32e46165ec494f76f959988b4865 Mon Sep 17 00:00:00 2001 From: Misha Milovidov <42074547+mishamilovidov@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:19:18 -0700 Subject: [PATCH 04/10] feat: add copilot plugin marketplace Generate a native Copilot marketplace from portable plugin bundles so Copilot can install their skills without the Pi target. Preserve managed-output ownership, pruning, and unchanged-write behavior across project and global scopes. --- README.md | 8 +-- docs/public/llms.txt | 9 ++-- docs/src/content/docs/cli.mdx | 19 ++----- examples/full/agents.toml | 2 +- .../src/cli/commands/install-user.test.ts | 4 +- .../src/cli/commands/install.test.ts | 17 +++++-- packages/dotagents/src/config/loader.test.ts | 6 +-- .../dotagents/src/plugins/runtime/layout.ts | 6 +++ .../src/plugins/runtime/marketplace.ts | 14 ++++- .../src/plugins/runtime/writer.test.ts | 47 +++++++++++++++-- packages/dotagents/src/plugins/store.test.ts | 32 ++++++++++++ packages/dotagents/src/plugins/store.ts | 1 + packages/dotagents/src/plugins/targets.ts | 2 +- packages/dotagents/src/targets/paths.ts | 8 +-- skills/dotagents-qa/SKILL.md | 3 +- .../dotagents-qa/references/plugin-runtime.md | 11 ++++ .../references/release-plugin-matrix.md | 18 +++++++ skills/dotagents-qa/scripts/qa-example.mjs | 51 +++++++++++++++++++ specs/SPEC.md | 7 +-- specs/plugins.md | 7 +-- 20 files changed, 222 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 2498c291..f8dae26a 100644 --- a/README.md +++ b/README.md @@ -156,19 +156,21 @@ OpenCode reuses an existing project config from `.opencode/opencode.jsonc`, `.op GitHub Copilot reads project skills from `.agents/skills/` without a symlink. New project MCP configuration uses `.mcp.json`. An existing `.github/mcp.json` is reused when `.mcp.json` is absent. Global MCP configuration uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs the global file with mode `0600`, which matches Copilot CLI. -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//`. Copilot consumes the canonical Agent Plugins bundle directly, so it does not need a generated plugin manifest. 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", "copilot", "cursor", "codex", "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`. + +After installation, register the DotAgents root with Copilot by running `copilot plugin marketplace add `. Then run `copilot plugin install @dotagents`. 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 835705a1..22b36b5c 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -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", "copilot", "cursor", "codex", "grok", "opencode", "pi"] ``` ### Top-level Fields @@ -160,7 +160,7 @@ targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] | `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`, `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, Copilot, Cursor, Codex, Grok, OpenCode, and Pi skill projection where supported. | | `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install. | | `minimum_release_age_exclude` | string[] | No | `[]` | Sources that bypass the minimum release age gate. Supports org names, `org/repo`, and `org/*`. | @@ -306,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 diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index c5e8a895..099d9a49 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -482,16 +482,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, Copilot, Cursor, and Codex 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 +498,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`) diff --git a/examples/full/agents.toml b/examples/full/agents.toml index a5d50677..6df05ea2 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", "copilot", "cursor", "codex", "grok", "opencode", "pi"] [[skills]] name = "review" diff --git a/packages/dotagents/src/cli/commands/install-user.test.ts b/packages/dotagents/src/cli/commands/install-user.test.ts index 09c787a5..8f737d99 100644 --- a/packages/dotagents/src/cli/commands/install-user.test.ts +++ b/packages/dotagents/src/cli/commands/install-user.test.ts @@ -104,8 +104,8 @@ 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)); expect(JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"))).toEqual({ diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 6e267ce4..7a068532 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -216,7 +216,7 @@ describe("runInstall", () => { await writeFile( join(projectRoot, "agents.toml"), `version = 1 -agents = ["codex", "claude", "cursor"] +agents = ["codex", "claude", "copilot", "cursor"] [[plugins]] name = "review-tools" @@ -291,6 +291,7 @@ source = "path:plugin-source/review-tools" join(projectRoot, ".agents", "plugins", "review-tools"), ); expect(await readFile(join(projectRoot, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(claudeMarketplaceJson); + expect(await readFile(join(projectRoot, ".github", "plugin", "marketplace.json"), "utf-8")).toBe(claudeMarketplaceJson); const claudeManifest = parseJsonObject(await readFile( join(projectRoot, ".agents", "plugins", "review-tools", ".claude-plugin", "plugin.json"), @@ -316,7 +317,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 +418,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 +482,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"], @@ -1432,7 +1438,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" @@ -1444,6 +1450,7 @@ source = "path:plugin-source/review-tools" 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); diff --git a/packages/dotagents/src/config/loader.test.ts b/packages/dotagents/src/config/loader.test.ts index 806ab9d5..fcb69e48 100644 --- a/packages/dotagents/src/config/loader.test.ts +++ b/packages/dotagents/src/config/loader.test.ts @@ -252,18 +252,18 @@ source = "https://agents.example.com" await writeFile( configPath, `version = 1 -agents = ["claude", "codex", "cursor", "grok", "opencode", "pi"] +agents = ["claude", "codex", "copilot", "cursor", "grok", "opencode", "pi"] [[plugins]] name = "review-tools" source = "getsentry/plugins" -targets = ["claude", "codex", "cursor", "grok", "opencode", "pi"] +targets = ["claude", "codex", "copilot", "cursor", "grok", "opencode", "pi"] `, ); const config = await loadConfig(configPath); expect(config.plugins).toHaveLength(1); - expect(config.plugins[0]!.targets).toEqual(["claude", "codex", "cursor", "grok", "opencode", "pi"]); + expect(config.plugins[0]!.targets).toEqual(["claude", "codex", "copilot", "cursor", "grok", "opencode", "pi"]); expect(config.plugins[0]!.source).toBe("getsentry/plugins"); }); 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..52765ec8 100644 --- a/packages/dotagents/src/plugins/runtime/marketplace.ts +++ b/packages/dotagents/src/plugins/runtime/marketplace.ts @@ -13,6 +13,7 @@ export function marketplaceOutputPaths(root: PluginRuntimeRoot): string[] { return [ layout.codexMarketplacePath, layout.claudeMarketplacePath, + layout.copilotMarketplacePath, layout.cursorMarketplacePath, ]; } @@ -28,6 +29,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 +41,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 +87,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..16594e04 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,23 @@ 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("does not rewrite an unchanged Copilot marketplace", async () => { + const alpha = await plugin("alpha-tools"); + const marketplacePath = join(root, ".github", "plugin", "marketplace.json"); + + expect(await writePluginOutputs(["copilot"], [alpha], root)).toMatchObject({ written: 1 }); + const firstStat = await lstat(marketplacePath); + const firstContent = await readFile(marketplacePath, "utf-8"); + + expect(await writePluginOutputs(["copilot"], [alpha], root)).toEqual({ + warnings: [], + written: 0, + }); + expect(await readFile(marketplacePath, "utf-8")).toBe(firstContent); + expect((await lstat(marketplacePath)).mtimeMs).toBe(firstStat.mtimeMs); }); it("uses default Codex categories for empty legacy category values", async () => { @@ -688,6 +705,25 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(true); }); + it("does not overwrite unmanaged Copilot marketplace files", async () => { + const alpha = await plugin("alpha-tools"); + const marketplacePath = join(root, ".github", "plugin", "marketplace.json"); + await mkdir(dirname(marketplacePath), { recursive: true }); + await writeFile(marketplacePath, "{ \"name\": \"mine\" }\n", "utf-8"); + + const result = await writePluginOutputs(["copilot"], [alpha], root); + + expect(result.written).toBe(0); + expect(result.warnings).toEqual([ + { + agent: "copilot", + name: "marketplace", + message: `Plugin marketplace exists and is not managed by dotagents: ${marketplacePath}`, + }, + ]); + expect(await readFile(marketplacePath, "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + }); + it.each([ ["codex", "Codex", ".codex-plugin"], ["claude", "Claude", ".claude-plugin"], @@ -723,6 +759,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 +1352,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 +1371,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/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 3186490d..4f354b1b 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -686,6 +686,38 @@ describe("plugin store", () => { } }); + it("discovers plugins 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"); + await mkdir(join(sourceRoot, ".github", "plugin"), { recursive: true }); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, "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", + 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"); + } 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..65eb236c 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -118,6 +118,7 @@ const MARKETPLACE_PATHS = [ ".claude-plugin/marketplace.json", ".cursor-plugin/marketplace.json", ".codex-plugin/marketplace.json", + ".github/plugin/marketplace.json", ".plugin/marketplace.json", ] as const; 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/targets/paths.ts b/packages/dotagents/src/targets/paths.ts index 9dfb5d77..fe1b3d49 100644 --- a/packages/dotagents/src/targets/paths.ts +++ b/packages/dotagents/src/targets/paths.ts @@ -26,12 +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": - return { + case "copilot": { + const target: UserMcpTarget = { filePath: join(process.env["COPILOT_HOME"] ?? join(home, ".copilot"), "mcp-config.json"), shared: false, - ...(process.platform === "win32" ? {} : { mode: 0o600 }), }; + if (process.platform !== "win32") {target.mode = 0o600;} + return target; + } default: throw new Error(`Unknown agent for user-scope MCP: ${agentId}`); } diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index 13e2b0ef..24c8ba50 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 --- @@ -102,6 +102,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/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md index 2d8e9005..e613ad1b 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 the plugin and checks the cached skill and MCP files. + ## 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..e0b581a7 100644 --- a/skills/dotagents-qa/references/release-plugin-matrix.md +++ b/skills/dotagents-qa/references/release-plugin-matrix.md @@ -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` and caches installed plugins under `COPILOT_HOME`. + ### 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..4ddefc7d 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -37,6 +37,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 +67,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 +76,7 @@ cpSync(exampleRoot, projectDir, { recursive: true }); const fixtureEnv = { ...process.env, + COPILOT_HOME: copilotHomeDir, HOME: homeDir, DOTAGENTS_HOME: dotagentsHomeDir, DOTAGENTS_STATE_DIR: stateDir, @@ -116,6 +119,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 +143,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 +204,7 @@ async function runClaudePluginProof() { async function runAvailablePluginClientProofs() { const proofs = [ ["claude", runClaudePluginProof], + ["copilot", runCopilotPluginProof], ["codex", runCodexPluginProof], ["grok", runGrokPluginProof], ]; @@ -217,6 +223,48 @@ async function runAvailablePluginClientProofs() { } } +async function runCopilotPluginProof() { + prepareClientHarness("copilot"); + rmSync(copilotHomeDir, { recursive: true, force: true }); + mkdirSync(copilotHomeDir, { recursive: true }); + const env = { ...fixtureEnv, COPILOT_HOME: copilotHomeDir }; + + execFileSync("copilot", ["plugin", "marketplace", "add", projectDir], { + cwd: projectDir, + env, + stdio: "inherit", + }); + 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 installedDir = join(copilotHomeDir, "installed-plugins", "dotagents", "qa-tools"); + if (!existsSync(join(installedDir, "skills", "plugin-qa", "SKILL.md"))) { + throw new Error("Copilot installed plugin did not include plugin-qa"); + } + const installedMcp = JSON.parse(readFileSync(join(installedDir, "mcp.json"), "utf-8")); + const installedMcpNames = Object.keys(installedMcp.mcpServers ?? {}).toSorted(); + if (JSON.stringify(installedMcpNames) !== JSON.stringify(["fixture-http", "fixture-stdio"])) { + throw new Error(`Copilot installed plugin MCP servers were unexpected: ${installedMcpNames.join(", ")}`); + } +} + async function runGrokPluginProof() { prepareClientHarness("grok"); const list = execFileSync("grok", ["plugin", "list"], { @@ -508,6 +556,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"); diff --git a/specs/SPEC.md b/specs/SPEC.md index 4e53a8b2..0d8c8b1d 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -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", "copilot", "cursor", "codex", "grok", "opencode", "pi"] ``` ### Fields @@ -262,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 diff --git a/specs/plugins.md b/specs/plugins.md index fc322a3b..7eb4e51f 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", "copilot", "cursor", "codex", "grok", "opencode", "pi"] ``` | Field | Required | Description | @@ -427,6 +427,7 @@ Agent Plugin bundle | Target | Portable core | Target extension | Generated output | |--------|---------------|------------------|------------------| | 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. | +| 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. | | 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. | | 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. | @@ -508,13 +509,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", "copilot", "cursor", "codex", "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 From c366a28242e12020f9dcde87d9b389cbbeca3740 Mon Sep 17 00:00:00 2001 From: Misha Milovidov <42074547+mishamilovidov@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:51:39 -0700 Subject: [PATCH 05/10] test: verify copilot live plugin resources Use Copilot 1.0.83 native commands to prove local marketplace registration. Verify live skill discovery and both plugin MCP resources without relying on copied caches. --- .../dotagents-qa/references/plugin-runtime.md | 2 +- .../references/release-plugin-matrix.md | 8 +-- skills/dotagents-qa/scripts/qa-example.mjs | 69 ++++++++++++++++--- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/skills/dotagents-qa/references/plugin-runtime.md b/skills/dotagents-qa/references/plugin-runtime.md index e613ad1b..85d24752 100644 --- a/skills/dotagents-qa/references/plugin-runtime.md +++ b/skills/dotagents-qa/references/plugin-runtime.md @@ -84,7 +84,7 @@ Automated proof: node skills/dotagents-qa/scripts/qa-example.mjs plugin-copilot ``` -This command adds the generated marketplace to an isolated `COPILOT_HOME`. It installs the plugin and checks the cached skill and MCP files. +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) diff --git a/skills/dotagents-qa/references/release-plugin-matrix.md b/skills/dotagents-qa/references/release-plugin-matrix.md index e0b581a7..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. @@ -71,7 +71,7 @@ copilot plugin install @dotagents copilot plugin list ``` -The marketplace source must be the project directory. Copilot reads `.github/plugin/marketplace.json` and caches installed plugins under `COPILOT_HOME`. +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 diff --git a/skills/dotagents-qa/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs index 4ddefc7d..42e4da80 100644 --- a/skills/dotagents-qa/scripts/qa-example.mjs +++ b/skills/dotagents-qa/scripts/qa-example.mjs @@ -234,6 +234,13 @@ async function runCopilotPluginProof() { 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"], @@ -254,14 +261,60 @@ async function runCopilotPluginProof() { }); assertIncludes(installed, "qa-tools@dotagents", "Copilot plugin list should include qa-tools@dotagents"); - const installedDir = join(copilotHomeDir, "installed-plugins", "dotagents", "qa-tools"); - if (!existsSync(join(installedDir, "skills", "plugin-qa", "SKILL.md"))) { - throw new Error("Copilot installed plugin did not include plugin-qa"); - } - const installedMcp = JSON.parse(readFileSync(join(installedDir, "mcp.json"), "utf-8")); - const installedMcpNames = Object.keys(installedMcp.mcpServers ?? {}).toSorted(); - if (JSON.stringify(installedMcpNames) !== JSON.stringify(["fixture-http", "fixture-stdio"])) { - throw new Error(`Copilot installed plugin MCP servers were unexpected: ${installedMcpNames.join(", ")}`); + 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}/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"); } } From f7fc3c768d3b93cde130ab662b47e15a97dfef81 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:59:59 -0700 Subject: [PATCH 06/10] fix: harden github copilot integration --- README.md | 8 +- docs/public/llms.txt | 15 +- docs/src/content/docs/cli.mdx | 28 +- docs/src/content/docs/guide.mdx | 28 +- docs/src/content/docs/index.mdx | 10 +- examples/full/local-plugins/qa-tools/mcp.json | 2 +- .../qa-tools/{ => runtime}/server.mjs | 0 .../dotagents/src/cli/commands/doctor.test.ts | 26 + .../src/cli/commands/install-user.test.ts | 144 ++++-- .../src/cli/commands/install.test.ts | 38 +- .../dotagents/src/cli/commands/sync.test.ts | 28 ++ packages/dotagents/src/cli/commands/sync.ts | 6 + .../src/plugins/runtime/marketplace.ts | 32 +- .../src/plugins/runtime/writer.test.ts | 50 ++ .../dotagents/src/plugins/runtime/writer.ts | 45 +- packages/dotagents/src/plugins/store.test.ts | 466 +++++++++++++++++- packages/dotagents/src/plugins/store.ts | 93 ++++ .../dotagents/src/symlinks/manager.test.ts | 155 +++++- packages/dotagents/src/symlinks/manager.ts | 105 +++- .../src/targets/definitions/claude.ts | 1 + .../src/targets/definitions/copilot.ts | 9 +- .../dotagents/src/targets/mcp-writer.test.ts | 311 +++++++++++- packages/dotagents/src/targets/mcp-writer.ts | 258 +++++++--- packages/dotagents/src/targets/paths.test.ts | 22 +- packages/dotagents/src/targets/paths.ts | 2 +- .../src/targets/skill-symlinks.test.ts | 7 +- .../dotagents/src/targets/skill-symlinks.ts | 11 + packages/dotagents/src/targets/types.ts | 4 + skills/dotagents-qa/Dockerfile | 2 + skills/dotagents-qa/SKILL.md | 3 +- skills/dotagents-qa/SOURCES.md | 4 +- skills/dotagents-qa/references/copilot.md | 70 +++ .../dotagents-qa/references/docker-sandbox.md | 5 +- skills/dotagents-qa/scripts/qa-example.mjs | 24 +- skills/dotagents/SKILL.md | 3 +- skills/dotagents/references/cli-reference.md | 2 +- skills/dotagents/references/config-schema.md | 7 +- skills/dotagents/references/configuration.md | 36 +- specs/SPEC.md | 26 +- specs/plugins.md | 15 + 40 files changed, 1908 insertions(+), 193 deletions(-) rename examples/full/local-plugins/qa-tools/{ => runtime}/server.mjs (100%) create mode 100644 skills/dotagents-qa/references/copilot.md diff --git a/README.md b/README.md index f8dae26a..866e965f 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, GitHub Copilot CLI/Desktop, 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 @@ -154,7 +154,9 @@ 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`. -GitHub Copilot reads project skills from `.agents/skills/` without a symlink. New project MCP configuration uses `.mcp.json`. An existing `.github/mcp.json` is reused when `.mcp.json` is absent. Global MCP configuration uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs the global file with mode `0600`, which matches Copilot CLI. +GitHub Copilot reads project skills from `.agents/skills/` without a symlink. For global skills, dotagents links `COPILOT_HOME/skills/` to the selected global skills directory; leave `COPILOT_HOME` unset to use `~/.copilot/skills/`. Set overrides to a non-empty absolute path: Copilot CLI resolves an explicitly empty value as the working-directory-relative `./skills`, which is unsuitable for a global projection. If the Copilot and dotagents homes are the same directory or filesystem alias, no self-referential link is created. Existing skill-name collisions fail before either directory is changed. + +New Copilot project MCP configuration uses `.mcp.json`; an existing `.github/mcp.json` is reused when `.mcp.json` is absent. Copilot accepts either an `mcpServers` document or a bare server map, so dotagents preserves the existing form for Copilot-only projects. If Claude shares the same `.mcp.json`, or remains after Copilot is removed, dotagents nests a recognized bare server map under `mcpServers` while preserving unmanaged servers. Global MCP configuration uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is non-empty and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs the global file with mode `0600`, which matches Copilot CLI. 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//`. Copilot consumes the canonical Agent Plugins bundle directly, so it does not need a generated plugin manifest. During legacy migration, generalized bundles can also project Markdown agents into `.opencode/agents/`; standard extension agents are preserved but are not projected yet: @@ -172,6 +174,8 @@ Global plugins install canonical bundles under `~/.agents/plugins/`. Claude and After installation, register the DotAgents root with Copilot by running `copilot plugin marketplace add `. Then run `copilot plugin install @dotagents`. +Copilot gives `marketplace.json` and `.plugin/marketplace.json` precedence over dotagents' `.github/plugin/marketplace.json`. When either higher-priority file exists, dotagents warns and removes any stale managed Copilot marketplace instead of generating ignored output. Copilot resolves plugin manifests in `.plugin`, root, `.github/plugin`, then `.claude-plugin` order. A source containing only `.plugin/plugin.json` or `.github/plugin/plugin.json` is canonicalized to root `plugin.json` during installation. Conflicting locators fail preflight when Copilot is selected if they would hide the canonical source or make dotagents and Copilot select different manifests. Legacy Copilot manifests may declare skills and MCP servers; native agent, command, hook, LSP, and executable-extension fields and implicitly discovered paths are rejected. Standard manifest extension data is preserved, but a physical `com.github.copilot/` extension directory is rejected because Copilot loads client-native components from it. Remove unsupported or conflicting fields and resources, or exclude `copilot` from that plugin's `targets`. + 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. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. Normal skills need no Pi-specific configuration; plugin bundles can target `pi` when their `skills/` components should be exposed there. diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 22b36b5c..e44b015d 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, GitHub Copilot CLI/Desktop, 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 ` @@ -57,7 +57,7 @@ And a lockfile (`agents.lock`) tracking which skills, subagents, and plugins are 2. `install` clones or refreshes sources and copies skills, subagents, and plugins into the selected scope's managed directories 3. `agents.lock` tracks which skills, subagents, and plugins are managed (automatically gitignored in project scope) 4. Managed project skills, canonical installed subagents, and managed plugin bundles under `.agents/` are gitignored. Collaborators run `npx @sentry/dotagents --project install` after cloning. Custom skills in `.agents/skills/` and project-authored plugin source directories in `.agents/plugins/` are tracked by git normally when they are not installed dependencies. -5. Symlinks connect `.agents/skills/` to each agent's expected location (`.claude/skills/` for Claude and Cursor) +5. Symlinks connect managed skills to each agent's expected global or project location (`.claude/skills/` for Claude and Cursor, and `$COPILOT_HOME/skills/` for global Copilot) 6. MCP, hook, subagent, and plugin configs are generated for each declared agent where supported ## Configuration (agents.toml) @@ -221,7 +221,7 @@ Config files generated per agent: - 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. -GitHub Copilot accepts the same implicit stdio shape as Claude, so both targets produce stable shared `.mcp.json` output. In user scope, GitHub Copilot uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs this file with mode `0600`. +GitHub Copilot accepts the same implicit stdio shape as Claude and accepts both an `mcpServers` document and a bare server map. Copilot-only projects preserve the existing form. When Claude shares `.mcp.json`, including after Copilot is removed from the configured agents, dotagents nests a recognized bare server map under `mcpServers` while preserving unmanaged entries. In global scope, GitHub Copilot uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is non-empty and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents repairs access and enforces mode `0600`. ### Hooks @@ -313,6 +313,8 @@ Generated project-scope plugin outputs: - 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 +Copilot loads the canonical Agent Plugins bundle directly. Its `marketplace.json` and `.plugin/marketplace.json` locations take precedence over dotagents' `.github/plugin/marketplace.json`; when either exists, dotagents warns and prunes stale managed Copilot marketplace output. Copilot resolves plugin manifests in `.plugin`, root, `.github/plugin`, then `.claude-plugin` order. A source containing only `.plugin/plugin.json` or `.github/plugin/plugin.json` is canonicalized to root `plugin.json`. Conflicting locators fail preflight when Copilot is selected if they would hide the canonical source or make dotagents and Copilot select different manifests. Legacy Copilot manifests may declare skills and MCP servers; native agent, command, hook, LSP, and executable-extension fields and implicitly discovered paths are rejected. Standard manifest extension data is preserved, but a physical `com.github.copilot/` extension directory is rejected because Copilot loads client-native components from it. Remove unsupported or conflicting fields and resources, or exclude `copilot` from the plugin's `targets`. + 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/`. 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/`. @@ -505,9 +507,9 @@ Check selected-scope health: gitignore setup where applicable, installed skills | `codex` | Codex | `.codex` | (reads `.agents/skills/` natively) | `.codex/config.toml` | Not supported | `.codex/agents/*.toml` | | `vscode` | VS Code Copilot | `.vscode` | (reads `.agents/skills/` natively) | `.vscode/mcp.json` | `.claude/settings.json` | Not supported | | `opencode` | OpenCode | `.opencode` | (reads `.agents/skills/` natively) | `.opencode/opencode.jsonc` by default | Not supported | `.opencode/agents/*.md` | -| `copilot` | GitHub Copilot CLI/Desktop | `.copilot` | (reads `.agents/skills/` natively) | `.mcp.json` or `.github/mcp.json` | Not supported | Not supported | +| `copilot` | GitHub Copilot CLI | `.copilot` | Project: reads `.agents/skills/`; global: `$COPILOT_HOME/skills/` symlink | `.mcp.json` or `.github/mcp.json` | Not supported | Not supported | -Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, OpenCode, and GitHub Copilot read `.agents/skills/` directly. +Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, and OpenCode read `.agents/skills/` directly. Copilot does so in project scope; global installs link the selected global skills directory into `COPILOT_HOME/skills/`, defaulting to `~/.copilot/skills/` when `COPILOT_HOME` is unset. Overrides must be non-empty absolute paths because Copilot CLI interprets an explicitly empty value as working-directory-relative `./skills`. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. Normal skills need no Pi-specific target or symlink configuration; plugin bundles can target `pi` when their `skills/` components should be exposed there. @@ -524,7 +526,7 @@ Operates on `DOTAGENTS_HOME` when set and otherwise `~/.agents/`, regardless of - Override location: `DOTAGENTS_HOME` environment variable - Explicit spellings: `--global`, or compatibility alias `--user` -Global-scope symlinks include `~/.claude/skills/` for Claude and Cursor. +Global-scope symlinks include `~/.claude/skills/` for Claude and Cursor, plus `COPILOT_HOME/skills/` (default `~/.copilot/skills/`) for Copilot. ### Project Scope (`--project`) @@ -614,6 +616,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 099d9a49..77f383b6 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -373,7 +373,7 @@ Status output: | `version` | integer | -- | Schema version. Always `1`. | | `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, Copilot, Cursor, Codex, 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,14 @@ Status output: | `headers` | table | No | HTTP headers for URL servers, not needed with OAuth | | `env` | string[] | No | Environment variable names to pass through | +Copilot project MCP uses `.mcp.json`, falling back to an existing +`.github/mcp.json`. Both a bare server map and an `mcpServers` document are +accepted and preserved for Copilot-only projects. Sharing `.mcp.json` with +Claude, including after Copilot is removed, promotes a recognized bare map +under `mcpServers` without dropping unmanaged servers. Global Copilot MCP uses +`$COPILOT_HOME/mcp-config.json`, defaulting to +`~/.copilot/mcp-config.json`, and is enforced as mode `0600` on POSIX. + ### Hooks | Field | Type | Required | Description | @@ -484,6 +492,21 @@ reinterpreting generated adapters as source input. Generated plugin outputs include Claude, Copilot, Cursor, and Codex 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. +Copilot consumes the canonical portable bundle directly. It gives root +`marketplace.json` and `.plugin/marketplace.json` precedence over the generated +`.github/plugin/marketplace.json`; dotagents warns and prunes stale managed +output when either higher-priority file exists. Copilot resolves plugin +manifests in `.plugin`, root, `.github/plugin`, then `.claude-plugin` order. A +source containing only `.plugin/plugin.json` or `.github/plugin/plugin.json` is +canonicalized to root `plugin.json`. Conflicting locators fail preflight when +dotagents and Copilot would select different manifests. Legacy Copilot +manifests may declare skills and MCP servers; native agent, command, hook, LSP, and +executable-extension fields and implicitly discovered paths are rejected. +Standard manifest extension data is preserved, but a physical +`com.github.copilot/` extension directory is rejected because Copilot loads +client-native components from it. Remove unsupported or shadowing fields and +resources, or exclude `copilot` from that plugin's `targets`. + ## Scopes ### Global Scope (default) @@ -498,7 +521,7 @@ dotagents add getsentry/agent-plugins review-tools dotagents install ``` -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/`. +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/`. Copilot global skills and MCP use `COPILOT_HOME`, defaulting to `~/.copilot/` when it is unset. Use a non-empty absolute override; Copilot CLI interprets an explicitly empty value as a working-directory-relative personal skills path. Register the selected scope root with Copilot by running `copilot plugin marketplace add `. Then run `copilot plugin install @dotagents`. @@ -520,3 +543,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..662bb448 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -146,8 +146,12 @@ 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, Copilot, Cursor, Codex, Grok, +OpenCode, and Pi locations. Override the dotagents root with `DOTAGENTS_HOME`. +Copilot global skills and MCP use `COPILOT_HOME` when it is a non-empty +absolute path and `~/.copilot/` when it is unset. Do not export an empty value: +Copilot CLI then resolves personal skills from working-directory-relative +`./skills`. `--global` selects global scope explicitly. `--user` remains a compatibility alias. @@ -160,7 +164,7 @@ alias. ```toml version = 1 -agents = ["claude", "cursor", "codex", "opencode"] +agents = ["claude", "copilot", "cursor", "codex", "opencode"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -217,7 +221,7 @@ targets = ["claude", "codex", "opencode"] [[plugins]] name = "review-tools" source = "getsentry/agent-pack" -targets = ["claude", "cursor", "codex", "opencode"] +targets = ["claude", "copilot", "cursor", "codex", "opencode"] ``` Portable Agent Plugins may coexist with authored Claude, Cursor, or Codex @@ -226,5 +230,21 @@ Dotagents ignores reproducible native manifests and retains only behavior it cannot generate as a matching-client fallback; unrelated targets receive only portable skills and MCP. +Copilot is a native projection of that same portable core. It accepts project +MCP files as either a bare server map or under `mcpServers`; dotagents preserves +the existing form unless Claude shares the file or remains after Copilot is +removed, in which case a recognized bare map is nested without dropping +unmanaged servers. For plugins, root +`marketplace.json` and `.plugin/marketplace.json` shadow the generated +`.github/plugin/marketplace.json`. Copilot resolves plugin manifests in +`.plugin`, root, `.github/plugin`, then `.claude-plugin` order. A source with +only `.plugin/plugin.json` or `.github/plugin/plugin.json` is canonicalized; +conflicting locators fail preflight if dotagents and Copilot would select +different manifests. Legacy Copilot +manifests reject native agent, command, hook, LSP, and executable-extension +resources so they do not expand the adapter's scope. Standard manifest +extension data is preserved, but a physical `com.github.copilot/` extension +directory is rejected because Copilot would activate its native components. + See the [CLI reference](/cli/#configuration-agentstoml) for all fields and options. diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 4f78ed67..a680fa7b 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", "copilot", "cursor", "codex", "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", "copilot", "cursor", "codex", "opencode"]`} @@ -97,7 +97,7 @@ targets = ["claude", "cursor", "codex", "opencode"]`}

Supported Agents

-

The `agents` array tells dotagents which tools to configure. Grok and Pi are plugin-only targets. Copilot and Pi read `.agents/skills/` directly.

+

The `agents` array tells dotagents which tools to configure. Grok and Pi are plugin-only targets. Copilot reads project skills directly and receives a global skills link.

| Agent | Config Dir | MCP Config | Hooks | Subagents | @@ -105,11 +105,11 @@ 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` | None | None | + | `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` | - GitHub Copilot reads user MCP configuration from `~/.copilot/mcp-config.json`. Projects use `.mcp.json`, or reuse `.github/mcp.json` when it exists. + GitHub Copilot reads project skills from `.agents/skills/`. Global installs link skills into `$COPILOT_HOME/skills/`, defaulting to `~/.copilot/skills/`. User MCP configuration follows the same home and uses `mcp-config.json`. Projects use `.mcp.json`, or reuse `.github/mcp.json` when it exists; existing bare and `mcpServers` forms are both supported. OpenCode reuses existing project config from `.opencode/opencode.jsonc`, `.opencode/opencode.json`, `opencode.jsonc`, or `opencode.json`, in that order. New projects use `.opencode/opencode.jsonc`.
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/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 421f5998..4580e9e2 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -281,6 +281,32 @@ source = "getsentry/plugins" } }); + it("reports a higher-priority Copilot marketplace", async () => { + const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ + $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + 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" +`); + await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); + await writeFile(join(projectRoot, ".agents", ".gitignore"), "# managed\n"); + + const result = await runDoctor({ scope: resolveScope("project", projectRoot) }); + const check = result.checks.find((candidate) => candidate.name === "plugin runtime"); + + expect(check?.status).toBe("warn"); + expect(check?.message).toContain("higher-priority marketplace exists"); + expect(check?.message).toContain(join(projectRoot, "marketplace.json")); + }); + it("detects generated files tracked by git", async () => { // Initialize a git repo so git ls-files works const { execSync } = await import("node:child_process"); diff --git a/packages/dotagents/src/cli/commands/install-user.test.ts b/packages/dotagents/src/cli/commands/install-user.test.ts index 8f737d99..ae2cf0c7 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, stat } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readlink, rm, symlink, writeFile, lstat, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; @@ -135,17 +135,106 @@ source = "path:skill-source/pdf" expect(lockfile!.skills["pdf"]).toEqual({ source: "path:skill-source/pdf" }); }); - it("writes copilot MCP config without a skill symlink", async () => { - tmpDir = await mkdtemp(join(tmpdir(), "dotagents-user-copilot-")); - const homeDir = join(tmpDir, "home"); - const dotagentsHome = join(tmpDir, "agents"); - const stateDir = join(tmpDir, "state"); + it.each(["default", "custom", "shared", "aliased"] as const)( + "writes Copilot global config with a %s home", + async (homeMode) => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-user-copilot-")); + const homeDir = join(tmpDir, "home"); + const dotagentsHome = join(tmpDir, "agents"); + const stateDir = join(tmpDir, "state"); + const copilotHome = homeMode === "default" + ? join(homeDir, ".copilot") + : homeMode === "shared" + ? dotagentsHome + : join(tmpDir, homeMode === "aliased" ? "copilot-alias" : "copilot"); + const sourceDir = join(dotagentsHome, "skill-source", "pdf"); + + process.env["HOME"] = homeDir; + process.env["DOTAGENTS_HOME"] = dotagentsHome; + process.env["DOTAGENTS_STATE_DIR"] = stateDir; + if (homeMode === "default") { + delete process.env["COPILOT_HOME"]; + } else { + process.env["COPILOT_HOME"] = copilotHome; + } + vi.resetModules(); + + const [{ runInstall }, { resolveScope }] = await Promise.all([ + import("./install.js"), + import("../../scope.js"), + ]); + + await mkdir(sourceDir, { recursive: true }); + if (homeMode === "aliased") { + await symlink( + dotagentsHome, + copilotHome, + process.platform === "win32" ? "junction" : "dir", + ); + } else { + await mkdir(copilotHome, { recursive: true }); + } + await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD); + await writeFile( + join(copilotHome, "mcp-config.json"), + JSON.stringify({ + note: "keep", + mcpServers: { + manual: { command: "manual", args: [] }, + fixture: { command: "old", args: [] }, + }, + }), + ); + + const scope = resolveScope("user"); + await mkdir(scope.root, { recursive: true }); + await writeFile( + scope.configPath, + `version = 1 +agents = ["copilot"] + +[[skills]] +name = "pdf" +source = "path:skill-source/pdf" + +[[mcp]] +name = "fixture" +command = "node" +args = ["server.js"] +`, + ); + + await runInstall({ scope }); + + expect(existsSync(join(scope.skillsDir, "pdf", "SKILL.md"))).toBe(true); + const copilotSkills = join(copilotHome, "skills"); + if (homeMode === "shared" || homeMode === "aliased") { + expect((await lstat(copilotSkills)).isDirectory()).toBe(true); + } else { + expect((await lstat(copilotSkills)).isSymbolicLink()).toBe(true); + expect(await readlink(copilotSkills)).toBe(relative(copilotHome, scope.skillsDir)); + } + expect(JSON.parse(await readFile(join(copilotHome, "mcp-config.json"), "utf-8"))).toEqual({ + note: "keep", + mcpServers: { + manual: { command: "manual", args: [] }, + fixture: { command: "node", args: ["server.js"] }, + }, + }); + if (process.platform !== "win32") { + expect((await stat(join(copilotHome, "mcp-config.json"))).mode & 0o777).toBe(0o600); + } + }, + ); + + it("rejects nested Copilot and dotagents homes before moving global state", async () => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-user-copilot-overlap-")); const copilotHome = join(tmpDir, "copilot"); + const dotagentsHome = join(copilotHome, "skills"); const sourceDir = join(dotagentsHome, "skill-source", "pdf"); - - process.env["HOME"] = homeDir; + process.env["HOME"] = join(tmpDir, "home"); process.env["DOTAGENTS_HOME"] = dotagentsHome; - process.env["DOTAGENTS_STATE_DIR"] = stateDir; + process.env["DOTAGENTS_STATE_DIR"] = join(tmpDir, "state"); process.env["COPILOT_HOME"] = copilotHome; vi.resetModules(); @@ -153,23 +242,9 @@ source = "path:skill-source/pdf" import("./install.js"), import("../../scope.js"), ]); - await mkdir(sourceDir, { recursive: true }); - await mkdir(copilotHome, { recursive: true }); await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD); - await writeFile( - join(copilotHome, "mcp-config.json"), - JSON.stringify({ - note: "keep", - mcpServers: { - manual: { command: "manual", args: [] }, - fixture: { command: "old", args: [] }, - }, - }), - ); - const scope = resolveScope("user"); - await mkdir(scope.root, { recursive: true }); await writeFile( scope.configPath, `version = 1 @@ -178,27 +253,14 @@ agents = ["copilot"] [[skills]] name = "pdf" source = "path:skill-source/pdf" - -[[mcp]] -name = "fixture" -command = "node" -args = ["server.js"] `, ); - await runInstall({ scope }); + await expect(runInstall({ scope })).rejects.toThrow("paths overlap"); - expect(existsSync(join(scope.skillsDir, "pdf", "SKILL.md"))).toBe(true); - expect(existsSync(join(copilotHome, "skills"))).toBe(false); - expect(JSON.parse(await readFile(join(copilotHome, "mcp-config.json"), "utf-8"))).toEqual({ - note: "keep", - mcpServers: { - manual: { command: "manual", args: [] }, - fixture: { command: "node", args: ["server.js"] }, - }, - }); - if (process.platform !== "win32") { - expect((await stat(join(copilotHome, "mcp-config.json"))).mode & 0o777).toBe(0o600); - } + expect(await readFile(scope.configPath, "utf-8")).toContain('agents = ["copilot"]'); + expect(existsSync(scope.lockPath)).toBe(true); + expect(await readFile(join(scope.skillsDir, "pdf", "SKILL.md"), "utf-8")).toBe(SKILL_MD); + expect(existsSync(join(copilotHome, "agents.toml"))).toBe(false); }); }); diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index e9428e3e..8ad665dd 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -216,7 +216,7 @@ describe("runInstall", () => { await writeFile( join(projectRoot, "agents.toml"), `version = 1 -agents = ["codex", "claude", "copilot", "cursor"] +agents = ["codex", "claude", "cursor"] [[plugins]] name = "review-tools" @@ -291,7 +291,6 @@ source = "path:plugin-source/review-tools" join(projectRoot, ".agents", "plugins", "review-tools"), ); expect(await readFile(join(projectRoot, ".cursor-plugin", "marketplace.json"), "utf-8")).toBe(claudeMarketplaceJson); - expect(await readFile(join(projectRoot, ".github", "plugin", "marketplace.json"), "utf-8")).toBe(claudeMarketplaceJson); const claudeManifest = parseJsonObject(await readFile( join(projectRoot, ".agents", "plugins", "review-tools", ".claude-plugin", "plugin.json"), @@ -651,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 }); 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/plugins/runtime/marketplace.ts b/packages/dotagents/src/plugins/runtime/marketplace.ts index 52765ec8..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"; @@ -18,6 +19,35 @@ export function marketplaceOutputPaths(root: PluginRuntimeRoot): string[] { ]; } +/** 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[], diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index 16594e04..08b1a757 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -724,6 +724,56 @@ describe("plugin writer", () => { expect(await readFile(marketplacePath, "utf-8")).toBe("{ \"name\": \"mine\" }\n"); }); + it.each(["marketplace.json", join(".plugin", "marketplace.json")])( + "warns about higher-priority Copilot %s and prunes stale managed output", + async (blockingRelativePath) => { + const alpha = await plugin("alpha-tools"); + const generatedPath = join(root, ".github", "plugin", "marketplace.json"); + const blockingPath = join(root, blockingRelativePath); + 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).toEqual([{ + agent: "copilot", + name: "marketplace", + message: expect.stringContaining(`higher-priority marketplace exists: ${blockingPath}`), + }]); + expect(existsSync(generatedPath)).toBe(false); + expect(await readFile(blockingPath, "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + expect(await verifyPluginOutputs(["copilot"], [alpha], root)).toEqual([{ + agent: "copilot", + name: "marketplace", + issue: expect.stringContaining(`higher-priority marketplace exists: ${blockingPath}`), + }]); + }, + ); + + it.skipIf(process.platform === "win32")( + "converges when a higher-priority Copilot locator aliases the managed marketplace", + async () => { + const alpha = await plugin("alpha-tools"); + const generatedPath = join(root, ".github", "plugin", "marketplace.json"); + const aliasPath = join(root, ".plugin", "marketplace.json"); + await writePluginOutputs(["copilot"], [alpha], root); + await mkdir(dirname(aliasPath), { recursive: true }); + await symlink(generatedPath, aliasPath); + + const first = await reconcilePluginOutputs(["copilot"], [alpha], root); + const second = await reconcilePluginOutputs(["copilot"], [alpha], root); + + expect(first).toEqual({ result: { warnings: [], written: 0 }, pruned: [] }); + expect(second).toEqual(first); + expect(await verifyPluginOutputs(["copilot"], [alpha], root)).toEqual([]); + expect(await readFile(aliasPath, "utf-8")).toBe( + await readFile(generatedPath, "utf-8"), + ); + }, + ); + it.each([ ["codex", "Codex", ".codex-plugin"], ["claude", "Claude", ".claude-plugin"], 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 4f354b1b..7ed224cd 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -386,6 +386,380 @@ 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 .plugin/plugin.json when a different native manifest was imported", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-shadow-")); + try { + const sourceRoot = join(projectRoot, "source"); + await mkdir(join(sourceRoot, ".codex-plugin"), { recursive: true }); + await mkdir(join(sourceRoot, ".plugin"), { recursive: true }); + await writeFile(join(sourceRoot, ".codex-plugin", "plugin.json"), JSON.stringify({ + name: "native-tools", + })); + await writeFile(join(sourceRoot, ".plugin", "plugin.json"), JSON.stringify({ + name: "native-tools", + })); + + const resolved = await resolvePlugin( + { name: "native-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + ); + expect(resolved.plugin.nativeSource).toBe("codex"); + expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).toThrow( + ".plugin/plugin.json would shadow the canonical plugin.json for Copilot", + ); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it.each([ + { source: "claude", manifestPath: join(".claude-plugin", "plugin.json") }, + { source: "cursor", manifestPath: join(".cursor-plugin", "plugin.json") }, + { source: "codex", manifestPath: join(".codex-plugin", "plugin.json") }, + ] as const)( + "rejects a .github Copilot manifest when the $source native fallback was imported", + async ({ source, manifestPath }) => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-precedence-")); + try { + const sourceRoot = join(projectRoot, "source"); + await mkdir(dirname(join(sourceRoot, manifestPath)), { recursive: true }); + await mkdir(join(sourceRoot, ".github", "plugin"), { recursive: true }); + await writeFile(join(sourceRoot, manifestPath), 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(resolved.plugin.nativeSource).toBe(source); + expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).toThrow( + "but Copilot would load .github/plugin/plugin.json instead", + ); + expect(() => preparePluginForTargets(resolved.plugin, [source])).not.toThrow(); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }, + ); + + it("rejects a .plugin/plugin.json shadow for legacy root manifests", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-shadow-")); + try { + await mkdir(join(sourceRoot, ".plugin"), { recursive: true }); + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + name: "legacy-tools", + skills: "./skills", + })); + await writeFile(join(sourceRoot, ".plugin", "plugin.json"), JSON.stringify({ + name: "legacy-tools", + commands: "./hidden-commands", + })); + const resolved = await resolvePlugin( + { name: "legacy-tools", source: "path:." }, + { stateDir: join(sourceRoot, "state"), projectRoot: sourceRoot }, + ); + + expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).toThrow( + ".plugin/plugin.json would shadow the canonical plugin.json for Copilot", + ); + } finally { + await rm(sourceRoot, { 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 implicit legacy .lsp.json resource", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-resources-")); + try { + await writeFile(join(sourceRoot, ".lsp.json"), "{}\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: .lsp.json", + ); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it("allows legacy fields and paths that Copilot does not load", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-resources-")); + try { + await mkdir(join(sourceRoot, "rules"), { recursive: true }); + await mkdir(join(sourceRoot, "lsp-config"), { recursive: true }); + await writeFile(join(sourceRoot, "rules", "review.md"), "# Review\n"); + await writeFile(join(sourceRoot, "lsp-config", "servers.json"), "{}\n"); + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + name: "resource-tools", + rules: "./rules", + apps: "./.app.json", + monitors: "./monitors", + bin: "./bin", + })); + const resolved = await resolvePlugin( + { name: "resource-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("allows inert legacy-root resources in standard 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", "claude-only.md"), "# Claude only\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"])).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 legacy Copilot MCP files while rejecting non-MCP roots", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-resources-")); + try { + await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ + name: "mcp-tools", + mcpServers: "./.mcp.json", + })); + await writeFile(join(sourceRoot, ".mcp.json"), JSON.stringify({ + mcpServers: { local: { command: "node", args: ["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("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,15 +1060,16 @@ describe("plugin store", () => { } }); - it("discovers plugins from a Copilot marketplace", async () => { + 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(pluginDir, { recursive: true }); + await mkdir(join(pluginDir, ".github", "plugin"), { recursive: true }); await writeFile( - join(pluginDir, "plugin.json"), + join(pluginDir, ".github", "plugin", "plugin.json"), JSON.stringify({ name: "review-tools", description: "Copilot marketplace plugin" }), "utf-8", ); @@ -702,6 +1077,7 @@ describe("plugin store", () => { join(sourceRoot, ".github", "plugin", "marketplace.json"), JSON.stringify({ name: "test-marketplace", + owner: { name: "test" }, plugins: [{ name: "review-tools", source: "./plugins/review-tools" }], }), "utf-8", @@ -713,11 +1089,95 @@ describe("plugin store", () => { ); 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); + + 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 fall through from a malformed higher-priority Copilot marketplace", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-store-")); + try { + const sourceRoot = join(projectRoot, "source"); + const lowerPlugin = join(sourceRoot, "catalog", "lower"); + await mkdir(join(sourceRoot, ".plugin"), { recursive: true }); + await mkdir(join(sourceRoot, ".github", "plugin"), { recursive: true }); + await mkdir(lowerPlugin, { recursive: true }); + await writeFile(join(lowerPlugin, "plugin.json"), JSON.stringify({ name: "review-tools" })); + await writeFile(join(sourceRoot, ".plugin", "marketplace.json"), "{"); + await writeFile( + join(sourceRoot, ".github", "plugin", "marketplace.json"), + JSON.stringify({ + name: "lower", + owner: { name: "test" }, + plugins: [{ name: "review-tools", source: "./catalog/lower" }], + }), + ); + + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow('Plugin "review-tools" not found'); } finally { await rm(projectRoot, { recursive: true, force: true }); } }); + it.each(["marketplace.json", join(".plugin", "marketplace.json")])( + "does not expose a lower-priority Copilot catalog when %s has different names", + async (higherMarketplace) => { + 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(dirname(join(sourceRoot, higherMarketplace)), { recursive: true }); + 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, higherMarketplace), 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" }], + }), + ); + + const discovered = await discoverPlugins(sourceRoot); + expect(discovered.map((candidate) => candidate.name)).toEqual(["other-tools"]); + await expect(resolvePlugin( + { name: "review-tools", source: "path:source" }, + { stateDir: join(projectRoot, "state"), projectRoot }, + )).rejects.toThrow('Plugin "review-tools" not found'); + } 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 65eb236c..fe65b81b 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -118,7 +118,13 @@ const MARKETPLACE_PATHS = [ ".claude-plugin/marketplace.json", ".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; @@ -127,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; @@ -159,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; @@ -623,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;} @@ -1033,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"); + // A source discovered only through Copilot's locator is now represented by + // the portable canonical manifest. Leaving the locator would shadow it. + if (!plugin.nativeSource) { + await rm(join(plugin.pluginDir, ".plugin", "plugin.json"), { force: true }); + } } async function writeManagedMarker(pluginDir: string): Promise { @@ -1152,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/symlinks/manager.test.ts b/packages/dotagents/src/symlinks/manager.test.ts index 6afa6df8..259a9989 100644 --- a/packages/dotagents/src/symlinks/manager.test.ts +++ b/packages/dotagents/src/symlinks/manager.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtemp, rm, @@ -6,13 +6,16 @@ import { symlink, writeFile, lstat, + readFile, readlink, readdir, + realpath, } 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"; -import { exec } from "@sentry/dotagents-lib"; +import * as dotagentsLib from "@sentry/dotagents-lib"; describe("symlinks", () => { let dir: string; @@ -25,6 +28,7 @@ describe("symlinks", () => { }); afterEach(async () => { + vi.restoreAllMocks(); await rm(dir, { recursive: true }); }); @@ -64,6 +68,85 @@ 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("creates a valid link when the target home is a deeper symlink alias", async () => { + const physicalTargetDir = join(dir, "copilot-home"); + const targetDir = join(dir, "deep", "nested", "copilot-alias"); + await mkdir(physicalTargetDir, { recursive: true }); + await mkdir(join(dir, "deep", "nested"), { recursive: true }); + await symlink( + physicalTargetDir, + targetDir, + process.platform === "win32" ? "junction" : "dir", + ); + + const result = await ensureSkillsSymlink(agentsDir, targetDir); + + expect(result).toEqual({ created: true, migrated: [] }); + expect(await realpath(join(targetDir, "skills"))).toBe( + await realpath(join(agentsDir, "skills")), + ); + expect(await verifySymlinks(agentsDir, [targetDir])).toEqual([]); + }); + + 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.each(["source-inside-link", "link-inside-source"] as const)( + "rejects overlapping skills paths before changing state: %s", + async (layout) => { + const copilotHome = join(dir, "copilot-home"); + const nestedAgentsDir = layout === "source-inside-link" + ? join(copilotHome, "skills") + : agentsDir; + const targetDir = layout === "source-inside-link" + ? copilotHome + : join(agentsDir, "skills"); + const sourceSkills = join(nestedAgentsDir, "skills"); + await mkdir(join(sourceSkills, "keep-me"), { recursive: true }); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(nestedAgentsDir, "agents.toml"), "keep config"); + await writeFile(join(sourceSkills, "keep-me", "SKILL.md"), "keep skill"); + + await expect(ensureSkillsSymlink(nestedAgentsDir, targetDir)).rejects.toThrow( + "paths overlap", + ); + + expect(await readFile(join(nestedAgentsDir, "agents.toml"), "utf-8")).toBe("keep config"); + expect(await readFile(join(sourceSkills, "keep-me", "SKILL.md"), "utf-8")).toBe("keep skill"); + expect((await lstat(sourceSkills)).isDirectory()).toBe(true); + }, + ); + it("replaces wrong symlink", async () => { const targetDir = join(dir, ".claude"); await mkdir(targetDir, { recursive: true }); @@ -100,14 +183,66 @@ describe("symlinks", () => { expect(stat.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("preserves a native skill added while migration is finishing", async () => { + const targetDir = join(dir, ".copilot"); + const nativeSkills = join(targetDir, "skills"); + const canonicalSkills = join(agentsDir, "skills"); + await mkdir(join(nativeSkills, "initial"), { recursive: true }); + await writeFile(join(nativeSkills, "initial", "SKILL.md"), "initial"); + + let releaseGit!: () => void; + let markGitStarted!: () => void; + const gitBlocked = new Promise((resolve) => {releaseGit = resolve;}); + const gitStarted = new Promise((resolve) => {markGitStarted = resolve;}); + vi.spyOn(dotagentsLib, "exec").mockImplementation(async () => { + markGitStarted(); + await gitBlocked; + return { stdout: "", stderr: "" }; + }); + + const migration = ensureSkillsSymlink(agentsDir, targetDir); + await gitStarted; + await mkdir(join(nativeSkills, "late"), { recursive: true }); + await writeFile(join(nativeSkills, "late", "SKILL.md"), "late"); + releaseGit(); + + await expect(migration).rejects.toMatchObject({ code: "ENOTEMPTY" }); + expect((await lstat(nativeSkills)).isDirectory()).toBe(true); + expect(await readFile(join(nativeSkills, "late", "SKILL.md"), "utf-8")).toBe("late"); + expect(await readFile(join(canonicalSkills, "initial", "SKILL.md"), "utf-8")).toBe("initial"); + expect(existsSync(join(canonicalSkills, "late"))).toBe(false); + }); + it("removes migrated files from git index", async () => { // Initialize a git repo in the temp dir - await exec("git", ["init"], { cwd: dir }); - await exec("git", ["config", "user.email", "test@test.com"], { + await dotagentsLib.exec("git", ["init"], { cwd: dir }); + await dotagentsLib.exec("git", ["config", "user.email", "test@test.com"], { cwd: dir, }); - await exec("git", ["config", "user.name", "Test"], { cwd: dir }); - await exec("git", ["config", "commit.gpgsign", "false"], { cwd: dir }); + await dotagentsLib.exec("git", ["config", "user.name", "Test"], { cwd: dir }); + await dotagentsLib.exec("git", ["config", "commit.gpgsign", "false"], { cwd: dir }); // Create a real skills directory with a committed file const targetDir = join(dir, ".claude"); @@ -118,11 +253,11 @@ describe("symlinks", () => { "---\nname: test\n---\n", ); - await exec("git", ["add", "."], { cwd: dir }); - await exec("git", ["commit", "-m", "initial"], { cwd: dir }); + await dotagentsLib.exec("git", ["add", "."], { cwd: dir }); + await dotagentsLib.exec("git", ["commit", "-m", "initial"], { cwd: dir }); // Verify file is tracked before migration - const { stdout: before } = await exec( + const { stdout: before } = await dotagentsLib.exec( "git", ["ls-files", ".claude/skills/"], { cwd: dir }, @@ -135,7 +270,7 @@ describe("symlinks", () => { expect(result.migrated).toContain("my-skill"); // Verify file is no longer in git index - const { stdout: after } = await exec( + const { stdout: after } = await dotagentsLib.exec( "git", ["ls-files", ".claude/skills/"], { cwd: dir }, diff --git a/packages/dotagents/src/symlinks/manager.ts b/packages/dotagents/src/symlinks/manager.ts index 28594458..365b176a 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,40 @@ 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 { 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 +160,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 +180,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 index e41ab360..0991a1f8 100644 --- a/packages/dotagents/src/targets/definitions/copilot.ts +++ b/packages/dotagents/src/targets/definitions/copilot.ts @@ -1,17 +1,22 @@ +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 .agents/skills/ natively at both project and user scope + // Reads project .agents/skills/ natively. Global discovery follows COPILOT_HOME. skillsParentDir: undefined, - userSkillsParentDirs: undefined, + userSkillsParentDirs: [copilotHome], mcp: { filePath: ".mcp.json", fallbackFilePaths: [".github/mcp.json"], + acceptsBareServerMap: true, rootKey: "mcpServers", format: "json", shared: false, diff --git a/packages/dotagents/src/targets/mcp-writer.test.ts b/packages/dotagents/src/targets/mcp-writer.test.ts index a0eafc70..78373932 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 { chmod, 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"; @@ -274,6 +274,118 @@ describe("writeMcpConfigs", () => { }); }); + it.each([".mcp.json", join(".github", "mcp.json")])( + "preserves a bare Copilot server map at %s", + async (relativePath) => { + const filePath = join(dir, relativePath); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify({ + manual: { command: "manual", args: [] }, + github: { command: "old", args: [] }, + })); + + await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ + manual: { command: "manual", args: [] }, + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }); + }, + ); + + it("migrates a bare .mcp.json when Claude and Copilot share it", async () => { + const filePath = join(dir, ".mcp.json"); + await writeFile(filePath, JSON.stringify({ + manual: { command: "manual", args: [] }, + github: { command: "old", args: [] }, + })); + + await writeMcpConfigs(["claude", "copilot"], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ + mcpServers: { + manual: { command: "manual", args: [] }, + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }, + }); + }); + + it("migrates a bare .mcp.json when Copilot is removed and Claude remains", async () => { + const filePath = join(dir, ".mcp.json"); + await writeFile(filePath, JSON.stringify({ + manual: { command: "manual", args: [] }, + github: { command: "old", args: [] }, + })); + + await writeMcpConfigs(["claude"], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ + mcpServers: { + manual: { command: "manual", args: [] }, + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }, + }); + }); + + it("does not mistake unrelated rootless Claude metadata for a bare server map", async () => { + const filePath = join(dir, ".mcp.json"); + await writeFile(filePath, JSON.stringify({ + metadata: { owner: "me" }, + settings: { enabled: true }, + })); + + await writeMcpConfigs(["claude"], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ + metadata: { owner: "me" }, + settings: { enabled: true }, + mcpServers: { + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }, + }); + }); + + it("promotes a bare Copilot fallback into Claude's rooted .mcp.json", async () => { + const preferredPath = join(dir, ".mcp.json"); + const fallbackPath = join(dir, ".github", "mcp.json"); + await mkdir(dirname(fallbackPath), { recursive: true }); + await writeFile(fallbackPath, JSON.stringify({ + manual: { command: "manual", args: [] }, + })); + + await writeMcpConfigs(["copilot", "claude"], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(JSON.parse(await readFile(preferredPath, "utf-8"))).toEqual({ + mcpServers: { + manual: { command: "manual", args: [] }, + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }, + }); + expect(JSON.parse(await readFile(fallbackPath, "utf-8"))).toEqual({ + manual: { command: "manual", args: [] }, + }); + }); + it("prefers copilot .mcp.json when both project paths exist", async () => { const preferredPath = join(dir, ".mcp.json"); const fallbackPath = join(dir, ".github", "mcp.json"); @@ -563,6 +675,203 @@ describe("writeMcpConfigs", () => { }, ); + it.skipIf(process.platform === "win32")( + "repairs a read-only user config mode before writing semantic drift", + async () => { + const filePath = join(dir, "copilot", "mcp-config.json"); + const resolver = () => ({ filePath, shared: false, mode: 0o600 }); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify({ + mcpServers: { github: { command: "old", args: [] } }, + })); + await chmod(filePath, 0o400); + + const result = await reconcileMcpConfigs( + ["copilot"], + [STDIO_SERVER], + resolver, + "apply", + ); + + expect(result.written).toEqual([filePath]); + expect((await stat(filePath)).mode & 0o777).toBe(0o600); + expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ + mcpServers: { + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }, + }); + }, + ); + + it.skipIf(process.platform === "win32")( + "repairs an unreadable user config mode before reconciliation", + async () => { + const filePath = join(dir, "copilot", "mcp-config.json"); + const resolver = () => ({ filePath, shared: false, mode: 0o600 }); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify({ + mcpServers: { github: { command: "old", args: [] } }, + })); + await chmod(filePath, 0o200); + + 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"))).toEqual({ + mcpServers: { + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }, + }); + }, + ); + + it.skipIf(process.platform === "win32")( + "does not chmod a directory mistaken for a user config file", + async () => { + const filePath = join(dir, "copilot", "mcp-config.json"); + const resolver = () => ({ filePath, shared: false, mode: 0o600 }); + await mkdir(filePath, { recursive: true }); + await chmod(filePath, 0o755); + + 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(filePath)).mode & 0o777).toBe(0o755); + }, + ); + + it.skipIf(process.platform === "win32")( + "does not chmod a symlink target before validating a user config", + async () => { + const filePath = join(dir, "copilot", "mcp-config.json"); + const unrelatedPath = join(dir, "unrelated-executable"); + const resolver = () => ({ filePath, shared: false, mode: 0o600 }); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(unrelatedPath, "not json\n"); + await chmod(unrelatedPath, 0o755); + 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(0o755); + expect(await readFile(unrelatedPath, "utf-8")).toBe("not json\n"); + }, + ); + + it.skipIf(process.platform === "win32")( + "does not chmod or overwrite a valid user 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", args: [] } }, + }); + 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.skipIf(process.platform === "win32")( + "does not create a target through a dangling user config symlink", + async () => { + const filePath = join(dir, "copilot", "mcp-config.json"); + const danglingTarget = join(dir, "external", "created.json"); + const resolver = () => ({ filePath, shared: false, mode: 0o600 }); + await mkdir(dirname(filePath), { recursive: true }); + await mkdir(dirname(danglingTarget), { recursive: true }); + await symlink(danglingTarget, 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(existsSync(danglingTarget)).toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "does not create a managed target through a dangling config symlink", + async () => { + const filePath = join(dir, "copilot", "mcp-config.json"); + const danglingTarget = join(dir, "external", "created.json"); + const statePath = join(dir, "state", "copilot-mcp.json"); + await mkdir(dirname(filePath), { recursive: true }); + await mkdir(dirname(danglingTarget), { recursive: true }); + await symlink(danglingTarget, filePath); + + const result = await reconcileManagedMcpConfig({ + agentId: "copilot", + servers: [STDIO_SERVER], + target: { filePath, shared: false, mode: 0o600 }, + statePath, + mode: "apply", + }); + + expect(result.unresolved).toEqual([ + expect.objectContaining({ issue: expect.stringContaining("not a regular file") }), + ]); + expect(result.written).toEqual([]); + expect(existsSync(danglingTarget)).toBe(false); + expect(existsSync(statePath)).toBe(false); + }, + ); + 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 b5c24ca4..00b0b767 100644 --- a/packages/dotagents/src/targets/mcp-writer.ts +++ b/packages/dotagents/src/targets/mcp-writer.ts @@ -1,4 +1,4 @@ -import { chmod, readFile, writeFile, mkdir, rm, stat } 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"; @@ -25,6 +25,8 @@ export interface McpResolvedTarget { shared: boolean; mode?: number; preferredFilePath?: string; + acceptsBareServerMap?: boolean; + recognizesBareServerMap?: boolean; } export type McpTargetResolver = (agentId: string, spec: McpConfigSpec) => McpResolvedTarget; @@ -82,6 +84,10 @@ export function projectMcpResolver(projectRoot: string): McpTargetResolver { filePath, shared: spec.shared, ...(filePath !== preferredFilePath && { preferredFilePath }), + ...(spec.acceptsBareServerMap && { acceptsBareServerMap: true }), + ...((spec.acceptsBareServerMap || spec.recognizesBareServerMap) && { + recognizesBareServerMap: true, + }), }; }; } @@ -133,45 +139,77 @@ export async function reconcileMcpConfigs( return [{ id, agent, target: resolveTarget(id, agent.mcp) }]; }); const claimedPaths = new Set(initialTargets.map(({ target }) => target.filePath)); - const promotedFallbacks = new Map(); + 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.filePath); + 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 } = 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") { - const fallbackPath = promotedFallbacks.get(filePath); - if (fallbackPath) { + const fallbackTarget = promotedFallbacks.get(filePath); + if (fallbackTarget) { let fallback: SerializedObject; - let fallbackServers: SerializedObject; + let fallbackRoot: McpServerRoot; try { - fallback = await readExisting(fallbackPath, mcp); - fallbackServers = readServerRoot(fallback, mcp.rootKey, fallbackPath); + 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: ${fallbackPath}` }; + const issue = { agent: id, issue: `Failed to read MCP config: ${fallbackTarget.filePath}` }; issues.push(issue); unresolved.push(issue); continue; } - await writeDocument(filePath, mcp, { - ...fallback, - [mcp.rootKey]: { ...fallbackServers, ...expectedServers }, - }, target.mode); + await writeDocument( + filePath, + mcp, + mergeServerDocument( + fallback, + mcp.rootKey, + fallbackRoot, + expectedServers, + !pathFormat.requiresRoot, + ), + target.mode, + ); } else { await writeDocument(filePath, mcp, expected, target.mode); } @@ -180,11 +218,21 @@ export async function reconcileMcpConfigs( 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); @@ -193,31 +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); - const modeCheck = await desiredModeIssue(id, filePath, target.mode); - if (modeCheck) {issues.push(modeCheck.issue);} - - if (mode === "apply" && (targetIssues.length > 0 || modeCheck)) { - const next = { - ...existing, - [mcp.rootKey]: { ...existingServers, ...expectedServers }, - }; - if (targetIssues.length > 0) { - await writeReconciledDocument(filePath, mcp, next, expectedServers, target.mode); - } - if (modeCheck && target.mode !== undefined) { - if (modeCheck.missing && targetIssues.length === 0) { - await writeDocument(filePath, mcp, next, target.mode); - } else if (!modeCheck.missing) { - try { - await chmod(filePath, target.mode); - } catch (err) { - if (!isNotFoundError(err)) {throw err;} - await writeDocument(filePath, mcp, next, target.mode); - } + 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); } } @@ -257,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(); @@ -287,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 { @@ -327,12 +383,13 @@ export async function reconcileManagedMcpConfig( } const targetIssues = desiredIssues(agentId, target.filePath, existingServers, expected); issues.push(...targetIssues); - const modeCheck = await desiredModeIssue(agentId, target.filePath, target.mode); - if (modeCheck) {issues.push(modeCheck.issue);} - if (mode === "apply") { const targetChanged = stale.some((name) => name in existingServers) || 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, @@ -345,22 +402,7 @@ export async function reconcileManagedMcpConfig( ); removed.push(...stale.filter((name) => name in existingServers)); } - if (modeCheck && target.mode !== undefined) { - const nextServers = { ...existingServers }; - for (const name of stale) {delete nextServers[name];} - Object.assign(nextServers, expected); - const next = { ...existing, [agent.mcp.rootKey]: nextServers }; - if (modeCheck.missing && !targetChanged) { - await writeDocument(target.filePath, agent.mcp, next, target.mode); - } else if (!modeCheck.missing) { - try { - await chmod(target.filePath, target.mode); - } catch (err) { - if (!isNotFoundError(err)) {throw err;} - await writeDocument(target.filePath, agent.mcp, next, target.mode); - } - } - } + 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);} @@ -438,6 +480,48 @@ 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, @@ -605,25 +689,49 @@ async function writeFileIfChanged( if (!isNotFoundError(err)) {throw err;} } - await writeFile(filePath, content, { encoding: "utf-8", mode }); + 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 } | undefined> { +): Promise<{ issue: McpReconcileIssue; missing: boolean; directRegularFile: boolean } | undefined> { if (expectedMode === undefined) {return undefined;} - let actualMode: number; + let fileStat: Awaited>; try { - actualMode = (await stat(filePath)).mode & 0o777; + 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: { @@ -631,9 +739,39 @@ async function desiredModeIssue( 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 { return hasErrorCode(err, "ENOENT"); } diff --git a/packages/dotagents/src/targets/paths.test.ts b/packages/dotagents/src/targets/paths.test.ts index 69b72e07..5ae474a4 100644 --- a/packages/dotagents/src/targets/paths.test.ts +++ b/packages/dotagents/src/targets/paths.test.ts @@ -71,6 +71,22 @@ describe("getUserMcpTarget", () => { } }); + 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"); }); @@ -111,10 +127,12 @@ describe("skill discovery paths", () => { expect(agent.userSkillsParentDirs).toBeUndefined(); }); - it("copilot reads .agents/skills/ natively", () => { + it("copilot reads project skills natively and projects global skills into its home", () => { const agent = getAgent("copilot")!; expect(agent.skillsParentDir).toBeUndefined(); - expect(agent.userSkillsParentDirs).toBeUndefined(); + expect(agent.userSkillsParentDirs).toEqual([ + process.env["COPILOT_HOME"] || join(home, ".copilot"), + ]); }); }); diff --git a/packages/dotagents/src/targets/paths.ts b/packages/dotagents/src/targets/paths.ts index fe1b3d49..3f32b8e8 100644 --- a/packages/dotagents/src/targets/paths.ts +++ b/packages/dotagents/src/targets/paths.ts @@ -28,7 +28,7 @@ export function getUserMcpTarget(agentId: string): UserMcpTarget { 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"), + filePath: join(process.env["COPILOT_HOME"] || join(home, ".copilot"), "mcp-config.json"), shared: false, }; if (process.platform !== "win32") {target.mode = 0o600;} diff --git a/packages/dotagents/src/targets/skill-symlinks.test.ts b/packages/dotagents/src/targets/skill-symlinks.test.ts index 7ba21b6a..71c1e786 100644 --- a/packages/dotagents/src/targets/skill-symlinks.test.ts +++ b/packages/dotagents/src/targets/skill-symlinks.test.ts @@ -20,7 +20,7 @@ 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( @@ -29,7 +29,10 @@ describe("skillSymlinkTargets", () => { ["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 24c8ba50..4f21c272 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -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 ``` 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/scripts/qa-example.mjs b/skills/dotagents-qa/scripts/qa-example.mjs index 42e4da80..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"; @@ -227,7 +228,11 @@ async function runCopilotPluginProof() { prepareClientHarness("copilot"); rmSync(copilotHomeDir, { recursive: true, force: true }); mkdirSync(copilotHomeDir, { recursive: true }); - const env = { ...fixtureEnv, COPILOT_HOME: copilotHomeDir }; + const env = { + ...fixtureEnv, + COPILOT_HOME: copilotHomeDir, + COPILOT_ALLOW_ALL: "true", + }; execFileSync("copilot", ["plugin", "marketplace", "add", projectDir], { cwd: projectDir, @@ -299,7 +304,7 @@ async function runCopilotPluginProof() { || local.enabled !== true || local.type !== "stdio" || local.command !== "node" - || JSON.stringify(local.args) !== JSON.stringify(["${PLUGIN_ROOT}/server.mjs"]) + || JSON.stringify(local.args) !== JSON.stringify(["${PLUGIN_ROOT}/runtime/server.mjs"]) || !isStringValue(local.env?.PLUGIN_ROOT) || realpathSync(local.env.PLUGIN_ROOT) !== pluginRoot ) { @@ -389,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, @@ -411,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); @@ -584,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"); @@ -638,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..4383d84d 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, GitHub Copilot, Cursor, Codex, Grok, VS Code, OpenCode, Pi) can use shared global or project config. ## Running dotagents @@ -119,6 +119,7 @@ When trust blocks a source, inspect syntax without mutation using `npx @sentry/d - **Hooks**: `[[hooks]]` declarations write tool-event hooks to each agent's config - **Subagents**: `[[subagents]]` declarations install portable or native subagent files - **Plugins**: `[[plugins]]` declarations install canonical bundles and generate runtime-specific plugin outputs +- **Copilot**: Project skills are native; global skills link into `COPILOT_HOME/skills`; project MCP and canonical plugin bundles receive thin native projections without hooks or subagents - **Gitignore**: In project scope, managed skills, subagents, and plugin bundles are gitignored; custom in-place sources are tracked - **Global scope**: the default; manages dependencies in `~/.agents/` shared across projects, including plugins - **Project scope**: `--project` manages repository-local `agents.toml`, `agents.lock`, and `.agents/` diff --git a/skills/dotagents/references/cli-reference.md b/skills/dotagents/references/cli-reference.md index f6564784..acfc5df0 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, copilot, cursor, codex, 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..d30133c5 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", "copilot", "cursor"] # 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`, `copilot`, `cursor`, `codex`, `vscode`, `grok`, `opencode`, `pi` | | `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install | | `minimum_release_age_exclude` | string[] | No | `[]` | Sources that bypass `minimum_release_age` | @@ -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", "copilot", "cursor", "codex", "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..04392c4f 100644 --- a/skills/dotagents/references/configuration.md +++ b/skills/dotagents/references/configuration.md @@ -104,6 +104,15 @@ 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` by default; an existing `.github/mcp.json` is reused. Copilot-only projects preserve either a bare server map or an `mcpServers` document. Sharing the file with Claude, including after Copilot is removed, promotes a recognized bare map under `mcpServers` without dropping unmanaged entries. + +Global Copilot MCP uses `COPILOT_HOME/mcp-config.json`, or +`~/.copilot/mcp-config.json` when `COPILOT_HOME` is empty or unset. Dotagents +enforces mode `0600` on POSIX. + +For global skill discovery, leave `COPILOT_HOME` unset or set it to a non-empty +absolute path. Copilot CLI interprets an explicitly empty value as the +working-directory-relative `./skills` directory. ## Hooks @@ -151,25 +160,39 @@ 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", "copilot", "cursor", "codex", "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, Copilot, Cursor, Codex, Grok (`~/.grok/plugins/`), OpenCode, and Pi +locations. + +Copilot consumes a generated `.github/plugin/marketplace.json` that points to +the canonical bundle. Root `marketplace.json` and `.plugin/marketplace.json` +take precedence, so dotagents warns and prunes stale managed Copilot output +when either exists. Copilot resolves plugin manifests in `.plugin`, root, +`.github/plugin`, then `.claude-plugin` order. Sources with only +`.plugin/plugin.json` or `.github/plugin/plugin.json` are canonicalized during +installation; conflicting locators cannot target Copilot when dotagents and +Copilot would select different manifests. Legacy Copilot manifests may declare +skills and MCP servers, but native agent, command, hook, LSP, and executable-extension fields and +implicitly discovered paths are rejected. Standard manifest extension +data is preserved, but a physical `com.github.copilot/` extension directory is +rejected because Copilot loads client-native components from it. ## Agents The `agents` array controls which agent tools get symlinks and configs. ```toml -agents = ["claude", "cursor", "codex", "vscode", "grok", "opencode", "pi"] +agents = ["claude", "copilot", "cursor", "codex", "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 +209,10 @@ npx @sentry/dotagents add getsentry/agent-plugins review-tools npx @sentry/dotagents install ``` -Global symlinks include `~/.claude/skills/` for Claude and Cursor. +Global symlinks include `~/.claude/skills/` for Claude and Cursor, plus +`COPILOT_HOME/skills/` for Copilot (default `~/.copilot/skills/`). The Copilot +link is skipped when its home is the dotagents home and the skills directory is +already the canonical one. ### Project Scope (`--project`) diff --git a/specs/SPEC.md b/specs/SPEC.md index 0d8c8b1d..fe233a6b 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -100,7 +100,7 @@ targets = ["claude", "copilot", "cursor", "codex", "grok", "opencode", "pi"] | `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, Copilot, Cursor, Codex, Grok, OpenCode, and Pi skill projection. | | `trust` | No | Trusted source restrictions. When absent, all sources allowed. See `[trust]` below. | | `minimum_release_age` | No | Minimum age in **minutes** a commit must have before it's eligible for install. Applies to all git skills, subagents, and plugins (pinned and unpinned). For unpinned sources, resolves to the newest qualifying commit. For pinned sources (`ref`), rejects if the pinned commit is too new. Install fails with an error if no qualifying commit exists. When absent, always uses HEAD. | | `minimum_release_age_exclude` | No | Sources excluded from the age gate. Accepts org names (`"myorg"` matches all repos), org/repo (`"myorg/skills"` exact match), or org wildcards (`"myorg/*"`). Defaults to `[]`. | @@ -269,6 +269,24 @@ compatibility implementation (see the remaining gaps in `specs/plugins.md`): | 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 | +Copilot loads the canonical portable bundle without a generated plugin manifest. +Its marketplace lookup prefers `marketplace.json` and +`.plugin/marketplace.json` over `.github/plugin/marketplace.json`. When either +higher-priority file exists, dotagents reports the conflict and prunes stale +managed `.github/plugin/marketplace.json` output rather than maintaining a file +Copilot ignores. Copilot resolves plugin manifests in `.plugin`, root, +`.github/plugin`, then `.claude-plugin` order. Sources containing only the +`.plugin/plugin.json` or `.github/plugin/plugin.json` locator are canonicalized +to root `plugin.json` during installation. Conflicting locators are rejected +when Copilot is selected if they would hide the canonical source or make +dotagents and Copilot select different manifests. Legacy +Copilot manifests reject native agent, command, hook, LSP, and +executable-extension fields and implicitly discovered paths. Standard manifest +extension data is preserved, but a physical `com.github.copilot/` extension +directory is rejected because Copilot loads client-native components from it. +Excluding Copilot or removing the unsupported or shadowing field or resource +resolves the conflict. + 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 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/`. @@ -283,11 +301,13 @@ Global scope installs canonical plugins into `~/.agents/plugins//`. It gen | `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` | -| `copilot` | GitHub Copilot CLI/Desktop | `.copilot` | `.mcp.json` or `.github/mcp.json` | JSON | Not supported | +| `copilot` | GitHub Copilot CLI | `.copilot` | `.mcp.json` or `.github/mcp.json` | JSON | Not supported | 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. -GitHub Copilot accepts the same implicit stdio shape as Claude, so both targets produce stable shared `.mcp.json` output. For user scope, GitHub Copilot uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs this file with mode `0600`. +GitHub Copilot accepts the same implicit stdio shape as Claude and accepts both an `mcpServers` document and a bare server map. A Copilot-only project preserves whichever form already exists. When Claude shares `.mcp.json`, including after Copilot is removed from the configured agents, dotagents promotes a recognized bare server map under `mcpServers` and preserves unmanaged entries. For global scope, GitHub Copilot uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is non-empty and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents repairs access and enforces mode `0600`. + +Copilot discovers project skills directly from `.agents/skills/`. In global scope, dotagents links `COPILOT_HOME/skills/`, or `~/.copilot/skills/` when the variable is unset, to the selected global skills directory. `COPILOT_HOME` overrides must be non-empty absolute paths because Copilot CLI interprets an explicitly empty value as the working-directory-relative `./skills`. Dotagents skips the link when the Copilot and dotagents homes coincide or resolve to the same filesystem entry, and fails without changing either directory when skill names collide during migration. ### Source Types diff --git a/specs/plugins.md b/specs/plugins.md index 7eb4e51f..97a355bb 100644 --- a/specs/plugins.md +++ b/specs/plugins.md @@ -434,6 +434,21 @@ Agent Plugin bundle | 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 resolves `marketplace.json` and `.plugin/marketplace.json` before the +generated `.github/plugin/marketplace.json`. If either higher-priority catalog +exists, dotagents reports the conflict and prunes stale managed Copilot output. +Copilot resolves plugin manifests in `.plugin`, root, `.github/plugin`, then +`.claude-plugin` order. A source containing only `.plugin/plugin.json` or +`.github/plugin/plugin.json` is canonicalized to root `plugin.json` during +installation. Conflicting locators cannot target Copilot if they would hide the +canonical source or make dotagents and Copilot select different manifests. For +legacy manifests targeting Copilot, dotagents rejects native agent, command, hook, LSP, and +executable-extension fields plus implicitly discovered active paths. +Standard manifest extension data is preserved, but a physical +`com.github.copilot/` extension directory is rejected because Copilot loads +client-native components from it even without a matching manifest entry. These +are harness constraints, not extensions of the portable plugin 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`, From cc6630f8e8a63c6d393555812a45b94e4298ea83 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:08:44 -0700 Subject: [PATCH 07/10] fix: recreate skill migration destination --- packages/dotagents/src/symlinks/manager.test.ts | 16 ++++++++++++++++ packages/dotagents/src/symlinks/manager.ts | 1 + 2 files changed, 17 insertions(+) diff --git a/packages/dotagents/src/symlinks/manager.test.ts b/packages/dotagents/src/symlinks/manager.test.ts index 259a9989..91bb033d 100644 --- a/packages/dotagents/src/symlinks/manager.test.ts +++ b/packages/dotagents/src/symlinks/manager.test.ts @@ -183,6 +183,22 @@ 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"); diff --git a/packages/dotagents/src/symlinks/manager.ts b/packages/dotagents/src/symlinks/manager.ts index 365b176a..aedecd0c 100644 --- a/packages/dotagents/src/symlinks/manager.ts +++ b/packages/dotagents/src/symlinks/manager.ts @@ -106,6 +106,7 @@ async function migrateDirectory( from: string, to: string, ): Promise { + await mkdir(to, { recursive: true }); const entries = await readdir(from, { withFileTypes: true }); const conflicts: string[] = []; From 5985ca47e16520597c26510f90e439206edc3d62 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:14:57 -0700 Subject: [PATCH 08/10] test: clarify copilot manifest precedence --- packages/dotagents/src/plugins/store.test.ts | 1 + packages/dotagents/src/plugins/store.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 7ed224cd..28acc4d5 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -1095,6 +1095,7 @@ describe("plugin store", () => { 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, diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts index fe65b81b..5e75dfd1 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -1071,8 +1071,8 @@ 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"); - // A source discovered only through Copilot's locator is now represented by - // the portable canonical manifest. Leaving the locator would shadow it. + // 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 }); } From fd9fcb12035298beb05079363e9cb44d3076e116 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:53:47 -0700 Subject: [PATCH 09/10] test: trim redundant copilot coverage --- .../dotagents/src/cli/commands/doctor.test.ts | 26 -- .../src/cli/commands/install-user.test.ts | 155 +------ .../src/plugins/runtime/writer.test.ts | 90 +--- packages/dotagents/src/plugins/store.test.ts | 230 ++-------- .../dotagents/src/symlinks/manager.test.ts | 98 +---- .../dotagents/src/targets/mcp-writer.test.ts | 404 ++---------------- 6 files changed, 95 insertions(+), 908 deletions(-) diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 4580e9e2..421f5998 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -281,32 +281,6 @@ source = "getsentry/plugins" } }); - it("reports a higher-priority Copilot marketplace", async () => { - const pluginDir = join(projectRoot, ".agents", "plugins", "review-tools"); - await mkdir(pluginDir, { recursive: true }); - await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ - $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - 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" -`); - await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); - await writeFile(join(projectRoot, ".agents", ".gitignore"), "# managed\n"); - - const result = await runDoctor({ scope: resolveScope("project", projectRoot) }); - const check = result.checks.find((candidate) => candidate.name === "plugin runtime"); - - expect(check?.status).toBe("warn"); - expect(check?.message).toContain("higher-priority marketplace exists"); - expect(check?.message).toContain(join(projectRoot, "marketplace.json")); - }); - it("detects generated files tracked by git", async () => { // Initialize a git repo so git ls-files works const { execSync } = await import("node:child_process"); diff --git a/packages/dotagents/src/cli/commands/install-user.test.ts b/packages/dotagents/src/cli/commands/install-user.test.ts index ae2cf0c7..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, symlink, writeFile, lstat, stat } 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"; @@ -53,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([ @@ -68,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"), @@ -79,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" @@ -108,6 +115,10 @@ args = ["server.js"] 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: { @@ -115,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" @@ -134,133 +154,4 @@ source = "path:skill-source/pdf" const lockfile = await loadLockfile(scope.lockPath); expect(lockfile!.skills["pdf"]).toEqual({ source: "path:skill-source/pdf" }); }); - - it.each(["default", "custom", "shared", "aliased"] as const)( - "writes Copilot global config with a %s home", - async (homeMode) => { - tmpDir = await mkdtemp(join(tmpdir(), "dotagents-user-copilot-")); - const homeDir = join(tmpDir, "home"); - const dotagentsHome = join(tmpDir, "agents"); - const stateDir = join(tmpDir, "state"); - const copilotHome = homeMode === "default" - ? join(homeDir, ".copilot") - : homeMode === "shared" - ? dotagentsHome - : join(tmpDir, homeMode === "aliased" ? "copilot-alias" : "copilot"); - const sourceDir = join(dotagentsHome, "skill-source", "pdf"); - - process.env["HOME"] = homeDir; - process.env["DOTAGENTS_HOME"] = dotagentsHome; - process.env["DOTAGENTS_STATE_DIR"] = stateDir; - if (homeMode === "default") { - delete process.env["COPILOT_HOME"]; - } else { - process.env["COPILOT_HOME"] = copilotHome; - } - vi.resetModules(); - - const [{ runInstall }, { resolveScope }] = await Promise.all([ - import("./install.js"), - import("../../scope.js"), - ]); - - await mkdir(sourceDir, { recursive: true }); - if (homeMode === "aliased") { - await symlink( - dotagentsHome, - copilotHome, - process.platform === "win32" ? "junction" : "dir", - ); - } else { - await mkdir(copilotHome, { recursive: true }); - } - await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD); - await writeFile( - join(copilotHome, "mcp-config.json"), - JSON.stringify({ - note: "keep", - mcpServers: { - manual: { command: "manual", args: [] }, - fixture: { command: "old", args: [] }, - }, - }), - ); - - const scope = resolveScope("user"); - await mkdir(scope.root, { recursive: true }); - await writeFile( - scope.configPath, - `version = 1 -agents = ["copilot"] - -[[skills]] -name = "pdf" -source = "path:skill-source/pdf" - -[[mcp]] -name = "fixture" -command = "node" -args = ["server.js"] -`, - ); - - await runInstall({ scope }); - - expect(existsSync(join(scope.skillsDir, "pdf", "SKILL.md"))).toBe(true); - const copilotSkills = join(copilotHome, "skills"); - if (homeMode === "shared" || homeMode === "aliased") { - expect((await lstat(copilotSkills)).isDirectory()).toBe(true); - } else { - expect((await lstat(copilotSkills)).isSymbolicLink()).toBe(true); - expect(await readlink(copilotSkills)).toBe(relative(copilotHome, scope.skillsDir)); - } - expect(JSON.parse(await readFile(join(copilotHome, "mcp-config.json"), "utf-8"))).toEqual({ - note: "keep", - mcpServers: { - manual: { command: "manual", args: [] }, - fixture: { command: "node", args: ["server.js"] }, - }, - }); - if (process.platform !== "win32") { - expect((await stat(join(copilotHome, "mcp-config.json"))).mode & 0o777).toBe(0o600); - } - }, - ); - - it("rejects nested Copilot and dotagents homes before moving global state", async () => { - tmpDir = await mkdtemp(join(tmpdir(), "dotagents-user-copilot-overlap-")); - const copilotHome = join(tmpDir, "copilot"); - const dotagentsHome = join(copilotHome, "skills"); - const sourceDir = join(dotagentsHome, "skill-source", "pdf"); - process.env["HOME"] = join(tmpDir, "home"); - process.env["DOTAGENTS_HOME"] = dotagentsHome; - process.env["DOTAGENTS_STATE_DIR"] = join(tmpDir, "state"); - process.env["COPILOT_HOME"] = copilotHome; - vi.resetModules(); - - const [{ runInstall }, { resolveScope }] = await Promise.all([ - import("./install.js"), - import("../../scope.js"), - ]); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD); - const scope = resolveScope("user"); - await writeFile( - scope.configPath, - `version = 1 -agents = ["copilot"] - -[[skills]] -name = "pdf" -source = "path:skill-source/pdf" -`, - ); - - await expect(runInstall({ scope })).rejects.toThrow("paths overlap"); - - expect(await readFile(scope.configPath, "utf-8")).toContain('agents = ["copilot"]'); - expect(existsSync(scope.lockPath)).toBe(true); - expect(await readFile(join(scope.skillsDir, "pdf", "SKILL.md"), "utf-8")).toBe(SKILL_MD); - expect(existsSync(join(copilotHome, "agents.toml"))).toBe(false); - }); }); diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index 08b1a757..9a1162d7 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -219,22 +219,6 @@ describe("plugin writer", () => { expect(await verifyPluginOutputs(["cursor", "codex", "claude", "copilot"], [beta, alpha], root)).toEqual([]); }); - it("does not rewrite an unchanged Copilot marketplace", async () => { - const alpha = await plugin("alpha-tools"); - const marketplacePath = join(root, ".github", "plugin", "marketplace.json"); - - expect(await writePluginOutputs(["copilot"], [alpha], root)).toMatchObject({ written: 1 }); - const firstStat = await lstat(marketplacePath); - const firstContent = await readFile(marketplacePath, "utf-8"); - - expect(await writePluginOutputs(["copilot"], [alpha], root)).toEqual({ - warnings: [], - written: 0, - }); - expect(await readFile(marketplacePath, "utf-8")).toBe(firstContent); - expect((await lstat(marketplacePath)).mtimeMs).toBe(firstStat.mtimeMs); - }); - it("uses default Codex categories for empty legacy category values", async () => { const alpha = await plugin("alpha-tools", { manifest: { @@ -705,75 +689,21 @@ describe("plugin writer", () => { expect(existsSync(join(root, ".agents", "plugins", "alpha-tools", ".codex-plugin", "plugin.json"))).toBe(true); }); - it("does not overwrite unmanaged Copilot marketplace files", async () => { + it("warns about a higher-priority Copilot marketplace and prunes stale output", async () => { const alpha = await plugin("alpha-tools"); - const marketplacePath = join(root, ".github", "plugin", "marketplace.json"); - await mkdir(dirname(marketplacePath), { recursive: true }); - await writeFile(marketplacePath, "{ \"name\": \"mine\" }\n", "utf-8"); + 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 result = await writePluginOutputs(["copilot"], [alpha], root); + const reconciled = await reconcilePluginOutputs(["copilot"], [alpha], root); - expect(result.written).toBe(0); - expect(result.warnings).toEqual([ - { - agent: "copilot", - name: "marketplace", - message: `Plugin marketplace exists and is not managed by dotagents: ${marketplacePath}`, - }, - ]); - expect(await readFile(marketplacePath, "utf-8")).toBe("{ \"name\": \"mine\" }\n"); + expect(reconciled.pruned).toEqual([generatedPath]); + expect(reconciled.result.warnings[0]?.message).toContain(blockingPath); + expect(existsSync(generatedPath)).toBe(false); }); - it.each(["marketplace.json", join(".plugin", "marketplace.json")])( - "warns about higher-priority Copilot %s and prunes stale managed output", - async (blockingRelativePath) => { - const alpha = await plugin("alpha-tools"); - const generatedPath = join(root, ".github", "plugin", "marketplace.json"); - const blockingPath = join(root, blockingRelativePath); - 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).toEqual([{ - agent: "copilot", - name: "marketplace", - message: expect.stringContaining(`higher-priority marketplace exists: ${blockingPath}`), - }]); - expect(existsSync(generatedPath)).toBe(false); - expect(await readFile(blockingPath, "utf-8")).toBe("{ \"name\": \"mine\" }\n"); - expect(await verifyPluginOutputs(["copilot"], [alpha], root)).toEqual([{ - agent: "copilot", - name: "marketplace", - issue: expect.stringContaining(`higher-priority marketplace exists: ${blockingPath}`), - }]); - }, - ); - - it.skipIf(process.platform === "win32")( - "converges when a higher-priority Copilot locator aliases the managed marketplace", - async () => { - const alpha = await plugin("alpha-tools"); - const generatedPath = join(root, ".github", "plugin", "marketplace.json"); - const aliasPath = join(root, ".plugin", "marketplace.json"); - await writePluginOutputs(["copilot"], [alpha], root); - await mkdir(dirname(aliasPath), { recursive: true }); - await symlink(generatedPath, aliasPath); - - const first = await reconcilePluginOutputs(["copilot"], [alpha], root); - const second = await reconcilePluginOutputs(["copilot"], [alpha], root); - - expect(first).toEqual({ result: { warnings: [], written: 0 }, pruned: [] }); - expect(second).toEqual(first); - expect(await verifyPluginOutputs(["copilot"], [alpha], root)).toEqual([]); - expect(await readFile(aliasPath, "utf-8")).toBe( - await readFile(generatedPath, "utf-8"), - ); - }, - ); - it.each([ ["codex", "Codex", ".codex-plugin"], ["claude", "Claude", ".claude-plugin"], diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 28acc4d5..47100dc4 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -464,92 +464,33 @@ describe("plugin store", () => { } }); - it("rejects .plugin/plugin.json when a different native manifest was imported", async () => { - const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-shadow-")); + 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, ".codex-plugin"), { recursive: true }); - await mkdir(join(sourceRoot, ".plugin"), { recursive: true }); - await writeFile(join(sourceRoot, ".codex-plugin", "plugin.json"), JSON.stringify({ - name: "native-tools", - })); - await writeFile(join(sourceRoot, ".plugin", "plugin.json"), JSON.stringify({ - name: "native-tools", - })); + 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(resolved.plugin.nativeSource).toBe("codex"); expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).toThrow( - ".plugin/plugin.json would shadow the canonical plugin.json for Copilot", + "but Copilot would load .github/plugin/plugin.json instead", ); } finally { await rm(projectRoot, { recursive: true, force: true }); } }); - it.each([ - { source: "claude", manifestPath: join(".claude-plugin", "plugin.json") }, - { source: "cursor", manifestPath: join(".cursor-plugin", "plugin.json") }, - { source: "codex", manifestPath: join(".codex-plugin", "plugin.json") }, - ] as const)( - "rejects a .github Copilot manifest when the $source native fallback was imported", - async ({ source, manifestPath }) => { - const projectRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-precedence-")); - try { - const sourceRoot = join(projectRoot, "source"); - await mkdir(dirname(join(sourceRoot, manifestPath)), { recursive: true }); - await mkdir(join(sourceRoot, ".github", "plugin"), { recursive: true }); - await writeFile(join(sourceRoot, manifestPath), 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(resolved.plugin.nativeSource).toBe(source); - expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).toThrow( - "but Copilot would load .github/plugin/plugin.json instead", - ); - expect(() => preparePluginForTargets(resolved.plugin, [source])).not.toThrow(); - } finally { - await rm(projectRoot, { recursive: true, force: true }); - } - }, - ); - - it("rejects a .plugin/plugin.json shadow for legacy root manifests", async () => { - const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-shadow-")); - try { - await mkdir(join(sourceRoot, ".plugin"), { recursive: true }); - await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ - name: "legacy-tools", - skills: "./skills", - })); - await writeFile(join(sourceRoot, ".plugin", "plugin.json"), JSON.stringify({ - name: "legacy-tools", - commands: "./hidden-commands", - })); - const resolved = await resolvePlugin( - { name: "legacy-tools", source: "path:." }, - { stateDir: join(sourceRoot, "state"), projectRoot: sourceRoot }, - ); - - expect(() => preparePluginForTargets(resolved.plugin, ["copilot"])).toThrow( - ".plugin/plugin.json would shadow the canonical plugin.json for Copilot", - ); - } finally { - await rm(sourceRoot, { recursive: true, force: true }); - } - }); - it("rejects active legacy Copilot component fields", async () => { const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-components-")); try { @@ -619,71 +560,6 @@ describe("plugin store", () => { } }); - it("rejects Copilot's implicit legacy .lsp.json resource", async () => { - const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-resources-")); - try { - await writeFile(join(sourceRoot, ".lsp.json"), "{}\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: .lsp.json", - ); - } finally { - await rm(sourceRoot, { recursive: true, force: true }); - } - }); - - it("allows legacy fields and paths that Copilot does not load", async () => { - const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-resources-")); - try { - await mkdir(join(sourceRoot, "rules"), { recursive: true }); - await mkdir(join(sourceRoot, "lsp-config"), { recursive: true }); - await writeFile(join(sourceRoot, "rules", "review.md"), "# Review\n"); - await writeFile(join(sourceRoot, "lsp-config", "servers.json"), "{}\n"); - await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ - name: "resource-tools", - rules: "./rules", - apps: "./.app.json", - monitors: "./monitors", - bin: "./bin", - })); - const resolved = await resolvePlugin( - { name: "resource-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("allows inert legacy-root resources in standard 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", "claude-only.md"), "# Claude only\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"])).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 { @@ -710,27 +586,6 @@ describe("plugin store", () => { } }); - it("allows legacy Copilot MCP files while rejecting non-MCP roots", async () => { - const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-resources-")); - try { - await writeFile(join(sourceRoot, "plugin.json"), JSON.stringify({ - name: "mcp-tools", - mcpServers: "./.mcp.json", - })); - await writeFile(join(sourceRoot, ".mcp.json"), JSON.stringify({ - mcpServers: { local: { command: "node", args: ["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("allows standard Copilot MCP implementations under bin", async () => { const sourceRoot = await mkdtemp(join(tmpdir(), "dotagents-plugin-copilot-bin-")); try { @@ -1111,16 +966,22 @@ describe("plugin store", () => { } }); - it("does not fall through from a malformed higher-priority Copilot marketplace", async () => { + 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, ".plugin"), { recursive: true }); 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, ".plugin", "marketplace.json"), "{"); + 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({ @@ -1130,55 +991,14 @@ describe("plugin store", () => { }), ); - await expect(resolvePlugin( - { name: "review-tools", source: "path:source" }, - { stateDir: join(projectRoot, "state"), projectRoot }, - )).rejects.toThrow('Plugin "review-tools" not found'); + expect((await discoverPlugins(sourceRoot)).map((candidate) => candidate.name)).toEqual([ + "other-tools", + ]); } finally { await rm(projectRoot, { recursive: true, force: true }); } }); - it.each(["marketplace.json", join(".plugin", "marketplace.json")])( - "does not expose a lower-priority Copilot catalog when %s has different names", - async (higherMarketplace) => { - 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(dirname(join(sourceRoot, higherMarketplace)), { recursive: true }); - 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, higherMarketplace), 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" }], - }), - ); - - const discovered = await discoverPlugins(sourceRoot); - expect(discovered.map((candidate) => candidate.name)).toEqual(["other-tools"]); - await expect(resolvePlugin( - { name: "review-tools", source: "path:source" }, - { stateDir: join(projectRoot, "state"), projectRoot }, - )).rejects.toThrow('Plugin "review-tools" not found'); - } 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/symlinks/manager.test.ts b/packages/dotagents/src/symlinks/manager.test.ts index 91bb033d..6602cd9d 100644 --- a/packages/dotagents/src/symlinks/manager.test.ts +++ b/packages/dotagents/src/symlinks/manager.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm, @@ -9,13 +9,12 @@ import { readFile, readlink, readdir, - realpath, } 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"; -import * as dotagentsLib from "@sentry/dotagents-lib"; +import { exec } from "@sentry/dotagents-lib"; describe("symlinks", () => { let dir: string; @@ -28,7 +27,6 @@ describe("symlinks", () => { }); afterEach(async () => { - vi.restoreAllMocks(); await rm(dir, { recursive: true }); }); @@ -82,26 +80,6 @@ describe("symlinks", () => { expect(await readFile(join(skillDir, "SKILL.md"), "utf-8")).toBe("keep me"); }); - it("creates a valid link when the target home is a deeper symlink alias", async () => { - const physicalTargetDir = join(dir, "copilot-home"); - const targetDir = join(dir, "deep", "nested", "copilot-alias"); - await mkdir(physicalTargetDir, { recursive: true }); - await mkdir(join(dir, "deep", "nested"), { recursive: true }); - await symlink( - physicalTargetDir, - targetDir, - process.platform === "win32" ? "junction" : "dir", - ); - - const result = await ensureSkillsSymlink(agentsDir, targetDir); - - expect(result).toEqual({ created: true, migrated: [] }); - expect(await realpath(join(targetDir, "skills"))).toBe( - await realpath(join(agentsDir, "skills")), - ); - expect(await verifySymlinks(agentsDir, [targetDir])).toEqual([]); - }); - it("rejects a Copilot home that aliases the canonical skills target", async () => { const externalSkills = join(dir, "external-skills"); const canonicalSkills = join(agentsDir, "skills"); @@ -121,32 +99,6 @@ describe("symlinks", () => { expect(existsSync(join(externalSkills, "skills"))).toBe(false); }); - it.each(["source-inside-link", "link-inside-source"] as const)( - "rejects overlapping skills paths before changing state: %s", - async (layout) => { - const copilotHome = join(dir, "copilot-home"); - const nestedAgentsDir = layout === "source-inside-link" - ? join(copilotHome, "skills") - : agentsDir; - const targetDir = layout === "source-inside-link" - ? copilotHome - : join(agentsDir, "skills"); - const sourceSkills = join(nestedAgentsDir, "skills"); - await mkdir(join(sourceSkills, "keep-me"), { recursive: true }); - await mkdir(targetDir, { recursive: true }); - await writeFile(join(nestedAgentsDir, "agents.toml"), "keep config"); - await writeFile(join(sourceSkills, "keep-me", "SKILL.md"), "keep skill"); - - await expect(ensureSkillsSymlink(nestedAgentsDir, targetDir)).rejects.toThrow( - "paths overlap", - ); - - expect(await readFile(join(nestedAgentsDir, "agents.toml"), "utf-8")).toBe("keep config"); - expect(await readFile(join(sourceSkills, "keep-me", "SKILL.md"), "utf-8")).toBe("keep skill"); - expect((await lstat(sourceSkills)).isDirectory()).toBe(true); - }, - ); - it("replaces wrong symlink", async () => { const targetDir = join(dir, ".claude"); await mkdir(targetDir, { recursive: true }); @@ -221,44 +173,14 @@ describe("symlinks", () => { expect(existsSync(join(canonicalSkills, "unique"))).toBe(false); }); - it("preserves a native skill added while migration is finishing", async () => { - const targetDir = join(dir, ".copilot"); - const nativeSkills = join(targetDir, "skills"); - const canonicalSkills = join(agentsDir, "skills"); - await mkdir(join(nativeSkills, "initial"), { recursive: true }); - await writeFile(join(nativeSkills, "initial", "SKILL.md"), "initial"); - - let releaseGit!: () => void; - let markGitStarted!: () => void; - const gitBlocked = new Promise((resolve) => {releaseGit = resolve;}); - const gitStarted = new Promise((resolve) => {markGitStarted = resolve;}); - vi.spyOn(dotagentsLib, "exec").mockImplementation(async () => { - markGitStarted(); - await gitBlocked; - return { stdout: "", stderr: "" }; - }); - - const migration = ensureSkillsSymlink(agentsDir, targetDir); - await gitStarted; - await mkdir(join(nativeSkills, "late"), { recursive: true }); - await writeFile(join(nativeSkills, "late", "SKILL.md"), "late"); - releaseGit(); - - await expect(migration).rejects.toMatchObject({ code: "ENOTEMPTY" }); - expect((await lstat(nativeSkills)).isDirectory()).toBe(true); - expect(await readFile(join(nativeSkills, "late", "SKILL.md"), "utf-8")).toBe("late"); - expect(await readFile(join(canonicalSkills, "initial", "SKILL.md"), "utf-8")).toBe("initial"); - expect(existsSync(join(canonicalSkills, "late"))).toBe(false); - }); - it("removes migrated files from git index", async () => { // Initialize a git repo in the temp dir - await dotagentsLib.exec("git", ["init"], { cwd: dir }); - await dotagentsLib.exec("git", ["config", "user.email", "test@test.com"], { + await exec("git", ["init"], { cwd: dir }); + await exec("git", ["config", "user.email", "test@test.com"], { cwd: dir, }); - await dotagentsLib.exec("git", ["config", "user.name", "Test"], { cwd: dir }); - await dotagentsLib.exec("git", ["config", "commit.gpgsign", "false"], { cwd: dir }); + await exec("git", ["config", "user.name", "Test"], { cwd: dir }); + await exec("git", ["config", "commit.gpgsign", "false"], { cwd: dir }); // Create a real skills directory with a committed file const targetDir = join(dir, ".claude"); @@ -269,11 +191,11 @@ describe("symlinks", () => { "---\nname: test\n---\n", ); - await dotagentsLib.exec("git", ["add", "."], { cwd: dir }); - await dotagentsLib.exec("git", ["commit", "-m", "initial"], { cwd: dir }); + await exec("git", ["add", "."], { cwd: dir }); + await exec("git", ["commit", "-m", "initial"], { cwd: dir }); // Verify file is tracked before migration - const { stdout: before } = await dotagentsLib.exec( + const { stdout: before } = await exec( "git", ["ls-files", ".claude/skills/"], { cwd: dir }, @@ -286,7 +208,7 @@ describe("symlinks", () => { expect(result.migrated).toContain("my-skill"); // Verify file is no longer in git index - const { stdout: after } = await dotagentsLib.exec( + const { stdout: after } = await exec( "git", ["ls-files", ".claude/skills/"], { cwd: dir }, diff --git a/packages/dotagents/src/targets/mcp-writer.test.ts b/packages/dotagents/src/targets/mcp-writer.test.ts index 78373932..dde06ac2 100644 --- a/packages/dotagents/src/targets/mcp-writer.test.ts +++ b/packages/dotagents/src/targets/mcp-writer.test.ts @@ -111,17 +111,6 @@ describe("writeMcpConfigs", () => { expect(raw).toContain("github"); }); - it("writes copilot .mcp.json with environment references", async () => { - await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); - - const content = JSON.parse(await readFile(join(dir, ".mcp.json"), "utf-8")); - expect(content.mcpServers.github).toEqual({ - command: "npx", - args: ["-y", "@mcp/server-github"], - env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, - }); - }); - it("writes .opencode/opencode.jsonc by default", async () => { await writeMcpConfigs(["opencode"], [STDIO_SERVER], projectMcpResolver(dir)); @@ -250,210 +239,60 @@ describe("writeMcpConfigs", () => { expect(existsSync(join(dir, ".opencode", "opencode.jsonc"))).toBe(false); }); - it("reuses .github/mcp.json for copilot when .mcp.json is absent", async () => { - const filePath = join(dir, ".github", "mcp.json"); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify({ - note: "keep", - mcpServers: { manual: { command: "manual", args: [] } }, - })); - - await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); - - expect(existsSync(join(dir, ".mcp.json"))).toBe(false); - expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ - note: "keep", - mcpServers: { - manual: { command: "manual", args: [] }, - github: { - command: "npx", - args: ["-y", "@mcp/server-github"], - env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, - }, - }, - }); - }); - - it.each([".mcp.json", join(".github", "mcp.json")])( - "preserves a bare Copilot server map at %s", - async (relativePath) => { - const filePath = join(dir, relativePath); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify({ - manual: { command: "manual", args: [] }, - github: { command: "old", args: [] }, - })); - - await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); - - expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ - manual: { command: "manual", args: [] }, - github: { - command: "npx", - args: ["-y", "@mcp/server-github"], - env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, - }, - }); - }, - ); - - it("migrates a bare .mcp.json when Claude and Copilot share it", async () => { - const filePath = join(dir, ".mcp.json"); - await writeFile(filePath, JSON.stringify({ - manual: { command: "manual", args: [] }, - github: { command: "old", args: [] }, - })); - - await writeMcpConfigs(["claude", "copilot"], [STDIO_SERVER], projectMcpResolver(dir)); - - expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ - mcpServers: { - manual: { command: "manual", args: [] }, - github: { - command: "npx", - args: ["-y", "@mcp/server-github"], - env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, - }, - }, - }); - }); - - it("migrates a bare .mcp.json when Copilot is removed and Claude remains", async () => { + 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(["claude"], [STDIO_SERVER], projectMcpResolver(dir)); + await writeMcpConfigs([...agents], [STDIO_SERVER], projectMcpResolver(dir)); - expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ - mcpServers: { - manual: { command: "manual", args: [] }, - github: { - command: "npx", - args: ["-y", "@mcp/server-github"], - env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, - }, - }, - }); + 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("does not mistake unrelated rootless Claude metadata for a bare server map", async () => { - const filePath = join(dir, ".mcp.json"); + 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({ - metadata: { owner: "me" }, - settings: { enabled: true }, - })); - - await writeMcpConfigs(["claude"], [STDIO_SERVER], projectMcpResolver(dir)); - - expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ - metadata: { owner: "me" }, - settings: { enabled: true }, - mcpServers: { - github: { - command: "npx", - args: ["-y", "@mcp/server-github"], - env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, - }, - }, - }); - }); - - it("promotes a bare Copilot fallback into Claude's rooted .mcp.json", async () => { - const preferredPath = join(dir, ".mcp.json"); - const fallbackPath = join(dir, ".github", "mcp.json"); - await mkdir(dirname(fallbackPath), { recursive: true }); - await writeFile(fallbackPath, JSON.stringify({ - manual: { command: "manual", args: [] }, - })); - - await writeMcpConfigs(["copilot", "claude"], [STDIO_SERVER], projectMcpResolver(dir)); - - expect(JSON.parse(await readFile(preferredPath, "utf-8"))).toEqual({ - mcpServers: { - manual: { command: "manual", args: [] }, - github: { - command: "npx", - args: ["-y", "@mcp/server-github"], - env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, - }, - }, - }); - expect(JSON.parse(await readFile(fallbackPath, "utf-8"))).toEqual({ manual: { command: "manual", args: [] }, - }); - }); - - it("prefers copilot .mcp.json when both project paths exist", async () => { - const preferredPath = join(dir, ".mcp.json"); - const fallbackPath = join(dir, ".github", "mcp.json"); - await mkdir(dirname(fallbackPath), { recursive: true }); - await writeFile(preferredPath, JSON.stringify({ mcpServers: {} })); - await writeFile(fallbackPath, JSON.stringify({ - mcpServers: { fallback: { command: "fallback", args: [] } }, })); await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); - expect(JSON.parse(await readFile(preferredPath, "utf-8")).mcpServers.github).toBeDefined(); - expect(JSON.parse(await readFile(fallbackPath, "utf-8"))).toEqual({ - mcpServers: { fallback: { command: "fallback", args: [] } }, - }); + 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"], - ])("seeds shared .mcp.json from the copilot fallback for %s first", async (first, second) => { + ] 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 = { - note: "keep", - mcpServers: { manual: { command: "manual", args: [] } }, + manual: { command: "manual", args: [] }, }; await mkdir(dirname(fallbackPath), { recursive: true }); await writeFile(fallbackPath, JSON.stringify(fallback)); await writeMcpConfigs([first, second], [STDIO_SERVER], projectMcpResolver(dir)); - expect(JSON.parse(await readFile(preferredPath, "utf-8"))).toEqual({ - note: "keep", - mcpServers: { - manual: { command: "manual", args: [] }, - github: { - command: "npx", - args: ["-y", "@mcp/server-github"], - env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, - }, - }, - }); + 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("writes the same shared .mcp.json for claude and copilot in either order", async () => { - const firstDir = join(dir, "first"); - const secondDir = join(dir, "second"); - await mkdir(firstDir); - await mkdir(secondDir); - - await writeMcpConfigs( - ["claude", "copilot"], - [STDIO_SERVER, HTTP_SERVER], - projectMcpResolver(firstDir), - ); - await writeMcpConfigs( - ["copilot", "claude"], - [STDIO_SERVER, HTTP_SERVER], - projectMcpResolver(secondDir), - ); - - expect(await readFile(join(firstDir, ".mcp.json"), "utf-8")).toBe( - await readFile(join(secondDir, ".mcp.json"), "utf-8"), - ); - }); - it("handles multiple servers", async () => { await writeMcpConfigs(["claude"], [STDIO_SERVER, HTTP_SERVER], projectMcpResolver(dir)); @@ -640,83 +479,17 @@ describe("writeMcpConfigs", () => { }); it.skipIf(process.platform === "win32")( - "creates, repairs, and preserves a secure user config mode", + "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 chmod(filePath, 0o644); - const before = await stat(filePath, { bigint: true }); - const repaired = await reconcileMcpConfigs( - ["copilot"], - [STDIO_SERVER], - resolver, - "apply", - ); - const after = await stat(filePath, { bigint: true }); - expect(repaired.written).toEqual([filePath]); - expect(repaired.issues).toEqual([ - expect.objectContaining({ issue: expect.stringContaining("expected 600") }), - ]); - expect(after.mode & 0o777n).toBe(0o600n); - expect(after.mtimeNs).toBe(before.mtimeNs); - - const unchanged = await reconcileMcpConfigs( - ["copilot"], - [STDIO_SERVER], - resolver, - "apply", - ); - expect(unchanged.issues).toEqual([]); - expect(unchanged.written).toEqual([]); - }, - ); - - it.skipIf(process.platform === "win32")( - "repairs a read-only user config mode before writing semantic drift", - async () => { - const filePath = join(dir, "copilot", "mcp-config.json"); - const resolver = () => ({ filePath, shared: false, mode: 0o600 }); - await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, JSON.stringify({ mcpServers: { github: { command: "old", args: [] } }, })); - await chmod(filePath, 0o400); - - const result = await reconcileMcpConfigs( - ["copilot"], - [STDIO_SERVER], - resolver, - "apply", - ); - - expect(result.written).toEqual([filePath]); - expect((await stat(filePath)).mode & 0o777).toBe(0o600); - expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ - mcpServers: { - github: { - command: "npx", - args: ["-y", "@mcp/server-github"], - env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, - }, - }, - }); - }, - ); - - it.skipIf(process.platform === "win32")( - "repairs an unreadable user config mode before reconciliation", - async () => { - const filePath = join(dir, "copilot", "mcp-config.json"); - const resolver = () => ({ filePath, shared: false, mode: 0o600 }); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify({ - mcpServers: { github: { command: "old", args: [] } }, - })); - await chmod(filePath, 0o200); + await chmod(filePath, 0o000); const result = await reconcileMcpConfigs( ["copilot"], @@ -728,77 +501,17 @@ describe("writeMcpConfigs", () => { 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"))).toEqual({ - mcpServers: { - github: { - command: "npx", - args: ["-y", "@mcp/server-github"], - env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, - }, - }, - }); + expect(JSON.parse(await readFile(filePath, "utf-8")).mcpServers.github.command).toBe("npx"); }, ); it.skipIf(process.platform === "win32")( - "does not chmod a directory mistaken for a user config file", - async () => { - const filePath = join(dir, "copilot", "mcp-config.json"); - const resolver = () => ({ filePath, shared: false, mode: 0o600 }); - await mkdir(filePath, { recursive: true }); - await chmod(filePath, 0o755); - - 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(filePath)).mode & 0o777).toBe(0o755); - }, - ); - - it.skipIf(process.platform === "win32")( - "does not chmod a symlink target before validating a user config", - async () => { - const filePath = join(dir, "copilot", "mcp-config.json"); - const unrelatedPath = join(dir, "unrelated-executable"); - const resolver = () => ({ filePath, shared: false, mode: 0o600 }); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(unrelatedPath, "not json\n"); - await chmod(unrelatedPath, 0o755); - 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(0o755); - expect(await readFile(unrelatedPath, "utf-8")).toBe("not json\n"); - }, - ); - - it.skipIf(process.platform === "win32")( - "does not chmod or overwrite a valid user config through a symlink", + "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", args: [] } }, - }); + const original = JSON.stringify({ mcpServers: { github: { command: "unmanaged" } } }); await mkdir(dirname(filePath), { recursive: true }); await writeFile(unrelatedPath, original); await chmod(unrelatedPath, 0o644); @@ -820,58 +533,6 @@ describe("writeMcpConfigs", () => { }, ); - it.skipIf(process.platform === "win32")( - "does not create a target through a dangling user config symlink", - async () => { - const filePath = join(dir, "copilot", "mcp-config.json"); - const danglingTarget = join(dir, "external", "created.json"); - const resolver = () => ({ filePath, shared: false, mode: 0o600 }); - await mkdir(dirname(filePath), { recursive: true }); - await mkdir(dirname(danglingTarget), { recursive: true }); - await symlink(danglingTarget, 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(existsSync(danglingTarget)).toBe(false); - }, - ); - - it.skipIf(process.platform === "win32")( - "does not create a managed target through a dangling config symlink", - async () => { - const filePath = join(dir, "copilot", "mcp-config.json"); - const danglingTarget = join(dir, "external", "created.json"); - const statePath = join(dir, "state", "copilot-mcp.json"); - await mkdir(dirname(filePath), { recursive: true }); - await mkdir(dirname(danglingTarget), { recursive: true }); - await symlink(danglingTarget, filePath); - - const result = await reconcileManagedMcpConfig({ - agentId: "copilot", - servers: [STDIO_SERVER], - target: { filePath, shared: false, mode: 0o600 }, - statePath, - mode: "apply", - }); - - expect(result.unresolved).toEqual([ - expect.objectContaining({ issue: expect.stringContaining("not a regular file") }), - ]); - expect(result.written).toEqual([]); - expect(existsSync(danglingTarget)).toBe(false); - expect(existsSync(statePath)).toBe(false); - }, - ); - it("interpolates env refs in claude HTTP headers/URL with ${VAR} syntax", async () => { await writeMcpConfigs(["claude"], [HTTP_SERVER_WITH_ENV_REFS], projectMcpResolver(dir)); @@ -917,17 +578,6 @@ describe("writeMcpConfigs", () => { }); }); - it("interpolates env refs in copilot HTTP headers/URL with ${VAR} syntax", async () => { - await writeMcpConfigs(["copilot"], [HTTP_SERVER_WITH_ENV_REFS], projectMcpResolver(dir)); - - const content = JSON.parse(await readFile(join(dir, ".mcp.json"), "utf-8")); - expect(content.mcpServers["authed-api"]).toEqual({ - type: "http", - url: "https://${API_HOST}/mcp", - headers: { "X-Api-Key": "${API_KEY}", Authorization: "Bearer ${TOKEN}" }, - }); - }); - it("preserves literal HTTP placeholder-like values for adapter declarations", async () => { const literal = { ...HTTP_SERVER_WITH_ENV_REFS, interpolateEnvRefs: false }; await writeMcpConfigs(["opencode", "codex"], [literal], projectMcpResolver(dir)); From 8067b283c36cf08e8c2289f6a161158b1e635773 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:04:14 -0700 Subject: [PATCH 10/10] docs: fold copilot into shared guidance --- README.md | 14 ++------ docs/public/llms.txt | 16 ++++----- docs/src/content/docs/cli.mdx | 30 +++-------------- docs/src/content/docs/guide.mdx | 26 ++------------- docs/src/content/docs/index.mdx | 8 ++--- examples/full/agents.toml | 2 +- skills/dotagents/SKILL.md | 3 +- skills/dotagents/references/cli-reference.md | 2 +- skills/dotagents/references/config-schema.md | 6 ++-- skills/dotagents/references/configuration.md | 35 ++++---------------- specs/SPEC.md | 31 ++++------------- specs/plugins.md | 29 +++++++--------- 12 files changed, 52 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index 866e965f..7b554bfc 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,10 @@ agents = ["claude", "cursor", "codex", "copilot", "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` | -| `copilot` | `.copilot` | `.mcp.json` or `.github/mcp.json` | -- | -- | Custom subagents are declared with `[[subagents]]` entries. dotagents writes generated runtime-specific files during `install` and repairs them during `sync`: @@ -154,28 +154,20 @@ 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`. -GitHub Copilot reads project skills from `.agents/skills/` without a symlink. For global skills, dotagents links `COPILOT_HOME/skills/` to the selected global skills directory; leave `COPILOT_HOME` unset to use `~/.copilot/skills/`. Set overrides to a non-empty absolute path: Copilot CLI resolves an explicitly empty value as the working-directory-relative `./skills`, which is unsuitable for a global projection. If the Copilot and dotagents homes are the same directory or filesystem alias, no self-referential link is created. Existing skill-name collisions fail before either directory is changed. - -New Copilot project MCP configuration uses `.mcp.json`; an existing `.github/mcp.json` is reused when `.mcp.json` is absent. Copilot accepts either an `mcpServers` document or a bare server map, so dotagents preserves the existing form for Copilot-only projects. If Claude shares the same `.mcp.json`, or remains after Copilot is removed, dotagents nests a recognized bare server map under `mcpServers` while preserving unmanaged servers. Global MCP configuration uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is non-empty and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs the global file with mode `0600`, which matches Copilot CLI. - -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//`. Copilot consumes the canonical Agent Plugins bundle directly, so it does not need a generated plugin manifest. 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", "copilot", "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/`. 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`. -After installation, register the DotAgents root with Copilot by running `copilot plugin marketplace add `. Then run `copilot plugin install @dotagents`. - -Copilot gives `marketplace.json` and `.plugin/marketplace.json` precedence over dotagents' `.github/plugin/marketplace.json`. When either higher-priority file exists, dotagents warns and removes any stale managed Copilot marketplace instead of generating ignored output. Copilot resolves plugin manifests in `.plugin`, root, `.github/plugin`, then `.claude-plugin` order. A source containing only `.plugin/plugin.json` or `.github/plugin/plugin.json` is canonicalized to root `plugin.json` during installation. Conflicting locators fail preflight when Copilot is selected if they would hide the canonical source or make dotagents and Copilot select different manifests. Legacy Copilot manifests may declare skills and MCP servers; native agent, command, hook, LSP, and executable-extension fields and implicitly discovered paths are rejected. Standard manifest extension data is preserved, but a physical `com.github.copilot/` extension directory is rejected because Copilot loads client-native components from it. Remove unsupported or conflicting fields and resources, or exclude `copilot` from that plugin's `targets`. - 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. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. Normal skills need no Pi-specific configuration; plugin bundles can target `pi` when their `skills/` components should be exposed there. diff --git a/docs/public/llms.txt b/docs/public/llms.txt index e44b015d..e255c583 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -57,7 +57,7 @@ And a lockfile (`agents.lock`) tracking which skills, subagents, and plugins are 2. `install` clones or refreshes sources and copies skills, subagents, and plugins into the selected scope's managed directories 3. `agents.lock` tracks which skills, subagents, and plugins are managed (automatically gitignored in project scope) 4. Managed project skills, canonical installed subagents, and managed plugin bundles under `.agents/` are gitignored. Collaborators run `npx @sentry/dotagents --project install` after cloning. Custom skills in `.agents/skills/` and project-authored plugin source directories in `.agents/plugins/` are tracked by git normally when they are not installed dependencies. -5. Symlinks connect managed skills to each agent's expected global or project location (`.claude/skills/` for Claude and Cursor, and `$COPILOT_HOME/skills/` for global Copilot) +5. Symlinks connect `.agents/skills/` to each agent's expected location (`.claude/skills/` for Claude and Cursor) 6. MCP, hook, subagent, and plugin configs are generated for each declared agent where supported ## Configuration (agents.toml) @@ -149,7 +149,7 @@ targets = ["claude", "codex", "opencode"] name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" -targets = ["claude", "copilot", "cursor", "codex", "grok", "opencode", "pi"] +targets = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` ### Top-level Fields @@ -160,7 +160,7 @@ targets = ["claude", "copilot", "cursor", "codex", "grok", "opencode", "pi"] | `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`, `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, Copilot, 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/*`. | @@ -221,7 +221,7 @@ Config files generated per agent: - 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. -GitHub Copilot accepts the same implicit stdio shape as Claude and accepts both an `mcpServers` document and a bare server map. Copilot-only projects preserve the existing form. When Claude shares `.mcp.json`, including after Copilot is removed from the configured agents, dotagents nests a recognized bare server map under `mcpServers` while preserving unmanaged entries. In global scope, GitHub Copilot uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is non-empty and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents repairs access and enforces mode `0600`. +Copilot accepts both bare server maps and `mcpServers` documents. Global MCP uses `$COPILOT_HOME/mcp-config.json` (default `~/.copilot/mcp-config.json`). ### Hooks @@ -313,8 +313,6 @@ Generated project-scope plugin outputs: - 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 -Copilot loads the canonical Agent Plugins bundle directly. Its `marketplace.json` and `.plugin/marketplace.json` locations take precedence over dotagents' `.github/plugin/marketplace.json`; when either exists, dotagents warns and prunes stale managed Copilot marketplace output. Copilot resolves plugin manifests in `.plugin`, root, `.github/plugin`, then `.claude-plugin` order. A source containing only `.plugin/plugin.json` or `.github/plugin/plugin.json` is canonicalized to root `plugin.json`. Conflicting locators fail preflight when Copilot is selected if they would hide the canonical source or make dotagents and Copilot select different manifests. Legacy Copilot manifests may declare skills and MCP servers; native agent, command, hook, LSP, and executable-extension fields and implicitly discovered paths are rejected. Standard manifest extension data is preserved, but a physical `com.github.copilot/` extension directory is rejected because Copilot loads client-native components from it. Remove unsupported or conflicting fields and resources, or exclude `copilot` from the plugin's `targets`. - 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/`. 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/`. @@ -505,11 +503,11 @@ 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` | -| `copilot` | GitHub Copilot CLI | `.copilot` | Project: reads `.agents/skills/`; global: `$COPILOT_HOME/skills/` symlink | `.mcp.json` or `.github/mcp.json` | Not supported | Not supported | -Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, and OpenCode read `.agents/skills/` directly. Copilot does so in project scope; global installs link the selected global skills directory into `COPILOT_HOME/skills/`, defaulting to `~/.copilot/skills/` when `COPILOT_HOME` is unset. Overrides must be non-empty absolute paths because Copilot CLI interprets an explicitly empty value as working-directory-relative `./skills`. +Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, and OpenCode read `.agents/skills/` directly. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. Normal skills need no Pi-specific target or symlink configuration; plugin bundles can target `pi` when their `skills/` components should be exposed there. @@ -526,7 +524,7 @@ Operates on `DOTAGENTS_HOME` when set and otherwise `~/.agents/`, regardless of - Override location: `DOTAGENTS_HOME` environment variable - Explicit spellings: `--global`, or compatibility alias `--user` -Global-scope symlinks include `~/.claude/skills/` for Claude and Cursor, plus `COPILOT_HOME/skills/` (default `~/.copilot/skills/`) for Copilot. +Global-scope symlinks include `~/.claude/skills/` for Claude and Cursor. ### Project Scope (`--project`) diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 77f383b6..44fe30b7 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -373,7 +373,7 @@ Status output: | `version` | integer | -- | Schema version. Always `1`. | | `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, Copilot, 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,13 +399,8 @@ Status output: | `headers` | table | No | HTTP headers for URL servers, not needed with OAuth | | `env` | string[] | No | Environment variable names to pass through | -Copilot project MCP uses `.mcp.json`, falling back to an existing -`.github/mcp.json`. Both a bare server map and an `mcpServers` document are -accepted and preserved for Copilot-only projects. Sharing `.mcp.json` with -Claude, including after Copilot is removed, promotes a recognized bare map -under `mcpServers` without dropping unmanaged servers. Global Copilot MCP uses -`$COPILOT_HOME/mcp-config.json`, defaulting to -`~/.copilot/mcp-config.json`, and is enforced as mode `0600` on POSIX. +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 @@ -490,22 +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, Copilot, Cursor, and Codex 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. - -Copilot consumes the canonical portable bundle directly. It gives root -`marketplace.json` and `.plugin/marketplace.json` precedence over the generated -`.github/plugin/marketplace.json`; dotagents warns and prunes stale managed -output when either higher-priority file exists. Copilot resolves plugin -manifests in `.plugin`, root, `.github/plugin`, then `.claude-plugin` order. A -source containing only `.plugin/plugin.json` or `.github/plugin/plugin.json` is -canonicalized to root `plugin.json`. Conflicting locators fail preflight when -dotagents and Copilot would select different manifests. Legacy Copilot -manifests may declare skills and MCP servers; native agent, command, hook, LSP, and -executable-extension fields and implicitly discovered paths are rejected. -Standard manifest extension data is preserved, but a physical -`com.github.copilot/` extension directory is rejected because Copilot loads -client-native components from it. Remove unsupported or shadowing fields and -resources, or exclude `copilot` from that plugin's `targets`. +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 @@ -521,7 +501,7 @@ dotagents add getsentry/agent-plugins review-tools dotagents install ``` -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/`. Copilot global skills and MCP use `COPILOT_HOME`, defaulting to `~/.copilot/` when it is unset. Use a non-empty absolute override; Copilot CLI interprets an explicitly empty value as a working-directory-relative personal skills path. +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`. diff --git a/docs/src/content/docs/guide.mdx b/docs/src/content/docs/guide.mdx index 662bb448..85423576 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -146,12 +146,8 @@ version. ## Global Paths Global config, skills, plugins, and the lockfile live under `~/.agents/`. Plugin -outputs are written to the global Claude, Copilot, Cursor, Codex, Grok, +outputs are written to the global Claude, Cursor, Codex, Copilot, Grok, OpenCode, and Pi locations. Override the dotagents root with `DOTAGENTS_HOME`. -Copilot global skills and MCP use `COPILOT_HOME` when it is a non-empty -absolute path and `~/.copilot/` when it is unset. Do not export an empty value: -Copilot CLI then resolves personal skills from working-directory-relative -`./skills`. `--global` selects global scope explicitly. `--user` remains a compatibility alias. @@ -164,7 +160,7 @@ alias. ```toml version = 1 -agents = ["claude", "copilot", "cursor", "codex", "opencode"] +agents = ["claude", "cursor", "codex", "copilot", "opencode"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -221,7 +217,7 @@ targets = ["claude", "codex", "opencode"] [[plugins]] name = "review-tools" source = "getsentry/agent-pack" -targets = ["claude", "copilot", "cursor", "codex", "opencode"] +targets = ["claude", "cursor", "codex", "copilot", "opencode"] ``` Portable Agent Plugins may coexist with authored Claude, Cursor, or Codex @@ -230,21 +226,5 @@ Dotagents ignores reproducible native manifests and retains only behavior it cannot generate as a matching-client fallback; unrelated targets receive only portable skills and MCP. -Copilot is a native projection of that same portable core. It accepts project -MCP files as either a bare server map or under `mcpServers`; dotagents preserves -the existing form unless Claude shares the file or remains after Copilot is -removed, in which case a recognized bare map is nested without dropping -unmanaged servers. For plugins, root -`marketplace.json` and `.plugin/marketplace.json` shadow the generated -`.github/plugin/marketplace.json`. Copilot resolves plugin manifests in -`.plugin`, root, `.github/plugin`, then `.claude-plugin` order. A source with -only `.plugin/plugin.json` or `.github/plugin/plugin.json` is canonicalized; -conflicting locators fail preflight if dotagents and Copilot would select -different manifests. Legacy Copilot -manifests reject native agent, command, hook, LSP, and executable-extension -resources so they do not expand the adapter's scope. Standard manifest -extension data is preserved, but a physical `com.github.copilot/` extension -directory is rejected because Copilot would activate its native components. - See the [CLI reference](/cli/#configuration-agentstoml) for all fields and options. diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index a680fa7b..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", "copilot", "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", "copilot", "cursor", "codex", "opencode"]`}
+targets = ["claude", "cursor", "codex", "copilot", "opencode"]`} @@ -97,7 +97,7 @@ targets = ["claude", "copilot", "cursor", "codex", "opencode"]`}

Supported Agents

-

The `agents` array tells dotagents which tools to configure. Grok and Pi are plugin-only targets. Copilot reads project skills directly and receives a global skills link.

+

The `agents` array tells dotagents which tools to configure. Grok and Pi are plugin-only targets; Pi also reads `.agents/skills/` directly.

| Agent | Config Dir | MCP Config | Hooks | Subagents | @@ -109,8 +109,6 @@ targets = ["claude", "copilot", "cursor", "codex", "opencode"]`} | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | None | | `opencode` | `.opencode` | `.opencode/opencode.jsonc` | None | `.opencode/agents/*.md` | - GitHub Copilot reads project skills from `.agents/skills/`. Global installs link skills into `$COPILOT_HOME/skills/`, defaulting to `~/.copilot/skills/`. User MCP configuration follows the same home and uses `mcp-config.json`. Projects use `.mcp.json`, or reuse `.github/mcp.json` when it exists; existing bare and `mcpServers` forms are both supported. - OpenCode reuses existing project config from `.opencode/opencode.jsonc`, `.opencode/opencode.json`, `opencode.jsonc`, or `opencode.json`, in that order. New projects use `.opencode/opencode.jsonc`.
diff --git a/examples/full/agents.toml b/examples/full/agents.toml index 6df05ea2..fcc2e21f 100644 --- a/examples/full/agents.toml +++ b/examples/full/agents.toml @@ -1,5 +1,5 @@ version = 1 -agents = ["claude", "copilot", "cursor", "codex", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] [[skills]] name = "review" diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index 4383d84d..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, GitHub Copilot, 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 @@ -119,7 +119,6 @@ When trust blocks a source, inspect syntax without mutation using `npx @sentry/d - **Hooks**: `[[hooks]]` declarations write tool-event hooks to each agent's config - **Subagents**: `[[subagents]]` declarations install portable or native subagent files - **Plugins**: `[[plugins]]` declarations install canonical bundles and generate runtime-specific plugin outputs -- **Copilot**: Project skills are native; global skills link into `COPILOT_HOME/skills`; project MCP and canonical plugin bundles receive thin native projections without hooks or subagents - **Gitignore**: In project scope, managed skills, subagents, and plugin bundles are gitignored; custom in-place sources are tracked - **Global scope**: the default; manages dependencies in `~/.agents/` shared across projects, including plugins - **Project scope**: `--project` manages repository-local `agents.toml`, `agents.lock`, and `.agents/` diff --git a/skills/dotagents/references/cli-reference.md b/skills/dotagents/references/cli-reference.md index acfc5df0..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, copilot, 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 d30133c5..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", "copilot", "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`, `copilot`, `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", "copilot", "cursor", "codex", "grok", "opencode", "pi"] +targets = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` | Field | Type | Required | Description | diff --git a/skills/dotagents/references/configuration.md b/skills/dotagents/references/configuration.md index 04392c4f..b87f8abb 100644 --- a/skills/dotagents/references/configuration.md +++ b/skills/dotagents/references/configuration.md @@ -104,15 +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` by default; an existing `.github/mcp.json` is reused. Copilot-only projects preserve either a bare server map or an `mcpServers` document. Sharing the file with Claude, including after Copilot is removed, promotes a recognized bare map under `mcpServers` without dropping unmanaged entries. - -Global Copilot MCP uses `COPILOT_HOME/mcp-config.json`, or -`~/.copilot/mcp-config.json` when `COPILOT_HOME` is empty or unset. Dotagents -enforces mode `0600` on POSIX. - -For global skill discovery, leave `COPILOT_HOME` unset or set it to a non-empty -absolute path. Copilot CLI interprets an explicitly empty value as the -working-directory-relative `./skills` directory. +- GitHub Copilot: `.mcp.json` or an existing `.github/mcp.json`; global scope uses `$COPILOT_HOME/mcp-config.json` (default `~/.copilot/mcp-config.json`). ## Hooks @@ -160,34 +152,21 @@ Declare plugin bundles with `[[plugins]]`. dotagents installs canonical bundles name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" -targets = ["claude", "copilot", "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, Copilot, Cursor, Codex, Grok (`~/.grok/plugins/`), OpenCode, and Pi +Claude, Cursor, Codex, Copilot, Grok (`~/.grok/plugins/`), OpenCode, and Pi locations. -Copilot consumes a generated `.github/plugin/marketplace.json` that points to -the canonical bundle. Root `marketplace.json` and `.plugin/marketplace.json` -take precedence, so dotagents warns and prunes stale managed Copilot output -when either exists. Copilot resolves plugin manifests in `.plugin`, root, -`.github/plugin`, then `.claude-plugin` order. Sources with only -`.plugin/plugin.json` or `.github/plugin/plugin.json` are canonicalized during -installation; conflicting locators cannot target Copilot when dotagents and -Copilot would select different manifests. Legacy Copilot manifests may declare -skills and MCP servers, but native agent, command, hook, LSP, and executable-extension fields and -implicitly discovered paths are rejected. Standard manifest extension -data is preserved, but a physical `com.github.copilot/` extension directory is -rejected because Copilot loads client-native components from it. - ## Agents The `agents` array controls which agent tools get symlinks and configs. ```toml -agents = ["claude", "copilot", "cursor", "codex", "vscode", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "vscode", "grok", "opencode", "pi"] ``` Each agent gets: @@ -209,10 +188,8 @@ npx @sentry/dotagents add getsentry/agent-plugins review-tools npx @sentry/dotagents install ``` -Global symlinks include `~/.claude/skills/` for Claude and Cursor, plus -`COPILOT_HOME/skills/` for Copilot (default `~/.copilot/skills/`). The Copilot -link is skipped when its home is the dotagents home and the skills directory is -already the canonical one. +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 fe233a6b..d16d41e9 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -82,7 +82,7 @@ targets = ["claude", "codex", "opencode"] name = "review-tools" source = "getsentry/agent-plugins" path = "plugins/review-tools" -targets = ["claude", "copilot", "cursor", "codex", "grok", "opencode", "pi"] +targets = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` ### Fields @@ -100,7 +100,7 @@ targets = ["claude", "copilot", "cursor", "codex", "grok", "opencode", "pi"] | `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, Copilot, 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 `[]`. | @@ -269,24 +269,6 @@ compatibility implementation (see the remaining gaps in `specs/plugins.md`): | 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 | -Copilot loads the canonical portable bundle without a generated plugin manifest. -Its marketplace lookup prefers `marketplace.json` and -`.plugin/marketplace.json` over `.github/plugin/marketplace.json`. When either -higher-priority file exists, dotagents reports the conflict and prunes stale -managed `.github/plugin/marketplace.json` output rather than maintaining a file -Copilot ignores. Copilot resolves plugin manifests in `.plugin`, root, -`.github/plugin`, then `.claude-plugin` order. Sources containing only the -`.plugin/plugin.json` or `.github/plugin/plugin.json` locator are canonicalized -to root `plugin.json` during installation. Conflicting locators are rejected -when Copilot is selected if they would hide the canonical source or make -dotagents and Copilot select different manifests. Legacy -Copilot manifests reject native agent, command, hook, LSP, and -executable-extension fields and implicitly discovered paths. Standard manifest -extension data is preserved, but a physical `com.github.copilot/` extension -directory is rejected because Copilot loads client-native components from it. -Excluding Copilot or removing the unsupported or shadowing field or resource -resolves the conflict. - 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 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/`. @@ -298,16 +280,17 @@ 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` | -| `copilot` | GitHub Copilot CLI | `.copilot` | `.mcp.json` or `.github/mcp.json` | JSON | Not supported | 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. -GitHub Copilot accepts the same implicit stdio shape as Claude and accepts both an `mcpServers` document and a bare server map. A Copilot-only project preserves whichever form already exists. When Claude shares `.mcp.json`, including after Copilot is removed from the configured agents, dotagents promotes a recognized bare server map under `mcpServers` and preserves unmanaged entries. For global scope, GitHub Copilot uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is non-empty and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents repairs access and enforces mode `0600`. - -Copilot discovers project skills directly from `.agents/skills/`. In global scope, dotagents links `COPILOT_HOME/skills/`, or `~/.copilot/skills/` when the variable is unset, to the selected global skills directory. `COPILOT_HOME` overrides must be non-empty absolute paths because Copilot CLI interprets an explicitly empty value as the working-directory-relative `./skills`. Dotagents skips the link when the Copilot and dotagents homes coincide or resolve to the same filesystem entry, and fails without changing either directory when skill names collide during migration. +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 diff --git a/specs/plugins.md b/specs/plugins.md index 97a355bb..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", "copilot", "cursor", "codex", "grok", "opencode", "pi"] +targets = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` | Field | Required | Description | @@ -427,27 +427,22 @@ Agent Plugin bundle | Target | Portable core | Target extension | Generated output | |--------|---------------|------------------|------------------| | 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. | -| 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. | | 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 resolves `marketplace.json` and `.plugin/marketplace.json` before the -generated `.github/plugin/marketplace.json`. If either higher-priority catalog -exists, dotagents reports the conflict and prunes stale managed Copilot output. -Copilot resolves plugin manifests in `.plugin`, root, `.github/plugin`, then -`.claude-plugin` order. A source containing only `.plugin/plugin.json` or -`.github/plugin/plugin.json` is canonicalized to root `plugin.json` during -installation. Conflicting locators cannot target Copilot if they would hide the -canonical source or make dotagents and Copilot select different manifests. For -legacy manifests targeting Copilot, dotagents rejects native agent, command, hook, LSP, and -executable-extension fields plus implicitly discovered active paths. -Standard manifest extension data is preserved, but a physical -`com.github.copilot/` extension directory is rejected because Copilot loads -client-native components from it even without a matching manifest entry. These -are harness constraints, not extensions of the portable plugin format. +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 @@ -524,7 +519,7 @@ review-tools/ `-- review.mdc ``` -With the current adapters, an install for `targets = ["claude", "copilot", "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