diff --git a/.gitignore b/.gitignore index f86e8242f..1699180a9 100644 --- a/.gitignore +++ b/.gitignore @@ -323,6 +323,9 @@ rulesync.local.jsonc **/.pi/prompts/ **/.pi/skills/ **/QWEN.md +**/.qwen/commands/ +**/.qwen/agents/ +**/.qwen/skills/ **/.qwenignore **/.qwen/memories/ **/.qwen/settings.json diff --git a/README.md b/README.md index 8944870d6..09d3f587c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Rovodev (Atlassian) | ✅ | | ✅ | | ✅ | ✅ | | ✅ | | Takt | ✅ | | | ✅ | ✅ | ✅ | | | | Vibe Code | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | -| Qwen Code | ✅ | ✅ | | | | | | ✅ | +| Qwen Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro CLI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro IDE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 51fb93cfb..9764ccf79 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -62,7 +62,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration ## `.rulesync/hooks.json` -Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`). +Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). Example: @@ -112,7 +112,7 @@ Example: - `version`: Schema version (currently `1`). - `hooks`: Map of canonical event names to an array of hook entries. These are dispatched to every tool that supports the given event. -- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `geminicli.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. +- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `geminicli.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`, `qwencode.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. **Hook entry keys:** @@ -191,6 +191,8 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory `hooks/hooks.json` that Goose auto-discovers at startup. Locations are `/.agents/plugins/rulesync/hooks/hooks.json` (project) and `~/.agents/plugins/rulesync/hooks/hooks.json` (global). The JSON shape matches Claude Code's (`{ "hooks": { "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "..." } ] } ] } }`). Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, `afterShellExecution` ⇄ `AfterShellExecution`, `subagentStart` ⇄ `SubagentStart`, and `subagentStop` ⇄ `SubagentStop`. The `matcher` regex is preserved, commands are emitted verbatim (Goose exposes `PLUGIN_ROOT` as a runtime environment variable), and only `command`-type hooks are supported. +> **Note:** 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": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, and `notification` ⇄ `Notification`. Qwen-only events (`StopFailure`, `TodoCreated`, `TodoCompleted`) have no canonical equivalent. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting), only `command`-type hooks are supported, and other top-level keys in `settings.json` are preserved on round-trip. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md). + ## `.copilot/mcp-config.json` Example: @@ -259,6 +261,8 @@ Execute the following in parallel: The command body itself uses a Claude Code-compatible **universal syntax** (e.g. `$ARGUMENTS`, `` !`cmd` ``). When a target tool expects a different placeholder syntax, rulesync translates it automatically on generation and reverses the translation on import. See [Command Syntax](./command-syntax.md) for the full mapping. +> **Qwen Code note:** Custom commands are emitted as **Markdown** files (not TOML — TOML is deprecated upstream) under `.qwen/commands/` (project) and `~/.qwen/commands/` (global, via `--global`). The file is an optional YAML frontmatter block (`description`) followed by the prompt body. Subdirectory namespacing is supported: `.qwen/commands/git/commit.md` becomes the `/git:commit` command. Any extra fields are preserved on round-trip under the `qwencode:` block. + ## `rulesync/subagents/*.md` Example: @@ -336,6 +340,8 @@ Attention, again, you are just the planner, so though you can read any files and > **Gemini CLI note (as of 2026-04-01):** Subagents are generated to `.gemini/agents/`. To enable the agents feature, set `"experimental": { "enableAgents": true }` in your `.gemini/settings.json`. +> **Qwen Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.qwen/agents/` (project) and `~/.qwen/agents/` (user/global, via `--global`); the body is the subagent's system prompt. Besides the shared `name`/`description`, the `qwencode:` block accepts these optional fields (all preserved on round-trip): `model`, `approvalMode` (`default` | `plan` | `auto-edit` | `yolo` | `bubble`), `tools` (allowlist), `disallowedTools` (denylist), `maxTurns`, `color`, `mcpServers` (per-agent MCP overrides), and `hooks` (per-agent hook registrations). See the [Qwen Code sub-agents docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/sub-agents.md). + > **Cline note:** Cline file-based agents are emitted as YAML files (`.yaml`) into `.cline/agents/` (project) and `~/.cline/agents/` (global, via `--global`). The file is a YAML frontmatter block (`name` required, `description`) followed by the system prompt body, matching Cline's agent config loader. > **Devin note:** Devin Local custom subagent profiles are emitted as `AGENT.md` files in a **directory-per-agent** layout: `.devin/agents//AGENT.md` (project) and `~/.config/devin/agents//AGENT.md` (global, via `--global`). The directory name `` is the profile id (derived from the rulesync subagent file name). The `AGENT.md` is a YAML frontmatter block followed by the subagent's system prompt. Besides the shared `name`/`description`, the `devin` subagent block accepts these optional fields (all preserved on round-trip): `model` (string, override the subagent LLM), `allowed-tools` (list of strings, restrict available tools), `permissions` (object with `allow`/`deny`/`ask` string lists, override tool permissions), and `max-nesting` (integer, enable nested subagent spawning up to the given depth). See the [Devin subagents docs](https://docs.devin.ai/cli/subagents). @@ -469,6 +475,12 @@ cursor: # for Cursor-specific parameters (optional) takt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") extends: "base" # (optional) emit a leading `{extends:}` facet-inheritance directive (Takt 0.39.0+) +qwencode: # for Qwen Code-specific parameters (optional; project .qwen/skills/, global ~/.qwen/skills/) + priority: 10 # (optional) higher values appear earlier in /skills listings + paths: # (optional) glob patterns (string or list) gating model discovery to matching files + - "src/**/*.ts" + user-invocable: false # (optional) hide from slash-command invocation, keep model access + disable-model-invocation: true # (optional) hide from the model but allow direct user invocation --- This is the skill body content. @@ -566,6 +578,8 @@ You can control which individual tools from an MCP server are enabled or disable - `enabledTools`: An array of tool names that should be explicitly enabled for this server. - `disabledTools`: An array of tool names that should be explicitly disabled for this server. +> **Qwen Code note:** MCP servers are written to the `mcpServers` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global, via `--global`). Qwen supports stdio (`command`/`args`), SSE (`url`), and HTTP (`httpUrl`) transports. Rulesync maps the canonical per-server `enabledTools` ⇄ Qwen's `includeTools` (allowlist) and `disabledTools` ⇄ Qwen's `excludeTools` (denylist). Other top-level keys in `settings.json` are preserved on round-trip. + ### Codex-specific: pass shell env vars to MCP servers (`envVars`) Codex CLI supports a per-server array of shell env var names to inherit when launching the MCP server process. The source schema uses `envVars` (camelCase, matching the project convention used by sibling fields like `enabledTools`/`disabledTools`); the codex generator renames it to `env_vars` (snake_case) for codex's native `config.toml` format. diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index ce9b6c59c..5eeaa8861 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -23,7 +23,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | 🌏 | | Takt | takt | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | | Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Qwen Code | qwencode | ✅ | ✅ | | | | | | ✅ 🌏 | +| Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | 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 51fb93cfb..9764ccf79 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -62,7 +62,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration ## `.rulesync/hooks.json` -Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`). +Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). Example: @@ -112,7 +112,7 @@ Example: - `version`: Schema version (currently `1`). - `hooks`: Map of canonical event names to an array of hook entries. These are dispatched to every tool that supports the given event. -- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `geminicli.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. +- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `geminicli.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`, `qwencode.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. **Hook entry keys:** @@ -191,6 +191,8 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory `hooks/hooks.json` that Goose auto-discovers at startup. Locations are `/.agents/plugins/rulesync/hooks/hooks.json` (project) and `~/.agents/plugins/rulesync/hooks/hooks.json` (global). The JSON shape matches Claude Code's (`{ "hooks": { "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "..." } ] } ] } }`). Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, `afterShellExecution` ⇄ `AfterShellExecution`, `subagentStart` ⇄ `SubagentStart`, and `subagentStop` ⇄ `SubagentStop`. The `matcher` regex is preserved, commands are emitted verbatim (Goose exposes `PLUGIN_ROOT` as a runtime environment variable), and only `command`-type hooks are supported. +> **Note:** 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": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, and `notification` ⇄ `Notification`. Qwen-only events (`StopFailure`, `TodoCreated`, `TodoCompleted`) have no canonical equivalent. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting), only `command`-type hooks are supported, and other top-level keys in `settings.json` are preserved on round-trip. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md). + ## `.copilot/mcp-config.json` Example: @@ -259,6 +261,8 @@ Execute the following in parallel: The command body itself uses a Claude Code-compatible **universal syntax** (e.g. `$ARGUMENTS`, `` !`cmd` ``). When a target tool expects a different placeholder syntax, rulesync translates it automatically on generation and reverses the translation on import. See [Command Syntax](./command-syntax.md) for the full mapping. +> **Qwen Code note:** Custom commands are emitted as **Markdown** files (not TOML — TOML is deprecated upstream) under `.qwen/commands/` (project) and `~/.qwen/commands/` (global, via `--global`). The file is an optional YAML frontmatter block (`description`) followed by the prompt body. Subdirectory namespacing is supported: `.qwen/commands/git/commit.md` becomes the `/git:commit` command. Any extra fields are preserved on round-trip under the `qwencode:` block. + ## `rulesync/subagents/*.md` Example: @@ -336,6 +340,8 @@ Attention, again, you are just the planner, so though you can read any files and > **Gemini CLI note (as of 2026-04-01):** Subagents are generated to `.gemini/agents/`. To enable the agents feature, set `"experimental": { "enableAgents": true }` in your `.gemini/settings.json`. +> **Qwen Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.qwen/agents/` (project) and `~/.qwen/agents/` (user/global, via `--global`); the body is the subagent's system prompt. Besides the shared `name`/`description`, the `qwencode:` block accepts these optional fields (all preserved on round-trip): `model`, `approvalMode` (`default` | `plan` | `auto-edit` | `yolo` | `bubble`), `tools` (allowlist), `disallowedTools` (denylist), `maxTurns`, `color`, `mcpServers` (per-agent MCP overrides), and `hooks` (per-agent hook registrations). See the [Qwen Code sub-agents docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/sub-agents.md). + > **Cline note:** Cline file-based agents are emitted as YAML files (`.yaml`) into `.cline/agents/` (project) and `~/.cline/agents/` (global, via `--global`). The file is a YAML frontmatter block (`name` required, `description`) followed by the system prompt body, matching Cline's agent config loader. > **Devin note:** Devin Local custom subagent profiles are emitted as `AGENT.md` files in a **directory-per-agent** layout: `.devin/agents//AGENT.md` (project) and `~/.config/devin/agents//AGENT.md` (global, via `--global`). The directory name `` is the profile id (derived from the rulesync subagent file name). The `AGENT.md` is a YAML frontmatter block followed by the subagent's system prompt. Besides the shared `name`/`description`, the `devin` subagent block accepts these optional fields (all preserved on round-trip): `model` (string, override the subagent LLM), `allowed-tools` (list of strings, restrict available tools), `permissions` (object with `allow`/`deny`/`ask` string lists, override tool permissions), and `max-nesting` (integer, enable nested subagent spawning up to the given depth). See the [Devin subagents docs](https://docs.devin.ai/cli/subagents). @@ -469,6 +475,12 @@ cursor: # for Cursor-specific parameters (optional) takt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") extends: "base" # (optional) emit a leading `{extends:}` facet-inheritance directive (Takt 0.39.0+) +qwencode: # for Qwen Code-specific parameters (optional; project .qwen/skills/, global ~/.qwen/skills/) + priority: 10 # (optional) higher values appear earlier in /skills listings + paths: # (optional) glob patterns (string or list) gating model discovery to matching files + - "src/**/*.ts" + user-invocable: false # (optional) hide from slash-command invocation, keep model access + disable-model-invocation: true # (optional) hide from the model but allow direct user invocation --- This is the skill body content. @@ -566,6 +578,8 @@ You can control which individual tools from an MCP server are enabled or disable - `enabledTools`: An array of tool names that should be explicitly enabled for this server. - `disabledTools`: An array of tool names that should be explicitly disabled for this server. +> **Qwen Code note:** MCP servers are written to the `mcpServers` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global, via `--global`). Qwen supports stdio (`command`/`args`), SSE (`url`), and HTTP (`httpUrl`) transports. Rulesync maps the canonical per-server `enabledTools` ⇄ Qwen's `includeTools` (allowlist) and `disabledTools` ⇄ Qwen's `excludeTools` (denylist). Other top-level keys in `settings.json` are preserved on round-trip. + ### Codex-specific: pass shell env vars to MCP servers (`envVars`) Codex CLI supports a per-server array of shell env var names to inherit when launching the MCP server process. The source schema uses `envVars` (camelCase, matching the project convention used by sibling fields like `enabledTools`/`disabledTools`); the codex generator renames it to `env_vars` (snake_case) for codex's native `config.toml` format. diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index ce9b6c59c..5eeaa8861 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -23,7 +23,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | 🌏 | | Takt | takt | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | | Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Qwen Code | qwencode | ✅ | ✅ | | | | | | ✅ 🌏 | +| Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Kiro ⚠️ | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro CLI | kiro-cli | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro IDE | kiro-ide | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index c7965df95..a9dd33764 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -338,8 +338,12 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ // Qwen Code { target: "qwencode", feature: "rules", entry: "**/QWEN.md" }, + { target: "qwencode", feature: "commands", entry: "**/.qwen/commands/" }, + { target: "qwencode", feature: "subagents", entry: "**/.qwen/agents/" }, + { target: "qwencode", feature: "skills", entry: "**/.qwen/skills/" }, { target: "qwencode", feature: "ignore", entry: "**/.qwenignore" }, { target: "qwencode", feature: "general", entry: "**/.qwen/memories/" }, + // mcp + hooks both write to `.qwen/settings.json`, shared with permissions. { target: "qwencode", feature: "permissions", entry: "**/.qwen/settings.json" }, // Replit diff --git a/src/constants/qwencode-paths.ts b/src/constants/qwencode-paths.ts index 69b32a761..9d64f4f66 100644 --- a/src/constants/qwencode-paths.ts +++ b/src/constants/qwencode-paths.ts @@ -2,6 +2,9 @@ import { join } from "node:path"; export const QWENCODE_DIR = ".qwen"; export const QWENCODE_MEMORIES_DIR_PATH = join(QWENCODE_DIR, "memories"); +export const QWENCODE_COMMANDS_DIR_PATH = join(QWENCODE_DIR, "commands"); +export const QWENCODE_AGENTS_DIR_PATH = join(QWENCODE_DIR, "agents"); +export const QWENCODE_SKILLS_DIR_PATH = join(QWENCODE_DIR, "skills"); export const QWENCODE_RULE_FILE_NAME = "QWEN.md"; export const QWENCODE_IGNORE_FILE_NAME = ".qwenignore"; export const QWENCODE_SETTINGS_FILE_NAME = "settings.json"; diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index ed8623896..1cbdc0475 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -33,6 +33,7 @@ describe("E2E: commands", () => { { target: "devin", outputPath: join(".devin", "workflows", "review-pr.md") }, { 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") }, ])("should generate $target commands", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -211,6 +212,7 @@ describe("E2E: commands (global mode)", () => { }, { target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") }, { target: "goose", outputPath: join(".config", "goose", "recipes", "review-pr.yaml") }, + { target: "qwencode", outputPath: join(".qwen", "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 eb91e5b8a..78da67ae9 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -34,6 +34,7 @@ describe("E2E: hooks", () => { { target: "opencode", outputPath: join(".opencode", "plugins", "rulesync-hooks.js") }, { target: "codexcli", outputPath: join(".codex", "hooks.json") }, { target: "geminicli", outputPath: join(".gemini", "settings.json") }, + { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { target: "goose", outputPath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"), @@ -121,6 +122,17 @@ describe("E2E: hooks", () => { // audit.sh — mapped to `Stop` — survives generation. expect(parsed.rulesync.Stop).toBeDefined(); expect(JSON.stringify(parsed)).toContain(".rulesync/hooks/audit.sh"); + } else if (target === "qwencode") { + // Qwen Code uses Claude-style PascalCase event names under the `hooks` + // key of .qwen/settings.json, but its mapping differs from Gemini CLI: + // canonical `sessionStart` → `SessionStart`, `stop` → `Stop` + // (NOT Gemini's BeforeAgent/AfterAgent). See + // CANONICAL_TO_QWENCODE_EVENT_NAMES in src/types/hooks.ts. + expect(parsed.hooks).toBeDefined(); + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.Stop).toBeDefined(); + expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); + expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/audit.sh"); } else { // codexcli, geminicli, factorydroid, goose: event-name casing/mapping // varies per tool, so verify the configured hook command paths are preserved. @@ -470,6 +482,7 @@ describe("E2E: hooks (global mode)", () => { { target: "claudecode", outputPath: join(".claude", "settings.json") }, { target: "codexcli", outputPath: join(".codex", "hooks.json") }, { target: "geminicli", outputPath: join(".gemini", "settings.json") }, + { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { target: "goose", outputPath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"), @@ -535,6 +548,16 @@ describe("E2E: hooks (global mode)", () => { const parsed = JSON.parse(generatedContent); expect(parsed.rulesync.Stop).toBeDefined(); expect(JSON.stringify(parsed)).toContain(".rulesync/hooks/audit.sh"); + } else if (target === "qwencode") { + // Qwen Code emits Claude-style PascalCase event names under the `hooks` + // key of .qwen/settings.json: canonical `sessionStart` → `SessionStart`, + // `stop` → `Stop`. See CANONICAL_TO_QWENCODE_EVENT_NAMES in + // src/types/hooks.ts. + const parsed = JSON.parse(generatedContent); + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.Stop).toBeDefined(); + expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); + expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/audit.sh"); } else { assertHookCommandsPreserved(JSON.parse(generatedContent)); } diff --git a/src/e2e/e2e-mcp.spec.ts b/src/e2e/e2e-mcp.spec.ts index 794c996a9..2d0d850bd 100644 --- a/src/e2e/e2e-mcp.spec.ts +++ b/src/e2e/e2e-mcp.spec.ts @@ -24,6 +24,7 @@ describe("E2E: mcp", () => { { target: "claudecode", outputPath: ".mcp.json" }, { target: "cursor", outputPath: join(".cursor", "mcp.json") }, { target: "geminicli", outputPath: join(".gemini", "settings.json") }, + { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { target: "codexcli", outputPath: join(".codex", "config.toml") }, { target: "copilot", outputPath: join(".vscode", "mcp.json") }, { target: "copilotcli", outputPath: join(".copilot", "mcp-config.json") }, @@ -370,6 +371,7 @@ describe("E2E: mcp (global mode)", () => { { target: "claudecode", outputPath: ".claude.json" }, { target: "cursor", outputPath: join(".cursor", "mcp.json") }, { target: "geminicli", outputPath: join(".gemini", "settings.json") }, + { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { target: "goose", outputPath: join(".config", "goose", "config.yaml") }, { target: "opencode", outputPath: join(".config", "opencode", "opencode.jsonc") }, { target: "codexcli", outputPath: join(".codex", "config.toml") }, diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index ca5bb22f1..cd1d43558 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -35,6 +35,10 @@ describe("E2E: skills", () => { target: "geminicli", outputPath: join(".gemini", "skills", "test-skill", "SKILL.md"), }, + { + target: "qwencode", + outputPath: join(".qwen", "skills", "test-skill", "SKILL.md"), + }, { target: "copilot", outputPath: join(".github", "skills", "test-skill", "SKILL.md"), @@ -342,6 +346,10 @@ describe("E2E: skills (global mode)", () => { target: "geminicli", outputPath: join(".gemini", "skills", "test-skill", "SKILL.md"), }, + { + target: "qwencode", + outputPath: join(".qwen", "skills", "test-skill", "SKILL.md"), + }, { target: "junie", outputPath: join(".junie", "skills", "test-skill", "SKILL.md"), diff --git a/src/e2e/e2e-subagents.spec.ts b/src/e2e/e2e-subagents.spec.ts index b29de93e0..02ce8ac38 100644 --- a/src/e2e/e2e-subagents.spec.ts +++ b/src/e2e/e2e-subagents.spec.ts @@ -31,6 +31,10 @@ describe("E2E: subagents", () => { target: "geminicli", outputPath: join(".gemini", "agents", "planner.md"), }, + { + target: "qwencode", + outputPath: join(".qwen", "agents", "planner.md"), + }, { target: "codexcli", outputPath: join(".codex", "agents", "planner.toml"), @@ -439,6 +443,7 @@ describe("E2E: subagents (global mode)", () => { { target: "copilotcli", outputPath: join(".copilot", "agents", "planner.agent.md") }, { target: "cursor", outputPath: join(".cursor", "agents", "planner.md") }, { target: "geminicli", outputPath: join(".gemini", "agents", "planner.md") }, + { target: "qwencode", outputPath: join(".qwen", "agents", "planner.md") }, { target: "junie", outputPath: join(".junie", "agents", "planner.md") }, { target: "opencode", outputPath: join(".config", "opencode", "agents", "planner.md") }, { target: "rovodev", outputPath: join(".rovodev", "subagents", "planner.md") }, diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index 3f8209301..8049cbe06 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -1152,6 +1152,7 @@ describe("CommandsProcessor", () => { "kiro-ide", "opencode", "pi", + "qwencode", "roo", "takt", "devin", @@ -1182,6 +1183,7 @@ describe("CommandsProcessor", () => { "kiro-ide", "opencode", "pi", + "qwencode", "roo", "takt", "devin", @@ -1209,6 +1211,7 @@ describe("CommandsProcessor", () => { "kilo", "opencode", "pi", + "qwencode", "takt", "devin", ]), diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index 43cac6bd1..3eb4a87b3 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -29,6 +29,7 @@ import { KiroCommand } from "./kiro-command.js"; 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 { RooCommand } from "./roo-command.js"; import { RulesyncCommand } from "./rulesync-command.js"; import { TaktCommand } from "./takt-command.js"; @@ -91,6 +92,7 @@ const commandsProcessorToolTargetTuple = [ "kiro-ide", "opencode", "pi", + "qwencode", "roo", "takt", "devin", @@ -372,6 +374,22 @@ export const toolCommandFactories = new Map `/git:commit`). + class: QwencodeCommand, + meta: { + extension: "md", + supportsProject: true, + supportsGlobal: true, + isSimulated: false, + supportsSubdirectory: true, + }, + }, + ], [ "roo", { diff --git a/src/features/commands/qwencode-command.test.ts b/src/features/commands/qwencode-command.test.ts new file mode 100644 index 000000000..300acaad4 --- /dev/null +++ b/src/features/commands/qwencode-command.test.ts @@ -0,0 +1,413 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { QWENCODE_COMMANDS_DIR_PATH } from "../../constants/qwencode-paths.js"; +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 { QwencodeCommand, QwencodeCommandFrontmatterSchema } from "./qwencode-command.js"; +import { RulesyncCommand } from "./rulesync-command.js"; + +describe("QwencodeCommand", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + const result = await setupTestDirectory(); + testDir = result.testDir; + cleanup = result.cleanup; + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("constructor", () => { + it("should create a valid QwencodeCommand instance", () => { + const command = new QwencodeCommand({ + outputRoot: testDir, + relativeDirPath: QWENCODE_COMMANDS_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { description: "Test command" }, + body: "This is a test command body", + }); + + expect(command).toBeInstanceOf(QwencodeCommand); + 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 QwencodeCommand({ + outputRoot: testDir, + relativeDirPath: QWENCODE_COMMANDS_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { description: 123 as any }, + body: "This is a test command body", + validate: true, + }); + }).toThrow(); + }); + + it("should generate correct file content with frontmatter", () => { + const command = new QwencodeCommand({ + outputRoot: testDir, + relativeDirPath: QWENCODE_COMMANDS_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { description: "Test command" }, + body: "This is a test command body", + }); + + const fileContent = command.getFileContent(); + expect(fileContent).toContain("---"); + expect(fileContent).toContain("description: Test command"); + expect(fileContent).toContain("This is a test command body"); + }); + }); + + describe("getSettablePaths", () => { + it("should return the commands dir for project scope", () => { + const paths = QwencodeCommand.getSettablePaths(); + expect(paths.relativeDirPath).toBe(QWENCODE_COMMANDS_DIR_PATH); + expect(paths.relativeDirPath).toBe(join(".qwen", "commands")); + }); + + it("should return the commands dir for global scope", () => { + const paths = QwencodeCommand.getSettablePaths({ global: true }); + expect(paths.relativeDirPath).toBe(QWENCODE_COMMANDS_DIR_PATH); + expect(paths.relativeDirPath).toBe(join(".qwen", "commands")); + }); + }); + + describe("validate", () => { + it("should return success for valid frontmatter", () => { + const command = new QwencodeCommand({ + outputRoot: testDir, + relativeDirPath: QWENCODE_COMMANDS_DIR_PATH, + 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 QwencodeCommand({ + outputRoot: testDir, + relativeDirPath: QWENCODE_COMMANDS_DIR_PATH, + 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("fromRulesyncCommand", () => { + it("should emit Markdown with description frontmatter", () => { + 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 qwencodeCommand = QwencodeCommand.fromRulesyncCommand({ + outputRoot: testDir, + rulesyncCommand, + }); + + expect(qwencodeCommand).toBeInstanceOf(QwencodeCommand); + expect(qwencodeCommand.getBody()).toBe("Sync command body"); + expect(qwencodeCommand.getFrontmatter()).toEqual({ + description: "Sync test command", + }); + expect(qwencodeCommand.getRelativeDirPath()).toBe(QWENCODE_COMMANDS_DIR_PATH); + expect(qwencodeCommand.getRelativeFilePath()).toBe("sync-test.md"); + + const fileContent = qwencodeCommand.getFileContent(); + expect(fileContent).toContain("---"); + expect(fileContent).toContain("description: Sync test command"); + expect(fileContent).toContain("Sync command body"); + }); + + it("should preserve qwencode-specific fields", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "passthrough.md", + frontmatter: { + targets: ["qwencode"], + description: "Passthrough command", + qwencode: { + "custom-setting": true, + }, + }, + body: "Body", + fileContent: "", + }); + + const qwencodeCommand = QwencodeCommand.fromRulesyncCommand({ + outputRoot: testDir, + rulesyncCommand, + }); + + const frontmatter = qwencodeCommand.getFrontmatter(); + expect(frontmatter.description).toBe("Passthrough command"); + expect(frontmatter["custom-setting"]).toBe(true); + }); + + 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 qwencodeCommand = QwencodeCommand.fromRulesyncCommand({ + outputRoot: testDir, + rulesyncCommand, + global: true, + }); + + expect(qwencodeCommand.getRelativeDirPath()).toBe(QWENCODE_COMMANDS_DIR_PATH); + expect(qwencodeCommand.getBody()).toBe("Global command body"); + }); + }); + + describe("toRulesyncCommand", () => { + it("should convert to RulesyncCommand correctly", () => { + const command = new QwencodeCommand({ + outputRoot: testDir, + relativeDirPath: QWENCODE_COMMANDS_DIR_PATH, + 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 in qwencode section", () => { + const command = new QwencodeCommand({ + outputRoot: testDir, + relativeDirPath: QWENCODE_COMMANDS_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { + description: "Test command", + "another-field": { nested: "value" }, + }, + body: "Test body", + validate: false, + }); + + const rulesyncCommand = command.toRulesyncCommand(); + const frontmatter = rulesyncCommand.getFrontmatter(); + + expect(frontmatter.qwencode).toEqual({ + "another-field": { nested: "value" }, + }); + }); + + it("should not include qwencode section when no extra fields", () => { + const command = new QwencodeCommand({ + outputRoot: testDir, + relativeDirPath: QWENCODE_COMMANDS_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { description: "Test command" }, + body: "Test body", + }); + + const rulesyncCommand = command.toRulesyncCommand(); + expect(rulesyncCommand.getFrontmatter().qwencode).toBeUndefined(); + }); + + it("should support round-trip conversion with RulesyncCommand", () => { + const originalCommand = new QwencodeCommand({ + outputRoot: testDir, + relativeDirPath: QWENCODE_COMMANDS_DIR_PATH, + relativeFilePath: "roundtrip.md", + frontmatter: { description: "Round trip test" }, + body: "Original command body", + }); + + const rulesyncCommand = originalCommand.toRulesyncCommand(); + const convertedCommand = QwencodeCommand.fromRulesyncCommand({ + outputRoot: testDir, + rulesyncCommand, + }); + + expect(convertedCommand.getBody()).toBe("Original command body"); + expect(convertedCommand.getFrontmatter()).toEqual({ + description: "Round trip test", + }); + expect(convertedCommand.getRelativeFilePath()).toBe("roundtrip.md"); + }); + }); + + describe("fromFile", () => { + it("should load QwencodeCommand from file", async () => { + const commandsDir = join(testDir, QWENCODE_COMMANDS_DIR_PATH); + await ensureDir(commandsDir); + + const fileContent = `--- +description: File test command +--- +This is the command body from file`; + + const filePath = join(commandsDir, "file-test.md"); + await writeFileContent(filePath, fileContent); + + const command = await QwencodeCommand.fromFile({ + outputRoot: testDir, + relativeFilePath: "file-test.md", + }); + + expect(command).toBeInstanceOf(QwencodeCommand); + expect(command.getBody()).toBe("This is the command body from file"); + expect(command.getFrontmatter()).toEqual({ + description: "File test command", + }); + expect(command.getRelativeFilePath()).toBe("file-test.md"); + expect(command.getRelativeDirPath()).toBe(QWENCODE_COMMANDS_DIR_PATH); + expect(command.getOutputRoot()).toBe(testDir); + }); + + it("should throw error for invalid frontmatter", async () => { + const commandsDir = join(testDir, QWENCODE_COMMANDS_DIR_PATH); + await ensureDir(commandsDir); + + const fileContent = `--- +description: 123 +--- +Command body`; + + const filePath = join(commandsDir, "invalid-test.md"); + await writeFileContent(filePath, fileContent); + + await expect( + QwencodeCommand.fromFile({ + outputRoot: testDir, + relativeFilePath: "invalid-test.md", + }), + ).rejects.toThrow(/Invalid frontmatter/); + }); + + it("should preserve subdirectory path in relativeFilePath", async () => { + const commandsDir = join(testDir, QWENCODE_COMMANDS_DIR_PATH, "git"); + await ensureDir(commandsDir); + + const fileContent = `--- +description: Subdirectory command +--- +Subdirectory command body`; + + const filePath = join(commandsDir, "commit.md"); + await writeFileContent(filePath, fileContent); + + const command = await QwencodeCommand.fromFile({ + outputRoot: testDir, + relativeFilePath: join("git", "commit.md"), + }); + + expect(command).toBeInstanceOf(QwencodeCommand); + expect(command.getBody()).toBe("Subdirectory command body"); + expect(command.getRelativeFilePath()).toBe(join("git", "commit.md")); + expect(command.getRelativeDirPath()).toBe(QWENCODE_COMMANDS_DIR_PATH); + }); + }); + + describe("isTargetedByRulesyncCommand", () => { + it("should return true for wildcard target", () => { + const rulesyncCommand = new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { targets: ["*"], description: "Test" }, + body: "Body", + fileContent: "", + }); + + expect(QwencodeCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return true for qwencode target", () => { + const rulesyncCommand = new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { targets: ["qwencode"], description: "Test" }, + body: "Body", + fileContent: "", + }); + + expect(QwencodeCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return false for different target", () => { + const rulesyncCommand = new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { targets: ["cursor"], description: "Test" }, + body: "Body", + fileContent: "", + }); + + expect(QwencodeCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(false); + }); + }); + + describe("QwencodeCommandFrontmatterSchema", () => { + it("should validate correct frontmatter", () => { + const result = QwencodeCommandFrontmatterSchema.safeParse({ description: "Valid" }); + expect(result.success).toBe(true); + }); + + it("should accept frontmatter without description", () => { + const result = QwencodeCommandFrontmatterSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should reject non-string description", () => { + const result = QwencodeCommandFrontmatterSchema.safeParse({ description: 123 }); + expect(result.success).toBe(false); + }); + + it("should allow additional properties", () => { + const result = QwencodeCommandFrontmatterSchema.safeParse({ + description: "Valid", + extra: "property", + }); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/features/commands/qwencode-command.ts b/src/features/commands/qwencode-command.ts new file mode 100644 index 000000000..fa7124232 --- /dev/null +++ b/src/features/commands/qwencode-command.ts @@ -0,0 +1,192 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { QWENCODE_COMMANDS_DIR_PATH } from "../../constants/qwencode-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"; + +// looseObject preserves unknown keys during parsing (like passthrough in Zod 3) +export const QwencodeCommandFrontmatterSchema = z.looseObject({ + description: z.optional(z.string()), +}); + +export type QwencodeCommandFrontmatter = z.infer; + +export type QwencodeCommandParams = { + frontmatter: QwencodeCommandFrontmatter; + body: string; +} & Omit; + +export class QwencodeCommand extends ToolCommand { + private readonly frontmatter: QwencodeCommandFrontmatter; + private readonly body: string; + + constructor({ frontmatter, body, ...rest }: QwencodeCommandParams) { + // Validate frontmatter before calling super to avoid validation order issues + if (rest.validate) { + const result = QwencodeCommandFrontmatterSchema.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 { + return { + relativeDirPath: QWENCODE_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 qwencode section + ...(Object.keys(restFields).length > 0 && { qwencode: 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): QwencodeCommand { + const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); + + // Merge qwencode-specific fields from rulesync frontmatter + const qwencodeFields = rulesyncFrontmatter.qwencode ?? {}; + + const qwencodeFrontmatter: QwencodeCommandFrontmatter = { + description: rulesyncFrontmatter.description, + ...qwencodeFields, + }; + + // Generate proper file content with Qwen Code specific frontmatter + const body = rulesyncCommand.getBody(); + + const paths = this.getSettablePaths({ global }); + + return new QwencodeCommand({ + outputRoot: outputRoot, + frontmatter: qwencodeFrontmatter, + 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 = QwencodeCommandFrontmatterSchema.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: "qwencode", + }); + } + + 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 QwencodeCommandFrontmatterSchema + const result = QwencodeCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } + + return new QwencodeCommand({ + outputRoot: outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + frontmatter: result.data, + body: content.trim(), + validate, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolCommandForDeletionParams): QwencodeCommand { + return new QwencodeCommand({ + 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 00ccedd17..30eed47b6 100644 --- a/src/features/hooks/hooks-processor.test.ts +++ b/src/features/hooks/hooks-processor.test.ts @@ -501,6 +501,7 @@ describe("HooksProcessor", () => { "devin", "augmentcode", "vibe", + "qwencode", ]); }); @@ -523,6 +524,7 @@ describe("HooksProcessor", () => { "augmentcode", "junie", "vibe", + "qwencode", ]); }); @@ -544,6 +546,7 @@ describe("HooksProcessor", () => { "devin", "augmentcode", "vibe", + "qwencode", ]); }); @@ -564,6 +567,7 @@ describe("HooksProcessor", () => { "augmentcode", "junie", "vibe", + "qwencode", ]); }); }); diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index 92da1a686..849c18548 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -18,6 +18,7 @@ import { KILO_HOOK_EVENTS, KIRO_HOOK_EVENTS, OPENCODE_HOOK_EVENTS, + QWENCODE_HOOK_EVENTS, VIBE_HOOK_EVENTS, type HookEvent, type HookType, @@ -44,6 +45,7 @@ import { KiloHooks } from "./kilo-hooks.js"; import { KiroCliHooks } from "./kiro-cli-hooks.js"; import { KiroHooks } from "./kiro-hooks.js"; import { OpencodeHooks } from "./opencode-hooks.js"; +import { QwencodeHooks } from "./qwencode-hooks.js"; import { RulesyncHooks } from "./rulesync-hooks.js"; import type { ToolHooksForDeletionParams, @@ -73,6 +75,7 @@ const hooksProcessorToolTargetTuple = [ "augmentcode", "junie", "vibe", + "qwencode", ] as const; export type HooksProcessorToolTarget = (typeof hooksProcessorToolTargetTuple)[number]; @@ -393,6 +396,23 @@ export const toolHooksFactories = new Map[0]> = {}, +) { + return { + outputRoot: "/mock", + relativeDirPath: ".rulesync", + relativeFilePath: "hooks.json", + fileContent: "{}", + ...override, + }; +} + +describe("QwencodeHooks", () => { + 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("fromRulesyncHooks", () => { + it("should map canonical events to Qwen Code PascalCase and filter unsupported events", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + preToolUse: [ + { command: "echo pre", name: "Pre Hook", description: "Runs before tool" }, + ], + beforeSubmitPrompt: [{ command: "echo prompt" }], + postToolUseFailure: [{ command: "echo failure" }], + // Not in QWENCODE_HOOK_EVENTS -> should be dropped. + afterFileEdit: [{ command: "echo ignored" }], + }, + }), + }), + ); + + const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + expect(parsed.hooks).toBeDefined(); + expect(parsed.hooks.PreToolUse).toBeDefined(); + expect(parsed.hooks.PreToolUse[0].hooks[0].command).toBe("echo pre"); + expect(parsed.hooks.PreToolUse[0].hooks[0].name).toBe("Pre Hook"); + expect(parsed.hooks.PreToolUse[0].hooks[0].description).toBe("Runs before tool"); + expect(parsed.hooks.UserPromptSubmit).toBeDefined(); + expect(parsed.hooks.UserPromptSubmit[0].hooks[0].command).toBe("echo prompt"); + expect(parsed.hooks.PostToolUseFailure).toBeDefined(); + expect(parsed.hooks.PostToolUseFailure[0].hooks[0].command).toBe("echo failure"); + // Unsupported canonical event must not leak through. + expect(parsed.hooks.AfterFileEdit).toBeUndefined(); + }); + + it("should NOT prefix dot-relative commands with $GEMINI_PROJECT_DIR", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "./hooks/start.sh" }], + }, + }), + }), + ); + + const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("./hooks/start.sh"); + expect(parsed.hooks.SessionStart[0].hooks[0].command).not.toContain("GEMINI_PROJECT_DIR"); + }); + + it("should group definitions by matcher", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + preToolUse: [ + { command: "echo a", matcher: "Edit" }, + { command: "echo b", matcher: "Edit" }, + { command: "echo c", matcher: "Write" }, + ], + }, + }), + }), + ); + + const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + const groups = parsed.hooks.PreToolUse; + expect(groups).toHaveLength(2); + const editGroup = groups.find((g: { matcher?: string }) => g.matcher === "Edit"); + const writeGroup = groups.find((g: { matcher?: string }) => g.matcher === "Write"); + expect(editGroup.hooks).toHaveLength(2); + expect(editGroup.hooks[0].command).toBe("echo a"); + expect(editGroup.hooks[1].command).toBe("echo b"); + expect(writeGroup.hooks).toHaveLength(1); + expect(writeGroup.hooks[0].command).toBe("echo c"); + }); + + it("should merge with existing settings.json", async () => { + const mockSettings = { + theme: "dark", + hooks: { + OldEvent: [{ hooks: [{ command: "old" }] }], + }, + }; + + const settingsPath = join(testDir, ".qwen", "settings.json"); + await ensureDir(join(testDir, ".qwen")); + await readOrInitializeFileContent(settingsPath, JSON.stringify(mockSettings)); + + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "echo start" }], + }, + }), + }), + ); + + const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + expect(parsed.theme).toBe("dark"); + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.OldEvent).toBeUndefined(); + }); + + it("should process qwencode overrides", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "echo start" }], + }, + qwencode: { + hooks: { + sessionStart: [{ command: "echo override" }], + sessionEnd: [{ command: "echo end" }], + }, + }, + }), + }), + ); + + const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo override"); + expect(parsed.hooks.SessionEnd[0].hooks[0].command).toBe("echo end"); + }); + }); + + describe("toRulesyncHooks", () => { + it("should round-trip Qwen Code PascalCase back to canonical", () => { + const qwencodeHooks = new QwencodeHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: "Edit", + hooks: [ + { + type: "command", + command: "echo pre", + timeout: 1000, + name: "Pre Hook", + description: "Runs before tool", + }, + ], + }, + ], + UserPromptSubmit: [{ hooks: [{ command: "echo prompt" }] }], + PostToolUseFailure: [{ hooks: [{ command: "echo failure" }] }], + }, + }), + }), + ); + + const rulesyncHooks = qwencodeHooks.toRulesyncHooks(); + const parsed = rulesyncHooks.getJson(); + + expect(parsed.hooks.preToolUse).toBeDefined(); + expect(parsed.hooks.preToolUse?.[0]).toEqual({ + type: "command", + command: "echo pre", + timeout: 1000, + matcher: "Edit", + name: "Pre Hook", + description: "Runs before tool", + }); + expect(parsed.hooks.beforeSubmitPrompt?.[0]).toEqual({ + type: "command", + command: "echo prompt", + }); + expect(parsed.hooks.postToolUseFailure?.[0]).toEqual({ + type: "command", + command: "echo failure", + }); + }); + + it("should NOT strip a $GEMINI_PROJECT_DIR prefix on import", () => { + const qwencodeHooks = new QwencodeHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ command: "$GEMINI_PROJECT_DIR/echo start" }] }], + }, + }), + }), + ); + + const parsed = qwencodeHooks.toRulesyncHooks().getJson(); + expect(parsed.hooks.sessionStart?.[0]?.command).toBe("$GEMINI_PROJECT_DIR/echo start"); + }); + + it("should ignore invalid entries", () => { + const qwencodeHooks = new QwencodeHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + PreToolUse: "invalid", + SessionEnd: ["invalid", { hooks: "invalid" }], + }, + }), + }), + ); + + const parsed = qwencodeHooks.toRulesyncHooks().getJson(); + expect(parsed.hooks.preToolUse).toBeUndefined(); + expect(parsed.hooks.sessionEnd).toBeUndefined(); + }); + }); + + describe("fromFile", () => { + it("should load from .qwen/settings.json when it exists", async () => { + await ensureDir(join(testDir, ".qwen")); + await writeFileContent( + join(testDir, ".qwen", "settings.json"), + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: "command", command: "echo start" }] }], + }, + }), + ); + + const qwencodeHooks = await QwencodeHooks.fromFile({ + outputRoot: testDir, + validate: false, + }); + expect(qwencodeHooks).toBeInstanceOf(QwencodeHooks); + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + expect(parsed.hooks.SessionStart).toHaveLength(1); + }); + + it("should initialize empty hooks when settings.json does not exist", async () => { + const qwencodeHooks = await QwencodeHooks.fromFile({ + outputRoot: testDir, + validate: false, + }); + expect(qwencodeHooks).toBeInstanceOf(QwencodeHooks); + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + expect(parsed.hooks).toEqual({}); + }); + }); + + describe("isDeletable", () => { + it("should return false", () => { + const hooks = new QwencodeHooks(createMockAiFileParams()); + expect(hooks.isDeletable()).toBe(false); + }); + }); + + describe("forDeletion", () => { + it("should create instance with empty hooks", () => { + const hooks = QwencodeHooks.forDeletion({ + relativeDirPath: ".qwen", + relativeFilePath: "settings.json", + }); + const parsed = JSON.parse(hooks.getFileContent()); + expect(parsed.hooks).toEqual({}); + }); + }); +}); diff --git a/src/features/hooks/qwencode-hooks.ts b/src/features/hooks/qwencode-hooks.ts new file mode 100644 index 000000000..fbf0f1e41 --- /dev/null +++ b/src/features/hooks/qwencode-hooks.ts @@ -0,0 +1,238 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { QWENCODE_DIR, QWENCODE_SETTINGS_FILE_NAME } from "../../constants/qwencode-paths.js"; +import type { AiFileParams } from "../../types/ai-file.js"; +import type { ValidationResult } from "../../types/ai-file.js"; +import type { HooksConfig } from "../../types/hooks.js"; +import { + QWENCODE_HOOK_EVENTS, + QWENCODE_TO_CANONICAL_EVENT_NAMES, + CANONICAL_TO_QWENCODE_EVENT_NAMES, +} from "../../types/hooks.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js"; +import type { RulesyncHooks } from "./rulesync-hooks.js"; +import { + ToolHooks, + type ToolHooksForDeletionParams, + type ToolHooksFromFileParams, + type ToolHooksFromRulesyncHooksParams, + type ToolHooksSettablePaths, +} from "./tool-hooks.js"; + +/** + * Convert canonical hooks config to Qwen Code format. + * Filters shared hooks to QWENCODE_HOOK_EVENTS, merges config.qwencode?.hooks, + * then converts to PascalCase and Qwen Code matcher/hooks structure. + * + * Qwen Code does not document a `$GEMINI_PROJECT_DIR`-style variable, so commands + * are passed through verbatim without any prefixing. + */ +function canonicalToQwencodeHooks(config: HooksConfig): Record { + const qwencodeSupported: Set = new Set(QWENCODE_HOOK_EVENTS); + const sharedHooks: HooksConfig["hooks"] = {}; + for (const [event, defs] of Object.entries(config.hooks)) { + if (qwencodeSupported.has(event)) { + sharedHooks[event] = defs; + } + } + const effectiveHooks: HooksConfig["hooks"] = { + ...sharedHooks, + ...config.qwencode?.hooks, + }; + const qwencode: Record = {}; + for (const [eventName, definitions] of Object.entries(effectiveHooks)) { + const qwencodeEventName = CANONICAL_TO_QWENCODE_EVENT_NAMES[eventName] ?? eventName; + const byMatcher = new Map(); + for (const def of definitions) { + const key = def.matcher ?? ""; + const list = byMatcher.get(key); + if (list) list.push(def); + else byMatcher.set(key, [def]); + } + const entries: unknown[] = []; + for (const [matcherKey, defs] of byMatcher) { + const hooks = defs.map((def) => { + return { + type: def.type ?? "command", + ...(def.command !== undefined && def.command !== null && { command: def.command }), + ...(def.timeout !== undefined && def.timeout !== null && { timeout: def.timeout }), + ...(def.name !== undefined && def.name !== null && { name: def.name }), + ...(def.description !== undefined && + def.description !== null && { description: def.description }), + }; + }); + entries.push(matcherKey ? { matcher: matcherKey, hooks } : { hooks }); + } + qwencode[qwencodeEventName] = entries; + } + return qwencode; +} + +/** + * Qwen Code hook entry as stored in each matcher group's `hooks` array. + * Uses `z.looseObject` so that unknown fields added by future Qwen Code + * versions are accepted and silently ignored during import. + */ +const QwencodeHookEntrySchema = z.looseObject({ + type: z.optional(z.string()), + command: z.optional(z.string()), + timeout: z.optional(z.number()), + name: z.optional(z.string()), + description: z.optional(z.string()), +}); + +/** + * A matcher group entry in a Qwen Code event array. + * Each event maps to an array of these groups. + */ +const QwencodeMatcherEntrySchema = z.looseObject({ + matcher: z.optional(z.string()), + hooks: z.optional(z.array(QwencodeHookEntrySchema)), +}); + +/** + * Extract hooks from Qwen Code settings.json into canonical format. + */ +function qwencodeHooksToCanonical(qwencodeHooks: unknown): HooksConfig["hooks"] { + if (qwencodeHooks === null || qwencodeHooks === undefined || typeof qwencodeHooks !== "object") { + return {}; + } + const canonical: HooksConfig["hooks"] = {}; + for (const [qwencodeEventName, matcherEntries] of Object.entries(qwencodeHooks)) { + const eventName = QWENCODE_TO_CANONICAL_EVENT_NAMES[qwencodeEventName] ?? qwencodeEventName; + if (!Array.isArray(matcherEntries)) continue; + const defs: HooksConfig["hooks"][string] = []; + for (const rawEntry of matcherEntries) { + const parseResult = QwencodeMatcherEntrySchema.safeParse(rawEntry); + if (!parseResult.success) continue; + const entry = parseResult.data; + const hooks = entry.hooks ?? []; + for (const h of hooks) { + const command = h.command; + const hookType = h.type === "command" || h.type === "prompt" ? h.type : "command"; + defs.push({ + type: hookType, + ...(command !== undefined && command !== null && { command }), + ...(h.timeout !== undefined && h.timeout !== null && { timeout: h.timeout }), + ...(h.name !== undefined && h.name !== null && { name: h.name }), + ...(h.description !== undefined && + h.description !== null && { description: h.description }), + ...(entry.matcher !== undefined && + entry.matcher !== null && + entry.matcher !== "" && { matcher: entry.matcher }), + }); + } + } + if (defs.length > 0) { + canonical[eventName] = defs; + } + } + return canonical; +} + +export class QwencodeHooks extends ToolHooks { + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "{}", + }); + } + + override isDeletable(): boolean { + return false; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolHooksSettablePaths { + return { relativeDirPath: QWENCODE_DIR, relativeFilePath: QWENCODE_SETTINGS_FILE_NAME }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + global = false, + }: ToolHooksFromFileParams): Promise { + const paths = QwencodeHooks.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const fileContent = (await readFileContentOrNull(filePath)) ?? '{"hooks":{}}'; + return new QwencodeHooks({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static async fromRulesyncHooks({ + outputRoot = process.cwd(), + rulesyncHooks, + validate = true, + global = false, + }: ToolHooksFromRulesyncHooksParams & { global?: boolean }): Promise { + const paths = QwencodeHooks.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 Qwen Code settings at ${filePath}: ${formatError(error)}`, + { cause: error }, + ); + } + const config = rulesyncHooks.getJson(); + const qwencodeHooks = canonicalToQwencodeHooks(config); + const merged = { ...settings, hooks: qwencodeHooks }; + const fileContent = JSON.stringify(merged, null, 2); + return new QwencodeHooks({ + 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 Qwen Code hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, + { + cause: error, + }, + ); + } + const hooks = qwencodeHooksToCanonical(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): QwencodeHooks { + return new QwencodeHooks({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify({ hooks: {} }, null, 2), + validate: false, + }); + } +} diff --git a/src/features/mcp/mcp-processor.ts b/src/features/mcp/mcp-processor.ts index 0188f69e3..7406d6e31 100644 --- a/src/features/mcp/mcp-processor.ts +++ b/src/features/mcp/mcp-processor.ts @@ -26,6 +26,7 @@ import { JunieMcp } from "./junie-mcp.js"; import { KiloMcp } from "./kilo-mcp.js"; import { KiroMcp } from "./kiro-mcp.js"; import { OpencodeMcp } from "./opencode-mcp.js"; +import { QwencodeMcp } from "./qwencode-mcp.js"; import { RooMcp } from "./roo-mcp.js"; import { RovodevMcp } from "./rovodev-mcp.js"; import { RulesyncMcp } from "./rulesync-mcp.js"; @@ -66,6 +67,7 @@ const mcpProcessorToolTargetTuple = [ "kiro-ide", "junie", "opencode", + "qwencode", "roo", "rovodev", "vibe", @@ -379,6 +381,23 @@ export const toolMcpFactories = new Map( }, }, ], + [ + "qwencode", + { + // Qwen Code reads MCP servers from the `mcpServers` key of + // `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global). + // It supports per-server tool filtering via `includeTools` (allowlist) + // and `excludeTools` (denylist), which the adapter maps to/from + // rulesync's `enabledTools`/`disabledTools`. + class: QwencodeMcp, + meta: { + supportsProject: true, + supportsGlobal: true, + supportsEnabledTools: true, + supportsDisabledTools: true, + }, + }, + ], [ "roo", { diff --git a/src/features/mcp/qwencode-mcp.test.ts b/src/features/mcp/qwencode-mcp.test.ts new file mode 100644 index 000000000..a6b4d7477 --- /dev/null +++ b/src/features/mcp/qwencode-mcp.test.ts @@ -0,0 +1,274 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + RULESYNC_MCP_SCHEMA_URL, + RULESYNC_RELATIVE_DIR_PATH, +} from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { QwencodeMcp } from "./qwencode-mcp.js"; +import { RulesyncMcp } from "./rulesync-mcp.js"; + +describe("QwencodeMcp", () => { + 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 correct paths for local mode", () => { + const paths = QwencodeMcp.getSettablePaths(); + + expect(paths.relativeDirPath).toBe(".qwen"); + expect(paths.relativeFilePath).toBe("settings.json"); + }); + + it("should return correct paths for global mode", () => { + const paths = QwencodeMcp.getSettablePaths({ global: true }); + + expect(paths.relativeDirPath).toBe(".qwen"); + expect(paths.relativeFilePath).toBe("settings.json"); + }); + }); + + describe("isDeletable", () => { + it("should always return false because settings.json may contain other settings", () => { + const localMcp = new QwencodeMcp({ + relativeDirPath: ".qwen", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ mcpServers: {} }), + global: false, + }); + + expect(localMcp.isDeletable()).toBe(false); + + const globalMcp = new QwencodeMcp({ + relativeDirPath: ".qwen", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ mcpServers: {} }), + global: true, + }); + + expect(globalMcp.isDeletable()).toBe(false); + }); + + it("should return false when created via forDeletion", () => { + const qwencodeMcp = QwencodeMcp.forDeletion({ + relativeDirPath: ".qwen", + relativeFilePath: "settings.json", + global: true, + }); + + expect(qwencodeMcp.isDeletable()).toBe(false); + }); + }); + + describe("fromRulesyncMcp", () => { + it("should write mcpServers with stdio and httpUrl servers", async () => { + const jsonData = { + mcpServers: { + filesystem: { + type: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", testDir], + }, + remote: { + type: "http", + httpUrl: "https://example.com/mcp", + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const qwencodeMcp = await QwencodeMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + }); + + const json = qwencodeMcp.getJson() as any; + expect(json.mcpServers.filesystem.command).toBe("npx"); + expect(json.mcpServers.filesystem.args).toEqual([ + "-y", + "@modelcontextprotocol/server-filesystem", + testDir, + ]); + expect(json.mcpServers.remote.httpUrl).toBe("https://example.com/mcp"); + expect(qwencodeMcp.getRelativeDirPath()).toBe(".qwen"); + expect(qwencodeMcp.getRelativeFilePath()).toBe("settings.json"); + }); + + it("should map enabledTools->includeTools and disabledTools->excludeTools", async () => { + const jsonData = { + mcpServers: { + server: { + type: "stdio", + command: "node", + args: ["server.js"], + enabledTools: ["read", "list"], + disabledTools: ["write"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const qwencodeMcp = await QwencodeMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + }); + + const server = (qwencodeMcp.getJson() as any).mcpServers.server; + expect(server.includeTools).toEqual(["read", "list"]); + expect(server.excludeTools).toEqual(["write"]); + expect(server.enabledTools).toBeUndefined(); + expect(server.disabledTools).toBeUndefined(); + }); + + it("should preserve non-mcpServers top-level keys", async () => { + const existingConfig = { + mcpServers: {}, + theme: "dark", + version: "1.0.0", + }; + await ensureDir(join(testDir, ".qwen")); + await writeFileContent( + join(testDir, ".qwen/settings.json"), + JSON.stringify(existingConfig, null, 2), + ); + + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + server: { type: "stdio", command: "node", args: ["s.js"] }, + }, + }), + }); + + const qwencodeMcp = await QwencodeMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + }); + + const json = qwencodeMcp.getJson() as any; + expect(json.theme).toBe("dark"); + expect(json.version).toBe("1.0.0"); + expect(json.mcpServers.server.command).toBe("node"); + }); + + it("should create instance in global mode", async () => { + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify({ mcpServers: {} }), + }); + + const qwencodeMcp = await QwencodeMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + global: true, + }); + + expect(qwencodeMcp.getRelativeDirPath()).toBe(".qwen"); + expect(qwencodeMcp.getRelativeFilePath()).toBe("settings.json"); + }); + }); + + describe("toRulesyncMcp", () => { + it("should round-trip and map includeTools->enabledTools / excludeTools->disabledTools", () => { + const jsonData = { + mcpServers: { + server: { + type: "stdio", + command: "node", + args: ["server.js"], + includeTools: ["read", "list"], + excludeTools: ["write"], + }, + remote: { + type: "http", + httpUrl: "https://example.com/mcp", + }, + }, + }; + const qwencodeMcp = new QwencodeMcp({ + relativeDirPath: ".qwen", + relativeFilePath: "settings.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = qwencodeMcp.toRulesyncMcp(); + + expect(rulesyncMcp).toBeInstanceOf(RulesyncMcp); + const exported = JSON.parse(rulesyncMcp.getFileContent()); + expect(exported.$schema).toBe(RULESYNC_MCP_SCHEMA_URL); + expect(exported.mcpServers.server.enabledTools).toEqual(["read", "list"]); + expect(exported.mcpServers.server.disabledTools).toEqual(["write"]); + expect(exported.mcpServers.server.includeTools).toBeUndefined(); + expect(exported.mcpServers.server.excludeTools).toBeUndefined(); + expect(exported.mcpServers.remote.httpUrl).toBe("https://example.com/mcp"); + expect(rulesyncMcp.getRelativeDirPath()).toBe(RULESYNC_RELATIVE_DIR_PATH); + }); + + it("should handle empty mcpServers object", () => { + const qwencodeMcp = new QwencodeMcp({ + relativeDirPath: ".qwen", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ mcpServers: {} }), + }); + + const rulesyncMcp = qwencodeMcp.toRulesyncMcp(); + const exported = JSON.parse(rulesyncMcp.getFileContent()); + expect(exported.mcpServers).toEqual({}); + }); + }); + + describe("integration", () => { + it("should round-trip enabledTools/disabledTools through both conversions", async () => { + const rulesyncJson = { + mcpServers: { + server: { + type: "stdio", + command: "node", + args: ["server.js"], + enabledTools: ["read"], + disabledTools: ["write"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(rulesyncJson), + }); + + const qwencodeMcp = await QwencodeMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + }); + + const backToRulesync = qwencodeMcp.toRulesyncMcp(); + const exported = JSON.parse(backToRulesync.getFileContent()); + expect(exported.mcpServers.server.enabledTools).toEqual(["read"]); + expect(exported.mcpServers.server.disabledTools).toEqual(["write"]); + }); + }); +}); diff --git a/src/features/mcp/qwencode-mcp.ts b/src/features/mcp/qwencode-mcp.ts new file mode 100644 index 000000000..3b1a8de33 --- /dev/null +++ b/src/features/mcp/qwencode-mcp.ts @@ -0,0 +1,175 @@ +import { join } from "node:path"; + +import { QWENCODE_DIR, QWENCODE_SETTINGS_FILE_NAME } from "../../constants/qwencode-paths.js"; +import { ValidationResult } from "../../types/ai-file.js"; +import { McpServers } from "../../types/mcp.js"; +import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js"; +import { RulesyncMcp } from "./rulesync-mcp.js"; +import { + ToolMcp, + ToolMcpForDeletionParams, + ToolMcpFromFileParams, + ToolMcpFromRulesyncMcpParams, + ToolMcpParams, + ToolMcpSettablePaths, +} from "./tool-mcp.js"; + +/** + * Map rulesync's canonical per-server tool filters to Qwen Code's field names. + * Qwen uses `includeTools` (allowlist) / `excludeTools` (denylist) while + * rulesync uses `enabledTools` / `disabledTools`. + * https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/mcp.md + */ +function convertToQwencodeFormat(mcpServers: McpServers): Record { + return Object.fromEntries( + Object.entries(mcpServers).map(([serverName, serverConfig]) => { + const newServer: Record = { ...serverConfig }; + if ("enabledTools" in newServer) { + newServer.includeTools = newServer.enabledTools; + delete newServer.enabledTools; + } + if ("disabledTools" in newServer) { + newServer.excludeTools = newServer.disabledTools; + delete newServer.disabledTools; + } + return [serverName, newServer]; + }), + ); +} + +/** + * Map Qwen Code's per-server tool filters back to rulesync's canonical names. + */ +function convertFromQwencodeFormat(mcpServers: Record): Record { + return Object.fromEntries( + Object.entries(mcpServers).map(([serverName, serverConfig]) => { + const newServer: Record = { + ...(serverConfig as Record), + }; + if ("includeTools" in newServer) { + newServer.enabledTools = newServer.includeTools; + delete newServer.includeTools; + } + if ("excludeTools" in newServer) { + newServer.disabledTools = newServer.excludeTools; + delete newServer.excludeTools; + } + return [serverName, newServer]; + }), + ); +} + +export class QwencodeMcp extends ToolMcp { + private readonly json: Record; + + constructor(params: ToolMcpParams) { + super(params); + this.json = JSON.parse(this.fileContent || "{}"); + } + + getJson(): Record { + return this.json; + } + + static getSettablePaths({ global }: { global?: boolean } = {}): ToolMcpSettablePaths { + if (global) { + return { + relativeDirPath: QWENCODE_DIR, + relativeFilePath: QWENCODE_SETTINGS_FILE_NAME, + }; + } + return { + relativeDirPath: QWENCODE_DIR, + relativeFilePath: QWENCODE_SETTINGS_FILE_NAME, + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + global = false, + }: ToolMcpFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const fileContent = + (await readFileContentOrNull( + join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), + )) ?? '{"mcpServers":{}}'; + const json = JSON.parse(fileContent); + const newJson = { ...json, mcpServers: json.mcpServers ?? {} }; + + return new QwencodeMcp({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent: JSON.stringify(newJson, null, 2), + validate, + }); + } + + static async fromRulesyncMcp({ + outputRoot = process.cwd(), + rulesyncMcp, + validate = true, + global = false, + }: ToolMcpFromRulesyncMcpParams): Promise { + const paths = this.getSettablePaths({ global }); + + const fileContent = await readOrInitializeFileContent( + join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), + JSON.stringify({ mcpServers: {} }, null, 2), + ); + const json = JSON.parse(fileContent); + // Use getMcpServers() (not getJson()) so rulesync-only fields and + // codex-only fields (`envVars`) are stripped before writing the + // qwen settings file. Then map enabledTools/disabledTools to Qwen's + // includeTools/excludeTools. + const newJson = { + ...json, + mcpServers: convertToQwencodeFormat(rulesyncMcp.getMcpServers()), + }; + + return new QwencodeMcp({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent: JSON.stringify(newJson, null, 2), + validate, + }); + } + + toRulesyncMcp(): RulesyncMcp { + const mcpServers = convertFromQwencodeFormat( + (this.json.mcpServers as Record) ?? {}, + ); + return this.toRulesyncMcpDefault({ + fileContent: JSON.stringify({ mcpServers }, null, 2), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + /** + * settings.json may contain other settings, so it should not be deleted. + */ + override isDeletable(): boolean { + return false; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + global = false, + }: ToolMcpForDeletionParams): QwencodeMcp { + return new QwencodeMcp({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: "{}", + validate: false, + global, + }); + } +} diff --git a/src/features/rules/qwencode-rule.ts b/src/features/rules/qwencode-rule.ts index 957439aa2..8a552e301 100644 --- a/src/features/rules/qwencode-rule.ts +++ b/src/features/rules/qwencode-rule.ts @@ -20,15 +20,23 @@ import { export type QwencodeRuleParams = ToolRuleParams; -export type QwencodeRuleSettablePaths = Omit & { - root: { - relativeDirPath: string; - relativeFilePath: string; - }; - nonRoot: { - relativeDirPath: string; - }; -}; +export type QwencodeRuleSettablePaths = + | (Omit & { + root: { + relativeDirPath: string; + relativeFilePath: string; + }; + nonRoot: { + relativeDirPath: string; + }; + }) + | { + root: { + relativeDirPath: string; + relativeFilePath: string; + }; + nonRoot?: undefined; + }; /** * Rule generator for Qwen Code AI assistant @@ -43,6 +51,17 @@ export class QwencodeRule extends ToolRule { excludeToolDir?: boolean; } = {}, ): QwencodeRuleSettablePaths { + // Global scope: the root memory file lives under `~/.qwen/QWEN.md`. Qwen + // Code does not document a global non-root memories directory, so only the + // root path is emitted (mirrors how geminicli global rules are wired). + if (_options.global) { + return { + root: { + relativeDirPath: buildToolPath(QWENCODE_DIR, ".", _options.excludeToolDir), + relativeFilePath: QWENCODE_RULE_FILE_NAME, + }, + }; + } return { root: { relativeDirPath: ".", @@ -58,34 +77,52 @@ export class QwencodeRule extends ToolRule { outputRoot = process.cwd(), relativeFilePath, validate = true, + global = false, }: ToolRuleFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); const isRoot = relativeFilePath === QWENCODE_RULE_FILE_NAME; - const relativePath = isRoot - ? QWENCODE_RULE_FILE_NAME - : join(QWENCODE_MEMORIES_DIR_PATH, relativeFilePath); + + if (isRoot) { + const fileContent = await readFileContent( + join(outputRoot, paths.root.relativeDirPath, QWENCODE_RULE_FILE_NAME), + ); + return new QwencodeRule({ + outputRoot, + relativeDirPath: paths.root.relativeDirPath, + relativeFilePath: QWENCODE_RULE_FILE_NAME, + fileContent, + validate, + root: true, + }); + } + + if (!paths.nonRoot) { + throw new Error(`nonRoot path is not set for ${relativeFilePath}`); + } + + const relativePath = join(QWENCODE_MEMORIES_DIR_PATH, relativeFilePath); const fileContent = await readFileContent(join(outputRoot, relativePath)); return new QwencodeRule({ outputRoot, - relativeDirPath: isRoot - ? this.getSettablePaths().root.relativeDirPath - : this.getSettablePaths().nonRoot.relativeDirPath, - relativeFilePath: isRoot ? QWENCODE_RULE_FILE_NAME : relativeFilePath, + relativeDirPath: paths.nonRoot.relativeDirPath, + relativeFilePath, fileContent, validate, - root: isRoot, + root: false, }); } static fromRulesyncRule(params: ToolRuleFromRulesyncRuleParams): QwencodeRule { - const { outputRoot = process.cwd(), rulesyncRule, validate = true } = params; + const { outputRoot = process.cwd(), rulesyncRule, validate = true, global = false } = params; + const paths = this.getSettablePaths({ global }); return new QwencodeRule( this.buildToolRuleParamsDefault({ outputRoot, rulesyncRule, validate, - rootPath: this.getSettablePaths().root, - nonRootPath: this.getSettablePaths().nonRoot, + rootPath: paths.root, + nonRootPath: paths.nonRoot, }), ); } diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 22732aaf8..d299812b2 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -908,6 +908,7 @@ Content that would fail parsing`; "kilo", "opencode", "pi", + "qwencode", "rovodev", "takt", "vibe", @@ -956,7 +957,7 @@ Content that would fail parsing`; expect(globalTargets).toContain("vibe"); expect(globalTargets).toContain("devin"); expect(globalTargets).toContain("zed"); - expect(globalTargets.length).toBe(23); + expect(globalTargets.length).toBe(24); // These targets should NOT be in global mode expect(globalTargets).not.toContain("cursor"); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index a6fd89884..0bd80b8a7 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -28,6 +28,7 @@ import { RulesyncSkill } from "../skills/rulesync-skill.js"; import { SkillsProcessor } from "../skills/skills-processor.js"; import { AgentsmdSubagent } from "../subagents/agentsmd-subagent.js"; import { GeminiCliSubagent } from "../subagents/geminicli-subagent.js"; +import { QwencodeSubagent } from "../subagents/qwencode-subagent.js"; import { RovodevSubagent } from "../subagents/rovodev-subagent.js"; import { SubagentsProcessor } from "../subagents/subagents-processor.js"; import { AgentsMdRule } from "./agentsmd-rule.js"; @@ -554,8 +555,13 @@ export const toolRuleFactories = 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.restoreAllMocks(); + }); + + describe("constructor", () => { + it("should create a QwencodeSkill with valid frontmatter and body", () => { + const frontmatter: QwencodeSkillFrontmatter = { + name: "test-skill", + description: "Test skill description", + }; + + const skill = new QwencodeSkill({ + dirName: "test-skill", + frontmatter, + body: "This is a test skill body", + otherFiles: [], + }); + + expect(skill.getFrontmatter()).toEqual(frontmatter); + expect(skill.getBody()).toBe("This is a test skill body"); + expect(skill.getOtherFiles()).toEqual([]); + }); + + it("should use default relativeDirPath", () => { + const skill = new QwencodeSkill({ + outputRoot: testDir, + dirName: "test-skill", + frontmatter: { name: "test-skill", description: "Test skill" }, + body: "Test body", + }); + + expect(skill.getRelativeDirPath()).toBe(join(".qwen", "skills")); + }); + + it("should support global mode", () => { + const skill = new QwencodeSkill({ + dirName: "global-skill", + frontmatter: { name: "global-skill", description: "Global skill" }, + body: "Global skill body", + global: true, + }); + + expect(skill.getGlobal()).toBe(true); + }); + }); + + describe("getSettablePaths", () => { + it("should return default paths without alternative roots", () => { + const paths = QwencodeSkill.getSettablePaths(); + expect(paths.relativeDirPath).toBe(join(".qwen", "skills")); + expect(paths.alternativeSkillRoots).toBeUndefined(); + }); + + it("should return same paths for global mode", () => { + const paths = QwencodeSkill.getSettablePaths({ global: true }); + expect(paths.relativeDirPath).toBe(join(".qwen", "skills")); + expect(paths.alternativeSkillRoots).toBeUndefined(); + }); + }); + + describe("validate", () => { + it("should validate successfully with valid frontmatter", () => { + const skill = new QwencodeSkill({ + dirName: "valid-skill", + frontmatter: { name: "valid-skill", description: "Valid skill description" }, + body: "Valid body", + validate: false, + }); + + const result = skill.validate(); + expect(result.success).toBe(true); + }); + + it("should fail validation with invalid frontmatter", () => { + const invalidFrontmatter = { + name: 123, + description: true, + } as any; + + const skill = new QwencodeSkill({ + dirName: "invalid-skill", + frontmatter: invalidFrontmatter, + body: "Test body", + validate: false, + }); + + const result = skill.validate(); + expect(result.success).toBe(false); + expect(result.error).toBeInstanceOf(Error); + }); + + it("should fail validation when mainFile is undefined", () => { + const skill = new QwencodeSkill({ + dirName: "test-skill", + frontmatter: { name: "test-skill", description: "Test skill" }, + body: "Test body", + validate: false, + }); + + (skill as any).mainFile = undefined; + + const result = skill.validate(); + expect(result.success).toBe(false); + expect(result.error?.message).toContain("SKILL.md file does not exist"); + }); + + it("should throw on missing required fields in the constructor", () => { + expect(() => { + const skill = new QwencodeSkill({ + dirName: "missing-fields", + frontmatter: { name: 123, description: true } as any, + body: "Test body", + }); + return skill; + }).toThrow(); + }); + }); + + describe("fromRulesyncSkill", () => { + it("should create /SKILL.md with name and description", async () => { + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { + name: "test-skill", + description: "Test description", + }; + + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + dirName: "test-skill", + frontmatter: rulesyncFrontmatter, + body: "Test body", + }); + + const qwencodeSkill = QwencodeSkill.fromRulesyncSkill({ + outputRoot: testDir, + rulesyncSkill, + }); + const frontmatter = qwencodeSkill.getFrontmatter(); + + expect(frontmatter.name).toBe("test-skill"); + expect(frontmatter.description).toBe("Test description"); + expect(qwencodeSkill.getRelativeDirPath()).toBe(join(".qwen", "skills")); + + // The main file is SKILL.md and carries name + description. + const mainFile = qwencodeSkill.getMainFile(); + expect(mainFile?.name).toBe(SKILL_FILE_NAME); + expect(mainFile?.frontmatter).toMatchObject({ + name: "test-skill", + description: "Test description", + }); + expect(qwencodeSkill.getBody()).toBe("Test body"); + }); + + it("should map qwencode section fields into frontmatter", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "full-skill", + frontmatter: { + name: "full-skill", + description: "Full skill", + qwencode: { + priority: 5, + paths: ["src/**/*.ts"], + "user-invocable": true, + "disable-model-invocation": false, + }, + } as RulesyncSkillFrontmatterInput, + body: "Full body", + }); + + const qwencodeSkill = QwencodeSkill.fromRulesyncSkill({ rulesyncSkill }); + const frontmatter = qwencodeSkill.getFrontmatter(); + + expect(frontmatter.priority).toBe(5); + expect(frontmatter.paths).toEqual(["src/**/*.ts"]); + expect(frontmatter["user-invocable"]).toBe(true); + expect(frontmatter["disable-model-invocation"]).toBe(false); + }); + + it("should prefer rulesync name and description", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "no-section", + frontmatter: { name: "no-section", description: "No section" }, + body: "Body", + }); + + const qwencodeSkill = QwencodeSkill.fromRulesyncSkill({ rulesyncSkill }); + const frontmatter = qwencodeSkill.getFrontmatter(); + + expect(frontmatter.name).toBe("no-section"); + expect(frontmatter.description).toBe("No section"); + expect(frontmatter.priority).toBeUndefined(); + expect(frontmatter.paths).toBeUndefined(); + }); + + it("should support global mode", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "global-skill", + frontmatter: { name: "global-skill", description: "Global skill" }, + body: "Global body", + global: true, + }); + + const qwencodeSkill = QwencodeSkill.fromRulesyncSkill({ + rulesyncSkill, + global: true, + }); + + expect(qwencodeSkill.getGlobal()).toBe(true); + }); + }); + + describe("toRulesyncSkill", () => { + it("should convert to RulesyncSkill without a qwencode section", () => { + const skill = new QwencodeSkill({ + dirName: "test-skill", + frontmatter: { name: "test-skill", description: "Test description" }, + body: "Test body", + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + + expect(rulesyncFrontmatter.name).toBe("test-skill"); + expect(rulesyncFrontmatter.description).toBe("Test description"); + expect((rulesyncFrontmatter as { qwencode?: unknown }).qwencode).toBeUndefined(); + expect(rulesyncSkill.getBody()).toBe("Test body"); + }); + + it("should convert to RulesyncSkill with a qwencode section", () => { + const skill = new QwencodeSkill({ + dirName: "full-skill", + frontmatter: { + name: "full-skill", + description: "Full skill", + priority: 3, + paths: "src/**/*.ts", + "user-invocable": false, + "disable-model-invocation": true, + }, + body: "Full body", + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + + expect((rulesyncFrontmatter as { qwencode?: unknown }).qwencode).toEqual({ + priority: 3, + paths: "src/**/*.ts", + "user-invocable": false, + "disable-model-invocation": true, + }); + }); + + it("should round-trip qwencode fields", () => { + const original = new QwencodeSkill({ + dirName: "round-trip", + frontmatter: { + name: "round-trip", + description: "Round trip", + priority: 7, + paths: ["a/**", "b/**"], + "user-invocable": true, + "disable-model-invocation": false, + }, + body: "Round trip body", + }); + + const rulesyncSkill = original.toRulesyncSkill(); + const restored = QwencodeSkill.fromRulesyncSkill({ rulesyncSkill }); + const fm = restored.getFrontmatter(); + + expect(fm.name).toBe("round-trip"); + expect(fm.description).toBe("Round trip"); + expect(fm.priority).toBe(7); + expect(fm.paths).toEqual(["a/**", "b/**"]); + expect(fm["user-invocable"]).toBe(true); + expect(fm["disable-model-invocation"]).toBe(false); + }); + + it("should preserve other files during conversion", () => { + const otherFiles = [ + { + relativeFilePathToDirPath: "helper.ts", + fileBuffer: Buffer.from("helper code"), + }, + ]; + + const skill = new QwencodeSkill({ + dirName: "test-skill", + frontmatter: { name: "test-skill", description: "Test skill" }, + body: "Test body", + otherFiles, + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + expect(rulesyncSkill.getOtherFiles()).toEqual(otherFiles); + }); + }); + + describe("isTargetedByRulesyncSkill", () => { + it("should target when targets includes *", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "test-skill", + frontmatter: { name: "test-skill", description: "Test skill" }, + body: "Test body", + }); + + expect(QwencodeSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true); + }); + + it("should target when targets includes qwencode", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "qwen-skill", + frontmatter: { + name: "qwen-skill", + description: "Qwen skill", + targets: ["qwencode"], + }, + body: "Body", + }); + + expect(QwencodeSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true); + }); + + it("should not target when targets excludes qwencode", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "cursor-skill", + frontmatter: { + name: "cursor-skill", + description: "Cursor skill", + targets: ["cursor"], + }, + body: "Body", + }); + + expect(QwencodeSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(false); + }); + }); + + describe("fromDir", () => { + it("should load skill from directory", async () => { + const skillDir = join(testDir, ".qwen", "skills", "test-skill"); + await ensureDir(skillDir); + + const content = `--- +name: test-skill +description: Test skill description +--- + +This is the skill body.`; + + await writeFileContent(join(skillDir, SKILL_FILE_NAME), content); + + const skill = await QwencodeSkill.fromDir({ + outputRoot: testDir, + dirName: "test-skill", + }); + + expect(skill.getFrontmatter()).toEqual({ + name: "test-skill", + description: "Test skill description", + }); + expect(skill.getBody()).toBe("This is the skill body."); + }); + + it("should load skill with qwencode fields and other files", async () => { + const skillDir = join(testDir, ".qwen", "skills", "multi-file-skill"); + await ensureDir(skillDir); + + const content = `--- +name: multi-file-skill +description: Skill with multiple files +priority: 2 +paths: + - src/**/*.ts +user-invocable: true +disable-model-invocation: false +--- + +Main skill content.`; + + await writeFileContent(join(skillDir, SKILL_FILE_NAME), content); + await writeFileBuffer( + join(skillDir, "helper.ts"), + Buffer.from("export function helper() {}"), + ); + + const skill = await QwencodeSkill.fromDir({ + outputRoot: testDir, + dirName: "multi-file-skill", + }); + + const frontmatter = skill.getFrontmatter(); + expect(frontmatter.priority).toBe(2); + expect(frontmatter.paths).toEqual(["src/**/*.ts"]); + expect(frontmatter["user-invocable"]).toBe(true); + expect(frontmatter["disable-model-invocation"]).toBe(false); + + const otherFiles = skill.getOtherFiles(); + expect(otherFiles).toHaveLength(1); + expect(otherFiles[0]?.relativeFilePathToDirPath).toBe("helper.ts"); + expect(otherFiles[0]?.fileBuffer.toString()).toBe("export function helper() {}"); + }); + + it("should throw error when SKILL.md does not exist", async () => { + const skillDir = join(testDir, ".qwen", "skills", "missing-skill"); + await ensureDir(skillDir); + + await expect( + QwencodeSkill.fromDir({ + outputRoot: testDir, + dirName: "missing-skill", + }), + ).rejects.toThrow("SKILL.md not found"); + }); + + it("should throw error with invalid frontmatter", async () => { + const skillDir = join(testDir, ".qwen", "skills", "invalid-skill"); + await ensureDir(skillDir); + + const content = `--- +name: 123 +description: true +--- + +Invalid frontmatter.`; + + await writeFileContent(join(skillDir, SKILL_FILE_NAME), content); + + await expect( + QwencodeSkill.fromDir({ + outputRoot: testDir, + dirName: "invalid-skill", + }), + ).rejects.toThrow("Invalid frontmatter"); + }); + }); + + describe("forDeletion", () => { + it("should create a minimal instance for deletion", () => { + const skill = QwencodeSkill.forDeletion({ + outputRoot: testDir, + relativeDirPath: join(".qwen", "skills"), + dirName: "to-delete", + }); + + expect(skill).toBeInstanceOf(QwencodeSkill); + expect(skill.getDirName()).toBe("to-delete"); + }); + }); + + describe("QwencodeSkillFrontmatterSchema", () => { + it("should validate valid frontmatter", () => { + const result = QwencodeSkillFrontmatterSchema.safeParse({ + name: "test-skill", + description: "Test description", + }); + expect(result.success).toBe(true); + }); + + it("should reject frontmatter without name", () => { + const result = QwencodeSkillFrontmatterSchema.safeParse({ + description: "Test description", + }); + expect(result.success).toBe(false); + }); + + it("should reject frontmatter without description", () => { + const result = QwencodeSkillFrontmatterSchema.safeParse({ + name: "test-skill", + }); + expect(result.success).toBe(false); + }); + + it("should reject non-number priority", () => { + const result = QwencodeSkillFrontmatterSchema.safeParse({ + name: "test-skill", + description: "Test", + priority: "high", + }); + expect(result.success).toBe(false); + }); + + it("should validate paths as string or array", () => { + expect( + QwencodeSkillFrontmatterSchema.safeParse({ + name: "test-skill", + description: "Test", + paths: "src/**/*.ts", + }).success, + ).toBe(true); + expect( + QwencodeSkillFrontmatterSchema.safeParse({ + name: "test-skill", + description: "Test", + paths: ["src/**/*.ts"], + }).success, + ).toBe(true); + }); + }); +}); diff --git a/src/features/skills/qwencode-skill.ts b/src/features/skills/qwencode-skill.ts new file mode 100644 index 000000000..995250c62 --- /dev/null +++ b/src/features/skills/qwencode-skill.ts @@ -0,0 +1,247 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { SKILL_FILE_NAME } from "../../constants/general.js"; +import { QWENCODE_SKILLS_DIR_PATH } from "../../constants/qwencode-paths.js"; +import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { ValidationResult } from "../../types/ai-dir.js"; +import { formatError } from "../../utils/error.js"; +import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; +import { + ToolSkill, + ToolSkillForDeletionParams, + ToolSkillFromDirParams, + ToolSkillFromRulesyncSkillParams, + ToolSkillSettablePaths, +} from "./tool-skill.js"; + +export const QwencodeSkillFrontmatterSchema = z.looseObject({ + name: z.string(), + description: z.string(), + priority: z.optional(z.number()), + paths: z.optional(z.union([z.string(), z.array(z.string())])), + "user-invocable": z.optional(z.boolean()), + "disable-model-invocation": z.optional(z.boolean()), +}); + +export type QwencodeSkillFrontmatter = z.infer; + +/** + * Shape of the `qwencode` section stored inside a RulesyncSkill frontmatter. + * The RulesyncSkill frontmatter schema is a `z.looseObject`, so this section is + * accepted at runtime even though it is not part of `RulesyncSkillFrontmatterInput`. + */ +type QwencodeRulesyncSection = { + priority?: number; + paths?: string | string[]; + "user-invocable"?: boolean; + "disable-model-invocation"?: boolean; +}; + +export type QwencodeSkillParams = { + outputRoot?: string; + relativeDirPath?: string; + dirName: string; + frontmatter: QwencodeSkillFrontmatter; + body: string; + otherFiles?: SkillFile[]; + validate?: boolean; + global?: boolean; +}; + +/** + * Represents a Qwen Code skill directory. + * Like Claude Code, Qwen Code skills are directories containing SKILL.md and + * optional supporting files. Extends ToolSkill to inherit directory management + * and security features from AiDir. + */ +export class QwencodeSkill extends ToolSkill { + constructor({ + outputRoot = process.cwd(), + relativeDirPath = QWENCODE_SKILLS_DIR_PATH, + dirName, + frontmatter, + body, + otherFiles = [], + validate = true, + global = false, + }: QwencodeSkillParams) { + super({ + outputRoot, + relativeDirPath, + dirName, + mainFile: { + name: SKILL_FILE_NAME, + body, + frontmatter: { ...frontmatter }, + }, + otherFiles, + global, + }); + + if (validate) { + const result = this.validate(); + if (!result.success) { + throw result.error; + } + } + } + + static getSettablePaths({ + global: _global = false, + }: { + global?: boolean; + } = {}): ToolSkillSettablePaths { + return { + relativeDirPath: QWENCODE_SKILLS_DIR_PATH, + }; + } + + getFrontmatter(): QwencodeSkillFrontmatter { + const result = QwencodeSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter()); + return result; + } + + getBody(): string { + return this.mainFile?.body ?? ""; + } + + validate(): ValidationResult { + if (this.mainFile === undefined) { + return { + success: false, + error: new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`), + }; + } + const result = QwencodeSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter); + if (!result.success) { + return { + success: false, + error: new Error( + `Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`, + ), + }; + } + + return { success: true, error: null }; + } + + toRulesyncSkill(): RulesyncSkill { + const frontmatter = this.getFrontmatter(); + const qwencodeSection: QwencodeRulesyncSection = { + ...(frontmatter.priority !== undefined && { priority: frontmatter.priority }), + ...(frontmatter.paths !== undefined && { paths: frontmatter.paths }), + ...(frontmatter["user-invocable"] !== undefined && { + "user-invocable": frontmatter["user-invocable"], + }), + ...(frontmatter["disable-model-invocation"] !== undefined && { + "disable-model-invocation": frontmatter["disable-model-invocation"], + }), + }; + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { + name: frontmatter.name, + description: frontmatter.description, + targets: ["*"], + ...(Object.keys(qwencodeSection).length > 0 && { qwencode: qwencodeSection }), + }; + + return new RulesyncSkill({ + outputRoot: this.outputRoot, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: this.getDirName(), + frontmatter: rulesyncFrontmatter, + body: this.getBody(), + otherFiles: this.getOtherFiles(), + validate: true, + global: this.global, + }); + } + + static fromRulesyncSkill({ + outputRoot = process.cwd(), + rulesyncSkill, + validate = true, + global = false, + }: ToolSkillFromRulesyncSkillParams): QwencodeSkill { + const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + const qwencodeSection = (rulesyncFrontmatter as { qwencode?: QwencodeRulesyncSection }) + .qwencode; + + const qwencodeFrontmatter: QwencodeSkillFrontmatter = { + name: rulesyncFrontmatter.name, + description: rulesyncFrontmatter.description, + ...(qwencodeSection?.priority !== undefined && { priority: qwencodeSection.priority }), + ...(qwencodeSection?.paths !== undefined && { paths: qwencodeSection.paths }), + ...(qwencodeSection?.["user-invocable"] !== undefined && { + "user-invocable": qwencodeSection["user-invocable"], + }), + ...(qwencodeSection?.["disable-model-invocation"] !== undefined && { + "disable-model-invocation": qwencodeSection["disable-model-invocation"], + }), + }; + + const settablePaths = QwencodeSkill.getSettablePaths({ global }); + + return new QwencodeSkill({ + outputRoot, + relativeDirPath: settablePaths.relativeDirPath, + dirName: rulesyncSkill.getDirName(), + frontmatter: qwencodeFrontmatter, + body: rulesyncSkill.getBody(), + otherFiles: rulesyncSkill.getOtherFiles(), + validate, + global, + }); + } + + static isTargetedByRulesyncSkill(rulesyncSkill: RulesyncSkill): boolean { + const frontmatter = rulesyncSkill.getFrontmatter(); + const targets = frontmatter.targets; + return targets.includes("*") || targets.includes("qwencode"); + } + + static async fromDir(params: ToolSkillFromDirParams): Promise { + const loaded = await this.loadSkillDirContent({ + ...params, + getSettablePaths: QwencodeSkill.getSettablePaths, + }); + + const result = QwencodeSkillFrontmatterSchema.safeParse(loaded.frontmatter); + if (!result.success) { + const skillDirPath = join(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName); + throw new Error( + `Invalid frontmatter in ${join(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`, + ); + } + + return new QwencodeSkill({ + outputRoot: loaded.outputRoot, + relativeDirPath: loaded.relativeDirPath, + dirName: loaded.dirName, + frontmatter: result.data, + body: loaded.body, + otherFiles: loaded.otherFiles, + validate: true, + global: loaded.global, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + dirName, + global = false, + }: ToolSkillForDeletionParams): QwencodeSkill { + return new QwencodeSkill({ + outputRoot, + relativeDirPath, + dirName, + frontmatter: { name: "", description: "" }, + body: "", + otherFiles: [], + validate: false, + global, + }); + } +} diff --git a/src/features/skills/skills-processor.test.ts b/src/features/skills/skills-processor.test.ts index 77cb49b34..f680e1f72 100644 --- a/src/features/skills/skills-processor.test.ts +++ b/src/features/skills/skills-processor.test.ts @@ -876,6 +876,7 @@ Content that would fail parsing`; "kiro-ide", "opencode", "pi", + "qwencode", "replit", "roo", "rovodev", @@ -916,6 +917,7 @@ Content that would fail parsing`; "kiro-ide", "opencode", "pi", + "qwencode", "replit", "roo", "rovodev", @@ -955,6 +957,7 @@ Content that would fail parsing`; "kiro-ide", "opencode", "pi", + "qwencode", "replit", "roo", "rovodev", @@ -1002,6 +1005,7 @@ Content that would fail parsing`; "kilo", "opencode", "pi", + "qwencode", "replit", "roo", "rovodev", @@ -1038,6 +1042,7 @@ Content that would fail parsing`; "kilo", "opencode", "pi", + "qwencode", "replit", "roo", "rovodev", diff --git a/src/features/skills/skills-processor.ts b/src/features/skills/skills-processor.ts index 7761a6576..08e5dc7a1 100644 --- a/src/features/skills/skills-processor.ts +++ b/src/features/skills/skills-processor.ts @@ -33,6 +33,7 @@ import { KiroIdeSkill } from "./kiro-ide-skill.js"; import { KiroSkill } from "./kiro-skill.js"; import { OpenCodeSkill } from "./opencode-skill.js"; import { PiSkill } from "./pi-skill.js"; +import { QwencodeSkill } from "./qwencode-skill.js"; import { ReplitSkill } from "./replit-skill.js"; import { RooSkill } from "./roo-skill.js"; import { RovodevSkill } from "./rovodev-skill.js"; @@ -103,6 +104,7 @@ const skillsProcessorToolTargetTuple = [ "kiro-ide", "opencode", "pi", + "qwencode", "replit", "roo", "rovodev", @@ -304,6 +306,15 @@ export const toolSkillFactories = new Map/SKILL.md`) under + // `.qwen/skills/` (project) / `~/.qwen/skills/` (personal/global). + class: QwencodeSkill, + meta: { supportsProject: true, supportsSimulated: false, supportsGlobal: true }, + }, + ], [ "replit", { diff --git a/src/features/subagents/qwencode-subagent.test.ts b/src/features/subagents/qwencode-subagent.test.ts new file mode 100644 index 000000000..d70686c64 --- /dev/null +++ b/src/features/subagents/qwencode-subagent.test.ts @@ -0,0 +1,477 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { writeFileContent } from "../../utils/file.js"; +import { QwencodeSubagent } from "./qwencode-subagent.js"; +import { RulesyncSubagent } from "./rulesync-subagent.js"; +import { ToolSubagent } from "./tool-subagent.js"; + +describe("QwencodeSubagent", () => { + let testDir: string; + let cleanup: () => Promise; + + const validMarkdownContent = `--- +name: Test Qwencode Agent +description: Test qwencode agent description +model: qwen-coder +approvalMode: auto +--- + +This is the body of the qwencode agent. +It can be multiline.`; + + const invalidMarkdownContent = `--- +# Missing required fields +invalid: true +--- + +Body content`; + + const markdownWithoutFrontmatter = `This is just plain content without frontmatter.`; + + beforeEach(async () => { + const testSetup = await setupTestDirectory(); + testDir = testSetup.testDir; + cleanup = testSetup.cleanup; + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return correct paths for qwencode subagents", () => { + const paths = QwencodeSubagent.getSettablePaths(); + expect(paths).toEqual({ + relativeDirPath: join(".qwen", "agents"), + }); + }); + + it("should return same .qwen/agents path in global mode (resolved relative to home)", () => { + // Per https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/sub-agents.md + // global subagents live at ~/.qwen/agents/*.md, which uses the same + // relative directory as project mode resolved against the home dir. + const paths = QwencodeSubagent.getSettablePaths({ global: true }); + expect(paths).toEqual({ + relativeDirPath: join(".qwen", "agents"), + }); + }); + }); + + describe("constructor", () => { + it("should create instance with valid markdown content", () => { + const subagent = new QwencodeSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + relativeFilePath: "test-agent.md", + frontmatter: { + name: "Test Qwencode Agent", + description: "Test qwencode agent description", + }, + body: "This is the body of the qwencode agent.\nIt can be multiline.", + validate: true, + }); + + expect(subagent).toBeInstanceOf(QwencodeSubagent); + expect(subagent.getBody()).toBe( + "This is the body of the qwencode agent.\nIt can be multiline.", + ); + expect(subagent.getFrontmatter()).toEqual({ + name: "Test Qwencode Agent", + description: "Test qwencode agent description", + }); + }); + + it("should throw error for invalid frontmatter when validation is enabled", () => { + expect( + () => + new QwencodeSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + relativeFilePath: "invalid-agent.md", + frontmatter: { + // Missing required name field + } as { name: string }, + body: "Body content", + validate: true, + }), + ).toThrow(); + }); + }); + + describe("getBody", () => { + it("should return the body content", () => { + const subagent = new QwencodeSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + relativeFilePath: "test-agent.md", + frontmatter: { + name: "Test Agent", + description: "Test description", + }, + body: "This is the body content.\nWith multiple lines.", + validate: true, + }); + + expect(subagent.getBody()).toBe("This is the body content.\nWith multiple lines."); + }); + }); + + describe("getFrontmatter", () => { + it("should return frontmatter with name and description", () => { + const subagent = new QwencodeSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + relativeFilePath: "test-agent.md", + frontmatter: { + name: "Test Qwencode Agent", + description: "Test qwencode agent", + }, + body: "Test body", + validate: true, + }); + + expect(subagent.getFrontmatter()).toEqual({ + name: "Test Qwencode Agent", + description: "Test qwencode agent", + }); + }); + }); + + describe("fromRulesyncSubagent", () => { + it("should create QwencodeSubagent from RulesyncSubagent", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "test-agent.md", + frontmatter: { + targets: ["qwencode"], + name: "Test Agent", + description: "Test description from rulesync", + }, + body: "Test agent content", + validate: true, + }); + + const qwencodeSubagent = QwencodeSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + rulesyncSubagent, + validate: true, + }) as QwencodeSubagent; + + expect(qwencodeSubagent).toBeInstanceOf(QwencodeSubagent); + expect(qwencodeSubagent.getBody()).toBe("Test agent content"); + expect(qwencodeSubagent.getFrontmatter()).toEqual({ + name: "Test Agent", + description: "Test description from rulesync", + }); + expect(qwencodeSubagent.getRelativeFilePath()).toBe("test-agent.md"); + expect(qwencodeSubagent.getRelativeDirPath()).toBe(".qwen/agents"); + }); + + it("should emit Markdown with YAML frontmatter including qwencode-section fields", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "rich-agent.md", + frontmatter: { + targets: ["qwencode"], + name: "Rich Agent", + description: "Rich agent description", + qwencode: { + model: "qwen-coder", + approvalMode: "auto", + tools: ["read_file", "write_file"], + disallowedTools: ["run_shell_command"], + maxTurns: 5, + color: "blue", + mcpServers: ["my-server"], + }, + }, + body: "Rich agent body", + validate: true, + }); + + const qwencodeSubagent = QwencodeSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + rulesyncSubagent, + validate: true, + }) as QwencodeSubagent; + + const frontmatter = qwencodeSubagent.getFrontmatter(); + expect(frontmatter).toEqual({ + name: "Rich Agent", + description: "Rich agent description", + model: "qwen-coder", + approvalMode: "auto", + tools: ["read_file", "write_file"], + disallowedTools: ["run_shell_command"], + maxTurns: 5, + color: "blue", + mcpServers: ["my-server"], + }); + + const fileContent = qwencodeSubagent.getFileContent(); + expect(fileContent).toContain("name: Rich Agent"); + expect(fileContent).toContain("model: qwen-coder"); + expect(fileContent).toContain("approvalMode: auto"); + expect(fileContent).toContain("Rich agent body"); + }); + + it("should handle empty name and description", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "test-agent.md", + frontmatter: { + targets: ["qwencode"], + name: "", + description: "", + }, + body: "Test content", + validate: true, + }); + + const qwencodeSubagent = QwencodeSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + rulesyncSubagent, + validate: true, + }) as QwencodeSubagent; + + expect(qwencodeSubagent.getFrontmatter()).toEqual({ + name: "", + description: "", + }); + }); + }); + + describe("toRulesyncSubagent", () => { + it("should convert to RulesyncSubagent and round-trip qwencode-section fields", () => { + const subagent = new QwencodeSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + relativeFilePath: "test-agent.md", + frontmatter: { + name: "Test Agent", + description: "Test description", + model: "qwen-coder", + approvalMode: "auto", + tools: ["read_file"], + }, + body: "Test body", + validate: true, + }); + + const rulesyncSubagent = subagent.toRulesyncSubagent(); + expect(rulesyncSubagent).toBeInstanceOf(RulesyncSubagent); + expect(rulesyncSubagent.getFrontmatter().name).toBe("Test Agent"); + expect(rulesyncSubagent.getFrontmatter().description).toBe("Test description"); + expect(rulesyncSubagent.getFrontmatter().qwencode).toEqual({ + model: "qwen-coder", + approvalMode: "auto", + tools: ["read_file"], + }); + expect(rulesyncSubagent.getBody()).toBe("Test body"); + + // round-trip back to QwencodeSubagent + const roundTripped = QwencodeSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + rulesyncSubagent, + validate: true, + }) as QwencodeSubagent; + + expect(roundTripped.getFrontmatter()).toEqual({ + name: "Test Agent", + description: "Test description", + model: "qwen-coder", + approvalMode: "auto", + tools: ["read_file"], + }); + }); + }); + + describe("fromFile", () => { + it("should load QwencodeSubagent from file", async () => { + const subagentsDir = join(testDir, ".qwen", "agents"); + const filePath = join(subagentsDir, "test-file-agent.md"); + + await writeFileContent(filePath, validMarkdownContent); + + const subagent = await QwencodeSubagent.fromFile({ + outputRoot: testDir, + relativeFilePath: "test-file-agent.md", + validate: true, + }); + + expect(subagent).toBeInstanceOf(QwencodeSubagent); + expect(subagent.getBody()).toBe( + "This is the body of the qwencode agent.\nIt can be multiline.", + ); + expect(subagent.getFrontmatter()).toEqual({ + name: "Test Qwencode Agent", + description: "Test qwencode agent description", + model: "qwen-coder", + approvalMode: "auto", + }); + expect(subagent.getRelativeFilePath()).toBe("test-file-agent.md"); + }); + + it("should throw error when file does not exist", async () => { + await expect( + QwencodeSubagent.fromFile({ + outputRoot: testDir, + relativeFilePath: "non-existent-agent.md", + validate: true, + }), + ).rejects.toThrow(); + }); + + it("should throw error when file contains invalid frontmatter", async () => { + const subagentsDir = join(testDir, ".qwen", "agents"); + const filePath = join(subagentsDir, "invalid-agent.md"); + + await writeFileContent(filePath, invalidMarkdownContent); + + await expect( + QwencodeSubagent.fromFile({ + outputRoot: testDir, + relativeFilePath: "invalid-agent.md", + validate: true, + }), + ).rejects.toThrow(); + }); + + it("should throw error for file without frontmatter", async () => { + const subagentsDir = join(testDir, ".qwen", "agents"); + const filePath = join(subagentsDir, "no-frontmatter.md"); + + await writeFileContent(filePath, markdownWithoutFrontmatter); + + await expect( + QwencodeSubagent.fromFile({ + outputRoot: testDir, + relativeFilePath: "no-frontmatter.md", + validate: true, + }), + ).rejects.toThrow(); + }); + }); + + describe("validate", () => { + it("should return success for valid frontmatter", () => { + const subagent = new QwencodeSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + relativeFilePath: "valid-agent.md", + frontmatter: { + name: "Valid Agent", + description: "Valid description", + }, + body: "Valid body", + validate: false, + }); + + const result = subagent.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + }); + + describe("inheritance", () => { + it("should be an instance of ToolSubagent", () => { + const subagent = new QwencodeSubagent({ + outputRoot: testDir, + relativeDirPath: ".qwen/agents", + relativeFilePath: "test.md", + frontmatter: { + name: "Test", + description: "Test", + }, + body: "Test", + validate: true, + }); + + expect(subagent).toBeInstanceOf(QwencodeSubagent); + expect(subagent).toBeInstanceOf(ToolSubagent); + }); + }); + + describe("isTargetedByRulesyncSubagent", () => { + it("should return true when targets includes qwencode", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "test-agent.md", + frontmatter: { + targets: ["qwencode"], + name: "Test Agent", + description: "Test description", + }, + body: "Test content", + validate: true, + }); + + expect(QwencodeSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(true); + }); + + it("should return true when targets includes asterisk", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "test-agent.md", + frontmatter: { + targets: ["*"], + name: "Test Agent", + description: "Test description", + }, + body: "Test content", + validate: true, + }); + + expect(QwencodeSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(true); + }); + + it("should return false when targets array is empty", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "test-agent.md", + frontmatter: { + targets: [], + name: "Test Agent", + description: "Test description", + }, + body: "Test content", + validate: false, + }); + + expect(QwencodeSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(false); + }); + + it("should return false when targets does not include qwencode", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "test-agent.md", + frontmatter: { + targets: ["copilot", "cline"], + name: "Test Agent", + description: "Test description", + }, + body: "Test content", + validate: true, + }); + + expect(QwencodeSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(false); + }); + }); +}); diff --git a/src/features/subagents/qwencode-subagent.ts b/src/features/subagents/qwencode-subagent.ts new file mode 100644 index 000000000..ed73e72dd --- /dev/null +++ b/src/features/subagents/qwencode-subagent.ts @@ -0,0 +1,200 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { QWENCODE_AGENTS_DIR_PATH } from "../../constants/qwencode-paths.js"; +import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-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 { RulesyncSubagent, RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; +import { + ToolSubagent, + ToolSubagentForDeletionParams, + ToolSubagentFromFileParams, + ToolSubagentFromRulesyncSubagentParams, + ToolSubagentSettablePaths, +} from "./tool-subagent.js"; + +const QwencodeSubagentFrontmatterSchema = z.looseObject({ + name: z.string(), + description: z.optional(z.string()), + model: z.optional(z.string()), + approvalMode: z.optional(z.string()), + tools: z.optional(z.array(z.string())), + disallowedTools: z.optional(z.array(z.string())), + maxTurns: z.optional(z.number()), + color: z.optional(z.string()), + mcpServers: z.optional(z.array(z.string())), + hooks: z.optional(z.unknown()), +}); + +type QwencodeSubagentFrontmatter = z.infer; + +type QwencodeSubagentParams = { + frontmatter: QwencodeSubagentFrontmatter; + body: string; +} & Omit & { fileContent?: string }; + +export class QwencodeSubagent extends ToolSubagent { + private readonly frontmatter: QwencodeSubagentFrontmatter; + private readonly body: string; + + constructor({ frontmatter, body, fileContent, ...rest }: QwencodeSubagentParams) { + if (rest.validate !== false) { + const result = QwencodeSubagentFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error( + `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, + ); + } + } + + super({ + ...rest, + fileContent: fileContent ?? stringifyFrontmatter(body, frontmatter), + }); + + this.frontmatter = frontmatter; + this.body = body; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolSubagentSettablePaths { + return { + relativeDirPath: QWENCODE_AGENTS_DIR_PATH, + }; + } + + getFrontmatter(): QwencodeSubagentFrontmatter { + return this.frontmatter; + } + + getBody(): string { + return this.body; + } + + toRulesyncSubagent(): RulesyncSubagent { + const { name, description, ...rest } = this.frontmatter; + + const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { + targets: ["*"] as const, + name, + description, + qwencode: { + ...rest, + }, + }; + + return new RulesyncSubagent({ + outputRoot: ".", + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: this.getRelativeFilePath(), + validate: true, + }); + } + + static fromRulesyncSubagent({ + outputRoot = process.cwd(), + rulesyncSubagent, + validate = true, + global = false, + }: ToolSubagentFromRulesyncSubagentParams): ToolSubagent { + const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter(); + const qwencodeSection = rulesyncFrontmatter.qwencode ?? {}; + + const qwencodeSubagentFrontmatter: QwencodeSubagentFrontmatter = { + name: rulesyncFrontmatter.name, + description: rulesyncFrontmatter.description, + ...qwencodeSection, + }; + + const body = rulesyncSubagent.getBody(); + const fileContent = stringifyFrontmatter(body, qwencodeSubagentFrontmatter, { + avoidBlockScalars: true, + }); + const paths = this.getSettablePaths({ global }); + + return new QwencodeSubagent({ + outputRoot, + frontmatter: qwencodeSubagentFrontmatter, + body, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: rulesyncSubagent.getRelativeFilePath(), + fileContent, + validate, + global, + }); + } + + validate(): ValidationResult { + if (!this.frontmatter) { + return { success: true, error: null }; + } + + const result = QwencodeSubagentFrontmatterSchema.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 isTargetedByRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): boolean { + return this.isTargetedByRulesyncSubagentDefault({ + rulesyncSubagent, + toolTarget: "qwencode", + }); + } + + static async fromFile({ + outputRoot = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolSubagentFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath); + const fileContent = await readFileContent(filePath); + const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); + + const result = QwencodeSubagentFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } + + return new QwencodeSubagent({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + frontmatter: result.data, + body: content.trim(), + fileContent, + validate, + global, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolSubagentForDeletionParams): QwencodeSubagent { + return new QwencodeSubagent({ + outputRoot, + relativeDirPath, + relativeFilePath, + frontmatter: { name: "", description: "" }, + body: "", + fileContent: "", + validate: false, + }); + } +} diff --git a/src/features/subagents/subagents-processor.test.ts b/src/features/subagents/subagents-processor.test.ts index 0e58e9f29..26e9c1aa7 100644 --- a/src/features/subagents/subagents-processor.test.ts +++ b/src/features/subagents/subagents-processor.test.ts @@ -1039,6 +1039,7 @@ Second global content`; "junie", "kilo", "opencode", + "qwencode", "rovodev", "takt", "vibe", @@ -1091,6 +1092,7 @@ Second global content`; "kiro-cli", "kiro-ide", "opencode", + "qwencode", "roo", "rovodev", "takt", diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index e120273ab..a20660f86 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -28,6 +28,7 @@ import { KiroCliSubagent } from "./kiro-cli-subagent.js"; import { KiroIdeSubagent } from "./kiro-ide-subagent.js"; import { KiroSubagent } from "./kiro-subagent.js"; import { OpenCodeSubagent } from "./opencode-subagent.js"; +import { QwencodeSubagent } from "./qwencode-subagent.js"; import { RooSubagent } from "./roo-subagent.js"; import { RovodevSubagent } from "./rovodev-subagent.js"; import { RulesyncSubagent } from "./rulesync-subagent.js"; @@ -100,6 +101,7 @@ const subagentsProcessorToolTargetTuple = [ "kiro-cli", "kiro-ide", "opencode", + "qwencode", "roo", "rovodev", "takt", @@ -288,6 +290,15 @@ export const toolSubagentFactories = new Map; @@ -733,3 +761,33 @@ export const CANONICAL_TO_VIBE_EVENT_NAMES: Record = { export const VIBE_TO_CANONICAL_EVENT_NAMES: Record = Object.fromEntries( Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k]), ); + +/** + * Map canonical camelCase event names to Qwen Code PascalCase. + * + * Qwen Code reuses the same Claude-style PascalCase event names for the events + * it shares, but its supported set differs from both Claude and Gemini CLI. + * @see https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md + */ +export const CANONICAL_TO_QWENCODE_EVENT_NAMES: Record = { + sessionStart: "SessionStart", + sessionEnd: "SessionEnd", + preToolUse: "PreToolUse", + postToolUse: "PostToolUse", + postToolUseFailure: "PostToolUseFailure", + beforeSubmitPrompt: "UserPromptSubmit", + stop: "Stop", + subagentStart: "SubagentStart", + subagentStop: "SubagentStop", + preCompact: "PreCompact", + postCompact: "PostCompact", + permissionRequest: "PermissionRequest", + notification: "Notification", +}; + +/** + * Map Qwen Code PascalCase event names to canonical camelCase. + */ +export const QWENCODE_TO_CANONICAL_EVENT_NAMES: Record = Object.fromEntries( + Object.entries(CANONICAL_TO_QWENCODE_EVENT_NAMES).map(([k, v]) => [v, k]), +);