From a68f94525c5d96f6c1436539cd84e92a7f0eee8a Mon Sep 17 00:00:00 2001 From: largeoliu Date: Sat, 23 May 2026 17:20:58 +0800 Subject: [PATCH] fix: update uninstall function signature to support both Pi and Qoder - Add qoder parameter to uninstall() function alongside pi - Update uninstall_init_dispatch to pass both pi and qoder flags - Update all test calls to include the new qoder parameter (set to false) --- hooks/qoder/README.md | 11 ++ hooks/qoder/rtk-awareness.md | 25 ++++ src/hooks/constants.rs | 4 + src/hooks/hook_check.rs | 12 +- src/hooks/hook_cmd.rs | 81 +++++++++++ src/hooks/init.rs | 269 ++++++++++++++++++++++++++++++++++- src/main.rs | 20 ++- 7 files changed, 411 insertions(+), 11 deletions(-) create mode 100644 hooks/qoder/README.md create mode 100644 hooks/qoder/rtk-awareness.md diff --git a/hooks/qoder/README.md b/hooks/qoder/README.md new file mode 100644 index 000000000..139160a44 --- /dev/null +++ b/hooks/qoder/README.md @@ -0,0 +1,11 @@ +# Qoder IDE Hooks + +> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code + +## Specifics + +- Shell-based `PreToolUse` hook (powered natively by `rtk hook qoder`) +- Returns `updatedInput` JSON for transparent command rewrite (IDE doesn't know RTK is involved) +- Protocol is identical to Claude Code. +- Exits silently (exit 0) on any failure or JSON parse error. +- `rtk-awareness.md` is embedded into `~/.qoder/RTK.md` by `rtk init -g --agent qoder`. \ No newline at end of file diff --git a/hooks/qoder/rtk-awareness.md b/hooks/qoder/rtk-awareness.md new file mode 100644 index 000000000..1d5ba3ae1 --- /dev/null +++ b/hooks/qoder/rtk-awareness.md @@ -0,0 +1,25 @@ +# RTK - Rust Token Killer + +**Usage**: Token-optimized CLI proxy (60-90% savings on dev operations) + +## Meta Commands (always use rtk directly) + +```bash +rtk gain # Show token savings analytics +rtk gain --history # Show command usage history with savings +rtk proxy # Execute raw command without filtering (for debugging) +``` + +## Installation Verification + +```bash +rtk --version # Should show: rtk X.Y.Z +rtk gain # Should work (not "command not found") +``` + +⚠️ **Name collision**: If `rtk gain` fails, you may have reachingforthejack/rtk (Rust Type Kit) installed instead. + +## Hook-Based Usage + +All other commands are automatically rewritten by the Qoder PreToolUse hook. +Example: `git status` → `rtk git status` (transparent, 0 tokens overhead) \ No newline at end of file diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 506e88cdf..55eb1d3c8 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -33,3 +33,7 @@ pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite"; pub const HERMES_PLUGIN_INIT_FILE: &str = "__init__.py"; pub const HERMES_PLUGIN_MANIFEST_FILE: &str = "plugin.yaml"; + +pub const QODER_DIR: &str = ".qoder"; +pub const QODER_HOOK_COMMAND: &str = "rtk hook qoder"; +pub const QODER_SETTINGS_JSON: &str = "settings.json"; diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index 4dd26d82b..387ad8758 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -157,7 +157,7 @@ mod tests { use crate::hooks::constants::{ CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, - OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, + OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, QODER_DIR, QODER_SETTINGS_JSON, }; fn other_integration_installed(home: &std::path::Path) -> bool { @@ -177,6 +177,7 @@ mod tests { .join(HERMES_PLUGINS_SUBDIR) .join(HERMES_PLUGIN_NAME) .join(HERMES_PLUGIN_MANIFEST_FILE), + home.join(QODER_DIR).join(QODER_SETTINGS_JSON), ]; paths.iter().any(|p| p.exists()) } @@ -284,6 +285,15 @@ mod tests { assert!(other_integration_installed(tmp.path())); } + #[test] + fn test_other_integration_qoder() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join(QODER_DIR).join(QODER_SETTINGS_JSON); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"settings").unwrap(); + assert!(other_integration_installed(tmp.path())); + } + #[test] fn test_other_integration_empty_dirs_not_enough() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5..4a8037ef2 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -397,6 +397,44 @@ fn run_claude_inner(input: &str) -> Option { } } +// ── Qoder native hook ────────────────────────────────────────── + +/// Run the Qoder IDE PreToolUse hook natively. +/// Protocol is identical to Claude Code; reuses 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(()) +} + // ── Cursor native hook ───────────────────────────────────────── /// Cursor on Windows ships hook payloads with one or more leading @@ -780,6 +818,49 @@ mod tests { assert!(run_claude_inner(&input).is_none()); } + // --- Qoder handler --- + + #[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, + } + } + + fn qoder_input(cmd: &str) -> String { + serde_json::json!({ + "session_id": "test-session", + "cwd": "/tmp/project", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { "command": cmd } + }) + .to_string() + } + + #[test] + fn test_qoder_rewrite_git_status() { + let input = qoder_input("git status"); + let output = run_qoder_inner(&input).unwrap(); + assert!(output.contains("\"updatedInput\":{\"command\":\"rtk git status\"}")); + } + + #[test] + fn test_qoder_passthrough_no_output() { + let input = qoder_input("htop"); + let output = run_qoder_inner(&input); + assert!(output.is_none()); + } + + #[test] + fn test_qoder_already_rtk_passthrough() { + let input = qoder_input("rtk git status"); + let output = run_qoder_inner(&input); + assert!(output.is_none()); + } + // --- Cursor handler --- fn cursor_input(cmd: &str) -> String { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 189f5de55..e5e89ae5a 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -16,7 +16,8 @@ use super::constants::{ GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, PI_PLUGIN_FILE, - PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, + PRE_TOOL_USE_KEY, QODER_DIR, QODER_HOOK_COMMAND, QODER_SETTINGS_JSON, REWRITE_HOOK_FILE, + SETTINGS_JSON, }; use super::integrity; @@ -29,6 +30,7 @@ const PI_PLUGIN: &str = include_str!("../../hooks/pi/rtk.ts"); // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../../hooks/claude/rtk-awareness.md"); const RTK_SLIM_CODEX: &str = include_str!("../../hooks/codex/rtk-awareness.md"); +const RTK_SLIM_QODER: &str = include_str!("../../hooks/qoder/rtk-awareness.md"); /// Template written by `rtk init` when no filters.toml exists yet. const FILTERS_TEMPLATE: &str = r#"# Project-local RTK filters — commit this file with your repo. @@ -604,13 +606,14 @@ fn remove_hook_from_settings(ctx: InitContext) -> Result { Ok(removed) } -/// Full uninstall for Claude, Gemini, Codex, Cursor, or Pi artifacts. +/// Full uninstall for Claude, Gemini, Codex, Cursor, Pi, or Qoder artifacts. pub fn uninstall( global: bool, gemini: bool, codex: bool, cursor: bool, pi: bool, + qoder: bool, ctx: InitContext, ) -> Result<()> { let InitContext { verbose, dry_run } = ctx; @@ -649,8 +652,38 @@ pub fn uninstall( return Ok(()); } - if pi { +if pi { uninstall_pi(global, ctx)?; + if dry_run { + print_dry_run_footer(); + } + return Ok(()); + } + + if qoder { + if !global { + anyhow::bail!("Qoder uninstall only works with --global flag"); + } + let qoder_removed = remove_qoder_hooks(ctx).context("Failed to remove Qoder hooks")?; + if !qoder_removed.is_empty() { + let header = if dry_run { + "[dry-run] would uninstall RTK (Qoder):" + } else { + "RTK uninstalled (Qoder):" + }; + println!("{}", header); + for item in &qoder_removed { + println!(" - {}", item); + } + if !dry_run { + println!("\nRestart Qoder to apply changes."); + } + } else { + println!("RTK Qoder support was not installed (nothing to remove)"); + } + if dry_run { + print_dry_run_footer(); + } return Ok(()); } @@ -2958,6 +2991,211 @@ fn resolve_cursor_dir() -> Result { resolve_home_subdir(CURSOR_DIR) } +fn resolve_qoder_dir() -> Result { + let home = dirs::home_dir().context("Could not determine home directory")?; + Ok(home.join(QODER_DIR)) +} + +/// Check whether the Qoder settings.json already contains the RTK hook entry. +fn qoder_hook_already_present(root: &serde_json::Value) -> bool { + let hooks = match root + .get("hooks") + .and_then(|h| h.get("PreToolUse")) + .and_then(|p| p.as_array()) + { + Some(arr) => arr, + None => return false, + }; + + hooks.iter().any(|entry| { + entry + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|h| { + h.iter().any(|inner| { + inner.get("command").and_then(|c| c.as_str()) == Some(QODER_HOOK_COMMAND) + }) + }) + }) +} + +/// Insert the RTK PreToolUse entry into a Qoder settings.json document. +fn insert_qoder_hook_entry(root: &mut serde_json::Value) -> Result<()> { + let root_obj = match root.as_object_mut() { + Some(obj) => obj, + None => { + *root = serde_json::json!({}); + root.as_object_mut().expect("just-created json object") + } + }; + + let hooks = root_obj + .entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .context("hooks value is not an object")?; + + let pre_tool_use = hooks + .entry("PreToolUse") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .context("PreToolUse value is not an array")?; + + pre_tool_use.push(serde_json::json!({ + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": QODER_HOOK_COMMAND + } + ] + })); + Ok(()) +} + +/// Install Qoder IDE hooks: write settings.json PreToolUse entry + RTK.md awareness doc. +pub fn install_qoder_hooks(ctx: InitContext) -> Result<()> { + let verbose = ctx.verbose; + let dry_run = ctx.dry_run; + let qoder_dir = resolve_qoder_dir()?; + + if !dry_run && !qoder_dir.exists() { + fs::create_dir_all(&qoder_dir).context("Failed to create .qoder directory")?; + } + + let settings_json_path = qoder_dir.join(QODER_SETTINGS_JSON); + + let mut root = if settings_json_path.exists() { + let content = fs::read_to_string(&settings_json_path) + .with_context(|| format!("Failed to read {}", settings_json_path.display()))?; + if content.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", settings_json_path.display()))? + } + } else { + serde_json::json!({}) + }; + + let patched = if qoder_hook_already_present(&root) { + if verbose > 0 { + eprintln!("Qoder settings.json: RTK hook already present"); + } + false + } else { + insert_qoder_hook_entry(&mut root)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; + + if dry_run { + println!( + "[dry-run] would patch Qoder settings.json: {}", + settings_json_path.display() + ); + } else { + if settings_json_path.exists() { + let backup_path = settings_json_path.with_extension("json.bak"); + fs::copy(&settings_json_path, &backup_path).ok(); + } + atomic_write(&settings_json_path, &serialized)?; + } + true + }; + + let rtk_md_path = qoder_dir.join("RTK.md"); + let md_written = write_if_changed(&rtk_md_path, RTK_SLIM_QODER, "Qoder awareness doc", ctx)?; + + if !dry_run { + println!("\nQoder hook registered (global).\n"); + println!(" Command: {}", QODER_HOOK_COMMAND); + println!(" settings.json: {}", settings_json_path.display()); + if patched { + println!(" settings.json: RTK PreToolUse entry added"); + } else { + println!(" settings.json: RTK PreToolUse entry already present"); + } + if md_written { + println!(" RTK.md: Written to {}", rtk_md_path.display()); + } + println!(" Test with: git status in Qoder IDE\n"); + } + + Ok(()) +} + +/// Remove the RTK hook entry from a Qoder settings.json, if present. +/// Returns `true` if an entry was removed. +fn remove_qoder_hook_from_json(root: &mut serde_json::Value) -> bool { + let pre_tool_use = match root + .get_mut("hooks") + .and_then(|h| h.get_mut("PreToolUse")) + .and_then(|p| p.as_array_mut()) + { + Some(arr) => arr, + None => return false, + }; + + let original_len = pre_tool_use.len(); + pre_tool_use.retain(|entry| { + !entry + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|h| { + h.iter().any(|inner| { + inner.get("command").and_then(|c| c.as_str()) == Some(QODER_HOOK_COMMAND) + }) + }) + }); + + pre_tool_use.len() < original_len +} + +/// Uninstall Qoder IDE hooks: remove settings.json entry and RTK.md awareness doc. +pub fn remove_qoder_hooks(ctx: InitContext) -> Result> { + let InitContext { + verbose: _, + dry_run, + } = ctx; + let qoder_dir = resolve_qoder_dir()?; + let mut removed = Vec::new(); + + let settings_json_path = qoder_dir.join(QODER_SETTINGS_JSON); + if settings_json_path.exists() { + let content = fs::read_to_string(&settings_json_path)?; + if !content.trim().is_empty() { + if let Ok(mut root) = serde_json::from_str::(&content) { + if remove_qoder_hook_from_json(&mut root) { + if dry_run { + println!("[dry-run] would remove RTK entry from Qoder settings.json"); + } else { + let backup_path = settings_json_path.with_extension("json.bak"); + fs::copy(&settings_json_path, &backup_path).ok(); + let serialized = serde_json::to_string_pretty(&root)?; + atomic_write(&settings_json_path, &serialized)?; + } + removed.push(format!("Qoder entry in {}", settings_json_path.display())); + } + } + } + } + + let rtk_md_path = qoder_dir.join("RTK.md"); + if rtk_md_path.exists() { + if dry_run { + println!( + "[dry-run] would remove Qoder RTK.md: {}", + rtk_md_path.display() + ); + } else { + fs::remove_file(&rtk_md_path).ok(); + } + removed.push(format!("Qoder awareness doc: {}", rtk_md_path.display())); + } + + Ok(removed) +} + /// Install Cursor hooks: register binary command in hooks.json fn install_cursor_hooks(ctx: InitContext) -> Result<()> { let InitContext { verbose, dry_run } = ctx; @@ -3488,6 +3726,21 @@ fn show_claude_config() -> Result<()> { println!("[--] Cursor: home dir not found"); } + // Check Qoder hooks + let qoder_dir = dirs::home_dir().map(|h| h.join(QODER_DIR)); + if let Some(dir) = qoder_dir { + let settings = dir.join(QODER_SETTINGS_JSON); + if settings.exists() { + if let Ok(content) = fs::read_to_string(&settings) { + if let Ok(root) = serde_json::from_str::(&content) { + if qoder_hook_already_present(&root) { + println!("[ok] Qoder Hook: {}", settings.display()); + } + } + } + } + } + println!("\nUsage:"); println!(" rtk init # Full injection into local CLAUDE.md"); println!(" rtk init -g # Hook + RTK.md + @RTK.md + settings.json (recommended)"); @@ -5603,7 +5856,7 @@ mod tests { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); - uninstall(true, false, false, false, false, InitContext::default()).unwrap(); + uninstall(true, false, false, false, false, false, InitContext::default()).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -5731,7 +5984,7 @@ mod tests { dry_run: true, ..Default::default() }; - uninstall(true, false, false, false, false, dry).unwrap(); + uninstall(true, false, false, false, false, false, dry).unwrap(); // Files must still exist with identical content assert!( @@ -5957,7 +6210,7 @@ mod tests { let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); assert!(plugin.exists()); - uninstall(true, false, false, false, true, InitContext::default()).unwrap(); + uninstall(true, false, false, false, true, false, InitContext::default()).unwrap(); assert!(!plugin.exists(), "plugin must be removed"); }); @@ -5971,7 +6224,7 @@ mod tests { std::env::set_current_dir(tmp.path()).unwrap(); run_pi_mode(false, InitContext::default()).unwrap(); - let result = uninstall(false, false, false, false, true, InitContext::default()); + let result = uninstall(false, false, false, false, true, false, InitContext::default()); std::env::set_current_dir(&cwd).unwrap(); result.unwrap(); @@ -6070,6 +6323,7 @@ mod tests { false, false, true, + false, InitContext { verbose: 0, dry_run: true, @@ -6108,6 +6362,7 @@ mod tests { false, false, true, + false, InitContext { verbose: 0, dry_run: true, diff --git a/src/main.rs b/src/main.rs index 22e6cbca8..7f1a87557 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,8 @@ pub enum AgentTarget { Pi, /// Hermes CLI Hermes, + /// Qoder IDE + Qoder, } #[derive(Parser)] @@ -775,6 +777,8 @@ enum HookCommands { Gemini, /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin) Copilot, + /// Process Qoder IDE PreToolUse hook (reads JSON from stdin) + Qoder, /// Check how a command would be rewritten by the hook engine (dry-run) Check { /// Target agent @@ -1377,14 +1381,15 @@ fn uninstall_init_dispatch( ) -> Result<()> where UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, - UninstallStandard: FnOnce(bool, bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, + UninstallStandard: FnOnce(bool, bool, bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, { if agent == Some(AgentTarget::Hermes) { uninstall_hermes(ctx) } else { let cursor = agent == Some(AgentTarget::Cursor); let pi = agent == Some(AgentTarget::Pi); - uninstall_standard(global, gemini, codex, cursor, pi, ctx) + let qoder = agent == Some(AgentTarget::Qoder); + uninstall_standard(global, gemini, codex, cursor, pi, qoder, ctx) } } @@ -1856,6 +1861,11 @@ fn run_cli() -> Result { ); } hooks::init::run_antigravity_mode(ctx)?; + } else if agent == Some(AgentTarget::Qoder) { + if !global { + anyhow::bail!("Qoder hooks are global-only. Use: rtk init -g --agent qoder"); + } + hooks::init::install_qoder_hooks(ctx)?; } else if agent == Some(AgentTarget::Hermes) { hooks::init::run_hermes_mode(ctx)?; } else { @@ -2191,6 +2201,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(" "); @@ -2709,7 +2723,7 @@ mod tests { assert!(ctx.dry_run); Ok(()) }, - |_, _, _, _, _, _| { + |_, _, _, _, _, _, _| { standard_called.set(true); Ok(()) },