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

> **Note:** Vibe Code (mistral-vibe) hooks are written to a dedicated `.vibe/hooks.toml` (project) / `~/.vibe/hooks.toml` (global) as a flat `[[hooks]]` TOML array. Each entry carries its own event `type`, a `command`, and optional `name`, `timeout` (seconds, default 60), and `description`. Tool-hook entries (`pre_tool` / `post_tool`) additionally carry a tool-name `match` (an fnmatch glob like `bash`/`mcp_*` or a `re:`-prefixed regex, case-insensitive — the canonical `matcher` field; `*` means "any tool") and an optional `strict` flag; `post_agent` carries neither. Three events are supported — `preToolUse` ⇄ `pre_tool`, `postToolUse` ⇄ `post_tool`, and `stop` ⇄ `post_agent` (fires after every assistant turn that ends without pending tool calls). Only `command`-type hooks are emitted. Vibe v2.21.0 graduated hooks from experimental: it renamed all three types (`before_tool` → `pre_tool`, `after_tool` → `post_tool`, `post_agent_turn` → `post_agent`) and removed the `enable_experimental_hooks` flag, so declaring a hook is enough and Rulesync no longer writes an auxiliary `.vibe/config.toml`. `HookType` is a strict enum upstream, so an entry using an old name is rejected outright.

> **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": "..." } ] } ] } }`). Eleven lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, and `afterShellExecution` ⇄ `AfterShellExecution` — matching Goose's `HookEvent` enum exactly (it has no `SubagentStart`/`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:** 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": "..." } ] } ] } }`). Eleven lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, and `afterShellExecution` ⇄ `AfterShellExecution` — matching Goose's `HookEvent` enum exactly (it has no `SubagentStart`/`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. One exception applies to the matcher: Goose compiles it with `Regex::new` and **silently drops the whole rule** when compilation fails, and the canonical catch-all `"*"` is not a valid regex, so it is emitted as _no_ matcher (which Goose treats as match-all) instead of verbatim.

> **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. Twenty-one lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `postToolBatch` ⇄ `PostToolBatch`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `userPromptExpansion` ⇄ `UserPromptExpansion`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `permissionDenied` ⇄ `PermissionDenied`, `notification` ⇄ `Notification`, `instructionsLoaded` ⇄ `InstructionsLoaded`, `todoCreated` ⇄ `TodoCreated`, `todoCompleted` ⇄ `TodoCompleted`, and `messageDisplay` ⇄ `MessageDisplay` (fires repeatedly as the reply streams; added in Qwen Code v0.19.10). Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt` (which carries the required `prompt` body — with `$ARGUMENTS` interpolation — and an optional `model` override, both round-tripped; a prompt hook without a `prompt` is warned about at generate time since Qwen Code loads it and fails it at runtime), `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).

Expand Down Expand Up @@ -995,7 +995,7 @@ Only a string `clientId` is duplicated (a non-string value would not be a usable

Goose configures MCP servers in two locations depending on scope:

- **Global (`--global`):** MCP servers are written as **extensions** in the shared user config `~/.config/goose/config.yaml`. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: `command` → `cmd` (an array `command` folds its tail into `args`), `env` → `envs`, `url`/`httpUrl` → `uri`, and `disabled: true` → `enabled: false`. The `type` is derived — `command` ⇒ `stdio`, a remote `url` ⇒ `streamable_http` (or `sse` when the canonical `type` is `sse`). Each extension also carries its own `name`. Generation merges the `extensions:` block into the existing `config.yaml`, preserving other Goose settings (model, provider, ...), and the file is never deleted. This location supports **both stdio and remote** (http/sse) servers.
- **Global (`--global`):** MCP servers are written as **extensions** in the shared user config `~/.config/goose/config.yaml`. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: `command` → `cmd` (an array `command` folds its tail into `args`), `env` → `envs`, `url`/`httpUrl` → `uri`, and `disabled: true` → `enabled: false`. The `type` is derived — `command` ⇒ `stdio`, a remote `url` ⇒ `streamable_http` (or `sse` when the canonical `type` is `sse`). Each extension also carries its own `name`. A canonical server with no `command` and no `url` is **skipped with a warning** rather than written as a `stdio` extension with no `cmd`, which Goose cannot start. Generation merges the `extensions:` block into the existing `config.yaml`, preserving other Goose settings (model, provider, ...), and the file is never deleted. The `extensions:` map itself is co-owned: Goose's own `builtin`/`platform`/`frontend`/`inline_python` extensions (`developer`, `memory`, ...) live there alongside MCP servers and are **carried over untouched**, as is any entry Rulesync cannot read as an MCP server, while every entry it positively identifies as one (`stdio`/`streamable_http`/`sse`) is Rulesync-owned, so a server deleted from `.rulesync/.mcp.json` is retracted with a warning naming it. Import mirrors this: a non-MCP extension type is skipped with a warning instead of being imported as a server (importing a `builtin` used to strip the type that makes it work). This location supports **both stdio and remote** (http/sse) servers.
- **Project:** Goose v1.39.0+ discovers MCP extensions in **open plugins** at `<project>/.agents/plugins/<name>/.mcp.json` (and `~/.agents/plugins/<name>/.mcp.json` at user scope). Rulesync emits `.agents/plugins/rulesync/.mcp.json`, reusing the same `.agents/plugins/rulesync/` tree already used for Goose hooks. The manifest uses the **Claude-style** `{ "mcpServers": { "<name>": { "command", "args", "env", "cwd" } } }` shape. This manifest is **stdio-only** — it cannot express `url`/`headers`, so **remote (http/sse) servers are skipped with a warning** in project mode; sync them with `--global` to `~/.config/goose/config.yaml` instead. The `.mcp.json` manifest is owned by Rulesync and is deleted when no servers remain.

See the [Goose extensions docs](https://block.github.io/goose/docs/getting-started/using-extensions/) and [open-plugins MCP PR #9471](https://github.com/block/goose/pull/9471).
Expand Down
34 changes: 34 additions & 0 deletions src/features/hooks/goose-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,40 @@ describe("GooseHooks", () => {
expect(parsed.hooks.AfterFileEdit[0].matcher).toBe("\\.rs$");
});

it("should emit a canonical '*' matcher as no matcher", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
preToolUse: [
{ command: "all-tools.sh", matcher: "*" },
{ command: "also-all-tools.sh" },
{ command: "shell-only.sh", matcher: "developer__shell" },
],
},
}),
}),
);

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

