Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,8 @@ Events present in the shared `hooks` block but unsupported by a given tool are s

> **Note:** Hook file paths per tool:
>
> - **Copilot (cloud agent / VS Code)** — project: `<project>/.github/hooks/copilot-hooks.json`; global: `~/.copilot/hooks/copilot-ide-hooks.json`. Command hooks carry `bash`/`powershell` with optional `timeoutSec`, plus the canonical `env` map. Which command field is written is chosen by the canonical `shell` selector; without it the portable `command` field is written, which upstream copies to both. It is deliberately **not** chosen from the platform Rulesync runs on: the cloud agent runs hooks in a **Linux sandbox** where only `bash` and `command` are honored, so a `powershell` entry generated on a Windows machine would simply never run. It also keeps the output identical everywhere, which matters because the cloud agent reads this file from the repository. For the same reason, an imported entry carrying both fields resolves to `bash` (with a warning) on every platform. VS Code and the coding agent both document `~/.copilot/hooks` as the user scope and load every `*.json` in that folder; the Copilot CLI's global file already occupies `copilot-hooks.json` there, so the VS Code target uses a distinct filename and the two never overwrite each other. Note the flip side of "every `*.json` is loaded": generating **both** `copilot` and `copilotcli` in global mode leaves two files in that one folder, and a reader of the folder runs the hooks from both — so a command present in your canonical config fires twice per event. Generate only one of the two globally unless you want that.
> - **Copilot CLI** — project: `<project>/.github/hooks/copilotcli-hooks.json`; global: `~/.copilot/hooks/copilot-hooks.json`. The Copilot CLI docs let you choose any filename inside `.github/hooks/`, so Rulesync uses the CLI-specific name to avoid colliding with the cloud-agent file when both targets are enabled. The global path is a Rulesync convention; the official Copilot CLI documentation does not currently enumerate a global hooks location, so this placement may change if the spec later mandates an alternate layout. Copilot CLI uses a **wider event surface** than the shared cloud-agent set (`sessionStart`, `sessionEnd`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `agentStop` ← `stop`, `subagentStart`, `subagentStop`, `errorOccurred` ← `afterError`, `preCompact`, `permissionRequest`, `notification`, `userPromptTransformed` ← `userPromptExpansion`, `preMcpToolCall` ← `beforeMCPExecution`) and supports three hook types: **`command`** (`bash`/`powershell` with optional `timeoutSec`, plus pass-through `cwd`/`env`; on import the portable `command` field is read as the cross-platform fallback when neither shell field is present, and `timeout` is honored as an alias for `timeoutSec` when `timeoutSec` is absent. On generate the canonical `shell` selector chooses `bash` or `powershell`; without it the portable `command` field is written, so the generated file does not depend on the machine Rulesync ran on), **`prompt`** (a `prompt` string — Copilot CLI only honors prompt hooks on `sessionStart`, so prompt hooks on other events are dropped), and **`http`** (`url`/`headers`/`allowedEnvVars` with optional `timeoutSec`). An entry's optional `matcher` field is emitted and round-tripped on the six events the hooks reference documents as matcher-aware — `preToolUse` and `postToolUse` (regex on the tool name), `permissionRequest` (tool name), `notification` (notification type), `preCompact` (the trigger, `manual` or `auto`) and `subagentStart` (agent name); on any other event a matcher is dropped with a warning because the CLI does not honor it there. See the [hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference).
> - **Copilot (cloud agent / VS Code)** — project: `<project>/.github/hooks/copilot-hooks.json`; global: `~/.copilot/hooks/copilot-ide-hooks.json`. Command hooks carry `bash`/`powershell` with optional `timeoutSec`, plus the canonical `env` map and a pass-through `cwd`. On import, `timeout` is honored as an alias for `timeoutSec` when `timeoutSec` is absent. Which command field is written is chosen by the canonical `shell` selector; without it the portable `command` field is written, which upstream copies to both. It is deliberately **not** chosen from the platform Rulesync runs on: the cloud agent runs hooks in a **Linux sandbox** where only `bash` and `command` are honored, so a `powershell` entry generated on a Windows machine would simply never run. It also keeps the output identical everywhere, which matters because the cloud agent reads this file from the repository. For the same reason, an imported entry carrying both fields resolves to `bash` (with a warning) on every platform. VS Code and the coding agent both document `~/.copilot/hooks` as the user scope and load every `*.json` in that folder; the Copilot CLI's global file already occupies `copilot-hooks.json` there, so the VS Code target uses a distinct filename and the two never overwrite each other. Note the flip side of "every `*.json` is loaded": generating **both** `copilot` and `copilotcli` in global mode leaves two files in that one folder, and a reader of the folder runs the hooks from both — so a command present in your canonical config fires twice per event. Generate only one of the two globally unless you want that.
> - **Copilot CLI** — project: `<project>/.github/hooks/copilotcli-hooks.json`; global: `~/.copilot/hooks/copilot-hooks.json`. The Copilot CLI docs let you choose any filename inside `.github/hooks/`, so Rulesync uses the CLI-specific name to avoid colliding with the cloud-agent file when both targets are enabled. The global path is a Rulesync convention; the official Copilot CLI documentation does not currently enumerate a global hooks location, so this placement may change if the spec later mandates an alternate layout. Copilot CLI uses a **wider event surface** than the shared cloud-agent set (`sessionStart`, `sessionEnd`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `agentStop` ← `stop`, `subagentStart`, `subagentStop`, `errorOccurred` ← `afterError`, `preCompact`, `permissionRequest`, `notification`, `userPromptTransformed` ← `userPromptExpansion`, `preMcpToolCall` ← `beforeMCPExecution`) and supports three hook types: **`command`** (`bash`/`powershell` with optional `timeoutSec`, plus pass-through `cwd`/`env`; on import the portable `command` field is read as the cross-platform fallback when neither shell field is present, and `timeout` is honored as an alias for `timeoutSec` when `timeoutSec` is absent. On generate the canonical `shell` selector chooses `bash` or `powershell`; without it the portable `command` field is written, so the generated file does not depend on the machine Rulesync ran on. An imported entry carrying both shell fields resolves to `bash` (with a warning) on every platform, so importing the same file yields the same canonical config everywhere), **`prompt`** (a `prompt` string — Copilot CLI only honors prompt hooks on `sessionStart`, so prompt hooks on other events are dropped), and **`http`** (`url`/`headers`/`allowedEnvVars` with optional `timeoutSec`). An entry's optional `matcher` field is emitted and round-tripped on the six events the hooks reference documents as matcher-aware — `preToolUse` and `postToolUse` (regex on the tool name), `permissionRequest` (tool name), `notification` (notification type), `preCompact` (the trigger, `manual` or `auto`) and `subagentStart` (agent name); on any other event a matcher is dropped with a warning because the CLI does not honor it there. See the [hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference).
> - **Antigravity IDE / Antigravity CLI** — project: `<project>/.agents/hooks.json`; global: `~/.gemini/config/hooks.json`. Both targets share the same dedicated `hooks.json` (a Claude-Code-style matcher map nested under a generated `rulesync` hook name), so enabling both writes the same file.
> - **Devin Desktop (formerly Windsurf)** — project: `<project>/.windsurf/hooks.json`; global: `~/.codeium/windsurf/hooks.json`. The Cascade Hooks file location is unchanged by the Devin Desktop rebrand.
> - **AugmentCode** — project: `<project>/.augment/settings.json`; global: `~/.augment/settings.json`. Hooks are merged under the top-level `hooks` key of the shared settings file (which also holds `toolPermissions`).
Expand Down
56 changes: 56 additions & 0 deletions src/features/hooks/copilot-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,62 @@ describe("CopilotHooks", () => {
expect(reExported.hooks.preToolUse[0].command).toBe("portable.sh");
});

