diff --git a/.gitignore b/.gitignore index 9be4ace67..7bc5b5448 100644 --- a/.gitignore +++ b/.gitignore @@ -288,6 +288,7 @@ rulesync.local.jsonc **/.opencode/commands/ **/.pi/prompts/ **/.qwen/commands/ +**/.reasonix/commands/ **/.roo/commands/ **/.takt/facets/instructions/ **/.devin/workflows/ @@ -364,6 +365,7 @@ rulesync.local.jsonc **/.devin/hooks.v1.json **/.augment/settings.json **/.vibe/hooks.toml +**/.reasonix/settings.json **/.cline/command-permissions.json **/.cursor/cli.json **/.junie/allowlist.json diff --git a/README.md b/README.md index 5f999c7b4..d96e5df82 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Takt | ✅ | | ✅ | ✅ | ✅ | ✅ | | ✅ | | Vibe Code | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | | Qwen Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Reasonix | | | ✅ | | | | | | +| Reasonix | | | ✅ | ✅ | | | ✅ | ✅ | | Kiro ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro CLI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro IDE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index f13671582..0cd978e10 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -207,6 +207,8 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Sixteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `notification` ⇄ `Notification`, `todoCreated` ⇄ `TodoCreated`, and `todoCompleted` ⇄ `TodoCompleted`. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt`, `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md). +> **Note:** Reasonix hooks are written to a dedicated `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` (global) — a Claude-Code-style but standalone JSON file, separate from the `[permissions]`/`[[plugins]]` TOML config. Unlike Claude Code, each event key maps directly to a **flat array** of hook objects (no `matcher`/`hooks` wrapper): `{ "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ... } ] }`. Only four of Reasonix's documented events are mapped — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, and `stop` ⇄ `Stop` — since the upstream issue scoped the rest (`SessionStart`, `SessionEnd`, `PostLLMCall`, `SubagentStop`, `Notification`, `PreCompact`) as a separate follow-up. `match` (Reasonix's matcher field name) is honored only on `PreToolUse`/`PostToolUse`; a matcher on any other event is dropped with a warning. The canonical `timeout` field is documented in seconds, while Reasonix's `timeout` is milliseconds, so rulesync converts (`× 1000` on generate, `÷ 1000` on import). Only `command`-type hooks are supported. The `settings.json` file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the [Reasonix Hooks guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md). + ## `.github/mcp.json` and `.copilot/mcp-config.json` Example: @@ -283,6 +285,8 @@ The command body itself uses a Claude Code-compatible **universal syntax** (e.g. > **OpenCode import note:** OpenCode lets commands live both as Markdown files under `.opencode/commands/*.md` **and** inline in `opencode.json`/`opencode.jsonc` under the top-level `command` key. On import, rulesync reads both: each inline entry's `template` becomes the command body and its `description`/`agent`/`model`/`subtask` fields become frontmatter. A Markdown file takes precedence over an inline entry with the same name. +> **Reasonix note:** Custom slash commands are Markdown files under `.reasonix/commands/` (project) / `~/.reasonix/commands/` (global, via `--global`) — directly analogous to Claude Code's `.claude/commands/`, since Reasonix explicitly mirrors Claude Code's conventions. Frontmatter supports `description` and `argument-hint`, and the body uses the same `$ARGUMENTS` / `$1`…`$N` placeholder syntax. Subdirectory namespacing is supported (`git/commit.md` → `/git:commit`). Any extra fields are preserved on round-trip under the `reasonix:` block. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md#slash-commands). + ## `rulesync/subagents/*.md` Example: @@ -702,7 +706,7 @@ Subagents live in the `subagents/` subdirectory so the command-recipe and subage Vibe (mistral-vibe) MCP servers live in `[[mcp_servers]]` arrays of the shared `.vibe/config.toml`. In addition to the flat fields, Rulesync passes through the stdio `cwd` (working directory) and a structured per-server `auth` block (Vibe v2.15.0+). The `auth` table is discriminated on `type`: `static` (`headers`, `api_key_env`, `api_key_header`, `api_key_format`) and `oauth` (`scopes`, `client_id` / `client_metadata_url`, `redirect_port`). Because Vibe rejects mixing legacy top-level static-auth keys with an explicit `[auth]` block, Rulesync suppresses the legacy keys (`headers`/`api_key_env`/`api_key_header`/`api_key_format`) whenever a server carries an `auth` block. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/config/_settings.py`). -> **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`), and the deprecated `sse` transport is collapsed onto `http`. The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp). +> **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`), and the deprecated `sse` transport is collapsed onto `http`. The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists, but each plugin entry may carry an optional `trusted_read_only_tools` array (raw MCP tool names pre-seeded as trusted for planner/read-only use); rulesync has no deep canonical mapping for it, so it round-trips as a passthrough field on the canonical MCP server object. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp) and [SPEC.md](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md) (`[[plugins]]` schema). ## `.rulesync/.aiignore` or `.rulesyncignore` @@ -880,4 +884,6 @@ For Amp, this writes to the shared `.amp/settings.json` (project mode) or `~/.co For JetBrains Junie CLI, this generates the Action Allowlist `rules` object in `.junie/allowlist.json` (project mode) or `~/.junie/allowlist.json` (global mode). Junie evaluates the allowlist top-to-bottom (first match wins) and groups rules into four buckets, onto which rulesync categories map: `bash` → `executables`, `edit`/`write` → `fileEditing`, `read` → `readOutsideProject`, `mcp` → `mcpTools`. Each rule carries an `action` (`allow` | `ask` | `deny`, a 1:1 with rulesync's canonical actions) plus either a literal `prefix` (matches commands that start with it) or a glob `pattern` (`*`, `**`, `?`, `[abc]`, `[!abc]`); rulesync emits `pattern` when the canonical pattern contains a glob metacharacter (`*`, `?`, `[`) and `prefix` otherwise. Categories Junie cannot represent (e.g. `webfetch`, `websearch`) are skipped with a warning when they carry rules. rulesync **owns the `rules` object** (it is replaced from the rulesync output on each generate), while the top-level `defaultBehavior` (defaulting to Junie's documented `ask` when absent) and `allowReadonlyCommands` settings — which have no canonical equivalent — are preserved verbatim on round-trip but not imported back into the rulesync model. Because `edit`/`write` both collapse onto `fileEditing`, importing normalizes back to `edit` (a documented, lossy mapping). The `allowlist.json` file is never deleted. See the [Junie Action Allowlist docs](https://junie.jetbrains.com/docs/action-allowlist-junie-cli.html). +For Reasonix, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in the `[permissions]` table of the shared `reasonix.toml` (project mode) or `~/.reasonix/config.toml` (global mode) — the same TOML file the MCP feature's `[[plugins]]` array-of-tables lives in. The rule syntax mirrors Claude Code's: entries are `Bash()`, `Read()`, `Edit()`, `Write()`, `WebFetch()`, `WebSearch()`, `Grep()`, `Glob()`, `NotebookEdit()`, `Agent()`, etc. (Reasonix's SPEC.md documents these as "Claude Code-style" families; `agent` → `Agent` is the one lower-confidence mapping, since Reasonix's own delegation tool is internally named `task`). `[permissions].mode` (the writer fallback: `ask`/`allow`/`deny`) has no canonical rulesync equivalent and is preserved untouched. The TOML file is shared with the MCP feature, so writes only replace the `permissions` table — every other table (`[[plugins]]`, `[agent]`, `[ui]`, …) is preserved on round-trip, and the file is never deleted. See [SPEC.md §3.7 Permissions](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md). + > **Note: Interaction with ignore feature.** Both the ignore feature and the permissions feature can manage `Read` tool deny entries in `.claude/settings.json`. When both features configure the `Read` tool, the **permissions feature takes precedence** and a warning is emitted. If you only need to restrict file reads based on glob patterns, use the ignore feature (`.rulesync/.aiignore`). Use permissions only when you need fine-grained `allow`/`ask`/`deny` control over the `Read` tool. diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 2eba4c434..04e182247 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -27,7 +27,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Takt | takt | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Reasonix | reasonix | | | ✅ 🌏 | | | | | | +| Reasonix | reasonix | | | ✅ 🌏 | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | | Kiro ⚠️ | kiro | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro CLI | kiro-cli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | Kiro IDE | kiro-ide | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index f13671582..0cd978e10 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -207,6 +207,8 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Sixteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `notification` ⇄ `Notification`, `todoCreated` ⇄ `TodoCreated`, and `todoCompleted` ⇄ `TodoCompleted`. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt`, `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md). +> **Note:** Reasonix hooks are written to a dedicated `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` (global) — a Claude-Code-style but standalone JSON file, separate from the `[permissions]`/`[[plugins]]` TOML config. Unlike Claude Code, each event key maps directly to a **flat array** of hook objects (no `matcher`/`hooks` wrapper): `{ "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ... } ] }`. Only four of Reasonix's documented events are mapped — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, and `stop` ⇄ `Stop` — since the upstream issue scoped the rest (`SessionStart`, `SessionEnd`, `PostLLMCall`, `SubagentStop`, `Notification`, `PreCompact`) as a separate follow-up. `match` (Reasonix's matcher field name) is honored only on `PreToolUse`/`PostToolUse`; a matcher on any other event is dropped with a warning. The canonical `timeout` field is documented in seconds, while Reasonix's `timeout` is milliseconds, so rulesync converts (`× 1000` on generate, `÷ 1000` on import). Only `command`-type hooks are supported. The `settings.json` file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the [Reasonix Hooks guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md). + ## `.github/mcp.json` and `.copilot/mcp-config.json` Example: @@ -283,6 +285,8 @@ The command body itself uses a Claude Code-compatible **universal syntax** (e.g. > **OpenCode import note:** OpenCode lets commands live both as Markdown files under `.opencode/commands/*.md` **and** inline in `opencode.json`/`opencode.jsonc` under the top-level `command` key. On import, rulesync reads both: each inline entry's `template` becomes the command body and its `description`/`agent`/`model`/`subtask` fields become frontmatter. A Markdown file takes precedence over an inline entry with the same name. +> **Reasonix note:** Custom slash commands are Markdown files under `.reasonix/commands/` (project) / `~/.reasonix/commands/` (global, via `--global`) — directly analogous to Claude Code's `.claude/commands/`, since Reasonix explicitly mirrors Claude Code's conventions. Frontmatter supports `description` and `argument-hint`, and the body uses the same `$ARGUMENTS` / `$1`…`$N` placeholder syntax. Subdirectory namespacing is supported (`git/commit.md` → `/git:commit`). Any extra fields are preserved on round-trip under the `reasonix:` block. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md#slash-commands). + ## `rulesync/subagents/*.md` Example: @@ -702,7 +706,7 @@ Subagents live in the `subagents/` subdirectory so the command-recipe and subage Vibe (mistral-vibe) MCP servers live in `[[mcp_servers]]` arrays of the shared `.vibe/config.toml`. In addition to the flat fields, Rulesync passes through the stdio `cwd` (working directory) and a structured per-server `auth` block (Vibe v2.15.0+). The `auth` table is discriminated on `type`: `static` (`headers`, `api_key_env`, `api_key_header`, `api_key_format`) and `oauth` (`scopes`, `client_id` / `client_metadata_url`, `redirect_port`). Because Vibe rejects mixing legacy top-level static-auth keys with an explicit `[auth]` block, Rulesync suppresses the legacy keys (`headers`/`api_key_env`/`api_key_header`/`api_key_format`) whenever a server carries an `auth` block. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/config/_settings.py`). -> **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`), and the deprecated `sse` transport is collapsed onto `http`. The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp). +> **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`), and the deprecated `sse` transport is collapsed onto `http`. The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists, but each plugin entry may carry an optional `trusted_read_only_tools` array (raw MCP tool names pre-seeded as trusted for planner/read-only use); rulesync has no deep canonical mapping for it, so it round-trips as a passthrough field on the canonical MCP server object. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp) and [SPEC.md](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md) (`[[plugins]]` schema). ## `.rulesync/.aiignore` or `.rulesyncignore` @@ -880,4 +884,6 @@ For Amp, this writes to the shared `.amp/settings.json` (project mode) or `~/.co For JetBrains Junie CLI, this generates the Action Allowlist `rules` object in `.junie/allowlist.json` (project mode) or `~/.junie/allowlist.json` (global mode). Junie evaluates the allowlist top-to-bottom (first match wins) and groups rules into four buckets, onto which rulesync categories map: `bash` → `executables`, `edit`/`write` → `fileEditing`, `read` → `readOutsideProject`, `mcp` → `mcpTools`. Each rule carries an `action` (`allow` | `ask` | `deny`, a 1:1 with rulesync's canonical actions) plus either a literal `prefix` (matches commands that start with it) or a glob `pattern` (`*`, `**`, `?`, `[abc]`, `[!abc]`); rulesync emits `pattern` when the canonical pattern contains a glob metacharacter (`*`, `?`, `[`) and `prefix` otherwise. Categories Junie cannot represent (e.g. `webfetch`, `websearch`) are skipped with a warning when they carry rules. rulesync **owns the `rules` object** (it is replaced from the rulesync output on each generate), while the top-level `defaultBehavior` (defaulting to Junie's documented `ask` when absent) and `allowReadonlyCommands` settings — which have no canonical equivalent — are preserved verbatim on round-trip but not imported back into the rulesync model. Because `edit`/`write` both collapse onto `fileEditing`, importing normalizes back to `edit` (a documented, lossy mapping). The `allowlist.json` file is never deleted. See the [Junie Action Allowlist docs](https://junie.jetbrains.com/docs/action-allowlist-junie-cli.html). +For Reasonix, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in the `[permissions]` table of the shared `reasonix.toml` (project mode) or `~/.reasonix/config.toml` (global mode) — the same TOML file the MCP feature's `[[plugins]]` array-of-tables lives in. The rule syntax mirrors Claude Code's: entries are `Bash()`, `Read()`, `Edit()`, `Write()`, `WebFetch()`, `WebSearch()`, `Grep()`, `Glob()`, `NotebookEdit()`, `Agent()`, etc. (Reasonix's SPEC.md documents these as "Claude Code-style" families; `agent` → `Agent` is the one lower-confidence mapping, since Reasonix's own delegation tool is internally named `task`). `[permissions].mode` (the writer fallback: `ask`/`allow`/`deny`) has no canonical rulesync equivalent and is preserved untouched. The TOML file is shared with the MCP feature, so writes only replace the `permissions` table — every other table (`[[plugins]]`, `[agent]`, `[ui]`, …) is preserved on round-trip, and the file is never deleted. See [SPEC.md §3.7 Permissions](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md). + > **Note: Interaction with ignore feature.** Both the ignore feature and the permissions feature can manage `Read` tool deny entries in `.claude/settings.json`. When both features configure the `Read` tool, the **permissions feature takes precedence** and a warning is emitted. If you only need to restrict file reads based on glob patterns, use the ignore feature (`.rulesync/.aiignore`). Use permissions only when you need fine-grained `allow`/`ask`/`deny` control over the `Read` tool. diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 2eba4c434..04e182247 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -27,7 +27,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Takt | takt | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Reasonix | reasonix | | | ✅ 🌏 | | | | | | +| Reasonix | reasonix | | | ✅ 🌏 | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | | Kiro ⚠️ | kiro | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro CLI | kiro-cli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | Kiro IDE | kiro-ide | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | diff --git a/src/constants/reasonix-paths.ts b/src/constants/reasonix-paths.ts index 68cbd0da0..17917d521 100644 --- a/src/constants/reasonix-paths.ts +++ b/src/constants/reasonix-paths.ts @@ -1,6 +1,27 @@ +import { join } from "node:path"; + // Reasonix resolves config from `./reasonix.toml` (project) and // `~/.reasonix/config.toml` (global). The project file lives at the repository // root, while the global file lives inside the `.reasonix` directory. export const REASONIX_PROJECT_MCP_FILE_NAME = "reasonix.toml"; export const REASONIX_GLOBAL_DIR = ".reasonix"; export const REASONIX_GLOBAL_MCP_FILE_NAME = "config.toml"; + +// The `[permissions]` table lives in the same shared TOML file as `[[plugins]]`: +// `./reasonix.toml` (project) / `~/.reasonix/config.toml` (global). +export const REASONIX_PROJECT_PERMISSIONS_FILE_NAME = REASONIX_PROJECT_MCP_FILE_NAME; +export const REASONIX_GLOBAL_PERMISSIONS_FILE_NAME = REASONIX_GLOBAL_MCP_FILE_NAME; + +// Hooks and commands live under a `.reasonix/` directory relative to the scope +// root: `/.reasonix/` (project) or `~/.reasonix/` (global, via the +// processor's home-relative outputRoot). This is the same directory name as +// `REASONIX_GLOBAL_DIR` above, reused here under a scope-neutral name because, +// unlike MCP/permissions, hooks/commands live under `.reasonix/` in project +// scope too (there is no repository-root file for these features). +export const REASONIX_DIR = REASONIX_GLOBAL_DIR; +// Hooks live in `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` +// (global) — a separate, Claude-Code-style JSON file from `reasonix.toml`. +export const REASONIX_SETTINGS_FILE_NAME = "settings.json"; +// Custom slash commands: Markdown files under `.reasonix/commands/` (project) / +// `~/.reasonix/commands/` (global). +export const REASONIX_COMMANDS_DIR_PATH = join(REASONIX_DIR, "commands"); diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index 64cf20b16..c28261981 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -33,6 +33,7 @@ describe("E2E: commands", () => { { target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") }, { target: "goose", outputPath: join(".goose", "recipes", "review-pr.yaml") }, { target: "qwencode", outputPath: join(".qwen", "commands", "review-pr.md") }, + { target: "reasonix", outputPath: join(".reasonix", "commands", "review-pr.md") }, ])("should generate $target commands", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -143,6 +144,7 @@ describe("E2E: commands (import)", () => { { target: "pi", sourcePath: join(".pi", "prompts", "review-pr.md") }, { target: "devin", sourcePath: join(".devin", "workflows", "review-pr.md") }, { target: "factorydroid", sourcePath: join(".factory", "commands", "review-pr.md") }, + { target: "reasonix", sourcePath: join(".reasonix", "commands", "review-pr.md") }, ])("should import $target commands", async ({ target, sourcePath }) => { const testDir = getTestDir(); @@ -212,6 +214,7 @@ describe("E2E: commands (global mode)", () => { // Hermes Agent has no project-scoped command location; commands are emitted // as Hermes skills under ~/.hermes/skills//SKILL.md (global only). { target: "hermesagent", outputPath: join(".hermes", "skills", "review-pr", "SKILL.md") }, + { target: "reasonix", outputPath: join(".reasonix", "commands", "review-pr.md") }, ])("should generate $target commands in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index 0954f4a04..ce3b95084 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -264,6 +264,60 @@ describe("E2E: hooks", () => { expect(JSON.stringify(parsed)).toContain(".rulesync/hooks/on-stop.sh"); }); + it("should generate reasonix hooks (.reasonix/settings.json, flat per-event arrays)", async () => { + const testDir = getTestDir(); + + // Reasonix only maps four events (PreToolUse/PostToolUse/UserPromptSubmit/Stop); + // sessionStart has no mapped Reasonix equivalent in rulesync's scoped surface. + const hooksContent = JSON.stringify( + { + version: 1, + hooks: { + preToolUse: [{ command: ".rulesync/hooks/pre-tool.sh", matcher: "bash", timeout: 5 }], + stop: [{ command: ".rulesync/hooks/audit.sh" }], + sessionStart: [{ command: ".rulesync/hooks/session-start.sh" }], + }, + }, + null, + 2, + ); + await writeFileContent(join(testDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH), hooksContent); + + await runGenerate({ target: "reasonix", features: "hooks" }); + + const generatedContent = await readFileContent(join(testDir, ".reasonix", "settings.json")); + const parsed = JSON.parse(generatedContent); + // Flat array of hook objects per event, no matcher-group wrapper. + expect(parsed.hooks.PreToolUse).toEqual([ + { match: "bash", command: ".rulesync/hooks/pre-tool.sh", timeout: 5000 }, + ]); + expect(parsed.hooks.Stop).toEqual([{ command: ".rulesync/hooks/audit.sh" }]); + expect(parsed.hooks.SessionStart).toBeUndefined(); + }); + + it("should import reasonix hooks from .reasonix/settings.json", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, ".reasonix", "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [{ match: "bash", command: "echo audit", timeout: 5000 }], + Stop: [{ command: "echo done" }], + }, + }), + ); + + await runImport({ target: "reasonix", features: "hooks" }); + + const importedContent = await readFileContent(join(testDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH)); + const parsed = JSON.parse(importedContent); + expect(parsed.hooks.preToolUse[0].command).toBe("echo audit"); + expect(parsed.hooks.preToolUse[0].matcher).toBe("bash"); + expect(parsed.hooks.preToolUse[0].timeout).toBe(5); + expect(parsed.hooks.stop[0].command).toBe("echo done"); + }); + it.each([ // claudecode, kiro use shared config files (isDeletable=false) — excluded. // factorydroid now writes a dedicated .factory/hooks.json (isDeletable=true). @@ -676,4 +730,33 @@ describe("E2E: hooks (global mode)", () => { expect(generatedContent).toContain("matcher: terminal"); expect(generatedContent).not.toContain(".rulesync/hooks/wt.sh"); }); + + it("should generate reasonix hooks in home directory", async () => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + const hooksContent = JSON.stringify( + { + version: 1, + root: true, + hooks: { + stop: [{ command: ".rulesync/hooks/audit.sh" }], + }, + }, + null, + 2, + ); + await writeFileContent(join(projectDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH), hooksContent); + + await runGenerate({ + target: "reasonix", + features: "hooks", + global: true, + env: { HOME_DIR: homeDir }, + }); + + const generatedContent = await readFileContent(join(homeDir, ".reasonix", "settings.json")); + const parsed = JSON.parse(generatedContent); + expect(parsed.hooks.Stop).toEqual([{ command: ".rulesync/hooks/audit.sh" }]); + }); }); diff --git a/src/e2e/e2e-permissions.spec.ts b/src/e2e/e2e-permissions.spec.ts index d3303edc1..c2381954a 100644 --- a/src/e2e/e2e-permissions.spec.ts +++ b/src/e2e/e2e-permissions.spec.ts @@ -506,6 +506,68 @@ describe("E2E: permissions", () => { expect(parsed.disabled_tools).toContain("write_file"); }); + it("should generate reasonix permissions into reasonix.toml and preserve MCP plugins", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, "reasonix.toml"), + [ + 'default_model = "deepseek"', + "", + "[[plugins]]", + 'name = "filesystem"', + 'command = "npx"', + ].join("\n"), + ); + await writeFileContent( + join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), + JSON.stringify( + { + permission: { + bash: { "*": "ask", "git *": "allow", "rm -rf *": "deny" }, + edit: { "docs/**": "allow" }, + }, + }, + null, + 2, + ), + ); + + await runGenerate({ target: "reasonix", features: "permissions" }); + + const parsed = toTable(smolToml.parse(await readFileContent(join(testDir, "reasonix.toml")))); + const permissions = toTable(parsed.permissions); + expect(permissions.allow).toContain("Bash(git *)"); + expect(permissions.allow).toContain("Edit(docs/**)"); + expect(permissions.ask).toContain("Bash"); + expect(permissions.deny).toContain("Bash(rm -rf *)"); + // The MCP [[plugins]] table (written by the MCP adapter) must survive. + expect(toTableArray(parsed.plugins)).toMatchObject([{ name: "filesystem", command: "npx" }]); + expect(parsed.default_model).toBe("deepseek"); + }); + + it("should import reasonix permissions from reasonix.toml", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, "reasonix.toml"), + [ + "[permissions]", + 'allow = ["Bash(git *)", "Edit(docs/**)"]', + 'deny = ["Bash(rm -rf *)"]', + ].join("\n"), + ); + + await runImport({ target: "reasonix", features: "permissions" }); + + const content = JSON.parse( + await readFileContent(join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH)), + ); + expect(content.permission.bash["git *"]).toBe("allow"); + expect(content.permission.bash["rm -rf *"]).toBe("deny"); + expect(content.permission.edit["docs/**"]).toBe("allow"); + }); + it("should generate takt permissions into .takt/config.yaml", async () => { const testDir = getTestDir(); @@ -1607,6 +1669,48 @@ describe("E2E: permissions (global mode)", () => { expect(parsed.model).toBe("hermes-large"); expect(parsed.terminal).toBe("tmux"); }); + + it("should generate reasonix permissions in home directory with --global", async () => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + await writeFileContent( + join(projectDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), + JSON.stringify( + { + root: true, + permission: { + bash: { "git status *": "allow" }, + read: { ".env": "deny" }, + }, + }, + null, + 2, + ), + ); + + // Pre-seed ~/.reasonix/config.toml with the MCP [[plugins]] table to verify + // the non-destructive merge into the shared global config. + await writeFileContent( + join(homeDir, ".reasonix", "config.toml"), + ["[[plugins]]", 'name = "existing"', 'command = "node"'].join("\n"), + ); + + await runGenerate({ + target: "reasonix", + features: "permissions", + global: true, + env: { HOME_DIR: homeDir }, + }); + + const parsed = toTable( + smolToml.parse(await readFileContent(join(homeDir, ".reasonix", "config.toml"))), + ); + const permissions = toTable(parsed.permissions); + expect(permissions.allow).toContain("Bash(git status *)"); + expect(permissions.deny).toContain("Read(.env)"); + expect(toTableArray(parsed.plugins)).toMatchObject([{ name: "existing", command: "node" }]); + }); }); type AugmentEntry = { diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index e128693ec..93404e296 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -1080,6 +1080,7 @@ describe("CommandsProcessor", () => { "opencode", "pi", "qwencode", + "reasonix", "roo", "takt", "devin", @@ -1110,6 +1111,7 @@ describe("CommandsProcessor", () => { "opencode", "pi", "qwencode", + "reasonix", "roo", "takt", "devin", @@ -1139,6 +1141,7 @@ describe("CommandsProcessor", () => { "opencode", "pi", "qwencode", + "reasonix", "takt", "devin", ]), diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index 9ef13fe10..461528136 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -32,6 +32,7 @@ import { KiroIdeCommand } from "./kiro-ide-command.js"; import { OpenCodeCommand } from "./opencode-command.js"; import { PiCommand } from "./pi-command.js"; import { QwencodeCommand } from "./qwencode-command.js"; +import { ReasonixCommand } from "./reasonix-command.js"; import { RooCommand } from "./roo-command.js"; import { RulesyncCommand } from "./rulesync-command.js"; import { TaktCommand } from "./takt-command.js"; @@ -379,6 +380,23 @@ export const toolCommandFactories = new Map `/git:commit`). + class: ReasonixCommand, + meta: { + extension: "md", + supportsProject: true, + supportsGlobal: true, + isSimulated: false, + supportsSubdirectory: true, + }, + }, + ], [ "roo", { diff --git a/src/features/commands/reasonix-command.test.ts b/src/features/commands/reasonix-command.test.ts new file mode 100644 index 000000000..f9f3b65b9 --- /dev/null +++ b/src/features/commands/reasonix-command.test.ts @@ -0,0 +1,400 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { ReasonixCommand, ReasonixCommandFrontmatterSchema } from "./reasonix-command.js"; +import { RulesyncCommand } from "./rulesync-command.js"; + +describe("ReasonixCommand", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("constructor", () => { + it("should create a valid ReasonixCommand instance", () => { + const command = new ReasonixCommand({ + outputRoot: testDir, + relativeDirPath: ".reasonix/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Test command" }, + body: "This is a test command body", + }); + + expect(command).toBeInstanceOf(ReasonixCommand); + expect(command.getBody()).toBe("This is a test command body"); + expect(command.getFrontmatter()).toEqual({ description: "Test command" }); + }); + + it("should validate frontmatter during construction by default", () => { + expect(() => { + new ReasonixCommand({ + outputRoot: testDir, + relativeDirPath: ".reasonix/commands", + relativeFilePath: "test.md", + frontmatter: { description: 123 as any }, + body: "This is a test command body", + validate: true, + }); + }).toThrow(); + }); + + it("should skip validation when validate is false", () => { + const command = new ReasonixCommand({ + outputRoot: testDir, + relativeDirPath: ".reasonix/commands", + relativeFilePath: "test.md", + frontmatter: { description: 123 as any }, + body: "This is a test command body", + validate: false, + }); + + expect(command).toBeInstanceOf(ReasonixCommand); + }); + + it("should generate correct file content with frontmatter", () => { + const command = new ReasonixCommand({ + outputRoot: testDir, + relativeDirPath: ".reasonix/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Test command", "argument-hint": "[focus-area]" }, + body: "Review the staged diff. Focus on $ARGUMENTS.", + }); + + const fileContent = command.getFileContent(); + expect(fileContent).toContain("---"); + expect(fileContent).toContain("description: Test command"); + expect(fileContent).toContain("argument-hint"); + expect(fileContent).toContain("[focus-area]"); + expect(fileContent).toContain("Review the staged diff. Focus on $ARGUMENTS."); + }); + }); + + describe("getSettablePaths", () => { + it("should return .reasonix/commands for project mode", () => { + const paths = ReasonixCommand.getSettablePaths({ global: false }); + expect(paths.relativeDirPath).toBe(join(".reasonix", "commands")); + }); + + it("should return .reasonix/commands for global mode too (home-relative outputRoot)", () => { + const paths = ReasonixCommand.getSettablePaths({ global: true }); + expect(paths.relativeDirPath).toBe(join(".reasonix", "commands")); + }); + }); + + describe("toRulesyncCommand", () => { + it("should convert to RulesyncCommand correctly", () => { + const command = new ReasonixCommand({ + outputRoot: testDir, + relativeDirPath: ".reasonix/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Test command" }, + body: "Command body content", + }); + + const rulesyncCommand = command.toRulesyncCommand(); + + expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); + expect(rulesyncCommand.getBody()).toBe("Command body content"); + expect(rulesyncCommand.getFrontmatter()).toEqual({ + targets: ["*"], + description: "Test command", + }); + expect(rulesyncCommand.getRelativeDirPath()).toBe(RULESYNC_COMMANDS_RELATIVE_DIR_PATH); + expect(rulesyncCommand.getRelativeFilePath()).toBe("test.md"); + }); + + it("should preserve extra fields (e.g. argument-hint) in the reasonix section", () => { + const command = new ReasonixCommand({ + outputRoot: testDir, + relativeDirPath: ".reasonix/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Test command", "argument-hint": "[focus-area]" }, + body: "Test body", + }); + + const rulesyncCommand = command.toRulesyncCommand(); + const frontmatter = rulesyncCommand.getFrontmatter(); + + expect(frontmatter.reasonix).toEqual({ "argument-hint": "[focus-area]" }); + }); + + it("should not include reasonix section when no extra fields", () => { + const command = new ReasonixCommand({ + outputRoot: testDir, + relativeDirPath: ".reasonix/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Test command" }, + body: "Test body", + }); + + const rulesyncCommand = command.toRulesyncCommand(); + expect(rulesyncCommand.getFrontmatter().reasonix).toBeUndefined(); + }); + }); + + describe("fromRulesyncCommand", () => { + it("should create ReasonixCommand from RulesyncCommand", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "sync-test.md", + frontmatter: { + targets: ["*"], + description: "Sync test command", + }, + body: "Sync command body", + fileContent: "", + }); + + const reasonixCommand = ReasonixCommand.fromRulesyncCommand({ + outputRoot: testDir, + rulesyncCommand, + }); + + expect(reasonixCommand).toBeInstanceOf(ReasonixCommand); + expect(reasonixCommand.getBody()).toBe("Sync command body"); + expect(reasonixCommand.getFrontmatter()).toEqual({ + description: "Sync test command", + }); + expect(reasonixCommand.getRelativeDirPath()).toBe(join(".reasonix", "commands")); + expect(reasonixCommand.getRelativeFilePath()).toBe("sync-test.md"); + }); + + it("should preserve reasonix-specific fields (e.g. argument-hint)", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "passthrough-test.md", + frontmatter: { + targets: ["reasonix"], + description: "Test command", + reasonix: { + "argument-hint": "[focus-area]", + }, + }, + body: "Test body", + fileContent: "", + }); + + const reasonixCommand = ReasonixCommand.fromRulesyncCommand({ + outputRoot: testDir, + rulesyncCommand, + }); + + const frontmatter = reasonixCommand.getFrontmatter(); + expect(frontmatter.description).toBe("Test command"); + expect(frontmatter["argument-hint"]).toBe("[focus-area]"); + }); + + it("should use global paths when global is true", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "global-test.md", + frontmatter: { + targets: ["*"], + description: "Global test command", + }, + body: "Global command body", + fileContent: "", + }); + + const reasonixCommand = ReasonixCommand.fromRulesyncCommand({ + outputRoot: testDir, + rulesyncCommand, + global: true, + }); + + expect(reasonixCommand.getRelativeDirPath()).toBe(join(".reasonix", "commands")); + expect(reasonixCommand.getBody()).toBe("Global command body"); + }); + }); + + describe("fromFile", () => { + it("should load ReasonixCommand from file", async () => { + const commandsDir = join(testDir, ".reasonix", "commands"); + await ensureDir(commandsDir); + + const fileContent = `--- +description: File test command +argument-hint: "[message]" +--- +This is the command body from file`; + + const filePath = join(commandsDir, "file-test.md"); + await writeFileContent(filePath, fileContent); + + const command = await ReasonixCommand.fromFile({ + outputRoot: testDir, + relativeFilePath: "file-test.md", + }); + + expect(command).toBeInstanceOf(ReasonixCommand); + expect(command.getBody()).toBe("This is the command body from file"); + expect(command.getFrontmatter()).toEqual({ + description: "File test command", + "argument-hint": "[message]", + }); + expect(command.getRelativeFilePath()).toBe("file-test.md"); + }); + + it("should throw error for invalid frontmatter", async () => { + const commandsDir = join(testDir, ".reasonix", "commands"); + await ensureDir(commandsDir); + + const fileContent = `--- +description: 123 +--- +Command body`; + + const filePath = join(commandsDir, "invalid-test.md"); + await writeFileContent(filePath, fileContent); + + await expect( + ReasonixCommand.fromFile({ + outputRoot: testDir, + relativeFilePath: "invalid-test.md", + }), + ).rejects.toThrow(/Invalid frontmatter/); + }); + + it("should preserve subdirectory path (namespacing, e.g. git/commit.md)", async () => { + const commandsDir = join(testDir, ".reasonix", "commands", "git"); + await ensureDir(commandsDir); + + const fileContent = `--- +description: Commit command +--- +Commit command body`; + + const filePath = join(commandsDir, "commit.md"); + await writeFileContent(filePath, fileContent); + + const command = await ReasonixCommand.fromFile({ + outputRoot: testDir, + relativeFilePath: join("git", "commit.md"), + }); + + expect(command).toBeInstanceOf(ReasonixCommand); + expect(command.getRelativeFilePath()).toBe(join("git", "commit.md")); + }); + }); + + describe("validate", () => { + it("should return success for valid frontmatter", () => { + const command = new ReasonixCommand({ + outputRoot: testDir, + relativeDirPath: ".reasonix/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Valid description" }, + body: "Command body", + }); + + const result = command.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + + it("should return error for invalid frontmatter", () => { + const command = new ReasonixCommand({ + outputRoot: testDir, + relativeDirPath: ".reasonix/commands", + relativeFilePath: "test.md", + frontmatter: { description: 123 as any }, + body: "Command body", + validate: false, + }); + + const result = command.validate(); + expect(result.success).toBe(false); + expect(result.error).toBeInstanceOf(Error); + }); + }); + + describe("forDeletion", () => { + it("should create a minimal instance for deletion", () => { + const command = ReasonixCommand.forDeletion({ + outputRoot: testDir, + relativeDirPath: ".reasonix/commands", + relativeFilePath: "orphan.md", + }); + + expect(command).toBeInstanceOf(ReasonixCommand); + }); + }); + + describe("isTargetedByRulesyncCommand", () => { + it("should return true for rulesync command with wildcard target", () => { + const rulesyncCommand = new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { targets: ["*"], description: "Test" }, + body: "Body", + fileContent: "", + }); + + expect(ReasonixCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return true for rulesync command with reasonix target", () => { + const rulesyncCommand = new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { targets: ["reasonix"], description: "Test" }, + body: "Body", + fileContent: "", + }); + + expect(ReasonixCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return false for rulesync command with a different target", () => { + const rulesyncCommand = new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { targets: ["cursor"], description: "Test" }, + body: "Body", + fileContent: "", + }); + + expect(ReasonixCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(false); + }); + }); + + describe("ReasonixCommandFrontmatterSchema", () => { + it("should validate correct frontmatter", () => { + const result = ReasonixCommandFrontmatterSchema.safeParse({ + description: "Valid description", + "argument-hint": "[message]", + }); + + expect(result.success).toBe(true); + }); + + it("should reject frontmatter with non-string description", () => { + const result = ReasonixCommandFrontmatterSchema.safeParse({ description: 123 }); + expect(result.success).toBe(false); + }); + + it("should allow additional properties (loose object)", () => { + const result = ReasonixCommandFrontmatterSchema.safeParse({ + description: "Valid description", + extra: "property", + }); + + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/features/commands/reasonix-command.ts b/src/features/commands/reasonix-command.ts new file mode 100644 index 000000000..58caa15f3 --- /dev/null +++ b/src/features/commands/reasonix-command.ts @@ -0,0 +1,204 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { REASONIX_COMMANDS_DIR_PATH } from "../../constants/reasonix-paths.js"; +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContent } from "../../utils/file.js"; +import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; +import { + ToolCommand, + ToolCommandForDeletionParams, + ToolCommandFromFileParams, + ToolCommandFromRulesyncCommandParams, + ToolCommandSettablePaths, +} from "./tool-command.js"; + +/** + * Reasonix custom slash commands are Markdown files under `.reasonix/commands/` + * (project) / `~/.reasonix/commands/` (global) — directly analogous to Claude + * Code's `.claude/commands/` (Reasonix explicitly copies Claude Code's + * conventions). Frontmatter supports `description` and `argument-hint`; the + * body uses the same `$ARGUMENTS` / `$1`…`$N` placeholder syntax rulesync's + * universal command-body syntax already targets. + * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md + */ +// looseObject preserves unknown keys during parsing (like passthrough in Zod 3) +export const ReasonixCommandFrontmatterSchema = z.looseObject({ + description: z.optional(z.string()), + "argument-hint": z.optional(z.string()), +}); + +export type ReasonixCommandFrontmatter = z.infer; + +export type ReasonixCommandParams = { + frontmatter: ReasonixCommandFrontmatter; + body: string; +} & Omit; + +export class ReasonixCommand extends ToolCommand { + private readonly frontmatter: ReasonixCommandFrontmatter; + private readonly body: string; + + constructor({ frontmatter, body, ...rest }: ReasonixCommandParams) { + // Validate frontmatter before calling super to avoid validation order issues + if (rest.validate) { + const result = ReasonixCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error( + `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, + ); + } + } + + super({ + ...rest, + fileContent: stringifyFrontmatter(body, frontmatter), + }); + + this.frontmatter = frontmatter; + this.body = body; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolCommandSettablePaths { + // Both project and global scope use the same relative dir; the processor + // supplies the home directory as outputRoot in global mode. + return { + relativeDirPath: REASONIX_COMMANDS_DIR_PATH, + }; + } + + getBody(): string { + return this.body; + } + + getFrontmatter(): Record { + return this.frontmatter; + } + + toRulesyncCommand(): RulesyncCommand { + const { description, ...restFields } = this.frontmatter; + + const rulesyncFrontmatter: RulesyncCommandFrontmatter = { + targets: ["*"], + description, + // Preserve extra fields in the reasonix section + ...(Object.keys(restFields).length > 0 && { reasonix: restFields }), + }; + + // Generate proper file content with Rulesync specific frontmatter + const fileContent = stringifyFrontmatter(this.body, rulesyncFrontmatter); + + return new RulesyncCommand({ + outputRoot: ".", // RulesyncCommand outputRoot is always the project root directory + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, + relativeFilePath: this.relativeFilePath, + fileContent, + validate: true, + }); + } + + static fromRulesyncCommand({ + outputRoot = process.cwd(), + rulesyncCommand, + validate = true, + global = false, + }: ToolCommandFromRulesyncCommandParams): ReasonixCommand { + const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); + + // Merge reasonix-specific fields from rulesync frontmatter + const reasonixFields = rulesyncFrontmatter.reasonix ?? {}; + + const reasonixFrontmatter: ReasonixCommandFrontmatter = { + description: rulesyncFrontmatter.description, + ...reasonixFields, + }; + + // Generate proper file content with Reasonix specific frontmatter + const body = rulesyncCommand.getBody(); + + const paths = this.getSettablePaths({ global }); + + return new ReasonixCommand({ + outputRoot: outputRoot, + frontmatter: reasonixFrontmatter, + body, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: rulesyncCommand.getRelativeFilePath(), + validate, + }); + } + + validate(): ValidationResult { + // Check if frontmatter is set (may be undefined during construction) + if (!this.frontmatter) { + return { success: true, error: null }; + } + + const result = ReasonixCommandFrontmatterSchema.safeParse(this.frontmatter); + if (result.success) { + return { success: true, error: null }; + } else { + return { + success: false, + error: new Error( + `Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, + ), + }; + } + } + + static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { + return this.isTargetedByRulesyncCommandDefault({ + rulesyncCommand, + toolTarget: "reasonix", + }); + } + + static async fromFile({ + outputRoot = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolCommandFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath); + // Read file content + const fileContent = await readFileContent(filePath); + const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); + + // Validate required fields using ReasonixCommandFrontmatterSchema + const result = ReasonixCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } + + return new ReasonixCommand({ + outputRoot: outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + frontmatter: result.data, + body: content.trim(), + validate, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolCommandForDeletionParams): ReasonixCommand { + return new ReasonixCommand({ + outputRoot, + relativeDirPath, + relativeFilePath, + frontmatter: { description: "" }, + body: "", + validate: false, + }); + } +} diff --git a/src/features/hooks/hooks-processor.test.ts b/src/features/hooks/hooks-processor.test.ts index 869804693..df26fa562 100644 --- a/src/features/hooks/hooks-processor.test.ts +++ b/src/features/hooks/hooks-processor.test.ts @@ -540,6 +540,7 @@ describe("HooksProcessor", () => { "augmentcode", "vibe", "qwencode", + "reasonix", ]); }); @@ -564,6 +565,7 @@ describe("HooksProcessor", () => { "junie", "vibe", "qwencode", + "reasonix", ]); }); @@ -586,6 +588,7 @@ describe("HooksProcessor", () => { "augmentcode", "vibe", "qwencode", + "reasonix", ]); }); @@ -608,6 +611,7 @@ describe("HooksProcessor", () => { "junie", "vibe", "qwencode", + "reasonix", ]); }); }); diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index c68828ce9..e071ea4e8 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -21,6 +21,7 @@ import { KIRO_IDE_HOOK_EVENTS, OPENCODE_HOOK_EVENTS, QWENCODE_HOOK_EVENTS, + REASONIX_HOOK_EVENTS, VIBE_HOOK_EVENTS, type HookEvent, type HookType, @@ -50,6 +51,7 @@ import { KiroHooks } from "./kiro-hooks.js"; import { KiroIdeHooks } from "./kiro-ide-hooks.js"; import { OpencodeHooks } from "./opencode-hooks.js"; import { QwencodeHooks } from "./qwencode-hooks.js"; +import { ReasonixHooks } from "./reasonix-hooks.js"; import { RulesyncHooks } from "./rulesync-hooks.js"; import type { ToolHooksForDeletionParams, @@ -450,6 +452,28 @@ export const toolHooksFactories = new Map { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return .reasonix and settings.json for project mode", () => { + const paths = ReasonixHooks.getSettablePaths({ global: false }); + expect(paths).toEqual({ relativeDirPath: ".reasonix", relativeFilePath: "settings.json" }); + }); + + it("should return .reasonix and settings.json for global mode", () => { + const paths = ReasonixHooks.getSettablePaths({ global: true }); + expect(paths).toEqual({ relativeDirPath: ".reasonix", relativeFilePath: "settings.json" }); + }); + }); + + describe("fromRulesyncHooks", () => { + it("should filter shared hooks to the four documented Reasonix events", async () => { + await ensureDir(join(testDir, ".reasonix")); + await writeFileContent(join(testDir, ".reasonix", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + preToolUse: [{ command: ".rulesync/hooks/pre-tool.sh" }], + postToolUse: [{ command: ".rulesync/hooks/post-tool.sh" }], + beforeSubmitPrompt: [{ command: ".rulesync/hooks/prompt.sh" }], + stop: [{ command: ".rulesync/hooks/audit.sh" }], + sessionStart: [{ command: ".rulesync/hooks/session-start.sh" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const reasonixHooks = await ReasonixHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const parsed = JSON.parse(reasonixHooks.getFileContent()); + expect(parsed.hooks.PreToolUse).toBeDefined(); + expect(parsed.hooks.PostToolUse).toBeDefined(); + expect(parsed.hooks.UserPromptSubmit).toBeDefined(); + expect(parsed.hooks.Stop).toBeDefined(); + // sessionStart has no Reasonix mapping in the scoped event set. + expect(parsed.hooks.SessionStart).toBeUndefined(); + }); + + it("should emit a flat array of hook objects per event (no matcher-group wrapper)", async () => { + const config = { + version: 1, + hooks: { + preToolUse: [ + { + command: "node .reasonix/hooks/check-bash.js", + matcher: "bash", + description: "Block dangerous shell commands", + timeout: 5, + }, + ], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const reasonixHooks = await ReasonixHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const parsed = JSON.parse(reasonixHooks.getFileContent()); + expect(parsed.hooks.PreToolUse).toEqual([ + { + match: "bash", + command: "node .reasonix/hooks/check-bash.js", + description: "Block dangerous shell commands", + timeout: 5000, + }, + ]); + }); + + it("should convert canonical timeout (seconds) to Reasonix timeout (milliseconds)", async () => { + const config = { + version: 1, + hooks: { + stop: [{ command: "echo done", timeout: 3 }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const reasonixHooks = await ReasonixHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const parsed = JSON.parse(reasonixHooks.getFileContent()); + expect(parsed.hooks.Stop[0].timeout).toBe(3000); + }); + + it("should drop the matcher on non-tool events with a warning", async () => { + const config = { + version: 1, + hooks: { + stop: [{ command: "echo done", matcher: "bash" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const reasonixHooks = await ReasonixHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + logger, + }); + + const parsed = JSON.parse(reasonixHooks.getFileContent()); + expect(parsed.hooks.Stop[0].match).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("does not support matchers"), + ); + }); + + it("should skip non-command hook types (no Reasonix equivalent)", async () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "prompt", prompt: "Are you sure?" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const reasonixHooks = await ReasonixHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const parsed = JSON.parse(reasonixHooks.getFileContent()); + expect(parsed.hooks.PreToolUse).toBeUndefined(); + }); + + it("should merge config.reasonix.hooks on top of shared hooks", async () => { + const config = { + version: 1, + hooks: { + stop: [{ command: "shared.sh" }], + }, + reasonix: { + hooks: { + stop: [{ command: "reasonix-override.sh" }], + }, + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const reasonixHooks = await ReasonixHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const parsed = JSON.parse(reasonixHooks.getFileContent()); + expect(parsed.hooks.Stop[0].command).toBe("reasonix-override.sh"); + }); + + it("should merge into existing .reasonix/settings.json content, preserving other keys", async () => { + await ensureDir(join(testDir, ".reasonix")); + await writeFileContent( + join(testDir, ".reasonix", "settings.json"), + JSON.stringify({ otherKey: "preserved" }), + ); + + const config = { + version: 1, + hooks: { stop: [{ command: "echo" }] }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const reasonixHooks = await ReasonixHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const parsed = JSON.parse(reasonixHooks.getFileContent()); + expect(parsed.otherKey).toBe("preserved"); + expect(parsed.hooks.Stop).toBeDefined(); + }); + + it("should throw error with descriptive message when existing settings.json contains invalid JSON", async () => { + await ensureDir(join(testDir, ".reasonix")); + await writeFileContent(join(testDir, ".reasonix", "settings.json"), "invalid json {"); + + const config = { version: 1, hooks: {} }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + await expect( + ReasonixHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }), + ).rejects.toThrow(/Failed to parse existing Reasonix settings/); + }); + + it("should write to the global path when global is true", async () => { + const config = { version: 1, hooks: { stop: [{ command: "echo" }] } }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const reasonixHooks = await ReasonixHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + global: true, + }); + + expect(reasonixHooks.getRelativeDirPath()).toBe(".reasonix"); + expect(reasonixHooks.getRelativeFilePath()).toBe("settings.json"); + }); + }); + + describe("toRulesyncHooks", () => { + it("should throw error with descriptive message when content contains invalid JSON", () => { + const reasonixHooks = new ReasonixHooks({ + outputRoot: testDir, + relativeDirPath: ".reasonix", + relativeFilePath: "settings.json", + fileContent: "invalid json {", + validate: false, + }); + + expect(() => reasonixHooks.toRulesyncHooks()).toThrow(/Failed to parse Reasonix hooks/); + }); + + it("should convert Reasonix events to canonical camelCase", () => { + const reasonixHooks = new ReasonixHooks({ + outputRoot: testDir, + relativeDirPath: ".reasonix", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ + hooks: { + PreToolUse: [{ match: "bash", command: "echo.sh", timeout: 5000 }], + Stop: [{ command: "audit.sh" }], + }, + }), + validate: false, + }); + + const rulesyncHooks = reasonixHooks.toRulesyncHooks(); + const json = rulesyncHooks.getJson(); + expect(json.hooks.preToolUse).toHaveLength(1); + expect(json.hooks.preToolUse?.[0]?.command).toBe("echo.sh"); + expect(json.hooks.preToolUse?.[0]?.matcher).toBe("bash"); + expect(json.hooks.preToolUse?.[0]?.timeout).toBe(5); + expect(json.hooks.stop).toHaveLength(1); + expect(json.hooks.stop?.[0]?.command).toBe("audit.sh"); + }); + + it("should handle an empty or missing hooks key", () => { + const reasonixHooks = new ReasonixHooks({ + outputRoot: testDir, + relativeDirPath: ".reasonix", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({}), + validate: false, + }); + + const rulesyncHooks = reasonixHooks.toRulesyncHooks(); + expect(rulesyncHooks.getJson().hooks).toEqual({}); + }); + }); + + describe("fromFile", () => { + it("should load from .reasonix/settings.json when it exists", async () => { + await ensureDir(join(testDir, ".reasonix")); + await writeFileContent( + join(testDir, ".reasonix", "settings.json"), + JSON.stringify({ hooks: { Stop: [{ command: "echo" }] } }), + ); + + const reasonixHooks = await ReasonixHooks.fromFile({ + outputRoot: testDir, + validate: false, + }); + expect(reasonixHooks).toBeInstanceOf(ReasonixHooks); + const parsed = JSON.parse(reasonixHooks.getFileContent()); + expect(parsed.hooks.Stop).toEqual([{ command: "echo" }]); + }); + + it("should initialize empty hooks when .reasonix/settings.json does not exist", async () => { + const reasonixHooks = await ReasonixHooks.fromFile({ + outputRoot: testDir, + validate: false, + }); + expect(reasonixHooks).toBeInstanceOf(ReasonixHooks); + const parsed = JSON.parse(reasonixHooks.getFileContent()); + expect(parsed.hooks).toEqual({}); + }); + }); + + describe("isDeletable", () => { + it("should return false", () => { + const reasonixHooks = new ReasonixHooks({ + outputRoot: testDir, + relativeDirPath: ".reasonix", + relativeFilePath: "settings.json", + fileContent: "{}", + validate: false, + }); + expect(reasonixHooks.isDeletable()).toBe(false); + }); + }); + + describe("forDeletion", () => { + it("should return ReasonixHooks instance with empty hooks for deletion path", () => { + const reasonixHooks = ReasonixHooks.forDeletion({ + outputRoot: testDir, + relativeDirPath: ".reasonix", + relativeFilePath: "settings.json", + }); + expect(reasonixHooks).toBeInstanceOf(ReasonixHooks); + const parsed = JSON.parse(reasonixHooks.getFileContent()); + expect(parsed.hooks).toEqual({}); + }); + }); +}); diff --git a/src/features/hooks/reasonix-hooks.ts b/src/features/hooks/reasonix-hooks.ts new file mode 100644 index 000000000..2786f8ef5 --- /dev/null +++ b/src/features/hooks/reasonix-hooks.ts @@ -0,0 +1,276 @@ +import { join } from "node:path"; + +import { REASONIX_DIR, REASONIX_SETTINGS_FILE_NAME } from "../../constants/reasonix-paths.js"; +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { + CANONICAL_TO_REASONIX_EVENT_NAMES, + type HookDefinition, + type HooksConfig, + REASONIX_HOOK_EVENTS, + REASONIX_TO_CANONICAL_EVENT_NAMES, +} from "../../types/hooks.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js"; +import type { Logger } from "../../utils/logger.js"; +import type { RulesyncHooks } from "./rulesync-hooks.js"; +import { + ToolHooks, + type ToolHooksForDeletionParams, + type ToolHooksFromFileParams, + type ToolHooksFromRulesyncHooksParams, + type ToolHooksSettablePaths, +} from "./tool-hooks.js"; + +/** + * A single hook entry serialized under an event key in `.reasonix/settings.json`. + * Unlike Claude Code, Reasonix does not wrap entries in `{ matcher, hooks: [...] }` + * groups — each event key maps directly to a flat array of these objects. + * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md + */ +type ReasonixHookEntry = { + match?: string; + command: string; + description?: string; + timeout?: number; +}; + +/** + * Only PreToolUse/PostToolUse honor the `match` field (an anchored regex + * against the tool name); it is ignored on every other event. + */ +const REASONIX_MATCHER_EVENTS: ReadonlySet = new Set(["PreToolUse", "PostToolUse"]); + +const SUPPORTED_REASONIX_EVENTS: ReadonlySet = new Set(REASONIX_HOOK_EVENTS); + +/** + * Convert canonical hooks config to the Reasonix `hooks` object. + * Filters shared hooks to REASONIX_HOOK_EVENTS, merges `config.reasonix?.hooks`, + * then maps event names and emits flat per-event hook-entry arrays. + */ +function canonicalToReasonixHooks({ + config, + toolOverrideHooks, + logger, +}: { + config: HooksConfig; + toolOverrideHooks: HooksConfig["hooks"] | undefined; + logger?: Logger; +}): Record { + const sharedHooks: HooksConfig["hooks"] = {}; + for (const [event, defs] of Object.entries(config.hooks)) { + if (SUPPORTED_REASONIX_EVENTS.has(event)) { + sharedHooks[event] = defs; + } + } + const effectiveHooks: HooksConfig["hooks"] = { ...sharedHooks, ...toolOverrideHooks }; + + const result: Record = {}; + for (const [event, defs] of Object.entries(effectiveHooks)) { + if (!SUPPORTED_REASONIX_EVENTS.has(event)) { + continue; + } + const reasonixEvent = CANONICAL_TO_REASONIX_EVENT_NAMES[event] ?? event; + const isMatcherEvent = REASONIX_MATCHER_EVENTS.has(reasonixEvent); + const entries: ReasonixHookEntry[] = []; + for (const def of defs) { + if ((def.type ?? "command") !== "command") { + // Reasonix hooks always run a shell command; other canonical hook + // types (prompt/http) have no Reasonix equivalent. + continue; + } + if (typeof def.command !== "string") { + continue; + } + const entry: ReasonixHookEntry = { command: def.command }; + if (typeof def.matcher === "string" && def.matcher !== "") { + if (isMatcherEvent) { + entry.match = def.matcher; + } else { + logger?.warn( + `matcher "${def.matcher}" on "${event}" hook will be ignored — Reasonix's "${reasonixEvent}" event does not support matchers`, + ); + } + } + if (typeof def.description === "string" && def.description !== "") { + entry.description = def.description; + } + if (typeof def.timeout === "number") { + // Canonical `timeout` is documented in seconds (see docs/reference/file-formats.md), + // while Reasonix's `timeout` field is milliseconds, so convert. + entry.timeout = Math.round(def.timeout * 1000); + } + entries.push(entry); + } + if (entries.length > 0) { + result[reasonixEvent] = [...(result[reasonixEvent] ?? []), ...entries]; + } + } + return result; +} + +/** + * Reverse {@link canonicalToReasonixHooks}: parse the Reasonix `hooks` object + * back into a canonical event -> definition[] record. + */ +function reasonixHooksToCanonical(hooks: unknown): HooksConfig["hooks"] { + const canonical: HooksConfig["hooks"] = {}; + if (hooks === null || hooks === undefined || typeof hooks !== "object" || Array.isArray(hooks)) { + return canonical; + } + for (const [reasonixEvent, rawEntries] of Object.entries(hooks as Record)) { + if (!Array.isArray(rawEntries)) { + continue; + } + const canonicalEvent = REASONIX_TO_CANONICAL_EVENT_NAMES[reasonixEvent] ?? reasonixEvent; + const defs: HookDefinition[] = []; + for (const rawEntry of rawEntries) { + if (rawEntry === null || typeof rawEntry !== "object" || Array.isArray(rawEntry)) { + continue; + } + const entry = rawEntry as Record; + if (typeof entry.command !== "string") { + continue; + } + const def: HookDefinition = { type: "command", command: entry.command }; + if (typeof entry.match === "string" && entry.match !== "") { + def.matcher = entry.match; + } + if (typeof entry.description === "string" && entry.description !== "") { + def.description = entry.description; + } + if (typeof entry.timeout === "number") { + def.timeout = entry.timeout / 1000; + } + defs.push(def); + } + if (defs.length > 0) { + canonical[canonicalEvent] = [...(canonical[canonicalEvent] ?? []), ...defs]; + } + } + return canonical; +} + +/** + * Reasonix hooks adapter. + * + * Reasonix hooks live in a Claude-Code-style but standalone JSON file — + * `.reasonix/settings.json` (project) or `~/.reasonix/settings.json` + * (global) — separate from the `[permissions]`/`[[plugins]]` TOML config. + * Only the four events documented in the upstream issue are mapped: + * PreToolUse/PostToolUse/UserPromptSubmit/Stop (see REASONIX_HOOK_EVENTS). + * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md + */ +export class ReasonixHooks extends ToolHooks { + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "{}", + }); + } + + override isDeletable(): boolean { + // settings.json is not documented as holding anything besides hooks today, + // but treat it conservatively (like claudecode-hooks.ts) in case future + // Reasonix versions add other keys to the same file. + return false; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolHooksSettablePaths { + // Both project and global scope use the same `.reasonix/` relative dir; + // the processor supplies the home directory as outputRoot in global mode. + return { relativeDirPath: REASONIX_DIR, relativeFilePath: REASONIX_SETTINGS_FILE_NAME }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + global = false, + }: ToolHooksFromFileParams): Promise { + const paths = ReasonixHooks.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const fileContent = (await readFileContentOrNull(filePath)) ?? '{"hooks":{}}'; + return new ReasonixHooks({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static async fromRulesyncHooks({ + outputRoot = process.cwd(), + rulesyncHooks, + validate = true, + global = false, + logger, + }: ToolHooksFromRulesyncHooksParams & { + global?: boolean; + logger?: Logger; + }): Promise { + const paths = ReasonixHooks.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const existingContent = await readOrInitializeFileContent( + filePath, + JSON.stringify({}, null, 2), + ); + let settings: Record; + try { + settings = JSON.parse(existingContent); + } catch (error) { + throw new Error( + `Failed to parse existing Reasonix settings at ${filePath}: ${formatError(error)}`, + { cause: error }, + ); + } + const config = rulesyncHooks.getJson(); + const reasonixHooks = canonicalToReasonixHooks({ + config, + toolOverrideHooks: config.reasonix?.hooks, + logger, + }); + const merged = { ...settings, hooks: reasonixHooks }; + const fileContent = JSON.stringify(merged, null, 2); + return new ReasonixHooks({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + toRulesyncHooks(): RulesyncHooks { + let settings: { hooks?: unknown }; + try { + settings = JSON.parse(this.getFileContent()); + } catch (error) { + throw new Error( + `Failed to parse Reasonix hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, + { cause: error }, + ); + } + const hooks = reasonixHooksToCanonical(settings.hooks); + 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): ReasonixHooks { + return new ReasonixHooks({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify({ hooks: {} }, null, 2), + validate: false, + }); + } +} diff --git a/src/features/mcp/reasonix-mcp.test.ts b/src/features/mcp/reasonix-mcp.test.ts index 07738c846..4e2246313 100644 --- a/src/features/mcp/reasonix-mcp.test.ts +++ b/src/features/mcp/reasonix-mcp.test.ts @@ -165,4 +165,94 @@ describe("ReasonixMcp", () => { expect(reasonixMcp.isDeletable()).toBe(false); }); + + describe("trusted_read_only_tools round-trip", () => { + it("should preserve trusted_read_only_tools when exporting rulesync MCP servers", async () => { + const rulesyncMcp = new RulesyncMcp({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + search: { + type: "stdio", + command: "reasonix-plugin-search", + trusted_read_only_tools: ["search"], + }, + }, + }), + }); + + const reasonixMcp = await ReasonixMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp }); + const parsed = smolToml.parse(reasonixMcp.getFileContent()) as any; + + expect(parsed.plugins[0]).toMatchObject({ + name: "search", + command: "reasonix-plugin-search", + trusted_read_only_tools: ["search"], + }); + }); + + it("should import trusted_read_only_tools from an existing [[plugins]] entry", () => { + const fileContent = [ + "[[plugins]]", + 'name = "search"', + 'command = "reasonix-plugin-search"', + 'trusted_read_only_tools = ["search"]', + ].join("\n"); + + const reasonixMcp = new ReasonixMcp({ + outputRoot: testDir, + relativeDirPath: ".", + relativeFilePath: "reasonix.toml", + fileContent, + }); + + const parsed = JSON.parse(reasonixMcp.toRulesyncMcp().getFileContent()); + + expect(parsed.mcpServers.search.trusted_read_only_tools).toEqual(["search"]); + }); + + it("should round-trip trusted_read_only_tools through export then import unchanged", async () => { + const rulesyncMcp = new RulesyncMcp({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + example: { + command: "reasonix-plugin-example", + trusted_read_only_tools: ["search", "list_files"], + }, + }, + }), + }); + + const reasonixMcp = await ReasonixMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp }); + const roundTripped = JSON.parse(reasonixMcp.toRulesyncMcp().getFileContent()); + + expect(roundTripped.mcpServers.example.trusted_read_only_tools).toEqual([ + "search", + "list_files", + ]); + }); + + it("should not emit trusted_read_only_tools when absent from the source", async () => { + const rulesyncMcp = new RulesyncMcp({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + plain: { command: "reasonix-plugin-plain" }, + }, + }), + }); + + const reasonixMcp = await ReasonixMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp }); + const parsed = smolToml.parse(reasonixMcp.getFileContent()) as any; + + expect(parsed.plugins[0].trusted_read_only_tools).toBeUndefined(); + }); + }); }); diff --git a/src/features/mcp/reasonix-mcp.ts b/src/features/mcp/reasonix-mcp.ts index 0d0e5b1d5..eaae08192 100644 --- a/src/features/mcp/reasonix-mcp.ts +++ b/src/features/mcp/reasonix-mcp.ts @@ -33,7 +33,22 @@ type ReasonixPlugin = Record & { // Reasonix declares an external stdio/http plugin (MCP server) as a `[[plugins]]` // array-of-tables entry. `type` selects the transport (`stdio` default, `http` // a.k.a. `streamable-http`); the remaining fields mirror the standard MCP schema. -const REASONIX_PLUGIN_FIELDS = ["type", "command", "args", "env", "url", "headers"] as const; +// `trusted_read_only_tools` is Reasonix-specific: an optional pre-seeded list of +// raw MCP tool names trusted for planner/read-only use (plan-mode trust, not a +// per-tool allow/deny list). There is no clean canonical rulesync equivalent, so +// it round-trips as a passthrough field on the canonical McpServer (a loose +// zod object, so unknown keys survive), mirroring how other MCP adapters +// preserve server-specific extra fields they don't deeply model. +// @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md +const REASONIX_PLUGIN_FIELDS = [ + "type", + "command", + "args", + "env", + "url", + "headers", + "trusted_read_only_tools", +] as const; export class ReasonixMcp extends ToolMcp { private readonly toml: ReasonixConfig; diff --git a/src/features/permissions/permissions-processor.test.ts b/src/features/permissions/permissions-processor.test.ts index 44c3ca4ec..c17ea9202 100644 --- a/src/features/permissions/permissions-processor.test.ts +++ b/src/features/permissions/permissions-processor.test.ts @@ -94,6 +94,7 @@ describe("PermissionsProcessor", () => { "kiro-ide", "opencode", "qwencode", + "reasonix", "takt", "vibe", "zed", @@ -118,6 +119,7 @@ describe("PermissionsProcessor", () => { "kilo", "opencode", "qwencode", + "reasonix", "rovodev", "takt", "vibe", @@ -145,6 +147,7 @@ describe("PermissionsProcessor", () => { "kiro-ide", "opencode", "qwencode", + "reasonix", "takt", "vibe", "zed", diff --git a/src/features/permissions/permissions-processor.ts b/src/features/permissions/permissions-processor.ts index 675e64c04..b59eeebfb 100644 --- a/src/features/permissions/permissions-processor.ts +++ b/src/features/permissions/permissions-processor.ts @@ -26,6 +26,7 @@ import { KiloPermissions } from "./kilo-permissions.js"; import { KiroPermissions } from "./kiro-permissions.js"; import { OpencodePermissions } from "./opencode-permissions.js"; import { QwencodePermissions } from "./qwencode-permissions.js"; +import { ReasonixPermissions } from "./reasonix-permissions.js"; import { RovodevPermissions } from "./rovodev-permissions.js"; import { RulesyncPermissions } from "./rulesync-permissions.js"; import { TaktPermissions } from "./takt-permissions.js"; @@ -310,6 +311,20 @@ export const toolPermissionsFactories = new Map< }, }, ], + [ + "reasonix", + { + class: ReasonixPermissions, + meta: { + // Reasonix reads the `[permissions]` table from the same shared TOML + // config the MCP adapter already writes: `./reasonix.toml` (project) / + // `~/.reasonix/config.toml` (global). + supportsProject: true, + supportsGlobal: true, + supportsImport: true, + }, + }, + ], [ "rovodev", { diff --git a/src/features/permissions/reasonix-permissions.test.ts b/src/features/permissions/reasonix-permissions.test.ts new file mode 100644 index 000000000..b00283088 --- /dev/null +++ b/src/features/permissions/reasonix-permissions.test.ts @@ -0,0 +1,376 @@ +import { join } from "node:path"; + +import * as smolToml from "smol-toml"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + RULESYNC_PERMISSIONS_FILE_NAME, + RULESYNC_RELATIVE_DIR_PATH, +} from "../../constants/rulesync-paths.js"; +import { createMockLogger } from "../../test-utils/mock-logger.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { writeFileContent } from "../../utils/file.js"; +import { ReasonixPermissions } from "./reasonix-permissions.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; + +describe("ReasonixPermissions", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return the project reasonix.toml path", () => { + const paths = ReasonixPermissions.getSettablePaths(); + expect(paths).toEqual({ relativeDirPath: ".", relativeFilePath: "reasonix.toml" }); + }); + + it("should return the global ~/.reasonix/config.toml path", () => { + const paths = ReasonixPermissions.getSettablePaths({ global: true }); + expect(paths).toEqual({ relativeDirPath: ".reasonix", relativeFilePath: "config.toml" }); + }); + }); + + describe("isDeletable", () => { + it("should return false because the config file is shared with MCP/other settings", () => { + const instance = ReasonixPermissions.forDeletion({ + outputRoot: testDir, + relativeDirPath: ".", + relativeFilePath: "reasonix.toml", + }); + + expect(instance.isDeletable()).toBe(false); + }); + }); + + describe("fromFile", () => { + it("should load existing reasonix.toml content", async () => { + await writeFileContent( + join(testDir, "reasonix.toml"), + ["[permissions]", 'allow = ["Bash(git *)"]'].join("\n"), + ); + + const instance = await ReasonixPermissions.fromFile({ outputRoot: testDir }); + expect(instance).toBeInstanceOf(ReasonixPermissions); + }); + + it("should use empty default content when the file does not exist", async () => { + const instance = await ReasonixPermissions.fromFile({ outputRoot: testDir }); + expect(instance).toBeInstanceOf(ReasonixPermissions); + }); + }); + + describe("fromRulesyncPermissions", () => { + it("should convert basic rulesync permissions to Reasonix Tool(specifier) syntax", async () => { + const rulesyncPermissions = new RulesyncPermissions({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME, + fileContent: JSON.stringify({ + permission: { + bash: { "git *": "allow", "rm -rf *": "deny", "*": "ask" }, + }, + }), + }); + + const instance = await ReasonixPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + + const parsed = smolToml.parse(instance.getFileContent()) as any; + expect(parsed.permissions.allow).toContain("Bash(git *)"); + expect(parsed.permissions.ask).toContain("Bash"); + expect(parsed.permissions.deny).toContain("Bash(rm -rf *)"); + }); + + it("should map canonical tool categories to Claude Code-style PascalCase families", async () => { + const rulesyncPermissions = new RulesyncPermissions({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME, + fileContent: JSON.stringify({ + permission: { + edit: { "docs/**": "allow" }, + webfetch: { "domain:github.com": "allow" }, + notebookedit: { "*": "deny" }, + }, + }), + }); + + const instance = await ReasonixPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + + const parsed = smolToml.parse(instance.getFileContent()) as any; + expect(parsed.permissions.allow).toContain("Edit(docs/**)"); + expect(parsed.permissions.allow).toContain("WebFetch(domain:github.com)"); + expect(parsed.permissions.deny).toContain("NotebookEdit"); + }); + + it("should preserve the [[plugins]] MCP table and other top-level keys on round-trip", async () => { + await writeFileContent( + join(testDir, "reasonix.toml"), + [ + 'default_model = "deepseek"', + "", + "[ui]", + 'theme = "dark"', + "", + "[[plugins]]", + 'name = "filesystem"', + 'command = "npx"', + ].join("\n"), + ); + + const rulesyncPermissions = new RulesyncPermissions({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME, + fileContent: JSON.stringify({ + permission: { bash: { "npm *": "allow" } }, + }), + }); + + const instance = await ReasonixPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + + const parsed = smolToml.parse(instance.getFileContent()) as any; + expect(parsed.default_model).toBe("deepseek"); + expect(parsed.ui.theme).toBe("dark"); + expect(parsed.plugins).toMatchObject([{ name: "filesystem", command: "npx" }]); + expect(parsed.permissions.allow).toContain("Bash(npm *)"); + }); + + it("should preserve an existing mode value untouched (no canonical equivalent)", async () => { + await writeFileContent( + join(testDir, "reasonix.toml"), + ["[permissions]", 'mode = "allow"'].join("\n"), + ); + + const rulesyncPermissions = new RulesyncPermissions({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME, + fileContent: JSON.stringify({ + permission: { bash: { "npm *": "allow" } }, + }), + }); + + const instance = await ReasonixPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + + const parsed = smolToml.parse(instance.getFileContent()) as any; + expect(parsed.permissions.mode).toBe("allow"); + }); + + it("should preserve permission entries from tool categories not managed by rulesync", async () => { + await writeFileContent( + join(testDir, "reasonix.toml"), + ["[permissions]", 'deny = ["Read(.env)", "Bash(dangerous *)"]'].join("\n"), + ); + + const rulesyncPermissions = new RulesyncPermissions({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME, + fileContent: JSON.stringify({ + permission: { bash: { "rm *": "deny" } }, + }), + }); + + const instance = await ReasonixPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + + const parsed = smolToml.parse(instance.getFileContent()) as any; + expect(parsed.permissions.deny).toContain("Read(.env)"); + expect(parsed.permissions.deny).toContain("Bash(rm *)"); + expect(parsed.permissions.deny).not.toContain("Bash(dangerous *)"); + }); + + it("should warn when permissions overwrites existing Read deny entries from ignore feature", async () => { + await writeFileContent( + join(testDir, "reasonix.toml"), + ["[permissions]", 'deny = ["Read(.env)", "Read(*.secret)"]'].join("\n"), + ); + + const rulesyncPermissions = new RulesyncPermissions({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME, + fileContent: JSON.stringify({ + permission: { read: { "src/**": "allow" } }, + }), + }); + + const mockLogger = createMockLogger(); + const instance = await ReasonixPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + logger: mockLogger, + }); + + const parsed = smolToml.parse(instance.getFileContent()) as any; + expect(parsed.permissions.deny).toBeUndefined(); + expect(parsed.permissions.allow).toContain("Read(src/**)"); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("Permissions feature manages 'Read' tool"), + ); + }); + + it("should remove empty arrays from output", async () => { + const rulesyncPermissions = new RulesyncPermissions({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME, + fileContent: JSON.stringify({ + permission: { bash: { "npm *": "allow" } }, + }), + }); + + const instance = await ReasonixPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + + const parsed = smolToml.parse(instance.getFileContent()) as any; + expect(parsed.permissions.allow).toEqual(["Bash(npm *)"]); + expect(parsed.permissions.ask).toBeUndefined(); + expect(parsed.permissions.deny).toBeUndefined(); + }); + + it("should write to the global config path when global is true", async () => { + const rulesyncPermissions = new RulesyncPermissions({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME, + fileContent: JSON.stringify({ + permission: { bash: { "npm *": "allow" } }, + }), + }); + + const instance = await ReasonixPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + global: true, + }); + + expect(instance.getRelativeDirPath()).toBe(".reasonix"); + expect(instance.getRelativeFilePath()).toBe("config.toml"); + }); + }); + + describe("toRulesyncPermissions", () => { + it("should convert Reasonix Tool(specifier) entries to rulesync canonical format", () => { + const instance = new ReasonixPermissions({ + relativeDirPath: ".", + relativeFilePath: "reasonix.toml", + fileContent: [ + "[permissions]", + 'allow = ["Bash(npm run *)", "Edit(docs/**)"]', + 'ask = ["Bash(git push *)"]', + 'deny = ["Bash(rm -rf *)"]', + ].join("\n"), + }); + + const rulesyncPermissions = instance.toRulesyncPermissions(); + const config = rulesyncPermissions.getJson(); + + expect(config.permission.bash).toEqual({ + "npm run *": "allow", + "git push *": "ask", + "rm -rf *": "deny", + }); + expect(config.permission.edit).toEqual({ "docs/**": "allow" }); + }); + + it("should handle bare tool entries without parentheses as a wildcard", () => { + const instance = new ReasonixPermissions({ + relativeDirPath: ".", + relativeFilePath: "reasonix.toml", + fileContent: ["[permissions]", 'allow = ["Bash"]', 'deny = ["WebFetch"]'].join("\n"), + }); + + const rulesyncPermissions = instance.toRulesyncPermissions(); + const config = rulesyncPermissions.getJson(); + + expect(config.permission.bash).toEqual({ "*": "allow" }); + expect(config.permission.webfetch).toEqual({ "*": "deny" }); + }); + + it("should not import mode (no canonical equivalent)", () => { + const instance = new ReasonixPermissions({ + relativeDirPath: ".", + relativeFilePath: "reasonix.toml", + fileContent: ["[permissions]", 'mode = "deny"', 'allow = ["Bash(git *)"]'].join("\n"), + }); + + const rulesyncPermissions = instance.toRulesyncPermissions(); + const config = rulesyncPermissions.getJson(); + + expect(config.permission.bash).toEqual({ "git *": "allow" }); + expect((config as Record).mode).toBeUndefined(); + }); + + it("should handle a missing permissions table", () => { + const instance = new ReasonixPermissions({ + relativeDirPath: ".", + relativeFilePath: "reasonix.toml", + fileContent: 'default_model = "deepseek"', + }); + + const rulesyncPermissions = instance.toRulesyncPermissions(); + const config = rulesyncPermissions.getJson(); + + expect(config.permission).toEqual({}); + }); + + it("should throw when constructed with invalid TOML content (mirrors reasonix-mcp.ts)", () => { + // The constructor eagerly parses the TOML content (same pattern as + // ReasonixMcp), so malformed content throws immediately rather than + // waiting for an explicit toRulesyncPermissions()/validate() call. + expect( + () => + new ReasonixPermissions({ + relativeDirPath: ".", + relativeFilePath: "reasonix.toml", + fileContent: "not [ valid toml", + }), + ).toThrow(); + }); + }); + + describe("validate", () => { + it("should succeed for valid TOML content", () => { + const instance = new ReasonixPermissions({ + relativeDirPath: ".", + relativeFilePath: "reasonix.toml", + fileContent: "[permissions]", + }); + + const result = instance.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + }); + + describe("forDeletion", () => { + it("should create a minimal instance for deletion", () => { + const instance = ReasonixPermissions.forDeletion({ + outputRoot: testDir, + relativeDirPath: ".", + relativeFilePath: "reasonix.toml", + }); + + expect(instance).toBeInstanceOf(ReasonixPermissions); + expect(instance.isDeletable()).toBe(false); + }); + }); +}); diff --git a/src/features/permissions/reasonix-permissions.ts b/src/features/permissions/reasonix-permissions.ts new file mode 100644 index 000000000..ae711b61b --- /dev/null +++ b/src/features/permissions/reasonix-permissions.ts @@ -0,0 +1,356 @@ +import { join } from "node:path"; + +import { uniq } from "es-toolkit"; +import * as smolToml from "smol-toml"; + +import { + REASONIX_GLOBAL_DIR, + REASONIX_GLOBAL_PERMISSIONS_FILE_NAME, + REASONIX_PROJECT_PERMISSIONS_FILE_NAME, +} from "../../constants/reasonix-paths.js"; +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import type { PermissionAction, PermissionsConfig } from "../../types/permissions.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContentOrNull } from "../../utils/file.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; +import { + ToolPermissions, + type ToolPermissionsForDeletionParams, + type ToolPermissionsFromFileParams, + type ToolPermissionsFromRulesyncPermissionsParams, + type ToolPermissionsSettablePaths, +} from "./tool-permissions.js"; + +/** + * Mapping from rulesync canonical tool category names (lowercase) to Reasonix + * permission-rule tool families (PascalCase). + * + * Reasonix's `[permissions]` rule syntax (SPEC.md §3.7) is explicitly + * documented as "Claude Code-style": "Bash and file mutation approvals use + * Claude Code-style families such as `Bash(npm run build)`, `Bash(npm run + * test:*)`, and `Edit(docs/**)`." Reasonix also accepts legacy lowercase tool + * IDs for compatibility, but new rules are saved using these PascalCase + * families, so rulesync reuses the same mapping `claudecode-permissions.ts` + * uses (the closest documented precedent for this syntax). + * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md + */ +const CANONICAL_TO_REASONIX_TOOL_NAMES: Record = { + bash: "Bash", + read: "Read", + edit: "Edit", + write: "Write", + webfetch: "WebFetch", + websearch: "WebSearch", + grep: "Grep", + glob: "Glob", + notebookedit: "NotebookEdit", + agent: "Agent", +}; + +/** + * Reverse mapping from Reasonix tool names to rulesync canonical names. + */ +const REASONIX_TO_CANONICAL_TOOL_NAMES: Record = Object.fromEntries( + Object.entries(CANONICAL_TO_REASONIX_TOOL_NAMES).map(([k, v]) => [v, k]), +); + +function toReasonixToolName(canonical: string): string { + return CANONICAL_TO_REASONIX_TOOL_NAMES[canonical] ?? canonical; +} + +function toCanonicalToolName(reasonixName: string): string { + return REASONIX_TO_CANONICAL_TOOL_NAMES[reasonixName] ?? reasonixName; +} + +/** + * Parse a Reasonix permission entry like "Bash(npm run *)" into tool name and pattern. + * If no parentheses, returns the tool name with "*" as the pattern. + */ +function parseReasonixPermissionEntry(entry: string): { toolName: string; pattern: string } { + const parenIndex = entry.indexOf("("); + if (parenIndex === -1) { + return { toolName: entry, pattern: "*" }; + } + const toolName = entry.slice(0, parenIndex); + // Verify closing parenthesis exists at the end before extracting the pattern + if (!entry.endsWith(")")) { + return { toolName, pattern: "*" }; + } + const pattern = entry.slice(parenIndex + 1, -1); + return { toolName, pattern: pattern || "*" }; +} + +/** + * Build a Reasonix permission entry like "Bash(npm run *)". + * If the pattern is "*", returns just the tool name. + */ +function buildReasonixPermissionEntry(toolName: string, pattern: string): string { + if (pattern === "*") { + return toolName; + } + return `${toolName}(${pattern})`; +} + +type ReasonixConfig = Record; + +type ReasonixPermissionsTable = Record & { + mode?: string; + allow?: string[]; + ask?: string[]; + deny?: string[]; +}; + +function parseReasonixConfig(fileContent: string): ReasonixConfig { + const parsed = smolToml.parse(fileContent || smolToml.stringify({})); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return { ...(parsed as Record) }; +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((entry): entry is string => typeof entry === "string"); +} + +function toPermissionsTable(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return { ...(value as Record) }; +} + +export class ReasonixPermissions extends ToolPermissions { + private readonly toml: ReasonixConfig; + + constructor(params: AiFileParams) { + super(params); + this.toml = parseReasonixConfig(this.getFileContent()); + } + + override isDeletable(): boolean { + // The Reasonix config file may hold many other settings (providers, ui, + // agent, MCP `[[plugins]]`, …), so it must never be deleted when no + // rulesync-managed permission rules remain. + return false; + } + + static getSettablePaths({ global }: { global?: boolean } = {}): ToolPermissionsSettablePaths { + // Project config lives at the repository root (`./reasonix.toml`), while the + // global config lives at `~/.reasonix/config.toml`; the home root is supplied + // by the processor via outputRoot. Same file the MCP adapter reads/writes. + if (global) { + return { + relativeDirPath: REASONIX_GLOBAL_DIR, + relativeFilePath: REASONIX_GLOBAL_PERMISSIONS_FILE_NAME, + }; + } + return { + relativeDirPath: ".", + relativeFilePath: REASONIX_PROJECT_PERMISSIONS_FILE_NAME, + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + global = false, + }: ToolPermissionsFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const fileContent = (await readFileContentOrNull(filePath)) ?? smolToml.stringify({}); + return new ReasonixPermissions({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static async fromRulesyncPermissions({ + outputRoot = process.cwd(), + rulesyncPermissions, + validate = true, + logger, + global = false, + }: ToolPermissionsFromRulesyncPermissionsParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const existingContent = (await readFileContentOrNull(filePath)) ?? smolToml.stringify({}); + const parsed = parseReasonixConfig(existingContent); + + const config = rulesyncPermissions.getJson(); + const { allow, ask, deny } = convertRulesyncToReasonixPermissions(config); + + // Determine which tool names are managed by the permissions config + const managedToolNames = new Set( + Object.keys(config.permission).map((category) => toReasonixToolName(category)), + ); + + // Read existing permission arrays and preserve entries for tools NOT in the permissions config + const existingPermissions = toPermissionsTable(parsed.permissions); + const preservedAllow = toStringArray(existingPermissions.allow).filter( + (entry) => !managedToolNames.has(parseReasonixPermissionEntry(entry).toolName), + ); + const preservedAsk = toStringArray(existingPermissions.ask).filter( + (entry) => !managedToolNames.has(parseReasonixPermissionEntry(entry).toolName), + ); + const preservedDeny = toStringArray(existingPermissions.deny).filter( + (entry) => !managedToolNames.has(parseReasonixPermissionEntry(entry).toolName), + ); + + // Warn when permissions feature overwrites ignore-generated Read(...) deny entries + if (logger && managedToolNames.has("Read")) { + const droppedReadDenyEntries = toStringArray(existingPermissions.deny).filter((entry) => { + const { toolName } = parseReasonixPermissionEntry(entry); + return toolName === "Read"; + }); + if (droppedReadDenyEntries.length > 0) { + logger.warn( + `Permissions feature manages 'Read' tool and will overwrite ${droppedReadDenyEntries.length} existing Read deny entries (possibly from ignore feature). Permissions take precedence.`, + ); + } + } + + // `mode` (the writer fallback: ask|allow|deny) has no equivalent in + // rulesync's canonical permissions model, so any existing value is + // preserved untouched via this spread rather than being managed here. + const mergedPermissions: ReasonixPermissionsTable = { ...existingPermissions }; + + const mergedAllow = uniq([...preservedAllow, ...allow].toSorted()); + const mergedAsk = uniq([...preservedAsk, ...ask].toSorted()); + const mergedDeny = uniq([...preservedDeny, ...deny].toSorted()); + + if (mergedAllow.length > 0) { + mergedPermissions.allow = mergedAllow; + } else { + delete mergedPermissions.allow; + } + if (mergedAsk.length > 0) { + mergedPermissions.ask = mergedAsk; + } else { + delete mergedPermissions.ask; + } + if (mergedDeny.length > 0) { + mergedPermissions.deny = mergedDeny; + } else { + delete mergedPermissions.deny; + } + + // Preserve every other top-level key (MCP `[[plugins]]`, `[agent]`, `[ui]`, + // …) exactly like `reasonix-mcp.ts`'s read-modify-write TOML merge. + const merged: ReasonixConfig = { ...parsed, permissions: mergedPermissions }; + const fileContent = smolToml.stringify(merged); + + return new ReasonixPermissions({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + toRulesyncPermissions(): RulesyncPermissions { + const permissions = toPermissionsTable(this.toml.permissions); + const config = convertReasonixToRulesyncPermissions({ + allow: toStringArray(permissions.allow), + ask: toStringArray(permissions.ask), + deny: toStringArray(permissions.deny), + }); + + return this.toRulesyncPermissionsDefault({ + fileContent: JSON.stringify(config, null, 2), + }); + } + + validate(): ValidationResult { + try { + parseReasonixConfig(this.getFileContent()); + return { success: true, error: null }; + } catch (error) { + return { + success: false, + error: new Error(`Failed to parse Reasonix config TOML: ${formatError(error)}`), + }; + } + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolPermissionsForDeletionParams): ReasonixPermissions { + return new ReasonixPermissions({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: smolToml.stringify({}), + validate: false, + }); + } +} + +/** + * Convert rulesync permissions config to Reasonix allow/ask/deny arrays. + */ +function convertRulesyncToReasonixPermissions(config: PermissionsConfig): { + allow: string[]; + ask: string[]; + deny: string[]; +} { + const allow: string[] = []; + const ask: string[] = []; + const deny: string[] = []; + + for (const [category, rules] of Object.entries(config.permission)) { + const reasonixToolName = toReasonixToolName(category); + for (const [pattern, action] of Object.entries(rules)) { + const entry = buildReasonixPermissionEntry(reasonixToolName, pattern); + switch (action) { + case "allow": + allow.push(entry); + break; + case "ask": + ask.push(entry); + break; + case "deny": + deny.push(entry); + break; + } + } + } + + return { allow, ask, deny }; +} + +/** + * Convert Reasonix allow/ask/deny arrays to rulesync permissions config. + */ +function convertReasonixToRulesyncPermissions(params: { + allow: string[]; + ask: string[]; + deny: string[]; +}): PermissionsConfig { + const permission: Record> = {}; + + const processEntries = (entries: string[], action: PermissionAction) => { + for (const entry of entries) { + const { toolName, pattern } = parseReasonixPermissionEntry(entry); + const canonical = toCanonicalToolName(toolName); + if (!permission[canonical]) { + permission[canonical] = {}; + } + permission[canonical][pattern] = action; + } + }; + + processEntries(params.allow, "allow"); + processEntries(params.ask, "ask"); + processEntries(params.deny, "deny"); + + return { permission }; +} diff --git a/src/lib/generate.ts b/src/lib/generate.ts index 0b2b21d9e..c5c95895a 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -332,6 +332,7 @@ export async function generate(params: { "grokcli-config", "vibe-config", "devin-config", + "reasonix-config", ], dependsOn: ["ignore"], run: () => generateMcpCore({ config, logger }), @@ -375,6 +376,7 @@ export async function generate(params: { "grokcli-config", "vibe-config", "devin-config", + "reasonix-config", ], dependsOn: ["ignore", "hooks", "mcp"], run: () => generatePermissionsCore({ config, logger }), diff --git a/src/types/hooks.ts b/src/types/hooks.ts index da3dfa454..25fb66f70 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -492,6 +492,26 @@ export const QWENCODE_HOOK_EVENTS: readonly HookEvent[] = [ "todoCompleted", ]; +/** + * Hook events supported by Reasonix. + * + * Reasonix's `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` + * (global) documents a ten-event surface (`PreToolUse`, `PostToolUse`, + * `UserPromptSubmit`, `Stop`, `PostLLMCall`, `SessionStart`, `SessionEnd`, + * `SubagentStop`, `Notification`, `PreCompact`), but only the four events the + * upstream issue scoped in are mapped here: `PreToolUse`, `PostToolUse`, + * `UserPromptSubmit` ← `beforeSubmitPrompt`, and `Stop`. `match` (Reasonix's + * matcher field name) is honored only on `PreToolUse`/`PostToolUse`, matching + * the canonical `matcher` field's tool-event scoping used by other adapters. + * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md + */ +export const REASONIX_HOOK_EVENTS: readonly HookEvent[] = [ + "preToolUse", + "postToolUse", + "beforeSubmitPrompt", + "stop", +]; + /** * Hook events supported by Hermes Agent's native Shell Hooks system. * @@ -569,6 +589,7 @@ export const HooksConfigSchema = z.looseObject({ hermesagent: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), junie: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), vibe: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), + reasonix: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), qwencode: z.optional( z.looseObject({ hooks: z.optional(hooksRecordSchema), @@ -1003,3 +1024,23 @@ export const CANONICAL_TO_QWENCODE_EVENT_NAMES: Record = { export const QWENCODE_TO_CANONICAL_EVENT_NAMES: Record = Object.fromEntries( Object.entries(CANONICAL_TO_QWENCODE_EVENT_NAMES).map(([k, v]) => [v, k]), ); + +/** + * Map canonical camelCase event names to Reasonix PascalCase. + * Reasonix explicitly mirrors Claude Code's hooks model, so it reuses the same + * PascalCase names for the four events rulesync maps. + * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md + */ +export const CANONICAL_TO_REASONIX_EVENT_NAMES: Record = { + preToolUse: "PreToolUse", + postToolUse: "PostToolUse", + beforeSubmitPrompt: "UserPromptSubmit", + stop: "Stop", +}; + +/** + * Map Reasonix PascalCase event names to canonical camelCase. + */ +export const REASONIX_TO_CANONICAL_EVENT_NAMES: Record = Object.fromEntries( + Object.entries(CANONICAL_TO_REASONIX_EVENT_NAMES).map(([k, v]) => [v, k]), +); diff --git a/src/types/tool-target-tuples.ts b/src/types/tool-target-tuples.ts index 867c132f8..ce6f8152f 100644 --- a/src/types/tool-target-tuples.ts +++ b/src/types/tool-target-tuples.ts @@ -118,6 +118,7 @@ export const commandsProcessorToolTargetTuple = [ "opencode", "pi", "qwencode", + "reasonix", "roo", "takt", "devin", @@ -212,6 +213,7 @@ export const hooksProcessorToolTargetTuple = [ "junie", "vibe", "qwencode", + "reasonix", ] as const; export const permissionsProcessorToolTargetTuple = [ @@ -235,6 +237,7 @@ export const permissionsProcessorToolTargetTuple = [ "kiro-ide", "opencode", "qwencode", + "reasonix", "rovodev", "takt", "vibe",