Skip to content

Commit 6d1bd4c

Browse files
committed
feat: add GitHub Copilot agent target
Signed-off-by: Sertan Helvacı <sertanhelvaci@icloud.com>
1 parent e1360aa commit 6d1bd4c

10 files changed

Lines changed: 336 additions & 77 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ ships the same local engine inside the editor, still account-free:
8888
- right-click any file or folder → **Pathrule** → browse the knowledge an AI session there
8989
would receive, or scope a new memory/rule/skill in two keystrokes;
9090
- **Connect AI Clients**: GitHub Copilot agent mode registers automatically; Claude Code,
91-
Cursor, Codex and Windsurf get managed MCP entries plus context hooks, no JSON editing;
91+
Cursor, Codex, Windsurf and the Copilot CLI get managed MCP entries plus context hooks
92+
(Copilot reads them from `.github/hooks/`), no JSON editing;
9293
- works on the same `~/.pathrule` store as the CLI: one identity, one knowledge base.
9394

9495
Also on [Open VSX](https://open-vsx.org/extension/pathrule/pathrule-vscode) for Cursor and Windsurf.

packages/cli-local/src/install-local.ts

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { homedir } from "node:os";
88
import { fileURLToPath } from "node:url";
99

10-
import { getInstaller } from "@pathrule/shared/mcp-installers/registry.js";
10+
import { getInstallers } from "@pathrule/shared/mcp-installers/registry.js";
1111
import type { AgentTargetId } from "@pathrule/shared/skills/agent-targets.js";
1212
import type { McpServerEntry } from "@pathrule/shared/mcp-types.js";
1313
import { atomicWrite, readIfExists } from "@pathrule/shared/local-runtime/atomic-write.js";
@@ -36,9 +36,11 @@ const TARGET_ALIASES: Record<string, CliInstallTarget> = {
3636
cursor: "cursor",
3737
codex: "codex",
3838
windsurf: "windsurf",
39+
copilot: "copilot",
40+
"github-copilot": "copilot",
3941
};
4042

41-
const ALL_TARGETS: AgentTargetId[] = ["claude-code", "cursor", "codex", "windsurf"];
43+
const ALL_TARGETS: AgentTargetId[] = ["claude-code", "cursor", "codex", "windsurf", "copilot"];
4244

4345
export function parseInstallTargets(raw: string | undefined): AgentTargetId[] {
4446
const target = TARGET_ALIASES[(raw ?? "all").toLowerCase()];
@@ -68,32 +70,36 @@ export async function installEntryToCliTargets(
6870
env: NodeJS.ProcessEnv,
6971
): Promise<CliInstallResult[]> {
7072
const platform = cliPlatform(env);
71-
return Promise.all(
72-
targets.map(async (target) => {
73-
const installer = getInstaller(target);
74-
const configPath = installer.homeConfigPath(homedir(), platform);
75-
try {
76-
const existing = await readIfExists(configPath);
77-
const result = installer.inject(existing, entry);
78-
await atomicWrite(configPath, result.body);
79-
return {
80-
client: target,
81-
ok: true,
82-
status: "installed" as const,
83-
config_path: configPath,
84-
was_new: result.wasNew,
85-
};
86-
} catch (err) {
87-
return {
88-
client: target,
89-
ok: false,
90-
status: "error" as const,
91-
config_path: configPath,
92-
error: err instanceof Error ? err.message : String(err),
93-
};
94-
}
95-
}),
73+
// One result per (target, config file) — multi-config clients (copilot:
74+
// CLI config + VS Code user mcp.json) report each config separately.
75+
const results = await Promise.all(
76+
targets.flatMap((target) =>
77+
getInstallers(target).map(async (installer): Promise<CliInstallResult> => {
78+
const configPath = installer.homeConfigPath(homedir(), platform);
79+
try {
80+
const existing = await readIfExists(configPath);
81+
const result = installer.inject(existing, entry);
82+
await atomicWrite(configPath, result.body);
83+
return {
84+
client: target,
85+
ok: true,
86+
status: "installed" as const,
87+
config_path: configPath,
88+
was_new: result.wasNew,
89+
};
90+
} catch (err) {
91+
return {
92+
client: target,
93+
ok: false,
94+
status: "error" as const,
95+
config_path: configPath,
96+
error: err instanceof Error ? err.message : String(err),
97+
};
98+
}
99+
}),
100+
),
96101
);
102+
return results;
97103
}
98104

99105
/**

packages/cli-local/src/local/setup-local.test.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,12 @@ vi.mock("@pathrule/shared/mcp-installers/registry.js", async (importOriginal) =>
1717
await importOriginal<typeof import("@pathrule/shared/mcp-installers/registry.js")>();
1818
return {
1919
...actual,
20-
getInstaller: (id: never) => {
21-
const real = actual.getInstaller(id);
22-
return {
20+
getInstallers: (id: never) =>
21+
actual.getInstallers(id).map((real, index) => ({
2322
...real,
24-
homeConfigPath: () => join(mocks.configDir.value, `${String(id)}.json`),
25-
};
26-
},
23+
homeConfigPath: () =>
24+
join(mocks.configDir.value, `${String(id)}${index > 0 ? `-${index}` : ""}.json`),
25+
})),
2726
};
2827
});
2928

packages/shared/src/hook-supervisor/client-config-writer.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
CLAUDE_TO_CODEX_EVENT_MAP,
33
CLAUDE_TO_CODEX_TOOL_MAP,
4+
CLAUDE_TO_COPILOT_EVENT_MAP,
5+
CLAUDE_TO_COPILOT_TOOL_MAP,
46
CLAUDE_TO_CURSOR_EVENT_MAP,
57
CLAUDE_TO_CURSOR_TOOL_MAP,
68
} from "./event-mapping.js";
@@ -155,3 +157,84 @@ export function buildCodexHookEvents(config: HookConfig): Record<string, HookMat
155157
export function renderCodexHooks(config: HookConfig): string {
156158
return `${JSON.stringify({ hooks: buildCodexHookEvents(config) }, null, 2)}\n`;
157159
}
160+
161+
// ─── GitHub Copilot (docs.github.com/en/copilot/reference/hooks-configuration) ─
162+
// One `.github/hooks/pathrule.json` feeds all three Copilot surfaces: the CLI
163+
// reads it natively, VS Code agent mode converts the camelCase event names to
164+
// PascalCase, and the cloud coding agent reads `.github/hooks/*.json` as its
165+
// only hook source. Copilot's `matcher` is a regex anchored against the tool
166+
// name — Claude's `a|b|c` pipe lists are valid alternations as-is.
167+
168+
const COPILOT_TOOL_SET = new Set(["bash", "create", "edit", "view", "grep", "glob"]);
169+
170+
// VS Code agent mode uses its own tool names (docs show both naming
171+
// generations). Folded into matchers so PreToolUse/PostToolUse still fire if
172+
// a host applies the matcher against VS Code names rather than CLI names.
173+
const COPILOT_VSCODE_TOOL_ALIASES: Record<string, readonly string[]> = {
174+
view: ["read_file", "readFile"],
175+
edit: ["editFiles", "replace_string_in_file"],
176+
create: ["createFile", "create_file"],
177+
bash: ["runTerminalCommand", "run_in_terminal"],
178+
};
179+
180+
const COPILOT_SUPPORTED_EVENTS = new Set<HookEventName>([
181+
"SessionStart",
182+
"PreToolUse",
183+
"PostToolUse",
184+
"Stop",
185+
"SubagentStop",
186+
"SessionEnd",
187+
]);
188+
189+
/** One entry per hook in Copilot's flattened per-event array format. The env
190+
* stamp is the hook script's primary client discriminator; the event stamp
191+
* exists because Copilot CLI's camelCase payload carries no event name. */
192+
interface CopilotHookEntry {
193+
type: "command";
194+
command: string;
195+
matcher?: string;
196+
env: Record<string, string>;
197+
}
198+
199+
function mapMatcherToCopilot(matcher: string | undefined): string | null | undefined {
200+
if (!matcher) return undefined; // matcher-less entry — fires on every tool
201+
const seen = new Set<string>();
202+
const mapped: string[] = [];
203+
for (const name of matcher.split("|")) {
204+
const copilotName = CLAUDE_TO_COPILOT_TOOL_MAP[name];
205+
if (!copilotName || !COPILOT_TOOL_SET.has(copilotName) || seen.has(copilotName)) continue;
206+
seen.add(copilotName);
207+
mapped.push(copilotName, ...(COPILOT_VSCODE_TOOL_ALIASES[copilotName] ?? []));
208+
}
209+
return mapped.length > 0 ? mapped.join("|") : null;
210+
}
211+
212+
export function buildCopilotHookEvents(config: HookConfig): Record<string, CopilotHookEntry[]> {
213+
const hooks: Record<string, CopilotHookEntry[]> = {};
214+
for (const [rawEvent, rawMatchers] of Object.entries(config.hooks)) {
215+
const event = rawEvent as HookEventName;
216+
if (!COPILOT_SUPPORTED_EVENTS.has(event)) continue;
217+
const copilotEvent = CLAUDE_TO_COPILOT_EVENT_MAP[event];
218+
if (!copilotEvent || !rawMatchers || rawMatchers.length === 0) continue;
219+
const entries: CopilotHookEntry[] = [];
220+
for (const matcherGroup of rawMatchers) {
221+
const matcher = mapMatcherToCopilot(matcherGroup.matcher);
222+
if (matcher === null) continue; // every tool in the group is unmappable
223+
for (const hook of matcherGroup.hooks) {
224+
if (hook.type !== "command") continue;
225+
entries.push({
226+
type: "command",
227+
command: hook.command,
228+
...(matcher !== undefined ? { matcher } : {}),
229+
env: { PATHRULE_HOOK_CLIENT: "copilot", PATHRULE_HOOK_EVENT: copilotEvent },
230+
});
231+
}
232+
}
233+
if (entries.length > 0) hooks[copilotEvent] = entries;
234+
}
235+
return hooks;
236+
}
237+
238+
export function renderCopilotHooks(config: HookConfig): string {
239+
return `${JSON.stringify({ version: 1, hooks: buildCopilotHookEvents(config) }, null, 2)}\n`;
240+
}

packages/shared/src/hook-supervisor/event-mapping.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,51 @@ export const CLAUDE_TO_CODEX_TOOL_MAP: Record<string, string> = {
7878
export const CODEX_TO_CLAUDE_TOOL_MAP: Record<string, string> = {
7979
apply_patch: "Edit",
8080
};
81+
82+
// ─── GitHub Copilot (docs.github.com/en/copilot/reference/hooks-configuration) ─
83+
// Copilot's native hook config uses camelCase event names; VS Code agent mode
84+
// converts them to PascalCase internally and the cloud coding agent reads the
85+
// same `.github/hooks/*.json` file. `UserPromptSubmit` is deliberately
86+
// unmapped: Copilot's userPromptSubmitted accepts no context output on any
87+
// surface, so wiring it would burn one process per prompt for zero injection
88+
// (same reasoning as Cursor's beforeSubmitPrompt).
89+
export type CopilotHookEventName =
90+
| "sessionStart"
91+
| "sessionEnd"
92+
| "userPromptSubmitted"
93+
| "preToolUse"
94+
| "postToolUse"
95+
| "postToolUseFailure"
96+
| "agentStop"
97+
| "subagentStart"
98+
| "subagentStop"
99+
| "errorOccurred"
100+
| "preCompact"
101+
| "permissionRequest"
102+
| "notification";
103+
104+
export const CLAUDE_TO_COPILOT_EVENT_MAP: Record<HookEventName, CopilotHookEventName | null> = {
105+
PreToolUse: "preToolUse",
106+
PostToolUse: "postToolUse",
107+
UserPromptSubmit: null,
108+
SessionStart: "sessionStart",
109+
SessionEnd: "sessionEnd",
110+
Stop: "agentStop",
111+
SubagentStop: "subagentStop",
112+
Notification: null,
113+
PreCompact: null,
114+
PostCompact: null,
115+
};
116+
117+
// Copilot CLI + cloud coding agent tool names. VS Code agent mode uses its own
118+
// names (createFile/editFiles/runTerminalCommand…) handled by the hook
119+
// script's copilot branch; renderer matchers carry both via
120+
// COPILOT_VSCODE_TOOL_ALIASES in client-config-writer.ts.
121+
export const CLAUDE_TO_COPILOT_TOOL_MAP: Record<string, string> = {
122+
Read: "view",
123+
Edit: "edit",
124+
Write: "create",
125+
Bash: "bash",
126+
Grep: "grep",
127+
Glob: "glob",
128+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// GitHub Copilot MCP installers. Copilot is the one multi-config client: the
2+
// Copilot CLI reads `~/.copilot/mcp-config.json` while Copilot in VS Code
3+
// (agent mode without the Pathrule extension's McpServerDefinitionProvider)
4+
// reads the user-profile `mcp.json`. Both are registered under the single
5+
// `copilot` agent target; the registry returns them as an array and every
6+
// consumer iterates. The cloud coding agent's MCP config lives in repo
7+
// settings on github.com (no file or API to manage) — doctor surfaces it as
8+
// a documented manual step instead.
9+
//
10+
// Schemas verified in docs/superpowers/specs/m55-copilot-capability-spike.md.
11+
12+
import {
13+
defaultManagedEntryInfo,
14+
joinHomePath,
15+
type ClientInstaller,
16+
type ManagedMcpEntryInfo,
17+
} from "./types.js";
18+
import { makeJsonInstaller } from "./json-installer.js";
19+
20+
// Copilot CLI entry schema: `{ "type": "local", "command", "args", "env",
21+
// "tools": ["*"] }` under a `mcpServers` root. `tools` is the per-server
22+
// allowlist — `["*"]` enables every Pathrule tool.
23+
const cliJson = makeJsonInstaller({
24+
includeTypeField: true,
25+
typeFieldValue: "local",
26+
entryExtras: { tools: ["*"] },
27+
});
28+
29+
export const copilotCliInstaller: ClientInstaller = {
30+
id: "copilot",
31+
configLabel: "Copilot CLI",
32+
homeConfigPath(homedir: string, platform: NodeJS.Platform): string {
33+
return joinHomePath(platform, homedir, ".copilot", "mcp-config.json");
34+
},
35+
inject: cliJson.inject,
36+
remove: cliJson.remove,
37+
read: cliJson.read,
38+
getManagedEntry(existing: string | null): ManagedMcpEntryInfo {
39+
return defaultManagedEntryInfo(cliJson.read, existing);
40+
},
41+
};
42+
43+
// VS Code user-profile `mcp.json`: root key is `servers` (NOT `mcpServers`)
44+
// and the entry keeps `type: "stdio"`. Stable VS Code only in v1 —
45+
// Insiders/VSCodium profile variants are documented out of scope.
46+
const vscodeJson = makeJsonInstaller({ includeTypeField: true, rootKey: "servers" });
47+
48+
export const copilotVscodeInstaller: ClientInstaller = {
49+
id: "copilot",
50+
configLabel: "VS Code user config",
51+
homeConfigPath(homedir: string, platform: NodeJS.Platform): string {
52+
if (platform === "darwin") {
53+
return joinHomePath(platform, homedir, "Library", "Application Support", "Code", "User", "mcp.json");
54+
}
55+
if (platform === "win32") {
56+
return joinHomePath(platform, homedir, "AppData", "Roaming", "Code", "User", "mcp.json");
57+
}
58+
return joinHomePath(platform, homedir, ".config", "Code", "User", "mcp.json");
59+
},
60+
inject: vscodeJson.inject,
61+
remove: vscodeJson.remove,
62+
read: vscodeJson.read,
63+
getManagedEntry(existing: string | null): ManagedMcpEntryInfo {
64+
return defaultManagedEntryInfo(vscodeJson.read, existing);
65+
},
66+
};

0 commit comments

Comments
 (0)