diff --git a/README.md b/README.md index f8d65efe5..685ba0539 100644 --- a/README.md +++ b/README.md @@ -351,11 +351,12 @@ rtk git status ## Supported AI Tools -RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. +RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| | **Claude Code** | `rtk init -g` | PreToolUse hook (bash) | +| **CodeBuddy Code** | `rtk init -g --agent codebuddy` | PreToolUse hook (bash) | | **GitHub Copilot (VS Code)** | `rtk init -g --copilot` | PreToolUse hook — transparent rewrite | | **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) | | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | diff --git a/src/hooks/README.md b/src/hooks/README.md index a0c76b76d..c994c33be 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 (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. +Owns: `rtk init` installation flows (agents via `AgentTarget` enum + 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/`). @@ -26,6 +26,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Default (global) | `rtk init -g` | Hook, SHA-256 hash, RTK.md | settings.json, CLAUDE.md | | Hook only | `rtk init -g --hook-only` | Hook, SHA-256 hash | settings.json | | Claude-MD (legacy) | `rtk init --claude-md` | 134-line RTK block | CLAUDE.md | +| CodeBuddy Code | `rtk init -g --agent codebuddy` | Native hook command | `~/.codebuddy/settings.json` | | Windsurf | `rtk init -g --agent windsurf` | `.windsurfrules` | -- | | Cline | `rtk init --agent cline` | `.clinerules` | -- | | Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md | @@ -85,6 +86,7 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i | Tool | ask support | Behavior on Default | |------|------------|-------------------| | Claude Code (rtk-rewrite.sh) | Yes | `permissionDecision: "ask"` — user prompted | +| CodeBuddy Code (rtk hook codebuddy) | Yes | uses `modifiedInput` and leaves default permission flow to CodeBuddy | | Copilot VS Code (rtk hook copilot) | Yes | `permissionDecision: "ask"` — user prompted | | 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) | diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 506e88cdf..b9a329a52 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -1,6 +1,7 @@ pub const REWRITE_HOOK_FILE: &str = "rtk-rewrite.sh"; pub const GEMINI_HOOK_FILE: &str = "rtk-hook-gemini.sh"; pub const CLAUDE_DIR: &str = ".claude"; +pub const CODEBUDDY_DIR: &str = ".codebuddy"; pub const HOOKS_SUBDIR: &str = "hooks"; pub const SETTINGS_JSON: &str = "settings.json"; pub const SETTINGS_LOCAL_JSON: &str = "settings.local.json"; @@ -10,6 +11,8 @@ pub const BEFORE_TOOL_KEY: &str = "BeforeTool"; /// Native Rust hook command for Claude Code (replaces rtk-rewrite.sh). pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude"; +/// Native Rust hook command for CodeBuddy Code. +pub const CODEBUDDY_HOOK_COMMAND: &str = "rtk hook codebuddy"; /// Native Rust hook command for Cursor (replaces rtk-rewrite.sh). pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5..3d6adcc11 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -397,6 +397,108 @@ fn run_claude_inner(input: &str) -> Option { } } +// ── CodeBuddy Code native hook ───────────────────────────────── + +fn process_codebuddy_payload(v: &Value) -> PayloadAction { + let cmd = match v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + Some(c) => c, + None => return PayloadAction::Ignore, + }; + + let verdict = permissions::check_command(cmd); + if verdict == PermissionVerdict::Deny { + return PayloadAction::Skip { + reason: "skip:deny_rule", + cmd: cmd.to_string(), + }; + } + + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => { + return PayloadAction::Skip { + reason: "skip:no_match", + cmd: cmd.to_string(), + } + } + }; + + let modified_input = { + let mut ti = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); + if let Some(obj) = ti.as_object_mut() { + obj.insert("command".into(), Value::String(rewritten.clone())); + } + ti + }; + + let mut hook_output = json!({ + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecisionReason": "RTK auto-rewrite", + "modifiedInput": modified_input + }); + + if verdict == PermissionVerdict::Allow { + hook_output + .as_object_mut() + .unwrap() + .insert("permissionDecision".into(), json!("allow")); + } + + PayloadAction::Rewrite { + cmd: cmd.to_string(), + rewritten, + output: json!({ "hookSpecificOutput": hook_output }), + } +} + +/// Run the CodeBuddy Code PreToolUse hook natively. +pub fn run_codebuddy() -> 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_codebuddy_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_codebuddy_inner(input: &str) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + match process_codebuddy_payload(&v) { + PayloadAction::Rewrite { output, .. } => Some(output.to_string()), + _ => None, + } +} + // ── Cursor native hook ───────────────────────────────────────── /// Cursor on Windows ships hook payloads with one or more leading @@ -780,6 +882,63 @@ mod tests { assert!(run_claude_inner(&input).is_none()); } + // --- CodeBuddy handler --- + + #[test] + fn test_codebuddy_rewrite_git_status() { + let result = run_codebuddy_inner(&claude_input("git status")).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let cmd = v + .pointer("/hookSpecificOutput/modifiedInput/command") + .and_then(|c| c.as_str()) + .unwrap(); + assert_eq!(cmd, "rtk git status"); + } + + #[test] + fn test_codebuddy_rewrite_preserves_tool_input_fields() { + let input = claude_input_with_fields("git status", 30000, "Check repo status"); + let result = run_codebuddy_inner(&input).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let modified = &v["hookSpecificOutput"]["modifiedInput"]; + assert_eq!(modified["command"], "rtk git status"); + assert_eq!(modified["timeout"], 30000); + assert_eq!(modified["description"], "Check repo status"); + } + + #[test] + fn test_codebuddy_passthrough_no_output() { + assert!(run_codebuddy_inner(&claude_input("htop")).is_none()); + } + + #[test] + fn test_codebuddy_empty_command_passthrough() { + let input = json!({ + "tool_name": "Bash", + "tool_input": { "command": "" } + }) + .to_string(); + assert!(run_codebuddy_inner(&input).is_none()); + } + + #[test] + fn test_codebuddy_malformed_json_passthrough() { + assert!(run_codebuddy_inner("not valid json {{{").is_none()); + } + + #[test] + fn test_codebuddy_json_output_structure() { + let result = run_codebuddy_inner(&claude_input("git status")).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let hook = &v["hookSpecificOutput"]; + + assert_eq!(hook["hookEventName"], PRE_TOOL_USE_KEY); + assert_eq!(hook["permissionDecisionReason"], "RTK auto-rewrite"); + assert!(hook["modifiedInput"].is_object()); + assert!(hook["modifiedInput"]["command"].is_string()); + assert!(hook.get("updatedInput").is_none()); + } + // --- Cursor handler --- fn cursor_input(cmd: &str) -> String { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 189f5de55..1b3be983e 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -12,11 +12,11 @@ use crate::hooks::constants::{ }; use super::constants::{ - BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, - 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, + BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEBUDDY_DIR, CODEBUDDY_HOOK_COMMAND, + CODEX_DIR, CURSOR_HOOK_COMMAND, 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, }; use super::integrity; @@ -497,8 +497,8 @@ fn prompt_telemetry_consent() -> Result<()> { Ok(()) } -fn print_manual_instructions(hook_command: &str, include_opencode: bool) { - println!("\n MANUAL STEP: Add this to ~/.claude/settings.json:"); +fn print_manual_instructions_at(settings_path: &str, hook_command: &str, restart_message: &str) { + println!("\n MANUAL STEP: Add this to {}:", settings_path); println!(" {{"); println!(" \"hooks\": {{ \"PreToolUse\": [{{"); println!(" \"matcher\": \"Bash\","); @@ -507,14 +507,10 @@ fn print_manual_instructions(hook_command: &str, include_opencode: bool) { println!(" }}]"); println!(" }}]}}"); println!(" }}"); - if include_opencode { - println!("\n Then restart Claude Code and OpenCode. Test with: git status\n"); - } else { - println!("\n Then restart Claude Code. Test with: git status\n"); - } + println!("\n {}\n", restart_message); } -fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { +fn remove_hook_from_json(root: &mut serde_json::Value, hook_command: &str) -> bool { let hooks = match root .get_mut("hooks") .and_then(|h| h.get_mut(PRE_TOOL_USE_KEY)) @@ -533,8 +529,8 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { if let Some(hooks_array) = entry.get("hooks").and_then(|h| h.as_array()) { for hook in hooks_array { if let Some(command) = hook.get("command").and_then(|c| c.as_str()) { - // Match both legacy script path and new binary command - if command.contains(REWRITE_HOOK_FILE) || command == CLAUDE_HOOK_COMMAND { + // Match both legacy script path and the selected native hook command. + if command.contains(REWRITE_HOOK_FILE) || command == hook_command { return false; } } @@ -548,10 +544,13 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { /// Remove RTK hook from settings.json file /// Backs up before modification, returns true if hook was found and removed -fn remove_hook_from_settings(ctx: InitContext) -> Result { +fn remove_hook_from_settings_at( + settings_dir: &Path, + hook_command: &str, + ctx: InitContext, +) -> Result { let InitContext { verbose, dry_run } = ctx; - let claude_dir = resolve_claude_dir()?; - let settings_path = claude_dir.join(SETTINGS_JSON); + let settings_path = settings_dir.join(SETTINGS_JSON); if !settings_path.exists() { if verbose > 0 { @@ -570,7 +569,7 @@ fn remove_hook_from_settings(ctx: InitContext) -> Result { let mut root: serde_json::Value = serde_json::from_str(&content) .with_context(|| format!("Failed to parse {} as JSON", settings_path.display()))?; - let removed = remove_hook_from_json(&mut root); + let removed = remove_hook_from_json(&mut root, hook_command); if removed { if dry_run { @@ -604,13 +603,24 @@ fn remove_hook_from_settings(ctx: InitContext) -> Result { Ok(removed) } -/// Full uninstall for Claude, Gemini, Codex, Cursor, or Pi artifacts. +fn remove_hook_from_settings(ctx: InitContext) -> Result { + let claude_dir = resolve_claude_dir()?; + remove_hook_from_settings_at(&claude_dir, CLAUDE_HOOK_COMMAND, ctx) +} + +fn remove_codebuddy_hook_from_settings(ctx: InitContext) -> Result { + let codebuddy_dir = resolve_codebuddy_dir()?; + remove_hook_from_settings_at(&codebuddy_dir, CODEBUDDY_HOOK_COMMAND, ctx) +} + +/// Full uninstall for Claude, Gemini, Codex, Cursor, Pi, or CodeBuddy artifacts. pub fn uninstall( global: bool, gemini: bool, codex: bool, cursor: bool, pi: bool, + codebuddy: bool, ctx: InitContext, ) -> Result<()> { let InitContext { verbose, dry_run } = ctx; @@ -654,6 +664,34 @@ pub fn uninstall( return Ok(()); } + if codebuddy { + if !global { + anyhow::bail!("CodeBuddy Code uninstall only works with --global flag"); + } + let removed = remove_codebuddy_hook_from_settings(ctx) + .context("Failed to remove CodeBuddy Code hook")?; + if removed { + println!( + "{}", + if dry_run { + "[dry-run] would uninstall RTK (CodeBuddy Code):" + } else { + "RTK uninstalled (CodeBuddy Code):" + } + ); + println!(" - settings.json: removed RTK hook entry"); + if !dry_run { + println!("\nRestart CodeBuddy Code to apply changes."); + } + } else { + println!("RTK CodeBuddy Code support was not installed (nothing to remove)"); + } + if dry_run { + print_dry_run_footer(); + } + return Ok(()); + } + if !global { anyhow::bail!("Uninstall only works with --global flag. For local projects, manually remove RTK from CLAUDE.md"); } @@ -924,9 +962,32 @@ fn patch_settings_json_command( include_opencode: bool, ctx: InitContext, ) -> Result { - let InitContext { verbose, dry_run } = ctx; let claude_dir = resolve_claude_dir()?; - let settings_path = claude_dir.join(SETTINGS_JSON); + let restart_message = if include_opencode { + "Restart Claude Code and OpenCode. Test with: git status" + } else { + "Restart Claude Code. Test with: git status" + }; + patch_settings_json_command_at( + &claude_dir, + hook_command, + mode, + "~/.claude/settings.json", + restart_message, + ctx, + ) +} + +fn patch_settings_json_command_at( + settings_dir: &Path, + hook_command: &str, + mode: PatchMode, + manual_settings_path: &str, + restart_message: &str, + ctx: InitContext, +) -> Result { + let InitContext { verbose, dry_run } = ctx; + let settings_path = settings_dir.join(SETTINGS_JSON); // Read or create settings.json let mut root = if settings_path.exists() { @@ -954,7 +1015,7 @@ fn patch_settings_json_command( // Handle mode match mode { PatchMode::Skip => { - print_manual_instructions(hook_command, include_opencode); + print_manual_instructions_at(manual_settings_path, hook_command, restart_message); return Ok(PatchResult::Skipped); } PatchMode::Ask => { @@ -965,7 +1026,7 @@ fn patch_settings_json_command( settings_path.display() ); } else if !prompt_user_consent(&settings_path)? { - print_manual_instructions(hook_command, include_opencode); + print_manual_instructions_at(manual_settings_path, hook_command, restart_message); return Ok(PatchResult::Declined); } } @@ -990,6 +1051,9 @@ fn patch_settings_json_command( return Ok(PatchResult::WouldPatch); } + fs::create_dir_all(settings_dir) + .with_context(|| format!("Failed to create config dir: {}", settings_dir.display()))?; + // Backup original if settings_path.exists() { let backup_path = settings_path.with_extension("json.bak"); @@ -1010,11 +1074,7 @@ fn patch_settings_json_command( settings_path.with_extension("json.bak").display() ); } - if include_opencode { - println!(" Restart Claude Code and OpenCode. Test with: git status"); - } else { - println!(" Restart Claude Code. Test with: git status"); - } + println!(" {}", restart_message); Ok(PatchResult::Patched) } @@ -1099,9 +1159,7 @@ fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { .filter_map(|entry| entry.get("hooks")?.as_array()) .flatten() .filter_map(|hook| hook.get("command")?.as_str()) - .any(|cmd| { - cmd == hook_command || cmd == CLAUDE_HOOK_COMMAND || cmd.contains(REWRITE_HOOK_FILE) - }) + .any(|cmd| cmd == hook_command || cmd.contains(REWRITE_HOOK_FILE)) } /// Default mode: hook + slim RTK.md + @RTK.md reference @@ -1193,6 +1251,49 @@ fn run_default_mode( Ok(()) } +pub fn run_codebuddy_mode(global: bool, patch_mode: PatchMode, ctx: InitContext) -> Result<()> { + if !global { + anyhow::bail!("CodeBuddy Code hooks are global-only. Use: rtk init -g --agent codebuddy"); + } + let codebuddy_dir = resolve_codebuddy_dir()?; + run_codebuddy_mode_at(&codebuddy_dir, patch_mode, ctx) +} + +fn run_codebuddy_mode_at( + codebuddy_dir: &Path, + patch_mode: PatchMode, + ctx: InitContext, +) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + if !dry_run { + println!("\nRTK hook registered for CodeBuddy Code (global).\n"); + println!(" Command: {}", CODEBUDDY_HOOK_COMMAND); + } + let patch_result = patch_settings_json_command_at( + codebuddy_dir, + CODEBUDDY_HOOK_COMMAND, + patch_mode, + "~/.codebuddy/settings.json", + "Restart CodeBuddy Code. Test with: git status", + ctx, + )?; + if dry_run { + print_dry_run_footer(); + } else { + match patch_result { + PatchResult::Patched => {} + PatchResult::AlreadyPresent => { + println!("\n settings.json: hook already present"); + println!(" Restart CodeBuddy Code. Test with: git status"); + } + PatchResult::Declined | PatchResult::Skipped => {} + PatchResult::WouldPatch => {} + } + println!(); + } + Ok(()) +} + /// Migrate old hook script to new binary command. /// Deletes `~/.claude/hooks/rtk-rewrite.sh` and `.rtk-hook.sha256` if present, /// and removes the stale settings.json entry so the new `rtk hook claude` entry @@ -2716,6 +2817,13 @@ fn resolve_claude_dir() -> Result { resolve_home_subdir(CLAUDE_DIR) } +fn resolve_codebuddy_dir() -> Result { + if let Ok(dir) = std::env::var("RTK_CODEBUDDY_DIR") { + return Ok(PathBuf::from(dir)); + } + resolve_home_subdir(CODEBUDDY_DIR) +} + fn resolve_codex_dir() -> Result { resolve_codex_dir_from( std::env::var_os("CODEX_HOME").map(PathBuf::from), @@ -3488,6 +3596,26 @@ fn show_claude_config() -> Result<()> { println!("[--] Cursor: home dir not found"); } + if let Ok(codebuddy_dir) = resolve_codebuddy_dir() { + let codebuddy_settings = codebuddy_dir.join(SETTINGS_JSON); + if codebuddy_settings.exists() { + let content = fs::read_to_string(&codebuddy_settings).unwrap_or_default(); + if let Ok(root) = serde_json::from_str::(&content) { + if hook_already_present(&root, CODEBUDDY_HOOK_COMMAND) { + println!("[ok] CodeBuddy Code: hook configured"); + } else { + println!("[--] CodeBuddy Code: settings.json exists but rtk not configured"); + } + } else { + println!("[warn] CodeBuddy Code: settings.json exists but invalid JSON"); + } + } else { + println!("[--] CodeBuddy Code: settings.json not found"); + } + } else { + println!("[--] CodeBuddy Code: home dir not found"); + } + println!("\nUsage:"); println!(" rtk init # Full injection into local CLAUDE.md"); println!(" rtk init -g # Hook + RTK.md + @RTK.md + settings.json (recommended)"); @@ -3500,6 +3628,7 @@ fn show_claude_config() -> Result<()> { println!(" rtk init -g --codex # Configure $CODEX_HOME/AGENTS.md + $CODEX_HOME/RTK.md (or ~/.codex/)"); println!(" rtk init -g --opencode # OpenCode plugin only"); println!(" rtk init -g --agent cursor # Install Cursor Agent hooks"); + println!(" rtk init -g --agent codebuddy # Install CodeBuddy Code hooks"); Ok(()) } @@ -4240,6 +4369,133 @@ mod tests { assert_eq!(content, "@RTK.md\n"); } + #[test] + fn test_codebuddy_settings_patch_creates_hook_entry() { + let temp = TempDir::new().unwrap(); + let result = patch_settings_json_command_at( + temp.path(), + CODEBUDDY_HOOK_COMMAND, + PatchMode::Auto, + "~/.codebuddy/settings.json", + "Restart CodeBuddy Code. Test with: git status", + InitContext::default(), + ) + .unwrap(); + + assert_eq!(result, PatchResult::Patched); + let content = fs::read_to_string(temp.path().join(SETTINGS_JSON)).unwrap(); + let root: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!(hook_already_present(&root, CODEBUDDY_HOOK_COMMAND)); + assert!(!hook_already_present(&root, CLAUDE_HOOK_COMMAND)); + } + + #[test] + fn test_codebuddy_settings_patch_is_idempotent() { + let temp = TempDir::new().unwrap(); + let first = patch_settings_json_command_at( + temp.path(), + CODEBUDDY_HOOK_COMMAND, + PatchMode::Auto, + "~/.codebuddy/settings.json", + "Restart CodeBuddy Code. Test with: git status", + InitContext::default(), + ) + .unwrap(); + let second = patch_settings_json_command_at( + temp.path(), + CODEBUDDY_HOOK_COMMAND, + PatchMode::Auto, + "~/.codebuddy/settings.json", + "Restart CodeBuddy Code. Test with: git status", + InitContext::default(), + ) + .unwrap(); + + assert_eq!(first, PatchResult::Patched); + assert_eq!(second, PatchResult::AlreadyPresent); + let content = fs::read_to_string(temp.path().join(SETTINGS_JSON)).unwrap(); + assert_eq!(content.matches(CODEBUDDY_HOOK_COMMAND).count(), 1); + } + + #[test] + fn test_codebuddy_settings_patch_preserves_existing_hooks() { + let temp = TempDir::new().unwrap(); + fs::write( + temp.path().join(SETTINGS_JSON), + r#"{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "existing hook" }] + }] + } +}"#, + ) + .unwrap(); + + patch_settings_json_command_at( + temp.path(), + CODEBUDDY_HOOK_COMMAND, + PatchMode::Auto, + "~/.codebuddy/settings.json", + "Restart CodeBuddy Code. Test with: git status", + InitContext::default(), + ) + .unwrap(); + + let content = fs::read_to_string(temp.path().join(SETTINGS_JSON)).unwrap(); + assert!(content.contains("existing hook")); + assert!(content.contains(CODEBUDDY_HOOK_COMMAND)); + } + + #[test] + fn test_remove_codebuddy_hook_preserves_other_hooks() { + let temp = TempDir::new().unwrap(); + fs::write( + temp.path().join(SETTINGS_JSON), + format!( + r#"{{ + "hooks": {{ + "PreToolUse": [ + {{ "matcher": "Bash", "hooks": [{{ "type": "command", "command": "{}" }}] }}, + {{ "matcher": "Bash", "hooks": [{{ "type": "command", "command": "existing hook" }}] }} + ] + }} +}}"#, + CODEBUDDY_HOOK_COMMAND + ), + ) + .unwrap(); + + let removed = remove_hook_from_settings_at( + temp.path(), + CODEBUDDY_HOOK_COMMAND, + InitContext::default(), + ) + .unwrap(); + + assert!(removed); + let content = fs::read_to_string(temp.path().join(SETTINGS_JSON)).unwrap(); + assert!(!content.contains(CODEBUDDY_HOOK_COMMAND)); + assert!(content.contains("existing hook")); + } + + #[test] + fn test_run_codebuddy_mode_dry_run_writes_nothing() { + let temp = TempDir::new().unwrap(); + run_codebuddy_mode_at( + temp.path(), + PatchMode::Auto, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!(!temp.path().join(SETTINGS_JSON).exists()); + } + #[test] fn test_patch_agents_md_migrates_inline_block() { let temp = TempDir::new().unwrap(); @@ -5194,7 +5450,7 @@ mod tests { } }); - let removed = remove_hook_from_json(&mut json_content); + let removed = remove_hook_from_json(&mut json_content, CLAUDE_HOOK_COMMAND); assert!(removed); // Should have only one hook left @@ -5229,7 +5485,7 @@ mod tests { } }); - let removed = remove_hook_from_json(&mut json_content); + let removed = remove_hook_from_json(&mut json_content, CLAUDE_HOOK_COMMAND); assert!(removed); let pre_tool_use = json_content["hooks"]["PreToolUse"].as_array().unwrap(); @@ -5254,7 +5510,7 @@ mod tests { } }); - let removed = remove_hook_from_json(&mut json_content); + let removed = remove_hook_from_json(&mut json_content, CLAUDE_HOOK_COMMAND); assert!(!removed); } @@ -5603,7 +5859,16 @@ 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 +5996,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 +6222,16 @@ 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 +6245,15 @@ 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 +6352,7 @@ mod tests { false, false, true, + false, InitContext { verbose: 0, dry_run: true, @@ -6108,6 +6391,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..02764dbf1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,8 @@ pub enum AgentTarget { Pi, /// Hermes CLI Hermes, + /// CodeBuddy Code + Codebuddy, } #[derive(Parser)] @@ -775,6 +777,8 @@ enum HookCommands { Gemini, /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin) Copilot, + /// Process CodeBuddy Code PreToolUse hook (reads JSON from stdin) + Codebuddy, /// Check how a command would be rewritten by the hook engine (dry-run) Check { /// Target agent @@ -1377,14 +1381,16 @@ 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 codebuddy = agent == Some(AgentTarget::Codebuddy); + uninstall_standard(global, gemini, codex, cursor, pi, codebuddy, ctx) } } @@ -1404,7 +1410,8 @@ fn run_cli() -> Result { // Warn if installed hook is outdated/missing (1/day, non-blocking). // Skip for Gain — it shows its own inline hook warning. - if !matches!(cli.command, Commands::Gain { .. }) { + // Skip hook processors because they must emit protocol JSON on stdout only. + if !matches!(cli.command, Commands::Gain { .. } | Commands::Hook { .. }) { hooks::hook_check::maybe_warn(); } @@ -1858,6 +1865,15 @@ fn run_cli() -> Result { hooks::init::run_antigravity_mode(ctx)?; } else if agent == Some(AgentTarget::Hermes) { hooks::init::run_hermes_mode(ctx)?; + } else if agent == Some(AgentTarget::Codebuddy) { + let patch_mode = if auto_patch { + hooks::init::PatchMode::Auto + } else if no_patch { + hooks::init::PatchMode::Skip + } else { + hooks::init::PatchMode::Ask + }; + hooks::init::run_codebuddy_mode(global, patch_mode, ctx)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -2191,6 +2207,10 @@ fn run_cli() -> Result { hooks::hook_cmd::run_copilot()?; 0 } + HookCommands::Codebuddy => { + hooks::hook_cmd::run_codebuddy()?; + 0 + } HookCommands::Check { agent: _, command } => { use crate::discover::registry::rewrite_command; let raw = command.join(" "); @@ -2688,6 +2708,32 @@ mod tests { } } + #[test] + fn test_try_parse_init_agent_codebuddy() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "codebuddy"]).unwrap(); + match cli.command { + Commands::Init { agent, .. } => { + assert_eq!(agent, Some(AgentTarget::Codebuddy)); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_try_parse_init_agent_codebuddy_uninstall() { + let cli = + Cli::try_parse_from(["rtk", "init", "--agent", "codebuddy", "--uninstall"]).unwrap(); + match cli.command { + Commands::Init { + agent, uninstall, .. + } => { + assert_eq!(agent, Some(AgentTarget::Codebuddy)); + assert!(uninstall); + } + _ => panic!("Expected Init command"), + } + } + #[test] fn test_init_uninstall_dispatch_routes_hermes_to_hermes_cleanup() { let hermes_called = Cell::new(false); @@ -2709,7 +2755,7 @@ mod tests { assert!(ctx.dry_run); Ok(()) }, - |_, _, _, _, _, _| { + |_, _, _, _, _, _, _| { standard_called.set(true); Ok(()) }, @@ -2840,6 +2886,17 @@ mod tests { )); } + #[test] + fn test_hook_codebuddy_parses() { + let cli = Cli::try_parse_from(["rtk", "hook", "codebuddy"]).unwrap(); + assert!(matches!( + cli.command, + Commands::Hook { + command: HookCommands::Codebuddy + } + )); + } + #[test] fn test_hook_check_parses() { let cli = Cli::try_parse_from(["rtk", "hook", "check", "git", "status"]).unwrap();