diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 2b6eef9b0..16523e7b4 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -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 `/.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` diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 2b6eef9b0..16523e7b4 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -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 `/.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` diff --git a/src/features/hooks/copilotcli-hooks.ts b/src/features/hooks/copilotcli-hooks.ts index dfbad3bdc..346a9bbde 100644 --- a/src/features/hooks/copilotcli-hooks.ts +++ b/src/features/hooks/copilotcli-hooks.ts @@ -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, @@ -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, }); diff --git a/src/features/hooks/qwencode-hooks.test.ts b/src/features/hooks/qwencode-hooks.test.ts index 7cdaea6b6..96e6dddc7 100644 --- a/src/features/hooks/qwencode-hooks.test.ts +++ b/src/features/hooks/qwencode-hooks.test.ts @@ -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({ @@ -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({ diff --git a/src/features/hooks/qwencode-hooks.ts b/src/features/hooks/qwencode-hooks.ts index 46dc34040..26e94407e 100644 --- a/src/features/hooks/qwencode-hooks.ts +++ b/src/features/hooks/qwencode-hooks.ts @@ -13,6 +13,7 @@ import { } from "../../types/hooks.js"; import { formatError } from "../../utils/error.js"; import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js"; +import { compact } from "../../utils/object.js"; import type { RulesyncHooks } from "./rulesync-hooks.js"; import { ToolHooks, @@ -22,6 +23,41 @@ import { type ToolHooksSettablePaths, } from "./tool-hooks.js"; +/** + * Build a single Qwen Code hook object from a canonical hook definition. + * Command-only fields (`async`/`env`/`shell`) are emitted only on command hooks + * and http-only fields (`headers`/`allowedEnvVars`/`once`) only on http hooks, + * matching upstream. `statusMessage` applies to both. + * https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md + */ +function canonicalDefToQwencodeHook( + def: HooksConfig["hooks"][string][number], +): Record { + const type = def.type ?? "command"; + const isHttp = type === "http"; + const isCommand = type === "command"; + return { + type, + ...compact({ + command: def.command, + url: def.url, + timeout: def.timeout, + name: def.name, + description: def.description, + statusMessage: def.statusMessage, + // Command-only per-hook fields (Qwen Code PR #2827) — upstream documents + // these for command hooks only, so gate on the command type explicitly. + async: isCommand ? def.async : undefined, + env: isCommand ? def.env : undefined, + shell: isCommand ? def.shell : undefined, + // Http-only per-hook fields (Qwen Code PR #2827). + headers: isHttp ? def.headers : undefined, + allowedEnvVars: isHttp ? def.allowedEnvVars : undefined, + once: isHttp ? def.once : undefined, + }), + }; +} + /** * Convert canonical hooks config to Qwen Code format. * Filters shared hooks to QWENCODE_HOOK_EVENTS, merges config.qwencode?.hooks, @@ -54,17 +90,7 @@ function canonicalToQwencodeHooks(config: HooksConfig): Record { - 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 }), - }; - }); + const hooks = defs.map(canonicalDefToQwencodeHook); // 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); @@ -94,6 +120,18 @@ const QwencodeHookEntrySchema = z.looseObject({ timeout: z.optional(z.number()), name: z.optional(z.string()), description: z.optional(z.string()), + // Progress text shown while the hook runs (command and http hooks). + statusMessage: z.optional(z.string()), + // Command-hook fields (Qwen Code PR #2827): background execution, extra + // subprocess env vars, and shell interpreter selection. + async: z.optional(z.boolean()), + env: z.optional(z.record(z.string(), z.string())), + shell: z.optional(z.string()), + // Http-hook fields (Qwen Code PR #2827): request headers, env-var allowlist + // for `${VAR}` interpolation, and once-per-session execution. + headers: z.optional(z.record(z.string(), z.string())), + allowedEnvVars: z.optional(z.array(z.string())), + once: z.optional(z.boolean()), }); /** @@ -116,23 +154,38 @@ function qwencodeMatcherEntryToCanonical( const defs: HooksConfig["hooks"][string] = []; const hooks = entry.hooks ?? []; const sequential = entry.sequential === true; + const matcher = + entry.matcher !== undefined && entry.matcher !== null && entry.matcher !== "" + ? entry.matcher + : undefined; for (const h of hooks) { - const command = h.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"; + const isHttp = hookType === "http"; + const isCommand = hookType === "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 }), + ...compact({ + command: h.command, + url: h.url, + timeout: h.timeout, + name: h.name, + description: h.description, + // `statusMessage` applies to both command and http hooks. + statusMessage: h.statusMessage, + // Command-only per-hook fields (Qwen Code PR #2827) — command type only. + async: isCommand ? h.async : undefined, + env: isCommand ? h.env : undefined, + shell: isCommand ? h.shell : undefined, + // Http-only per-hook fields (Qwen Code PR #2827). + headers: isHttp ? h.headers : undefined, + allowedEnvVars: isHttp ? h.allowedEnvVars : undefined, + once: isHttp ? h.once : undefined, + sequential: sequential ? true : undefined, + matcher, + }), }); } return defs; diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 729f7bbc6..30b3c4809 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -43,6 +43,26 @@ export const HookDefinitionSchema = z.looseObject({ // round-tripped through the canonical, flat list of definitions. // https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md sequential: z.optional(z.boolean()), + // Qwen Code per-hook fields (PR https://github.com/QwenLM/qwen-code/pull/2827). + // Command hooks: `async` runs the command in the background without blocking; + // `env` supplies extra environment variables to the subprocess; `shell` + // selects the interpreter (`"bash"` | `"powershell"`). + async: z.optional(z.boolean()), + // Map/string values use `safeString` so control characters (newline/CR/NUL) + // can't ride into a generated shell env var or HTTP header (header-splitting + // shape), consistent with how `command`/`url` are guarded. + env: z.optional(z.record(z.string(), safeString)), + shell: z.optional(safeString), + // `statusMessage` is the progress text shown while the hook runs; Qwen Code + // accepts it on both command and http hooks. + statusMessage: z.optional(safeString), + // HTTP hooks: `headers` sets request headers (with `${VAR}` interpolation); + // `allowedEnvVars` whitelists the env vars usable in URL/headers; `once` + // limits execution to a single invocation per event per session. + // https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md + headers: z.optional(z.record(z.string(), safeString)), + allowedEnvVars: z.optional(z.array(z.string())), + once: z.optional(z.boolean()), }); export type HookDefinition = z.infer; diff --git a/src/utils/object.test.ts b/src/utils/object.test.ts new file mode 100644 index 000000000..3f94ac014 --- /dev/null +++ b/src/utils/object.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { compact } from "./object.js"; + +describe("compact", () => { + it("drops undefined and null values", () => { + expect(compact({ a: 1, b: undefined, c: null, d: "x" })).toEqual({ a: 1, d: "x" }); + }); + + it("keeps falsy-but-defined values", () => { + expect(compact({ a: 0, b: "", c: false })).toEqual({ a: 0, b: "", c: false }); + }); + + it("returns an empty object when all values are nullish", () => { + expect(compact({ a: undefined, b: null })).toEqual({}); + }); +}); diff --git a/src/utils/object.ts b/src/utils/object.ts new file mode 100644 index 000000000..bed0edb31 --- /dev/null +++ b/src/utils/object.ts @@ -0,0 +1,16 @@ +/** + * Return a shallow copy of `obj` keeping only the entries whose value is + * neither `undefined` nor `null`. + * + * Used to assemble generated config objects without one conditional spread per + * optional field (which would otherwise exceed the lint complexity budget). + */ +export function compact>(obj: T): Partial { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined && value !== null) { + result[key] = value; + } + } + return result as Partial; +}