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 @@ -308,7 +308,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s

> **Note:** Grok CLI (xAI Grok Build) hooks are written to a dedicated, standalone `rulesync.json` that Grok auto-discovers from `.grok/hooks/*.json` (project) / `~/.grok/hooks/*.json` (global). The JSON shape is Claude-Code-compatible: each event nests under the top-level `hooks` key as a per-matcher array (`{ "hooks": { "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] } }`). All fourteen documented events map 1:1 onto canonical arms — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `permissionDenied` ⇄ `PermissionDenied`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `notification` ⇄ `Notification`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, and `postCompact` ⇄ `PostCompact`. A `matcher` (a regex tested against the tool name) is honored on the tool-name events (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionDenied`), matching Claude Code's semantics; a matcher on any other event is dropped with a warning. Commands are emitted verbatim (Grok documents no project-directory variable). See the [Grok hooks docs](https://docs.x.ai/build/features/hooks). Both handler types Grok defines round-trip: a `command` hook runs a command, and an `http` hook POSTs the payload to its `url`. A command hook's `env` map (upstream `HookConfig.env`, merged into the spawned command's `extra_env`) round-trips as well. Note that a `.rulesync/hooks.*` obtained with `rulesync fetch` can therefore point a Grok hook at any URL — read it before generating.

> **Note:** Kimi Code hooks are global-only and written as flat `[[hooks]]` entries in `~/.kimi-code/config.toml`, with `event`, `command`, and optional `matcher`/`timeout`. Rulesync maps fourteen canonical lifecycle events to Kimi's PascalCase names: `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `stop`, `stopFailure`, `notification`, `subagentStart`, `subagentStop`, `preCompact`, and `postCompact`. Kimi's native `PermissionResult`, `Interrupt`, `TurnStarted`, `UserPromptQueued`, `TaskStarted`, and `SessionHeartbeat` events have no canonical equivalents, but they can be written and preserved through the `kimi-code.hooks` override under their native names. (`TaskStarted` is deliberately not folded into the canonical `taskCreated`: it fires when a background task starts and matches on task kind, while `taskCreated` models Claude Code's blocking, matcher-less `TaskCreated` fired during task creation.) Only `command` hooks are emitted. Kimi normally runs these user-level hooks with each current session project as the working directory, which would let an unrelated repository substitute a relative script or influence commands such as `npm test`. Rulesync therefore wraps every generated command so it first changes to the trusted absolute directory containing the source `.rulesync/hooks.jsonc`; relative paths and project-aware commands consistently resolve against that source rather than whichever repository Kimi later opens. Kimi requires `timeout` to be an integer from 1 to 600 seconds; invalid canonical values are omitted with a warning so Kimi can still load the config. The shared TOML file is merged in place and never deleted. See the [Kimi Code hooks docs](https://moonshotai.github.io/kimi-code/en/customization/hooks.html).
> **Note:** Kimi Code hooks are global-only and written as flat `[[hooks]]` entries in `~/.kimi-code/config.toml`, with `event`, `command`, and optional `matcher`/`timeout`. Rulesync maps fourteen canonical lifecycle events to Kimi's PascalCase names: `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `stop`, `stopFailure`, `notification`, `subagentStart`, `subagentStop`, `preCompact`, and `postCompact`. Kimi's native `PermissionResult`, `Interrupt`, `TurnStarted`, `UserPromptQueued`, `TaskStarted`, and `SessionHeartbeat` events have no canonical equivalents, but they can be written and preserved through the `kimi-code.hooks` override under their native names. (`TaskStarted` is deliberately not folded into the canonical `taskCreated`: it fires when a background task starts and matches on task kind, while `taskCreated` models Claude Code's blocking, matcher-less `TaskCreated` fired during task creation.) Only `command` hooks are emitted. A matcher is dropped with a warning on `Stop`, `SessionHeartbeat`, and `Interrupt`, the three events whose Event Reference row documents the matcher as an empty string. Kimi treats `matcher` as a regular expression tested against the event target, so on these events it is tested against `""` and any non-trivial matcher never matches — such a hook silently never ran. Dropping the matcher is therefore a behavior change for existing configs: the hook now fires, which is what authoring it meant. Every other event matches a real value (`UserPromptSubmit` the submitted prompt text, `PermissionRequest` and `PermissionResult` the tool name, `PreCompact` the trigger, and so on), so matchers there are kept. Kimi normally runs these user-level hooks with each current session project as the working directory, which would let an unrelated repository substitute a relative script or influence commands such as `npm test`. Rulesync therefore wraps every generated command so it first changes to the trusted absolute directory containing the source `.rulesync/hooks.jsonc`; relative paths and project-aware commands consistently resolve against that source rather than whichever repository Kimi later opens. Kimi requires `timeout` to be an integer from 1 to 600 seconds; invalid canonical values are omitted with a warning so Kimi can still load the config. The shared TOML file is merged in place and never deleted. See the [Kimi Code hooks docs](https://moonshotai.github.io/kimi-code/en/customization/hooks.html).

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

Expand Down
86 changes: 86 additions & 0 deletions src/features/hooks/kimi-code-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,67 @@ describe("KimiCodeHooks", () => {
}
});

it("drops matchers on the events whose matcher is documented as an empty string", () => {
const logger = createMockLogger();
const hooks = KimiCodeHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks: makeRulesyncHooks({
version: 1,
hooks: {
stop: [{ type: "command", command: "./stop.sh", matcher: "Bash" }],
},
"kimi-code": {
hooks: {
SessionHeartbeat: [{ command: "./beat.sh", matcher: "Bash" }],
Interrupt: [{ command: "./interrupt.sh", matcher: "Bash" }],
},
},
}),
logger,
});

const entries = readEntries(hooks.getFileContent());
expect(entries.map((entry) => entry.event)).toEqual([
"Stop",
"SessionHeartbeat",
"Interrupt",
]);
for (const entry of entries) {
expect(entry.matcher).toBeUndefined();
}
for (const nativeEvent of ["Stop", "SessionHeartbeat", "Interrupt"]) {
expect(logger.warn).toHaveBeenCalledWith(
`matcher "Bash" on "${nativeEvent}" hook will be ignored — this event does not support matchers`,
);
}
});

