diff --git a/.changeset/copilot-cli-mcp-install-target.md b/.changeset/copilot-cli-mcp-install-target.md new file mode 100644 index 000000000..383533e7a --- /dev/null +++ b/.changeset/copilot-cli-mcp-install-target.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +`ok init` now installs the OpenKnowledge MCP server into the GitHub Copilot CLI. When you run `ok init` (or the desktop first-launch consent dialog) and `~/.copilot` is present, Copilot is detected alongside Claude Code, Cursor, and Codex and gets a `local` MCP entry written to `~/.copilot/mcp-config.json` (honoring `COPILOT_HOME`), with every tool pre-approved so the agent can call the OpenKnowledge `exec` tool immediately. The Copilot CLI MCP config is global only — it writes no project-local config. `ok init` also installs the project skill at `.github/skills/open-knowledge/SKILL.md` (and `ok start` keeps it refreshed), so Copilot CLI gets the same in-repo OpenKnowledge skill that Claude Code does. diff --git a/docs/content/integrations/copilot.mdx b/docs/content/integrations/copilot.mdx new file mode 100644 index 000000000..b68a9986d --- /dev/null +++ b/docs/content/integrations/copilot.mdx @@ -0,0 +1,28 @@ +--- +title: GitHub Copilot CLI +description: Use OpenKnowledge with the GitHub Copilot CLI. +--- + +OpenKnowledge plugs into the [GitHub Copilot CLI](https://github.com/github/copilot-cli) through MCP. + +## Install + + + +The entry is written to your global Copilot CLI config at `~/.copilot/mcp-config.json` (or `$COPILOT_HOME/mcp-config.json` if you've set `COPILOT_HOME`). It's registered as a `local` server with every tool pre-approved (`"tools": ["*"]`), so Copilot can call the OpenKnowledge `exec` tool without an extra per-tool prompt. + +Unlike Claude Code, Cursor, and Codex, the Copilot CLI **MCP config** is **global only** — there's no project-local `mcp-config.json`. The single global entry applies to every project you open with `copilot`. + +## Project skill + +When you run `ok init` in a project (or `ok start`, which refreshes skills), OpenKnowledge installs a project skill at `.github/skills/open-knowledge/SKILL.md`. This is the same project skill Claude Code gets at `.claude/skills/open-knowledge/`, and it teaches the agent how to read and write OpenKnowledge content in the repo. + +The Copilot CLI discovers project skills from `.github/skills`, `.claude/skills`, and `.agents/skills`, so if you also select Claude Code, Cursor, or Codex during `ok init`, Copilot may see the same skill from more than one directory — that's expected and harmless. + +## Verify + +Start the Copilot CLI in your project and ask: + + + +If you don't see the tool, restart the Copilot CLI so it reloads `mcp-config.json`. diff --git a/docs/content/integrations/meta.json b/docs/content/integrations/meta.json index 84d789235..92f306e79 100644 --- a/docs/content/integrations/meta.json +++ b/docs/content/integrations/meta.json @@ -1,5 +1,5 @@ { "title": "Integrations", "icon": "LuPlug", - "pages": ["claude-code", "cursor", "codex"] + "pages": ["claude-code", "cursor", "codex", "copilot"] } diff --git a/packages/cli/src/commands/editors.test.ts b/packages/cli/src/commands/editors.test.ts index eb468ef43..f825eb92b 100644 --- a/packages/cli/src/commands/editors.test.ts +++ b/packages/cli/src/commands/editors.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; import { + buildCopilotServerEntry, buildManagedServerEntry, CHAIN_V1, CHAIN_VERSION_SENTINEL, @@ -9,6 +11,7 @@ import { resolveClaudeCodeConfigPath, resolveClaudeDesktopConfigPath, resolveCodexConfigPath, + resolveCopilotCliConfigPath, resolveCursorConfigPath, resolveEditorTargets, } from './editors.ts'; @@ -141,6 +144,28 @@ describe('resolveCodexConfigPath', () => { }); }); +describe('resolveCopilotCliConfigPath', () => { + it('builds the default Copilot CLI config path', () => { + expect( + resolveCopilotCliConfigPath({ + home: '/Users/alice', + platformName: 'darwin', + env: {}, + }), + ).toBe('/Users/alice/.copilot/mcp-config.json'); + }); + + it('honors COPILOT_HOME when present', () => { + expect( + resolveCopilotCliConfigPath({ + home: '/Users/alice', + platformName: 'darwin', + env: { COPILOT_HOME: '/tmp/custom-copilot-home' }, + }), + ).toBe('/tmp/custom-copilot-home/mcp-config.json'); + }); +}); + describe('CHAIN_V1', () => { it('starts with the version sentinel', () => { expect(CHAIN_V1.startsWith(CHAIN_VERSION_SENTINEL)).toBe(true); @@ -225,7 +250,7 @@ describe('buildManagedServerEntry', () => { expect((b.args as unknown[]).length).toBe(3); }); - it('every editor target produces the byte-identical chain entry', () => { + it('every chain-shaped editor target produces the byte-identical chain entry', () => { const editors: EditorId[] = ['claude', 'claude-desktop', 'cursor', 'codex']; const baseline = buildManagedServerEntry({ mode: 'published' }); for (const id of editors) { @@ -236,6 +261,57 @@ describe('buildManagedServerEntry', () => { }); }); +describe('buildCopilotServerEntry', () => { + const originalArgv1 = process.argv[1]; + beforeEach(() => { + process.argv[1] = '/repo/packages/cli/src/cli.ts'; + }); + afterEach(() => { + process.argv[1] = originalArgv1; + }); + + it('wraps the published chain in a Copilot local-server envelope', () => { + expect(buildCopilotServerEntry({ mode: 'published' })).toEqual({ + type: 'local', + command: '/bin/sh', + args: ['-l', '-c', CHAIN_V1], + tools: ['*'], + }); + }); + + it('wraps the dev chain (preserving env) in a Copilot local-server envelope', () => { + expect(buildCopilotServerEntry({ mode: 'dev' })).toEqual({ + type: 'local', + command: 'node', + args: ['/repo/packages/cli/dist/cli.mjs', 'mcp'], + env: { MCP_DEBUG: '1', OK_LOG_FILE: '/tmp/ok-mcp.log' }, + tools: ['*'], + }); + }); + + it('is the entry the copilot EDITOR_TARGET emits', () => { + const target = resolveEditorTargets(['copilot'])[0]; + expect(target.buildEntry('', { mode: 'published' })).toEqual( + buildCopilotServerEntry({ mode: 'published' }), + ); + }); + + it('installs a project skill at .github/skills but has no repo-local MCP config', () => { + const target = resolveEditorTargets(['copilot'])[0]; + // Copilot CLI reads project skills from .github/skills (like claude-code reads + // .claude/skills), but it has no repo-local MCP config — only the global + // ~/.copilot/mcp-config.json — so projectConfigPath is intentionally undefined. + expect(target.projectConfigPath).toBeUndefined(); + expect(target.projectSkillPath?.('/x')).toBe( + join('/x', '.github', 'skills', 'open-knowledge', 'SKILL.md'), + ); + }); + + it('keeps the chain command/args so isEntryUpToDate still recognizes it', () => { + expect(isEntryUpToDate(buildCopilotServerEntry({ mode: 'published' }))).toBe(true); + }); +}); + describe('isEntryUpToDate', () => { it('true for the current chain shape', () => { expect(isEntryUpToDate(buildManagedServerEntry({ mode: 'published' }))).toBe(true); diff --git a/packages/cli/src/commands/editors.ts b/packages/cli/src/commands/editors.ts index 95846c492..ac961fb8d 100644 --- a/packages/cli/src/commands/editors.ts +++ b/packages/cli/src/commands/editors.ts @@ -88,6 +88,19 @@ export function buildManagedServerEntry(options: McpInstallOptions = {}): Record }; } +/** GitHub Copilot CLI wraps each stdio MCP server in a `type: 'local'` + * envelope and reads a `tools` allowlist (`['*']` = all tools). The launch + * command/args/env are the shared managed-server chain, so Copilot stays in + * lock-step with the other editors and the desktop startup repair (which only + * inspects `command`/`args` via `isEntryUpToDate`). */ +export function buildCopilotServerEntry(options: McpInstallOptions = {}): Record { + return { + type: 'local', + ...buildManagedServerEntry(options), + tools: ['*'], + }; +} + interface AppSupportOptions { home?: string; platformName?: NodeJS.Platform; @@ -162,6 +175,18 @@ export function resolveCodexConfigPath(options: AppSupportOptions = {}): string return pathApiForPlatform(platformName).join(resolveCodexHomePath(options), 'config.toml'); } +function resolveCopilotHomePath(options: AppSupportOptions = {}): string { + const platformName = options.platformName ?? process.platform; + const home = options.home ?? homedir(); + const env = options.env ?? process.env; + return env.COPILOT_HOME ?? pathApiForPlatform(platformName).join(home, '.copilot'); +} + +export function resolveCopilotCliConfigPath(options: AppSupportOptions = {}): string { + const platformName = options.platformName ?? process.platform; + return pathApiForPlatform(platformName).join(resolveCopilotHomePath(options), 'mcp-config.json'); +} + export interface EditorMcpTarget { id: EditorId; label: string; @@ -227,6 +252,21 @@ export const EDITOR_TARGETS: Record = { projectConfigPath: (cwd) => join(cwd, '.codex', 'config.toml'), projectSkillPath: (cwd) => join(cwd, '.agents', 'skills', 'open-knowledge', 'SKILL.md'), }, + copilot: { + id: 'copilot', + label: EDITOR_LABELS.copilot, + configPath: (_cwd, home) => resolveCopilotCliConfigPath({ home }), + format: 'json', + topLevelKey: 'mcpServers', + serverName: () => MCP_SERVER_NAME, + buildEntry: (_cwd, options) => buildCopilotServerEntry(options), + scope: 'global', + detectPath: (_cwd, home) => resolveCopilotHomePath({ home }), + // Copilot CLI has no repo-local MCP config (it reads only the global + // ~/.copilot/mcp-config.json), so there is no projectConfigPath. It does read + // project skills from .github/skills, so it gets a project skill like claude-code. + projectSkillPath: (cwd) => join(cwd, '.github', 'skills', 'open-knowledge', 'SKILL.md'), + }, }; export function resolveEditorTargets(ids: EditorId[]): EditorMcpTarget[] { diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 1d7de9935..36e83c8f3 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -22,6 +22,7 @@ import { resolveClaudeCodeConfigPath, resolveClaudeDesktopConfigPath, resolveCodexConfigPath, + resolveCopilotCliConfigPath, resolveCursorConfigPath, } from './editors.ts'; @@ -574,6 +575,9 @@ describe('runInit', () => { mkdirSync(dirname(resolveClaudeDesktopConfigPath({ home: fakeHome })), { recursive: true }); mkdirSync(dirname(cursorConfigPath()), { recursive: true }); mkdirSync(dirname(codexConfigPath()), { recursive: true }); + mkdirSync(dirname(resolveCopilotCliConfigPath({ home: fakeHome, env: {} })), { + recursive: true, + }); const result = await runInitForTest({ editors: [...ALL_EDITOR_IDS] }); @@ -586,6 +590,7 @@ describe('runInit', () => { expect(existsSync(resolveClaudeDesktopConfigPath({ home: fakeHome }))).toBe(true); expect(existsSync(cursorConfigPath())).toBe(true); expect(existsSync(codexConfigPath())).toBe(true); + expect(existsSync(resolveCopilotCliConfigPath({ home: fakeHome, env: {} }))).toBe(true); }); it('overwrites across all targeted editors', async () => { @@ -1606,11 +1611,23 @@ describe('detectInstalledEditors', () => { expect(detected).not.toContain('claude-desktop'); }); + it('detects Copilot CLI when ~/.copilot exists', async () => { + mkdirSync(join(fakeHome, '.copilot'), { recursive: true }); + const detected = detectInstalledEditors(testDir, fakeHome); + expect(detected).toContain('copilot'); + }); + + it('does NOT detect Copilot CLI when ~/.copilot is absent', async () => { + const detected = detectInstalledEditors(testDir, fakeHome); + expect(detected).not.toContain('copilot'); + }); + it('returns all supported editors when all editor config dirs exist', async () => { mkdirSync(join(fakeHome, '.claude'), { recursive: true }); mkdirSync(dirname(resolveClaudeDesktopConfigPath({ home: fakeHome })), { recursive: true }); mkdirSync(dirname(cursorConfigPath()), { recursive: true }); mkdirSync(dirname(codexConfigPath()), { recursive: true }); + mkdirSync(join(fakeHome, '.copilot'), { recursive: true }); const detected = detectInstalledEditors(testDir, fakeHome); expect(detected).toEqual(expect.arrayContaining([...ALL_EDITOR_IDS])); expect(detected).toHaveLength(ALL_EDITOR_IDS.length); @@ -1621,8 +1638,9 @@ describe('detectInstalledEditors', () => { mkdirSync(dirname(resolveClaudeDesktopConfigPath({ home: fakeHome })), { recursive: true }); mkdirSync(dirname(cursorConfigPath()), { recursive: true }); mkdirSync(dirname(codexConfigPath()), { recursive: true }); + mkdirSync(join(fakeHome, '.copilot'), { recursive: true }); const detected = detectInstalledEditors(testDir, fakeHome); - expect(detected).toEqual(['claude', 'claude-desktop', 'cursor', 'codex']); + expect(detected).toEqual(['claude', 'claude-desktop', 'cursor', 'codex', 'copilot']); }); it('returns empty list when the cwd itself does not exist (zero-detected edge case)', () => { diff --git a/packages/cli/src/commands/repair-skills.ts b/packages/cli/src/commands/repair-skills.ts index c15c5fc97..5e0dbeb3e 100644 --- a/packages/cli/src/commands/repair-skills.ts +++ b/packages/cli/src/commands/repair-skills.ts @@ -28,6 +28,11 @@ const HOSTS_WITH_USER_SKILL_DIR: ReadonlyArray<{ { hostDir: '.claude', editorId: 'claude' }, { hostDir: '.cursor', editorId: 'cursor' }, { hostDir: '.agents', editorId: 'codex' }, + // Copilot CLI reads project skills from .github/skills, so that is its project + // host dir. It has no ~/.github user skill dir, so the user sweep reports + // skipped-host-absent; its user-level discovery skill is served by the central + // ~/.agents/skills write (Copilot CLI also reads ~/.agents/skills). + { hostDir: '.github', editorId: 'copilot' }, ]; const USER_SKILL_DIR_NAME = 'open-knowledge-discovery'; diff --git a/packages/cli/src/integrations/write-project-ai-integrations.test.ts b/packages/cli/src/integrations/write-project-ai-integrations.test.ts index 5c7f1b89d..48d129521 100644 --- a/packages/cli/src/integrations/write-project-ai-integrations.test.ts +++ b/packages/cli/src/integrations/write-project-ai-integrations.test.ts @@ -54,14 +54,26 @@ describe('writeProjectAiIntegrations — installs MCP config AND the project ski ); }); - test('all editors: 2 outcomes per editor; claude-desktop skips both as unsupported', () => { + test('all editors: 2 outcomes per editor; global-only editors skip the MCP config', () => { const result = writeProjectAiIntegrations(projectDir, ALL_EDITOR_IDS); expect(result.integrations).toHaveLength(ALL_EDITOR_IDS.length * 2); - const desktop = result.integrations.filter((o) => o.editorId === 'claude-desktop'); - expect(desktop).toHaveLength(2); - for (const outcome of desktop) expect(outcome.action).toBe('skipped-unsupported'); + // claude-desktop is fully global-only (no project MCP config and no project + // skill), so both its project integrations report skipped-unsupported. + const claudeDesktop = result.integrations.filter((o) => o.editorId === 'claude-desktop'); + expect(claudeDesktop).toHaveLength(2); + for (const outcome of claudeDesktop) expect(outcome.action).toBe('skipped-unsupported'); + + // Copilot CLI has no repo-local MCP config (global-only), but it DOES read a + // project skill from .github/skills — so the skill is written and only the + // MCP config is skipped. + const copilot = result.integrations.filter((o) => o.editorId === 'copilot'); + expect(copilot.find((o) => o.integration === 'mcp-config')?.action).toBe('skipped-unsupported'); + expect(copilot.find((o) => o.integration === 'project-skill')?.action).toBe('written'); + expect(existsSync(join(projectDir, '.github', 'skills', 'open-knowledge', 'SKILL.md'))).toBe( + true, + ); }); test('empty selection returns no integrations and no launch.json', () => { diff --git a/packages/cli/src/sharing/git-exclude.test.ts b/packages/cli/src/sharing/git-exclude.test.ts index 0b00bb143..c493519f3 100644 --- a/packages/cli/src/sharing/git-exclude.test.ts +++ b/packages/cli/src/sharing/git-exclude.test.ts @@ -47,7 +47,7 @@ describe('getOkArtifactPaths', () => { rmSync(dir, { recursive: true, force: true }); }); - it('returns the canonical nine-path artifact set when no config.yml exists', () => { + it('returns the canonical ten-path artifact set when no config.yml exists', () => { const paths = getOkArtifactPaths(dir); expect(paths).toContain(`${OK_DIR}/`); expect(paths).toContain('.okignore'); @@ -57,8 +57,9 @@ describe('getOkArtifactPaths', () => { expect(paths).toContain('.claude/skills/open-knowledge/'); expect(paths).toContain('.cursor/skills/open-knowledge/'); expect(paths).toContain('.agents/skills/open-knowledge/'); + expect(paths).toContain('.github/skills/open-knowledge/'); expect(paths).toContain('.claude/launch.json'); - expect(paths).toHaveLength(9); + expect(paths).toHaveLength(10); }); it('preserves a stable order so `ok config-sharing status` and unit-test snapshots are deterministic', () => { @@ -76,7 +77,7 @@ describe('getOkArtifactPaths', () => { expect(paths).not.toContain('docs/.ok/'); expect(paths).not.toContain('docs/.okignore'); expect(paths.some((p) => p.includes('**'))).toBe(false); - expect(paths).toHaveLength(9); + expect(paths).toHaveLength(10); }); }); diff --git a/packages/core/src/constants/editors.ts b/packages/core/src/constants/editors.ts index 7f887b716..0e2304076 100644 --- a/packages/core/src/constants/editors.ts +++ b/packages/core/src/constants/editors.ts @@ -1,10 +1,11 @@ -export type EditorId = 'claude' | 'claude-desktop' | 'cursor' | 'codex'; +export type EditorId = 'claude' | 'claude-desktop' | 'cursor' | 'codex' | 'copilot'; export const ALL_EDITOR_IDS = [ 'claude', 'claude-desktop', 'cursor', 'codex', + 'copilot', ] as const satisfies readonly EditorId[]; export const EDITOR_LABELS = { @@ -12,4 +13,5 @@ export const EDITOR_LABELS = { 'claude-desktop': 'Claude Desktop', cursor: 'Cursor', codex: 'Codex', + copilot: 'Copilot CLI', } as const satisfies Record; diff --git a/packages/desktop/src/main/create-new-project.test.ts b/packages/desktop/src/main/create-new-project.test.ts index 6c2378b12..c0712e984 100644 --- a/packages/desktop/src/main/create-new-project.test.ts +++ b/packages/desktop/src/main/create-new-project.test.ts @@ -463,10 +463,18 @@ describe('runCreateNew — installs the project-local skill (PRD-6733)', () => { expect( existsSync(join(result.projectDir, '.agents', 'skills', 'open-knowledge', 'SKILL.md')), ).toBe(true); + expect( + existsSync(join(result.projectDir, '.github', 'skills', 'open-knowledge', 'SKILL.md')), + ).toBe(true); const skillWrites = result.aiIntegrations.integrations.filter( (o) => o.integration === 'project-skill' && o.action === 'written', ); - expect(skillWrites.map((o) => o.editorId).sort()).toEqual(['claude', 'codex', 'cursor']); + expect(skillWrites.map((o) => o.editorId).sort()).toEqual([ + 'claude', + 'codex', + 'copilot', + 'cursor', + ]); }); }); diff --git a/packages/desktop/src/main/skill-reclaim.ts b/packages/desktop/src/main/skill-reclaim.ts index fd71e8a45..18f2e422a 100644 --- a/packages/desktop/src/main/skill-reclaim.ts +++ b/packages/desktop/src/main/skill-reclaim.ts @@ -27,6 +27,11 @@ const HOSTS_WITH_USER_SKILL_DIR: ReadonlyArray<{ { hostDir: '.claude', editorId: 'claude' }, { hostDir: '.cursor', editorId: 'cursor' }, { hostDir: '.agents', editorId: 'codex' }, + // Copilot CLI reads project skills from .github/skills, so that is its project + // host dir. It has no ~/.github user skill dir, so the user sweep reports + // skipped-host-absent; its user-level discovery skill is served by the central + // ~/.agents/skills write (Copilot CLI also reads ~/.agents/skills). + { hostDir: '.github', editorId: 'copilot' }, ]; const OK_MCP_MARKER = '# ok-mcp-v1'; diff --git a/packages/desktop/tests/integration/m1-smoke.test.ts b/packages/desktop/tests/integration/m1-smoke.test.ts index c50f87bbd..6c6ecbaaa 100644 --- a/packages/desktop/tests/integration/m1-smoke.test.ts +++ b/packages/desktop/tests/integration/m1-smoke.test.ts @@ -273,8 +273,9 @@ describe('M1 smoke', () => { typeName: 'EditorId', canonicalPath: editorsConstantPath, canonicalRe: /type\s+EditorId\s*=([^;]+);/, - expectedLiteralCount: 4, - inlineRe: /'claude'\s*\|\s*'claude-desktop'\s*\|\s*'cursor'\s*\|\s*'codex'/, + expectedLiteralCount: 5, + inlineRe: + /'claude'\s*\|\s*'claude-desktop'\s*\|\s*'cursor'\s*\|\s*'codex'\s*\|\s*'copilot'/, mirrors: [ ['cli/commands/editors.ts', cliEditorsPath], ['desktop/shared/ipc-channels.ts', ipcChannelsPath],