diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index 197034af..3f7ba1db 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -7,9 +7,9 @@ The Archgate VS Code plugin gives AI agents working in [VS Code](https://code.vi ## How it works -VS Code supports **agent plugins** installed from git-based marketplaces. The Archgate plugin is served from a git repository at `plugins.archgate.dev/archgate-vscode.git`. When you add this marketplace to your VS Code user settings, VS Code discovers and installs the plugin automatically. +VS Code supports **agent plugins** installed from git-based marketplaces. The Archgate plugin is served from a git repository at `plugins.archgate.dev/archgate.git`. When you add this marketplace to your VS Code user settings, VS Code discovers and installs the plugin automatically. -The plugin is served in VS Code Copilot's native `.github/plugin/` manifest format, separate from the Claude Code `.claude-plugin/` format. +The plugin uses the same format as Claude Code plugins (`.claude-plugin/plugin.json`), which VS Code's agent plugin system recognizes natively. ## Installation @@ -88,7 +88,7 @@ The user-level marketplace setting (added to your `settings.json`): ```json { "chat.plugins.marketplaces": [ - "https://:@plugins.archgate.dev/archgate-vscode.git" + "https://:@plugins.archgate.dev/archgate.git" ] } ``` diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx index 0b0eaa92..066d3498 100644 --- a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -7,7 +7,7 @@ O plugin Archgate para VS Code oferece aos agentes de IA que trabalham no [VS Co ## Como funciona -O VS Code suporta **plugins de agente** instalados a partir de marketplaces baseados em git. O plugin Archgate é servido a partir de um repositório git em `plugins.archgate.dev/archgate-vscode.git`. Quando você adiciona esse marketplace às suas configurações de usuário do VS Code, o VS Code descobre e instala o plugin automaticamente. +O VS Code suporta **plugins de agente** instalados a partir de marketplaces baseados em git. O plugin Archgate é servido a partir de um repositório git em `plugins.archgate.dev/archgate.git`. Quando você adiciona esse marketplace às suas configurações de usuário do VS Code, o VS Code descobre e instala o plugin automaticamente. O plugin usa o mesmo formato dos plugins do Claude Code (`.claude-plugin/plugin.json`), que o sistema de plugins de agente do VS Code reconhece nativamente. @@ -88,7 +88,7 @@ A configuração do marketplace no nível de usuário (adicionada ao seu `settin ```json { "chat.plugins.marketplaces": [ - "https://:@plugins.archgate.dev/archgate-vscode.git" + "https://:@plugins.archgate.dev/archgate.git" ] } ``` diff --git a/src/helpers/copilot-settings.ts b/src/helpers/copilot-settings.ts index 68b2c70b..e8a4afdf 100644 --- a/src/helpers/copilot-settings.ts +++ b/src/helpers/copilot-settings.ts @@ -1,20 +1,77 @@ import { join } from "node:path"; import { existsSync, mkdirSync } from "node:fs"; +/** + * MCP server configuration that archgate injects for Copilot CLI. + * + * Copilot CLI uses the same `.github/copilot/mcp.json` format for MCP servers + * and supports git-based plugin repositories via `copilot plugin install`. + */ +export const ARCHGATE_COPILOT_MCP_CONFIG = { + mcpServers: { + archgate: { + command: "archgate", + args: ["mcp"], + }, + }, +} as const; + +type CopilotMcpConfig = Record; + +/** + * Pure, additive merge of archgate MCP server config into existing Copilot MCP config. + * + * - Preserves all existing MCP server entries + * - Adds the archgate server only if not already present + */ +export function mergeCopilotMcpConfig( + existing: CopilotMcpConfig, + archgate: typeof ARCHGATE_COPILOT_MCP_CONFIG +): CopilotMcpConfig { + const existingServers = + typeof existing.mcpServers === "object" && + existing.mcpServers !== null && + !Array.isArray(existing.mcpServers) + ? (existing.mcpServers as Record) + : {}; + + return { + ...existing, + mcpServers: { + ...existingServers, + ...archgate.mcpServers, + }, + }; +} + /** * Configure Copilot CLI settings for archgate integration. * - * Creates the `.github/copilot/` directory if it does not exist. - * Plugin installation is handled separately via `archgate init --install-plugin`. + * Creates/updates `.github/copilot/mcp.json` with archgate MCP server. * - * @returns Absolute path to the `.github/copilot/` directory. + * @returns Absolute path to the MCP config file. */ -export function configureCopilotSettings(projectRoot: string): string { +export async function configureCopilotSettings( + projectRoot: string +): Promise { const copilotDir = join(projectRoot, ".github", "copilot"); + const mcpConfigPath = join(copilotDir, "mcp.json"); + // Read existing MCP config or start with empty object + let existing: CopilotMcpConfig = {}; + if (existsSync(mcpConfigPath)) { + const content = await Bun.file(mcpConfigPath).text(); + existing = JSON.parse(content) as CopilotMcpConfig; + } + + const merged = mergeCopilotMcpConfig(existing, ARCHGATE_COPILOT_MCP_CONFIG); + + // Ensure directories exist if (!existsSync(copilotDir)) { mkdirSync(copilotDir, { recursive: true }); } - return copilotDir; + await Bun.write(mcpConfigPath, JSON.stringify(merged, null, 2) + "\n"); + + return mcpConfigPath; } diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index d37b4087..a39e968a 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -116,11 +116,11 @@ async function configureEditorSettings( case "cursor": return configureCursorSettings(projectRoot); case "vscode": { - // VS Code: marketplace URL to user settings if logged in + // VS Code: .vscode/mcp.json always, marketplace URL to user settings if logged in const { loadCredentials } = await import("./auth"); const creds = await loadCredentials(); const marketplaceUrl = creds - ? (await import("./plugin-install")).buildVscodeMarketplaceUrl(creds) + ? (await import("./plugin-install")).buildMarketplaceUrl(creds) : undefined; return configureVscodeSettings(projectRoot, marketplaceUrl); } diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 035b9a2c..4581b992 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -44,16 +44,6 @@ export function buildMarketplaceUrl(credentials: StoredCredentials): string { return `https://${credentials.github_user}:${credentials.token}@plugins.archgate.dev/archgate.git`; } -/** - * Build the authenticated git marketplace URL for VS Code plugin installation. - * VS Code Copilot uses the .github/plugin/ manifest format, served from a separate repo. - */ -export function buildVscodeMarketplaceUrl( - credentials: StoredCredentials -): string { - return `https://${credentials.github_user}:${credentials.token}@plugins.archgate.dev/archgate-vscode.git`; -} - /** * Check whether the `claude` CLI is available on the system PATH. */ diff --git a/src/helpers/vscode-settings.ts b/src/helpers/vscode-settings.ts index 668f778e..7bf8a773 100644 --- a/src/helpers/vscode-settings.ts +++ b/src/helpers/vscode-settings.ts @@ -2,8 +2,51 @@ import { join } from "node:path"; import { existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; +/** + * MCP server configuration that archgate injects into .vscode/mcp.json. + * + * VS Code uses a dedicated `.vscode/mcp.json` file for MCP server registration + * (not `.vscode/settings.json`). The `servers` key format is defined by VS Code's + * MCP configuration spec. + */ +export const ARCHGATE_VSCODE_MCP_CONFIG = { + servers: { + archgate: { + command: "archgate", + args: ["mcp"], + }, + }, +} as const; + +type VscodeMcpConfig = Record; type VscodeUserSettings = Record; +/** + * Pure, additive merge of archgate MCP server config into existing VS Code MCP config. + * + * - Preserves all existing MCP server entries + * - Adds the archgate server (overwrites if already present) + */ +export function mergeVscodeMcpConfig( + existing: VscodeMcpConfig, + archgate: typeof ARCHGATE_VSCODE_MCP_CONFIG +): VscodeMcpConfig { + const existingServers = + typeof existing.servers === "object" && + existing.servers !== null && + !Array.isArray(existing.servers) + ? (existing.servers as Record) + : {}; + + return { + ...existing, + servers: { + ...existingServers, + ...archgate.servers, + }, + }; +} + /** * VS Code's built-in default marketplaces for `chat.plugins.marketplaces`. * @@ -85,27 +128,40 @@ export function getVscodeUserSettingsPath(): string { /** * Configure VS Code settings for archgate integration. * - * If `marketplaceUrl` is provided, adds it to `chat.plugins.marketplaces` in - * the VS Code user-level settings.json (application-scoped — cannot be set per workspace). + * 1. Creates/updates `.vscode/mcp.json` (workspace-level) with the Archgate MCP server. + * 2. If `marketplaceUrl` is provided, adds it to `chat.plugins.marketplaces` in + * the VS Code user-level settings.json (application-scoped — cannot be set per workspace). * - * @returns Absolute path to the .vscode/ directory. + * @returns Absolute path to the workspace MCP config file. */ export async function configureVscodeSettings( projectRoot: string, marketplaceUrl?: string ): Promise { const vscodeDir = join(projectRoot, ".vscode"); + const mcpConfigPath = join(vscodeDir, "mcp.json"); + + // --- Workspace: .vscode/mcp.json --- + let existing: VscodeMcpConfig = {}; + if (existsSync(mcpConfigPath)) { + const content = await Bun.file(mcpConfigPath).text(); + existing = Bun.JSONC.parse(content) as VscodeMcpConfig; + } + + const merged = mergeVscodeMcpConfig(existing, ARCHGATE_VSCODE_MCP_CONFIG); if (!existsSync(vscodeDir)) { mkdirSync(vscodeDir, { recursive: true }); } + await Bun.write(mcpConfigPath, JSON.stringify(merged, null, 2) + "\n"); + // --- User-level: chat.plugins.marketplaces --- if (marketplaceUrl) { await addMarketplaceToUserSettings(marketplaceUrl); } - return vscodeDir; + return mcpConfigPath; } /** diff --git a/tests/helpers/copilot-settings.test.ts b/tests/helpers/copilot-settings.test.ts index 525f922d..8cc77e94 100644 --- a/tests/helpers/copilot-settings.test.ts +++ b/tests/helpers/copilot-settings.test.ts @@ -1,8 +1,92 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { mkdtempSync, rmSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { configureCopilotSettings } from "../../src/helpers/copilot-settings"; +import { + ARCHGATE_COPILOT_MCP_CONFIG, + mergeCopilotMcpConfig, + configureCopilotSettings, +} from "../../src/helpers/copilot-settings"; + +describe("mergeCopilotMcpConfig", () => { + test("sets archgate server when existing config is empty", () => { + const result = mergeCopilotMcpConfig({}, ARCHGATE_COPILOT_MCP_CONFIG); + + expect(result.mcpServers).toEqual({ + archgate: { + command: "archgate", + args: ["mcp"], + }, + }); + }); + + test("preserves existing MCP servers", () => { + const result = mergeCopilotMcpConfig( + { + mcpServers: { + "other-server": { + command: "other", + args: ["start"], + }, + }, + }, + ARCHGATE_COPILOT_MCP_CONFIG + ); + + const servers = result.mcpServers as Record; + expect(servers["other-server"]).toEqual({ + command: "other", + args: ["start"], + }); + expect(servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("overwrites existing archgate server entry", () => { + const result = mergeCopilotMcpConfig( + { + mcpServers: { + archgate: { + command: "old-command", + args: ["old"], + }, + }, + }, + ARCHGATE_COPILOT_MCP_CONFIG + ); + + const servers = result.mcpServers as Record; + expect(servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("preserves unknown top-level keys", () => { + const result = mergeCopilotMcpConfig( + { customKey: "value" }, + ARCHGATE_COPILOT_MCP_CONFIG + ); + + expect(result.customKey).toBe("value"); + }); + + test("handles non-object mcpServers gracefully", () => { + const result = mergeCopilotMcpConfig( + { mcpServers: "invalid" }, + ARCHGATE_COPILOT_MCP_CONFIG + ); + + expect(result.mcpServers).toEqual({ + archgate: { + command: "archgate", + args: ["mcp"], + }, + }); + }); +}); describe("configureCopilotSettings", () => { let tempDir: string; @@ -15,23 +99,51 @@ describe("configureCopilotSettings", () => { rmSync(tempDir, { recursive: true, force: true }); }); - test("creates .github/copilot/ dir when nothing exists", async () => { - await configureCopilotSettings(tempDir); + test("creates .github/copilot/ dir and mcp.json when nothing exists", async () => { + const mcpConfigPath = await configureCopilotSettings(tempDir); expect(existsSync(join(tempDir, ".github", "copilot"))).toBe(true); + expect(existsSync(mcpConfigPath)).toBe(true); + + const mcpContent = JSON.parse(await Bun.file(mcpConfigPath).text()); + expect(mcpContent.mcpServers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); }); - test("does not create mcp.json", async () => { + test("merges into existing mcp.json without overwriting other servers", async () => { + const copilotDir = join(tempDir, ".github", "copilot"); + mkdirSync(copilotDir, { recursive: true }); + + const existingConfig = { + mcpServers: { + "my-server": { command: "my-cmd", args: [] }, + }, + }; + await Bun.write( + join(copilotDir, "mcp.json"), + JSON.stringify(existingConfig, null, 2) + ); + await configureCopilotSettings(tempDir); - expect(existsSync(join(tempDir, ".github", "copilot", "mcp.json"))).toBe( - false + const content = JSON.parse( + await Bun.file(join(copilotDir, "mcp.json")).text() ); + expect(content.mcpServers["my-server"]).toEqual({ + command: "my-cmd", + args: [], + }); + expect(content.mcpServers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); }); - test("returns path to .github/copilot/ directory", async () => { - const result = await configureCopilotSettings(tempDir); + test("returns correct absolute path to mcp.json", async () => { + const mcpConfigPath = await configureCopilotSettings(tempDir); - expect(result).toBe(join(tempDir, ".github", "copilot")); + expect(mcpConfigPath).toBe(join(tempDir, ".github", "copilot", "mcp.json")); }); }); diff --git a/tests/helpers/vscode-settings.test.ts b/tests/helpers/vscode-settings.test.ts index f34e66b7..5c04ca90 100644 --- a/tests/helpers/vscode-settings.test.ts +++ b/tests/helpers/vscode-settings.test.ts @@ -3,12 +3,94 @@ import { mkdtempSync, rmSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { + ARCHGATE_VSCODE_MCP_CONFIG, + mergeVscodeMcpConfig, mergeMarketplaceUrl, configureVscodeSettings, addMarketplaceToUserSettings, getVscodeUserSettingsPath, } from "../../src/helpers/vscode-settings"; +describe("mergeVscodeMcpConfig", () => { + test("sets archgate server when existing config is empty", () => { + const result = mergeVscodeMcpConfig({}, ARCHGATE_VSCODE_MCP_CONFIG); + + expect(result.servers).toEqual({ + archgate: { + command: "archgate", + args: ["mcp"], + }, + }); + }); + + test("preserves existing MCP servers", () => { + const result = mergeVscodeMcpConfig( + { + servers: { + "other-server": { + command: "other", + args: ["start"], + }, + }, + }, + ARCHGATE_VSCODE_MCP_CONFIG + ); + + const servers = result.servers as Record; + expect(servers["other-server"]).toEqual({ + command: "other", + args: ["start"], + }); + expect(servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("overwrites existing archgate server entry", () => { + const result = mergeVscodeMcpConfig( + { + servers: { + archgate: { + command: "old-command", + args: ["old"], + }, + }, + }, + ARCHGATE_VSCODE_MCP_CONFIG + ); + + const servers = result.servers as Record; + expect(servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("preserves unknown top-level keys", () => { + const result = mergeVscodeMcpConfig( + { inputs: [{ type: "promptString", id: "key" }] }, + ARCHGATE_VSCODE_MCP_CONFIG + ); + + expect(result.inputs).toEqual([{ type: "promptString", id: "key" }]); + }); + + test("handles non-object servers gracefully", () => { + const result = mergeVscodeMcpConfig( + { servers: "invalid" }, + ARCHGATE_VSCODE_MCP_CONFIG + ); + + expect(result.servers).toEqual({ + archgate: { + command: "archgate", + args: ["mcp"], + }, + }); + }); +}); + describe("mergeMarketplaceUrl", () => { const URL = "https://user:token@plugins.archgate.dev/archgate.git"; @@ -64,22 +146,80 @@ describe("configureVscodeSettings", () => { rmSync(tempDir, { recursive: true, force: true }); }); - test("creates .vscode/ dir when nothing exists", async () => { - await configureVscodeSettings(tempDir); + test("creates .vscode/ dir and mcp.json when nothing exists", async () => { + const mcpConfigPath = await configureVscodeSettings(tempDir); expect(existsSync(join(tempDir, ".vscode"))).toBe(true); + expect(existsSync(mcpConfigPath)).toBe(true); + + const content = JSON.parse(await Bun.file(mcpConfigPath).text()); + expect(content.servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); }); - test("does not create mcp.json", async () => { + test("merges into existing mcp.json without overwriting other servers", async () => { + const vscodeDir = join(tempDir, ".vscode"); + mkdirSync(vscodeDir, { recursive: true }); + + const existingConfig = { + servers: { + "my-server": { command: "my-cmd", args: [] }, + }, + }; + await Bun.write( + join(vscodeDir, "mcp.json"), + JSON.stringify(existingConfig, null, 2) + ); + await configureVscodeSettings(tempDir); - expect(existsSync(join(tempDir, ".vscode", "mcp.json"))).toBe(false); + const content = JSON.parse( + await Bun.file(join(vscodeDir, "mcp.json")).text() + ); + expect(content.servers["my-server"]).toEqual({ + command: "my-cmd", + args: [], + }); + expect(content.servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("parses JSONC (comments + trailing commas) in existing mcp.json", async () => { + const vscodeDir = join(tempDir, ".vscode"); + mkdirSync(vscodeDir, { recursive: true }); + + // Write JSONC with comments and trailing comma — as VS Code produces + const jsoncContent = `{ + // MCP servers + "servers": { + "my-server": { "command": "my-cmd", "args": [] }, + } + }`; + await Bun.write(join(vscodeDir, "mcp.json"), jsoncContent); + + await configureVscodeSettings(tempDir); + + const content = JSON.parse( + await Bun.file(join(vscodeDir, "mcp.json")).text() + ); + expect(content.servers["my-server"]).toEqual({ + command: "my-cmd", + args: [], + }); + expect(content.servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); }); - test("returns path to .vscode/ directory", async () => { - const result = await configureVscodeSettings(tempDir); + test("returns correct absolute path to mcp.json", async () => { + const mcpConfigPath = await configureVscodeSettings(tempDir); - expect(result).toBe(join(tempDir, ".vscode")); + expect(mcpConfigPath).toBe(join(tempDir, ".vscode", "mcp.json")); }); });