diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 6486e660f..67de789d1 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -29,6 +29,9 @@ cursor: # cursor specific parameters alwaysApply: true description: "Rulesync project overview and development guidelines for unified AI rules management CLI tool" globs: ["*"] +copilot: # copilot specific parameters (non-root `*.instructions.md` files only) + name: "TypeScript Style" # (optional) display name shown in the VS Code UI; defaults to the file name + excludeAgent: "code-review" # (optional) "code-review" or "cloud-agent": skip this file for that agent antigravity: # antigravity specific parameters trigger: "always_on" # always_on, glob, manual, or model_decision globs: ["**/*"] # (optional) file patterns to match when trigger is "glob" @@ -230,7 +233,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** Hook file paths per tool: > -> - **Copilot (cloud agent)** — `/.github/hooks/copilot-hooks.json`. +> - **Copilot (cloud agent / VS Code)** — project: `/.github/hooks/copilot-hooks.json`; global: `~/.copilot/hooks/copilot-ide-hooks.json`. VS Code and the coding agent both document `~/.copilot/hooks` as the user scope and load every `*.json` in that folder; the Copilot CLI's global file already occupies `copilot-hooks.json` there, so the VS Code target uses a distinct filename and the two never overwrite each other. Note the flip side of "every `*.json` is loaded": generating **both** `copilot` and `copilotcli` in global mode leaves two files in that one folder, and a reader of the folder runs the hooks from both — so a command present in your canonical config fires twice per event. Generate only one of the two globally unless you want that. > - **Copilot CLI** — project: `/.github/hooks/copilotcli-hooks.json`; global: `~/.copilot/hooks/copilot-hooks.json`. The Copilot CLI docs let you choose any filename inside `.github/hooks/`, so Rulesync uses the CLI-specific name to avoid colliding with the cloud-agent file when both targets are enabled. The global path is a Rulesync convention; the official Copilot CLI documentation does not currently enumerate a global hooks location, so this placement may change if the spec later mandates an alternate layout. Copilot CLI uses a **wider event surface** than the shared cloud-agent set (`sessionStart`, `sessionEnd`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `agentStop` ← `stop`, `subagentStart`, `subagentStop`, `errorOccurred` ← `afterError`, `preCompact`, `permissionRequest`, `notification`, `userPromptTransformed` ← `userPromptExpansion`, `preMcpToolCall` ← `beforeMCPExecution`) and supports three hook types: **`command`** (`bash`/`powershell` with optional `timeoutSec`, plus pass-through `cwd`/`env`; on import the portable `command` field is read as the cross-platform fallback when neither shell field is present, and `timeout` is honored as an alias for `timeoutSec` when `timeoutSec` is absent. On generate the canonical `shell` selector chooses `bash` or `powershell`; without it the portable `command` field is written, so the generated file does not depend on the machine Rulesync ran on), **`prompt`** (a `prompt` string — Copilot CLI only honors prompt hooks on `sessionStart`, so prompt hooks on other events are dropped), and **`http`** (`url`/`headers`/`allowedEnvVars` with optional `timeoutSec`). An entry's optional `matcher` field is emitted and round-tripped on the six events the hooks reference documents as matcher-aware — `preToolUse` and `postToolUse` (regex on the tool name), `permissionRequest` (tool name), `notification` (notification type), `preCompact` (the trigger, `manual` or `auto`) and `subagentStart` (agent name); on any other event a matcher is dropped with a warning because the CLI does not honor it there. See the [hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference). > - **Antigravity IDE / Antigravity CLI** — project: `/.agents/hooks.json`; global: `~/.gemini/config/hooks.json`. Both targets share the same dedicated `hooks.json` (a Claude-Code-style matcher map nested under a generated `rulesync` hook name), so enabling both writes the same file. > - **Devin Desktop (formerly Windsurf)** — project: `/.windsurf/hooks.json`; global: `~/.codeium/windsurf/hooks.json`. The Cascade Hooks file location is unchanged by the Devin Desktop rebrand. @@ -945,6 +948,8 @@ Goose [recipes](https://block.github.io/goose/docs/guides/recipes/recipe-referen Vibe (mistral-vibe) MCP servers live in `[[mcp_servers]]` arrays of the shared `.vibe/config.toml`. In addition to the flat fields, Rulesync passes through the stdio `cwd` (working directory), a structured per-server `auth` block (Vibe v2.15.0+), and the four keys Vibe's `/mcp` panel writes back when you toggle a server or one of its tools — `prompt`, `sampling_enabled`, `disabled` and `disabled_tools`. Because `mcp_servers` is replaced as a whole array on each generate, a server Rulesync writes is seeded from the on-disk entry of the same name for exactly those keys, so a toggle you made in the TUI survives — unless your `.rulesync/mcp.json` states the value itself, which wins. `disabled_tools` is the canonical `disabledTools` under Vibe's spelling; `prompt` and `sampling_enabled` have no canonical equivalent and pass through as-is. The `auth` table is discriminated on `type`: `static` (`headers`, `api_key_env`, `api_key_header`, `api_key_format`) and `oauth` (`scopes`, `client_id` / `client_metadata_url`, `redirect_port`). Because Vibe rejects mixing legacy top-level static-auth keys with an explicit `[auth]` block, Rulesync suppresses the legacy keys (`headers`/`api_key_env`/`api_key_header`/`api_key_format`) whenever a server carries an `auth` block. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/config/models.py`). +> **GitHub Copilot (VS Code) MCP note:** the `copilot` target writes `.vscode/mcp.json`, which has three documented top-level sections: `servers`, `inputs` (secret prompts referenced as `${input:id}`) and `sandbox` (filesystem/network rules for sandboxed servers, added in VS Code v1.112). Rulesync owns and replaces only `servers`; the rest of the document — including any future top-level section — is read back and preserved on each generate. VS Code recommends committing this file, so dropping an `inputs` entry would leave `${input:…}` unresolvable and the affected servers would fail to start. If the existing file cannot be parsed, generate fails with an error rather than overwriting it. See the [MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration). + > **Rovo Dev CLI MCP note:** Rovo Dev documents the per-server transport key as `transport` (`stdio` | `http` | `sse`), not the canonical `type`. Rulesync translates on the way out (`local` → `stdio`, `streamable-http` → `http`) and back on import; `ws` has no Rovo Dev equivalent, so those servers are skipped with a warning, and a `transport` value outside Rovo Dev's vocabulary is dropped on import rather than written into the canonical config, whose transport field is a strict enum. A server marked `disabled` is also skipped, because Rovo Dev turns servers off through `mcp.disabledMcpServers` in `config.yml` rather than through a flag in `mcp.json`, and writing the entry anyway would leave it running; `disabled` is stripped from the servers that are written, too, since `mcp.json` is not where a server is switched on and off. A server Rovo Dev has disabled through `config.yml` is still imported as an ordinary enabled server, because `mcp.json` carries no trace of it. `mcp.json` is global-only. See the [Rovo Dev MCP docs](https://support.atlassian.com/rovo/docs/connect-to-an-mcp-server-in-rovo-dev-cli/). > **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`; `sse`, the legacy 2024-11-05 HTTP+SSE transport, written verbatim — Reasonix re-implemented it in v1.17.18, and collapsing it onto `http` pointed the client at Streamable HTTP so the server could not connect). The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists. The `trusted_read_only_tools` array (raw MCP tool names pre-seeded as trusted for planner/read-only use) is neither written nor imported: v1.17.18 retired it along with `default_tools_approval_mode`, `tools..approval_mode` and `approvals_reviewer` — installing a server is the authorization decision now, and Reasonix ignores the key on load and strips it the next time it saves that entry. Importing it would put a Reasonix-only dead key into the canonical `mcpServers` that every MCP target writes out, so it would surface in `.mcp.json` and the rest. Note that Rulesync owns the `plugins` key, so the next generate drops the key from an older `reasonix.toml` as well; nothing is lost that Reasonix still reads. An MCP server whose transport Reasonix does not implement (`ws`, including a `ws://`/`wss://` URL that states no transport at all) is skipped with a warning rather than written as a `type` its loader rejects. Each entry also supports `call_timeout_seconds` (a per-server MCP call timeout) and `tool_timeout_seconds` (a per-tool inline table keyed by raw MCP tool name). None of these have a deep canonical mapping, so they round-trip as passthrough fields on the canonical MCP server object. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp) and [SPEC.md](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md) (`[[plugins]]` schema). @@ -1220,7 +1225,7 @@ For Cursor CLI, this generates `permissions` entries in `.cursor/cli.json` (proj > > The separate Cursor **IDE** `permissions.json` (`mcpAllowlist`, `terminalAllowlist`, `autoRun.*`) is a different file and is not targeted by this translator. -For GitHub Copilot (`copilot`), this manages the `chat.tools.terminal.autoApprove` map in the workspace `.vscode/settings.json` (project mode only). VS Code has no standalone, environment-agnostic Copilot policy file, so project-level terminal auto-approvals are configured through VS Code Copilot Chat's workspace settings. The scope is deliberately limited to this one clean, non-lossy mapping: only the canonical `bash` (terminal command) category is mapped — `allow` → `true` (auto-approve) and `deny` → `false` (never auto-approve); an `ask` rule is represented by **omitting** the entry, so VS Code falls through to its default in-chat approval prompt. Other canonical categories (`read`/`edit`/`webfetch`/…) have no terminal-command equivalent in this setting and are not emitted. `.vscode/settings.json` is a general workspace file (JSONC), so Rulesync merges only the single `chat.tools.terminal.autoApprove` key non-destructively and never deletes the file; every unrelated setting is preserved. VS Code's user-scope `settings.json` lives at a platform-dependent path outside Rulesync's home-relative global model, so only project scope is supported. The all-or-nothing `chat.tools.global.autoApprove` boolean and the registry-allowlist `chat.mcp.access` setting are intentionally **not** mapped, since collapsing per-pattern rules into them would misrepresent what was configured. See the [VS Code agent approvals docs](https://code.visualstudio.com/docs/agents/approvals). +For GitHub Copilot (`copilot`), this manages the three `chat.tools.*.autoApprove` maps in the workspace `.vscode/settings.json` (project mode only). VS Code has no standalone, environment-agnostic Copilot policy file, so project-level auto-approvals are configured through VS Code Copilot Chat's workspace settings. Three canonical categories have a clean, non-lossy mapping and are emitted: `bash` → `chat.tools.terminal.autoApprove` (command patterns), `edit` → `chat.tools.edits.autoApprove` (file globs) and `webfetch` → `chat.tools.urls.autoApprove` (URL patterns). In all three, `allow` → `true` (auto-approve) and `deny` → `false` (never auto-approve); an `ask` rule is represented by **omitting** the entry, so VS Code falls through to its default in-chat approval prompt. The canonical `read` category has no VS Code approval surface, and `write` is deliberately **not** folded into the edits map alongside `edit` — doing so would make the two indistinguishable on import — so neither is emitted. VS Code also accepts a `{ "approveRequest": …, "approveResponse": … }` object per URL pattern; that form has no canonical equivalent, so it is skipped on import, and because Rulesync owns the key outright it is replaced whenever the canonical config carries any `webfetch` rule. `.vscode/settings.json` is a general workspace file (JSONC), so Rulesync merges only those three keys non-destructively and never deletes the file; every unrelated setting is preserved. VS Code's user-scope `settings.json` lives at a platform-dependent path outside Rulesync's home-relative global model, so only project scope is supported. The all-or-nothing `chat.tools.global.autoApprove` boolean and the registry-allowlist `chat.mcp.access` setting are intentionally **not** mapped, since collapsing per-pattern rules into them would misrepresent what was configured. See the [VS Code agent approvals docs](https://code.visualstudio.com/docs/agents/approvals) and the [edit-approval docs](https://code.visualstudio.com/docs/copilot/chat/review-code-edits). For Kilo Code, this generates the `permission` object in `kilo.jsonc` (project mode) or `~/.config/kilo/kilo.jsonc` (global mode). The shape is identical to OpenCode's (Kilo is an OpenCode fork), so categories like `bash`, `read`, `edit`, `write`, `webfetch`, and `mcp` accept either a string catch-all (`"allow" | "ask" | "deny"`) or a `{ : }` map. Other top-level keys in `kilo.jsonc` are preserved on round-trip. **The `permission` object is merged per top-level tool key**: for each tool key present in the rulesync output, that key is replaced entirely from rulesync (rulesync owns its managed keys; manual edits inside a managed key will be overwritten on the next generation). Tool keys that exist in the existing `kilo.jsonc` but are NOT in the rulesync output are preserved verbatim so user-added Kilo-only categories survive regeneration. When a regenerate replaces a key whose existing value contained `deny` patterns that disappear from the new rulesync output, an aggregated `logger.warn` enumerates the dropped patterns (matching the project convention used by every other permissions translator). Edits to other top-level keys (e.g. `model`) are preserved. **Malformed `kilo.jsonc` aborts the run**: the `jsonc-parser` library would otherwise silently coerce a syntax error to `{}` and overwrite the corrupted file with an empty `permission`, dropping the user's existing `deny` rules. Rulesync now surfaces parse errors so the run aborts before any destructive write — matching the strict `JSON.parse` behavior used by every other permissions translator. diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 19d3e8001..cb0220afc 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Claude Code plugin | claudecode-plugin | | | ✅ | ✅ | ✅ | ✅ | ✅ | | | | Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | -| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ | | +| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | | Goose | goose | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | 🌏 | | | Hermes Agent | hermesagent | ✅ | ✅ | 🌏 🔧 | 🌏 | ✅ 🌏 | 🌏 | 🌏 | 🌏 | ✅ | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 89768897f..63c4a0465 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -29,6 +29,9 @@ cursor: # cursor specific parameters alwaysApply: true description: "Rulesync project overview and development guidelines for unified AI rules management CLI tool" globs: ["*"] +copilot: # copilot specific parameters (non-root `*.instructions.md` files only) + name: "TypeScript Style" # (optional) display name shown in the VS Code UI; defaults to the file name + excludeAgent: "code-review" # (optional) "code-review" or "cloud-agent": skip this file for that agent antigravity: # antigravity specific parameters trigger: "always_on" # always_on, glob, manual, or model_decision globs: ["**/*"] # (optional) file patterns to match when trigger is "glob" @@ -230,7 +233,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** Hook file paths per tool: > -> - **Copilot (cloud agent)** — `/.github/hooks/copilot-hooks.json`. +> - **Copilot (cloud agent / VS Code)** — project: `/.github/hooks/copilot-hooks.json`; global: `~/.copilot/hooks/copilot-ide-hooks.json`. VS Code and the coding agent both document `~/.copilot/hooks` as the user scope and load every `*.json` in that folder; the Copilot CLI's global file already occupies `copilot-hooks.json` there, so the VS Code target uses a distinct filename and the two never overwrite each other. Note the flip side of "every `*.json` is loaded": generating **both** `copilot` and `copilotcli` in global mode leaves two files in that one folder, and a reader of the folder runs the hooks from both — so a command present in your canonical config fires twice per event. Generate only one of the two globally unless you want that. > - **Copilot CLI** — project: `/.github/hooks/copilotcli-hooks.json`; global: `~/.copilot/hooks/copilot-hooks.json`. The Copilot CLI docs let you choose any filename inside `.github/hooks/`, so Rulesync uses the CLI-specific name to avoid colliding with the cloud-agent file when both targets are enabled. The global path is a Rulesync convention; the official Copilot CLI documentation does not currently enumerate a global hooks location, so this placement may change if the spec later mandates an alternate layout. Copilot CLI uses a **wider event surface** than the shared cloud-agent set (`sessionStart`, `sessionEnd`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `agentStop` ← `stop`, `subagentStart`, `subagentStop`, `errorOccurred` ← `afterError`, `preCompact`, `permissionRequest`, `notification`, `userPromptTransformed` ← `userPromptExpansion`, `preMcpToolCall` ← `beforeMCPExecution`) and supports three hook types: **`command`** (`bash`/`powershell` with optional `timeoutSec`, plus pass-through `cwd`/`env`; on import the portable `command` field is read as the cross-platform fallback when neither shell field is present, and `timeout` is honored as an alias for `timeoutSec` when `timeoutSec` is absent. On generate the canonical `shell` selector chooses `bash` or `powershell`; without it the portable `command` field is written, so the generated file does not depend on the machine Rulesync ran on), **`prompt`** (a `prompt` string — Copilot CLI only honors prompt hooks on `sessionStart`, so prompt hooks on other events are dropped), and **`http`** (`url`/`headers`/`allowedEnvVars` with optional `timeoutSec`). An entry's optional `matcher` field is emitted and round-tripped on the six events the hooks reference documents as matcher-aware — `preToolUse` and `postToolUse` (regex on the tool name), `permissionRequest` (tool name), `notification` (notification type), `preCompact` (the trigger, `manual` or `auto`) and `subagentStart` (agent name); on any other event a matcher is dropped with a warning because the CLI does not honor it there. See the [hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference). > - **Antigravity IDE / Antigravity CLI** — project: `/.agents/hooks.json`; global: `~/.gemini/config/hooks.json`. Both targets share the same dedicated `hooks.json` (a Claude-Code-style matcher map nested under a generated `rulesync` hook name), so enabling both writes the same file. > - **Devin Desktop (formerly Windsurf)** — project: `/.windsurf/hooks.json`; global: `~/.codeium/windsurf/hooks.json`. The Cascade Hooks file location is unchanged by the Devin Desktop rebrand. @@ -945,6 +948,8 @@ Goose [recipes](https://block.github.io/goose/docs/guides/recipes/recipe-referen Vibe (mistral-vibe) MCP servers live in `[[mcp_servers]]` arrays of the shared `.vibe/config.toml`. In addition to the flat fields, Rulesync passes through the stdio `cwd` (working directory), a structured per-server `auth` block (Vibe v2.15.0+), and the four keys Vibe's `/mcp` panel writes back when you toggle a server or one of its tools — `prompt`, `sampling_enabled`, `disabled` and `disabled_tools`. Because `mcp_servers` is replaced as a whole array on each generate, a server Rulesync writes is seeded from the on-disk entry of the same name for exactly those keys, so a toggle you made in the TUI survives — unless your `.rulesync/mcp.json` states the value itself, which wins. `disabled_tools` is the canonical `disabledTools` under Vibe's spelling; `prompt` and `sampling_enabled` have no canonical equivalent and pass through as-is. The `auth` table is discriminated on `type`: `static` (`headers`, `api_key_env`, `api_key_header`, `api_key_format`) and `oauth` (`scopes`, `client_id` / `client_metadata_url`, `redirect_port`). Because Vibe rejects mixing legacy top-level static-auth keys with an explicit `[auth]` block, Rulesync suppresses the legacy keys (`headers`/`api_key_env`/`api_key_header`/`api_key_format`) whenever a server carries an `auth` block. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/config/models.py`). +> **GitHub Copilot (VS Code) MCP note:** the `copilot` target writes `.vscode/mcp.json`, which has three documented top-level sections: `servers`, `inputs` (secret prompts referenced as `${input:id}`) and `sandbox` (filesystem/network rules for sandboxed servers, added in VS Code v1.112). Rulesync owns and replaces only `servers`; the rest of the document — including any future top-level section — is read back and preserved on each generate. VS Code recommends committing this file, so dropping an `inputs` entry would leave `${input:…}` unresolvable and the affected servers would fail to start. If the existing file cannot be parsed, generate fails with an error rather than overwriting it. See the [MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration). + > **Rovo Dev CLI MCP note:** Rovo Dev documents the per-server transport key as `transport` (`stdio` | `http` | `sse`), not the canonical `type`. Rulesync translates on the way out (`local` → `stdio`, `streamable-http` → `http`) and back on import; `ws` has no Rovo Dev equivalent, so those servers are skipped with a warning, and a `transport` value outside Rovo Dev's vocabulary is dropped on import rather than written into the canonical config, whose transport field is a strict enum. A server marked `disabled` is also skipped, because Rovo Dev turns servers off through `mcp.disabledMcpServers` in `config.yml` rather than through a flag in `mcp.json`, and writing the entry anyway would leave it running; `disabled` is stripped from the servers that are written, too, since `mcp.json` is not where a server is switched on and off. A server Rovo Dev has disabled through `config.yml` is still imported as an ordinary enabled server, because `mcp.json` carries no trace of it. `mcp.json` is global-only. See the [Rovo Dev MCP docs](https://support.atlassian.com/rovo/docs/connect-to-an-mcp-server-in-rovo-dev-cli/). > **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`; `sse`, the legacy 2024-11-05 HTTP+SSE transport, written verbatim — Reasonix re-implemented it in v1.17.18, and collapsing it onto `http` pointed the client at Streamable HTTP so the server could not connect). The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists. The `trusted_read_only_tools` array (raw MCP tool names pre-seeded as trusted for planner/read-only use) is neither written nor imported: v1.17.18 retired it along with `default_tools_approval_mode`, `tools..approval_mode` and `approvals_reviewer` — installing a server is the authorization decision now, and Reasonix ignores the key on load and strips it the next time it saves that entry. Importing it would put a Reasonix-only dead key into the canonical `mcpServers` that every MCP target writes out, so it would surface in `.mcp.json` and the rest. Note that Rulesync owns the `plugins` key, so the next generate drops the key from an older `reasonix.toml` as well; nothing is lost that Reasonix still reads. An MCP server whose transport Reasonix does not implement (`ws`, including a `ws://`/`wss://` URL that states no transport at all) is skipped with a warning rather than written as a `type` its loader rejects. Each entry also supports `call_timeout_seconds` (a per-server MCP call timeout) and `tool_timeout_seconds` (a per-tool inline table keyed by raw MCP tool name). None of these have a deep canonical mapping, so they round-trip as passthrough fields on the canonical MCP server object. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp) and [SPEC.md](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md) (`[[plugins]]` schema). @@ -1220,7 +1225,7 @@ For Cursor CLI, this generates `permissions` entries in `.cursor/cli.json` (proj > > The separate Cursor **IDE** `permissions.json` (`mcpAllowlist`, `terminalAllowlist`, `autoRun.*`) is a different file and is not targeted by this translator. -For GitHub Copilot (`copilot`), this manages the `chat.tools.terminal.autoApprove` map in the workspace `.vscode/settings.json` (project mode only). VS Code has no standalone, environment-agnostic Copilot policy file, so project-level terminal auto-approvals are configured through VS Code Copilot Chat's workspace settings. The scope is deliberately limited to this one clean, non-lossy mapping: only the canonical `bash` (terminal command) category is mapped — `allow` → `true` (auto-approve) and `deny` → `false` (never auto-approve); an `ask` rule is represented by **omitting** the entry, so VS Code falls through to its default in-chat approval prompt. Other canonical categories (`read`/`edit`/`webfetch`/…) have no terminal-command equivalent in this setting and are not emitted. `.vscode/settings.json` is a general workspace file (JSONC), so Rulesync merges only the single `chat.tools.terminal.autoApprove` key non-destructively and never deletes the file; every unrelated setting is preserved. VS Code's user-scope `settings.json` lives at a platform-dependent path outside Rulesync's home-relative global model, so only project scope is supported. The all-or-nothing `chat.tools.global.autoApprove` boolean and the registry-allowlist `chat.mcp.access` setting are intentionally **not** mapped, since collapsing per-pattern rules into them would misrepresent what was configured. See the [VS Code agent approvals docs](https://code.visualstudio.com/docs/agents/approvals). +For GitHub Copilot (`copilot`), this manages the three `chat.tools.*.autoApprove` maps in the workspace `.vscode/settings.json` (project mode only). VS Code has no standalone, environment-agnostic Copilot policy file, so project-level auto-approvals are configured through VS Code Copilot Chat's workspace settings. Three canonical categories have a clean, non-lossy mapping and are emitted: `bash` → `chat.tools.terminal.autoApprove` (command patterns), `edit` → `chat.tools.edits.autoApprove` (file globs) and `webfetch` → `chat.tools.urls.autoApprove` (URL patterns). In all three, `allow` → `true` (auto-approve) and `deny` → `false` (never auto-approve); an `ask` rule is represented by **omitting** the entry, so VS Code falls through to its default in-chat approval prompt. The canonical `read` category has no VS Code approval surface, and `write` is deliberately **not** folded into the edits map alongside `edit` — doing so would make the two indistinguishable on import — so neither is emitted. VS Code also accepts a `{ "approveRequest": …, "approveResponse": … }` object per URL pattern; that form has no canonical equivalent, so it is skipped on import, and because Rulesync owns the key outright it is replaced whenever the canonical config carries any `webfetch` rule. `.vscode/settings.json` is a general workspace file (JSONC), so Rulesync merges only those three keys non-destructively and never deletes the file; every unrelated setting is preserved. VS Code's user-scope `settings.json` lives at a platform-dependent path outside Rulesync's home-relative global model, so only project scope is supported. The all-or-nothing `chat.tools.global.autoApprove` boolean and the registry-allowlist `chat.mcp.access` setting are intentionally **not** mapped, since collapsing per-pattern rules into them would misrepresent what was configured. See the [VS Code agent approvals docs](https://code.visualstudio.com/docs/agents/approvals) and the [edit-approval docs](https://code.visualstudio.com/docs/copilot/chat/review-code-edits). For Kilo Code, this generates the `permission` object in `kilo.jsonc` (project mode) or `~/.config/kilo/kilo.jsonc` (global mode). The shape is identical to OpenCode's (Kilo is an OpenCode fork), so categories like `bash`, `read`, `edit`, `write`, `webfetch`, and `mcp` accept either a string catch-all (`"allow" | "ask" | "deny"`) or a `{ : }` map. Other top-level keys in `kilo.jsonc` are preserved on round-trip. **The `permission` object is merged per top-level tool key**: for each tool key present in the rulesync output, that key is replaced entirely from rulesync (rulesync owns its managed keys; manual edits inside a managed key will be overwritten on the next generation). Tool keys that exist in the existing `kilo.jsonc` but are NOT in the rulesync output are preserved verbatim so user-added Kilo-only categories survive regeneration. When a regenerate replaces a key whose existing value contained `deny` patterns that disappear from the new rulesync output, an aggregated `logger.warn` enumerates the dropped patterns (matching the project convention used by every other permissions translator). Edits to other top-level keys (e.g. `model`) are preserved. **Malformed `kilo.jsonc` aborts the run**: the `jsonc-parser` library would otherwise silently coerce a syntax error to `{}` and overwrite the corrupted file with an empty `permission`, dropping the user's existing `deny` rules. Rulesync now surfaces parse errors so the run aborts before any destructive write — matching the strict `JSON.parse` behavior used by every other permissions translator. diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 19d3e8001..cb0220afc 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Claude Code plugin | claudecode-plugin | | | ✅ | ✅ | ✅ | ✅ | ✅ | | | | Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | -| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ | | +| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | | Goose | goose | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | 🌏 | | | Hermes Agent | hermesagent | ✅ | ✅ | 🌏 🔧 | 🌏 | ✅ 🌏 | 🌏 | 🌏 | 🌏 | ✅ | diff --git a/src/constants/copilot-paths.ts b/src/constants/copilot-paths.ts index 0aa56ccac..bfffc5ec3 100644 --- a/src/constants/copilot-paths.ts +++ b/src/constants/copilot-paths.ts @@ -8,6 +8,13 @@ export const COPILOT_SKILLS_DIR_PATH = join(GITHUB_DIR, "skills"); export const COPILOT_AGENTS_DIR_PATH = join(GITHUB_DIR, "agents"); export const COPILOT_HOOKS_DIR_PATH = join(GITHUB_DIR, "hooks"); export const COPILOT_HOOKS_FILE_NAME = "copilot-hooks.json"; +// User-scope hooks for VS Code Copilot (and the coding agent) live in +// `~/.copilot/hooks/`, which loads every `*.json` in the folder. The Copilot CLI +// global hooks file already occupies `copilot-hooks.json` there, so the +// VS Code target uses a distinct name and the two never overwrite each other. +// https://code.visualstudio.com/docs/agent-customization/hooks +// https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks +export const COPILOT_GLOBAL_HOOKS_FILE_NAME = "copilot-ide-hooks.json"; export const COPILOT_MCP_DIR = ".vscode"; export const COPILOT_MCP_FILE_NAME = "mcp.json"; // VS Code Copilot Chat reads its terminal auto-approval map @@ -22,7 +29,11 @@ export const COPILOTCLI_MCP_FILE_NAME = "mcp-config.json"; // https://github.com/github/copilot-cli (changelog v1.0.61, 2026-06-09) export const COPILOTCLI_PROJECT_MCP_FILE_NAME = "mcp.json"; export const COPILOTCLI_AGENTS_DIR_PATH = join(COPILOT_DIR, "agents"); +// The single user-scope hooks folder. VS Code, the coding agent and the +// Copilot CLI all load every `*.json` in it, so both the `copilot` and +// `copilotcli` targets write here — under different filenames. export const COPILOTCLI_HOOKS_DIR_PATH = join(COPILOT_DIR, "hooks"); +export const COPILOT_GLOBAL_HOOKS_DIR_PATH = COPILOTCLI_HOOKS_DIR_PATH; export const COPILOTCLI_HOOKS_FILE_NAME = "copilotcli-hooks.json"; // Both GitHub Copilot and the Copilot CLI auto-discover personal/global skills // from the same `~/.copilot/skills/` location (mirroring the project diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index 80cb892d0..fb7e17e84 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -656,6 +656,7 @@ const hooksGlobalTargets = [ { target: "deepagents", outputPath: join(".deepagents", "hooks.json") }, { target: "junie", outputPath: join(".junie", "config.json") }, { target: "cursor", outputPath: join(".cursor", "hooks.json") }, + { target: "copilot", outputPath: join(".copilot", "hooks", "copilot-ide-hooks.json") }, { target: "copilotcli", outputPath: join(".copilot", "hooks", "copilot-hooks.json") }, { target: "antigravity-ide", outputPath: join(".gemini", "config", "hooks.json") }, { target: "antigravity-cli", outputPath: join(".gemini", "config", "hooks.json") }, @@ -734,8 +735,8 @@ describe("E2E: hooks (global mode)", () => { expect(generatedContent).toContain('pi.on("agent_end"'); expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); - } else if (target === "copilotcli") { - // Copilot CLI does not support the `stop` hook event, so audit.sh is + } else if (target === "copilot" || target === "copilotcli") { + // Neither Copilot target supports the `stop` hook event, so audit.sh is // intentionally dropped during generation. const parsed = JSON.parse(generatedContent); expect(parsed.hooks.sessionStart).toBeDefined(); diff --git a/src/features/hooks/copilot-hooks.test.ts b/src/features/hooks/copilot-hooks.test.ts index 4891670b6..86d0131a4 100644 --- a/src/features/hooks/copilot-hooks.test.ts +++ b/src/features/hooks/copilot-hooks.test.ts @@ -32,12 +32,37 @@ describe("CopilotHooks", () => { }); }); - it("should return .github/hooks and copilot-hooks.json for global mode", () => { + it("should return the ~/.copilot/hooks user-scope file for global mode", () => { const paths = CopilotHooks.getSettablePaths({ global: true }); expect(paths).toEqual({ - relativeDirPath: join(".github", "hooks"), - relativeFilePath: "copilot-hooks.json", + relativeDirPath: join(".copilot", "hooks"), + // Distinct from the Copilot CLI's global `copilot-hooks.json` in the + // same folder, so the two targets never overwrite each other. + relativeFilePath: "copilot-ide-hooks.json", + }); + }); + + it("writes the generated global file to the user-scope path", async () => { + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify({ + version: 1, + hooks: { sessionStart: [{ command: "echo hi" }] }, + }), + validate: false, }); + + const hooks = await CopilotHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + global: true, + }); + + expect(hooks.getRelativeDirPath()).toBe(join(".copilot", "hooks")); + expect(hooks.getRelativeFilePath()).toBe("copilot-ide-hooks.json"); + expect(JSON.parse(hooks.getFileContent()).hooks.sessionStart).toBeDefined(); }); }); diff --git a/src/features/hooks/copilot-hooks.ts b/src/features/hooks/copilot-hooks.ts index 72150f995..e794b2f3b 100644 --- a/src/features/hooks/copilot-hooks.ts +++ b/src/features/hooks/copilot-hooks.ts @@ -2,7 +2,12 @@ import { join } from "node:path"; import { z } from "zod/mini"; -import { COPILOT_HOOKS_DIR_PATH, COPILOT_HOOKS_FILE_NAME } from "../../constants/copilot-paths.js"; +import { + COPILOT_GLOBAL_HOOKS_DIR_PATH, + COPILOT_GLOBAL_HOOKS_FILE_NAME, + COPILOT_HOOKS_DIR_PATH, + COPILOT_HOOKS_FILE_NAME, +} from "../../constants/copilot-paths.js"; import type { AiFileParams } from "../../types/ai-file.js"; import type { ValidationResult } from "../../types/ai-file.js"; import type { HooksConfig } from "../../types/hooks.js"; @@ -164,7 +169,13 @@ export class CopilotHooks extends ToolHooks { }); } - static getSettablePaths(_options: { global?: boolean } = {}): ToolHooksSettablePaths { + static getSettablePaths({ global = false }: { global?: boolean } = {}): ToolHooksSettablePaths { + if (global) { + return { + relativeDirPath: COPILOT_GLOBAL_HOOKS_DIR_PATH, + relativeFilePath: COPILOT_GLOBAL_HOOKS_FILE_NAME, + }; + } return { relativeDirPath: COPILOT_HOOKS_DIR_PATH, relativeFilePath: COPILOT_HOOKS_FILE_NAME, @@ -185,6 +196,7 @@ export class CopilotHooks extends ToolHooks { relativeFilePath: paths.relativeFilePath, fileContent, validate, + global, }); } @@ -192,10 +204,11 @@ export class CopilotHooks extends ToolHooks { outputRoot = process.cwd(), rulesyncHooks, validate = true, + global = false, }: ToolHooksFromRulesyncHooksParams & { global?: boolean; }): Promise { - const paths = CopilotHooks.getSettablePaths(); + const paths = CopilotHooks.getSettablePaths({ global }); const config = rulesyncHooks.getJson(); const copilotHooks = canonicalToCopilotHooks(config); const fileContent = JSON.stringify({ version: 1, hooks: copilotHooks }, null, 2); @@ -205,6 +218,7 @@ export class CopilotHooks extends ToolHooks { relativeFilePath: paths.relativeFilePath, fileContent, validate, + global, }); } diff --git a/src/features/hooks/hooks-processor.test.ts b/src/features/hooks/hooks-processor.test.ts index b91e31d4e..e41708522 100644 --- a/src/features/hooks/hooks-processor.test.ts +++ b/src/features/hooks/hooks-processor.test.ts @@ -582,6 +582,7 @@ describe("HooksProcessor", () => { "cursor", "claudecode", "codexcli", + "copilot", "copilotcli", "kilo", "opencode", @@ -636,6 +637,7 @@ describe("HooksProcessor", () => { "cursor", "claudecode", "codexcli", + "copilot", "copilotcli", "factorydroid", "goose", diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index 1a27cc030..682d9f88f 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -275,7 +275,10 @@ export const toolHooksFactories = new Map { }); describe("fromRulesyncMcp", () => { - it("should convert mcpServers key to servers key", () => { + it("should convert mcpServers key to servers key", async () => { const inputMcpServers = { "test-server": { command: "node", @@ -249,7 +249,7 @@ describe("CopilotMcp", () => { fileContent: JSON.stringify({ mcpServers: inputMcpServers }), }); - const copilotMcp = CopilotMcp.fromRulesyncMcp({ + const copilotMcp = await CopilotMcp.fromRulesyncMcp({ rulesyncMcp, }); @@ -261,7 +261,7 @@ describe("CopilotMcp", () => { expect(copilotMcp.getRelativeFilePath()).toBe("mcp.json"); }); - it("should create instance from RulesyncMcp with custom outputRoot", () => { + it("should create instance from RulesyncMcp with custom outputRoot", async () => { const inputMcpServers = { "custom-server": { command: "python", @@ -278,7 +278,7 @@ describe("CopilotMcp", () => { fileContent: JSON.stringify({ mcpServers: inputMcpServers }), }); - const copilotMcp = CopilotMcp.fromRulesyncMcp({ + const copilotMcp = await CopilotMcp.fromRulesyncMcp({ outputRoot: "/target/dir", rulesyncMcp, }); @@ -288,7 +288,7 @@ describe("CopilotMcp", () => { expect(copilotMcp.getJson()).toEqual({ servers: inputMcpServers }); }); - it("should handle validation when validate is true", () => { + it("should handle validation when validate is true", async () => { const inputMcpServers = { "validated-server": { command: "node", @@ -301,7 +301,7 @@ describe("CopilotMcp", () => { fileContent: JSON.stringify({ mcpServers: inputMcpServers }), }); - const copilotMcp = CopilotMcp.fromRulesyncMcp({ + const copilotMcp = await CopilotMcp.fromRulesyncMcp({ rulesyncMcp, validate: true, }); @@ -309,14 +309,14 @@ describe("CopilotMcp", () => { expect(copilotMcp.getJson()).toEqual({ servers: inputMcpServers }); }); - it("should skip validation when validate is false", () => { + it("should skip validation when validate is false", async () => { const rulesyncMcp = new RulesyncMcp({ relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, relativeFilePath: "mcp.json", fileContent: JSON.stringify({ mcpServers: {} }), }); - const copilotMcp = CopilotMcp.fromRulesyncMcp({ + const copilotMcp = await CopilotMcp.fromRulesyncMcp({ rulesyncMcp, validate: false, }); @@ -324,14 +324,14 @@ describe("CopilotMcp", () => { expect(copilotMcp.getJson()).toEqual({ servers: {} }); }); - it("should handle empty mcpServers object", () => { + it("should handle empty mcpServers object", async () => { const rulesyncMcp = new RulesyncMcp({ relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, relativeFilePath: "mcp.json", fileContent: JSON.stringify({ mcpServers: {} }), }); - const copilotMcp = CopilotMcp.fromRulesyncMcp({ + const copilotMcp = await CopilotMcp.fromRulesyncMcp({ rulesyncMcp, }); @@ -339,6 +339,74 @@ describe("CopilotMcp", () => { }); }); + describe("preserving the rest of mcp.json", () => { + it("keeps the inputs and sandbox sections and any unknown top-level key", async () => { + await writeFileContent( + join(testDir, ".vscode", "mcp.json"), + JSON.stringify({ + inputs: [{ id: "api-key", type: "promptString", password: true }], + sandbox: { filesystem: { read: ["${workspaceFolder}"] } }, + servers: { stale: { command: "gone" } }, + unknownFutureSection: { keep: true }, + }), + ); + + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({ mcpServers: { fresh: { command: "node" } } }), + }); + + const copilotMcp = await CopilotMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp }); + const json = JSON.parse(copilotMcp.getFileContent()); + + expect(json.inputs).toEqual([{ id: "api-key", type: "promptString", password: true }]); + expect(json.sandbox).toEqual({ filesystem: { read: ["${workspaceFolder}"] } }); + expect(json.unknownFutureSection).toEqual({ keep: true }); + // `servers` is the only section rulesync owns, so it is replaced. + expect(json.servers).toEqual({ fresh: { command: "node" } }); + }); + + it("refuses to overwrite an mcp.json it could not parse", async () => { + await writeFileContent(join(testDir, ".vscode", "mcp.json"), "{ not json"); + + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({ mcpServers: {} }), + }); + + await expect( + CopilotMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp }), + ).rejects.toThrow("Failed to parse shared config"); + }); + + it("reads the JSONC form VS Code's own scaffold writes", async () => { + await writeFileContent( + join(testDir, ".vscode", "mcp.json"), + [ + "// For more info, visit https://aka.ms/vscode-add-mcp", + "{", + ' "inputs": [{ "id": "api-key", "type": "promptString" }],', + ' "servers": {},', + "}", + ].join("\n"), + ); + + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({ mcpServers: { fresh: { command: "node" } } }), + }); + + const copilotMcp = await CopilotMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp }); + const json = JSON.parse(copilotMcp.getFileContent()); + + expect(json.inputs).toEqual([{ id: "api-key", type: "promptString" }]); + expect(json.servers).toEqual({ fresh: { command: "node" } }); + }); + }); + describe("toRulesyncMcp", () => { it("should convert servers key to mcpServers key", () => { const inputServers = { @@ -542,7 +610,7 @@ describe("CopilotMcp", () => { }); // Step 3: Create new CopilotMcp from RulesyncMcp (should have servers key again) - const newCopilotMcp = CopilotMcp.fromRulesyncMcp({ + const newCopilotMcp = await CopilotMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp, }); @@ -552,7 +620,7 @@ describe("CopilotMcp", () => { expect(newCopilotMcp.getFilePath()).toBe(join(testDir, ".vscode/mcp.json")); }); - it("should maintain data consistency across transformations", () => { + it("should maintain data consistency across transformations", async () => { const originalServers = { "primary-server": { command: "node", @@ -588,7 +656,7 @@ describe("CopilotMcp", () => { $schema: RULESYNC_MCP_SCHEMA_URL, }); - const newCopilotMcp = CopilotMcp.fromRulesyncMcp({ + const newCopilotMcp = await CopilotMcp.fromRulesyncMcp({ outputRoot: "/project", rulesyncMcp, }); diff --git a/src/features/mcp/copilot-mcp.ts b/src/features/mcp/copilot-mcp.ts index 0d7ee3983..582bde63a 100644 --- a/src/features/mcp/copilot-mcp.ts +++ b/src/features/mcp/copilot-mcp.ts @@ -3,7 +3,8 @@ import { join } from "node:path"; import { COPILOT_MCP_DIR, COPILOT_MCP_FILE_NAME } from "../../constants/copilot-paths.js"; import { ValidationResult } from "../../types/ai-file.js"; import { McpServers } from "../../types/mcp.js"; -import { readFileContent } from "../../utils/file.js"; +import { readFileContent, readFileContentOrNull } from "../../utils/file.js"; +import { applySharedConfigPatch, sharedConfigFileKey } from "../shared/shared-config-gateway.js"; import { RulesyncMcp } from "./rulesync-mcp.js"; import { ToolMcp, @@ -14,14 +15,21 @@ import { ToolMcpSettablePaths, } from "./tool-mcp.js"; +/** + * `.vscode/mcp.json` has three documented top-level sections: `servers` (the + * one rulesync manages), `inputs` (secret prompts referenced as + * `${input:id}`) and `sandbox` (filesystem/network rules for sandboxed + * servers). Only `servers` is generated; the rest of the document is read back + * and preserved, since VS Code recommends committing this file and dropping an + * `inputs` entry would leave `${input:…}` unresolvable at startup. + * + * @see https://code.visualstudio.com/docs/agents/reference/mcp-configuration + */ type CopilotMcpConfig = { servers?: McpServers; + [key: string]: unknown; }; -function convertToCopilotFormat(mcpServers: McpServers): CopilotMcpConfig { - return { servers: mcpServers }; -} - function convertFromCopilotFormat(copilotConfig: CopilotMcpConfig): McpServers { return copilotConfig.servers ?? {}; } @@ -65,17 +73,32 @@ export class CopilotMcp extends ToolMcp { }); } - static fromRulesyncMcp({ + static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, - }: ToolMcpFromRulesyncMcpParams): CopilotMcp { - const copilotConfig = convertToCopilotFormat(rulesyncMcp.getMcpServers()); + }: ToolMcpFromRulesyncMcpParams): Promise { + const paths = this.getSettablePaths(); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + // Read without initializing so this stays side-effect-free under + // `--dry-run`/`--check`; the actual write happens later in `writeAiFiles`. + const existingContent = (await readFileContentOrNull(filePath)) ?? "{}"; + return new CopilotMcp({ outputRoot, - relativeDirPath: this.getSettablePaths().relativeDirPath, - relativeFilePath: this.getSettablePaths().relativeFilePath, - fileContent: JSON.stringify(copilotConfig, null, 2), + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + // The shared-config gateway owns only `servers`, parses the file as the + // JSONC VS Code actually writes (its "MCP: Add Server" scaffold starts + // with a comment), and fails closed rather than overwriting a file it + // could not fully parse. + fileContent: applySharedConfigPatch({ + fileKey: sharedConfigFileKey(paths), + feature: "mcp", + existingContent, + patch: { servers: rulesyncMcp.getMcpServers() }, + filePath, + }), validate, }); } diff --git a/src/features/mcp/mcp-processor.test.ts b/src/features/mcp/mcp-processor.test.ts index 5e961c0fc..93d1854fb 100644 --- a/src/features/mcp/mcp-processor.test.ts +++ b/src/features/mcp/mcp-processor.test.ts @@ -659,7 +659,7 @@ describe("McpProcessor", () => { fileContent: JSON.stringify({ servers: {} }), }); - vi.mocked(CopilotMcp.fromRulesyncMcp).mockReturnValue(mockToolMcp); + vi.mocked(CopilotMcp.fromRulesyncMcp).mockResolvedValue(mockToolMcp); const processor = new McpProcessor({ logger: createMockLogger(), diff --git a/src/features/permissions/copilot-permissions.test.ts b/src/features/permissions/copilot-permissions.test.ts index 6ad7b0951..4bbbdcea2 100644 --- a/src/features/permissions/copilot-permissions.test.ts +++ b/src/features/permissions/copilot-permissions.test.ts @@ -8,6 +8,8 @@ import { CopilotPermissions } from "./copilot-permissions.js"; import { RulesyncPermissions } from "./rulesync-permissions.js"; const AUTO_APPROVE_KEY = "chat.tools.terminal.autoApprove"; +const EDITS_KEY = "chat.tools.edits.autoApprove"; +const URLS_KEY = "chat.tools.urls.autoApprove"; function createRulesyncPermissions(permission: Record>) { return new RulesyncPermissions({ @@ -67,11 +69,13 @@ describe("CopilotPermissions", () => { expect(json[AUTO_APPROVE_KEY]).not.toHaveProperty("npm *"); }); - it("maps only the bash category and ignores non-terminal categories", async () => { + it("maps bash, edit and webfetch and ignores categories VS Code cannot express", async () => { const rulesyncPermissions = createRulesyncPermissions({ bash: { "git *": "allow" }, + edit: { "src/**": "allow", "**/.env": "deny" }, + webfetch: { "https://*.example.com/*": "allow" }, read: { ".env": "deny" }, - edit: { "src/**": "allow" }, + write: { "dist/**": "allow" }, }); const permissions = await CopilotPermissions.fromRulesyncPermissions({ @@ -81,8 +85,69 @@ describe("CopilotPermissions", () => { const json = JSON.parse(permissions.getFileContent()); expect(json[AUTO_APPROVE_KEY]).toEqual({ "git *": true }); - // read/edit have no terminal-command representation and are not emitted. - expect(Object.keys(json)).toEqual([AUTO_APPROVE_KEY]); + expect(json[EDITS_KEY]).toEqual({ "src/**": true, "**/.env": false }); + expect(json[URLS_KEY]).toEqual({ "https://*.example.com/*": true }); + // `read` has no approval surface, and `write` is deliberately not folded + // into the edits map so it stays distinguishable from `edit` on import. + expect(Object.keys(json)).toEqual([AUTO_APPROVE_KEY, EDITS_KEY, URLS_KEY]); + }); + + it("leaves a hand-written map alone when its canonical category is absent", async () => { + await writeFileContent( + join(testDir, ".vscode", "settings.json"), + JSON.stringify({ + "editor.tabSize": 2, + [EDITS_KEY]: { "**/.env": false, "src/**": true }, + [URLS_KEY]: { "https://x.example": { approveRequest: true } }, + }), + ); + + // The canonical config states `bash` only, so adopting rulesync must not + // disturb the edits/urls maps the user wrote by hand. + const permissions = await CopilotPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: createRulesyncPermissions({ bash: { "git *": "allow" } }), + }); + + const json = JSON.parse(permissions.getFileContent()); + expect(json[EDITS_KEY]).toEqual({ "**/.env": false, "src/**": true }); + expect(json[URLS_KEY]).toEqual({ "https://x.example": { approveRequest: true } }); + expect(json[AUTO_APPROVE_KEY]).toEqual({ "git *": true }); + expect(json["editor.tabSize"]).toBe(2); + }); + + it("still retracts a key whose category is stated but yields nothing", async () => { + await writeFileContent( + join(testDir, ".vscode", "settings.json"), + JSON.stringify({ [EDITS_KEY]: { "src/**": true } }), + ); + + const permissions = await CopilotPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: createRulesyncPermissions({ edit: { "src/**": "ask" } }), + }); + + expect(JSON.parse(permissions.getFileContent())).not.toHaveProperty(EDITS_KEY); + }); + + it("imports all three maps back into their canonical categories", () => { + const permissions = new CopilotPermissions({ + outputRoot: testDir, + relativeDirPath: ".vscode", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ + "editor.tabSize": 2, + [AUTO_APPROVE_KEY]: { "git *": true }, + [EDITS_KEY]: { "src/**": true, "**/.env": false }, + // The object form has no canonical action, so it is skipped. + [URLS_KEY]: { "https://ok.example": true, "https://x.example": { approveRequest: true } }, + }), + }); + + const json = permissions.toRulesyncPermissions().getJson(); + expect(json.permission.bash).toEqual({ "git *": "allow" }); + expect(json.permission.edit).toEqual({ "src/**": "allow", "**/.env": "deny" }); + expect(json.permission.webfetch).toEqual({ "https://ok.example": "allow" }); }); it("preserves unrelated VS Code settings when merging", async () => { diff --git a/src/features/permissions/copilot-permissions.ts b/src/features/permissions/copilot-permissions.ts index c250e0cef..87131ab4b 100644 --- a/src/features/permissions/copilot-permissions.ts +++ b/src/features/permissions/copilot-permissions.ts @@ -24,20 +24,31 @@ import { } from "./tool-permissions.js"; /** - * The flat, dotted VS Code setting key this adapter manages. VS Code stores - * settings with dotted keys flat at the document top level, so this is a single - * literal key — not a nested `chat.tools.terminal` path. + * The flat, dotted VS Code setting keys this adapter manages, one per canonical + * permission category. VS Code stores settings with dotted keys flat at the + * document top level, so each is a single literal key — not a nested + * `chat.tools.terminal` path. All three share the same + * pattern-to-boolean shape, so one conversion covers them. + * + * The canonical `read` and `write` categories stay unmapped: VS Code has no + * read-approval surface, and folding `write` into the edits map alongside + * `edit` would make the two indistinguishable on import. + * * @see https://code.visualstudio.com/docs/agents/approvals + * @see https://code.visualstudio.com/docs/copilot/chat/review-code-edits */ -const AUTO_APPROVE_KEY = "chat.tools.terminal.autoApprove"; - -/** - * The canonical permission category this adapter maps. Only shell/terminal - * commands (`bash`) have a clean, high-fidelity representation in VS Code's - * `chat.tools.terminal.autoApprove` map; other categories (read/edit/webfetch/ - * …) have no terminal-command equivalent and are intentionally not mapped. - */ -const TERMINAL_CATEGORY = "bash"; +const AUTO_APPROVE_KEYS: Readonly> = { + bash: "chat.tools.terminal.autoApprove", + // Glob-to-boolean map gating agent edits, e.g. `{"**/*": true, "**/.env": false}`. + // Added in VS Code v1.104. + edit: "chat.tools.edits.autoApprove", + // URL-pattern-to-boolean map gating fetch approvals. VS Code also accepts an + // `{approveRequest, approveResponse}` object per pattern, which the canonical + // allow/deny/ask model cannot express: such an entry is skipped on import, + // and — since rulesync owns this key outright — is replaced on generate + // whenever the canonical config states a `webfetch` category at all. + webfetch: "chat.tools.urls.autoApprove", +}; function asAutoApproveMap(value: unknown): Record { if (!isPlainObject(value)) { @@ -52,6 +63,31 @@ function asAutoApproveMap(value: unknown): Record { return result; } +/** + * Render one canonical category's rules as a VS Code auto-approve map. Returns + * `undefined` when the category contributes nothing, so the key is retracted + * rather than written as an empty object. + * + * The resulting map replaces the file's existing value wholesale — rulesync + * owns these keys, so a rule dropped from the canonical config disappears from + * the settings file too. + */ +function buildAutoApproveValue( + rules: Record, +): Record | undefined { + const autoApprove: Record = {}; + for (const [pattern, action] of Object.entries(rules)) { + if (action === "allow") { + autoApprove[pattern] = true; + } else if (action === "deny") { + autoApprove[pattern] = false; + } + // `ask` is represented by omitting the entry: VS Code falls through to its + // default in-chat approval prompt. + } + return Object.keys(autoApprove).length > 0 ? autoApprove : undefined; +} + /** * Permissions generator for GitHub Copilot Chat in VS Code. * @@ -62,13 +98,16 @@ function asAutoApproveMap(value: unknown): Record { * many unrelated keys, so reads and writes merge into the existing JSON * (touching only the one managed key) and the file is never deleted. * - * Scope is deliberately limited to `chat.tools.terminal.autoApprove` — the one - * clean, non-lossy mapping. The canonical `bash` category's per-pattern rules - * map as: `allow` → `true` (auto-approve), `deny` → `false` (never approve), - * and `ask` → the entry is OMITTED (VS Code then falls through to its default - * in-chat approval prompt, i.e. "ask"). Only project scope is modeled: VS Code's - * user-scope settings.json lives at a platform-dependent path outside rulesync's - * home-relative global model. + * Three canonical categories have a clean, non-lossy representation and are + * mapped (see {@link AUTO_APPROVE_KEYS}): `bash`, `edit` and `webfetch`. In + * every one, per-pattern rules map as: `allow` → `true` (auto-approve), `deny` + * → `false` (VS Code then always prompts — note this is "never auto-approve", + * not a hard block), and `ask` → the entry is OMITTED (VS Code falls through to + * the same default prompt). A key whose canonical category is absent entirely + * is left untouched, so authoring only `bash` rules never disturbs a + * hand-written edits or urls map. + * Only project scope is modeled: VS Code's user-scope settings.json lives at a + * platform-dependent path outside rulesync's home-relative global model. */ export class CopilotPermissions extends ToolPermissions { constructor(params: AiFileParams) { @@ -122,23 +161,21 @@ export class CopilotPermissions extends ToolPermissions { const existingContent = (await readFileContentOrNull(filePath)) ?? "{}"; const config = rulesyncPermissions.getJson(); - const rules = config.permission[TERMINAL_CATEGORY] ?? {}; - - const autoApprove: Record = {}; - for (const [pattern, action] of Object.entries(rules)) { - if (action === "allow") { - autoApprove[pattern] = true; - } else if (action === "deny") { - autoApprove[pattern] = false; + + // Only categories the canonical config actually states are touched. A key + // whose category is absent stays exactly as the user left it — otherwise + // adopting rulesync for `bash` alone would wipe a hand-authored + // `chat.tools.edits.autoApprove`. A category that IS stated but yields + // nothing (all `ask`) still retracts its key, since rulesync owns it. + const patch: Record = {}; + for (const [category, settingKey] of Object.entries(AUTO_APPROVE_KEYS)) { + const rules = config.permission[category]; + if (rules === undefined) { + continue; } - // `ask` is represented by omitting the entry: VS Code falls through to its - // default in-chat approval prompt. + patch[settingKey] = buildAutoApproveValue(rules); } - // Retract the key entirely when there is nothing to auto-approve, so the - // file never carries an empty managed object. - const patchValue = Object.keys(autoApprove).length > 0 ? autoApprove : undefined; - return new CopilotPermissions({ outputRoot, relativeDirPath: paths.relativeDirPath, @@ -147,7 +184,7 @@ export class CopilotPermissions extends ToolPermissions { fileKey: sharedConfigFileKey(paths), feature: "permissions", existingContent, - patch: { [AUTO_APPROVE_KEY]: patchValue }, + patch, filePath, }), validate: true, @@ -175,17 +212,21 @@ export class CopilotPermissions extends ToolPermissions { ); } - const autoApprove = asAutoApproveMap(settings[AUTO_APPROVE_KEY]); - const rules: Record = {}; - for (const [pattern, flag] of Object.entries(autoApprove)) { - rules[pattern] = flag ? "allow" : "deny"; + const permission: Record> = {}; + for (const [category, settingKey] of Object.entries(AUTO_APPROVE_KEYS)) { + // Non-boolean values (e.g. a urls entry in the `{approveRequest, + // approveResponse}` object form) have no canonical action and are skipped + // by `asAutoApproveMap`; the write path leaves them in place. + const autoApprove = asAutoApproveMap(settings[settingKey]); + const rules: Record = {}; + for (const [pattern, flag] of Object.entries(autoApprove)) { + rules[pattern] = flag ? "allow" : "deny"; + } + if (Object.keys(rules).length > 0) { + permission[category] = rules; + } } - const permission: Record> = Object.keys(rules).length > - 0 - ? { [TERMINAL_CATEGORY]: rules } - : {}; - return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2), }); diff --git a/src/features/rules/copilot-rule.test.ts b/src/features/rules/copilot-rule.test.ts index f10c9aa40..26586c423 100644 --- a/src/features/rules/copilot-rule.test.ts +++ b/src/features/rules/copilot-rule.test.ts @@ -1316,4 +1316,29 @@ description: "Test trimming" expect(CopilotRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); }); }); + describe("name frontmatter", () => { + it("carries the display name in both directions", () => { + const rulesyncRule = new RulesyncRule({ + frontmatter: { + targets: ["*"], + root: false, + description: "Style rules", + globs: ["**/*.ts"], + copilot: { name: "TypeScript Style", excludeAgent: "code-review" }, + }, + body: "Body", + relativeDirPath: join(".rulesync", "rules"), + relativeFilePath: "style.md", + }); + + const copilotRule = CopilotRule.fromRulesyncRule({ rulesyncRule }); + expect(copilotRule.getFileContent()).toContain("name: TypeScript Style"); + + const backToRulesync = copilotRule.toRulesyncRule().getFrontmatter(); + expect(backToRulesync.copilot).toEqual({ + name: "TypeScript Style", + excludeAgent: "code-review", + }); + }); + }); }); diff --git a/src/features/rules/copilot-rule.ts b/src/features/rules/copilot-rule.ts index 7c0a53212..8c5ad35b3 100644 --- a/src/features/rules/copilot-rule.ts +++ b/src/features/rules/copilot-rule.ts @@ -23,6 +23,9 @@ import { export const CopilotRuleFrontmatterSchema = z.object({ description: z.optional(z.string()), applyTo: z.optional(z.string()), + // Display name shown in the UI; defaults to the file name when absent. + // https://code.visualstudio.com/docs/agent-customization/custom-instructions + name: z.optional(z.string()), // Documented values are `code-review` and `cloud-agent`; `coding-agent` is kept // as a deprecated alias so existing configs still import. // https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions @@ -152,8 +155,11 @@ export class CopilotRule extends ToolRule { root: this.isRoot(), description: this.frontmatter.description, globs, - ...(this.frontmatter.excludeAgent && { - copilot: { excludeAgent: this.frontmatter.excludeAgent }, + ...((this.frontmatter.excludeAgent || this.frontmatter.name) && { + copilot: { + ...(this.frontmatter.excludeAgent && { excludeAgent: this.frontmatter.excludeAgent }), + ...(this.frontmatter.name && { name: this.frontmatter.name }), + }, }), }; @@ -185,6 +191,7 @@ export class CopilotRule extends ToolRule { description: rulesyncFrontmatter.description, applyTo: rulesyncFrontmatter.globs?.length ? rulesyncFrontmatter.globs.join(",") : undefined, excludeAgent: rulesyncFrontmatter.copilot?.excludeAgent, + name: rulesyncFrontmatter.copilot?.name, }; // Generate proper file content with Copilot specific frontmatter diff --git a/src/features/rules/rulesync-rule.ts b/src/features/rules/rulesync-rule.ts index 96eb9d8eb..b63e98cd9 100644 --- a/src/features/rules/rulesync-rule.ts +++ b/src/features/rules/rulesync-rule.ts @@ -49,6 +49,9 @@ export const RulesyncRuleFrontmatterSchema = z.object({ excludeAgent: z.optional( z.union([z.literal("code-review"), z.literal("cloud-agent"), z.literal("coding-agent")]), ), + // Display name shown in the VS Code UI for an `*.instructions.md` file. + // https://code.visualstudio.com/docs/agent-customization/custom-instructions + name: z.optional(z.string()), }), ), antigravity: z.optional( diff --git a/src/features/shared/shared-config-gateway.ts b/src/features/shared/shared-config-gateway.ts index b341bf591..99c5f39d6 100644 --- a/src/features/shared/shared-config-gateway.ts +++ b/src/features/shared/shared-config-gateway.ts @@ -386,11 +386,11 @@ export const SHARED_CONFIG_OWNERSHIP: Readonly