Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/copilot-cli-mcp-install-target.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions docs/content/integrations/copilot.mdx
Original file line number Diff line number Diff line change
@@ -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

<McpInstall editor="GitHub Copilot CLI" />

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:

<VerifyExec subject="Copilot CLI" />

If you don't see the tool, restart the Copilot CLI so it reloads `mcp-config.json`.
2 changes: 1 addition & 1 deletion docs/content/integrations/meta.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"title": "Integrations",
"icon": "LuPlug",
"pages": ["claude-code", "cursor", "codex"]
"pages": ["claude-code", "cursor", "codex", "copilot"]
}
78 changes: 77 additions & 1 deletion packages/cli/src/commands/editors.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,6 +11,7 @@ import {
resolveClaudeCodeConfigPath,
resolveClaudeDesktopConfigPath,
resolveCodexConfigPath,
resolveCopilotCliConfigPath,
resolveCursorConfigPath,
resolveEditorTargets,
} from './editors.ts';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/commands/editors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return {
type: 'local',
...buildManagedServerEntry(options),
tools: ['*'],
};
}

interface AppSupportOptions {
home?: string;
platformName?: NodeJS.Platform;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -227,6 +252,21 @@ export const EDITOR_TARGETS: Record<EditorId, EditorMcpTarget> = {
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[] {
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
resolveClaudeCodeConfigPath,
resolveClaudeDesktopConfigPath,
resolveCodexConfigPath,
resolveCopilotCliConfigPath,
resolveCursorConfigPath,
} from './editors.ts';

Expand Down Expand Up @@ -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] });

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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)', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/repair-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/sharing/git-exclude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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', () => {
Expand All @@ -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);
});
});

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/constants/editors.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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 = {
claude: 'Claude',
'claude-desktop': 'Claude Desktop',
cursor: 'Cursor',
codex: 'Codex',
copilot: 'Copilot CLI',
} as const satisfies Record<EditorId, string>;
10 changes: 9 additions & 1 deletion packages/desktop/src/main/create-new-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
});
5 changes: 5 additions & 0 deletions packages/desktop/src/main/skill-reclaim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading