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(" ");