Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 5 additions & 62 deletions src/helpers/copilot-settings.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,20 @@
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<string, unknown>;

/**
* 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<string, unknown>)
: {};

return {
...existing,
mcpServers: {
...existingServers,
...archgate.mcpServers,
},
};
}

/**
* Configure Copilot CLI settings for archgate integration.
*
* Creates/updates `.github/copilot/mcp.json` with archgate MCP server.
* Creates the `.github/copilot/` directory if it does not exist.
* Plugin installation is handled separately via `archgate init --install-plugin`.
*
* @returns Absolute path to the MCP config file.
* @returns Absolute path to the `.github/copilot/` directory.
*/
export async function configureCopilotSettings(
projectRoot: string
): Promise<string> {
export function configureCopilotSettings(projectRoot: string): string {
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 });
}

await Bun.write(mcpConfigPath, JSON.stringify(merged, null, 2) + "\n");

return mcpConfigPath;
return copilotDir;
}
2 changes: 1 addition & 1 deletion src/helpers/init-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ async function configureEditorSettings(
case "cursor":
return configureCursorSettings(projectRoot);
case "vscode": {
// VS Code: .vscode/mcp.json always, marketplace URL to user settings if logged in
// VS Code: marketplace URL to user settings if logged in
const { loadCredentials } = await import("./auth");
const creds = await loadCredentials();
const marketplaceUrl = creds
Expand Down
64 changes: 4 additions & 60 deletions src/helpers/vscode-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,8 @@ 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<string, unknown>;
type VscodeUserSettings = Record<string, unknown>;

/**
* 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<string, unknown>)
: {};

return {
...existing,
servers: {
...existingServers,
...archgate.servers,
},
};
}

/**
* VS Code's built-in default marketplaces for `chat.plugins.marketplaces`.
*
Expand Down Expand Up @@ -128,40 +85,27 @@ export function getVscodeUserSettingsPath(): string {
/**
* Configure VS Code settings for archgate integration.
*
* 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).
* 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 workspace MCP config file.
* @returns Absolute path to the .vscode/ directory.
*/
export async function configureVscodeSettings(
projectRoot: string,
marketplaceUrl?: string
): Promise<string> {
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 mcpConfigPath;
return vscodeDir;
}

/**
Expand Down
132 changes: 10 additions & 122 deletions tests/helpers/copilot-settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,92 +1,8 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, rmSync, existsSync, mkdirSync } from "node:fs";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
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<string, unknown>;
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<string, unknown>;
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"],
},
});
});
});
import { configureCopilotSettings } from "../../src/helpers/copilot-settings";

describe("configureCopilotSettings", () => {
let tempDir: string;
Expand All @@ -99,51 +15,23 @@ describe("configureCopilotSettings", () => {
rmSync(tempDir, { recursive: true, force: true });
});

test("creates .github/copilot/ dir and mcp.json when nothing exists", async () => {
const mcpConfigPath = await configureCopilotSettings(tempDir);
test("creates .github/copilot/ dir when nothing exists", async () => {
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("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)
);

test("does not create mcp.json", async () => {
await configureCopilotSettings(tempDir);

const content = JSON.parse(
await Bun.file(join(copilotDir, "mcp.json")).text()
expect(existsSync(join(tempDir, ".github", "copilot", "mcp.json"))).toBe(
false
);
expect(content.mcpServers["my-server"]).toEqual({
command: "my-cmd",
args: [],
});
expect(content.mcpServers.archgate).toEqual({
command: "archgate",
args: ["mcp"],
});
});

test("returns correct absolute path to mcp.json", async () => {
const mcpConfigPath = await configureCopilotSettings(tempDir);
test("returns path to .github/copilot/ directory", async () => {
const result = await configureCopilotSettings(tempDir);

expect(mcpConfigPath).toBe(join(tempDir, ".github", "copilot", "mcp.json"));
expect(result).toBe(join(tempDir, ".github", "copilot"));
});
});
Loading
Loading