|
1 | 1 | import { |
2 | 2 | CLAUDE_TO_CODEX_EVENT_MAP, |
3 | 3 | CLAUDE_TO_CODEX_TOOL_MAP, |
| 4 | + CLAUDE_TO_COPILOT_EVENT_MAP, |
| 5 | + CLAUDE_TO_COPILOT_TOOL_MAP, |
4 | 6 | CLAUDE_TO_CURSOR_EVENT_MAP, |
5 | 7 | CLAUDE_TO_CURSOR_TOOL_MAP, |
6 | 8 | } from "./event-mapping.js"; |
@@ -155,3 +157,84 @@ export function buildCodexHookEvents(config: HookConfig): Record<string, HookMat |
155 | 157 | export function renderCodexHooks(config: HookConfig): string { |
156 | 158 | return `${JSON.stringify({ hooks: buildCodexHookEvents(config) }, null, 2)}\n`; |
157 | 159 | } |
| 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 | +} |
0 commit comments