it("should round-trip cwd through import and re-export", async () => {
const copilotHooks = new CopilotHooks({
outputRoot: testDir,
relativeDirPath: join(".github", "hooks"),
relativeFilePath: "copilot-hooks.json",
fileContent: JSON.stringify({
version: 1,
hooks: {
sessionStart: [
{ type: "command", bash: "echo hi", cwd: "packages/api", timeoutSec: 30 },
],
},
}),
validate: false,
});

const imported = copilotHooks.toRulesyncHooks().getJson();
expect(imported.hooks.sessionStart?.[0]?.cwd).toBe("packages/api");
expect(imported.hooks.sessionStart?.[0]?.timeout).toBe(30);

// Generate re-emits non-canonical keys through `rest`, so `cwd` only
// survives a round trip if import preserved it.
const reExported = JSON.parse(
(
await CopilotHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks: copilotHooks.toRulesyncHooks(),
validate: false,
})
).getFileContent(),
);
expect(reExported.hooks.sessionStart[0].cwd).toBe("packages/api");
expect(reExported.hooks.sessionStart[0].timeoutSec).toBe(30);
});

it("should read the timeout alias when timeoutSec is absent", () => {
const copilotHooks = new CopilotHooks({
outputRoot: testDir,
relativeDirPath: join(".github", "hooks"),
relativeFilePath: "copilot-hooks.json",
fileContent: JSON.stringify({
version: 1,
hooks: {
sessionStart: [{ type: "command", bash: "echo hi", timeout: 45 }],
sessionEnd: [{ type: "command", bash: "echo bye", timeout: 45, timeoutSec: 10 }],
},
}),
validate: false,
});

const json = copilotHooks.toRulesyncHooks().getJson();
expect(json.hooks.sessionStart?.[0]?.timeout).toBe(45);
// `timeoutSec` is the documented spelling, so it wins over the alias.
expect(json.hooks.sessionEnd?.[0]?.timeout).toBe(10);
});