const parsed = JSON.parse(gooseHooks.getFileContent());
// Goose compiles `matcher` as a regex and drops the whole rule when it
// fails to compile, so "*" must not reach the file; it collapses into the
// matcher-less group rather than producing a second bare entry.
expect(parsed.hooks.PreToolUse).toHaveLength(2);
expect(parsed.hooks.PreToolUse[0].matcher).toBeUndefined();
expect(parsed.hooks.PreToolUse[0].hooks.map((h: { command: string }) => h.command)).toEqual([
"all-tools.sh",
"also-all-tools.sh",
]);
expect(parsed.hooks.PreToolUse[1].matcher).toBe("developer__shell");
});

it("should map all Goose lifecycle events", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
Expand Down
6 changes: 6 additions & 0 deletions src/features/hooks/goose-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const GOOSE_CONVERTER_CONFIG: ToolHooksConverterConfig = {
toolToCanonicalEventNames: GOOSE_TO_CANONICAL_EVENT_NAMES,
projectDirVar: "",
supportedHookTypes: new Set(["command"]),
// Goose compiles `matcher` with `Regex::new` and drops the whole rule when
// compilation fails ("Invalid hook matcher regex; skipping rule"). A bare
// `"*"` is not a valid regex, so emitting the canonical catch-all verbatim
// produces a hook that never runs; an absent matcher means match-all.
// https://github.com/aaif-goose/goose/pull/10467
wildcardMatcherMeansAll: true,
};

/**
Expand Down
15 changes: 14 additions & 1 deletion src/features/hooks/tool-hooks-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ export type ToolHooksConverterConfig = {
* will be silently dropped with a warning during export.
*/
noMatcherEvents?: ReadonlySet<string>;
/**
* When true, the canonical catch-all matcher `"*"` is exported as *no*
* matcher instead of verbatim. Set it for a tool that compiles `matcher` as a
* regular expression (where `"*"` is a syntax error) and treats an absent
* matcher as match-all — emitting `"*"` there produces a rule the tool
* refuses to compile and drops. Tools that generate code from the matcher
* rewrite `"*"` to `".*"` in their own generators instead.
*/
wildcardMatcherMeansAll?: boolean;
};

/**
Expand Down Expand Up @@ -189,7 +198,11 @@ function groupDefinitionsByMatcher({
);
const byMatcher = new Map<string, { matcher: string; defs: HooksConfig["hooks"][string] }>();
for (const def of definitions) {
const matcher = def.matcher ?? "";
const rawMatcher = def.matcher ?? "";
// Normalized here rather than at emission so a `"*"` group and an
// already-matcher-less group collapse into one entry instead of producing
// two indistinguishable entries for the same event.
const matcher = converterConfig.wildcardMatcherMeansAll && rawMatcher === "*" ? "" : rawMatcher;
const key = [
matcher,
// A value the tool cannot express is never emitted, so keying on it would
Expand Down
Loading
Loading