it("keeps matchers on the events that match a real value", () => {
const logger = createMockLogger();
const hooks = KimiCodeHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks: makeRulesyncHooks({
version: 1,
hooks: {
// PermissionRequest matches the tool name, UserPromptSubmit matches
// the submitted prompt text, StopFailure matches the error type.
permissionRequest: [{ type: "command", command: "./perm.sh", matcher: "Bash" }],
beforeSubmitPrompt: [{ type: "command", command: "./prompt.sh", matcher: "deploy" }],
stopFailure: [{ type: "command", command: "./fail.sh", matcher: "timeout" }],
},
"kimi-code": {
// PermissionResult matches the tool name too.
hooks: { PermissionResult: [{ command: "./result.sh", matcher: "Bash" }] },
},
}),
logger,
});

const entries = readEntries(hooks.getFileContent());
expect(entries.map((entry) => entry.matcher)).toEqual(["Bash", "deploy", "timeout", "Bash"]);
expect(logger.warn).not.toHaveBeenCalled();
});

it("still skips event names Kimi Code does not accept", () => {
const logger = createMockLogger();
const hooks = KimiCodeHooks.fromRulesyncHooks({
Expand Down Expand Up @@ -122,6 +183,31 @@ describe("KimiCodeHooks", () => {
expect(config["kimi-code"].hooks.SessionHeartbeat).toHaveLength(1);
});

it("preserves a matcher on the matcher-less events, unlike generate", () => {
const hooks = new KimiCodeHooks({
outputRoot: testDir,
fileContent: [
"[[hooks]]",
'event = "Stop"',
'command = "./stop.sh"',
'matcher = "Bash"',
"",
"[[hooks]]",
'event = "Interrupt"',
'command = "./interrupt.sh"',
'matcher = "Bash"',
"",
].join("\n"),
validate: false,
});

// Import stays lossless so a hand-written matcher is never silently
// erased from the user's config; generate is the side that drops it.
const config = JSON.parse(hooks.toRulesyncHooks().getFileContent());
expect(config.hooks.stop?.[0]?.matcher).toBe("Bash");
expect(config["kimi-code"].hooks.Interrupt?.[0]?.matcher).toBe("Bash");
});

it("round-trips the 0.32.0 events through generate and import", () => {
const generated = KimiCodeHooks.fromRulesyncHooks({
outputRoot: testDir,
Expand Down
46 changes: 45 additions & 1 deletion src/features/hooks/kimi-code-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,50 @@ function stripTrustedDirectoryWrapper(command: string): string {
return windows?.[1] ?? command;
}

/**
* Native Kimi Code events whose Event Reference row lists the matcher as
* "Empty string". Kimi Code documents `matcher` as "a regular expression to
* filter event targets; if omitted, matches all", so on these events the
* regex is tested against `""`: any non-trivial matcher simply never matches
* and the hook silently never runs. Dropping the matcher is what makes the
* hook fire at all, which is the authored intent — these events have no target
* to filter on in the first place.
*
* Keyed on native names because the check runs after the canonical → native
* mapping: `SessionHeartbeat` and `Interrupt` have no canonical counterpart and
* are only reachable through a per-tool `kimi-code` override naming them
* directly.
*
* Deliberately narrower than Claude Code's equivalent set: Kimi Code's
* `UserPromptSubmit` matches the submitted prompt text, and `PermissionResult`
* matches the tool name, so a matcher on either is meaningful and is kept.
*
* @see https://moonshotai.github.io/kimi-code/en/customization/hooks.html
*/
const KIMI_CODE_NO_MATCHER_EVENTS = new Set(["Stop", "SessionHeartbeat", "Interrupt"]);

/** Resolve the `matcher` part of an emitted entry, dropping dead matchers. */
function resolveMatcherPart({
matcher,
nativeEvent,
logger,
}: {
matcher: string | undefined;
nativeEvent: string;
logger?: Logger;
}): { matcher?: string } {
if (!matcher) {
return {};
}
if (!KIMI_CODE_NO_MATCHER_EVENTS.has(nativeEvent)) {
return { matcher };
}
logger?.warn(
`matcher "${matcher}" on "${nativeEvent}" hook will be ignored — this event does not support matchers`,
);
return {};
}

function buildEffectiveHooks(
config: HooksConfig,
toolOverrideHooks: HooksConfig["hooks"] | undefined,
Expand Down Expand Up @@ -127,7 +171,7 @@ function canonicalToKimiCodeHooks({
command: definition.command,
trustedDirectory,
}),
...(definition.matcher && { matcher: definition.matcher }),
...resolveMatcherPart({ matcher: definition.matcher, nativeEvent, logger }),
...(validTimeout && timeout !== undefined && { timeout }),
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/generated/docs-content.ts

Large diffs are not rendered by default.

Loading