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
2 changes: 1 addition & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s

> **Note:** Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory `hooks/hooks.json` that Goose auto-discovers at startup. Locations are `<project>/.agents/plugins/rulesync/hooks/hooks.json` (project) and `~/.agents/plugins/rulesync/hooks/hooks.json` (global). The JSON shape matches Claude Code's (`{ "hooks": { "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "..." } ] } ] } }`). Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, `afterShellExecution` ⇄ `AfterShellExecution`, `subagentStart` ⇄ `SubagentStart`, and `subagentStop` ⇄ `SubagentStop`. The `matcher` regex is preserved, commands are emitted verbatim (Goose exposes `PLUGIN_ROOT` as a runtime environment variable), and only `command`-type hooks are supported.

> **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Sixteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `notification` ⇄ `Notification`, `todoCreated` ⇄ `TodoCreated`, and `todoCompleted` ⇄ `TodoCompleted`. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt`, `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md).
> **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Sixteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `notification` ⇄ `Notification`, `todoCreated` ⇄ `TodoCreated`, and `todoCompleted` ⇄ `TodoCompleted`. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt`, `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md).

## `.github/mcp.json` and `.copilot/mcp-config.json`

Expand Down
2 changes: 1 addition & 1 deletion skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s

> **Note:** Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory `hooks/hooks.json` that Goose auto-discovers at startup. Locations are `<project>/.agents/plugins/rulesync/hooks/hooks.json` (project) and `~/.agents/plugins/rulesync/hooks/hooks.json` (global). The JSON shape matches Claude Code's (`{ "hooks": { "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "..." } ] } ] } }`). Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, `afterShellExecution` ⇄ `AfterShellExecution`, `subagentStart` ⇄ `SubagentStart`, and `subagentStop` ⇄ `SubagentStop`. The `matcher` regex is preserved, commands are emitted verbatim (Goose exposes `PLUGIN_ROOT` as a runtime environment variable), and only `command`-type hooks are supported.

> **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Sixteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `notification` ⇄ `Notification`, `todoCreated` ⇄ `TodoCreated`, and `todoCompleted` ⇄ `TodoCompleted`. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt`, `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md).
> **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Sixteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `notification` ⇄ `Notification`, `todoCreated` ⇄ `TodoCreated`, and `todoCompleted` ⇄ `TodoCompleted`. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt`, `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md).

## `.github/mcp.json` and `.copilot/mcp-config.json`

Expand Down
14 changes: 11 additions & 3 deletions src/features/hooks/copilotcli-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { formatError } from "../../utils/error.js";
import { readFileContentOrNull } from "../../utils/file.js";
import type { Logger } from "../../utils/logger.js";
import { compact } from "../../utils/object.js";
import type { RulesyncHooks } from "./rulesync-hooks.js";
import {
ToolHooks,
Expand Down Expand Up @@ -195,19 +196,26 @@ function buildCopilotCliEntriesForEvent({
if (def.prompt === undefined || def.prompt === null) continue;
entries.push({ type: "prompt", prompt: def.prompt, ...rest });
} else if (hookType === "http") {
// `url`, `headers`, and `allowedEnvVars` are canonical fields Copilot CLI
// supports natively on http hooks, so emit them explicitly rather than via
// the non-canonical passthrough.
entries.push({
type: "http",
...matcherPart,
...(def.url !== undefined && def.url !== null && { url: def.url }),
...compact({
url: def.url,
headers: def.headers,
allowedEnvVars: def.allowedEnvVars,
}),
...timeoutPart,
...rest,
});
} else {
const command = def.command;
// `env` is a canonical field Copilot CLI supports natively on command hooks.
entries.push({
type: "command",
...matcherPart,
...(command !== undefined && command !== null && { [commandField]: command }),
...compact({ [commandField]: def.command, env: def.env }),
...timeoutPart,
...rest,
});
Expand Down
212 changes: 212 additions & 0 deletions src/features/hooks/qwencode-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,116 @@ describe("QwencodeHooks", () => {
expect(parsed.hooks.PreToolUse[0].hooks[0].url).toBe("https://example.com/hook");
});

it("should emit command-only per-hook fields (async/env/shell/statusMessage)", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
preToolUse: [
{
type: "command",
command: "echo cmd",
matcher: "Edit",
async: true,
env: { FOO: "bar" },
shell: "bash",
statusMessage: "Running...",
},
],
},
}),
}),
);

const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks,
validate: true,
});

const hook = JSON.parse(qwencodeHooks.getFileContent()).hooks.PreToolUse[0].hooks[0];
expect(hook.async).toBe(true);
expect(hook.env).toEqual({ FOO: "bar" });
expect(hook.shell).toBe("bash");
expect(hook.statusMessage).toBe("Running...");
// Http-only fields must never leak onto command hooks.
expect(hook.headers).toBeUndefined();
expect(hook.allowedEnvVars).toBeUndefined();
expect(hook.once).toBeUndefined();
});

