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],