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 @@ -193,7 +193,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": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, and `notification` ⇄ `Notification`. Qwen-only events (`StopFailure`, `TodoCreated`, `TodoCompleted`) have no canonical equivalent. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting), only `command`-type hooks are supported, and other top-level keys in `settings.json` are preserved on round-trip. 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`. 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).

## `.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 @@ -193,7 +193,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": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, and `notification` ⇄ `Notification`. Qwen-only events (`StopFailure`, `TodoCreated`, `TodoCompleted`) have no canonical equivalent. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting), only `command`-type hooks are supported, and other top-level keys in `settings.json` are preserved on round-trip. 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`. 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).

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

Expand Down
7 changes: 6 additions & 1 deletion src/features/hooks/copilotcli-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,12 @@ function canonicalToCopilotCliHooks(
if (def.prompt === undefined || def.prompt === null) continue;
entries.push({ type: "prompt", prompt: def.prompt, ...rest });
} else if (hookType === "http") {
entries.push({ type: "http", ...timeoutPart, ...rest });
entries.push({
type: "http",
...(def.url !== undefined && def.url !== null && { url: def.url }),
...timeoutPart,
...rest,
});
} else {
const command = def.command;
entries.push({
Expand Down
138 changes: 138 additions & 0 deletions src/features/hooks/qwencode-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,78 @@ describe("QwencodeHooks", () => {
expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo override");
expect(parsed.hooks.SessionEnd[0].hooks[0].command).toBe("echo end");
});

it("should map the new TodoCreated, TodoCompleted, and StopFailure events", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
todoCreated: [{ command: "echo created" }],
todoCompleted: [{ command: "echo completed" }],
stopFailure: [{ command: "echo stop-failure" }],
},
}),
}),
);

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

const parsed = JSON.parse(qwencodeHooks.getFileContent());
expect(parsed.hooks.TodoCreated[0].hooks[0].command).toBe("echo created");
expect(parsed.hooks.TodoCompleted[0].hooks[0].command).toBe("echo completed");
expect(parsed.hooks.StopFailure[0].hooks[0].command).toBe("echo stop-failure");
});

it("should preserve the http hook type and its url", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
preToolUse: [{ type: "http", url: "https://example.com/hook", matcher: "Edit" }],
},
}),
}),
);

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

const parsed = JSON.parse(qwencodeHooks.getFileContent());
expect(parsed.hooks.PreToolUse[0].hooks[0].type).toBe("http");
expect(parsed.hooks.PreToolUse[0].hooks[0].url).toBe("https://example.com/hook");
});

it("should emit group-level sequential and top-level disableAllHooks", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
preToolUse: [{ command: "echo a", matcher: "Edit", sequential: true }],
},
qwencode: {
disableAllHooks: true,
},
}),
}),
);

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

const parsed = JSON.parse(qwencodeHooks.getFileContent());
expect(parsed.disableAllHooks).toBe(true);
expect(parsed.hooks.PreToolUse[0].sequential).toBe(true);
});
});

describe("toRulesyncHooks", () => {
Expand Down Expand Up @@ -253,6 +325,72 @@ describe("QwencodeHooks", () => {
expect(parsed.hooks.sessionStart?.[0]?.command).toBe("$GEMINI_PROJECT_DIR/echo start");
});

it("should import the new TodoCreated, TodoCompleted, and StopFailure events", () => {
const qwencodeHooks = new QwencodeHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
TodoCreated: [{ hooks: [{ command: "echo created" }] }],
TodoCompleted: [{ hooks: [{ command: "echo completed" }] }],
StopFailure: [{ hooks: [{ command: "echo stop-failure" }] }],
},
}),
}),
);

const parsed = qwencodeHooks.toRulesyncHooks().getJson();
expect(parsed.hooks.todoCreated?.[0]?.command).toBe("echo created");
expect(parsed.hooks.todoCompleted?.[0]?.command).toBe("echo completed");
expect(parsed.hooks.stopFailure?.[0]?.command).toBe("echo stop-failure");
});

it("should preserve the http hook type and its url on import", () => {
const qwencodeHooks = new QwencodeHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: "Edit",
hooks: [{ type: "http", url: "https://example.com/hook" }],
},
],
},
}),
}),
);

const parsed = qwencodeHooks.toRulesyncHooks().getJson();
expect(parsed.hooks.preToolUse?.[0]).toEqual({
type: "http",
url: "https://example.com/hook",
matcher: "Edit",
});
});

it("should round-trip group-level sequential and top-level disableAllHooks", () => {
const qwencodeHooks = new QwencodeHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
disableAllHooks: true,
hooks: {
PreToolUse: [
{
matcher: "Edit",
sequential: true,
hooks: [{ type: "command", command: "echo a" }],
},
],
},
}),
}),
);

const parsed = qwencodeHooks.toRulesyncHooks().getJson();
expect(parsed.qwencode?.disableAllHooks).toBe(true);
expect(parsed.hooks.preToolUse?.[0]?.sequential).toBe(true);
});