it("should emit http-only per-hook fields (headers/allowedEnvVars/once/statusMessage)", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
preToolUse: [
{
type: "http",
url: "https://example.com/hook",
matcher: "Edit",
headers: { Authorization: "Bearer ${HOOK_API_KEY}" },
allowedEnvVars: ["HOOK_API_KEY"],
once: true,
statusMessage: "Calling remote...",
},
],
},
}),
}),
);

const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks,
validate: true,
});

const hook = JSON.parse(qwencodeHooks.getFileContent()).hooks.PreToolUse[0].hooks[0];
expect(hook.headers).toEqual({ Authorization: "Bearer ${HOOK_API_KEY}" });
expect(hook.allowedEnvVars).toEqual(["HOOK_API_KEY"]);
expect(hook.once).toBe(true);
expect(hook.statusMessage).toBe("Calling remote...");
// Command-only fields must never leak onto http hooks.
expect(hook.async).toBeUndefined();
expect(hook.env).toBeUndefined();
expect(hook.shell).toBeUndefined();
});

it("should not emit command-only fields onto http hooks or http-only fields onto command hooks", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
preToolUse: [
// Command hook authored with http-only fields -> dropped on emit.
{ type: "command", command: "echo cmd", matcher: "Edit", once: true },
// Http hook authored with command-only fields -> dropped on emit.
{
type: "http",
url: "https://example.com/hook",
matcher: "Edit",
async: true,
},
],
},
}),
}),
);

const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks,
validate: true,
});

const hooks = JSON.parse(qwencodeHooks.getFileContent()).hooks.PreToolUse[0].hooks;
const commandHook = hooks.find((h: { type: string }) => h.type === "command");
const httpHook = hooks.find((h: { type: string }) => h.type === "http");
expect(commandHook.once).toBeUndefined();
expect(httpHook.async).toBeUndefined();
});

it("should emit group-level sequential and top-level disableAllHooks", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
Expand Down Expand Up @@ -368,6 +478,108 @@ describe("QwencodeHooks", () => {
});
});

it("should import command-only per-hook fields (async/env/shell/statusMessage)", () => {
const qwencodeHooks = new QwencodeHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: "Edit",
hooks: [
{
type: "command",
command: "echo cmd",
async: true,
env: { FOO: "bar" },
shell: "bash",
statusMessage: "Running...",
},
],
},
],
},
}),
}),
);

const parsed = qwencodeHooks.toRulesyncHooks().getJson();
expect(parsed.hooks.preToolUse?.[0]).toEqual({
type: "command",
command: "echo cmd",
statusMessage: "Running...",
async: true,
env: { FOO: "bar" },
shell: "bash",
matcher: "Edit",
});
});

it("should import http-only per-hook fields (headers/allowedEnvVars/once/statusMessage)", () => {
const qwencodeHooks = new QwencodeHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: "Edit",
hooks: [
{
type: "http",
url: "https://example.com/hook",
headers: { Authorization: "Bearer ${HOOK_API_KEY}" },
allowedEnvVars: ["HOOK_API_KEY"],
once: true,
statusMessage: "Calling remote...",
},
],
},
],
},
}),
}),
);

const parsed = qwencodeHooks.toRulesyncHooks().getJson();
expect(parsed.hooks.preToolUse?.[0]).toEqual({
type: "http",
url: "https://example.com/hook",
statusMessage: "Calling remote...",
headers: { Authorization: "Bearer ${HOOK_API_KEY}" },
allowedEnvVars: ["HOOK_API_KEY"],
once: true,
matcher: "Edit",
});
});

it("should not import command-only fields onto http hooks or http-only fields onto command hooks", () => {
const qwencodeHooks = new QwencodeHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: "Edit",
hooks: [
// http hook carrying a command-only field upstream rejects.
{ type: "http", url: "https://example.com/hook", async: true },
// command hook carrying an http-only field upstream rejects.
{ type: "command", command: "echo cmd", once: true },
],
},
],
},
}),
}),
);

const parsed = qwencodeHooks.toRulesyncHooks().getJson();
const httpDef = parsed.hooks.preToolUse?.find((d) => d.type === "http");
const commandDef = parsed.hooks.preToolUse?.find((d) => d.type === "command");
expect(httpDef?.async).toBeUndefined();
expect(commandDef?.once).toBeUndefined();
});

it("should round-trip group-level sequential and top-level disableAllHooks", () => {
const qwencodeHooks = new QwencodeHooks(
createMockAiFileParams({
Expand Down
Loading
Loading