Skip to content

Commit 6fb0b13

Browse files
committed
feat(qwen): add native hook integration
1 parent 8a24ce2 commit 6fb0b13

8 files changed

Lines changed: 662 additions & 5 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ rtk gain # Should show the savings dashboard
114114
# 1. Install for your AI tool
115115
rtk init -g # Claude Code / Copilot (default)
116116
rtk init -g --gemini # Gemini CLI
117+
rtk init -g --qwen # Qwen Code CLI
117118
rtk init -g --codex # Codex (OpenAI)
118119
rtk init -g --agent cursor # Cursor
119120
rtk init -g --agent windsurf # Windsurf
@@ -381,7 +382,7 @@ rtk init -g
381382

382383
## Supported AI Tools
383384

384-
RTK supports 15 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents, reducing the bash output the agent reads where the agent supports command interception.
385+
RTK supports 16 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents, reducing the bash output the agent reads where the agent supports command interception.
385386

386387
| Tool | Install | Method |
387388
|------|---------|--------|
@@ -390,6 +391,7 @@ RTK supports 15 AI coding tools. Each integration rewrites shell commands to `rt
390391
| **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) |
391392
| **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) |
392393
| **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook |
394+
| **Qwen Code CLI** | `rtk init -g --qwen` | PreToolUse hook with native permission preservation |
393395
| **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions |
394396
| **Windsurf** | `rtk init -g --agent windsurf` | .windsurfrules (project-scoped) |
395397
| **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) |

src/hooks/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
The **lifecycle management** layer for LLM agent hooks: install, uninstall, verify integrity, audit usage, and manage trust. This component creates and maintains the hook artifacts that live in `hooks/` (root), but does **not** execute rewrite logic itself — that lives in `discover/registry`.
88

9-
Owns: `rtk init` installation flows (5 agents via `AgentTarget` enum + 3 special modes: Gemini, Codex, OpenCode), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management.
9+
Owns: `rtk init` installation flows (agents via `AgentTarget` plus special Gemini, Qwen, Codex, and OpenCode modes), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management.
1010

1111
Does **not** own: the deployed hook scripts themselves (that's `hooks/`), the rewrite pattern registry (that's `discover/`), or command filtering (that's `cmds/`).
1212

@@ -88,6 +88,7 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i
8888
| Copilot VS Code (rtk hook copilot) | Yes | `permissionDecision: "ask"` — user prompted |
8989
| Cursor (rtk hook cursor) | Ready | `permission: "ask",` — users will be prompted when Cursor enforces the permission; in the meantime, allow |
9090
| Gemini CLI (rtk hook gemini) | No (allow/deny only) | allow (limitation — no ask mode in Gemini) |
91+
| Qwen Code (rtk hook qwen) | Yes | ask |
9192
| Copilot CLI (rtk hook copilot) | No updatedInput | deny-with-suggestion (unchanged) |
9293
| Codex | ask parsed but no-op | allow (limitation — fails open) |
9394

src/hooks/constants.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub const REWRITE_HOOK_FILE: &str = "rtk-rewrite.sh";
22
pub const GEMINI_HOOK_FILE: &str = "rtk-hook-gemini.sh";
3+
pub const QWEN_HOOK_FILE: &str = "rtk-hook-qwen.sh";
34
pub const CLAUDE_DIR: &str = ".claude";
45
pub const HOOKS_SUBDIR: &str = "hooks";
56
pub const SETTINGS_JSON: &str = "settings.json";
@@ -23,6 +24,7 @@ pub const OPENCODE_PLUGIN_FILE: &str = "rtk.ts";
2324
pub const CURSOR_DIR: &str = ".cursor";
2425
pub const CODEX_DIR: &str = ".codex";
2526
pub const GEMINI_DIR: &str = ".gemini";
27+
pub const QWEN_DIR: &str = ".qwen";
2628

2729
pub const GITHUB_DIR: &str = ".github";
2830
pub const COPILOT_HOOK_FILE: &str = "rtk-rewrite.json";

src/hooks/hook_check.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ mod tests {
152152
use crate::hooks::constants::{
153153
CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, HERMES_DIR,
154154
HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME,
155-
OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR,
155+
OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, QWEN_DIR, QWEN_HOOK_FILE,
156156
};
157157

158158
fn other_integration_installed(home: &std::path::Path) -> bool {
@@ -168,6 +168,7 @@ mod tests {
168168
home.join(GEMINI_DIR)
169169
.join(HOOKS_SUBDIR)
170170
.join(GEMINI_HOOK_FILE),
171+
home.join(QWEN_DIR).join(HOOKS_SUBDIR).join(QWEN_HOOK_FILE),
171172
home.join(HERMES_DIR)
172173
.join(HERMES_PLUGINS_SUBDIR)
173174
.join(HERMES_PLUGIN_NAME)
@@ -288,6 +289,19 @@ mod tests {
288289
assert!(other_integration_installed(tmp.path()));
289290
}
290291

292+
#[test]
293+
fn test_other_integration_qwen() {
294+
let tmp = tempfile::tempdir().expect("tempdir");
295+
let path = tmp
296+
.path()
297+
.join(QWEN_DIR)
298+
.join(HOOKS_SUBDIR)
299+
.join(QWEN_HOOK_FILE);
300+
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
301+
std::fs::write(&path, b"hook").unwrap();
302+
assert!(other_integration_installed(tmp.path()));
303+
}
304+
291305
#[test]
292306
fn test_other_integration_hermes() {
293307
let tmp = tempfile::tempdir().expect("tempdir");
@@ -308,6 +322,7 @@ mod tests {
308322
std::fs::create_dir_all(tmp.path().join(CURSOR_DIR).join(HOOKS_SUBDIR)).unwrap();
309323
std::fs::create_dir_all(tmp.path().join(CODEX_DIR)).unwrap();
310324
std::fs::create_dir_all(tmp.path().join(GEMINI_DIR)).unwrap();
325+
std::fs::create_dir_all(tmp.path().join(QWEN_DIR)).unwrap();
311326
std::fs::create_dir_all(
312327
tmp.path()
313328
.join(HERMES_DIR)

src/hooks/hook_cmd.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,115 @@ fn print_gemini(decision: &str, rewrite: Option<&str>) {
332332
let _ = writeln!(io::stdout(), "{}", gemini_json(decision, rewrite));
333333
}
334334

335+
// ── Qwen Code hook ────────────────────────────────────────────
336+
337+
/// Run the Qwen Code PreToolUse hook.
338+
pub fn run_qwen() -> Result<()> {
339+
let input = read_stdin_limited()?;
340+
let input = strip_leading_bom(&input).trim();
341+
if input.is_empty() {
342+
return Ok(());
343+
}
344+
345+
let json: Value = match serde_json::from_str(input) {
346+
Ok(value) => value,
347+
Err(error) => {
348+
let _ = writeln!(
349+
io::stderr(),
350+
"[rtk hook] Failed to parse JSON input: {error}"
351+
);
352+
return Ok(());
353+
}
354+
};
355+
356+
if json.get("tool_name").and_then(Value::as_str) != Some("run_shell_command") {
357+
return Ok(());
358+
}
359+
360+
let tool_input = json.get("tool_input").cloned().unwrap_or_else(|| json!({}));
361+
let Some(command) = tool_input
362+
.get("command")
363+
.and_then(Value::as_str)
364+
.filter(|command| !command.is_empty())
365+
else {
366+
return Ok(());
367+
};
368+
369+
let configured_verdict = permissions::check_command_for(command, permissions::Host::Qwen);
370+
let verdict = qwen_effective_verdict(
371+
configured_verdict,
372+
json.get("permission_mode").and_then(Value::as_str),
373+
);
374+
if let Some(output) = qwen_response(command, &tool_input, verdict) {
375+
let _ = writeln!(io::stdout(), "{output}");
376+
}
377+
378+
Ok(())
379+
}
380+
381+
fn qwen_effective_verdict(
382+
configured: PermissionVerdict,
383+
permission_mode: Option<&str>,
384+
) -> PermissionVerdict {
385+
if configured == PermissionVerdict::Default && permission_mode == Some("yolo") {
386+
PermissionVerdict::Allow
387+
} else {
388+
configured
389+
}
390+
}
391+
392+
fn qwen_response(command: &str, tool_input: &Value, verdict: PermissionVerdict) -> Option<Value> {
393+
match decide_from_verdict(command, verdict) {
394+
HookDecision::Deny => {
395+
audit_log("deny", command, "");
396+
Some(qwen_json("deny", None))
397+
}
398+
HookDecision::AllowRewrite(rewritten) => {
399+
audit_log("rewrite", command, &rewritten);
400+
Some(qwen_json(
401+
"allow",
402+
Some(rewrite_tool_input(tool_input, &rewritten)),
403+
))
404+
}
405+
HookDecision::AskRewrite { rewritten, .. } => {
406+
audit_log("ask", command, &rewritten);
407+
Some(qwen_json(
408+
"ask",
409+
Some(rewrite_tool_input(tool_input, &rewritten)),
410+
))
411+
}
412+
HookDecision::Defer => None,
413+
}
414+
}
415+
416+
fn rewrite_tool_input(tool_input: &Value, rewritten: &str) -> Value {
417+
let mut updated = tool_input.clone();
418+
if let Some(object) = updated.as_object_mut() {
419+
object.insert("command".to_string(), Value::String(rewritten.to_string()));
420+
updated
421+
} else {
422+
json!({ "command": rewritten })
423+
}
424+
}
425+
426+
fn qwen_json(decision: &str, updated_input: Option<Value>) -> Value {
427+
let mut hook_output = json!({
428+
"hookEventName": PRE_TOOL_USE_KEY,
429+
"permissionDecision": decision,
430+
"permissionDecisionReason": if decision == "deny" {
431+
"Blocked by RTK permission rule"
432+
} else {
433+
"RTK auto-rewrite"
434+
}
435+
});
436+
437+
if let Some(input) = updated_input {
438+
hook_output["updatedInput"] = input;
439+
}
440+
441+
json!({ "hookSpecificOutput": hook_output })
442+
}
443+
335444
// ── Audit logging ─────────────────────────────────────────────
336445

337446
/// Best-effort audit log when RTK_HOOK_AUDIT=1.
@@ -1682,6 +1791,83 @@ mod tests {
16821791
assert_eq!(v["decision"], "deny");
16831792
}
16841793

1794+
// --- Qwen Code rendering ---
1795+
1796+
#[test]
1797+
fn test_qwen_allow_emits_current_pre_tool_use_schema() {
1798+
let input = json!({ "command": "git status", "description": "Inspect worktree" });
1799+
let output = qwen_response("git status", &input, PermissionVerdict::Allow).unwrap();
1800+
1801+
assert_eq!(
1802+
output["hookSpecificOutput"]["hookEventName"],
1803+
PRE_TOOL_USE_KEY
1804+
);
1805+
assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "allow");
1806+
assert_eq!(
1807+
output["hookSpecificOutput"]["updatedInput"]["command"],
1808+
"rtk git status"
1809+
);
1810+
assert_eq!(
1811+
output["hookSpecificOutput"]["updatedInput"]["description"],
1812+
"Inspect worktree"
1813+
);
1814+
}
1815+
1816+
#[test]
1817+
fn test_qwen_default_asks_with_rewritten_input() {
1818+
let input = json!({ "command": "cargo test" });
1819+
let output = qwen_response("cargo test", &input, PermissionVerdict::Default).unwrap();
1820+
1821+
assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "ask");
1822+
assert_eq!(
1823+
output["hookSpecificOutput"]["updatedInput"]["command"],
1824+
"rtk cargo test"
1825+
);
1826+
}
1827+
1828+
#[test]
1829+
fn test_qwen_deny_omits_updated_input() {
1830+
let input = json!({ "command": "rm -rf /tmp/x" });
1831+
let output = qwen_response("rm -rf /tmp/x", &input, PermissionVerdict::Deny).unwrap();
1832+
1833+
assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "deny");
1834+
assert!(output["hookSpecificOutput"].get("updatedInput").is_none());
1835+
}
1836+
1837+
#[test]
1838+
fn test_qwen_unsupported_or_unsafe_commands_defer_to_host() {
1839+
let unsupported = json!({ "command": "unknown-tool --flag" });
1840+
assert!(qwen_response(
1841+
"unknown-tool --flag",
1842+
&unsupported,
1843+
PermissionVerdict::Default
1844+
)
1845+
.is_none());
1846+
let unsafe_input = json!({ "command": "git status $(rm -rf /tmp/x)" });
1847+
assert!(qwen_response(
1848+
"git status $(rm -rf /tmp/x)",
1849+
&unsafe_input,
1850+
PermissionVerdict::Allow
1851+
)
1852+
.is_none());
1853+
}
1854+
1855+
#[test]
1856+
fn test_qwen_yolo_only_upgrades_default_permission() {
1857+
assert_eq!(
1858+
qwen_effective_verdict(PermissionVerdict::Default, Some("yolo")),
1859+
PermissionVerdict::Allow
1860+
);
1861+
assert_eq!(
1862+
qwen_effective_verdict(PermissionVerdict::Ask, Some("yolo")),
1863+
PermissionVerdict::Ask
1864+
);
1865+
assert_eq!(
1866+
qwen_effective_verdict(PermissionVerdict::Deny, Some("yolo")),
1867+
PermissionVerdict::Deny
1868+
);
1869+
}
1870+
16851871
// --- Factory Droid hook ---
16861872

16871873
fn droid_input(tool: &str, cmd: &str) -> String {

0 commit comments

Comments
 (0)