it("should ignore invalid entries", () => {
const qwencodeHooks = new QwencodeHooks(
createMockAiFileParams({
Expand Down
42 changes: 36 additions & 6 deletions src/features/hooks/qwencode-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,23 @@ function canonicalToQwencodeHooks(config: HooksConfig): Record<string, unknown[]
return {
type: def.type ?? "command",
...(def.command !== undefined && def.command !== null && { command: def.command }),
...(def.url !== undefined && def.url !== null && { url: def.url }),
...(def.timeout !== undefined && def.timeout !== null && { timeout: def.timeout }),
...(def.name !== undefined && def.name !== null && { name: def.name }),
...(def.description !== undefined &&
def.description !== null && { description: def.description }),
};
});
entries.push(matcherKey ? { matcher: matcherKey, hooks } : { hooks });
// A matcher group runs sequentially when any of its definitions opt in.
// Qwen Code defaults to parallel execution, so only emit when true.
const sequential = defs.some((def) => def.sequential === true);
const group: Record<string, unknown> = matcherKey
? { matcher: matcherKey, hooks }
: { hooks };
if (sequential) {
group.sequential = true;
}
entries.push(group);
}
qwencode[qwencodeEventName] = entries;
}
Expand All @@ -79,18 +89,22 @@ function canonicalToQwencodeHooks(config: HooksConfig): Record<string, unknown[]
const QwencodeHookEntrySchema = z.looseObject({
type: z.optional(z.string()),
command: z.optional(z.string()),
// Target URL for `http` hooks (the hook POSTs JSON to this URL).
url: z.optional(z.string()),
timeout: z.optional(z.number()),
name: z.optional(z.string()),
description: z.optional(z.string()),
});

/**
* A matcher group entry in a Qwen Code event array.
* Each event maps to an array of these groups.
* Each event maps to an array of these groups. The `sequential` flag (parallel
* by default) makes the group's hooks run one after another.
*/
const QwencodeMatcherEntrySchema = z.looseObject({
matcher: z.optional(z.string()),
hooks: z.optional(z.array(QwencodeHookEntrySchema)),
sequential: z.optional(z.boolean()),
});

/**
Expand All @@ -110,16 +124,22 @@ function qwencodeHooksToCanonical(qwencodeHooks: unknown): HooksConfig["hooks"]
if (!parseResult.success) continue;
const entry = parseResult.data;
const hooks = entry.hooks ?? [];
const sequential = entry.sequential === true;
for (const h of hooks) {
const command = h.command;
const hookType = h.type === "command" || h.type === "prompt" ? h.type : "command";
// Preserve the `http` transport (and its target URL) instead of
// collapsing every non-prompt hook to `command`.
const hookType =
h.type === "command" || h.type === "prompt" || h.type === "http" ? h.type : "command";
defs.push({
type: hookType,
...(command !== undefined && command !== null && { command }),
...(h.url !== undefined && h.url !== null && { url: h.url }),
...(h.timeout !== undefined && h.timeout !== null && { timeout: h.timeout }),
...(h.name !== undefined && h.name !== null && { name: h.name }),
...(h.description !== undefined &&
h.description !== null && { description: h.description }),
...(sequential && { sequential: true }),
...(entry.matcher !== undefined &&
entry.matcher !== null &&
entry.matcher !== "" && { matcher: entry.matcher }),
Expand Down Expand Up @@ -189,7 +209,12 @@ export class QwencodeHooks extends ToolHooks {
}
const config = rulesyncHooks.getJson();
const qwencodeHooks = canonicalToQwencodeHooks(config);
const merged = { ...settings, hooks: qwencodeHooks };
const merged: Record<string, unknown> = { ...settings, hooks: qwencodeHooks };
// Round-trip Qwen Code's top-level switch that disables every hook.
const disableAllHooks = config.qwencode?.disableAllHooks;
if (typeof disableAllHooks === "boolean") {
merged.disableAllHooks = disableAllHooks;
}
const fileContent = JSON.stringify(merged, null, 2);
return new QwencodeHooks({
outputRoot,
Expand All @@ -201,7 +226,7 @@ export class QwencodeHooks extends ToolHooks {
}

toRulesyncHooks(): RulesyncHooks {
let settings: { hooks?: unknown };
let settings: { hooks?: unknown; disableAllHooks?: unknown };
try {
settings = JSON.parse(this.getFileContent());
} catch (error) {
Expand All @@ -213,8 +238,13 @@ export class QwencodeHooks extends ToolHooks {
);
}
const hooks = qwencodeHooksToCanonical(settings.hooks);
// Preserve the top-level `disableAllHooks` switch under the qwencode namespace.
const canonical: HooksConfig =
typeof settings.disableAllHooks === "boolean"
? { version: 1, hooks, qwencode: { disableAllHooks: settings.disableAllHooks } }
: { version: 1, hooks };
return this.toRulesyncHooksDefault({
fileContent: JSON.stringify({ version: 1, hooks }, null, 2),
fileContent: JSON.stringify(canonical, null, 2),
});
}

Expand Down
Loading