diff --git a/cspell.json b/cspell.json index aeecf52bb..42b509eb4 100644 --- a/cspell.json +++ b/cspell.json @@ -23,7 +23,9 @@ ], "words": [ "PKCE", + "backgrounding", "bugbot", + "rewake", "slugifies", "fakehash", "fakeintegrityvalue", diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 19ab383d6..316aa331a 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -160,10 +160,14 @@ Example: - `cacheTtl` (optional): Number of seconds to cache a successful hook result. Forwarded to Kiro CLI as `cache_ttl_seconds`; `0` disables caching and Kiro never caches `AgentSpawn` hooks. - `failClosed` (optional): Boolean. When `true`, a hook failure (crash, timeout, invalid JSON) blocks the action instead of allowing it through. Passed through to Cursor's `.cursor/hooks.json` and to JetBrains Junie's `~/.junie/config.json` (as Junie's equivalently-named `blockOnError` flag). - `async` (optional): Boolean. When `true`, the hook command runs in the background without blocking. Forwarded to Qwen Code (`.qwen/settings.json`) and JetBrains Junie (`~/.junie/config.json`, same field name). -- `shell` (optional): Either `"bash"` or `"powershell"` — the only two interpreter values any tool accepts. Forwarded to Qwen Code command hooks. +- `shell` (optional): Either `"bash"` or `"powershell"` — the only two interpreter values any tool accepts. Forwarded to Qwen Code and Claude Code command hooks. Like `args`, `async` and `asyncRewake`, it is documented on command hooks only, so it is not emitted on a hook of another type. - `url` / `headers` / `allowedEnvVars` (optional, `http` hooks): the POST target URL, request headers (values support `$VAR` interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks. - `server` / `tool` / `input` (optional, `mcp_tool` hooks): the configured MCP server name, the tool to call on it, and the (arbitrary JSON) arguments, whose string values support `${path}` substitution from the hook input. Forwarded to Claude Code mcp_tool hooks. - `model` (optional, `prompt` / `agent` hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks. +- `args` (optional, `command` hooks): an argument list. When present — an empty list counts, and is the form the Claude Code docs use — the tool spawns `command` directly as an executable with these arguments. There is no shell, so Rulesync writes the project-directory prefix as the braced placeholder `${CLAUDE_PROJECT_DIR}/…` that Claude Code substitutes itself, rather than the quoted shell form. Forwarded to Claude Code and AugmentCode. Only `command` is prefixed; entries of `args` are passed through exactly as written. +- `asyncRewake` (optional): boolean. Like `async`, but wakes Claude when the hook exits with code 2. Forwarded to Claude Code command hooks. +- `once` (optional): boolean. Run the hook once per session, then remove it. Forwarded to Claude Code (honored in skill frontmatter; accepted but ignored in settings files) and Qwen Code http hooks. +- `continueOnBlock` (optional): boolean. Feed a blocking hook's rejection reason back to the model and continue the turn instead of ending it. Forwarded to Claude Code. - `commandWindows` (optional): a Windows-only override for `command`, so one hook set can be cross-platform. Forwarded to Codex CLI command hooks (`.codex/hooks.json`), which is the only tool that accepts it. - `statusMessage` (optional): the progress text shown while the hook runs. Forwarded to Qwen Code (command and http hooks) and to Codex CLI command hooks. - `if` (optional): a single permission rule (same syntax as `settings.json` permission rules, e.g. `"Bash(rm *)"`) that filters a hook by tool arguments in addition to the tool name. Forwarded to Claude Code, where it is evaluated only on tool events (`preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `permissionDenied`); it round-trips as an opaque string. @@ -220,10 +224,11 @@ Events present in the shared `hooks` block but unsupported by a given tool are s | `configChange` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `cwdChanged` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `fileChanged` | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | +| `directoryAdded` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `elicitation` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `elicitationResult` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | -> **Note:** `worktreeCreate`, `worktreeRemove`, `messageDisplay`, `postToolBatch`, `taskCreated`, `taskCompleted`, `teammateIdle`, and `cwdChanged` are Claude Code events that do not support the `matcher` field (they fire on every occurrence). Any matcher defined in the config is ignored for these events. +> **Note:** `beforeSubmitPrompt`, `stop`, `worktreeCreate`, `worktreeRemove`, `messageDisplay`, `postToolBatch`, `taskCreated`, `taskCompleted`, `teammateIdle`, and `cwdChanged` are the Claude Code events the [matcher table](https://code.claude.com/docs/en/hooks) lists as not supporting the `matcher` field (they fire on every occurrence). A matcher authored on one of them is dropped with a warning rather than written into `settings.json` to be ignored. `directoryAdded` is treated the same way for now: the event is announced in the 2.1.219 changelog but has no row in the docs' event table yet, so its matcher support is unknown. > **Note:** Rulesync implements OpenCode hooks as a plugin at `.opencode/plugins/rulesync-hooks.js` and Kilo hooks as a plugin at `.kilo/plugins/rulesync-hooks.js`, so importing from OpenCode/Kilo to rulesync is not supported. Both only support command-type hooks (not prompt-type). @@ -594,6 +599,7 @@ claudecode: # for claudecode-specific parameters - "pr_number" context: fork # (optional) set to "fork" to run the skill in a forked subagent context agent: code-reviewer # (optional) subagent type to use when context: fork + background: false # (optional, context: fork only) wait for the forked subagent in the invoking turn instead of backgrounding it (default true) shell: bash # (optional) shell for ! command blocks: bash (default) or powershell hooks: # (optional) hooks scoped to the skill's lifecycle (free-form per the Claude Code docs) PreToolUse: diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 0018a6b3a..2068ca4b2 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -160,10 +160,14 @@ Example: - `cacheTtl` (optional): Number of seconds to cache a successful hook result. Forwarded to Kiro CLI as `cache_ttl_seconds`; `0` disables caching and Kiro never caches `AgentSpawn` hooks. - `failClosed` (optional): Boolean. When `true`, a hook failure (crash, timeout, invalid JSON) blocks the action instead of allowing it through. Passed through to Cursor's `.cursor/hooks.json` and to JetBrains Junie's `~/.junie/config.json` (as Junie's equivalently-named `blockOnError` flag). - `async` (optional): Boolean. When `true`, the hook command runs in the background without blocking. Forwarded to Qwen Code (`.qwen/settings.json`) and JetBrains Junie (`~/.junie/config.json`, same field name). -- `shell` (optional): Either `"bash"` or `"powershell"` — the only two interpreter values any tool accepts. Forwarded to Qwen Code command hooks. +- `shell` (optional): Either `"bash"` or `"powershell"` — the only two interpreter values any tool accepts. Forwarded to Qwen Code and Claude Code command hooks. Like `args`, `async` and `asyncRewake`, it is documented on command hooks only, so it is not emitted on a hook of another type. - `url` / `headers` / `allowedEnvVars` (optional, `http` hooks): the POST target URL, request headers (values support `$VAR` interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks. - `server` / `tool` / `input` (optional, `mcp_tool` hooks): the configured MCP server name, the tool to call on it, and the (arbitrary JSON) arguments, whose string values support `${path}` substitution from the hook input. Forwarded to Claude Code mcp_tool hooks. - `model` (optional, `prompt` / `agent` hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks. +- `args` (optional, `command` hooks): an argument list. When present — an empty list counts, and is the form the Claude Code docs use — the tool spawns `command` directly as an executable with these arguments. There is no shell, so Rulesync writes the project-directory prefix as the braced placeholder `${CLAUDE_PROJECT_DIR}/…` that Claude Code substitutes itself, rather than the quoted shell form. Forwarded to Claude Code and AugmentCode. Only `command` is prefixed; entries of `args` are passed through exactly as written. +- `asyncRewake` (optional): boolean. Like `async`, but wakes Claude when the hook exits with code 2. Forwarded to Claude Code command hooks. +- `once` (optional): boolean. Run the hook once per session, then remove it. Forwarded to Claude Code (honored in skill frontmatter; accepted but ignored in settings files) and Qwen Code http hooks. +- `continueOnBlock` (optional): boolean. Feed a blocking hook's rejection reason back to the model and continue the turn instead of ending it. Forwarded to Claude Code. - `commandWindows` (optional): a Windows-only override for `command`, so one hook set can be cross-platform. Forwarded to Codex CLI command hooks (`.codex/hooks.json`), which is the only tool that accepts it. - `statusMessage` (optional): the progress text shown while the hook runs. Forwarded to Qwen Code (command and http hooks) and to Codex CLI command hooks. - `if` (optional): a single permission rule (same syntax as `settings.json` permission rules, e.g. `"Bash(rm *)"`) that filters a hook by tool arguments in addition to the tool name. Forwarded to Claude Code, where it is evaluated only on tool events (`preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `permissionDenied`); it round-trips as an opaque string. @@ -220,10 +224,11 @@ Events present in the shared `hooks` block but unsupported by a given tool are s | `configChange` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `cwdChanged` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `fileChanged` | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | +| `directoryAdded` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `elicitation` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `elicitationResult` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | -> **Note:** `worktreeCreate`, `worktreeRemove`, `messageDisplay`, `postToolBatch`, `taskCreated`, `taskCompleted`, `teammateIdle`, and `cwdChanged` are Claude Code events that do not support the `matcher` field (they fire on every occurrence). Any matcher defined in the config is ignored for these events. +> **Note:** `beforeSubmitPrompt`, `stop`, `worktreeCreate`, `worktreeRemove`, `messageDisplay`, `postToolBatch`, `taskCreated`, `taskCompleted`, `teammateIdle`, and `cwdChanged` are the Claude Code events the [matcher table](https://code.claude.com/docs/en/hooks) lists as not supporting the `matcher` field (they fire on every occurrence). A matcher authored on one of them is dropped with a warning rather than written into `settings.json` to be ignored. `directoryAdded` is treated the same way for now: the event is announced in the 2.1.219 changelog but has no row in the docs' event table yet, so its matcher support is unknown. > **Note:** Rulesync implements OpenCode hooks as a plugin at `.opencode/plugins/rulesync-hooks.js` and Kilo hooks as a plugin at `.kilo/plugins/rulesync-hooks.js`, so importing from OpenCode/Kilo to rulesync is not supported. Both only support command-type hooks (not prompt-type). @@ -594,6 +599,7 @@ claudecode: # for claudecode-specific parameters - "pr_number" context: fork # (optional) set to "fork" to run the skill in a forked subagent context agent: code-reviewer # (optional) subagent type to use when context: fork + background: false # (optional, context: fork only) wait for the forked subagent in the invoking turn instead of backgrounding it (default true) shell: bash # (optional) shell for ! command blocks: bash (default) or powershell hooks: # (optional) hooks scoped to the skill's lifecycle (free-form per the Claude Code docs) PreToolUse: diff --git a/src/features/hooks/claudecode-hooks.test.ts b/src/features/hooks/claudecode-hooks.test.ts index 9359cf507..3fcea8244 100644 --- a/src/features/hooks/claudecode-hooks.test.ts +++ b/src/features/hooks/claudecode-hooks.test.ts @@ -73,6 +73,140 @@ describe("ClaudecodeHooks", () => { expect(parsed.hooks.afterFileEdit).toBeUndefined(); }); + it("should emit the documented per-handler fields and the DirectoryAdded event", async () => { + await ensureDir(join(testDir, ".claude")); + await writeFileContent(join(testDir, ".claude", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + directoryAdded: [{ command: "on-add-dir.sh" }], + preToolUse: [ + { + command: "node", + args: ["./scripts/check.js", "--strict"], + async: true, + asyncRewake: true, + shell: "bash", + statusMessage: "Checking", + once: true, + continueOnBlock: true, + }, + ], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const claudecodeHooks = await ClaudecodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const parsed = JSON.parse(claudecodeHooks.getFileContent()); + expect(parsed.hooks.DirectoryAdded).toBeDefined(); + expect(parsed.hooks.PreToolUse[0].hooks[0]).toMatchObject({ + type: "command", + command: "node", + args: ["./scripts/check.js", "--strict"], + async: true, + asyncRewake: true, + shell: "bash", + statusMessage: "Checking", + once: true, + continueOnBlock: true, + }); + }); + + it("should use the braced placeholder for an exec-form command", async () => { + await ensureDir(join(testDir, ".claude")); + await writeFileContent(join(testDir, ".claude", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + preToolUse: [ + // No shell to strip the quotes the shell form adds, but Claude Code + // substitutes the braced placeholder itself. An empty `args` selects + // the exec form too — the docs' own example is `"args": []`. + { command: "./scripts/exec.sh", args: ["--strict"] }, + { command: "./scripts/empty-args.sh", args: [] }, + { command: "./scripts/shell-form.sh" }, + ], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const claudecodeHooks = await ClaudecodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const hooks = JSON.parse(claudecodeHooks.getFileContent()).hooks.PreToolUse[0].hooks; + expect(hooks[0].command).toBe("${CLAUDE_PROJECT_DIR}/scripts/exec.sh"); + expect(hooks[1].command).toBe("${CLAUDE_PROJECT_DIR}/scripts/empty-args.sh"); + expect(hooks[2].command).toBe('"$CLAUDE_PROJECT_DIR"/scripts/shell-form.sh'); + }); + + it("should keep command-only fields off non-command hooks", async () => { + await ensureDir(join(testDir, ".claude")); + await writeFileContent(join(testDir, ".claude", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + // `args`, `async`, `asyncRewake` and `shell` are documented on command + // hooks only; `statusMessage` and `once` are common to every type. + preToolUse: [ + { + type: "http", + url: "https://example.com/hook", + args: ["--strict"], + async: true, + asyncRewake: true, + shell: "bash", + statusMessage: "Calling", + once: true, + }, + ], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const claudecodeHooks = await ClaudecodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const hook = JSON.parse(claudecodeHooks.getFileContent()).hooks.PreToolUse[0].hooks[0]; + expect(hook.args).toBeUndefined(); + expect(hook.async).toBeUndefined(); + expect(hook.asyncRewake).toBeUndefined(); + expect(hook.shell).toBeUndefined(); + expect(hook.statusMessage).toBe("Calling"); + expect(hook.once).toBe(true); + }); + it("should emit http/mcp_tool/agent hooks with their type-specific payload fields", async () => { await ensureDir(join(testDir, ".claude")); await writeFileContent(join(testDir, ".claude", "settings.json"), JSON.stringify({})); @@ -510,6 +644,52 @@ describe("ClaudecodeHooks", () => { ); }); + it("should import DirectoryAdded and the per-handler fields, undoing the exec-form prefix", () => { + const claudecodeHooks = new ClaudecodeHooks({ + outputRoot: testDir, + relativeDirPath: ".claude", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ + hooks: { + DirectoryAdded: [{ hooks: [{ command: "on-add-dir.sh" }] }], + PreToolUse: [ + { + hooks: [ + { + type: "command", + command: "${CLAUDE_PROJECT_DIR}/scripts/check.js", + args: ["--strict"], + async: true, + asyncRewake: true, + shell: "bash", + statusMessage: "Checking", + once: true, + continueOnBlock: true, + }, + ], + }, + ], + }, + }), + validate: false, + }); + + const parsed = claudecodeHooks.toRulesyncHooks().getJson(); + expect(parsed.hooks.directoryAdded).toBeDefined(); + expect(parsed.hooks.preToolUse?.[0]).toMatchObject({ + type: "command", + // The braced placeholder generate wrote comes back as the relative path. + command: "./scripts/check.js", + args: ["--strict"], + async: true, + asyncRewake: true, + shell: "bash", + statusMessage: "Checking", + once: true, + continueOnBlock: true, + }); + }); + it("should convert Claude PascalCase hooks to canonical camelCase", () => { const claudecodeHooks = new ClaudecodeHooks({ outputRoot: testDir, @@ -882,6 +1062,47 @@ describe("ClaudecodeHooks", () => { expect(parsed.hooks.MessageDisplay[0].matcher).toBeUndefined(); }); + it("should drop a matcher on UserPromptSubmit and Stop, which take none", async () => { + // Both are in the docs' no-matcher table; before, a matcher authored on + // them was written into settings.json and silently ignored upstream. + await ensureDir(join(testDir, ".claude")); + await writeFileContent(join(testDir, ".claude", "settings.json"), JSON.stringify({})); + + const warnSpy = vi.spyOn(logger, "warn"); + + const config = { + version: 1, + hooks: { + beforeSubmitPrompt: [{ command: "prompt.sh", matcher: "*.js" }], + stop: [{ command: "stop.sh", matcher: "*.ts" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const claudecodeHooks = await ClaudecodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + logger, + }); + + const parsed = JSON.parse(claudecodeHooks.getFileContent()); + expect(parsed.hooks.UserPromptSubmit[0].matcher).toBeUndefined(); + expect(parsed.hooks.Stop[0].matcher).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('matcher "*.js" on "beforeSubmitPrompt" hook will be ignored'), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('matcher "*.ts" on "stop" hook will be ignored'), + ); + }); + it("should warn when matcher is defined on worktree events", async () => { await ensureDir(join(testDir, ".claude")); await writeFileContent(join(testDir, ".claude", "settings.json"), JSON.stringify({})); diff --git a/src/features/hooks/claudecode-hooks.ts b/src/features/hooks/claudecode-hooks.ts index 1f3e8d688..d64969505 100644 --- a/src/features/hooks/claudecode-hooks.ts +++ b/src/features/hooks/claudecode-hooks.ts @@ -41,6 +41,12 @@ const CLAUDE_NO_MATCHER_EVENTS: ReadonlySet = new Set([ "taskCompleted", "teammateIdle", "cwdChanged", + "beforeSubmitPrompt", + "stop", + // Not in the docs' matcher table yet — the event is only announced in the + // 2.1.219 changelog. Listed here so a matcher authored on it is dropped with + // the usual warning rather than written into settings.json to be ignored. + "directoryAdded", ]); const CLAUDE_CONVERTER_CONFIG: ToolHooksConverterConfig = { @@ -60,7 +66,27 @@ const CLAUDE_CONVERTER_CONFIG: ToolHooksConverterConfig = { // Claude Code's tool-event `if` condition (a single permission rule) is // Claude-Code-specific and round-trips as an opaque string. // https://code.claude.com/docs/en/hooks - stringPassthroughFields: [{ canonical: "if", tool: "if" }], + stringPassthroughFields: [ + { canonical: "if", tool: "if" }, + // Common to every handler type: the spinner label shown while it runs. + { canonical: "statusMessage", tool: "statusMessage" }, + // Command hooks: the interpreter, `"bash"` or `"powershell"`. + { canonical: "shell", tool: "shell", commandOnly: true }, + ], + // `once` is common to every handler type (honored in skill frontmatter only, + // but accepted everywhere); `async` / `asyncRewake` are command-hook flags, + // and `continueOnBlock` feeds a blocking hook's reason back to the model. + // https://code.claude.com/docs/en/hooks + booleanPassthroughFields: [ + { canonical: "once", tool: "once" }, + { canonical: "async", tool: "async", commandOnly: true }, + { canonical: "asyncRewake", tool: "asyncRewake", commandOnly: true }, + { canonical: "continueOnBlock", tool: "continueOnBlock" }, + ], + // Command hooks: the exec form. With `args` present, `command` is resolved as + // an executable and spawned directly, so no shell is involved and a path + // never needs quoting. + arrayPassthroughFields: [{ canonical: "args", tool: "args", commandOnly: true }], }; export class ClaudecodeHooks extends ToolHooks { diff --git a/src/features/hooks/tool-hooks-converter.ts b/src/features/hooks/tool-hooks-converter.ts index 74755cb69..917935ac0 100644 --- a/src/features/hooks/tool-hooks-converter.ts +++ b/src/features/hooks/tool-hooks-converter.ts @@ -44,8 +44,10 @@ export type ToolHooksConverterConfig = { * any other value is ignored so a malformed field can't leak through. */ booleanPassthroughFields?: ReadonlyArray<{ - readonly canonical: "failClosed" | "async"; + readonly canonical: "failClosed" | "async" | "once" | "asyncRewake" | "continueOnBlock"; readonly tool: string; + /** Emit only on `command` hooks, for a field the tool documents there only. */ + readonly commandOnly?: boolean; }>; /** * Per-hook string fields to carry through the round-trip, each mapping a @@ -55,8 +57,10 @@ export type ToolHooksConverterConfig = { * for tool-specific opaque strings such as Claude Code's `if` condition. */ stringPassthroughFields?: ReadonlyArray<{ - readonly canonical: "if" | "statusMessage" | "commandWindows"; + readonly canonical: "if" | "statusMessage" | "commandWindows" | "shell"; readonly tool: string; + /** Emit only on `command` hooks, for a field the tool documents there only. */ + readonly commandOnly?: boolean; }>; /** * Per-hook string-array fields to carry through the round-trip. Only arrays @@ -66,6 +70,8 @@ export type ToolHooksConverterConfig = { arrayPassthroughFields?: ReadonlyArray<{ readonly canonical: "args"; readonly tool: string; + /** Emit only on `command` hooks, for a field the tool documents there only. */ + readonly commandOnly?: boolean; }>; /** * Fields that live on the *matcher group* rather than on a hook. They are @@ -139,6 +145,15 @@ function groupDefinitionsByMatcher( return byMatcher; } +/** `$CLAUDE_PROJECT_DIR` -> `${CLAUDE_PROJECT_DIR}`, the form the tool substitutes. */ +function bracePlaceholder(projectDirVar: string): string { + return `\${${projectDirVar.replace(/^\$/, "")}}`; +} + +function stripSurroundingQuotes(value: string): string { + return value.replace(/^(["'])(.*)\1$/, "$2").replace(/^["']/, ""); +} + /** * Apply the optional project directory variable prefix to a command string. */ @@ -158,6 +173,12 @@ function applyCommandPrefix({ (posix.isAbsolute(unquotedCommand) || win32.isAbsolute(unquotedCommand) || unquotedCommand.startsWith("~/")); + // The exec form is `args` being *present* — an empty array selects it too, + // and the docs' own example uses `"args": []`. Only checked for tools that + // actually emit `args`; for the rest `command` stays a shell string. + const emitsArgs = + converterConfig.arrayPassthroughFields?.some(({ canonical }) => canonical === "args") ?? false; + const isExecForm = emitsArgs && Array.isArray(def.args); const shouldPrefix = converterConfig.projectDirVar !== "" && typeof trimmedCommand === "string" && @@ -176,6 +197,12 @@ function applyCommandPrefix({ // inside it so the quoted project root and quoted relative path concatenate // into one shell word: "$PROJECT_DIR"/"scripts/my hook.sh". const relativeCommand = trimmedCommand.replace(/^(["'])\.\//, "$1").replace(/^\.\//, ""); + if (isExecForm) { + // No shell here, so the quotes would become part of the file name. The + // braced placeholder is what the tool substitutes itself, and it needs no + // quoting because each argument is passed through verbatim. + return `${bracePlaceholder(converterConfig.projectDirVar)}/${stripSurroundingQuotes(relativeCommand)}`; + } return `"${converterConfig.projectDirVar}"/${relativeCommand}`; } @@ -186,14 +213,19 @@ function applyCommandPrefix({ */ function emitBooleanPassthroughFields({ def, + hookType, converterConfig, }: { def: HooksConfig["hooks"][string][number]; + hookType: HookType; converterConfig: ToolHooksConverterConfig; }): Record { return Object.fromEntries( (converterConfig.booleanPassthroughFields ?? []) - .filter(({ canonical }) => typeof def[canonical] === "boolean") + .filter(({ canonical, commandOnly }) => { + if (commandOnly === true && hookType !== "command") return false; + return typeof def[canonical] === "boolean"; + }) .map(({ canonical, tool }) => [tool, def[canonical] as boolean]), ); } @@ -223,14 +255,19 @@ function importBooleanPassthroughFields({ */ function emitStringPassthroughFields({ def, + hookType, converterConfig, }: { def: HooksConfig["hooks"][string][number]; + hookType: HookType; converterConfig: ToolHooksConverterConfig; }): Record { return Object.fromEntries( (converterConfig.stringPassthroughFields ?? []) - .filter(({ canonical }) => typeof def[canonical] === "string" && def[canonical] !== "") + .filter(({ canonical, commandOnly }) => { + if (commandOnly === true && hookType !== "command") return false; + return typeof def[canonical] === "string" && def[canonical] !== ""; + }) .map(({ canonical, tool }) => [tool, def[canonical] as string]), ); } @@ -259,14 +296,19 @@ function importStringPassthroughFields({ */ function emitArrayPassthroughFields({ def, + hookType, converterConfig, }: { def: HooksConfig["hooks"][string][number]; + hookType: HookType; converterConfig: ToolHooksConverterConfig; }): Record { return Object.fromEntries( (converterConfig.arrayPassthroughFields ?? []) - .filter(({ canonical }) => isStringArray(def[canonical])) + .filter(({ canonical, commandOnly }) => { + if (commandOnly === true && hookType !== "command") return false; + return isStringArray(def[canonical]); + }) .map(({ canonical, tool }) => [tool, def[canonical] as string[]]), ); } @@ -420,9 +462,9 @@ function buildToolHooks({ // Spread the boolean and string passthrough fields first so the // explicitly-handled core fields below always win: a misconfigured `tool` // name (e.g. mapping onto "type"/"command") can never silently shadow them. - ...emitBooleanPassthroughFields({ def, converterConfig }), - ...emitStringPassthroughFields({ def, converterConfig }), - ...emitArrayPassthroughFields({ def, converterConfig }), + ...emitBooleanPassthroughFields({ def, hookType, converterConfig }), + ...emitStringPassthroughFields({ def, hookType, converterConfig }), + ...emitArrayPassthroughFields({ def, hookType, converterConfig }), type: hookType, ...(command !== undefined && command !== null && { command }), ...(def.timeout !== undefined && def.timeout !== null && { timeout: def.timeout }), @@ -531,6 +573,12 @@ function stripCommandPrefix({ if (cmd.startsWith(quotedPrefix)) { return `./${cmd.slice(quotedPrefix.length)}`; } + // The exec form's braced placeholder, so a generated hook round-trips back to + // the relative command it was authored as. + const bracedPrefix = `${bracePlaceholder(converterConfig.projectDirVar)}/`; + if (cmd.startsWith(bracedPrefix)) { + return `./${cmd.slice(bracedPrefix.length)}`; + } if (cmd.includes(`${converterConfig.projectDirVar}/`)) { const escapedVar = converterConfig.projectDirVar.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return cmd.replace(new RegExp(`^${escapedVar}\\/?`), "./"); diff --git a/src/features/skills/claudecode-skill.test.ts b/src/features/skills/claudecode-skill.test.ts index 643874184..2682d04d0 100644 --- a/src/features/skills/claudecode-skill.test.ts +++ b/src/features/skills/claudecode-skill.test.ts @@ -308,6 +308,30 @@ describe("ClaudecodeSkill", () => { expect(rulesyncFrontmatter.claudecode).toEqual({ model: "opus" }); }); + it("should round-trip background: false, the meaningful value of the field", () => { + // `background` defaults to true, so a truthy-only copy would drop the one + // value worth writing. https://code.claude.com/docs/en/skills + const skill = new ClaudecodeSkill({ + dirName: "forked-skill", + frontmatter: { + name: "forked-skill", + description: "Runs in a forked subagent", + context: "fork", + background: false, + }, + body: "Body", + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + expect(rulesyncSkill.getFrontmatter().claudecode).toEqual({ + context: "fork", + background: false, + }); + expect(ClaudecodeSkill.fromRulesyncSkill({ rulesyncSkill }).getFrontmatter().background).toBe( + false, + ); + }); + it("should round-trip the extended Claude Code skill frontmatter fields", () => { const frontmatter: ClaudecodeSkillFrontmatter = { name: "extended-skill", diff --git a/src/features/skills/claudecode-skill.ts b/src/features/skills/claudecode-skill.ts index ce6a8597d..a3bcbc22b 100644 --- a/src/features/skills/claudecode-skill.ts +++ b/src/features/skills/claudecode-skill.ts @@ -48,6 +48,11 @@ export const ClaudecodeSkillFrontmatterSchema = z.looseObject({ context: z.optional(z.string()), // Which subagent type to use when `context: fork` is set. agent: z.optional(z.string()), + // Only applies with `context: fork`. `false` waits for the forked subagent's + // result in the invoking turn instead of running it in the background. + // Defaults to `true`, so `false` is the meaningful value to write. + // https://code.claude.com/docs/en/skills + background: z.optional(z.boolean()), // Hooks scoped to the skill's lifecycle (free-form per the docs). hooks: z.optional(z.looseObject({})), // Shell for `!` command blocks in the skill (`bash` default or `powershell`). @@ -92,6 +97,9 @@ function buildClaudecodeSkillFrontmatter({ shell: section.shell, }; const definedFields: Record = { + // Defined rather than truthy: `background: false` is the whole point of the + // field, and a truthy check would drop it. + background: section.background, arguments: section.arguments, hooks: section.hooks, "disable-model-invocation": resolvedDisableModelInvocation, @@ -219,6 +227,7 @@ export class ClaudecodeSkill extends ToolSkill { ...(frontmatter.arguments !== undefined && { arguments: frontmatter.arguments }), ...(frontmatter.context && { context: frontmatter.context }), ...(frontmatter.agent && { agent: frontmatter.agent }), + ...(frontmatter.background !== undefined && { background: frontmatter.background }), ...(frontmatter.hooks !== undefined && { hooks: frontmatter.hooks }), ...(frontmatter.shell && { shell: frontmatter.shell }), ...(frontmatter["disable-model-invocation"] !== undefined && { diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index 7d1f89b08..b251ec4cd 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -31,6 +31,7 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ arguments: z.optional(z.union([z.string(), z.array(z.string())])), context: z.optional(z.string()), agent: z.optional(z.string()), + background: z.optional(z.boolean()), hooks: z.optional(z.looseObject({})), shell: z.optional(z.string()), "disable-model-invocation": z.optional(z.boolean()), @@ -274,6 +275,7 @@ export type RulesyncSkillFrontmatterInput = { arguments?: string | string[]; context?: string; agent?: string; + background?: boolean; hooks?: Record; shell?: string; "disable-model-invocation"?: boolean; diff --git a/src/types/hooks.ts b/src/types/hooks.ts index f496a9715..7c2ce0be8 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -120,6 +120,14 @@ export const HookDefinitionSchema = z.looseObject({ // `.codex/hooks.json`, which is the file rulesync writes. // https://learn.chatgpt.com/docs/hooks commandWindows: z.optional(safeString), + // Claude Code command hooks: `asyncRewake` runs the hook in the background + // and wakes Claude on exit code 2 (it implies `async`). + // https://code.claude.com/docs/en/hooks + asyncRewake: z.optional(z.boolean()), + // Claude Code: feed a blocking hook's rejection reason back to the model and + // continue the turn instead of ending it. Added for `PostToolUse` in 2.1.139. + // https://code.claude.com/docs/en/hooks + continueOnBlock: z.optional(z.boolean()), }); export type HookDefinition = z.infer; @@ -176,6 +184,7 @@ export const HOOK_EVENTS = [ "configChange", "cwdChanged", "fileChanged", + "directoryAdded", "elicitation", "elicitationResult", ] as const; @@ -243,6 +252,12 @@ export const CLAUDE_HOOK_EVENTS: readonly HookEvent[] = [ "configChange", "cwdChanged", "fileChanged", + // Announced in the 2.1.219 changelog — "fires after `/add-dir` or the SDK + // `register_repo_root` control request registers a new working directory + // mid-session" — but not yet in the docs' event table, so its matcher support + // is unknown and it is treated as no-matcher (see CLAUDE_NO_MATCHER_EVENTS). + // https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md + "directoryAdded", "postCompact", "elicitation", "elicitationResult", @@ -933,6 +948,7 @@ export const CANONICAL_TO_CLAUDE_EVENT_NAMES: Record = { configChange: "ConfigChange", cwdChanged: "CwdChanged", fileChanged: "FileChanged", + directoryAdded: "DirectoryAdded", postCompact: "PostCompact", elicitation: "Elicitation", elicitationResult: "ElicitationResult",