diff --git a/README.md b/README.md
index 1452b1ca8..c7d72f138 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,8 @@ rtk init --agent windsurf # Windsurf
rtk init --agent cline # Cline / Roo Code
rtk init --agent kilocode # Kilo Code
rtk init --agent antigravity # Google Antigravity
+rtk init --agent qoder # Qoder CLI (project-scoped)
+rtk init -g --agent qoder # Qoder CLI (global)
# 2. Restart your AI tool, then test
git status # Automatically rewritten to rtk git status
@@ -350,7 +352,7 @@ rtk git status
## Supported AI Tools
-RTK supports 12 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings.
+RTK supports 13 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings.
| Tool | Install | Method |
|------|---------|--------|
@@ -367,6 +369,7 @@ RTK supports 12 AI coding tools. Each integration transparently rewrites shell c
| **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream |
| **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) |
| **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) |
+| **Qoder CLI** | `rtk init --agent qoder`
`rtk init -g --agent qoder` | PreToolUse hook — transparent rewrite (project or global) |
For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents).
diff --git a/hooks/README.md b/hooks/README.md
index 6a6744281..2868e5e37 100644
--- a/hooks/README.md
+++ b/hooks/README.md
@@ -4,7 +4,7 @@
**Deployed hook artifacts** — the actual files installed on user machines by `rtk init`. These are shell scripts, TypeScript plugins, and rules files that run outside the Rust binary. They are **thin delegates**: parse agent-specific JSON, call `rtk rewrite` as a subprocess, format agent-specific response. Zero filtering logic lives here.
-Owns: per-agent hook scripts and configuration files for 7 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode).
+Owns: per-agent hook scripts and configuration files for 8 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Qoder CLI).
Does **not** own: hook installation/uninstallation (that's `src/hooks/init.rs`), the rewrite pattern registry (that's `discover/registry`), or integrity verification (that's `src/hooks/integrity.rs`).
@@ -40,6 +40,7 @@ Each agent subdirectory has its own README with hook-specific details:
- **[`windsurf/`](windsurf/README.md)** — Rules file (prompt-level), `.windsurfrules` workspace-scoped
- **[`codex/`](codex/README.md)** — Awareness document, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location
- **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation
+- **[`qoder/`](qoder/README.md)** — Awareness document + Rust binary hook (`rtk hook qoder`), `PreToolUse` transparent rewrite, `settings.json` patching
## Supported Agents
@@ -54,6 +55,7 @@ Each agent subdirectory has its own README with hook-specific details:
| Windsurf | Custom instructions (rules file) | Prompt-level guidance | N/A |
| Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A |
| OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes |
+| Qoder CLI | Rust binary (`rtk hook qoder`) | Transparent rewrite | Yes (`updatedInput`) |
## JSON Formats by Agent
@@ -93,6 +95,35 @@ Each agent subdirectory has its own README with hook-specific details:
Returns `{}` when no rewrite (Cursor requires JSON for all paths).
+### Qoder CLI (Rust Binary)
+
+Uses the same `PreToolUse` JSON protocol as Claude Code. The Rust binary (`rtk hook qoder`) reads from stdin and returns `updatedInput` for transparent command rewriting.
+
+**Input** (stdin, snake_case):
+```json
+{
+ "tool_name": "Bash",
+ "tool_input": {
+ "command": "git status"
+ }
+}
+```
+
+**Output** (stdout, transparent rewrite):
+```json
+{
+ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecisionReason": "RTK auto-rewrite",
+ "updatedInput": {
+ "command": "rtk git status"
+ }
+ }
+}
+```
+
+Returns nothing (empty stdout) when the command is non-Bash, already rtk-prefixed, or not optimizable.
+
### Copilot CLI (Rust Binary)
**Input** (stdin, camelCase, `toolArgs` is JSON-stringified):
diff --git a/hooks/qoder/README.md b/hooks/qoder/README.md
new file mode 100644
index 000000000..ddce2b34a
--- /dev/null
+++ b/hooks/qoder/README.md
@@ -0,0 +1,25 @@
+# Qoder CLI Hooks
+
+> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code
+
+## Specifics
+
+- Uses the `rtk hook qoder` Rust binary (not a shell script) — no `jq` dependency
+- Qoder CLI uses the same `PreToolUse` JSON protocol as Claude Code (`tool_name`/`tool_input` + `updatedInput`)
+- Hook config written to `~/.qoder/settings.json` (global) or `.qoder/settings.json` (project)
+- Transparent rewrite via `updatedInput` — no denial, no retry
+
+## How it works
+
+1. Agent runs `git status` → `rtk hook qoder` intercepts via `PreToolUse`
+2. `rtk hook` reads JSON from stdin, matches tool_name `Bash`
+3. Returns `hookSpecificOutput.updatedInput.command = "rtk git status"`
+4. Qoder CLI rewrites the command transparently — agent sees the filtered output directly
+
+## Testing
+
+```bash
+# Simulate Qoder CLI PreToolUse input
+echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' | rtk hook qoder
+# Should output JSON with updatedInput.command = "rtk git status"
+```
diff --git a/hooks/qoder/rtk-awareness.md b/hooks/qoder/rtk-awareness.md
new file mode 100644
index 000000000..6bed9df8d
--- /dev/null
+++ b/hooks/qoder/rtk-awareness.md
@@ -0,0 +1,46 @@
+# RTK — Qoder CLI Integration
+
+**Usage**: Token-optimized CLI proxy (60-90% savings on dev operations)
+
+## What's automatic
+
+The `PreToolUse` hook in Qoder CLI's settings.json intercepts raw Bash tool calls
+and rewrites them through RTK transparently via `updatedInput`.
+
+The `@RTK.md` reference in `AGENTS.md` provides prompt-level guidance for the LLM
+to proactively use `rtk` prefix on shell commands.
+
+No shell scripts, no `jq` dependency — `rtk hook qoder` is a native Rust binary.
+
+## Meta commands (always use directly)
+
+```bash
+rtk gain # Token savings dashboard for this session
+rtk gain --history # Per-command history with savings %
+rtk discover # Scan session history for missed rtk opportunities
+rtk proxy # Run raw (no filtering) but still track it
+```
+
+## Installation verification
+
+```bash
+rtk --version # Should print: rtk X.Y.Z
+rtk gain # Should show a dashboard (not "command not found")
+which rtk # Verify correct binary path
+```
+
+> ⚠️ **Name collision**: If `rtk gain` fails, you may have `reachingforthejack/rtk`
+> (Rust Type Kit) installed instead. Check `which rtk` and reinstall from rtk-ai/rtk.
+
+## How the hook works
+
+`rtk hook qoder` reads `PreToolUse` JSON from stdin and rewrites commands transparently:
+
+1. Agent calls `Bash("git status")` → Qoder CLI fires `PreToolUse` hook
+2. `rtk hook qoder` receives JSON, extracts `tool_input.command`
+3. Returns `hookSpecificOutput.updatedInput.command = "rtk git status"`
+4. Qoder CLI applies the updated input — agent sees RTK-filtered output directly
+
+**Compound commands** (with `&&`, `||`, `|`, `;`) are handled by `rtk`'s internal
+`rewrite_compound()` — each segment is rewritten independently, and pipe-incompatible
+commands like `find`/`fd` are skipped automatically.
diff --git a/src/hooks/README.md b/src/hooks/README.md
index bf947a0f1..9a60a5ff9 100644
--- a/src/hooks/README.md
+++ b/src/hooks/README.md
@@ -6,7 +6,7 @@
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`.
-Owns: `rtk init` installation flows (4 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.
+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.
Does **not** own: the deployed hook scripts themselves (that's `hooks/`), the rewrite pattern registry (that's `discover/`), or command filtering (that's `cmds/`).
@@ -87,6 +87,7 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i
| Gemini CLI (rtk hook gemini) | No (allow/deny only) | allow (limitation — no ask mode in Gemini) |
| Copilot CLI (rtk hook copilot) | No updatedInput | deny-with-suggestion (unchanged) |
| Codex | ask parsed but no-op | allow (limitation — fails open) |
+| Qoder CLI (rtk hook qoder) | Yes | `permissionDecision: "ask"` — user prompted |
### Implementation
diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs
index 1d9f33ccd..2bb2732fd 100644
--- a/src/hooks/constants.rs
+++ b/src/hooks/constants.rs
@@ -21,3 +21,4 @@ pub const OPENCODE_PLUGIN_FILE: &str = "rtk.ts";
pub const CURSOR_DIR: &str = ".cursor";
pub const CODEX_DIR: &str = ".codex";
pub const GEMINI_DIR: &str = ".gemini";
+pub const QODER_DIR: &str = ".qoder";
diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs
index cd3c82d1e..5bd39cb7e 100644
--- a/src/hooks/hook_cmd.rs
+++ b/src/hooks/hook_cmd.rs
@@ -397,6 +397,53 @@ fn run_claude_inner(input: &str) -> Option {
}
}
+/// Run the Qoder CLI PreToolUse hook natively.
+///
+/// Qoder CLI uses the same PreToolUse JSON protocol as Claude Code
+/// (`tool_input.command` + `updatedInput`), so we reuse `process_claude_payload`.
+pub fn run_qoder() -> Result<()> {
+ let input = read_stdin_limited()?;
+
+ let input = input.trim();
+ if input.is_empty() {
+ return Ok(());
+ }
+
+ let v: Value = match serde_json::from_str(input) {
+ Ok(v) => v,
+ Err(e) => {
+ let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}");
+ return Ok(());
+ }
+ };
+
+ match process_claude_payload(&v) {
+ PayloadAction::Rewrite {
+ cmd,
+ rewritten,
+ output,
+ } => {
+ audit_log("rewrite", &cmd, &rewritten);
+ let _ = writeln!(io::stdout(), "{output}");
+ }
+ PayloadAction::Skip { reason, cmd } => {
+ audit_log(reason, &cmd, "");
+ }
+ PayloadAction::Ignore => {}
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+fn run_qoder_inner(input: &str) -> Option {
+ let v: Value = serde_json::from_str(input).ok()?;
+ match process_claude_payload(&v) {
+ PayloadAction::Rewrite { output, .. } => Some(output.to_string()),
+ _ => None,
+ }
+}
+
// ── Cursor native hook ─────────────────────────────────────────
/// Run the Cursor Agent hook natively.
@@ -756,6 +803,46 @@ mod tests {
assert!(run_claude_inner(&input).is_none());
}
+ // --- Qoder handler ---
+
+ fn qoder_input(cmd: &str) -> String {
+ json!({
+ "tool_name": "Bash",
+ "tool_input": { "command": cmd }
+ })
+ .to_string()
+ }
+
+ #[test]
+ fn test_qoder_rewrites_bash() {
+ let output = run_qoder_inner(&qoder_input("git status")).unwrap();
+ assert!(
+ output.contains("updatedInput"),
+ "Should contain updatedInput"
+ );
+ let v: Value = serde_json::from_str(&output).unwrap();
+ assert_eq!(
+ v["hookSpecificOutput"]["updatedInput"]["command"],
+ "rtk git status"
+ );
+ }
+
+ #[test]
+ fn test_qoder_passthrough_non_bash() {
+ let input = json!({"tool_name": "Read", "tool_input": {"file_path": "test"}}).to_string();
+ assert!(run_qoder_inner(&input).is_none());
+ }
+
+ #[test]
+ fn test_qoder_passthrough_already_rtk() {
+ assert!(run_qoder_inner(&qoder_input("rtk git status")).is_none());
+ }
+
+ #[test]
+ fn test_qoder_passthrough_echo() {
+ assert!(run_qoder_inner(&qoder_input("echo hello")).is_none());
+ }
+
// --- Cursor handler ---
fn cursor_input(cmd: &str) -> String {
diff --git a/src/hooks/init.rs b/src/hooks/init.rs
index c6bd05c2b..5aa6444f0 100644
--- a/src/hooks/init.rs
+++ b/src/hooks/init.rs
@@ -12,7 +12,8 @@ use crate::hooks::constants::{
use super::constants::{
BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND,
- GEMINI_HOOK_FILE, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON,
+ GEMINI_HOOK_FILE, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, QODER_DIR, REWRITE_HOOK_FILE,
+ SETTINGS_JSON,
};
use super::integrity;
@@ -2653,6 +2654,249 @@ fn uninstall_gemini(verbose: u8) -> Result> {
Ok(removed)
}
+// ── Qoder CLI integration ────────────────────────────────────
+
+/// Embedded Qoder RTK awareness instructions
+const RTK_SLIM_QODER: &str = include_str!("../../hooks/qoder/rtk-awareness.md");
+
+fn resolve_qoder_dir() -> Result {
+ resolve_home_subdir(QODER_DIR)
+}
+
+/// Entry point for `rtk init --agent qoder`
+pub fn run_qoder_mode(global: bool, verbose: u8) -> Result<()> {
+ if global {
+ let qoder_dir = resolve_qoder_dir()?;
+ fs::create_dir_all(&qoder_dir).with_context(|| {
+ format!("Failed to create Qoder config dir: {}", qoder_dir.display())
+ })?;
+
+ // 1. Write RTK awareness instructions
+ let rtk_md_path = qoder_dir.join(RTK_MD);
+ write_if_changed(&rtk_md_path, RTK_SLIM_QODER, RTK_MD, verbose)?;
+
+ // 2. Patch ~/.qoder/settings.json with PreToolUse hook
+ let hook_status = patch_qoder_settings(&qoder_dir, verbose)?;
+
+ println!("\nRTK configured for Qoder CLI (global).\n");
+ println!(" RTK.md: {}", rtk_md_path.display());
+ match hook_status {
+ QoderHookStatus::AlreadyInstalled => {
+ println!(" Hook: Already installed (`rtk hook qoder` in settings.json)");
+ }
+ QoderHookStatus::Upgraded(ref old) => {
+ println!(
+ " Hook: Upgraded to `rtk hook qoder` (replaced `{}`)",
+ old
+ );
+ }
+ QoderHookStatus::Installed => {
+ println!(" Hook: Added `rtk hook qoder` to settings.json");
+ }
+ }
+ println!("\n Restart Qoder CLI. Test with: git status\n");
+ } else {
+ // Project-scoped: write RTK.md to CWD and patch AGENTS.md
+ let rtk_md_path = PathBuf::from(RTK_MD);
+ write_if_changed(&rtk_md_path, RTK_SLIM_QODER, RTK_MD, verbose)?;
+
+ let agents_md_path = PathBuf::from(AGENTS_MD);
+ let added_ref = patch_agents_md(&agents_md_path, RTK_MD_REF, verbose)?;
+
+ // Write project-scoped hook config
+ let project_qoder_dir = PathBuf::from(QODER_DIR);
+ fs::create_dir_all(&project_qoder_dir).with_context(|| {
+ format!(
+ "Failed to create Qoder config dir: {}",
+ project_qoder_dir.display()
+ )
+ })?;
+ let hook_status = patch_qoder_settings(&project_qoder_dir, verbose)?;
+
+ println!("\nRTK configured for Qoder CLI (project-scoped).\n");
+ println!(" RTK.md: {}", rtk_md_path.display());
+ if added_ref {
+ println!(" AGENTS.md: @RTK.md reference added");
+ } else {
+ println!(" AGENTS.md: @RTK.md reference already present");
+ }
+ match hook_status {
+ QoderHookStatus::AlreadyInstalled => {
+ println!(
+ " Hook: Already installed (`rtk hook qoder` in .qoder/settings.json)"
+ );
+ }
+ QoderHookStatus::Upgraded(ref old) => {
+ println!(
+ " Hook: Upgraded to `rtk hook qoder` (replaced `{}`)",
+ old
+ );
+ }
+ QoderHookStatus::Installed => {
+ println!(" Hook: Added `rtk hook qoder` to .qoder/settings.json");
+ }
+ }
+ println!("\n Restart Qoder CLI. Test with: git status\n");
+ }
+
+ Ok(())
+}
+
+/// Outcome of patching Qoder settings.json
+#[derive(Debug)]
+enum QoderHookStatus {
+ /// `rtk hook qoder` is already the active hook — nothing to do
+ AlreadyInstalled,
+ /// Upgraded from a workaround hook (e.g. shell script) to the native binary
+ Upgraded(String),
+ /// Fresh install — no previous RTK hook existed
+ Installed,
+}
+
+/// Patch Qoder settings.json with the PreToolUse hook for rtk
+fn patch_qoder_settings(qoder_dir: &Path, verbose: u8) -> Result {
+ let settings_path = qoder_dir.join(SETTINGS_JSON);
+ let target_command = "rtk hook qoder";
+
+ // Read or create settings.json
+ let mut settings: serde_json::Value = if settings_path.exists() {
+ let content = fs::read_to_string(&settings_path)
+ .with_context(|| format!("Failed to read {}", settings_path.display()))?;
+ serde_json::from_str(&content).unwrap_or(serde_json::json!({}))
+ } else {
+ serde_json::json!({})
+ };
+
+ // Check existing hooks
+ let pre_tool_use_pointer = format!("/hooks/{}", PRE_TOOL_USE_KEY);
+ if let Some(hooks) = settings.pointer(&pre_tool_use_pointer) {
+ if let Some(arr) = hooks.as_array() {
+ for entry in arr {
+ if let Some(cmd) = entry.pointer("/hooks/0/command").and_then(|c| c.as_str()) {
+ if cmd == target_command {
+ if verbose > 0 {
+ eprintln!("Qoder settings.json already has `rtk hook qoder`");
+ }
+ return Ok(QoderHookStatus::AlreadyInstalled);
+ }
+ if cmd.contains("rtk") {
+ // Found a workaround hook — upgrade it to the native binary
+ let old_cmd = cmd.to_string();
+ return upgrade_existing_hook(
+ &settings_path,
+ &mut settings,
+ &pre_tool_use_pointer,
+ old_cmd,
+ verbose,
+ );
+ }
+ }
+ }
+ }
+ }
+
+ // No existing RTK hook — add new entry
+ install_fresh_hook(&settings_path, &mut settings, verbose)?;
+ Ok(QoderHookStatus::Installed)
+}
+
+/// Replace an existing workaround hook command with the native binary
+fn upgrade_existing_hook(
+ settings_path: &Path,
+ settings: &mut serde_json::Value,
+ pre_tool_use_pointer: &str,
+ old_command: String,
+ verbose: u8,
+) -> Result {
+ // Navigate to the hooks array and replace the command
+ if let Some(arr) = settings
+ .pointer_mut(pre_tool_use_pointer)
+ .and_then(|v| v.as_array_mut())
+ {
+ for entry in arr.iter_mut() {
+ let needs_upgrade = entry
+ .pointer("/hooks/0/command")
+ .and_then(|c| c.as_str())
+ .is_some_and(|c| c == old_command);
+ if needs_upgrade {
+ if let Some(hook) = entry.pointer_mut("/hooks/0") {
+ if let Some(obj) = hook.as_object_mut() {
+ obj.insert(
+ "command".to_string(),
+ serde_json::Value::String("rtk hook qoder".to_string()),
+ );
+ }
+ }
+ }
+ }
+ }
+
+ atomic_write(settings_path, &serde_json::to_string_pretty(&settings)?)
+ .with_context(|| format!("Failed to write {}", settings_path.display()))?;
+
+ if verbose > 0 {
+ eprintln!(
+ "Upgraded Qoder hook from `{}` to `rtk hook qoder`",
+ old_command
+ );
+ }
+
+ Ok(QoderHookStatus::Upgraded(old_command))
+}
+
+/// Install a fresh RTK hook entry into settings.json
+fn install_fresh_hook(
+ settings_path: &Path,
+ settings: &mut serde_json::Value,
+ verbose: u8,
+) -> Result<()> {
+ let hook_entry = serde_json::json!({
+ "matcher": "Bash",
+ "hooks": [{
+ "type": "command",
+ "command": "rtk hook qoder"
+ }]
+ });
+
+ // Insert into settings
+ let hooks = settings
+ .as_object_mut()
+ .context("settings.json is not an object")?
+ .entry("hooks")
+ .or_insert_with(|| serde_json::json!({}));
+
+ let entry_for_fallback = hook_entry.clone();
+ hooks[PRE_TOOL_USE_KEY] = serde_json::Value::Array(
+ hooks
+ .get(PRE_TOOL_USE_KEY)
+ .and_then(|v| v.as_array())
+ .map(|existing| {
+ let mut arr = existing.clone();
+ // Check for duplicate
+ if !arr.iter().any(|entry| {
+ entry.get("matcher").and_then(|m| m.as_str()) == Some("Bash")
+ && entry
+ .pointer("/hooks/0/command")
+ .and_then(|c| c.as_str())
+ .is_some_and(|c| c == "rtk hook qoder")
+ }) {
+ arr.push(hook_entry);
+ }
+ arr
+ })
+ .unwrap_or_else(|| vec![entry_for_fallback]),
+ );
+
+ atomic_write(settings_path, &serde_json::to_string_pretty(&settings)?)
+ .with_context(|| format!("Failed to write {}", settings_path.display()))?;
+
+ if verbose > 0 {
+ eprintln!("Patched {} with RTK hook", settings_path.display());
+ }
+
+ Ok(())
+}
+
// ── Copilot integration ─────────────────────────────────────
const COPILOT_HOOK_JSON: &str = r#"{
@@ -3956,4 +4200,101 @@ mod tests {
"RTK end marker must be removed"
);
}
+
+ // --- Qoder init ---
+
+ fn settings_has_qoder_hook(path: &Path) -> bool {
+ let content = fs::read_to_string(path).unwrap();
+ content.contains("rtk hook qoder")
+ }
+
+ #[test]
+ fn test_qoder_patch_creates_new() {
+ let dir = TempDir::new().unwrap();
+ let status = patch_qoder_settings(dir.path(), 0).unwrap();
+ assert!(
+ matches!(status, QoderHookStatus::Installed),
+ "Fresh init should return Installed"
+ );
+ assert!(
+ settings_has_qoder_hook(&dir.path().join("settings.json")),
+ "settings.json should contain rtk hook qoder"
+ );
+ }
+
+ #[test]
+ fn test_qoder_patch_upgrades_shell_script() {
+ let dir = TempDir::new().unwrap();
+ let settings = serde_json::json!({
+ "hooks": {
+ "PreToolUse": [{
+ "matcher": "Bash",
+ "hooks": [{
+ "type": "command",
+ "command": "~/.qoder/hooks/rtk-qoder-hook.sh"
+ }]
+ }]
+ }
+ });
+ fs::write(
+ dir.path().join("settings.json"),
+ serde_json::to_string_pretty(&settings).unwrap(),
+ )
+ .unwrap();
+
+ let status = patch_qoder_settings(dir.path(), 0).unwrap();
+ match &status {
+ QoderHookStatus::Upgraded(old) => {
+ assert!(
+ old.contains("rtk-qoder-hook.sh"),
+ "Should detect shell script"
+ );
+ }
+ other => panic!("Expected Upgraded, got {:?}", other),
+ }
+ assert!(
+ settings_has_qoder_hook(&dir.path().join("settings.json")),
+ "Shell script should be upgraded to rtk hook qoder"
+ );
+ }
+
+ #[test]
+ fn test_qoder_patch_idempotent() {
+ let dir = TempDir::new().unwrap();
+ let s1 = patch_qoder_settings(dir.path(), 0).unwrap();
+ assert!(matches!(s1, QoderHookStatus::Installed));
+ let s2 = patch_qoder_settings(dir.path(), 0).unwrap();
+ assert!(
+ matches!(s2, QoderHookStatus::AlreadyInstalled),
+ "Second call should return AlreadyInstalled"
+ );
+ }
+
+ #[test]
+ fn test_qoder_patch_preserves_existing() {
+ let dir = TempDir::new().unwrap();
+ let settings = serde_json::json!({
+ "model": {"name": "custom-model"},
+ "permissions": {"trustDirectories": ["/tmp/test"]}
+ });
+ fs::write(
+ dir.path().join("settings.json"),
+ serde_json::to_string_pretty(&settings).unwrap(),
+ )
+ .unwrap();
+
+ patch_qoder_settings(dir.path(), 0).unwrap();
+ let content = fs::read_to_string(dir.path().join("settings.json")).unwrap();
+ let v: serde_json::Value = serde_json::from_str(&content).unwrap();
+
+ assert_eq!(
+ v.pointer("/model/name").and_then(|v| v.as_str()),
+ Some("custom-model"),
+ "Existing model config must be preserved"
+ );
+ assert!(
+ v.pointer("/hooks/PreToolUse").is_some(),
+ "Hook must be added alongside existing config"
+ );
+ }
}
diff --git a/src/main.rs b/src/main.rs
index ccb096ef0..d15002866 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -44,6 +44,8 @@ pub enum AgentTarget {
Kilocode,
/// Google Antigravity
Antigravity,
+ /// Qoder CLI
+ Qoder,
}
#[derive(Parser)]
@@ -759,6 +761,8 @@ enum HookCommands {
Gemini,
/// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin)
Copilot,
+ /// Process Qoder CLI PreToolUse hook (reads JSON from stdin)
+ Qoder,
/// Check how a command would be rewritten by the hook engine (dry-run)
Check {
/// Target agent
@@ -1783,6 +1787,8 @@ fn run_cli() -> Result {
);
}
hooks::init::run_antigravity_mode(cli.verbose)?;
+ } else if agent == Some(AgentTarget::Qoder) {
+ hooks::init::run_qoder_mode(global, cli.verbose)?;
} else {
let install_opencode = opencode;
let install_claude = !opencode;
@@ -2114,6 +2120,10 @@ fn run_cli() -> Result {
hooks::hook_cmd::run_copilot()?;
0
}
+ HookCommands::Qoder => {
+ hooks::hook_cmd::run_qoder()?;
+ 0
+ }
HookCommands::Check { agent: _, command } => {
use crate::discover::registry::rewrite_command;
let raw = command.join(" ");