it("should handle empty hooks", () => {
const copilotHooks = new CopilotHooks({
outputRoot: testDir,
Expand Down
25 changes: 23 additions & 2 deletions src/features/hooks/copilot-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ const CopilotHookEntrySchema = z.looseObject({
bash: z.optional(z.string()),
powershell: z.optional(z.string()),
command: z.optional(z.string()),
cwd: z.optional(z.string()),
env: z.optional(z.record(z.string(), z.string())),
timeoutSec: z.optional(z.number()),
// Alias for `timeoutSec`, used only when `timeoutSec` is absent, matching how
// the Copilot CLI sibling reads the two spellings.
timeout: z.optional(z.number()),
});

type CopilotHookEntry = z.infer<typeof CopilotHookEntrySchema>;
Expand Down Expand Up @@ -146,6 +150,23 @@ function resolveImportCommand(
return typeof entry.command === "string" ? { command: entry.command } : {};
}

/**
* Extract the non-command fields preserved across import.
*
* Generate re-emits any non-canonical key verbatim through `rest`, so a key
* dropped here does not survive an import → generate round trip. `cwd` is a
* documented Copilot hook field and was previously lost that way.
*
* @see https://docs.github.com/en/copilot/reference/hooks-reference
* @see https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks
*/
function importPassthrough(entry: CopilotHookEntry): Record<string, unknown> {
const passthrough: Record<string, unknown> = {};
if (entry.cwd !== undefined) passthrough.cwd = entry.cwd;
if (entry.env !== undefined) passthrough.env = entry.env;
return passthrough;
}

/**
* Extract hooks from Copilot hooks JSON into canonical format.
* Copilot format: { version: 1, hooks: { eventName: [...hookEntries] } }
Expand All @@ -165,14 +186,14 @@ function copilotHooksToCanonical(copilotHooks: unknown, logger?: Logger): HooksC
if (!parseResult.success) continue;
const entry = parseResult.data;
const { command, shell } = resolveImportCommand(entry, logger);
const timeout = entry.timeoutSec;
const timeout = entry.timeoutSec ?? entry.timeout;

defs.push({
type: "command",
...(command !== undefined && { command }),
...(shell !== undefined && { shell }),
...(entry.env !== undefined && { env: entry.env }),
...(timeout !== undefined && { timeout }),
...importPassthrough(entry),
});
}
if (defs.length > 0) {
Expand Down
83 changes: 83 additions & 0 deletions src/features/hooks/copilotcli-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,89 @@ describe("CopilotcliHooks", () => {
expect(json.hooks.preToolUse?.[0]?.timeout).toBe(10);
});

it("should round-trip cwd through import and re-export", async () => {
const hooks = new CopilotcliHooks({
outputRoot: testDir,
relativeDirPath: join(".github", "hooks"),
relativeFilePath: "copilotcli-hooks.json",
fileContent: JSON.stringify({
version: 1,
hooks: {
sessionStart: [
{ type: "command", bash: "echo hi", cwd: "packages/api", timeoutSec: 30 },
],
},
}),
validate: false,
});

const imported = hooks.toRulesyncHooks().getJson();
expect(imported.hooks.sessionStart?.[0]?.cwd).toBe("packages/api");

const reExported = JSON.parse(
(
await CopilotcliHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks: hooks.toRulesyncHooks(),
validate: false,
})
).getFileContent(),
);
expect(reExported.hooks.sessionStart[0].cwd).toBe("packages/api");
expect(reExported.hooks.sessionStart[0].timeoutSec).toBe(30);
});

it("should always take bash when both bash and powershell are present", () => {
const logger = createMockLogger();

const hooks = new CopilotcliHooks({
outputRoot: testDir,
relativeDirPath: join(".github", "hooks"),
relativeFilePath: "copilotcli-hooks.json",
fileContent: JSON.stringify({
version: 1,
hooks: {
sessionStart: [
{ type: "command", bash: "echo start", powershell: "Write-Output start" },
],
},
}),
validate: false,
});

const json = hooks.toRulesyncHooks({ logger }).getJson();
expect(json.hooks.sessionStart?.[0]?.command).toBe("echo start");
expect(json.hooks.sessionStart?.[0]?.shell).toBe("bash");
expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(
"Copilot CLI hook has both bash and powershell commands; using bash and ignoring powershell, so the imported config does not depend on the machine the import ran on.",
);
});

it("should pick bash on Windows too, so import does not depend on the platform", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");

const hooks = new CopilotcliHooks({
outputRoot: testDir,
relativeDirPath: join(".github", "hooks"),
relativeFilePath: "copilotcli-hooks.json",
fileContent: JSON.stringify({
version: 1,
hooks: {
sessionStart: [
{ type: "command", bash: "echo start", powershell: "Write-Output start" },
],
},
}),
validate: false,
});

// The same file must import to the same canonical config everywhere,
// otherwise the rulesync hooks file differs per contributor's machine.
const json = hooks.toRulesyncHooks().getJson();
expect(json.hooks.sessionStart?.[0]?.command).toBe("echo start");
expect(json.hooks.sessionStart?.[0]?.shell).toBe("bash");
});

it("should default missing 'type' field to 'command' when importing", () => {
const hooks = new CopilotcliHooks({
outputRoot: testDir,
Expand Down
20 changes: 11 additions & 9 deletions src/features/hooks/copilotcli-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ const CopilotCliHookEntrySchema = z.looseObject({
bash: z.optional(z.string()),
powershell: z.optional(z.string()),
// Cross-platform fallback: upstream copies it to both `bash` and
// `powershell` when those fields are absent, so it is import-only here —
// generate always writes the platform-specific field.
// `powershell` when those fields are absent. Generate writes it whenever the
// canonical `shell` selector is unset (see `buildCopilotCliEntriesForEvent`),
// and writes the shell-specific field otherwise.
command: z.optional(z.string()),
prompt: z.optional(z.string()),
url: z.optional(z.string()),
Expand Down Expand Up @@ -302,6 +303,12 @@ function importPassthrough(entry: CopilotCliHookEntry): Record<string, unknown>
* A shell-specific field carries its `shell` through so re-export writes the
* same field back. An entry using only the portable `command` field leaves
* `shell` unset, which re-export renders as the portable field again.
*
* When both shell-specific fields are present, `bash` wins and a warning names
* the ignored `powershell`. The choice is deliberately not platform-dependent:
* importing on Windows must not produce a different canonical config than
* importing the same file on Linux, which would make the rulesync hooks file
* differ per machine for anyone who checks it in.
*/
function resolveImportCommand(
entry: CopilotCliHookEntry,
Expand All @@ -310,15 +317,10 @@ function resolveImportCommand(
const hasBash = typeof entry.bash === "string";
const hasPowershell = typeof entry.powershell === "string";
if (hasBash && hasPowershell) {
const isWindows = process.platform === "win32";
const chosen = isWindows ? "powershell" : "bash";
const ignored = isWindows ? "bash" : "powershell";
logger?.warn(
`Copilot CLI hook has both bash and powershell commands; using ${chosen} and ignoring ${ignored} on this platform.`,
"Copilot CLI hook has both bash and powershell commands; using bash and ignoring powershell, so the imported config does not depend on the machine the import ran on.",
);
return isWindows
? { command: entry.powershell, shell: "powershell" }
: { command: entry.bash, shell: "bash" };
return { command: entry.bash, shell: "bash" };
} else if (hasBash) {
return { command: entry.bash, shell: "bash" };
} else if (hasPowershell) {
Expand Down
Loading
Loading