From 5ef6dc931bd0e6ef14190dabcf277779c579f038 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Mon, 8 Jun 2026 06:56:15 -0700 Subject: [PATCH 1/2] feat: follow up Claude Code, Codex CLI, and Goose upstream updates Resolves three maintainer-scrap follow-ups in one batch: #1740 (Claude Code): add ws + streamable-http MCP transports to McpServerSchema type/transport enums; add the messageDisplay canonical hook event with the Claude MessageDisplay mapping; add first-class disallowed-tools frontmatter to Claude Code slash commands and skills (round-trips via the claudecode section). #1741 (Codex CLI): emit an agents/openai.yaml sidecar next to SKILL.md from a new codexcli interface/policy/dependencies frontmatter section, route the user-facing short description to interface.short_description, and read the sidecar back on import. SKILL.md frontmatter stays name + description only; legacy metadata.short-description is preserved. #1742 (Goose): add a Goose lifecycle hooks adapter that writes .agents/plugins/rulesync/hooks/hooks.json (project + global), register it in hooks-processor with GOOSE_HOOK_EVENTS and canonical mappings, gitignore the plugin dir, and sync README/docs. Closes #1740 Closes #1741 Closes #1742 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + README.md | 2 +- docs/reference/file-formats.md | 94 ++++--- docs/reference/supported-tools.md | 2 +- skills/rulesync/file-formats.md | 94 ++++--- skills/rulesync/supported-tools.md | 2 +- src/cli/commands/gitignore-entries.ts | 2 + src/e2e/e2e-hooks.spec.ts | 25 +- .../commands/claudecode-command.test.ts | 27 ++ src/features/commands/claudecode-command.ts | 3 + src/features/hooks/goose-hooks.test.ts | 232 ++++++++++++++++++ src/features/hooks/goose-hooks.ts | 143 +++++++++++ src/features/hooks/hooks-processor.test.ts | 4 + src/features/hooks/hooks-processor.ts | 20 ++ src/features/skills/claudecode-skill.test.ts | 25 ++ src/features/skills/claudecode-skill.ts | 9 + src/features/skills/codexcli-skill.test.ts | 175 ++++++++++++- src/features/skills/codexcli-skill.ts | 143 ++++++++++- src/features/skills/rulesync-skill.ts | 54 ++++ src/types/hooks.test.ts | 11 + src/types/hooks.ts | 52 +++- src/types/mcp.test.ts | 30 +++ src/types/mcp.ts | 7 +- 23 files changed, 1073 insertions(+), 84 deletions(-) create mode 100644 src/features/hooks/goose-hooks.test.ts create mode 100644 src/features/hooks/goose-hooks.ts diff --git a/.gitignore b/.gitignore index 1b5206958..8935aa0f7 100644 --- a/.gitignore +++ b/.gitignore @@ -284,6 +284,7 @@ rulesync.local.jsonc **/.goosehints **/.goose/ **/.gooseignore +**/.agents/plugins/ **/.github/copilot-instructions.md **/.github/instructions/ **/.github/prompts/ diff --git a/README.md b/README.md index b53811623..d55e207ae 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Claude Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Codex CLI | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Gemini CLI ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Goose | ✅ | ✅ | | | | | | | +| Goose | ✅ | ✅ | | | | | ✅ | | | GitHub Copilot | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | ✅ | | ✅ | | ✅ | | ✅ | | | Cursor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index a137b5ed7..e21cf85a8 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -45,7 +45,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration ## `.rulesync/hooks.json` -Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, and Gemini CLI get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`). +Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`). Example: @@ -95,7 +95,7 @@ Example: - `version`: Schema version (currently `1`). - `hooks`: Map of canonical event names to an array of hook entries. These are dispatched to every tool that supports the given event. -- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `geminicli.hooks`, `codexcli.hooks`, `deepagents.hooks`, `kiro.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. +- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `geminicli.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. **Hook entry keys:** @@ -108,38 +108,39 @@ Events present in the shared `hooks` block but unsupported by a given tool are s ### Hook event × tool matrix -| Event | Cursor | Claude Code | OpenCode | Kilo | Copilot | Copilot CLI | Factory Droid | Gemini CLI | Codex CLI | deepagents | Kiro | Antigravity IDE | Antigravity CLI | Devin | AugmentCode | -| ---------------------- | :----: | :---------: | :------: | :--: | :-----: | :---------: | :-----------: | :--------: | :-------: | :--------: | :--: | :-------------: | :-------------: | :---: | :---------: | -| `sessionStart` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | -| `sessionEnd` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | — | — | ✅ | -| `beforeSubmitPrompt` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — | -| `preToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | -| `postToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | -| `preModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | -| `postModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | -| `postToolUseFailure` | ✅ | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | -| `stop` | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | -| `subagentStart` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | -| `subagentStop` | ✅ | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | -| `preCompact` | ✅ | ✅ | — | — | — | — | ✅ | ✅ | — | ✅ | — | — | — | — | — | -| `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | -| `beforeShellExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `afterShellExecution` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | -| `beforeMCPExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `afterMCPExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `beforeReadFile` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `beforeAgentResponse` | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | — | -| `afterAgentResponse` | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | — | -| `afterAgentThought` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | -| `beforeTabFileRead` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `afterTabFileEdit` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `beforeToolSelection` | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | -| `permissionRequest` | — | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | ✅ | — | — | — | — | — | -| `notification` | — | ✅ | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | -| `setup` | — | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | -| `worktreeCreate` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `worktreeRemove` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | -| `afterError` | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | +| Event | Cursor | Claude Code | OpenCode | Kilo | Copilot | Copilot CLI | Factory Droid | Gemini CLI | Codex CLI | deepagents | Kiro | Antigravity IDE | Antigravity CLI | Devin | AugmentCode | Goose | +| ---------------------- | :----: | :---------: | :------: | :--: | :-----: | :---------: | :-----------: | :--------: | :-------: | :--------: | :--: | :-------------: | :-------------: | :---: | :---------: | :---: | +| `sessionStart` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ | +| `sessionEnd` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | — | — | ✅ | ✅ | +| `beforeSubmitPrompt` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | +| `preToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | ✅ | +| `postToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | ✅ | +| `preModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — | +| `postModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — | +| `postToolUseFailure` | ✅ | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | +| `stop` | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | +| `subagentStart` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | +| `subagentStop` | ✅ | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | +| `preCompact` | ✅ | ✅ | — | — | — | — | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | +| `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | +| `beforeShellExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | +| `afterShellExecution` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | +| `beforeMCPExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | +| `afterMCPExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | +| `beforeReadFile` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | +| `beforeAgentResponse` | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | — | — | +| `afterAgentResponse` | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | — | — | +| `afterAgentThought` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | +| `beforeTabFileRead` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | +| `afterTabFileEdit` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | +| `beforeToolSelection` | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | +| `permissionRequest` | — | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | +| `notification` | — | ✅ | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | +| `setup` | — | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | +| `worktreeCreate` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | +| `worktreeRemove` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | +| `messageDisplay` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | +| `afterError` | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | > **Note:** `worktreeCreate` and `worktreeRemove` are Claude Code-specific events and do not support the `matcher` field. Any matcher defined in the config is ignored for these events. @@ -165,6 +166,8 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** AugmentCode (Auggie CLI) hooks are merged under the top-level `hooks` key of the shared `.augment/settings.json` (project) / `~/.augment/settings.json` (global), mirroring Claude Code's per-event matcher arrays (`{ "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). The `hooks` block is merged in place so it coexists with the `toolPermissions` block from the permissions feature. Five lifecycle events are supported — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, and `stop` ⇄ `Stop`. The `matcher` field (a case-sensitive regex, default `.*`, with `mcp:*` support) applies only to the tool events `PreToolUse`/`PostToolUse`; any matcher on the session events is dropped with a logged warning. Commands are emitted verbatim — Auggie exposes `AUGMENT_PROJECT_DIR` as a runtime environment variable, not as an inline command substitution, so no directory prefix is added. 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 `/.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`. 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. + ## `.copilot/mcp-config.json` Example: @@ -289,6 +292,8 @@ claudecode: # for claudecode-specific parameters - "Read" - "Write" - "Grep" + disallowed-tools: # (optional) removes these tools while the skill is active (string or list) + - "WebFetch" disable-model-invocation: true # (optional) disable model invocation for this skill scheduled-task: true # (optional) emit to .claude/scheduled-tasks//SKILL.md instead of .claude/skills//SKILL.md paths: # (optional) glob patterns (string or list) limiting auto-activation @@ -296,6 +301,19 @@ claudecode: # for claudecode-specific parameters - "test/**/*.ts" codexcli: # for codexcli-specific parameters short-description: A brief user-facing description + # The following sections are emitted to the agents/openai.yaml sidecar next to SKILL.md. + # See https://developers.openai.com/codex/skills.md + interface: # (optional) UI metadata + display_name: Example Skill + short_description: A brief user-facing description + default_prompt: Do the thing + policy: # (optional) invocation policy + allow_implicit_invocation: false # only invoke explicitly via $skill + dependencies: # (optional) tool dependencies + tools: + - type: mcp + value: example + description: Example MCP tool pi: # for Pi Coding Agent-specific parameters (optional) allowed-tools: - "Bash" @@ -337,6 +355,10 @@ Skills are directory-based and can include additional files alongside SKILL.md. When `claudecode.scheduled-task: true` is set, that skill is emitted only as a Claude Code scheduled task and is not emitted to other tools even if `targets` contains `"*"`. ``` +> **Note:** `claudecode.disallowed-tools` (a space/comma-separated string or a YAML list) removes the listed tools from the model while the skill is active. The same field is available on Claude Code slash commands. Both round-trip through the `claudecode` frontmatter section. + +> **Note:** Codex CLI reads UI metadata, invocation policy, and tool dependencies from an `agents/openai.yaml` sidecar next to `SKILL.md` (Codex's `SKILL.md` frontmatter only carries `name` and `description`). When `codexcli.interface`, `codexcli.policy`, or `codexcli.dependencies` is present, Rulesync emits `.codex/skills//agents/openai.yaml` and reads it back on import. If the sidecar is emitted and `interface.short_description` is absent, the legacy `codexcli.short-description` is routed there. See the [Codex skills docs](https://developers.openai.com/codex/skills.md). + ## `.rulesync/mcp.json` Example: @@ -385,6 +407,10 @@ Rulesync provides a JSON Schema for editor validation and autocompletion. Add th } ``` +### Transport types (`type` / `transport`) + +The `type` (and the equivalent `transport`) field accepts `local`, `stdio`, `sse`, `http`, `ws`, and `streamable-http`. `streamable-http` is the MCP specification's name for the HTTP transport and is accepted as an alias of `http`, so configurations copied from a server's documentation work unchanged. `ws` is the WebSocket transport (a persistent bidirectional connection) and accepts the same `url`/`headers`/`headersHelper`/`timeout` fields as `http`. Tools that do not recognize a given transport keep it on round-trip but may ignore it at runtime. + ### MCP Tool Config (`enabledTools` / `disabledTools`) You can control which individual tools from an MCP server are enabled or disabled using `enabledTools` and `disabledTools` arrays per server. diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 8c9c12df7..b05a8a841 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Gemini CLI ⚠️ | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | | -| Goose | goose | ✅ 🌏 | ✅ | | | | | | | +| Goose | goose | ✅ 🌏 | ✅ | | | | | ✅ 🌏 | | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | deepagents-cli | deepagents | ✅ | | ✅ 🌏 | | ✅ | ✅ | 🌏 | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | 🎮 | 🎮 | 🎮 | ✅ 🌏 | | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index a137b5ed7..e21cf85a8 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -45,7 +45,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration ## `.rulesync/hooks.json` -Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, and Gemini CLI get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`). +Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`). Example: @@ -95,7 +95,7 @@ Example: - `version`: Schema version (currently `1`). - `hooks`: Map of canonical event names to an array of hook entries. These are dispatched to every tool that supports the given event. -- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `geminicli.hooks`, `codexcli.hooks`, `deepagents.hooks`, `kiro.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. +- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `geminicli.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. **Hook entry keys:** @@ -108,38 +108,39 @@ Events present in the shared `hooks` block but unsupported by a given tool are s ### Hook event × tool matrix -| Event | Cursor | Claude Code | OpenCode | Kilo | Copilot | Copilot CLI | Factory Droid | Gemini CLI | Codex CLI | deepagents | Kiro | Antigravity IDE | Antigravity CLI | Devin | AugmentCode | -| ---------------------- | :----: | :---------: | :------: | :--: | :-----: | :---------: | :-----------: | :--------: | :-------: | :--------: | :--: | :-------------: | :-------------: | :---: | :---------: | -| `sessionStart` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | -| `sessionEnd` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | — | — | ✅ | -| `beforeSubmitPrompt` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — | -| `preToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | -| `postToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | -| `preModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | -| `postModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | -| `postToolUseFailure` | ✅ | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | -| `stop` | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | -| `subagentStart` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | -| `subagentStop` | ✅ | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | -| `preCompact` | ✅ | ✅ | — | — | — | — | ✅ | ✅ | — | ✅ | — | — | — | — | — | -| `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | -| `beforeShellExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `afterShellExecution` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | -| `beforeMCPExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `afterMCPExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `beforeReadFile` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `beforeAgentResponse` | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | — | -| `afterAgentResponse` | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | — | -| `afterAgentThought` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | -| `beforeTabFileRead` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `afterTabFileEdit` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `beforeToolSelection` | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | -| `permissionRequest` | — | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | ✅ | — | — | — | — | — | -| `notification` | — | ✅ | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | -| `setup` | — | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | -| `worktreeCreate` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | -| `worktreeRemove` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | -| `afterError` | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | +| Event | Cursor | Claude Code | OpenCode | Kilo | Copilot | Copilot CLI | Factory Droid | Gemini CLI | Codex CLI | deepagents | Kiro | Antigravity IDE | Antigravity CLI | Devin | AugmentCode | Goose | +| ---------------------- | :----: | :---------: | :------: | :--: | :-----: | :---------: | :-----------: | :--------: | :-------: | :--------: | :--: | :-------------: | :-------------: | :---: | :---------: | :---: | +| `sessionStart` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ | +| `sessionEnd` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | — | — | ✅ | ✅ | +| `beforeSubmitPrompt` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | +| `preToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | ✅ | +| `postToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | ✅ | +| `preModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — | +| `postModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — | +| `postToolUseFailure` | ✅ | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | +| `stop` | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | +| `subagentStart` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | +| `subagentStop` | ✅ | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | +| `preCompact` | ✅ | ✅ | — | — | — | — | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | +| `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | +| `beforeShellExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | +| `afterShellExecution` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | +| `beforeMCPExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | +| `afterMCPExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | +| `beforeReadFile` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | +| `beforeAgentResponse` | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | — | — | +| `afterAgentResponse` | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | — | — | +| `afterAgentThought` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | +| `beforeTabFileRead` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | +| `afterTabFileEdit` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | +| `beforeToolSelection` | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | +| `permissionRequest` | — | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | +| `notification` | — | ✅ | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | +| `setup` | — | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | +| `worktreeCreate` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | +| `worktreeRemove` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | +| `messageDisplay` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | +| `afterError` | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | > **Note:** `worktreeCreate` and `worktreeRemove` are Claude Code-specific events and do not support the `matcher` field. Any matcher defined in the config is ignored for these events. @@ -165,6 +166,8 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** AugmentCode (Auggie CLI) hooks are merged under the top-level `hooks` key of the shared `.augment/settings.json` (project) / `~/.augment/settings.json` (global), mirroring Claude Code's per-event matcher arrays (`{ "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). The `hooks` block is merged in place so it coexists with the `toolPermissions` block from the permissions feature. Five lifecycle events are supported — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, and `stop` ⇄ `Stop`. The `matcher` field (a case-sensitive regex, default `.*`, with `mcp:*` support) applies only to the tool events `PreToolUse`/`PostToolUse`; any matcher on the session events is dropped with a logged warning. Commands are emitted verbatim — Auggie exposes `AUGMENT_PROJECT_DIR` as a runtime environment variable, not as an inline command substitution, so no directory prefix is added. 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 `/.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`. 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. + ## `.copilot/mcp-config.json` Example: @@ -289,6 +292,8 @@ claudecode: # for claudecode-specific parameters - "Read" - "Write" - "Grep" + disallowed-tools: # (optional) removes these tools while the skill is active (string or list) + - "WebFetch" disable-model-invocation: true # (optional) disable model invocation for this skill scheduled-task: true # (optional) emit to .claude/scheduled-tasks//SKILL.md instead of .claude/skills//SKILL.md paths: # (optional) glob patterns (string or list) limiting auto-activation @@ -296,6 +301,19 @@ claudecode: # for claudecode-specific parameters - "test/**/*.ts" codexcli: # for codexcli-specific parameters short-description: A brief user-facing description + # The following sections are emitted to the agents/openai.yaml sidecar next to SKILL.md. + # See https://developers.openai.com/codex/skills.md + interface: # (optional) UI metadata + display_name: Example Skill + short_description: A brief user-facing description + default_prompt: Do the thing + policy: # (optional) invocation policy + allow_implicit_invocation: false # only invoke explicitly via $skill + dependencies: # (optional) tool dependencies + tools: + - type: mcp + value: example + description: Example MCP tool pi: # for Pi Coding Agent-specific parameters (optional) allowed-tools: - "Bash" @@ -337,6 +355,10 @@ Skills are directory-based and can include additional files alongside SKILL.md. When `claudecode.scheduled-task: true` is set, that skill is emitted only as a Claude Code scheduled task and is not emitted to other tools even if `targets` contains `"*"`. ``` +> **Note:** `claudecode.disallowed-tools` (a space/comma-separated string or a YAML list) removes the listed tools from the model while the skill is active. The same field is available on Claude Code slash commands. Both round-trip through the `claudecode` frontmatter section. + +> **Note:** Codex CLI reads UI metadata, invocation policy, and tool dependencies from an `agents/openai.yaml` sidecar next to `SKILL.md` (Codex's `SKILL.md` frontmatter only carries `name` and `description`). When `codexcli.interface`, `codexcli.policy`, or `codexcli.dependencies` is present, Rulesync emits `.codex/skills//agents/openai.yaml` and reads it back on import. If the sidecar is emitted and `interface.short_description` is absent, the legacy `codexcli.short-description` is routed there. See the [Codex skills docs](https://developers.openai.com/codex/skills.md). + ## `.rulesync/mcp.json` Example: @@ -385,6 +407,10 @@ Rulesync provides a JSON Schema for editor validation and autocompletion. Add th } ``` +### Transport types (`type` / `transport`) + +The `type` (and the equivalent `transport`) field accepts `local`, `stdio`, `sse`, `http`, `ws`, and `streamable-http`. `streamable-http` is the MCP specification's name for the HTTP transport and is accepted as an alias of `http`, so configurations copied from a server's documentation work unchanged. `ws` is the WebSocket transport (a persistent bidirectional connection) and accepts the same `url`/`headers`/`headersHelper`/`timeout` fields as `http`. Tools that do not recognize a given transport keep it on round-trip but may ignore it at runtime. + ### MCP Tool Config (`enabledTools` / `disabledTools`) You can control which individual tools from an MCP server are enabled or disabled using `enabledTools` and `disabledTools` arrays per server. diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 8c9c12df7..b05a8a841 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Gemini CLI ⚠️ | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | | -| Goose | goose | ✅ 🌏 | ✅ | | | | | | | +| Goose | goose | ✅ 🌏 | ✅ | | | | | ✅ 🌏 | | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | deepagents-cli | deepagents | ✅ | | ✅ 🌏 | | ✅ | ✅ | 🌏 | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | 🎮 | 🎮 | 🎮 | ✅ 🌏 | | diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index d3655e285..29d60152d 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -175,6 +175,8 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "goose", feature: "rules", entry: "**/.goosehints" }, { target: "goose", feature: "rules", entry: "**/.goose/" }, { target: "goose", feature: "ignore", entry: "**/.gooseignore" }, + // Goose lifecycle hooks plugin (.agents/plugins//hooks/hooks.json) + { target: "goose", feature: "hooks", entry: "**/.agents/plugins/" }, // GitHub Copilot { diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index 2a402d132..d979e3467 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -34,6 +34,10 @@ describe("E2E: hooks", () => { { target: "opencode", outputPath: join(".opencode", "plugins", "rulesync-hooks.js") }, { target: "codexcli", outputPath: join(".codex", "hooks.json") }, { target: "geminicli", outputPath: join(".gemini", "settings.json") }, + { + target: "goose", + outputPath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"), + }, { target: "copilot", outputPath: join(".github", "hooks", "copilot-hooks.json") }, { target: "copilotcli", outputPath: join(".github", "hooks", "copilotcli-hooks.json") }, { target: "factorydroid", outputPath: join(".factory", "settings.json") }, @@ -117,8 +121,8 @@ describe("E2E: hooks", () => { expect(parsed.rulesync.Stop).toBeDefined(); expect(JSON.stringify(parsed)).toContain(".rulesync/hooks/audit.sh"); } else { - // codexcli, geminicli, factorydroid: event-name casing/mapping varies - // per tool, so verify the configured hook command paths are preserved. + // codexcli, geminicli, factorydroid, goose: event-name casing/mapping + // varies per tool, so verify the configured hook command paths are preserved. assertHookCommandsPreserved(parsed); } } @@ -343,6 +347,19 @@ describe("E2E: hooks (import)", () => { }, }, }, + { + // Goose reads `.agents/plugins//hooks/hooks.json` with Claude-style + // PascalCase event names; SessionStart round-trips to canonical `sessionStart`. + target: "goose", + sourcePath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"), + sourceContent: { + hooks: { + SessionStart: [ + { matcher: "", hooks: [{ type: "command", command: "echo session started" }] }, + ], + }, + }, + }, ])( "should import $target hooks", async ({ target, sourcePath, sourceContent, expectedEvent }) => { @@ -367,6 +384,10 @@ describe("E2E: hooks (global mode)", () => { { target: "claudecode", outputPath: join(".claude", "settings.json") }, { target: "codexcli", outputPath: join(".codex", "hooks.json") }, { target: "geminicli", outputPath: join(".gemini", "settings.json") }, + { + target: "goose", + outputPath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"), + }, { target: "opencode", outputPath: join(".config", "opencode", "plugins", "rulesync-hooks.js") }, { target: "factorydroid", outputPath: join(".factory", "settings.json") }, { target: "deepagents", outputPath: join(".deepagents", "hooks.json") }, diff --git a/src/features/commands/claudecode-command.test.ts b/src/features/commands/claudecode-command.test.ts index baa743e82..3b1547622 100644 --- a/src/features/commands/claudecode-command.test.ts +++ b/src/features/commands/claudecode-command.test.ts @@ -624,6 +624,33 @@ Roundtrip body`; expect(result.success).toBe(false); }); + it("should accept disallowed-tools in both string and list forms", () => { + const listForm = ClaudecodeCommandFrontmatterSchema.safeParse({ + description: "Valid description", + "disallowed-tools": ["Bash", "Edit"], + }); + expect(listForm.success).toBe(true); + if (listForm.success) { + expect(listForm.data["disallowed-tools"]).toEqual(["Bash", "Edit"]); + } + + const stringForm = ClaudecodeCommandFrontmatterSchema.safeParse({ + description: "Valid description", + "disallowed-tools": "Bash, Edit", + }); + expect(stringForm.success).toBe(true); + if (stringForm.success) { + expect(stringForm.data["disallowed-tools"]).toBe("Bash, Edit"); + } + }); + + it("should reject invalid disallowed-tools definitions", () => { + const invalidFrontmatter = { description: "Valid description", "disallowed-tools": 123 }; + const result = ClaudecodeCommandFrontmatterSchema.safeParse(invalidFrontmatter); + + expect(result.success).toBe(false); + }); + it("should reject invalid disable-model-invocation definitions", () => { const invalidFrontmatter = { description: "Valid description", diff --git a/src/features/commands/claudecode-command.ts b/src/features/commands/claudecode-command.ts index fd3324a3a..89cdde6eb 100644 --- a/src/features/commands/claudecode-command.ts +++ b/src/features/commands/claudecode-command.ts @@ -19,6 +19,9 @@ import { export const ClaudecodeCommandFrontmatterSchema = z.looseObject({ description: z.optional(z.string()), "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])), + // Removes the listed tools from the model while the command is active. + // Accepts the space/comma-separated string form or a YAML list, mirroring `allowed-tools`. + "disallowed-tools": z.optional(z.union([z.string(), z.array(z.string())])), "argument-hint": z.optional(z.string()), model: z.optional(z.string()), "disable-model-invocation": z.optional(z.boolean()), diff --git a/src/features/hooks/goose-hooks.test.ts b/src/features/hooks/goose-hooks.test.ts new file mode 100644 index 000000000..9d49b766d --- /dev/null +++ b/src/features/hooks/goose-hooks.test.ts @@ -0,0 +1,232 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { GooseHooks } from "./goose-hooks.js"; +import { RulesyncHooks } from "./rulesync-hooks.js"; + +const GOOSE_HOOKS_DIR = join(".agents", "plugins", "rulesync", "hooks"); + +function createMockAiFileParams( + override: Partial[0]> = {}, +) { + return { + outputRoot: "/mock", + relativeDirPath: ".rulesync", + relativeFilePath: "hooks.json", + fileContent: "{}", + ...override, + }; +} + +describe("GooseHooks", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + }); + + afterEach(async () => { + await cleanup(); + }); + + describe("getSettablePaths", () => { + it("should write to the .agents/plugins/rulesync/hooks plugin directory", () => { + const paths = GooseHooks.getSettablePaths(); + expect(paths.relativeDirPath).toBe(GOOSE_HOOKS_DIR); + expect(paths.relativeFilePath).toBe("hooks.json"); + }); + }); + + describe("fromRulesyncHooks", () => { + it("should convert canonical hooks to Goose PascalCase events with matcher/hooks arrays", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "echo start" }], + preToolUse: [ + { command: "./scripts/lint.sh", matcher: "developer__shell", timeout: 30 }, + ], + afterFileEdit: [{ command: "cargo fmt", matcher: "\\.rs$" }], + }, + }), + }), + ); + + const gooseHooks = await GooseHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(gooseHooks.getFileContent()); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo start"); + expect(parsed.hooks.SessionStart[0].hooks[0].type).toBe("command"); + expect(parsed.hooks.PreToolUse[0].matcher).toBe("developer__shell"); + expect(parsed.hooks.PreToolUse[0].hooks[0].command).toBe("./scripts/lint.sh"); + expect(parsed.hooks.PreToolUse[0].hooks[0].timeout).toBe(30); + expect(parsed.hooks.AfterFileEdit[0].matcher).toBe("\\.rs$"); + }); + + it("should map all Goose lifecycle events", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionEnd: [{ command: "echo end" }], + stop: [{ command: "echo stop" }], + beforeSubmitPrompt: [{ command: "echo prompt" }], + postToolUse: [{ command: "echo post" }], + postToolUseFailure: [{ command: "echo fail" }], + beforeReadFile: [{ command: "echo read" }], + beforeShellExecution: [{ command: "echo before-sh" }], + afterShellExecution: [{ command: "echo after-sh" }], + }, + }), + }), + ); + + const gooseHooks = await GooseHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(gooseHooks.getFileContent()); + expect(parsed.hooks.SessionEnd).toBeDefined(); + expect(parsed.hooks.Stop).toBeDefined(); + expect(parsed.hooks.UserPromptSubmit).toBeDefined(); + expect(parsed.hooks.PostToolUse).toBeDefined(); + expect(parsed.hooks.PostToolUseFailure).toBeDefined(); + expect(parsed.hooks.BeforeReadFile).toBeDefined(); + expect(parsed.hooks.BeforeShellExecution).toBeDefined(); + expect(parsed.hooks.AfterShellExecution).toBeDefined(); + }); + + it("should filter unsupported events", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "echo start" }], + subagentStop: [{ command: "echo sub" }], + notification: [{ command: "echo notify" }], + }, + }), + }), + ); + + const gooseHooks = await GooseHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(gooseHooks.getFileContent()); + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.SubagentStop).toBeUndefined(); + expect(parsed.hooks.Notification).toBeUndefined(); + }); + + it("should process goose-specific overrides", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "echo shared" }], + }, + goose: { + hooks: { + sessionStart: [{ command: "echo override" }], + stop: [{ command: "echo stop" }], + }, + }, + }), + }), + ); + + const gooseHooks = await GooseHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(gooseHooks.getFileContent()); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo override"); + expect(parsed.hooks.Stop[0].hooks[0].command).toBe("echo stop"); + }); + }); + + describe("toRulesyncHooks", () => { + it("should convert Goose format back to canonical format", () => { + const gooseHooks = new GooseHooks( + createMockAiFileParams({ + relativeDirPath: GOOSE_HOOKS_DIR, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: "developer__shell", + hooks: [{ type: "command", command: "echo pre", timeout: 1000 }], + }, + ], + AfterShellExecution: [{ hooks: [{ command: "echo done" }] }], + }, + }), + }), + ); + + const parsed = gooseHooks.toRulesyncHooks().getJson(); + expect(parsed.hooks.preToolUse?.[0]).toEqual({ + type: "command", + command: "echo pre", + timeout: 1000, + matcher: "developer__shell", + }); + expect(parsed.hooks.afterShellExecution?.[0]).toEqual({ + type: "command", + command: "echo done", + }); + }); + }); + + describe("fromFile", () => { + it("should load from the plugin hooks.json when it exists", async () => { + await ensureDir(join(testDir, GOOSE_HOOKS_DIR)); + await writeFileContent( + join(testDir, GOOSE_HOOKS_DIR, "hooks.json"), + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: "command", command: "echo start" }] }], + }, + }), + ); + + const gooseHooks = await GooseHooks.fromFile({ outputRoot: testDir, validate: false }); + const parsed = JSON.parse(gooseHooks.getFileContent()); + expect(parsed.hooks.SessionStart).toHaveLength(1); + }); + + it("should initialize empty hooks when hooks.json does not exist", async () => { + const gooseHooks = await GooseHooks.fromFile({ outputRoot: testDir, validate: false }); + const parsed = JSON.parse(gooseHooks.getFileContent()); + expect(parsed.hooks).toEqual({}); + }); + }); + + describe("forDeletion", () => { + it("should create instance with empty hooks", () => { + const hooks = GooseHooks.forDeletion({ + relativeDirPath: GOOSE_HOOKS_DIR, + relativeFilePath: "hooks.json", + }); + const parsed = JSON.parse(hooks.getFileContent()); + expect(parsed.hooks).toEqual({}); + }); + }); +}); diff --git a/src/features/hooks/goose-hooks.ts b/src/features/hooks/goose-hooks.ts new file mode 100644 index 000000000..c6cfd5600 --- /dev/null +++ b/src/features/hooks/goose-hooks.ts @@ -0,0 +1,143 @@ +import { join } from "node:path"; + +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { + CANONICAL_TO_GOOSE_EVENT_NAMES, + GOOSE_HOOK_EVENTS, + GOOSE_TO_CANONICAL_EVENT_NAMES, +} from "../../types/hooks.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContentOrNull } from "../../utils/file.js"; +import type { RulesyncHooks } from "./rulesync-hooks.js"; +import type { ToolHooksConverterConfig } from "./tool-hooks-converter.js"; +import { canonicalToToolHooks, toolHooksToCanonical } from "./tool-hooks-converter.js"; +import { + ToolHooks, + type ToolHooksForDeletionParams, + type ToolHooksFromFileParams, + type ToolHooksFromRulesyncHooksParams, + type ToolHooksSettablePaths, +} from "./tool-hooks.js"; + +/** + * Plugin directory (under `.agents/plugins/`) that rulesync writes Goose hooks into. + * Goose auto-discovers any plugin directory containing `hooks/hooks.json`, so the + * exact name is arbitrary; rulesync uses a fixed, recognizable name. + */ +const GOOSE_PLUGIN_NAME = "rulesync"; + +const GOOSE_CONVERTER_CONFIG: ToolHooksConverterConfig = { + supportedEvents: GOOSE_HOOK_EVENTS, + canonicalToToolEventNames: CANONICAL_TO_GOOSE_EVENT_NAMES, + toolToCanonicalEventNames: GOOSE_TO_CANONICAL_EVENT_NAMES, + projectDirVar: "", + supportedHookTypes: new Set(["command"]), +}; + +/** + * Represents a Goose lifecycle hooks file. + * + * Goose adopts the Open Plugins hooks spec: a plugin directory containing + * `hooks/hooks.json` is auto-discovered at startup. rulesync emits to + * `.agents/plugins/rulesync/hooks/hooks.json` (project) or the same path under the + * user home (`~/.agents/plugins/rulesync/hooks/hooks.json`) in global mode. + * + * The JSON shape matches Claude Code's: each PascalCase event maps to an array of + * `{ matcher, hooks: [{ type: "command", command }] }` entries. + * @see https://goose-docs.ai/blog/2026/05/14/goose-hooks/ + */ +export class GooseHooks extends ToolHooks { + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "{}", + }); + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolHooksSettablePaths { + return { + relativeDirPath: join(".agents", "plugins", GOOSE_PLUGIN_NAME, "hooks"), + relativeFilePath: "hooks.json", + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + global = false, + }: ToolHooksFromFileParams): Promise { + const paths = GooseHooks.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const fileContent = (await readFileContentOrNull(filePath)) ?? '{"hooks":{}}'; + return new GooseHooks({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static async fromRulesyncHooks({ + outputRoot = process.cwd(), + rulesyncHooks, + validate = true, + global = false, + }: ToolHooksFromRulesyncHooksParams & { global?: boolean }): Promise { + const paths = GooseHooks.getSettablePaths({ global }); + const config = rulesyncHooks.getJson(); + const gooseHooks = canonicalToToolHooks({ + config, + toolOverrideHooks: config.goose?.hooks, + converterConfig: GOOSE_CONVERTER_CONFIG, + }); + const fileContent = JSON.stringify({ hooks: gooseHooks }, null, 2); + + return new GooseHooks({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + toRulesyncHooks(): RulesyncHooks { + let parsed: { hooks?: unknown }; + try { + parsed = JSON.parse(this.getFileContent()); + } catch (error) { + throw new Error( + `Failed to parse Goose hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, + { + cause: error, + }, + ); + } + const hooks = toolHooksToCanonical({ + hooks: parsed.hooks, + converterConfig: GOOSE_CONVERTER_CONFIG, + }); + return this.toRulesyncHooksDefault({ + fileContent: JSON.stringify({ version: 1, hooks }, null, 2), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolHooksForDeletionParams): GooseHooks { + return new GooseHooks({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify({ hooks: {} }, null, 2), + validate: false, + }); + } +} diff --git a/src/features/hooks/hooks-processor.test.ts b/src/features/hooks/hooks-processor.test.ts index 9917fb8c1..8b8f16491 100644 --- a/src/features/hooks/hooks-processor.test.ts +++ b/src/features/hooks/hooks-processor.test.ts @@ -495,6 +495,7 @@ describe("HooksProcessor", () => { "opencode", "factorydroid", "geminicli", + "goose", "kiro", "devin", "augmentcode", @@ -514,6 +515,7 @@ describe("HooksProcessor", () => { "opencode", "factorydroid", "geminicli", + "goose", "deepagents", "devin", "augmentcode", @@ -532,6 +534,7 @@ describe("HooksProcessor", () => { "copilotcli", "factorydroid", "geminicli", + "goose", "kiro", "devin", "augmentcode", @@ -549,6 +552,7 @@ describe("HooksProcessor", () => { "copilotcli", "factorydroid", "geminicli", + "goose", "deepagents", "devin", "augmentcode", diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index d87720f02..a7062d03f 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -12,6 +12,7 @@ import { DEEPAGENTS_HOOK_EVENTS, FACTORYDROID_HOOK_EVENTS, GEMINICLI_HOOK_EVENTS, + GOOSE_HOOK_EVENTS, KILO_HOOK_EVENTS, KIRO_HOOK_EVENTS, OPENCODE_HOOK_EVENTS, @@ -34,6 +35,7 @@ import { DeepagentsHooks } from "./deepagents-hooks.js"; import { DEVIN_HOOK_EVENTS, DevinHooks } from "./devin-hooks.js"; import { FactorydroidHooks } from "./factorydroid-hooks.js"; import { GeminicliHooks } from "./geminicli-hooks.js"; +import { GooseHooks } from "./goose-hooks.js"; import { KiloHooks } from "./kilo-hooks.js"; import { KiroHooks } from "./kiro-hooks.js"; import { OpencodeHooks } from "./opencode-hooks.js"; @@ -57,6 +59,7 @@ const hooksProcessorToolTargetTuple = [ "opencode", "factorydroid", "geminicli", + "goose", "deepagents", "kiro", "devin", @@ -249,6 +252,23 @@ const toolHooksFactories = new Map([ supportsMatcher: true, }, ], + [ + "goose", + { + class: GooseHooks, + meta: { + // Goose auto-discovers plugins from both `.agents/plugins/` (project) + // and `~/.agents/plugins/` (global). rulesync writes + // `.agents/plugins/rulesync/hooks/hooks.json` in both modes. + supportsProject: true, + supportsGlobal: true, + supportsImport: true, + }, + supportedEvents: GOOSE_HOOK_EVENTS, + supportedHookTypes: ["command"], + supportsMatcher: true, + }, + ], [ "deepagents", { diff --git a/src/features/skills/claudecode-skill.test.ts b/src/features/skills/claudecode-skill.test.ts index 09b77bb16..e20b30743 100644 --- a/src/features/skills/claudecode-skill.test.ts +++ b/src/features/skills/claudecode-skill.test.ts @@ -264,6 +264,31 @@ describe("ClaudecodeSkill", () => { }); }); + it("should convert to RulesyncSkill with disallowed-tools", () => { + const frontmatter: ClaudecodeSkillFrontmatter = { + name: "restricted-skill", + description: "Restricted skill", + "disallowed-tools": ["Bash", "Edit"], + }; + + const skill = new ClaudecodeSkill({ + dirName: "restricted-skill", + frontmatter, + body: "Restricted body", + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + + expect(rulesyncFrontmatter.claudecode).toEqual({ + "disallowed-tools": ["Bash", "Edit"], + }); + + // round-trip back to a ClaudecodeSkill preserves disallowed-tools + const roundTripped = ClaudecodeSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(roundTripped.getFrontmatter()["disallowed-tools"]).toEqual(["Bash", "Edit"]); + }); + it("should convert to RulesyncSkill with model", () => { const frontmatter: ClaudecodeSkillFrontmatter = { name: "model-skill", diff --git a/src/features/skills/claudecode-skill.ts b/src/features/skills/claudecode-skill.ts index e6ce18fbc..b9d7ebc72 100644 --- a/src/features/skills/claudecode-skill.ts +++ b/src/features/skills/claudecode-skill.ts @@ -22,6 +22,9 @@ export const ClaudecodeSkillFrontmatterSchema = z.looseObject({ name: z.string(), description: z.string(), "allowed-tools": z.optional(z.array(z.string())), + // Removes the listed tools from the model while the skill is active. + // Accepts the space/comma-separated string form or a YAML list, mirroring `allowed-tools`. + "disallowed-tools": z.optional(z.union([z.string(), z.array(z.string())])), model: z.optional(z.string()), "disable-model-invocation": z.optional(z.boolean()), paths: z.optional(z.union([z.string(), z.array(z.string())])), @@ -121,6 +124,9 @@ export class ClaudecodeSkill extends ToolSkill { const frontmatter = this.getFrontmatter(); const claudecodeSection = { ...(frontmatter["allowed-tools"] && { "allowed-tools": frontmatter["allowed-tools"] }), + ...(frontmatter["disallowed-tools"] && { + "disallowed-tools": frontmatter["disallowed-tools"], + }), ...(frontmatter.model && { model: frontmatter.model }), ...(frontmatter["disable-model-invocation"] !== undefined && { "disable-model-invocation": frontmatter["disable-model-invocation"], @@ -161,6 +167,9 @@ export class ClaudecodeSkill extends ToolSkill { ...(rulesyncFrontmatter.claudecode?.["allowed-tools"] && { "allowed-tools": rulesyncFrontmatter.claudecode["allowed-tools"], }), + ...(rulesyncFrontmatter.claudecode?.["disallowed-tools"] && { + "disallowed-tools": rulesyncFrontmatter.claudecode["disallowed-tools"], + }), ...(rulesyncFrontmatter.claudecode?.model && { model: rulesyncFrontmatter.claudecode.model, }), diff --git a/src/features/skills/codexcli-skill.test.ts b/src/features/skills/codexcli-skill.test.ts index dfa7ed94a..7f2073ada 100644 --- a/src/features/skills/codexcli-skill.test.ts +++ b/src/features/skills/codexcli-skill.test.ts @@ -1,14 +1,24 @@ import { join } from "node:path"; +import { load } from "js-yaml"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SKILL_FILE_NAME } from "../../constants/general.js"; import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { ensureDir, toPosixPath, writeFileContent } from "../../utils/file.js"; import { CodexCliSkill } from "./codexcli-skill.js"; import { RulesyncSkill } from "./rulesync-skill.js"; +const OPENAI_YAML_PATH = join("agents", "openai.yaml"); + +function findOpenaiYaml(skill: CodexCliSkill): string | undefined { + const file = skill + .getOtherFiles() + .find((f) => toPosixPath(f.relativeFilePathToDirPath) === toPosixPath(OPENAI_YAML_PATH)); + return file ? file.fileBuffer.toString("utf-8") : undefined; +} + describe("CodexCliSkill", () => { let testDir: string; let cleanup: () => Promise; @@ -400,4 +410,167 @@ This is the body of the codex cli skill.`; expect(rulesyncSkill.getBody()).toBe("Test body"); }); }); + + describe("agents/openai.yaml sidecar", () => { + it("should emit agents/openai.yaml from codexcli interface/policy/dependencies", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "AI-facing description", + codexcli: { + interface: { + display_name: "Test Skill", + short_description: "User-facing description", + default_prompt: "Do the thing", + }, + policy: { + allow_implicit_invocation: false, + }, + dependencies: { + tools: [ + { + type: "mcp", + value: "example", + description: "Example MCP tool", + transport: "http", + url: "https://mcp.example.com/mcp", + }, + ], + }, + }, + }, + body: "Test body content", + validate: true, + }); + + const codexCliSkill = CodexCliSkill.fromRulesyncSkill({ rulesyncSkill, validate: true }); + + // SKILL.md frontmatter stays name + description only + expect(codexCliSkill.getFrontmatter()).toEqual({ + name: "Test Skill", + description: "AI-facing description", + }); + + const yamlContent = findOpenaiYaml(codexCliSkill); + expect(yamlContent).toBeDefined(); + expect(load(yamlContent ?? "")).toEqual({ + interface: { + display_name: "Test Skill", + short_description: "User-facing description", + default_prompt: "Do the thing", + }, + policy: { + allow_implicit_invocation: false, + }, + dependencies: { + tools: [ + { + type: "mcp", + value: "example", + description: "Example MCP tool", + transport: "http", + url: "https://mcp.example.com/mcp", + }, + ], + }, + }); + }); + + it("should route the legacy short-description to interface.short_description when the sidecar is emitted", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "AI-facing description", + codexcli: { + "short-description": "User-facing description", + policy: { allow_implicit_invocation: false }, + }, + }, + body: "Test body content", + validate: true, + }); + + const codexCliSkill = CodexCliSkill.fromRulesyncSkill({ rulesyncSkill, validate: true }); + + // Legacy short-description still lands in SKILL.md metadata for backward compatibility + expect(codexCliSkill.getFrontmatter()).toEqual({ + name: "Test Skill", + description: "AI-facing description", + metadata: { "short-description": "User-facing description" }, + }); + + expect(load(findOpenaiYaml(codexCliSkill) ?? "")).toEqual({ + interface: { short_description: "User-facing description" }, + policy: { allow_implicit_invocation: false }, + }); + }); + + it("should NOT emit agents/openai.yaml for a lone short-description", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "AI-facing description", + codexcli: { "short-description": "User-facing description" }, + }, + body: "Test body content", + validate: true, + }); + + const codexCliSkill = CodexCliSkill.fromRulesyncSkill({ rulesyncSkill, validate: true }); + expect(findOpenaiYaml(codexCliSkill)).toBeUndefined(); + }); + + it("should read agents/openai.yaml back into the codexcli section on import", async () => { + const skillDir = join(testDir, ".codex", "skills", "test-skill"); + await ensureDir(join(skillDir, "agents")); + await writeFileContent( + join(skillDir, SKILL_FILE_NAME), + `---\nname: Test Skill\ndescription: AI-facing description\n---\n\nBody.`, + ); + await writeFileContent( + join(skillDir, "agents", "openai.yaml"), + [ + "interface:", + " display_name: Test Skill", + " short_description: User-facing description", + "policy:", + " allow_implicit_invocation: false", + "", + ].join("\n"), + ); + + const skill = await CodexCliSkill.fromDir({ + outputRoot: testDir, + dirName: "test-skill", + global: false, + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + expect(rulesyncSkill.getFrontmatter().codexcli).toEqual({ + interface: { + display_name: "Test Skill", + short_description: "User-facing description", + }, + policy: { + allow_implicit_invocation: false, + }, + }); + + // The sidecar is consumed as structured data, not carried as a passthrough file. + expect( + rulesyncSkill + .getOtherFiles() + .some((f) => toPosixPath(f.relativeFilePathToDirPath) === toPosixPath(OPENAI_YAML_PATH)), + ).toBe(false); + }); + }); }); diff --git a/src/features/skills/codexcli-skill.ts b/src/features/skills/codexcli-skill.ts index 3f054e441..acdf00c8b 100644 --- a/src/features/skills/codexcli-skill.ts +++ b/src/features/skills/codexcli-skill.ts @@ -1,12 +1,19 @@ import { join } from "node:path"; +import { dump, load } from "js-yaml"; import { z } from "zod/mini"; import { SKILL_FILE_NAME } from "../../constants/general.js"; import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; import { ValidationResult } from "../../types/ai-dir.js"; import { formatError } from "../../utils/error.js"; -import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; +import { toPosixPath } from "../../utils/file.js"; +import { + RulesyncSkill, + RulesyncSkillFrontmatter, + RulesyncSkillFrontmatterInput, + SkillFile, +} from "./rulesync-skill.js"; import { ToolSkill, ToolSkillForDeletionParams, @@ -27,6 +34,103 @@ export const CodexCliSkillFrontmatterSchema = z.looseObject({ export type CodexCliSkillFrontmatter = z.infer; +/** + * Relative path (within a skill directory) of the Codex `agents/openai.yaml` sidecar. + * Codex CLI reads UI metadata, invocation policy, and tool dependencies from this file; + * `SKILL.md` frontmatter only carries `name` and `description`. + * @see https://developers.openai.com/codex/skills.md + */ +const CODEX_OPENAI_YAML_RELATIVE_PATH = join("agents", "openai.yaml"); + +type CodexcliRulesyncSection = NonNullable; + +/** + * Build the `agents/openai.yaml` object from a rulesync `codexcli` section. + * Only produced when the user opts in via `interface`, `policy`, or `dependencies`; + * a lone legacy `short-description` keeps mapping to `SKILL.md` `metadata` only. + * When the sidecar is produced and `interface.short_description` is absent, the + * legacy `short-description` is routed there (its canonical home per Codex docs). + */ +function buildOpenaiYamlObject( + codexcli: CodexcliRulesyncSection | undefined, +): Record | undefined { + if (!codexcli) { + return undefined; + } + const hasSidecarFields = Boolean(codexcli.interface || codexcli.policy || codexcli.dependencies); + if (!hasSidecarFields) { + return undefined; + } + + const interfaceSection: Record = { ...codexcli.interface }; + if ( + interfaceSection.short_description === undefined && + codexcli["short-description"] !== undefined + ) { + interfaceSection.short_description = codexcli["short-description"]; + } + + const result: Record = {}; + if (Object.keys(interfaceSection).length > 0) { + result.interface = interfaceSection; + } + if (codexcli.policy && Object.keys(codexcli.policy).length > 0) { + result.policy = codexcli.policy; + } + if (codexcli.dependencies && Object.keys(codexcli.dependencies).length > 0) { + result.dependencies = codexcli.dependencies; + } + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Split out the `agents/openai.yaml` sidecar (if any) from a skill's other files. + * Returns the parsed sidecar object plus the remaining passthrough files. A malformed + * sidecar is left in `rest` (preserved as a passthrough file) rather than dropped. + */ +function extractOpenaiYamlFile(otherFiles: SkillFile[]): { + parsed: Record | undefined; + rest: SkillFile[]; +} { + const target = toPosixPath(CODEX_OPENAI_YAML_RELATIVE_PATH); + let parsed: Record | undefined; + const rest: SkillFile[] = []; + for (const file of otherFiles) { + if (toPosixPath(file.relativeFilePathToDirPath) === target) { + try { + const loaded = load(file.fileBuffer.toString("utf-8")); + if (loaded !== null && typeof loaded === "object" && !Array.isArray(loaded)) { + parsed = loaded as Record; + continue; + } + } catch { + // fall through: keep the malformed sidecar as a passthrough file + } + } + rest.push(file); + } + return { parsed, rest }; +} + +/** + * Map a parsed `agents/openai.yaml` object back into a rulesync `codexcli` section. + */ +function openaiYamlToCodexcliSection( + parsed: Record | undefined, +): Record | undefined { + if (!parsed) { + return undefined; + } + const section: Record = {}; + for (const key of ["interface", "policy", "dependencies"] as const) { + const value = parsed[key]; + if (value !== null && typeof value === "object") { + section[key] = value; + } + } + return Object.keys(section).length > 0 ? section : undefined; +} + export type CodexCliSkillParams = { outputRoot?: string; relativeDirPath?: string; @@ -119,14 +223,22 @@ export class CodexCliSkill extends ToolSkill { toRulesyncSkill(): RulesyncSkill { const frontmatter = this.getFrontmatter(); + const { parsed, rest } = extractOpenaiYamlFile(this.getOtherFiles()); + const openaiSection = openaiYamlToCodexcliSection(parsed); + + const codexcliSection: Record = { + ...(frontmatter.metadata?.["short-description"] && { + "short-description": frontmatter.metadata["short-description"], + }), + ...openaiSection, + }; + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { name: frontmatter.name, description: frontmatter.description, targets: ["*"], - ...(frontmatter.metadata?.["short-description"] && { - codexcli: { - "short-description": frontmatter.metadata["short-description"], - }, + ...(Object.keys(codexcliSection).length > 0 && { + codexcli: codexcliSection as RulesyncSkillFrontmatterInput["codexcli"], }), }; @@ -136,7 +248,7 @@ export class CodexCliSkill extends ToolSkill { dirName: this.getDirName(), frontmatter: rulesyncFrontmatter, body: this.getBody(), - otherFiles: this.getOtherFiles(), + otherFiles: rest, validate: true, global: this.global, }); @@ -161,13 +273,30 @@ export class CodexCliSkill extends ToolSkill { }), }; + // Emit the Codex `agents/openai.yaml` sidecar when interface/policy/dependencies + // are configured, replacing any stale copy carried through as a passthrough file. + const target = toPosixPath(CODEX_OPENAI_YAML_RELATIVE_PATH); + const baseOtherFiles = rulesyncSkill + .getOtherFiles() + .filter((file) => toPosixPath(file.relativeFilePathToDirPath) !== target); + const openaiObject = buildOpenaiYamlObject(rulesyncFrontmatter.codexcli); + const otherFiles: SkillFile[] = openaiObject + ? [ + ...baseOtherFiles, + { + relativeFilePathToDirPath: CODEX_OPENAI_YAML_RELATIVE_PATH, + fileBuffer: Buffer.from(dump(openaiObject)), + }, + ] + : baseOtherFiles; + return new CodexCliSkill({ outputRoot, relativeDirPath: settablePaths.relativeDirPath, dirName: rulesyncSkill.getDirName(), frontmatter: codexFrontmatter, body: rulesyncSkill.getBody(), - otherFiles: rulesyncSkill.getOtherFiles(), + otherFiles, validate, global, }); diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index 02fb401d2..3ae8e8ca0 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -17,6 +17,7 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ claudecode: z.optional( z.looseObject({ "allowed-tools": z.optional(z.array(z.string())), + "disallowed-tools": z.optional(z.union([z.string(), z.array(z.string())])), model: z.optional(z.string()), "disable-model-invocation": z.optional(z.boolean()), "scheduled-task": z.optional(z.boolean()), @@ -26,6 +27,38 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ codexcli: z.optional( z.looseObject({ "short-description": z.optional(z.string()), + // Fields emitted to the `agents/openai.yaml` sidecar next to SKILL.md. + // See https://developers.openai.com/codex/skills.md + interface: z.optional( + z.looseObject({ + display_name: z.optional(z.string()), + short_description: z.optional(z.string()), + icon_small: z.optional(z.string()), + icon_large: z.optional(z.string()), + brand_color: z.optional(z.string()), + default_prompt: z.optional(z.string()), + }), + ), + policy: z.optional( + z.looseObject({ + allow_implicit_invocation: z.optional(z.boolean()), + }), + ), + dependencies: z.optional( + z.looseObject({ + tools: z.optional( + z.array( + z.looseObject({ + type: z.optional(z.string()), + value: z.optional(z.string()), + description: z.optional(z.string()), + transport: z.optional(z.string()), + url: z.optional(z.string()), + }), + ), + ), + }), + ), }), ), opencode: z.optional( @@ -90,6 +123,7 @@ export type RulesyncSkillFrontmatterInput = { targets?: ("*" | string)[]; claudecode?: { "allowed-tools"?: string[]; + "disallowed-tools"?: string | string[]; model?: string; "disable-model-invocation"?: boolean; "scheduled-task"?: boolean; @@ -97,6 +131,26 @@ export type RulesyncSkillFrontmatterInput = { }; codexcli?: { "short-description"?: string; + interface?: { + display_name?: string; + short_description?: string; + icon_small?: string; + icon_large?: string; + brand_color?: string; + default_prompt?: string; + }; + policy?: { + allow_implicit_invocation?: boolean; + }; + dependencies?: { + tools?: Array<{ + type?: string; + value?: string; + description?: string; + transport?: string; + url?: string; + }>; + }; }; opencode?: { "allowed-tools"?: string[]; diff --git a/src/types/hooks.test.ts b/src/types/hooks.test.ts index 69389e11a..be1d810e2 100644 --- a/src/types/hooks.test.ts +++ b/src/types/hooks.test.ts @@ -37,6 +37,17 @@ describe("Event map completeness", () => { }); }); +describe("Claude Code event naming", () => { + it("should map the messageDisplay event to Claude's MessageDisplay name", () => { + // Verified against https://code.claude.com/docs/en/changelog (v2.1.152) + expect(CANONICAL_TO_CLAUDE_EVENT_NAMES.messageDisplay).toBe("MessageDisplay"); + }); + + it("should list messageDisplay as a supported Claude hook event", () => { + expect(CLAUDE_HOOK_EVENTS).toContain("messageDisplay"); + }); +}); + describe("Factory Droid event naming", () => { it("should map canonical event names to documented Factory Droid PascalCase names", () => { // Verified against https://docs.factory.ai/reference/hooks-reference diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 937e94b22..5a3918e65 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -73,7 +73,8 @@ export type HookEvent = | "afterError" | "beforeToolSelection" | "worktreeCreate" - | "worktreeRemove"; + | "worktreeRemove" + | "messageDisplay"; /** Hook events supported by Cursor. */ export const CURSOR_HOOK_EVENTS: readonly HookEvent[] = [ @@ -114,6 +115,7 @@ export const CLAUDE_HOOK_EVENTS: readonly HookEvent[] = [ "setup", "worktreeCreate", "worktreeRemove", + "messageDisplay", ]; /** Hook events supported by OpenCode. */ @@ -198,6 +200,28 @@ export const CODEXCLI_HOOK_EVENTS: readonly HookEvent[] = [ "permissionRequest", ]; +/** + * Hook events supported by Goose. + * + * Goose adopts the Open Plugins hooks spec: each plugin's `hooks/hooks.json` + * maps PascalCase event names to matcher/handler arrays. Every Goose event has a + * 1:1 canonical equivalent, so no new canonical events are required. + * @see https://goose-docs.ai/blog/2026/05/14/goose-hooks/ + */ +export const GOOSE_HOOK_EVENTS: readonly HookEvent[] = [ + "sessionStart", + "sessionEnd", + "stop", + "beforeSubmitPrompt", + "preToolUse", + "postToolUse", + "postToolUseFailure", + "beforeReadFile", + "afterFileEdit", + "beforeShellExecution", + "afterShellExecution", +]; + /** Hook events supported by Kiro CLI. */ export const KIRO_HOOK_EVENTS: readonly HookEvent[] = [ "sessionStart", @@ -256,6 +280,7 @@ export const HooksConfigSchema = z.looseObject({ factorydroid: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), geminicli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), codexcli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), + goose: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), deepagents: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), kiro: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), devin: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), @@ -283,6 +308,7 @@ export const CANONICAL_TO_CLAUDE_EVENT_NAMES: Record = { setup: "Setup", worktreeCreate: "WorktreeCreate", worktreeRemove: "WorktreeRemove", + messageDisplay: "MessageDisplay", }; /** @@ -471,6 +497,30 @@ export const CODEXCLI_TO_CANONICAL_EVENT_NAMES: Record = Object. Object.entries(CANONICAL_TO_CODEXCLI_EVENT_NAMES).map(([k, v]) => [v, k]), ); +/** + * Map canonical camelCase event names to Goose PascalCase. + */ +export const CANONICAL_TO_GOOSE_EVENT_NAMES: Record = { + sessionStart: "SessionStart", + sessionEnd: "SessionEnd", + stop: "Stop", + beforeSubmitPrompt: "UserPromptSubmit", + preToolUse: "PreToolUse", + postToolUse: "PostToolUse", + postToolUseFailure: "PostToolUseFailure", + beforeReadFile: "BeforeReadFile", + afterFileEdit: "AfterFileEdit", + beforeShellExecution: "BeforeShellExecution", + afterShellExecution: "AfterShellExecution", +}; + +/** + * Map Goose PascalCase event names to canonical camelCase. + */ +export const GOOSE_TO_CANONICAL_EVENT_NAMES: Record = Object.fromEntries( + Object.entries(CANONICAL_TO_GOOSE_EVENT_NAMES).map(([k, v]) => [v, k]), +); + /** * Map canonical camelCase event names to deepagents-cli dot-notation. */ diff --git a/src/types/mcp.test.ts b/src/types/mcp.test.ts index 8a62e87d7..b3f8400a6 100644 --- a/src/types/mcp.test.ts +++ b/src/types/mcp.test.ts @@ -62,4 +62,34 @@ describe("McpServerSchema", () => { }); expect(result.success).toBe(false); }); + + it("should accept the WebSocket transport (type/transport: ws)", () => { + const result = McpServerSchema.safeParse({ + type: "ws", + transport: "ws", + url: "wss://mcp.example.com/socket", + headers: { Authorization: "Bearer token" }, + }); + expect(result.success).toBe(true); + }); + + it("should accept streamable-http as an alias of http (type/transport)", () => { + const result = McpServerSchema.safeParse({ + type: "streamable-http", + transport: "streamable-http", + url: "https://mcp.example.com/mcp", + }); + expect(result.success).toBe(true); + }); + + it("should still accept the existing http/stdio/sse/local transports", () => { + for (const type of ["local", "stdio", "sse", "http"] as const) { + expect(McpServerSchema.safeParse({ type, transport: type }).success).toBe(true); + } + }); + + it("should reject an unknown transport value", () => { + const result = McpServerSchema.safeParse({ type: "grpc" }); + expect(result.success).toBe(false); + }); }); diff --git a/src/types/mcp.ts b/src/types/mcp.ts index e4c4b2764..aead06408 100644 --- a/src/types/mcp.ts +++ b/src/types/mcp.ts @@ -10,7 +10,10 @@ const EnvVarNameSchema = z ); export const McpServerSchema = z.looseObject({ - type: z.optional(z.enum(["local", "stdio", "sse", "http"])), + // `streamable-http` is the MCP spec's transport name and an accepted alias for + // `http` (Claude Code), so configs copied from server docs work unchanged. + // `ws` is Claude Code's WebSocket transport (same url/headers/timeout fields as http). + type: z.optional(z.enum(["local", "stdio", "sse", "http", "ws", "streamable-http"])), command: z.optional(z.union([z.string(), z.array(z.string())])), args: z.optional(z.array(z.string())), url: z.optional(z.string()), @@ -31,7 +34,7 @@ export const McpServerSchema = z.looseObject({ timeout: z.optional(z.number()), trust: z.optional(z.boolean()), cwd: z.optional(z.string()), - transport: z.optional(z.enum(["local", "stdio", "sse", "http"])), + transport: z.optional(z.enum(["local", "stdio", "sse", "http", "ws", "streamable-http"])), alwaysAllow: z.optional(z.array(z.string())), tools: z.optional(z.array(z.string())), kiroAutoApprove: z.optional(z.array(z.string())), From e5205d5c38f976040894f67db5f77d0dfaf97397 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Mon, 8 Jun 2026 17:21:55 -0700 Subject: [PATCH 2/2] fix: treat messageDisplay as no-matcher event and emit Codex YAML with project conventions Address /review-pr mid-severity findings on PR #1770: - Add "messageDisplay" to CLAUDE_NO_MATCHER_EVENTS so the matcher field is dropped (with a warning) when present. Per the Claude Code hooks docs, MessageDisplay does not support a matcher and silently ignores it. Add a converter test asserting the matcher is dropped, and update the no-matcher note in both file-formats docs. - Pass { lineWidth: -1, noRefs: true } to js-yaml dump() when emitting the Codex agents/openai.yaml sidecar, matching the project-wide dump convention and avoiding line-folding of long URLs/descriptions. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/file-formats.md | 2 +- skills/rulesync/file-formats.md | 2 +- src/features/hooks/claudecode-hooks.test.ts | 30 +++++++++++++++++++++ src/features/hooks/claudecode-hooks.ts | 6 ++++- src/features/skills/codexcli-skill.ts | 2 +- 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index e21cf85a8..8b6e19dbe 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -142,7 +142,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s | `messageDisplay` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `afterError` | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | -> **Note:** `worktreeCreate` and `worktreeRemove` are Claude Code-specific events and do not support the `matcher` field. Any matcher defined in the config is ignored for these events. +> **Note:** `worktreeCreate`, `worktreeRemove`, and `messageDisplay` are Claude Code-specific events and do not support the `matcher` field. Any matcher defined in the config is ignored for these events. > **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). diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index e21cf85a8..8b6e19dbe 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -142,7 +142,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s | `messageDisplay` | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | `afterError` | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | -> **Note:** `worktreeCreate` and `worktreeRemove` are Claude Code-specific events and do not support the `matcher` field. Any matcher defined in the config is ignored for these events. +> **Note:** `worktreeCreate`, `worktreeRemove`, and `messageDisplay` are Claude Code-specific events and do not support the `matcher` field. Any matcher defined in the config is ignored for these events. > **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). diff --git a/src/features/hooks/claudecode-hooks.test.ts b/src/features/hooks/claudecode-hooks.test.ts index ccd5a7e22..32498678d 100644 --- a/src/features/hooks/claudecode-hooks.test.ts +++ b/src/features/hooks/claudecode-hooks.test.ts @@ -355,6 +355,36 @@ describe("ClaudecodeHooks", () => { expect(parsed.hooks.WorktreeRemove[0].matcher).toBeUndefined(); }); + it("should NOT emit matcher for messageDisplay even if defined in config", async () => { + await ensureDir(join(testDir, ".claude")); + await writeFileContent(join(testDir, ".claude", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + messageDisplay: [{ type: "command", command: "display.sh", matcher: "*.md" }], + }, + }; + 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 content = claudecodeHooks.getFileContent(); + const parsed = JSON.parse(content); + expect(parsed.hooks.MessageDisplay).toBeDefined(); + expect(parsed.hooks.MessageDisplay[0].matcher).toBeUndefined(); + }); + 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 a01a7b0a5..9ff259d31 100644 --- a/src/features/hooks/claudecode-hooks.ts +++ b/src/features/hooks/claudecode-hooks.ts @@ -21,7 +21,11 @@ import { type ToolHooksSettablePaths, } from "./tool-hooks.js"; -const CLAUDE_NO_MATCHER_EVENTS: ReadonlySet = new Set(["worktreeCreate", "worktreeRemove"]); +const CLAUDE_NO_MATCHER_EVENTS: ReadonlySet = new Set([ + "worktreeCreate", + "worktreeRemove", + "messageDisplay", +]); const CLAUDE_CONVERTER_CONFIG: ToolHooksConverterConfig = { supportedEvents: CLAUDE_HOOK_EVENTS, diff --git a/src/features/skills/codexcli-skill.ts b/src/features/skills/codexcli-skill.ts index acdf00c8b..9cf623d0d 100644 --- a/src/features/skills/codexcli-skill.ts +++ b/src/features/skills/codexcli-skill.ts @@ -285,7 +285,7 @@ export class CodexCliSkill extends ToolSkill { ...baseOtherFiles, { relativeFilePathToDirPath: CODEX_OPENAI_YAML_RELATIVE_PATH, - fileBuffer: Buffer.from(dump(openaiObject)), + fileBuffer: Buffer.from(dump(openaiObject, { lineWidth: -1, noRefs: true })), }, ] : baseOtherFiles;