From 21a069ad76c3d8ffeee870d24408b1942cee691c Mon Sep 17 00:00:00 2001 From: hed0rah <18272116+hed0rah@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:07:28 -0400 Subject: [PATCH 01/33] feat(init): add --dry-run flag to preview changes without writing Rebased onto current develop (binary-command era). Threads dry_run through patch_settings_json_command, migrate_old_hook_script, install_cursor_hooks, run_kilocode_mode, run_antigravity_mode, run_gemini, run_copilot, and uninstall. Fixes from PR #1032 review: - --uninstall --dry-run no longer deletes files (uninstall() now takes dry_run, every fs::remove_file / fs::write / atomic_write guarded) - Success messages ("installed", "configured", "Restart ...") gated on !dry_run in run_default_mode, run_hook_only_mode, run_codex_mode, run_copilot, install_cursor_hooks, run_gemini - prompt_telemetry_consent() skipped in dry-run - integrity::store_hash() in run_gemini guarded - KiloCode and Antigravity modes now accept dry_run - PatchResult::WouldPatch variant added for patch_settings_json_command - [dry-run] Nothing written. footer printed by every sub-mode - write_if_changed uses atomic_write (not fs::write) Added integrity::hash_path_for() public wrapper so dry-run can check sidecar existence without the destructive remove_hash. Tests: write_if_changed(dry_run=true) creates nothing; run_codex_mode_with_paths(dry_run=true) creates neither RTK.md nor AGENTS.md. 1596 tests pass, clippy clean. --- src/hooks/init.rs | 1248 +++++++++++++++++++++++++++++----------- src/hooks/integrity.rs | 5 + src/main.rs | 16 +- 3 files changed, 922 insertions(+), 347 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 65517fc7bc..d46253b23c 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -71,6 +71,12 @@ pub enum PatchResult { AlreadyPresent, // Hook was already in settings.json Declined, // User declined when prompted Skipped, // --no-patch flag used + WouldPatch, // Dry-run: hook would have been added +} + +/// Shared dry-run footer printed at the end of every init sub-mode. +fn print_dry_run_footer() { + println!("\n[dry-run] Nothing written."); } // Legacy full instructions for backward compatibility (--claude-md mode) @@ -228,6 +234,7 @@ pub fn run( codex: bool, patch_mode: PatchMode, verbose: u8, + dry_run: bool, ) -> Result<()> { // Validation: Codex mode conflicts if codex { @@ -246,7 +253,7 @@ pub fn run( if matches!(patch_mode, PatchMode::Skip) { anyhow::bail!("--codex cannot be combined with --no-patch"); } - return run_codex_mode(global, verbose); + return run_codex_mode(global, verbose, dry_run); } // Validation: Global-only features @@ -264,20 +271,24 @@ pub fn run( // Windsurf-only mode if install_windsurf { - return run_windsurf_mode(verbose); + return run_windsurf_mode(verbose, dry_run); } // Cline-only mode if install_cline { - return run_cline_mode(verbose); + return run_cline_mode(verbose, dry_run); } // Mode selection (Claude Code / OpenCode) match (install_claude, install_opencode, claude_md, hook_only) { - (false, true, _, _) => run_opencode_only_mode(verbose)?, - (true, opencode, true, _) => run_claude_md_mode(global, verbose, opencode)?, - (true, opencode, false, true) => run_hook_only_mode(global, patch_mode, verbose, opencode)?, - (true, opencode, false, false) => run_default_mode(global, patch_mode, verbose, opencode)?, + (false, true, _, _) => run_opencode_only_mode(verbose, dry_run)?, + (true, opencode, true, _) => run_claude_md_mode(global, verbose, opencode, dry_run)?, + (true, opencode, false, true) => { + run_hook_only_mode(global, patch_mode, verbose, opencode, dry_run)? + } + (true, opencode, false, false) => { + run_default_mode(global, patch_mode, verbose, opencode, dry_run)? + } (false, false, _, _) => { if !install_cursor { anyhow::bail!("at least one of install_claude or install_opencode must be true") @@ -287,18 +298,27 @@ pub fn run( // Cursor hooks (additive, installed alongside Claude Code) if install_cursor { - install_cursor_hooks(verbose)?; + install_cursor_hooks(verbose, dry_run)?; } - prompt_telemetry_consent()?; + if !dry_run { + prompt_telemetry_consent()?; + } println!(); Ok(()) } -/// Idempotent file write: create or update if content differs -fn write_if_changed(path: &Path, content: &str, name: &str, verbose: u8) -> Result { +/// Idempotent file write: create or update if content differs. +/// When `dry_run` is true, prints the intended action and does not touch the filesystem. +fn write_if_changed( + path: &Path, + content: &str, + name: &str, + verbose: u8, + dry_run: bool, +) -> Result { if path.exists() { let existing = fs::read_to_string(path) .with_context(|| format!("Failed to read {}: {}", name, path.display()))?; @@ -309,18 +329,32 @@ fn write_if_changed(path: &Path, content: &str, name: &str, verbose: u8) -> Resu } Ok(false) } else { - atomic_write(path, content) - .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; - if verbose > 0 { - eprintln!("Updated {}: {}", name, path.display()); + if dry_run { + println!("[dry-run] would update {}: {}", name, path.display()); + if verbose > 0 { + println!("[dry-run] content:\n{}", content); + } + } else { + atomic_write(path, content) + .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; + if verbose > 0 { + eprintln!("Updated {}: {}", name, path.display()); + } } Ok(true) } } else { - atomic_write(path, content) - .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; - if verbose > 0 { - eprintln!("Created {}: {}", name, path.display()); + if dry_run { + println!("[dry-run] would create {}: {}", name, path.display()); + if verbose > 0 { + println!("[dry-run] content:\n{}", content); + } + } else { + atomic_write(path, content) + .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; + if verbose > 0 { + eprintln!("Created {}: {}", name, path.display()); + } } Ok(true) } @@ -492,7 +526,7 @@ 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(verbose: u8) -> Result { +fn remove_hook_from_settings(verbose: u8, dry_run: bool) -> Result { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -516,6 +550,19 @@ fn remove_hook_from_settings(verbose: u8) -> Result { let removed = remove_hook_from_json(&mut root); if removed { + if dry_run { + println!( + "[dry-run] would remove RTK hook entry from {}", + settings_path.display() + ); + if verbose > 0 { + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize settings.json")?; + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + // Backup original let backup_path = settings_path.with_extension("json.bak"); fs::copy(&settings_path, &backup_path) @@ -535,9 +582,16 @@ fn remove_hook_from_settings(verbose: u8) -> Result { } /// Full uninstall for Claude, Gemini, Codex, or Cursor artifacts. -pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: u8) -> Result<()> { +pub fn uninstall( + global: bool, + gemini: bool, + codex: bool, + cursor: bool, + verbose: u8, + dry_run: bool, +) -> Result<()> { if codex { - return uninstall_codex(global, verbose); + return uninstall_codex(global, verbose, dry_run); } if cursor { @@ -545,13 +599,22 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: anyhow::bail!("Cursor uninstall only works with --global flag"); } let cursor_removed = - remove_cursor_hooks(verbose).context("Failed to remove Cursor hooks")?; + remove_cursor_hooks(verbose, dry_run).context("Failed to remove Cursor hooks")?; if !cursor_removed.is_empty() { - println!("RTK uninstalled (Cursor):"); + let header = if dry_run { + "[dry-run] would uninstall RTK (Cursor):" + } else { + "RTK uninstalled (Cursor):" + }; + println!("{}", header); for item in &cursor_removed { println!(" - {}", item); } - println!("\nRestart Cursor to apply changes."); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nRestart Cursor to apply changes."); + } } else { println!("RTK Cursor support was not installed (nothing to remove)"); } @@ -567,14 +630,23 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: // Also uninstall Gemini artifacts if --gemini or always (clean everything) if gemini { - let gemini_removed = uninstall_gemini(verbose)?; + let gemini_removed = uninstall_gemini(verbose, dry_run)?; removed.extend(gemini_removed); if !removed.is_empty() { - println!("RTK uninstalled (Gemini):"); + let header = if dry_run { + "[dry-run] would uninstall RTK (Gemini):" + } else { + "RTK uninstalled (Gemini):" + }; + println!("{}", header); for item in &removed { println!(" - {}", item); } - println!("\nRestart Gemini CLI to apply changes."); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nRestart Gemini CLI to apply changes."); + } } else { println!("RTK Gemini support was not installed (nothing to remove)"); } @@ -584,21 +656,38 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: // 1. Remove legacy hook file (if exists from old installation) let hook_path = claude_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + if dry_run { + println!( + "[dry-run] would remove hook script: {}", + hook_path.display() + ); + } else { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + } removed.push(format!("Hook script: {}", hook_path.display())); } // 1b. Remove integrity hash file - if integrity::remove_hash(&hook_path)? { + if dry_run { + // integrity::remove_hash would delete the sidecar file; just report intent. + if integrity::hash_path_for(&hook_path).exists() { + println!("[dry-run] would remove integrity hash sidecar"); + removed.push("Integrity hash: removed".to_string()); + } + } else if integrity::remove_hash(&hook_path)? { removed.push("Integrity hash: removed".to_string()); } // 2. Remove RTK.md let rtk_md_path = claude_dir.join(RTK_MD); if rtk_md_path.exists() { - fs::remove_file(&rtk_md_path) - .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + if dry_run { + println!("[dry-run] would remove RTK.md: {}", rtk_md_path.display()); + } else { + fs::remove_file(&rtk_md_path) + .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + } removed.push(format!("RTK.md: {}", rtk_md_path.display())); } @@ -618,43 +707,62 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: // Clean up double blanks let cleaned = clean_double_blanks(&new_content); - fs::write(&claude_md_path, cleaned).with_context(|| { - format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) - })?; + if dry_run { + println!( + "[dry-run] would remove @RTK.md reference from CLAUDE.md: {}", + claude_md_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", cleaned); + } + } else { + fs::write(&claude_md_path, cleaned).with_context(|| { + format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) + })?; + } removed.push("CLAUDE.md: removed @RTK.md reference".to_string()); } } // 4. Remove hook entry from settings.json - if remove_hook_from_settings(verbose)? { + if remove_hook_from_settings(verbose, dry_run)? { removed.push("settings.json: removed RTK hook entry".to_string()); } // 5. Remove OpenCode plugin - let opencode_removed = remove_opencode_plugin(verbose)?; + let opencode_removed = remove_opencode_plugin(verbose, dry_run)?; for path in opencode_removed { removed.push(format!("OpenCode plugin: {}", path.display())); } // 6. Remove Cursor hooks - let cursor_removed = remove_cursor_hooks(verbose)?; + let cursor_removed = remove_cursor_hooks(verbose, dry_run)?; removed.extend(cursor_removed); // Report results if removed.is_empty() { println!("RTK was not installed (nothing to remove)"); } else { - println!("RTK uninstalled:"); + let header = if dry_run { + "[dry-run] would uninstall RTK:" + } else { + "RTK uninstalled:" + }; + println!("{}", header); for item in removed { println!(" - {}", item); } - println!("\nRestart Claude Code, OpenCode, and Cursor (if used) to apply changes."); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nRestart Claude Code, OpenCode, and Cursor (if used) to apply changes."); + } } Ok(()) } -fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { +fn uninstall_codex(global: bool, verbose: u8, dry_run: bool) -> Result<()> { if !global { anyhow::bail!( "Uninstall only works with --global flag. For local projects, manually remove RTK from AGENTS.md" @@ -662,30 +770,42 @@ fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { } let codex_dir = resolve_codex_dir()?; - let removed = uninstall_codex_at(&codex_dir, verbose)?; + let removed = uninstall_codex_at(&codex_dir, verbose, dry_run)?; if removed.is_empty() { println!("RTK was not installed for Codex CLI (nothing to remove)"); } else { - println!("RTK uninstalled for Codex CLI:"); + let header = if dry_run { + "[dry-run] would uninstall RTK for Codex CLI:" + } else { + "RTK uninstalled for Codex CLI:" + }; + println!("{}", header); for item in removed { println!(" - {}", item); } + if dry_run { + print_dry_run_footer(); + } } Ok(()) } -fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { +fn uninstall_codex_at(codex_dir: &Path, verbose: u8, dry_run: bool) -> Result> { let mut removed = Vec::new(); let absolute_rtk_md_ref = codex_rtk_md_ref(codex_dir); let rtk_md_path = codex_dir.join(RTK_MD); if rtk_md_path.exists() { - fs::remove_file(&rtk_md_path) - .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; - if verbose > 0 { - eprintln!("Removed RTK.md: {}", rtk_md_path.display()); + if dry_run { + println!("[dry-run] would remove RTK.md: {}", rtk_md_path.display()); + } else { + fs::remove_file(&rtk_md_path) + .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + if verbose > 0 { + eprintln!("Removed RTK.md: {}", rtk_md_path.display()); + } } removed.push(format!("RTK.md: {}", rtk_md_path.display())); } @@ -695,6 +815,7 @@ fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { &agents_md_path, &[RTK_MD_REF, absolute_rtk_md_ref.as_str()], verbose, + dry_run, )? { removed.push("AGENTS.md: removed @RTK.md reference".to_string()); } @@ -709,6 +830,7 @@ fn patch_settings_json_command( mode: PatchMode, verbose: u8, include_opencode: bool, + dry_run: bool, ) -> Result { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -743,7 +865,13 @@ fn patch_settings_json_command( return Ok(PatchResult::Skipped); } PatchMode::Ask => { - if !prompt_user_consent(&settings_path)? { + // Skip the interactive prompt in dry-run: we must not mutate state or block on stdin. + if dry_run { + println!( + "[dry-run] would prompt before patching {}", + settings_path.display() + ); + } else if !prompt_user_consent(&settings_path)? { print_manual_instructions(hook_command, include_opencode); return Ok(PatchResult::Declined); } @@ -755,6 +883,20 @@ fn patch_settings_json_command( insert_hook_entry(&mut root, hook_command)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; + + if dry_run { + println!( + "[dry-run] would patch settings.json: {}", + settings_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(PatchResult::WouldPatch); + } + // Backup original if settings_path.exists() { let backup_path = settings_path.with_extension("json.bak"); @@ -766,8 +908,6 @@ fn patch_settings_json_command( } // Atomic write - let serialized = - serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; atomic_write(&settings_path, &serialized)?; println!("\n settings.json: hook added"); @@ -878,11 +1018,12 @@ fn run_default_mode( _patch_mode: PatchMode, _verbose: u8, _install_opencode: bool, + _dry_run: bool, ) -> Result<()> { eprintln!("[warn] Hook-based mode requires Unix (macOS/Linux)."); eprintln!(" Windows: use --claude-md mode for full injection."); eprintln!(" Falling back to --claude-md mode."); - run_claude_md_mode(_global, _verbose, _install_opencode) + run_claude_md_mode(_global, _verbose, _install_opencode, _dry_run) } #[cfg(unix)] @@ -891,11 +1032,12 @@ fn run_default_mode( patch_mode: PatchMode, verbose: u8, install_opencode: bool, + dry_run: bool, ) -> Result<()> { if !global { // Local init: inject CLAUDE.md + generate project-local filters template - run_claude_md_mode(false, verbose, install_opencode)?; - generate_project_filters_template(verbose)?; + run_claude_md_mode(false, verbose, install_opencode, dry_run)?; + generate_project_filters_template(verbose, dry_run)?; return Ok(()); } @@ -904,62 +1046,78 @@ fn run_default_mode( let claude_md_path = claude_dir.join(CLAUDE_MD); // 1. Migrate old hook script if present - migrate_old_hook_script(verbose); + migrate_old_hook_script(verbose, dry_run); // 2. Write RTK.md - write_if_changed(&rtk_md_path, RTK_SLIM, RTK_MD, verbose)?; + write_if_changed(&rtk_md_path, RTK_SLIM, RTK_MD, verbose, dry_run)?; let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&path, verbose)?; + ensure_opencode_plugin_installed(&path, verbose, dry_run)?; Some(path) } else { None }; // 3. Patch CLAUDE.md (add @RTK.md, migrate if needed) - let migrated = patch_claude_md(&claude_md_path, verbose)?; - - // 4. Print success message - println!("\nRTK hook registered (global).\n"); - println!(" Command: {}", CLAUDE_HOOK_COMMAND); - println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); - if let Some(path) = &opencode_plugin_path { - println!(" OpenCode: {}", path.display()); - } - println!(" CLAUDE.md: @RTK.md reference added"); + let migrated = patch_claude_md(&claude_md_path, verbose, dry_run)?; + + // 4. Print success message (skip in dry-run) + if !dry_run { + println!("\nRTK hook registered (global).\n"); + println!(" Command: {}", CLAUDE_HOOK_COMMAND); + println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); + if let Some(path) = &opencode_plugin_path { + println!(" OpenCode: {}", path.display()); + } + println!(" CLAUDE.md: @RTK.md reference added"); - if migrated { - println!("\n [ok] Migrated: removed 137-line RTK block from CLAUDE.md"); - println!(" replaced with @RTK.md (10 lines)"); + if migrated { + println!("\n [ok] Migrated: removed 137-line RTK block from CLAUDE.md"); + println!(" replaced with @RTK.md (10 lines)"); + } } // 5. Patch settings.json with binary command - let patch_result = - patch_settings_json_command(CLAUDE_HOOK_COMMAND, patch_mode, verbose, install_opencode)?; + let patch_result = patch_settings_json_command( + CLAUDE_HOOK_COMMAND, + patch_mode, + verbose, + install_opencode, + dry_run, + )?; // Report result - match patch_result { - PatchResult::Patched => { - // Already printed by patch_settings_json_command - } - PatchResult::AlreadyPresent => { - println!("\n settings.json: hook already present"); - if install_opencode { - println!(" Restart Claude Code and OpenCode. Test with: git status"); - } else { - println!(" Restart Claude Code. Test with: git status"); + if !dry_run { + match patch_result { + PatchResult::Patched => { + // Already printed by patch_settings_json_command + } + PatchResult::AlreadyPresent => { + println!("\n settings.json: hook already present"); + if install_opencode { + println!(" Restart Claude Code and OpenCode. Test with: git status"); + } else { + println!(" Restart Claude Code. Test with: git status"); + } + } + PatchResult::Declined | PatchResult::Skipped => { + // Manual instructions already printed + } + PatchResult::WouldPatch => { + // Cannot happen outside dry_run } - } - PatchResult::Declined | PatchResult::Skipped => { - // Manual instructions already printed } } // 6. Generate user-global filters template (~/.config/rtk/filters.toml) - generate_global_filters_template(verbose)?; + generate_global_filters_template(verbose, dry_run)?; - println!(); // Final newline + if dry_run { + print_dry_run_footer(); + } else { + println!(); // Final newline + } Ok(()) } @@ -968,14 +1126,19 @@ fn run_default_mode( /// 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 /// can be registered. -fn migrate_old_hook_script(verbose: u8) { +fn migrate_old_hook_script(verbose: u8, dry_run: bool) { if let Some(home) = dirs::home_dir() { let old_hook = home .join(CLAUDE_DIR) .join(HOOKS_SUBDIR) .join(REWRITE_HOOK_FILE); if old_hook.exists() { - if let Err(e) = std::fs::remove_file(&old_hook) { + if dry_run { + println!( + "[dry-run] would migrate legacy hook script: {}", + old_hook.display() + ); + } else if let Err(e) = std::fs::remove_file(&old_hook) { if verbose > 0 { eprintln!(" [warn] Failed to remove old hook script: {e}"); } @@ -984,7 +1147,7 @@ fn migrate_old_hook_script(verbose: u8) { eprintln!(" [ok] Removed old hook script: {}", old_hook.display()); } // Clean up the stale settings.json entry that pointed to the deleted script - if let Err(e) = remove_legacy_settings_entries(verbose) { + if let Err(e) = remove_legacy_settings_entries(verbose, dry_run) { if verbose > 0 { eprintln!(" [warn] Failed to clean legacy settings.json entry: {e}"); } @@ -997,19 +1160,33 @@ fn migrate_old_hook_script(verbose: u8) { .join(HOOKS_SUBDIR) .join(".rtk-hook.sha256"); if hash_file.exists() { - let _ = std::fs::remove_file(&hash_file); + if dry_run { + println!( + "[dry-run] would remove legacy hash file: {}", + hash_file.display() + ); + } else { + let _ = std::fs::remove_file(&hash_file); + } } // Remove Cursor legacy hook let cursor_hook = home.join(".cursor").join("hooks").join(REWRITE_HOOK_FILE); if cursor_hook.exists() { - let _ = std::fs::remove_file(&cursor_hook); + if dry_run { + println!( + "[dry-run] would remove legacy Cursor hook: {}", + cursor_hook.display() + ); + } else { + let _ = std::fs::remove_file(&cursor_hook); + } } } } /// Remove only legacy `rtk-rewrite.sh` entries from settings.json. /// Preserves any existing `rtk hook claude` entries (new format). -fn remove_legacy_settings_entries(verbose: u8) -> Result<()> { +fn remove_legacy_settings_entries(verbose: u8, dry_run: bool) -> Result<()> { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -1030,6 +1207,14 @@ fn remove_legacy_settings_entries(verbose: u8) -> Result<()> { return Ok(()); } + if dry_run { + println!( + "[dry-run] would remove legacy rtk-rewrite.sh entry from {}", + settings_path.display() + ); + return Ok(()); + } + // Backup before modifying let backup_path = settings_path.with_extension("json.bak"); fs::copy(&settings_path, &backup_path) @@ -1078,7 +1263,7 @@ fn remove_legacy_hook_entries_from_json(root: &mut serde_json::Value) -> bool { } /// Generate .rtk/filters.toml template in the current directory if not present. -fn generate_project_filters_template(verbose: u8) -> Result<()> { +fn generate_project_filters_template(verbose: u8, dry_run: bool) -> Result<()> { let rtk_dir = std::path::Path::new(".rtk"); let path = rtk_dir.join("filters.toml"); @@ -1089,6 +1274,14 @@ fn generate_project_filters_template(verbose: u8) -> Result<()> { return Ok(()); } + if dry_run { + println!( + "[dry-run] would create .rtk/filters.toml template: {}", + path.display() + ); + return Ok(()); + } + fs::create_dir_all(rtk_dir) .with_context(|| format!("Failed to create directory: {}", rtk_dir.display()))?; fs::write(&path, FILTERS_TEMPLATE) @@ -1102,7 +1295,7 @@ fn generate_project_filters_template(verbose: u8) -> Result<()> { } /// Generate ~/.config/rtk/filters.toml template if not present. -fn generate_global_filters_template(verbose: u8) -> Result<()> { +fn generate_global_filters_template(verbose: u8, dry_run: bool) -> Result<()> { let config_dir = dirs::config_dir().unwrap_or_else(|| std::path::PathBuf::from(".config")); let rtk_dir = config_dir.join(crate::core::constants::RTK_DATA_DIR); let path = rtk_dir.join("filters.toml"); @@ -1114,6 +1307,14 @@ fn generate_global_filters_template(verbose: u8) -> Result<()> { return Ok(()); } + if dry_run { + println!( + "[dry-run] would create global filters template: {}", + path.display() + ); + return Ok(()); + } + fs::create_dir_all(&rtk_dir) .with_context(|| format!("Failed to create directory: {}", rtk_dir.display()))?; fs::write(&path, FILTERS_GLOBAL_TEMPLATE) @@ -1133,6 +1334,7 @@ fn run_hook_only_mode( _patch_mode: PatchMode, _verbose: u8, _install_opencode: bool, + _dry_run: bool, ) -> Result<()> { anyhow::bail!("Hook install requires Unix (macOS/Linux). Use WSL or --claude-md mode.") } @@ -1143,6 +1345,7 @@ fn run_hook_only_mode( patch_mode: PatchMode, verbose: u8, install_opencode: bool, + dry_run: bool, ) -> Result<()> { if !global { eprintln!("[warn] Warning: --hook-only only makes sense with --global"); @@ -1151,61 +1354,82 @@ fn run_hook_only_mode( } // Migrate old hook script if present - migrate_old_hook_script(verbose); + migrate_old_hook_script(verbose, dry_run); let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&path, verbose)?; + ensure_opencode_plugin_installed(&path, verbose, dry_run)?; Some(path) } else { None }; - println!("\nRTK hook registered (hook-only mode).\n"); - println!(" Command: {}", CLAUDE_HOOK_COMMAND); - if let Some(path) = &opencode_plugin_path { - println!(" OpenCode: {}", path.display()); + if !dry_run { + println!("\nRTK hook registered (hook-only mode).\n"); + println!(" Command: {}", CLAUDE_HOOK_COMMAND); + if let Some(path) = &opencode_plugin_path { + println!(" OpenCode: {}", path.display()); + } + println!( + " Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)." + ); } - println!( - " Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)." - ); // Patch settings.json with binary command - let patch_result = - patch_settings_json_command(CLAUDE_HOOK_COMMAND, patch_mode, verbose, install_opencode)?; + let patch_result = patch_settings_json_command( + CLAUDE_HOOK_COMMAND, + patch_mode, + verbose, + install_opencode, + dry_run, + )?; // Report result - match patch_result { - PatchResult::Patched => { - // Already printed by patch_settings_json_command - } - PatchResult::AlreadyPresent => { - println!("\n settings.json: hook already present"); - if install_opencode { - println!(" Restart Claude Code and OpenCode. Test with: git status"); - } else { - println!(" Restart Claude Code. Test with: git status"); + if !dry_run { + match patch_result { + PatchResult::Patched => { + // Already printed by patch_settings_json_command + } + PatchResult::AlreadyPresent => { + println!("\n settings.json: hook already present"); + if install_opencode { + println!(" Restart Claude Code and OpenCode. Test with: git status"); + } else { + println!(" Restart Claude Code. Test with: git status"); + } + } + PatchResult::Declined | PatchResult::Skipped => { + // Manual instructions already printed + } + PatchResult::WouldPatch => { + // Cannot happen outside dry_run } - } - PatchResult::Declined | PatchResult::Skipped => { - // Manual instructions already printed } } - println!(); // Final newline + if dry_run { + print_dry_run_footer(); + } else { + println!(); // Final newline + } Ok(()) } /// Legacy mode: full 137-line injection into CLAUDE.md -fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Result<()> { +fn run_claude_md_mode( + global: bool, + verbose: u8, + install_opencode: bool, + dry_run: bool, +) -> Result<()> { let path = if global { resolve_claude_dir()?.join(CLAUDE_MD) } else { PathBuf::from(CLAUDE_MD) }; - if global { + if global && !dry_run { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } @@ -1222,18 +1446,31 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu match action { RtkBlockUpsert::Added => { - fs::write(&path, new_content)?; - println!("[ok] Added rtk instructions to existing {}", path.display()); + if dry_run { + println!("[dry-run] would add rtk instructions to {}", path.display()); + } else { + fs::write(&path, new_content)?; + println!("[ok] Added rtk instructions to existing {}", path.display()); + } } RtkBlockUpsert::Updated => { - fs::write(&path, new_content)?; - println!("[ok] Updated rtk instructions in {}", path.display()); + if dry_run { + println!( + "[dry-run] would update rtk instructions in {}", + path.display() + ); + } else { + fs::write(&path, new_content)?; + println!("[ok] Updated rtk instructions in {}", path.display()); + } } RtkBlockUpsert::Unchanged => { - println!( - "[ok] {} already contains up-to-date rtk instructions", - path.display() - ); + if !dry_run { + println!( + "[ok] {} already contains up-to-date rtk instructions", + path.display() + ); + } return Ok(()); } RtkBlockUpsert::Malformed => { @@ -1259,6 +1496,11 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu return Ok(()); } } + } else if dry_run { + println!( + "[dry-run] would create {} with rtk instructions", + path.display() + ); } else { fs::write(&path, RTK_INSTRUCTIONS)?; println!("[ok] Created {} with rtk instructions", path.display()); @@ -1267,17 +1509,25 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu if global { if install_opencode { let opencode_plugin_path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&opencode_plugin_path, verbose)?; - println!( - "[ok] OpenCode plugin installed: {}", - opencode_plugin_path.display() - ); + ensure_opencode_plugin_installed(&opencode_plugin_path, verbose, dry_run)?; + if !dry_run { + println!( + "[ok] OpenCode plugin installed: {}", + opencode_plugin_path.display() + ); + } } - println!(" Claude Code will now use rtk in all sessions"); - } else { + if !dry_run { + println!(" Claude Code will now use rtk in all sessions"); + } + } else if !dry_run { println!(" Claude Code will use rtk in this project"); } + if dry_run { + print_dry_run_footer(); + } + Ok(()) } @@ -1291,61 +1541,93 @@ const CLINE_RULES: &str = include_str!("../../hooks/cline/rules.md"); // ─── Cline / Roo Code support ───────────────────────────────── -fn run_cline_mode(verbose: u8) -> Result<()> { +fn run_cline_mode(verbose: u8, dry_run: bool) -> Result<()> { // Cline reads .clinerules from the project root (workspace-scoped) let rules_path = PathBuf::from(".clinerules"); let existing = fs::read_to_string(&rules_path).unwrap_or_default(); if existing.contains("RTK") || existing.contains("rtk") { - println!("\nRTK already configured for Cline in this project.\n"); - println!(" Rules: .clinerules (already present)"); + if !dry_run { + println!("\nRTK already configured for Cline in this project.\n"); + println!(" Rules: .clinerules (already present)"); + } } else { let new_content = if existing.trim().is_empty() { CLINE_RULES.to_string() } else { format!("{}\n\n{}", existing.trim(), CLINE_RULES) }; - fs::write(&rules_path, &new_content).context("Failed to write .clinerules")?; + if dry_run { + println!( + "[dry-run] would write .clinerules: {}", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::write(&rules_path, &new_content).context("Failed to write .clinerules")?; - if verbose > 0 { - eprintln!("Wrote .clinerules"); - } + if verbose > 0 { + eprintln!("Wrote .clinerules"); + } - println!("\nRTK configured for Cline.\n"); - println!(" Rules: .clinerules (installed)"); + println!("\nRTK configured for Cline.\n"); + println!(" Rules: .clinerules (installed)"); + } + } + if dry_run { + print_dry_run_footer(); + } else { + println!(" Cline will now use rtk commands for token savings."); + println!(" Test with: git status\n"); } - println!(" Cline will now use rtk commands for token savings."); - println!(" Test with: git status\n"); Ok(()) } -fn run_windsurf_mode(verbose: u8) -> Result<()> { +fn run_windsurf_mode(verbose: u8, dry_run: bool) -> Result<()> { // Windsurf reads .windsurfrules from the project root (workspace-scoped). // Global rules (~/.codeium/windsurf/memories/global_rules.md) are unreliable. let rules_path = PathBuf::from(".windsurfrules"); let existing = fs::read_to_string(&rules_path).unwrap_or_default(); if existing.contains("RTK") || existing.contains("rtk") { - println!("\nRTK already configured for Windsurf in this project.\n"); - println!(" Rules: .windsurfrules (already present)"); + if !dry_run { + println!("\nRTK already configured for Windsurf in this project.\n"); + println!(" Rules: .windsurfrules (already present)"); + } } else { let new_content = if existing.trim().is_empty() { WINDSURF_RULES.to_string() } else { format!("{}\n\n{}", existing.trim(), WINDSURF_RULES) }; - fs::write(&rules_path, &new_content).context("Failed to write .windsurfrules")?; + if dry_run { + println!( + "[dry-run] would write .windsurfrules: {}", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::write(&rules_path, &new_content).context("Failed to write .windsurfrules")?; - if verbose > 0 { - eprintln!("Wrote .windsurfrules"); - } + if verbose > 0 { + eprintln!("Wrote .windsurfrules"); + } - println!("\nRTK configured for Windsurf Cascade.\n"); - println!(" Rules: .windsurfrules (installed)"); + println!("\nRTK configured for Windsurf Cascade.\n"); + println!(" Rules: .windsurfrules (installed)"); + } + } + if dry_run { + print_dry_run_footer(); + } else { + println!(" Cascade will now use rtk commands for token savings."); + println!(" Restart Windsurf. Test with: git status\n"); } - println!(" Cascade will now use rtk commands for token savings."); - println!(" Restart Windsurf. Test with: git status\n"); Ok(()) } @@ -1354,38 +1636,55 @@ fn run_windsurf_mode(verbose: u8) -> Result<()> { const KILOCODE_RULES: &str = include_str!("../../hooks/kilocode/rules.md"); -pub fn run_kilocode_mode(verbose: u8) -> Result<()> { - run_kilocode_mode_at(&std::env::current_dir()?, verbose) +pub fn run_kilocode_mode(verbose: u8, dry_run: bool) -> Result<()> { + run_kilocode_mode_at(&std::env::current_dir()?, verbose, dry_run) } -fn run_kilocode_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { +fn run_kilocode_mode_at(base_dir: &Path, verbose: u8, dry_run: bool) -> Result<()> { // Kilo Code reads .kilocode/rules/ from the project root (workspace-scoped) let target_dir = base_dir.join(".kilocode/rules"); let rules_path = target_dir.join("rtk-rules.md"); let existing = fs::read_to_string(&rules_path).unwrap_or_default(); if existing.contains("RTK") || existing.contains("rtk") { - println!("\nRTK already configured for Kilo Code in this project.\n"); - println!(" Rules: .kilocode/rules/rtk-rules.md (already present)"); + if !dry_run { + println!("\nRTK already configured for Kilo Code in this project.\n"); + println!(" Rules: .kilocode/rules/rtk-rules.md (already present)"); + } } else { - fs::create_dir_all(&target_dir).context("Failed to create .kilocode/rules directory")?; let new_content = if existing.trim().is_empty() { KILOCODE_RULES.to_string() } else { format!("{}\n\n{}", existing.trim(), KILOCODE_RULES) }; - fs::write(&rules_path, &new_content) - .context("Failed to write .kilocode/rules/rtk-rules.md")?; + if dry_run { + println!( + "[dry-run] would write {}: (and create parent dir if missing)", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::create_dir_all(&target_dir) + .context("Failed to create .kilocode/rules directory")?; + fs::write(&rules_path, &new_content) + .context("Failed to write .kilocode/rules/rtk-rules.md")?; - if verbose > 0 { - eprintln!("Wrote .kilocode/rules/rtk-rules.md"); - } + if verbose > 0 { + eprintln!("Wrote .kilocode/rules/rtk-rules.md"); + } - println!("\nRTK configured for Kilo Code.\n"); - println!(" Rules: .kilocode/rules/rtk-rules.md (installed)"); + println!("\nRTK configured for Kilo Code.\n"); + println!(" Rules: .kilocode/rules/rtk-rules.md (installed)"); + } + } + if dry_run { + print_dry_run_footer(); + } else { + println!(" Kilo Code will now use rtk commands for token savings."); + println!(" Test with: git status\n"); } - println!(" Kilo Code will now use rtk commands for token savings."); - println!(" Test with: git status\n"); Ok(()) } @@ -1394,43 +1693,59 @@ fn run_kilocode_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { const ANTIGRAVITY_RULES: &str = include_str!("../../hooks/antigravity/rules.md"); -pub fn run_antigravity_mode(verbose: u8) -> Result<()> { - run_antigravity_mode_at(&std::env::current_dir()?, verbose) +pub fn run_antigravity_mode(verbose: u8, dry_run: bool) -> Result<()> { + run_antigravity_mode_at(&std::env::current_dir()?, verbose, dry_run) } -fn run_antigravity_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { +fn run_antigravity_mode_at(base_dir: &Path, verbose: u8, dry_run: bool) -> Result<()> { // Antigravity reads .agents/rules/ from the project root (workspace-scoped) let target_dir = base_dir.join(".agents/rules"); let rules_path = target_dir.join("antigravity-rtk-rules.md"); let existing = fs::read_to_string(&rules_path).unwrap_or_default(); if existing.contains("RTK") || existing.contains("rtk") { - println!("\nRTK already configured for Antigravity in this project.\n"); - println!(" Rules: .agents/rules/antigravity-rtk-rules.md (already present)"); + if !dry_run { + println!("\nRTK already configured for Antigravity in this project.\n"); + println!(" Rules: .agents/rules/antigravity-rtk-rules.md (already present)"); + } } else { - fs::create_dir_all(&target_dir).context("Failed to create .agents/rules directory")?; let new_content = if existing.trim().is_empty() { ANTIGRAVITY_RULES.to_string() } else { format!("{}\n\n{}", existing.trim(), ANTIGRAVITY_RULES) }; - fs::write(&rules_path, &new_content) - .context("Failed to write .agents/rules/antigravity-rtk-rules.md")?; + if dry_run { + println!( + "[dry-run] would write {}: (and create parent dir if missing)", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::create_dir_all(&target_dir).context("Failed to create .agents/rules directory")?; + fs::write(&rules_path, &new_content) + .context("Failed to write .agents/rules/antigravity-rtk-rules.md")?; - if verbose > 0 { - eprintln!("Wrote .agents/rules/antigravity-rtk-rules.md"); - } + if verbose > 0 { + eprintln!("Wrote .agents/rules/antigravity-rtk-rules.md"); + } - println!("\nRTK configured for Google Antigravity.\n"); - println!(" Rules: .agents/rules/antigravity-rtk-rules.md (installed)"); + println!("\nRTK configured for Google Antigravity.\n"); + println!(" Rules: .agents/rules/antigravity-rtk-rules.md (installed)"); + } + } + if dry_run { + print_dry_run_footer(); + } else { + println!(" Antigravity will now use rtk commands for token savings."); + println!(" Test with: git status\n"); } - println!(" Antigravity will now use rtk commands for token savings."); - println!(" Test with: git status\n"); Ok(()) } -fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { +fn run_codex_mode(global: bool, verbose: u8, dry_run: bool) -> Result<()> { let (agents_md_path, rtk_md_path) = if global { let codex_dir = resolve_codex_dir()?; (codex_dir.join(AGENTS_MD), codex_dir.join(RTK_MD)) @@ -1438,7 +1753,7 @@ fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { (PathBuf::from(AGENTS_MD), PathBuf::from(RTK_MD)) }; - run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, verbose) + run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, verbose, dry_run) } fn run_codex_mode_with_paths( @@ -1446,8 +1761,9 @@ fn run_codex_mode_with_paths( rtk_md_path: PathBuf, global: bool, verbose: u8, + dry_run: bool, ) -> Result<()> { - if global { + if global && !dry_run { if let Some(parent) = agents_md_path.parent() { fs::create_dir_all(parent).with_context(|| { format!( @@ -1471,26 +1787,30 @@ fn run_codex_mode_with_paths( RTK_MD_REF.to_string() }; - write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, RTK_MD, verbose)?; - let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, verbose)?; + write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, RTK_MD, verbose, dry_run)?; + let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, verbose, dry_run)?; - println!("\nRTK configured for Codex CLI.\n"); - println!(" RTK.md: {}", rtk_md_path.display()); - if added_ref { - println!(" AGENTS.md: {} reference added", rtk_md_ref); - } else { - println!(" AGENTS.md: {} reference already present", rtk_md_ref); - } - if global { - println!( - "\n Codex global instructions path: {}", - agents_md_path.display() - ); + if !dry_run { + println!("\nRTK configured for Codex CLI.\n"); + println!(" RTK.md: {}", rtk_md_path.display()); + if added_ref { + println!(" AGENTS.md: {} reference added", rtk_md_ref); + } else { + println!(" AGENTS.md: {} reference already present", rtk_md_ref); + } + if global { + println!( + "\n Codex global instructions path: {}", + agents_md_path.display() + ); + } else { + println!( + "\n Codex project instructions path: {}", + agents_md_path.display() + ); + } } else { - println!( - "\n Codex project instructions path: {}", - agents_md_path.display() - ); + print_dry_run_footer(); } Ok(()) @@ -1560,7 +1880,7 @@ fn upsert_rtk_block(content: &str, block: &str) -> (String, RtkBlockUpsert) { } /// Patch CLAUDE.md: add @RTK.md, migrate if old block exists -fn patch_claude_md(path: &Path, verbose: u8) -> Result { +fn patch_claude_md(path: &Path, verbose: u8, dry_run: bool) -> Result { let mut content = if path.exists() { fs::read_to_string(path)? } else { @@ -1587,7 +1907,14 @@ fn patch_claude_md(path: &Path, verbose: u8) -> Result { eprintln!("@RTK.md reference already present in CLAUDE.md"); } if migrated { - fs::write(path, content)?; + if dry_run { + println!( + "[dry-run] would migrate old RTK block in CLAUDE.md: {}", + path.display() + ); + } else { + fs::write(path, content)?; + } } return Ok(migrated); } @@ -1599,17 +1926,27 @@ fn patch_claude_md(path: &Path, verbose: u8) -> Result { format!("{}\n\n@RTK.md\n", content.trim()) }; - fs::write(path, new_content)?; + if dry_run { + println!( + "[dry-run] would add @RTK.md reference to CLAUDE.md: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::write(path, new_content)?; - if verbose > 0 { - eprintln!("Added @RTK.md reference to CLAUDE.md"); + if verbose > 0 { + eprintln!("Added @RTK.md reference to CLAUDE.md"); + } } Ok(migrated) } /// Patch AGENTS.md: add @RTK.md (or absolute path), migrate old inline block if present -fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8) -> Result { +fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8, dry_run: bool) -> Result { let mut content = if path.exists() { fs::read_to_string(path) .with_context(|| format!("Failed to read AGENTS.md: {}", path.display()))? @@ -1638,16 +1975,32 @@ fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8) -> Result { if rtk_md_ref != RTK_MD_REF && content.contains(RTK_MD_REF) && !content.contains(rtk_md_ref) { content = content.replace(RTK_MD_REF, rtk_md_ref); - atomic_write(path, &content) - .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; - if verbose > 0 { - eprintln!("Migrated {} to {}", RTK_MD_REF, rtk_md_ref); + if dry_run { + println!( + "[dry-run] would migrate {} to {} in {}", + RTK_MD_REF, + rtk_md_ref, + path.display() + ); + } else { + atomic_write(path, &content) + .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + if verbose > 0 { + eprintln!("Migrated {} to {}", RTK_MD_REF, rtk_md_ref); + } } return Ok(true); } if migrated { - atomic_write(path, &content) - .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + if dry_run { + println!( + "[dry-run] would write migrated AGENTS.md: {}", + path.display() + ); + } else { + atomic_write(path, &content) + .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + } } return Ok(false); } @@ -1658,10 +2011,21 @@ fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8) -> Result { format!("{}\n\n{}\n", content.trim(), rtk_md_ref) }; - atomic_write(path, &new_content) - .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; - if verbose > 0 { - eprintln!("Added {} reference to AGENTS.md", rtk_md_ref); + if dry_run { + println!( + "[dry-run] would add {} reference to AGENTS.md: {}", + rtk_md_ref, + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + if verbose > 0 { + eprintln!("Added {} reference to AGENTS.md", rtk_md_ref); + } } Ok(true) @@ -1674,7 +2038,12 @@ fn has_rtk_reference(content: &str, refs: &[&str]) -> bool { .any(|line| refs.contains(&line)) } -fn remove_rtk_reference_from_agents(path: &Path, refs: &[&str], verbose: u8) -> Result { +fn remove_rtk_reference_from_agents( + path: &Path, + refs: &[&str], + verbose: u8, + dry_run: bool, +) -> Result { if !path.exists() { return Ok(false); } @@ -1694,6 +2063,18 @@ fn remove_rtk_reference_from_agents(path: &Path, refs: &[&str], verbose: u8) -> .collect::>() .join("\n"); let cleaned = clean_double_blanks(&new_content); + + if dry_run { + println!( + "[dry-run] would remove RTK.md reference from AGENTS.md: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", cleaned); + } + return Ok(true); + } + atomic_write(path, &cleaned) .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; @@ -1792,33 +2173,41 @@ fn opencode_plugin_path(opencode_dir: &Path) -> PathBuf { fn prepare_opencode_plugin_path() -> Result { let opencode_dir = resolve_opencode_dir()?; let path = opencode_plugin_path(&opencode_dir); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create OpenCode plugin directory: {}", - parent.display() - ) - })?; - } + // Directory creation is deferred to install time (caller guards on dry_run). Ok(path) } /// Write OpenCode plugin file if missing or outdated -fn ensure_opencode_plugin_installed(path: &Path, verbose: u8) -> Result { - write_if_changed(path, OPENCODE_PLUGIN, "OpenCode plugin", verbose) +fn ensure_opencode_plugin_installed(path: &Path, verbose: u8, dry_run: bool) -> Result { + // Ensure parent dir exists (skip in dry-run) + if !dry_run { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create OpenCode plugin directory: {}", + parent.display() + ) + })?; + } + } + write_if_changed(path, OPENCODE_PLUGIN, "OpenCode plugin", verbose, dry_run) } /// Remove OpenCode plugin file -fn remove_opencode_plugin(verbose: u8) -> Result> { +fn remove_opencode_plugin(verbose: u8, dry_run: bool) -> Result> { let opencode_dir = resolve_opencode_dir()?; let path = opencode_plugin_path(&opencode_dir); let mut removed = Vec::new(); if path.exists() { - fs::remove_file(&path) - .with_context(|| format!("Failed to remove OpenCode plugin: {}", path.display()))?; - if verbose > 0 { - eprintln!("Removed OpenCode plugin: {}", path.display()); + if dry_run { + println!("[dry-run] would remove OpenCode plugin: {}", path.display()); + } else { + fs::remove_file(&path) + .with_context(|| format!("Failed to remove OpenCode plugin: {}", path.display()))?; + if verbose > 0 { + eprintln!("Removed OpenCode plugin: {}", path.display()); + } } removed.push(path); } @@ -1833,22 +2222,30 @@ fn resolve_cursor_dir() -> Result { } /// Install Cursor hooks: register binary command in hooks.json -fn install_cursor_hooks(verbose: u8) -> Result<()> { +fn install_cursor_hooks(verbose: u8, dry_run: bool) -> Result<()> { let cursor_dir = resolve_cursor_dir()?; // Migrate old hook script if present let old_hook = cursor_dir.join("hooks").join(REWRITE_HOOK_FILE); if old_hook.exists() { - let _ = fs::remove_file(&old_hook); - if verbose > 0 { - eprintln!( - " [ok] Removed old Cursor hook script: {}", + if dry_run { + println!( + "[dry-run] would remove old Cursor hook script: {}", old_hook.display() ); + } else { + let _ = fs::remove_file(&old_hook); + if verbose > 0 { + eprintln!( + " [ok] Removed old Cursor hook script: {}", + old_hook.display() + ); + } } // Clean stale hooks.json entry pointing to the deleted script let hooks_json_path = cursor_dir.join(HOOKS_JSON); - if let Err(e) = remove_legacy_cursor_hooks_json_entries(&hooks_json_path, verbose) { + if let Err(e) = remove_legacy_cursor_hooks_json_entries(&hooks_json_path, verbose, dry_run) + { if verbose > 0 { eprintln!(" [warn] Failed to clean legacy Cursor hooks.json entry: {e}"); } @@ -1857,27 +2254,31 @@ fn install_cursor_hooks(verbose: u8) -> Result<()> { // Create or patch hooks.json with binary command let hooks_json_path = cursor_dir.join(HOOKS_JSON); - let patched = patch_cursor_hooks_json(&hooks_json_path, verbose)?; + let patched = patch_cursor_hooks_json(&hooks_json_path, verbose, dry_run)?; + + // Report (skip in dry-run) + if !dry_run { + println!("\nCursor hook registered (global).\n"); + println!(" Command: {}", CURSOR_HOOK_COMMAND); + println!(" hooks.json: {}", hooks_json_path.display()); - // Report - println!("\nCursor hook registered (global).\n"); - println!(" Command: {}", CURSOR_HOOK_COMMAND); - println!(" hooks.json: {}", hooks_json_path.display()); + if patched { + println!(" hooks.json: RTK preToolUse entry added"); + } else { + println!(" hooks.json: RTK preToolUse entry already present"); + } - if patched { - println!(" hooks.json: RTK preToolUse entry added"); + println!(" Cursor reloads hooks.json automatically. Test with: git status\n"); } else { - println!(" hooks.json: RTK preToolUse entry already present"); + print_dry_run_footer(); } - println!(" Cursor reloads hooks.json automatically. Test with: git status\n"); - Ok(()) } /// Patch ~/.cursor/hooks.json to add RTK preToolUse hook. /// Returns true if the file was modified. -fn patch_cursor_hooks_json(path: &Path, verbose: u8) -> Result { +fn patch_cursor_hooks_json(path: &Path, verbose: u8, dry_run: bool) -> Result { let mut root = if path.exists() { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; @@ -1901,6 +2302,20 @@ fn patch_cursor_hooks_json(path: &Path, verbose: u8) -> Result { insert_cursor_hook_entry(&mut root)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; + + if dry_run { + println!( + "[dry-run] would patch Cursor hooks.json: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + // Backup if exists if path.exists() { let backup_path = path.with_extension("json.bak"); @@ -1912,8 +2327,6 @@ fn patch_cursor_hooks_json(path: &Path, verbose: u8) -> Result { } // Atomic write - let serialized = - serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; atomic_write(path, &serialized)?; Ok(true) @@ -1972,7 +2385,7 @@ fn insert_cursor_hook_entry(root: &mut serde_json::Value) -> Result<()> { /// Remove only legacy `rtk-rewrite.sh` entries from Cursor hooks.json. /// Preserves any existing `rtk hook cursor` entries (new format). -fn remove_legacy_cursor_hooks_json_entries(path: &Path, verbose: u8) -> Result<()> { +fn remove_legacy_cursor_hooks_json_entries(path: &Path, verbose: u8, dry_run: bool) -> Result<()> { if !path.exists() { return Ok(()); } @@ -1990,6 +2403,14 @@ fn remove_legacy_cursor_hooks_json_entries(path: &Path, verbose: u8) -> Result<( return Ok(()); } + if dry_run { + println!( + "[dry-run] would remove legacy rtk-rewrite.sh entry from Cursor hooks.json: {}", + path.display() + ); + return Ok(()); + } + let serialized = serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; atomic_write(path, &serialized)?; @@ -2025,15 +2446,23 @@ fn remove_legacy_cursor_hook_entries_from_json(root: &mut serde_json::Value) -> } /// Remove Cursor RTK artifacts: hook script + hooks.json entry -fn remove_cursor_hooks(verbose: u8) -> Result> { +fn remove_cursor_hooks(verbose: u8, dry_run: bool) -> Result> { let cursor_dir = resolve_cursor_dir()?; let mut removed = Vec::new(); // 1. Remove hook script let hook_path = cursor_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove Cursor hook: {}", hook_path.display()))?; + if dry_run { + println!( + "[dry-run] would remove Cursor hook: {}", + hook_path.display() + ); + } else { + fs::remove_file(&hook_path).with_context(|| { + format!("Failed to remove Cursor hook: {}", hook_path.display()) + })?; + } removed.push(format!("Cursor hook: {}", hook_path.display())); } @@ -2046,18 +2475,24 @@ fn remove_cursor_hooks(verbose: u8) -> Result> { if !content.trim().is_empty() { if let Ok(mut root) = serde_json::from_str::(&content) { if remove_cursor_hook_from_json(&mut root) { - let backup_path = hooks_json_path.with_extension("json.bak"); - fs::copy(&hooks_json_path, &backup_path).ok(); - - let serialized = serde_json::to_string_pretty(&root) - .context("Failed to serialize hooks.json")?; - atomic_write(&hooks_json_path, &serialized)?; - - removed.push("Cursor hooks.json: removed RTK entry".to_string()); - - if verbose > 0 { - eprintln!("Removed RTK hook from Cursor hooks.json"); + if dry_run { + println!( + "[dry-run] would remove RTK entry from Cursor hooks.json: {}", + hooks_json_path.display() + ); + } else { + let backup_path = hooks_json_path.with_extension("json.bak"); + fs::copy(&hooks_json_path, &backup_path).ok(); + + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize hooks.json")?; + atomic_write(&hooks_json_path, &serialized)?; + + if verbose > 0 { + eprintln!("Removed RTK hook from Cursor hooks.json"); + } } + removed.push("Cursor hooks.json: removed RTK entry".to_string()); } } } @@ -2386,12 +2821,16 @@ fn show_codex_config() -> Result<()> { Ok(()) } -fn run_opencode_only_mode(verbose: u8) -> Result<()> { +fn run_opencode_only_mode(verbose: u8, dry_run: bool) -> Result<()> { let opencode_plugin_path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&opencode_plugin_path, verbose)?; - println!("\nOpenCode plugin installed (global).\n"); - println!(" OpenCode: {}", opencode_plugin_path.display()); - println!(" Restart OpenCode. Test with: git status\n"); + ensure_opencode_plugin_installed(&opencode_plugin_path, verbose, dry_run)?; + if dry_run { + print_dry_run_footer(); + } else { + println!("\nOpenCode plugin installed (global).\n"); + println!(" OpenCode: {}", opencode_plugin_path.display()); + println!(" Restart OpenCode. Test with: git status\n"); + } Ok(()) } @@ -2407,53 +2846,76 @@ fn resolve_gemini_dir() -> Result { } /// Entry point for `rtk init --gemini` -pub fn run_gemini(global: bool, hook_only: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { +pub fn run_gemini( + global: bool, + hook_only: bool, + patch_mode: PatchMode, + verbose: u8, + dry_run: bool, +) -> Result<()> { if !global { anyhow::bail!("Gemini support is global-only. Use: rtk init -g --gemini"); } let gemini_dir = resolve_gemini_dir()?; - fs::create_dir_all(&gemini_dir).with_context(|| { - format!( - "Failed to create Gemini config dir: {}", - gemini_dir.display() - ) - })?; + if !dry_run { + fs::create_dir_all(&gemini_dir).with_context(|| { + format!( + "Failed to create Gemini config dir: {}", + gemini_dir.display() + ) + })?; + } // 1. Install hook script let hook_dir = gemini_dir.join("hooks"); - fs::create_dir_all(&hook_dir) - .with_context(|| format!("Failed to create hook dir: {}", hook_dir.display()))?; + if !dry_run { + fs::create_dir_all(&hook_dir) + .with_context(|| format!("Failed to create hook dir: {}", hook_dir.display()))?; + } let hook_path = hook_dir.join(GEMINI_HOOK_FILE); - write_if_changed(&hook_path, GEMINI_HOOK_SCRIPT, "Gemini hook", verbose)?; + write_if_changed( + &hook_path, + GEMINI_HOOK_SCRIPT, + "Gemini hook", + verbose, + dry_run, + )?; #[cfg(unix)] - { + if !dry_run { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)) .with_context(|| format!("Failed to set hook permissions: {}", hook_path.display()))?; } - // Store integrity baseline for tamper detection - integrity::store_hash(&hook_path) - .with_context(|| format!("Failed to store integrity hash for {}", hook_path.display()))?; + // Store integrity baseline for tamper detection (skip in dry-run) + if !dry_run { + integrity::store_hash(&hook_path).with_context(|| { + format!("Failed to store integrity hash for {}", hook_path.display()) + })?; + } // 2. Install GEMINI.md (RTK awareness for Gemini) if !hook_only { let gemini_md_path = gemini_dir.join(GEMINI_MD); // Reuse the same slim RTK awareness content - write_if_changed(&gemini_md_path, RTK_SLIM, GEMINI_MD, verbose)?; + write_if_changed(&gemini_md_path, RTK_SLIM, GEMINI_MD, verbose, dry_run)?; } // 3. Patch ~/.gemini/settings.json - patch_gemini_settings(&gemini_dir, &hook_path, patch_mode, verbose)?; + patch_gemini_settings(&gemini_dir, &hook_path, patch_mode, verbose, dry_run)?; - println!("\nGemini CLI hook installed (global).\n"); - println!(" Hook: {}", hook_path.display()); - if !hook_only { - println!(" GEMINI.md: {}", gemini_dir.join(GEMINI_MD).display()); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nGemini CLI hook installed (global).\n"); + println!(" Hook: {}", hook_path.display()); + if !hook_only { + println!(" GEMINI.md: {}", gemini_dir.join(GEMINI_MD).display()); + } + println!(" Restart Gemini CLI. Test with: git status\n"); } - println!(" Restart Gemini CLI. Test with: git status\n"); Ok(()) } @@ -2463,6 +2925,7 @@ fn patch_gemini_settings( hook_path: &Path, patch_mode: PatchMode, verbose: u8, + dry_run: bool, ) -> Result<()> { let settings_path = gemini_dir.join(SETTINGS_JSON); let hook_cmd = hook_path.to_string_lossy().to_string(); @@ -2503,13 +2966,20 @@ fn patch_gemini_settings( } if patch_mode == PatchMode::Ask { - print!("Patch {} with RTK hook? [y/N] ", settings_path.display()); - std::io::Write::flush(&mut std::io::stdout())?; - let mut answer = String::new(); - std::io::stdin().read_line(&mut answer)?; - if !answer.trim().eq_ignore_ascii_case("y") { - println!("Skipped. Add hook manually later."); - return Ok(()); + if dry_run { + println!( + "[dry-run] would prompt before patching {}", + settings_path.display() + ); + } else { + print!("Patch {} with RTK hook? [y/N] ", settings_path.display()); + std::io::Write::flush(&mut std::io::stdout())?; + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + if !answer.trim().eq_ignore_ascii_case("y") { + println!("Skipped. Add hook manually later."); + return Ok(()); + } } } @@ -2540,8 +3010,20 @@ fn patch_gemini_settings( .context("BeforeTool is not an array")? .push(hook_entry); - // Write atomically let content = serde_json::to_string_pretty(&settings)?; + + if dry_run { + println!( + "[dry-run] would patch Gemini settings.json: {}", + settings_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", content); + } + return Ok(()); + } + + // Write atomically let tmp = NamedTempFile::new_in(gemini_dir)?; fs::write(tmp.path(), &content)?; tmp.persist(&settings_path) @@ -2555,7 +3037,7 @@ fn patch_gemini_settings( } /// Remove Gemini artifacts during uninstall -fn uninstall_gemini(verbose: u8) -> Result> { +fn uninstall_gemini(verbose: u8, dry_run: bool) -> Result> { let mut removed = Vec::new(); let gemini_dir = match resolve_gemini_dir() { Ok(d) => d, @@ -2565,16 +3047,27 @@ fn uninstall_gemini(verbose: u8) -> Result> { // Remove hook let hook_path = gemini_dir.join(HOOKS_SUBDIR).join(GEMINI_HOOK_FILE); if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove {}", hook_path.display()))?; + if dry_run { + println!( + "[dry-run] would remove Gemini hook: {}", + hook_path.display() + ); + } else { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove {}", hook_path.display()))?; + } removed.push(format!("Gemini hook: {}", hook_path.display())); } // Remove GEMINI.md let gemini_md = gemini_dir.join(GEMINI_MD); if gemini_md.exists() { - fs::remove_file(&gemini_md) - .with_context(|| format!("Failed to remove {}", gemini_md.display()))?; + if dry_run { + println!("[dry-run] would remove GEMINI.md: {}", gemini_md.display()); + } else { + fs::remove_file(&gemini_md) + .with_context(|| format!("Failed to remove {}", gemini_md.display()))?; + } removed.push(format!("GEMINI.md: {}", gemini_md.display())); } @@ -2595,8 +3088,15 @@ fn uninstall_gemini(verbose: u8) -> Result> { .is_some_and(|c| c.contains("rtk")) }); if arr.len() < before { - let new_content = serde_json::to_string_pretty(&settings)?; - fs::write(&settings_path, new_content)?; + if dry_run { + println!( + "[dry-run] would remove RTK hook from Gemini settings.json: {}", + settings_path.display() + ); + } else { + let new_content = serde_json::to_string_pretty(&settings)?; + fs::write(&settings_path, new_content)?; + } removed.push("Gemini settings.json: removed RTK hook entry".to_string()); } } @@ -2654,12 +3154,14 @@ rtk proxy # Run raw (no filtering) but track usage "#; /// Entry point for `rtk init --copilot` -pub fn run_copilot(verbose: u8) -> Result<()> { +pub fn run_copilot(verbose: u8, dry_run: bool) -> Result<()> { // Install in current project's .github/ directory let github_dir = Path::new(".github"); let hooks_dir = github_dir.join("hooks"); - fs::create_dir_all(&hooks_dir).context("Failed to create .github/hooks/ directory")?; + if !dry_run { + fs::create_dir_all(&hooks_dir).context("Failed to create .github/hooks/ directory")?; + } // 1. Write hook config let hook_path = hooks_dir.join("rtk-rewrite.json"); @@ -2668,6 +3170,7 @@ pub fn run_copilot(verbose: u8) -> Result<()> { COPILOT_HOOK_JSON, "Copilot hook config", verbose, + dry_run, )?; // 2. Write instructions @@ -2677,14 +3180,19 @@ pub fn run_copilot(verbose: u8) -> Result<()> { COPILOT_INSTRUCTIONS, "Copilot instructions", verbose, + dry_run, )?; - println!("\nGitHub Copilot integration installed (project-scoped).\n"); - println!(" Hook config: {}", hook_path.display()); - println!(" Instructions: {}", instructions_path.display()); - println!("\n Works with VS Code Copilot Chat (transparent rewrite)"); - println!(" and Copilot CLI (deny-with-suggestion)."); - println!("\n Restart your IDE or Copilot CLI session to activate.\n"); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nGitHub Copilot integration installed (project-scoped).\n"); + println!(" Hook config: {}", hook_path.display()); + println!(" Instructions: {}", instructions_path.display()); + println!("\n Works with VS Code Copilot Chat (transparent rewrite)"); + println!(" and Copilot CLI (deny-with-suggestion)."); + println!("\n Restart your IDE or Copilot CLI session to activate.\n"); + } Ok(()) } @@ -2754,13 +3262,13 @@ More content"#; fs::create_dir_all(plugin_path.parent().unwrap()).unwrap(); assert!(!plugin_path.exists()); - let changed = ensure_opencode_plugin_installed(&plugin_path, 0).unwrap(); + let changed = ensure_opencode_plugin_installed(&plugin_path, 0, false).unwrap(); assert!(changed); let content = fs::read_to_string(&plugin_path).unwrap(); assert_eq!(content, OPENCODE_PLUGIN); fs::write(&plugin_path, "// old").unwrap(); - let changed_again = ensure_opencode_plugin_installed(&plugin_path, 0).unwrap(); + let changed_again = ensure_opencode_plugin_installed(&plugin_path, 0, false).unwrap(); assert!(changed_again); let content_updated = fs::read_to_string(&plugin_path).unwrap(); assert_eq!(content_updated, OPENCODE_PLUGIN); @@ -2875,8 +3383,8 @@ More notes let agents_md = temp.path().join("AGENTS.md"); fs::write(&agents_md, "# Team rules\n").unwrap(); - let first_added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); - let second_added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + let first_added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); + let second_added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); assert!(first_added); assert!(!second_added); @@ -2899,6 +3407,7 @@ More notes true, PatchMode::Auto, 0, + false, ) .unwrap_err(); assert_eq!( @@ -2921,6 +3430,7 @@ More notes true, PatchMode::Skip, 0, + false, ) .unwrap_err(); assert_eq!( @@ -2932,7 +3442,7 @@ More notes #[test] fn test_kilocode_mode_creates_rules_file() { let temp = TempDir::new().unwrap(); - run_kilocode_mode_at(temp.path(), 0).unwrap(); + run_kilocode_mode_at(temp.path(), 0, false).unwrap(); let rules_path = temp.path().join(".kilocode/rules/rtk-rules.md"); assert!(rules_path.exists(), "Rules file should be created"); @@ -2943,13 +3453,13 @@ More notes #[test] fn test_kilocode_mode_is_idempotent() { let temp = TempDir::new().unwrap(); - run_kilocode_mode_at(temp.path(), 0).unwrap(); + run_kilocode_mode_at(temp.path(), 0, false).unwrap(); let path = temp.path().join(".kilocode/rules/rtk-rules.md"); let first = fs::read_to_string(&path).unwrap(); // Second run should not overwrite - run_kilocode_mode_at(temp.path(), 0).unwrap(); + run_kilocode_mode_at(temp.path(), 0, false).unwrap(); let second = fs::read_to_string(&path).unwrap(); assert_eq!(first, second, "Idempotent: content should not change"); } @@ -2957,7 +3467,7 @@ More notes #[test] fn test_antigravity_mode_creates_rules_file() { let temp = TempDir::new().unwrap(); - run_antigravity_mode_at(temp.path(), 0).unwrap(); + run_antigravity_mode_at(temp.path(), 0, false).unwrap(); let rules_path = temp.path().join(".agents/rules/antigravity-rtk-rules.md"); assert!(rules_path.exists(), "Rules file should be created"); @@ -2968,13 +3478,13 @@ More notes #[test] fn test_antigravity_mode_is_idempotent() { let temp = TempDir::new().unwrap(); - run_antigravity_mode_at(temp.path(), 0).unwrap(); + run_antigravity_mode_at(temp.path(), 0, false).unwrap(); let path = temp.path().join(".agents/rules/antigravity-rtk-rules.md"); let first = fs::read_to_string(&path).unwrap(); // Second run should not overwrite - run_antigravity_mode_at(temp.path(), 0).unwrap(); + run_antigravity_mode_at(temp.path(), 0, false).unwrap(); let second = fs::read_to_string(&path).unwrap(); assert_eq!(first, second, "Idempotent: content should not change"); } @@ -2984,7 +3494,7 @@ More notes let temp = TempDir::new().unwrap(); let agents_md = temp.path().join("AGENTS.md"); - let added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + let added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); assert!(added); let content = fs::read_to_string(&agents_md).unwrap(); @@ -3001,7 +3511,7 @@ More notes ) .unwrap(); - let added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + let added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); assert!(added); let content = fs::read_to_string(&agents_md).unwrap(); @@ -3015,7 +3525,7 @@ More notes let agents_md = temp.path().join("AGENTS.md"); let rtk_md = temp.path().join("RTK.md"); - run_codex_mode_with_paths(agents_md.clone(), rtk_md.clone(), true, 0).unwrap(); + run_codex_mode_with_paths(agents_md.clone(), rtk_md.clone(), true, 0, false).unwrap(); assert!(rtk_md.exists()); assert_eq!(fs::read_to_string(&rtk_md).unwrap(), RTK_SLIM_CODEX); @@ -3051,8 +3561,8 @@ More notes fs::write(&agents_md, "# Team rules\n\n@RTK.md\n").unwrap(); fs::write(&rtk_md, "codex config").unwrap(); - let removed_first = uninstall_codex_at(codex_dir, 0).unwrap(); - let removed_second = uninstall_codex_at(codex_dir, 0).unwrap(); + let removed_first = uninstall_codex_at(codex_dir, 0, false).unwrap(); + let removed_second = uninstall_codex_at(codex_dir, 0, false).unwrap(); assert_eq!(removed_first.len(), 2); assert!(removed_second.is_empty()); @@ -3074,7 +3584,7 @@ More notes fs::write(&agents_md, format!("# Team rules\n\n{}\n", absolute_ref)).unwrap(); fs::write(&rtk_md, "codex config").unwrap(); - let removed = uninstall_codex_at(codex_dir, 0).unwrap(); + let removed = uninstall_codex_at(codex_dir, 0, false).unwrap(); assert_eq!(removed.len(), 2); let content = fs::read_to_string(&agents_md).unwrap(); @@ -3082,6 +3592,60 @@ More notes assert!(content.contains("# Team rules")); } + #[test] + fn test_write_if_changed_dry_run_does_not_create_file() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("rtk-test.md"); + + let changed = write_if_changed(&target, "some content", "test file", 0, true).unwrap(); + + assert!( + changed, + "dry-run should report would-change for missing file" + ); + assert!( + !target.exists(), + "dry-run must not create file: {}", + target.display() + ); + } + + #[test] + fn test_write_if_changed_dry_run_does_not_modify_existing_file() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("rtk-test.md"); + fs::write(&target, "original").unwrap(); + + let changed = write_if_changed(&target, "new content", "test file", 0, true).unwrap(); + + assert!(changed, "dry-run should report would-change"); + assert_eq!( + fs::read_to_string(&target).unwrap(), + "original", + "dry-run must not modify file contents" + ); + } + + #[test] + fn test_run_codex_mode_dry_run_writes_nothing() { + let temp = TempDir::new().unwrap(); + let agents_md = temp.path().join("AGENTS.md"); + let rtk_md = temp.path().join("RTK.md"); + + run_codex_mode_with_paths(agents_md.clone(), rtk_md.clone(), true, 0, true).unwrap(); + + assert!( + !rtk_md.exists(), + "dry-run must not create RTK.md: {}", + rtk_md.display() + ); + assert!( + !agents_md.exists(), + "dry-run must not create AGENTS.md: {}", + agents_md.display() + ); + } + #[test] fn test_local_init_unchanged() { // Local init should use claude-md mode diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index 5b0721d0f4..1101fe26a9 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -53,6 +53,11 @@ fn hash_path(hook_path: &Path) -> PathBuf { .join(HASH_FILENAME) } +/// Public accessor for the hash sidecar path (used by dry-run existence checks). +pub fn hash_path_for(hook_path: &Path) -> PathBuf { + hash_path(hook_path) +} + /// Store SHA-256 hash of the hook script after installation. /// /// Format is compatible with `sha256sum -c`: diff --git a/src/main.rs b/src/main.rs index e8a19c2be2..668f585ba1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -356,6 +356,10 @@ enum Commands { /// Install GitHub Copilot integration (VS Code + CLI) #[arg(long)] copilot: bool, + + /// Preview changes without writing any files (combine with -v to show content) + #[arg(long = "dry-run")] + dry_run: bool, }, /// Download with compact output (strips progress bars) @@ -1716,12 +1720,13 @@ fn run_cli() -> Result { uninstall, codex, copilot, + dry_run, } => { if show { hooks::init::show_config(codex)?; } else if uninstall { let cursor = agent == Some(AgentTarget::Cursor); - hooks::init::uninstall(global, gemini, codex, cursor, cli.verbose)?; + hooks::init::uninstall(global, gemini, codex, cursor, cli.verbose, dry_run)?; } else if gemini { let patch_mode = if auto_patch { hooks::init::PatchMode::Auto @@ -1730,21 +1735,21 @@ fn run_cli() -> Result { } else { hooks::init::PatchMode::Ask }; - hooks::init::run_gemini(global, hook_only, patch_mode, cli.verbose)?; + hooks::init::run_gemini(global, hook_only, patch_mode, cli.verbose, dry_run)?; } else if copilot { - hooks::init::run_copilot(cli.verbose)?; + hooks::init::run_copilot(cli.verbose, dry_run)?; } else if agent == Some(AgentTarget::Kilocode) { if global { anyhow::bail!("Kilo Code is project-scoped. Use: rtk init --agent kilocode"); } - hooks::init::run_kilocode_mode(cli.verbose)?; + hooks::init::run_kilocode_mode(cli.verbose, dry_run)?; } else if agent == Some(AgentTarget::Antigravity) { if global { anyhow::bail!( "Antigravity is project-scoped. Use: rtk init --agent antigravity" ); } - hooks::init::run_antigravity_mode(cli.verbose)?; + hooks::init::run_antigravity_mode(cli.verbose, dry_run)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -1771,6 +1776,7 @@ fn run_cli() -> Result { codex, patch_mode, cli.verbose, + dry_run, )?; } 0 From add14643886e1b2c5450056433e577c003872e4d Mon Sep 17 00:00:00 2001 From: hed0rah <18272116+hed0rah@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:42:16 -0400 Subject: [PATCH 02/33] fix(tests): update init.rs test calls for dry_run parameter --- src/hooks/init.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 8c0ce12c5a..2ba0752fe2 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -4230,7 +4230,7 @@ More notes fn test_global_default_mode_creates_artifacts() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); + run_default_mode(true, PatchMode::Auto, 0, false, false).unwrap(); assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created"); assert!( @@ -4252,8 +4252,8 @@ More notes fn test_global_uninstall_removes_artifacts() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); - uninstall(true, false, false, false, 0).unwrap(); + run_default_mode(true, PatchMode::Auto, 0, false, false).unwrap(); + uninstall(true, false, false, false, 0, false).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -4269,8 +4269,8 @@ More notes fn test_global_default_mode_idempotent() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); + run_default_mode(true, PatchMode::Auto, 0, false, false).unwrap(); + run_default_mode(true, PatchMode::Auto, 0, false, false).unwrap(); let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); let count = settings.matches(CLAUDE_HOOK_COMMAND).count(); @@ -4282,14 +4282,14 @@ More notes fn test_upgrade_from_claude_md_to_hook_mode() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_claude_md_mode(true, 0, false).unwrap(); + run_claude_md_mode(true, 0, false, false).unwrap(); let claude_md_content = fs::read_to_string(claude_dir.join(CLAUDE_MD)).unwrap(); assert!( claude_md_content.contains(" ") { @@ -1194,7 +1195,7 @@ fn filter_cargo_clippy(output: &str) -> String { // Sort warning rules by frequency let mut rule_counts: Vec<_> = by_rule.iter().collect(); - rule_counts.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + rule_counts.sort_by_key(|b| std::cmp::Reverse(b.1.len())); for (rule, locations) in rule_counts.iter().take(15) { result.push_str(&format!(" {} ({}x)\n", rule, locations.len())); @@ -1632,10 +1633,22 @@ error[E0308]: mismatched types error: aborting due to 1 previous error "#; let result = filter_cargo_clippy(output); - assert!(result.contains("cargo clippy: 1 errors, 0 warnings"), "got: {}", result); - assert!(result.contains("error[E0308]: mismatched types"), "got: {}", result); + assert!( + result.contains("cargo clippy: 1 errors, 0 warnings"), + "got: {}", + result + ); + assert!( + result.contains("error[E0308]: mismatched types"), + "got: {}", + result + ); assert!(result.contains("src/main.rs:10:5"), "got: {}", result); - assert!(result.contains("expected `i32`, found `&str`"), "got: {}", result); + assert!( + result.contains("expected `i32`, found `&str`"), + "got: {}", + result + ); } #[test] diff --git a/src/cmds/system/format_cmd.rs b/src/cmds/system/format_cmd.rs index e147640ea5..6e37c15968 100644 --- a/src/cmds/system/format_cmd.rs +++ b/src/cmds/system/format_cmd.rs @@ -83,17 +83,13 @@ pub fn run(args: &[String], verbose: u8) -> Result { let user_args = args[start_idx..].to_vec(); match formatter.as_str() { - "black" => { - // Inject --check if not present for check mode - if !user_args.iter().any(|a| a == "--check" || a == "--diff") { - cmd.arg("--check"); - } + // Inject --check if not present for check mode + "black" if !user_args.iter().any(|a| a == "--check" || a == "--diff") => { + cmd.arg("--check"); } - "ruff" => { - // Add "format" subcommand if not present - if user_args.is_empty() || !user_args[0].starts_with("format") { - cmd.arg("format"); - } + // Add "format" subcommand if not present + "ruff" if user_args.is_empty() || !user_args[0].starts_with("format") => { + cmd.arg("format"); } _ => {} } diff --git a/src/discover/mod.rs b/src/discover/mod.rs index e1ff3a0807..36861406ba 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -228,7 +228,7 @@ pub fn run( .collect(); // Sort by estimated savings descending - supported.sort_by(|a, b| b.estimated_savings_tokens.cmp(&a.estimated_savings_tokens)); + supported.sort_by_key(|b| std::cmp::Reverse(b.estimated_savings_tokens)); let mut unsupported: Vec = unsupported_map .into_iter() @@ -240,7 +240,7 @@ pub fn run( .collect(); // Sort by count descending - unsupported.sort_by(|a, b| b.count.cmp(&a.count)); + unsupported.sort_by_key(|b| std::cmp::Reverse(b.count)); // Build RTK_DISABLED examples sorted by frequency (top 5) let rtk_disabled_examples: Vec = { diff --git a/src/hooks/hook_audit_cmd.rs b/src/hooks/hook_audit_cmd.rs index 2138ec939f..5fe339b940 100644 --- a/src/hooks/hook_audit_cmd.rs +++ b/src/hooks/hook_audit_cmd.rs @@ -143,7 +143,7 @@ pub fn run(since_days: u64, verbose: u8) -> Result<()> { if !skip_actions.is_empty() { let mut sorted_skips = skip_actions; - sorted_skips.sort_by(|a, b| b.1.cmp(&a.1)); + sorted_skips.sort_by_key(|b| std::cmp::Reverse(b.1)); for (action, count) in &sorted_skips { let reason = action.strip_prefix("skip:").unwrap_or(action); println!( diff --git a/src/learn/detector.rs b/src/learn/detector.rs index 81ebade847..867e035a6b 100644 --- a/src/learn/detector.rs +++ b/src/learn/detector.rs @@ -345,7 +345,7 @@ pub fn deduplicate_corrections(pairs: Vec) -> Vec Date: Sun, 10 May 2026 12:44:17 +0200 Subject: [PATCH 28/33] chore(ci): deny warnings and make clippy pass mandatory in ci --- .claude/agents/rust-rtk.md | 2 +- .claude/commands/tech/codereview.md | 12 ++++++------ .github/workflows/CICD.md | 15 +++++++-------- .github/workflows/ci.yml | 5 ++--- Cargo.toml | 4 ++++ 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.claude/agents/rust-rtk.md b/.claude/agents/rust-rtk.md index 5adca48b91..4e86206e37 100644 --- a/.claude/agents/rust-rtk.md +++ b/.claude/agents/rust-rtk.md @@ -397,7 +397,7 @@ docker run --rm -v $(pwd):/rtk -w /rtk rust:latest cargo test # Linux via Docke ✅ **DO** compile regex once with `lazy_static!` ✅ **DO** verify token savings claims in tests (≥60%) ✅ **DO** test on macOS + Linux + Windows (via CI or manual) -✅ **DO** run `cargo fmt && cargo clippy && cargo test` before commit +✅ **DO** run `cargo fmt && cargo clippy --all-targets && cargo test` before commit ✅ **DO** benchmark startup time with `hyperfine` (<10ms target) ✅ **DO** use `anyhow::Result` with `.context()` for all error propagation diff --git a/.claude/commands/tech/codereview.md b/.claude/commands/tech/codereview.md index 35e92360d5..bcc58f0443 100644 --- a/.claude/commands/tech/codereview.md +++ b/.claude/commands/tech/codereview.md @@ -230,12 +230,12 @@ Glob tests/fixtures/_raw.txt └────────┬────────┘ │ ▼ - ┌──────────────────────┐ - │ 3. Quality gate │ - │ cargo fmt --all │ - │ cargo clippy │ - │ cargo test │ - └────────┬─────────────┘ + ┌─────────────────────────────┐ + │ 3. Quality gate │ + │ cargo fmt --all │ + │ cargo clippy --all-targets │ + │ cargo test │ + └──────────────┬──────────────┘ │ Loop ←┘ (max N iterations) ``` diff --git a/.github/workflows/CICD.md b/.github/workflows/CICD.md index b20e9cff6f..ad5deb0acc 100644 --- a/.github/workflows/CICD.md +++ b/.github/workflows/CICD.md @@ -10,17 +10,16 @@ Trigger: pull_request to develop or master └────────┬─────────┘ │ ┌────────▼─────────┐ - │ fmt │ + │ fmt --all │ └────────┬─────────┘ │ - ┌────────▼─────────┐ - │ clippy │ - │ -D unsafe_code │ - └┬───┬───┬───┬───┬─┘ + ┌───────────▼──────────┐ + │ clippy --all-targets │ + └───┬───┬───┬───┬───┬──┘ │ │ │ │ │ - ┌───────────────┘ │ │ │ └───────────────┐ - │ ┌───────────┘ │ └──────────┐ │ - ▼ ▼ ▼ ▼ ▼ + ┌───────────────┘ │ │ │ └────────────────┐ + │ ┌───────────┘ │ └───────────┐ │ + ▼ ▼ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌─────────┐ ┌──────────┐ │ test │ │ security │ │ semgrep │ │benchmark│ │ doc │ │ ubuntu │ │ cargo │ │ AST-aware │ │ >=80% │ │ review │ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b56acffad2..992dea78e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --all-targets -- -D unsafe_code + - run: cargo clippy --all-targets # ─── Parallel gates (all need code to compile) ─── @@ -220,7 +220,7 @@ jobs: - name: Install Go uses: actions/setup-go@v5 with: - go-version: 'stable' + go-version: "stable" - name: Install Go tools run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest @@ -228,7 +228,6 @@ jobs: - name: Run benchmark run: ./scripts/benchmark.sh - # ─── AI Doc Review: develop PRs only ─── doc-review: diff --git a/Cargo.toml b/Cargo.toml index 81cc9c1df4..726a01709c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,3 +65,7 @@ assets = [ assets = [ { source = "target/release/rtk", dest = "/usr/bin/rtk", mode = "755" }, ] + +[lints.rust] +unsafe_code = "deny" +warnings = "deny" From 9d3b99dec8516fd32071d151306b5bb6fd4d06e3 Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Fri, 8 May 2026 05:40:41 +0800 Subject: [PATCH 29/33] feat(hermes): add rtk integration Signed-off-by: Kayphoon <109347466+Kayphoon@users.noreply.github.com> --- README.md | 8 +- .../guide/getting-started/supported-agents.md | 15 +- hooks/README.md | 18 +- hooks/hermes/README.md | 43 + hooks/hermes/rtk-rewrite/__init__.py | 80 ++ hooks/hermes/rtk-rewrite/plugin.yaml | 8 + hooks/hermes/tests/__init__.py | 0 hooks/hermes/tests/test_rtk_rewrite_plugin.py | 352 ++++++ src/discover/mod.rs | 1 + src/discover/provider.rs | 132 ++- src/discover/report.rs | 125 +- src/hooks/README.md | 3 +- src/hooks/constants.rs | 5 + src/hooks/hook_check.rs | 30 +- src/hooks/init.rs | 1029 ++++++++++++++++- src/main.rs | 94 +- 16 files changed, 1883 insertions(+), 60 deletions(-) create mode 100644 hooks/hermes/README.md create mode 100644 hooks/hermes/rtk-rewrite/__init__.py create mode 100644 hooks/hermes/rtk-rewrite/plugin.yaml create mode 100644 hooks/hermes/tests/__init__.py create mode 100644 hooks/hermes/tests/test_rtk_rewrite_plugin.py diff --git a/README.md b/README.md index d3ada2b142..a0db81d25c 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,13 @@ 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 hermes # Hermes # 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status ``` -The hook transparently rewrites Bash commands (e.g., `git status` -> `rtk git status`) before execution. Claude never sees the rewrite, it just gets compressed output. +Hook-based agents rewrite Bash commands (e.g., `git status` -> `rtk git status`) before execution. Plugin-based agents, including Hermes, use their plugin API to rewrite commands before execution. The agent receives compact output without needing to call `rtk` explicitly. **Important:** the hook only runs on Bash tool calls. Claude Code built-in tools like `Read`, `Grep`, and `Glob` do not pass through the Bash hook, so they are not auto-rewritten. To get RTK's compact output for those workflows, use shell commands (`cat`/`head`/`tail`, `rg`/`grep`, `find`) or call `rtk read`, `rtk grep`, or `rtk find` directly. @@ -350,7 +351,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 rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| @@ -364,11 +365,12 @@ RTK supports 12 AI coding tools. Each integration transparently rewrites shell c | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | +| **Hermes** | `rtk init --agent hermes` | Python plugin adapter (terminal command mutation via `rtk rewrite`) | | **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) | -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). +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). The Hermes plugin source and tests live in `hooks/hermes/`; installed Hermes runtime files still live under `~/.hermes/plugins/rtk-rewrite/`. ## Configuration diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 4623353d5e..561f9de151 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -1,6 +1,6 @@ --- title: Supported Agents -description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Kilo Code, and Antigravity +description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, and Antigravity sidebar: order: 3 --- @@ -35,6 +35,7 @@ Agent runs "cargo test" | Gemini CLI | Rust binary (`BeforeTool`) | Yes | | OpenCode | TypeScript plugin (`tool.execute.before`) | Yes | | OpenClaw | TypeScript plugin (`before_tool_call`) | Yes | +| Hermes | Python plugin (`terminal` command mutation) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | | Codex CLI | AGENTS.md instructions | N/A | @@ -92,6 +93,16 @@ openclaw plugins install ./openclaw Plugin in the `openclaw/` directory. Uses the `before_tool_call` hook, delegates to `rtk rewrite`. +### Hermes + +```bash +rtk init --agent hermes +``` + +Creates `~/.hermes/plugins/rtk-rewrite/` and enables it through `plugins.enabled` in the Hermes config. Hermes loads Python plugins, so the plugin entrypoint is Python, but it is only a thin adapter. It mutates the Hermes `terminal` tool `command` before execution and delegates all rewrite decisions to Rust through `rtk rewrite`. The repository source and tests for that adapter live in `hooks/hermes/`; only installed runtime files use the `~/.hermes/plugins/rtk-rewrite/` path. + +The plugin fails open. If `rtk` is missing at load time, the hook is not registered. If `rtk rewrite` errors, the tool is not `terminal`, the payload has no string `command`, or the plugin raises an exception, Hermes runs the original command unchanged. The same `rtk rewrite` limitations apply: already-prefixed `rtk` commands, compound shell commands, heredocs, and commands without filters are not rewritten. + ### Cline / Roo Code ```bash @@ -137,7 +148,7 @@ Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https:// | Tier | Mechanism | How rewrites work | |------|-----------|------------------| | **Full hook** | Shell script or Rust binary, intercepts via agent API | Transparent — agent never sees the raw command | -| **Plugin** | TypeScript/JS in agent's plugin system | Transparent — in-place mutation | +| **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it | | **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk ` | Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. diff --git a/hooks/README.md b/hooks/README.md index 6a6744281d..0879de9bbb 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, Hermes). 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 +- **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation ## 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 | +| Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | ## JSON Formats by Agent @@ -156,6 +158,17 @@ if (rewritten && rewritten !== command) { } ``` +### Hermes (Python Plugin) + +Mutates `args["command"]` in-place via the `pre_tool_call` hook: + +```python +result = subprocess.run(["rtk", "rewrite", command], capture_output=True, text=True, timeout=2) +rewritten = result.stdout.strip() +if result.returncode in {0, 3} and rewritten and rewritten != command: + args["command"] = rewritten +``` + ## Command Rewrite Registry The registry (`src/discover/registry.rs`) handles command patterns across these categories: @@ -217,7 +230,7 @@ New integrations must follow the [Exit Code Contract](#exit-code-contract) and [ | Tier | Mechanism | Maintenance | Examples | |------|-----------|-------------|----------| | **Full hook** | Shell script or Rust binary, intercepts commands via agent's hook API | High — must track agent API changes | Claude Code, Cursor, Copilot, Gemini | -| **Plugin** | TypeScript/JS plugin in agent's plugin system | Medium — agent manages loading | OpenCode | +| **Plugin** | TypeScript/JS/Python plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Hermes | | **Rules file** | Prompt-level instructions the agent reads | Low — no code to break | Cline, Windsurf, Codex | ### Eligibility @@ -232,4 +245,3 @@ RTK supports AI coding assistants that developers actually use day-to-day. To ad ### Maintenance If an agent's API changes and the hook breaks, the integration should be updated promptly. If the agent becomes unmaintained or the hook can't be fixed, the integration may be deprecated with a release note. - diff --git a/hooks/hermes/README.md b/hooks/hermes/README.md new file mode 100644 index 0000000000..2a755c7106 --- /dev/null +++ b/hooks/hermes/README.md @@ -0,0 +1,43 @@ +# RTK Plugin for Hermes + +Rewrites Hermes `terminal` tool commands to RTK equivalents before execution, so Hermes receives compact command output without changing your workflow. + +## Installation + +```bash +rtk init --agent hermes +``` + +The installer writes the plugin to `~/.hermes/plugins/rtk-rewrite/` and enables it through `plugins.enabled` in the Hermes config. The repository copy lives in `hooks/hermes/`; don't use that repo path as the runtime install path. + +## Development + +Run the Hermes plugin tests from the repository root: + +```bash +python3 -m unittest discover -s hooks/hermes +``` + +## How it works + +Hermes loads plugins from Python, so the plugin entrypoint is Python. The Python code is only a thin Hermes adapter. It reads the Hermes terminal tool payload, calls `rtk rewrite` for the actual command decision, then mutates the terminal tool `command` before Hermes executes it. + +All rewrite rules stay in Rust inside `rtk rewrite`. When RTK adds or changes command rewrite behavior, the Hermes plugin picks up that behavior by delegating to the RTK binary. + +## Fail-open behavior + +The plugin does not block command execution. If anything goes wrong, Hermes runs the original command unchanged. + +If rtk is not available in PATH when Hermes loads the plugin, the plugin prints a warning and skips hook registration. + +- `rtk` is missing from `PATH` +- `rtk rewrite` exits with an error +- Hermes sends a non-terminal tool call +- The tool payload has no string `command` +- The plugin raises an unexpected exception + +## Limitations + +- Only Hermes `terminal` tool calls are rewritten. +- Commands skipped by `rtk rewrite` stay unchanged, including commands already prefixed with `rtk`, compound shell commands, heredocs, and commands without an RTK filter. +- Shell hooks are not used for Hermes command rewriting. The integration depends on Hermes loading Python plugins and passing a mutable terminal tool payload. diff --git a/hooks/hermes/rtk-rewrite/__init__.py b/hooks/hermes/rtk-rewrite/__init__.py new file mode 100644 index 0000000000..6dcf44e832 --- /dev/null +++ b/hooks/hermes/rtk-rewrite/__init__.py @@ -0,0 +1,80 @@ +"""Hermes plugin adapter for RTK command rewriting. + +All rewrite logic lives in RTK's Rust ``rtk rewrite`` command; this module +only bridges Hermes ``pre_tool_call`` payloads to that command and fails open. +""" + +import shutil +import subprocess +import sys + + +ACCEPTED_REWRITE_RETURN_CODES = {0, 3} +EXPECTED_PASSTHROUGH_RETURN_CODES = {1, 2} +_rtk_available = None +_rtk_missing_warned = False + + +def register(ctx): + """Register the Hermes pre-tool callback.""" + if not _check_rtk(): + return + + ctx.register_hook("pre_tool_call", _pre_tool_call) + + +def _check_rtk(): + """Return whether the rtk binary is in PATH, warning once when missing.""" + global _rtk_available, _rtk_missing_warned + + if _rtk_available is None: + _rtk_available = shutil.which("rtk") is not None + + if not _rtk_available and not _rtk_missing_warned: + _warn("rtk binary not found in PATH; Hermes hook not registered") + _rtk_missing_warned = True + + return _rtk_available + + +def _pre_tool_call(tool_name=None, args=None, **_kwargs): + """Rewrite mutable Hermes terminal command args when RTK provides a change.""" + try: + if tool_name != "terminal" or not isinstance(args, dict): + return + + command = args.get("command") + if not isinstance(command, str) or not command.strip(): + return + + try: + result = subprocess.run( + ["rtk", "rewrite", command], + shell=False, + timeout=2, + capture_output=True, + text=True, + ) + except subprocess.TimeoutExpired: + _warn("rtk rewrite timed out") + return + + if result.returncode not in ACCEPTED_REWRITE_RETURN_CODES: + if result.returncode not in EXPECTED_PASSTHROUGH_RETURN_CODES: + details = f"rtk rewrite failed with exit {result.returncode}" + stderr = result.stderr.strip() + if stderr: + details = f"{details}: {stderr}" + _warn(details) + return + + rewritten = result.stdout.strip() + if rewritten and rewritten != command: + args["command"] = rewritten + except Exception as e: + _warn(str(e)) + return + + +def _warn(message): + print(f"rtk: hermes plugin warning: {message}", file=sys.stderr) diff --git a/hooks/hermes/rtk-rewrite/plugin.yaml b/hooks/hermes/rtk-rewrite/plugin.yaml new file mode 100644 index 0000000000..7a08e40b54 --- /dev/null +++ b/hooks/hermes/rtk-rewrite/plugin.yaml @@ -0,0 +1,8 @@ +name: rtk-rewrite +version: "0.1.0" +description: Rewrite Hermes terminal commands through RTK before execution. +author: RTK Contributors +hooks: + - pre_tool_call +provides_hooks: + - pre_tool_call diff --git a/hooks/hermes/tests/__init__.py b/hooks/hermes/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hooks/hermes/tests/test_rtk_rewrite_plugin.py b/hooks/hermes/tests/test_rtk_rewrite_plugin.py new file mode 100644 index 0000000000..1434262466 --- /dev/null +++ b/hooks/hermes/tests/test_rtk_rewrite_plugin.py @@ -0,0 +1,352 @@ +import io +import importlib.util +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +PLUGIN_PATH = Path(__file__).resolve().parents[1] / "rtk-rewrite" / "__init__.py" + + +class FakeContext: + def __init__(self): + self.hooks = {} + + def register_hook(self, hook_name, callback): + self.hooks[hook_name] = callback + + +class FakeCompletedProcess: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def load_plugin_module(path=PLUGIN_PATH, module_name="rtk_rewrite_plugin"): + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load Hermes plugin from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def write_fake_rtk(bin_dir): + fake_rtk = bin_dir / "rtk" + fake_rtk.write_text( + "\n".join( + [ + f"#!{sys.executable}", + "import sys", + "if sys.argv[1:] == ['rewrite', 'git status']:", + " print('rtk git status')", + " raise SystemExit(0)", + "print('unexpected rtk args:', sys.argv[1:], file=sys.stderr)", + "raise SystemExit(1)", + "", + ] + ) + ) + fake_rtk.chmod(fake_rtk.stat().st_mode | stat.S_IXUSR) + return fake_rtk + + +class RtkRewritePluginTest(unittest.TestCase): + def load_callback(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + ctx = FakeContext() + + with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk"): + module.register(ctx) + + self.assertIn("pre_tool_call", ctx.hooks) + return module, ctx.hooks["pre_tool_call"] + + def test_missing_rtk_skips_registering_pre_tool_call(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + ctx = FakeContext() + + with mock.patch.object(module.shutil, "which", return_value=None): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + module.register(ctx) + + self.assertNotIn("pre_tool_call", ctx.hooks) + self.assertEqual( + "rtk: hermes plugin warning: rtk binary not found in PATH; Hermes hook not registered\n", + stderr.getvalue(), + ) + + def test_missing_rtk_warns_only_once(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + + with mock.patch.object(module.shutil, "which", return_value=None): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + self.assertFalse(module._check_rtk()) + self.assertFalse(module._check_rtk()) + + self.assertEqual( + "rtk: hermes plugin warning: rtk binary not found in PATH; Hermes hook not registered\n", + stderr.getvalue(), + ) + + def test_check_rtk_found_is_quiet(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + + with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk"): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + self.assertTrue(module._check_rtk()) + + self.assertEqual("", stderr.getvalue()) + + def test_check_rtk_caches_result_across_calls(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + + with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk") as which: + self.assertTrue(module._check_rtk()) + self.assertTrue(module._check_rtk()) + + which.assert_called_once_with("rtk") + + def test_rewrite_success_mutates_same_terminal_args_dict(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(stdout="rtk git status\n"), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + def test_rewrite_returncode_three_mutates_same_terminal_args_dict(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(returncode=3, stdout="rtk git status\n"), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + def test_rewrite_returncode_zero_mutates_when_rewrite_changes_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(stdout="rtk git status\n"), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + def test_expected_passthrough_returncodes_do_not_warn_or_mutate(self): + for returncode in (1, 2): + with self.subTest(returncode=returncode): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess( + returncode=returncode, + stdout="rtk git status\n", + stderr="unexpected stderr", + ), + ): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("", stderr.getvalue()) + + def test_unexpected_returncode_warns_with_stderr_details(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(returncode=4, stdout="rtk git status\n", stderr="bad news"), + ): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("rtk: hermes plugin warning: rtk rewrite failed with exit 4: bad news\n", stderr.getvalue()) + + def test_rewrite_timeout_warns_and_preserves_original_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + timeout = subprocess.TimeoutExpired(cmd=["rtk", "rewrite", "git status"], timeout=2) + with mock.patch.object(module.subprocess, "run", side_effect=timeout): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("rtk: hermes plugin warning: rtk rewrite timed out\n", stderr.getvalue()) + + def test_file_not_found_preserves_original_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object(module.subprocess, "run", side_effect=FileNotFoundError): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertIn("rtk: hermes plugin warning:", stderr.getvalue()) + + def test_unexpected_exception_prints_warning_and_keeps_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object(module.subprocess, "run", side_effect=RuntimeError("boom")): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("rtk: hermes plugin warning: boom\n", stderr.getvalue()) + + def test_non_terminal_tool_is_noop(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="read_file", args=args) + + run.assert_not_called() + self.assertEqual({"command": "git status"}, args) + + def test_missing_command_is_noop(self): + module, callback = self.load_callback() + args = {} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="terminal", args=args) + + run.assert_not_called() + self.assertEqual({}, args) + + def test_non_string_command_is_noop(self): + module, callback = self.load_callback() + args = {"command": ["git", "status"]} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="terminal", args=args) + + run.assert_not_called() + self.assertEqual({"command": ["git", "status"]}, args) + + def test_empty_command_strings_are_noop(self): + for command in ("", " ", "\t\n"): + with self.subTest(command=command): + module, callback = self.load_callback() + args = {"command": command} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="terminal", args=args) + + run.assert_not_called() + self.assertEqual({"command": command}, args) + + def test_empty_or_unchanged_rewrite_output_preserves_original_command(self): + for stdout in ("", "\n", "git status\n"): + with self.subTest(stdout=stdout): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(stdout=stdout), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + + +class InstalledRtkRewritePluginTest(unittest.TestCase): + @unittest.skipUnless(shutil.which("cargo"), "cargo is required for installed flow") + def test_cargo_init_installs_importable_plugin_that_rewrites_with_fake_rtk(self): + repo_root = Path(__file__).resolve().parents[3] + self.assertTrue((repo_root / "Cargo.toml").exists(), "repo_root must point at the repository root") + real_home = Path(os.path.expanduser("~")) + + with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as bin_dir: + home_path = Path(home) + fake_bin = Path(bin_dir) + write_fake_rtk(fake_bin) + + env = os.environ.copy() + env["HOME"] = str(home_path) + env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "") + env["RTK_TELEMETRY_DISABLED"] = "1" + env["CARGO_TERM_COLOR"] = "never" + env.setdefault("RUSTUP_TOOLCHAIN", "stable") + if "RUSTUP_HOME" not in env and (real_home / ".rustup").exists(): + env["RUSTUP_HOME"] = str(real_home / ".rustup") + if "CARGO_HOME" not in env and (real_home / ".cargo").exists(): + env["CARGO_HOME"] = str(real_home / ".cargo") + env.pop("RTK_CLAUDE_DIR", None) + + result = subprocess.run( + ["cargo", "run", "--quiet", "--", "init", "--agent", "hermes"], + cwd=repo_root, + env=env, + capture_output=True, + text=True, + timeout=300, + ) + + self.assertEqual( + 0, + result.returncode, + msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) + + plugin_dir = home_path / ".hermes" / "plugins" / "rtk-rewrite" + init_path = plugin_dir / "__init__.py" + manifest_path = plugin_dir / "plugin.yaml" + self.assertTrue(init_path.exists(), "installed plugin __init__.py must exist") + self.assertTrue(manifest_path.exists(), "installed plugin.yaml must exist") + + module = load_plugin_module(init_path, "installed_rtk_rewrite_plugin") + ctx = FakeContext() + with mock.patch.dict(os.environ, {"PATH": env["PATH"]}): + module.register(ctx) + callback = ctx.hooks["pre_tool_call"] + + args = {"command": "git status"} + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/discover/mod.rs b/src/discover/mod.rs index 36861406ba..98d3eae765 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -263,6 +263,7 @@ pub fn run( parse_errors, rtk_disabled_count, rtk_disabled_examples, + agent_status: report::AgentIntegrationStatus::detect(), }; match format { diff --git a/src/discover/provider.rs b/src/discover/provider.rs index 08b4ddc8f6..ee76c7a169 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -45,47 +45,25 @@ impl ClaudeProvider { /// Get the base directory for Claude Code projects. fn projects_dir() -> Result { let home = dirs::home_dir().context("could not determine home directory")?; - let dir = home.join(CLAUDE_DIR).join("projects"); - if !dir.exists() { - anyhow::bail!( - "Claude Code projects directory not found: {}\nMake sure Claude Code has been used at least once.", - dir.display() - ); - } - Ok(dir) + Ok(Self::projects_dir_for_home(&home)) } - /// Encode a filesystem path to Claude Code's directory name format. - /// - /// Claude Code replaces `/`, `.`, `_`, `\`, and any non-ASCII character - /// with `-` when computing the project directory slug under `~/.claude/projects/`. - /// - /// `/Users/foo/bar` → `-Users-foo-bar` - /// `/Users/first.last/bar` → `-Users-first-last-bar` - /// `/home/chris/2_project` → `-home-chris-2-project` - /// `C:\Users\foo\bar` → `C:-Users-foo-bar` - pub fn encode_project_path(path: &str) -> String { - const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\']; - - path.chars() - .map(|c| { - if !c.is_ascii() || SANITIZED_CHARS.contains(&c) { - '-' - } else { - c - } - }) - .collect() + fn projects_dir_for_home(home: &Path) -> PathBuf { + home.join(CLAUDE_DIR).join("projects") } -} -impl SessionProvider for ClaudeProvider { - fn discover_sessions( - &self, + fn discover_sessions_in_projects_dir( + projects_dir: &Path, project_filter: Option<&str>, since_days: Option, ) -> Result> { - let projects_dir = Self::projects_dir()?; + if !projects_dir + .try_exists() + .with_context(|| format!("failed to access {}", projects_dir.display()))? + { + return Ok(Vec::new()); + } + let cutoff = since_days.map(|days| { SystemTime::now() .checked_sub(Duration::from_secs(days * 86400)) @@ -95,7 +73,7 @@ impl SessionProvider for ClaudeProvider { let mut sessions = Vec::new(); // List project directories - let entries = fs::read_dir(&projects_dir) + let entries = fs::read_dir(projects_dir) .with_context(|| format!("failed to read {}", projects_dir.display()))?; for entry in entries.flatten() { @@ -141,6 +119,40 @@ impl SessionProvider for ClaudeProvider { Ok(sessions) } + /// Encode a filesystem path to Claude Code's directory name format. + /// + /// Claude Code replaces `/`, `.`, `_`, `\`, and any non-ASCII character + /// with `-` when computing the project directory slug under `~/.claude/projects/`. + /// + /// `/Users/foo/bar` → `-Users-foo-bar` + /// `/Users/first.last/bar` → `-Users-first-last-bar` + /// `/home/chris/2_project` → `-home-chris-2-project` + /// `C:\Users\foo\bar` → `C:-Users-foo-bar` + pub fn encode_project_path(path: &str) -> String { + const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\']; + + path.chars() + .map(|c| { + if !c.is_ascii() || SANITIZED_CHARS.contains(&c) { + '-' + } else { + c + } + }) + .collect() + } +} + +impl SessionProvider for ClaudeProvider { + fn discover_sessions( + &self, + project_filter: Option<&str>, + since_days: Option, + ) -> Result> { + let projects_dir = Self::projects_dir()?; + Self::discover_sessions_in_projects_dir(&projects_dir, project_filter, since_days) + } + fn extract_commands(&self, path: &Path) -> Result> { let file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; @@ -409,6 +421,56 @@ mod tests { assert!(encoded.contains("Sites")); } + #[test] + fn test_discover_sessions_missing_projects_dir_returns_empty() { + let temp_home = tempfile::tempdir().unwrap(); + let missing_projects_dir = temp_home.path().join(CLAUDE_DIR).join("projects"); + + let sessions = ClaudeProvider::discover_sessions_in_projects_dir( + &missing_projects_dir, + None, + Some(30), + ) + .unwrap(); + + assert!(sessions.is_empty()); + } + + #[test] + fn test_discover_sessions_applies_project_filter() { + let projects_dir = tempfile::tempdir().unwrap(); + let matching_project = projects_dir.path().join("-Users-test-rtk"); + let other_project = projects_dir.path().join("-Users-test-other"); + std::fs::create_dir_all(&matching_project).unwrap(); + std::fs::create_dir_all(&other_project).unwrap(); + std::fs::write(matching_project.join("matching.jsonl"), "").unwrap(); + std::fs::write(other_project.join("other.jsonl"), "").unwrap(); + + let sessions = ClaudeProvider::discover_sessions_in_projects_dir( + projects_dir.path(), + Some("rtk"), + None, + ) + .unwrap(); + + assert_eq!(sessions.len(), 1); + assert_eq!( + sessions[0].file_name().and_then(|name| name.to_str()), + Some("matching.jsonl") + ); + } + + #[test] + fn test_discover_sessions_existing_non_directory_returns_error() { + let projects_file = tempfile::NamedTempFile::new().unwrap(); + + let err = + ClaudeProvider::discover_sessions_in_projects_dir(projects_file.path(), None, None) + .unwrap_err(); + + assert!(err.to_string().contains("failed to read")); + } + #[test] fn test_extract_output_content() { let jsonl = make_jsonl(&[ diff --git a/src/discover/report.rs b/src/discover/report.rs index 2af129a5c3..c2c523e246 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -1,7 +1,11 @@ //! Data types for reporting which commands RTK can and cannot optimize. -use crate::hooks::constants::{CURSOR_DIR, HOOKS_SUBDIR, REWRITE_HOOK_FILE}; +use crate::hooks::constants::{ + CURSOR_DIR, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, + HOOKS_SUBDIR, REWRITE_HOOK_FILE, +}; use serde::Serialize; +use std::path::Path; /// RTK support status for a command. #[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] @@ -44,6 +48,36 @@ pub struct UnsupportedEntry { pub example: String, } +#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq, Default)] +pub struct AgentIntegrationStatus { + pub cursor_hook_installed: bool, + pub hermes_plugin_installed: bool, +} + +impl AgentIntegrationStatus { + pub fn detect() -> Self { + dirs::home_dir() + .map(|home| Self::detect_from_home(&home)) + .unwrap_or_default() + } + + fn detect_from_home(home: &Path) -> Self { + Self { + cursor_hook_installed: home + .join(CURSOR_DIR) + .join(HOOKS_SUBDIR) + .join(REWRITE_HOOK_FILE) + .exists(), + hermes_plugin_installed: home + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE) + .is_file(), + } + } +} + /// Full discover report. #[derive(Debug, Serialize)] pub struct DiscoverReport { @@ -56,6 +90,7 @@ pub struct DiscoverReport { pub parse_errors: usize, pub rtk_disabled_count: usize, pub rtk_disabled_examples: Vec, + pub agent_status: AgentIntegrationStatus, } impl DiscoverReport { @@ -94,6 +129,7 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri if report.supported.is_empty() && report.unsupported.is_empty() { out.push_str("\nNo missed savings found. RTK usage looks good!\n"); + append_agent_notes(&mut out, report.agent_status); return out; } @@ -168,16 +204,7 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri out.push_str("\n~estimated from tool_result output sizes\n"); - // Cursor note: check if Cursor hooks are installed - if let Some(home) = dirs::home_dir() { - let cursor_hook = home - .join(CURSOR_DIR) - .join(HOOKS_SUBDIR) - .join(REWRITE_HOOK_FILE); - if cursor_hook.exists() { - out.push_str("\nNote: Cursor sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); - } - } + append_agent_notes(&mut out, report.agent_status); if verbose && report.parse_errors > 0 { out.push_str(&format!("Parse errors skipped: {}\n", report.parse_errors)); @@ -186,6 +213,16 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri out } +fn append_agent_notes(out: &mut String, status: AgentIntegrationStatus) { + if status.cursor_hook_installed { + out.push_str("\nNote: Cursor sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } + + if status.hermes_plugin_installed { + out.push_str("\nNote: Hermes plugin is installed; Hermes sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } +} + /// Format report as JSON. pub fn format_json(report: &DiscoverReport) -> String { serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string()) @@ -230,6 +267,7 @@ mod tests { parse_errors: 0, rtk_disabled_count: 0, rtk_disabled_examples: vec![], + agent_status: AgentIntegrationStatus::default(), } } @@ -267,4 +305,69 @@ mod tests { let output = format_text(&report, 10, false); assert!(output.contains("100.0%")); } + + #[test] + fn test_agent_status_detects_hermes_plugin_manifest() { + let temp_home = tempfile::tempdir().unwrap(); + let manifest = temp_home + .path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE); + std::fs::create_dir_all(manifest.parent().unwrap()).unwrap(); + std::fs::write(&manifest, "name: rtk-rewrite\n").unwrap(); + + let status = AgentIntegrationStatus::detect_from_home(temp_home.path()); + + assert!(status.hermes_plugin_installed); + assert!(!status.cursor_hook_installed); + } + + #[test] + fn test_agent_status_ignores_hermes_plugin_dir_without_manifest() { + let temp_home = tempfile::tempdir().unwrap(); + let plugin_dir = temp_home + .path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME); + std::fs::create_dir_all(plugin_dir).unwrap(); + + let status = AgentIntegrationStatus::detect_from_home(temp_home.path()); + + assert!(!status.hermes_plugin_installed); + } + + #[test] + fn test_format_text_reports_hermes_plugin_detected() { + let mut report = make_report(0, 0); + report.agent_status = AgentIntegrationStatus { + hermes_plugin_installed: true, + ..AgentIntegrationStatus::default() + }; + + let output = format_text(&report, 10, false); + + assert!( + output.contains("Hermes plugin is installed"), + "Expected Hermes installed note in output but got:\n{}", + output + ); + } + + #[test] + fn test_format_json_includes_agent_status() { + let mut report = make_report(0, 0); + report.agent_status = AgentIntegrationStatus { + cursor_hook_installed: true, + hermes_plugin_installed: true, + }; + + let output = format_json(&report); + let json: serde_json::Value = serde_json::from_str(&output).unwrap(); + + assert_eq!(json["agent_status"]["cursor_hook_installed"], true); + assert_eq!(json["agent_status"]["hermes_plugin_installed"], true); + } } diff --git a/src/hooks/README.md b/src/hooks/README.md index bf947a0f15..01a0213cb5 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -19,7 +19,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri ## Installation Modes -`rtk init` supports 6 distinct installation flows: +`rtk init` supports these installation flows: | Mode | Command | Creates | Patches | |------|---------|---------|---------| @@ -30,6 +30,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Cline | `rtk init --agent cline` | `.clinerules` | -- | | Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | +| Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | ## Integrity Verification diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 1d9f33ccd8..85340510c8 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -21,3 +21,8 @@ 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 HERMES_DIR: &str = ".hermes"; +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"; diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index ce288ba99e..4dd26d82b8 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -155,8 +155,9 @@ fn warn_marker_path() -> Option { mod tests { use super::*; use crate::hooks::constants::{ - CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, OPENCODE_PLUGIN_FILE, - OPENCODE_SUBDIR, PLUGIN_SUBDIR, + 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, }; fn other_integration_installed(home: &std::path::Path) -> bool { @@ -172,6 +173,10 @@ mod tests { home.join(GEMINI_DIR) .join(HOOKS_SUBDIR) .join(GEMINI_HOOK_FILE), + home.join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE), ]; paths.iter().any(|p| p.exists()) } @@ -265,12 +270,33 @@ mod tests { assert!(other_integration_installed(tmp.path())); } + #[test] + fn test_other_integration_hermes() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp + .path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"plugin").unwrap(); + assert!(other_integration_installed(tmp.path())); + } + #[test] fn test_other_integration_empty_dirs_not_enough() { let tmp = tempfile::tempdir().expect("tempdir"); std::fs::create_dir_all(tmp.path().join(CURSOR_DIR).join(HOOKS_SUBDIR)).unwrap(); std::fs::create_dir_all(tmp.path().join(CODEX_DIR)).unwrap(); std::fs::create_dir_all(tmp.path().join(GEMINI_DIR)).unwrap(); + std::fs::create_dir_all( + tmp.path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME), + ) + .unwrap(); assert!(!other_integration_installed(tmp.path())); } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index eaa3ec7e75..21da7c7485 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -1,6 +1,7 @@ //! Sets up RTK hooks so AI coding agents automatically route commands through RTK. use anyhow::{Context, Result}; +use std::ffi::OsString; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -12,7 +13,9 @@ 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, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, + REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -1782,6 +1785,505 @@ fn run_antigravity_mode_at(base_dir: &Path, ctx: InitContext) -> Result<()> { Ok(()) } +// ─── Hermes support ──────────────────────────────────────────── + +const HERMES_PLUGIN_INIT: &str = include_str!("../../hooks/hermes/rtk-rewrite/__init__.py"); +const HERMES_PLUGIN_YAML: &str = include_str!("../../hooks/hermes/rtk-rewrite/plugin.yaml"); + +pub fn run_hermes_mode(ctx: InitContext) -> Result<()> { + let hermes_home = resolve_hermes_home()?; + run_hermes_mode_at(&hermes_home, ctx) +} + +fn hermes_plugin_dir(hermes_home: &Path) -> PathBuf { + hermes_home + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) +} + +fn run_hermes_mode_at(hermes_home: &Path, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let plugin_dir = hermes_plugin_dir(hermes_home); + if !dry_run { + fs::create_dir_all(&plugin_dir).with_context(|| { + format!( + "Failed to create Hermes plugin directory: {}", + plugin_dir.display() + ) + })?; + } + + let init_path = plugin_dir.join(HERMES_PLUGIN_INIT_FILE); + let manifest_path = plugin_dir.join(HERMES_PLUGIN_MANIFEST_FILE); + write_if_changed(&init_path, HERMES_PLUGIN_INIT, "Hermes plugin", ctx)?; + write_if_changed( + &manifest_path, + HERMES_PLUGIN_YAML, + "Hermes plugin manifest", + ctx, + )?; + + let config_path = hermes_home.join("config.yaml"); + let existing_config = if config_path.exists() { + fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read Hermes config: {}", config_path.display()))? + } else { + String::new() + }; + let patched_config = patch_hermes_config(&existing_config); + write_if_changed(&config_path, &patched_config, "Hermes config", ctx)?; + + if dry_run { + print_dry_run_footer(); + } else { + println!("\nRTK configured for Hermes.\n"); + println!(" Plugin: {}", plugin_dir.display()); + println!(" Config: {}", config_path.display()); + println!(" Hermes will now rewrite terminal commands through rtk."); + println!(" Restart Hermes. Test with: git status\n"); + } + + Ok(()) +} + +pub fn uninstall_hermes(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let hermes_home = resolve_hermes_home()?; + let removed = uninstall_hermes_at(&hermes_home, ctx)?; + + if removed.is_empty() { + println!("RTK Hermes support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK for Hermes CLI:" + } else { + "RTK uninstalled for Hermes CLI:" + }; + println!("{}", header); + for item in removed { + println!(" - {}", item); + } + } + + if dry_run { + print_dry_run_footer(); + } + + Ok(()) +} + +fn uninstall_hermes_at(hermes_home: &Path, ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; + let mut removed = Vec::new(); + + let plugin_dir = hermes_plugin_dir(hermes_home); + if plugin_dir.exists() { + if dry_run { + println!( + "[dry-run] would remove Hermes plugin directory: {}", + plugin_dir.display() + ); + } else { + // nosemgrep: filesystem-deletion -- uninstall intentionally removes only RTK's Hermes plugin directory. + fs::remove_dir_all(&plugin_dir).with_context(|| { + format!( + "Failed to remove Hermes plugin directory: {}", + plugin_dir.display() + ) + })?; + if verbose > 0 { + eprintln!("Removed Hermes plugin directory: {}", plugin_dir.display()); + } + } + removed.push(format!("Hermes plugin: {}", plugin_dir.display())); + } + + let config_path = hermes_home.join("config.yaml"); + if config_path.exists() { + let existing_config = fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read Hermes config: {}", config_path.display()))?; + let patched_config = unpatch_hermes_config(&existing_config); + + if patched_config != existing_config { + if dry_run { + println!( + "[dry-run] would update Hermes config: {}", + config_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", patched_config); + } + } else { + atomic_write(&config_path, &patched_config).with_context(|| { + format!("Failed to write Hermes config: {}", config_path.display()) + })?; + if verbose > 0 { + eprintln!("Updated Hermes config: {}", config_path.display()); + } + } + removed.push("Hermes config: removed RTK plugin entry".to_string()); + } + } + + Ok(removed) +} + +fn patch_hermes_config(existing: &str) -> String { + rewrite_hermes_config(existing, true) +} + +fn unpatch_hermes_config(existing: &str) -> String { + rewrite_hermes_config(existing, false) +} + +fn rewrite_hermes_config(existing: &str, add_rtk: bool) -> String { + if existing.trim().is_empty() { + return if add_rtk { + hermes_plugins_block() + } else { + String::new() + }; + } + + let mut lines = split_yaml_lines(existing); + let Some(plugins_idx) = find_yaml_key_line(&lines, "plugins", 0, None) else { + return if add_rtk { + append_hermes_plugins_block(existing) + } else { + existing.to_string() + }; + }; + + let plugins_indent = yaml_indent(&lines[plugins_idx]); + let plugins_end = yaml_block_end(&lines, plugins_idx, plugins_indent); + let Some(enabled_idx) = find_yaml_key_line( + &lines, + "enabled", + plugins_idx + 1, + Some((plugins_end, plugins_indent)), + ) else { + if add_rtk { + let (enabled_indent, item_indent) = + hermes_missing_enabled_indents(&lines, plugins_idx, plugins_end, plugins_indent); + let enabled_block = format!( + "{}enabled:\n{}- {}\n", + " ".repeat(enabled_indent), + " ".repeat(item_indent), + HERMES_PLUGIN_NAME + ); + ensure_previous_yaml_line_ends_with_newline(&mut lines, plugins_end); + lines.insert(plugins_end, enabled_block); + } + return lines.concat(); + }; + + if yaml_line_without_ending(&lines[enabled_idx]).contains('[') { + rewrite_inline_hermes_enabled(&mut lines, enabled_idx, add_rtk); + return lines.concat(); + } + + rewrite_block_hermes_enabled(&mut lines, enabled_idx, add_rtk); + lines.concat() +} + +fn split_yaml_lines(input: &str) -> Vec { + if input.is_empty() { + Vec::new() + } else { + input.split_inclusive('\n').map(str::to_string).collect() + } +} + +fn ensure_previous_yaml_line_ends_with_newline(lines: &mut [String], insert_idx: usize) { + if insert_idx == 0 { + return; + } + + if let Some(previous) = lines.get_mut(insert_idx - 1) { + if !previous.ends_with('\n') { + previous.push('\n'); + } + } +} + +fn hermes_plugins_block() -> String { + format!("plugins:\n enabled:\n - {}\n", HERMES_PLUGIN_NAME) +} + +fn append_hermes_plugins_block(existing: &str) -> String { + let mut patched = existing.to_string(); + if !patched.ends_with('\n') { + patched.push('\n'); + } + patched.push_str(&hermes_plugins_block()); + patched +} + +fn find_yaml_key_line( + lines: &[String], + key: &str, + start: usize, + block: Option<(usize, usize)>, +) -> Option { + let end = block.map_or(lines.len(), |(end, _)| end); + let min_indent = block.map(|(_, indent)| indent); + + lines[start..end] + .iter() + .enumerate() + .find_map(|(offset, line)| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + if min_indent.is_some_and(|indent| yaml_indent(line) <= indent) { + return None; + } + + let is_key = trimmed == format!("{key}:") || trimmed.starts_with(&format!("{key}:")); + is_key.then_some(start + offset) + }) +} + +fn yaml_block_end(lines: &[String], start: usize, parent_indent: usize) -> usize { + lines[start + 1..] + .iter() + .enumerate() + .find_map(|(offset, line)| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + (yaml_indent(line) <= parent_indent).then_some(start + 1 + offset) + }) + .unwrap_or(lines.len()) +} + +fn rewrite_inline_hermes_enabled(lines: &mut [String], enabled_idx: usize, add_rtk: bool) { + let line_ending = yaml_line_ending(&lines[enabled_idx]); + let raw = yaml_line_without_ending(&lines[enabled_idx]); + let Some((prefix, rest)) = raw.split_once('[') else { + return; + }; + let Some((items_raw, suffix)) = rest.rsplit_once(']') else { + return; + }; + + let mut items = Vec::new(); + let mut saw_rtk = false; + for item in items_raw.split(',') { + let trimmed = item.trim(); + if trimmed.is_empty() { + continue; + } + + if is_hermes_plugin_name(trimmed) { + if add_rtk && !saw_rtk { + items.push(trimmed.to_string()); + saw_rtk = true; + } + } else { + items.push(trimmed.to_string()); + } + } + + if add_rtk && !saw_rtk { + items.push(HERMES_PLUGIN_NAME.to_string()); + } + + let replacement = if items.is_empty() { + format!("{}[]{}{}", prefix, suffix, line_ending) + } else { + format!("{}[{}]{}{}", prefix, items.join(", "), suffix, line_ending) + }; + lines[enabled_idx] = replacement; +} + +fn rewrite_block_hermes_enabled(lines: &mut Vec, enabled_idx: usize, add_rtk: bool) { + let enabled_end = hermes_enabled_list_end(lines, enabled_idx); + let item_indent = hermes_enabled_list_item_indent(lines, enabled_idx, enabled_end); + let mut kept = Vec::with_capacity(lines.len() + 1); + let mut saw_rtk = false; + + for line in &lines[enabled_idx + 1..enabled_end] { + if is_yaml_list_item_named(line, HERMES_PLUGIN_NAME) { + if add_rtk && !saw_rtk { + kept.push(line.clone()); + saw_rtk = true; + } + continue; + } + + kept.push(line.clone()); + } + + if add_rtk && !saw_rtk { + let insert_idx = kept.len(); + ensure_previous_yaml_line_ends_with_newline(&mut kept, insert_idx); + kept.push(format!( + "{}- {}\n", + " ".repeat(item_indent), + HERMES_PLUGIN_NAME + )); + } + + let mut enabled_line = if add_rtk || kept.iter().any(|line| is_yaml_list_item_line(line)) { + lines[enabled_idx].clone() + } else { + collapse_yaml_list_key_to_empty(&lines[enabled_idx]) + }; + + if add_rtk + && kept + .iter() + .any(|line| is_yaml_list_item_named(line, HERMES_PLUGIN_NAME)) + && !enabled_line.ends_with('\n') + { + enabled_line.push('\n'); + } + + let mut patched = Vec::with_capacity(lines.len() + 1); + patched.extend_from_slice(&lines[..enabled_idx]); + patched.push(enabled_line); + patched.extend(kept); + patched.extend_from_slice(&lines[enabled_end..]); + *lines = patched; +} + +fn hermes_enabled_list_end(lines: &[String], enabled_idx: usize) -> usize { + let enabled_indent = yaml_indent(&lines[enabled_idx]); + + lines[enabled_idx + 1..] + .iter() + .enumerate() + .find_map(|(offset, line)| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + let indent = yaml_indent(line); + if indent < enabled_indent + || (indent == enabled_indent && !is_yaml_list_item_line(line)) + { + return Some(enabled_idx + 1 + offset); + } + + None + }) + .unwrap_or(lines.len()) +} + +fn hermes_enabled_list_item_indent( + lines: &[String], + enabled_idx: usize, + enabled_end: usize, +) -> usize { + lines[enabled_idx + 1..enabled_end] + .iter() + .find(|line| is_yaml_list_item_line(line)) + .map(|line| yaml_indent(line)) + .unwrap_or_else(|| yaml_indent(&lines[enabled_idx]) + 2) +} + +fn hermes_missing_enabled_indents( + lines: &[String], + plugins_idx: usize, + plugins_end: usize, + plugins_indent: usize, +) -> (usize, usize) { + let child_indent = lines[plugins_idx + 1..plugins_end] + .iter() + .filter_map(|line| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + let indent = yaml_indent(line); + (indent > plugins_indent).then_some(indent) + }) + .min() + .unwrap_or(plugins_indent + 2); + + let uses_indentationless_sequences = lines[plugins_idx + 1..plugins_end] + .iter() + .any(|line| is_yaml_list_item_line(line) && yaml_indent(line) == child_indent); + + let item_indent = if uses_indentationless_sequences { + child_indent + } else { + child_indent + 2 + }; + + (child_indent, item_indent) +} + +fn yaml_line_without_ending(line: &str) -> &str { + line.trim_end_matches(['\r', '\n']) +} + +fn yaml_line_ending(line: &str) -> &str { + if line.ends_with("\r\n") { + "\r\n" + } else if line.ends_with('\n') { + "\n" + } else { + "" + } +} + +fn yaml_indent(line: &str) -> usize { + yaml_line_without_ending(line) + .chars() + .take_while(|ch| ch.is_whitespace()) + .count() +} + +fn is_yaml_list_item_named(line: &str, expected: &str) -> bool { + let trimmed = yaml_line_without_ending(line).trim(); + let Some(item) = trimmed.strip_prefix("- ") else { + return false; + }; + + normalized_yaml_scalar(item).is_some_and(|item| item == expected) +} + +fn is_yaml_list_item_line(line: &str) -> bool { + yaml_line_without_ending(line).trim().starts_with("- ") +} + +fn is_hermes_plugin_name(value: &str) -> bool { + normalized_yaml_scalar(value).is_some_and(|item| item == HERMES_PLUGIN_NAME) +} + +fn collapse_yaml_list_key_to_empty(line: &str) -> String { + let raw = yaml_line_without_ending(line); + let indent = yaml_indent(line); + let Some((key, suffix)) = raw.split_once(':') else { + return format!("{}enabled: []\n", " ".repeat(indent)); + }; + + let comment = suffix + .find('#') + .map(|idx| format!(" {}", suffix[idx..].trim_start())) + .unwrap_or_default(); + + format!("{}: []{}\n", key, comment) +} + +fn normalized_yaml_scalar(value: &str) -> Option { + let without_comment = value.split_once('#').map_or(value, |(item, _)| item); + let trimmed = without_comment.trim().trim_matches(['\'', '"']); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + fn run_codex_mode(global: bool, ctx: InitContext) -> Result<()> { let (agents_md_path, rtk_md_path) = if global { let codex_dir = resolve_codex_dir()?; @@ -2195,6 +2697,23 @@ fn resolve_codex_dir_from( .context("Cannot determine Codex config directory. Set $CODEX_HOME or $HOME.") } +fn resolve_hermes_home() -> Result { + resolve_hermes_home_from_env(dirs::home_dir(), std::env::var_os("HERMES_HOME")) +} + +fn resolve_hermes_home_from_env( + home_dir: Option, + hermes_home: Option, +) -> Result { + if let Some(path) = hermes_home.filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path)); + } + + home_dir + .map(|home| home.join(HERMES_DIR)) + .context("Cannot determine Hermes home directory. Set $HERMES_HOME or $HOME.") +} + fn codex_rtk_md_ref(codex_dir: &Path) -> String { format!("@{}", codex_dir.join(RTK_MD).display()) } @@ -3550,6 +4069,490 @@ mod tests { assert_eq!(content.matches("@RTK.md").count(), 1); } + #[test] + fn test_hermes_mode_creates_plugin_files() { + let temp = TempDir::new().unwrap(); + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + + let plugin_dir = temp.path().join("plugins/rtk-rewrite"); + let init_path = plugin_dir.join("__init__.py"); + let manifest_path = plugin_dir.join("plugin.yaml"); + let config_path = temp.path().join("config.yaml"); + + assert!(init_path.exists(), "Python plugin should be created"); + assert!(manifest_path.exists(), "Plugin manifest should be created"); + assert_eq!( + fs::read_to_string(&init_path).unwrap(), + include_str!("../../hooks/hermes/rtk-rewrite/__init__.py") + ); + assert_eq!( + fs::read_to_string(&manifest_path).unwrap(), + include_str!("../../hooks/hermes/rtk-rewrite/plugin.yaml") + ); + + let config = fs::read_to_string(&config_path).unwrap(); + assert!(config.contains("plugins:\n")); + assert!(config.contains(" enabled:\n")); + assert_eq!(config.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_mode_preserves_config_and_is_idempotent() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("config.yaml"); + fs::write( + &config_path, + "theme: dark\nplugins:\n enabled:\n - existing-plugin\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let first = fs::read_to_string(&config_path).unwrap(); + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let second = fs::read_to_string(&config_path).unwrap(); + + assert_eq!(first, second, "Hermes config patch should be idempotent"); + assert!(first.contains("theme: dark\n")); + assert!(first.contains(" - existing-plugin\n")); + assert!(first.contains(" search_path: ./plugins\n")); + assert!(first.contains("other: true\n")); + assert_eq!(first.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_mode_preserves_pyyaml_same_indent_config_and_is_idempotent() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("config.yaml"); + fs::write( + &config_path, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let first = fs::read_to_string(&config_path).unwrap(); + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let second = fs::read_to_string(&config_path).unwrap(); + + let expected = "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n search_path: ./plugins\nother: true\n"; + assert_eq!(first, expected); + assert_eq!( + second, expected, + "Hermes PyYAML config patch should be idempotent" + ); + assert_eq!(first.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_mode_patches_and_uninstalls_pyyaml_same_indent_missing_enabled_idempotently() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + let plugin_dir = hermes_home.join("plugins").join(HERMES_PLUGIN_NAME); + let other_plugin_dir = hermes_home.join("plugins/keep-me"); + let other_plugin_file = other_plugin_dir.join("plugin.yaml"); + let config_path = hermes_home.join("config.yaml"); + + fs::create_dir_all(&other_plugin_dir).unwrap(); + fs::write(&other_plugin_file, "keep").unwrap(); + fs::write( + &config_path, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + run_hermes_mode_at(hermes_home, InitContext::default()).unwrap(); + let first = fs::read_to_string(&config_path).unwrap(); + run_hermes_mode_at(hermes_home, InitContext::default()).unwrap(); + let second = fs::read_to_string(&config_path).unwrap(); + + let installed = "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n enabled:\n - rtk-rewrite\nother: true\n"; + assert_eq!(first, installed); + assert_eq!(second, installed); + assert_eq!(first.matches("rtk-rewrite").count(), 1); + assert!(plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!plugin_dir.exists()); + assert!(other_plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let uninstalled = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + uninstalled, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n enabled: []\nother: true\n" + ); + assert!(!uninstalled.contains("\n - \n")); + assert!(!uninstalled.contains("\n -\n")); + assert_eq!(uninstalled.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_uninstall_hermes_at_removes_plugin_dir_and_cleans_config() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + let plugin_dir = hermes_home.join("plugins").join(HERMES_PLUGIN_NAME); + let nested_plugin_file = plugin_dir.join("nested/marker.txt"); + let other_plugin_dir = hermes_home.join("plugins/keep-me"); + let other_plugin_file = other_plugin_dir.join("plugin.yaml"); + let config_path = hermes_home.join("config.yaml"); + + fs::create_dir_all(nested_plugin_file.parent().unwrap()).unwrap(); + fs::write(&nested_plugin_file, "rtk").unwrap(); + fs::create_dir_all(&other_plugin_dir).unwrap(); + fs::write(&other_plugin_file, "keep").unwrap(); + fs::write( + &config_path, + "theme: dark\nplugins:\n enabled:\n - existing-plugin\n - rtk-rewrite\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!plugin_dir.exists()); + assert!(other_plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let config = fs::read_to_string(&config_path).unwrap(); + assert!(config.contains("theme: dark\n")); + assert!(config.contains(" - existing-plugin\n")); + assert!(config.contains(" search_path: ./plugins\n")); + assert!(config.contains("other: true\n")); + assert_eq!(config.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_uninstall_hermes_at_cleans_pyyaml_same_indent_config_idempotently() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + let plugin_dir = hermes_home.join("plugins").join(HERMES_PLUGIN_NAME); + let nested_plugin_file = plugin_dir.join("nested/marker.txt"); + let other_plugin_dir = hermes_home.join("plugins/keep-me"); + let other_plugin_file = other_plugin_dir.join("plugin.yaml"); + let config_path = hermes_home.join("config.yaml"); + + fs::create_dir_all(nested_plugin_file.parent().unwrap()).unwrap(); + fs::write(&nested_plugin_file, "rtk").unwrap(); + fs::create_dir_all(&other_plugin_dir).unwrap(); + fs::write(&other_plugin_file, "keep").unwrap(); + fs::write( + &config_path, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!plugin_dir.exists()); + assert!(other_plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let config = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + config, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n search_path: ./plugins\nother: true\n" + ); + assert!(!config.contains("\n - \n")); + assert!(!config.contains("\n -\n")); + assert_eq!(config.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_uninstall_hermes_at_missing_files_is_idempotent() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert!(removed_first.is_empty()); + assert!(removed_second.is_empty()); + assert!(!hermes_home.join("plugins").exists()); + assert!(!hermes_home.join("config.yaml").exists()); + } + + #[test] + fn test_hermes_config_patch_adds_missing_enabled_list() { + let existing = "theme: dark\nplugins:\n search_path: ./plugins\nother: true\n"; + let patched = patch_hermes_config(existing); + + assert!(patched.contains("theme: dark\n")); + assert!(patched.contains("plugins:\n")); + assert!(patched.contains(" search_path: ./plugins\n")); + assert!(patched.contains(" enabled:\n - rtk-rewrite\n")); + assert!(patched.contains("other: true\n")); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_removes_duplicate_rtk_rewrite() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n - other\n - rtk-rewrite\n"; + let patched = patch_hermes_config(existing); + + assert!(patched.contains(" - other\n")); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_enabled_list() { + let existing = + "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_default_compact_enabled_list() { + let existing = "plugins:\n enabled:\n - foo\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - foo\n - rtk-rewrite\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_missing_enabled_list() { + let existing = + "plugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n enabled:\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_enabled_is_idempotent() { + let existing = "plugins:\n enabled:\n - disk-cleanup\n disabled:\n - spotify\n"; + + let patched_once = patch_hermes_config(existing); + let patched_twice = patch_hermes_config(&patched_once); + + assert_eq!( + patched_once, + "plugins:\n enabled:\n - disk-cleanup\n - rtk-rewrite\n disabled:\n - spotify\n" + ); + assert_eq!(patched_twice, patched_once); + assert_eq!(patched_once.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_final_line_without_newline() { + let existing = "plugins:\n enabled:\n - disk-cleanup"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n enabled:\n - disk-cleanup\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_block_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n enabled:\n - existing-plugin\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_missing_enabled_after_final_child_without_newline() { + let existing = "plugins:\n search_path: ./plugins"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n search_path: ./plugins\n enabled:\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_empty_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled:"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - rtk-rewrite\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_inline_enabled_is_idempotent() { + let existing = "theme: dark\nplugins:\n enabled: [existing-plugin, rtk-rewrite] # keep\n search_path: ./plugins\nother: true\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, existing); + assert_eq!(patch_hermes_config(&patched), patched); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_inline_enabled_without_final_newline_is_idempotent() { + let existing = "plugins:\n enabled: [existing-plugin, rtk-rewrite]"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, existing); + assert_eq!(patch_hermes_config(&patched), patched); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_unpatch_inline_enabled_without_rtk_preserves_missing_final_newline() { + let existing = "plugins:\n enabled: [existing-plugin]"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); + } + + #[test] + fn test_hermes_config_unpatch_inline_enabled_preserves_unrelated_entries() { + let existing = "theme: dark\nplugins:\n enabled: [alpha, rtk-rewrite, beta] # keep comment\n search_path: ./plugins\nother: true\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!( + patched, + "theme: dark\nplugins:\n enabled: [alpha, beta] # keep comment\n search_path: ./plugins\nother: true\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_inline_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled: [existing-plugin, rtk-rewrite]"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: [existing-plugin]"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_removes_duplicate_inline_rtk_rewrite() { + let existing = "plugins:\n enabled: [alpha, rtk-rewrite, beta, rtk-rewrite]\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: [alpha, beta]\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_removes_duplicate_block_rtk_rewrite() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n - other\n - rtk-rewrite\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - other\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_pyyaml_indentationless_enabled_list() { + let existing = "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n search_path: ./plugins\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n search_path: ./plugins\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_pyyaml_indentationless_only_rtk_collapses_to_empty() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n search_path: ./plugins\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: []\n search_path: ./plugins\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_block_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin\n - rtk-rewrite"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - existing-plugin\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_block_enabled_without_rtk_preserves_missing_final_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); + } + + #[test] + fn test_hermes_config_unpatch_preserves_quoted_exact_values() { + let existing = "plugins:\n enabled:\n - 'alpha'\n - \"rtk-rewrite\"\n - 'beta'\n search_path: ./plugins\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n enabled:\n - 'alpha'\n - 'beta'\n search_path: ./plugins\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_leaves_missing_enabled_list_unchanged() { + let existing = "theme: dark\nplugins:\n search_path: ./plugins\nother: true\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); + } + + #[test] + fn test_hermes_config_unpatch_collapses_empty_enabled_list() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: []\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + #[test] fn test_run_codex_mode_global_writes_absolute_reference_to_codex_dir() { let temp = TempDir::new().unwrap(); @@ -3588,6 +4591,30 @@ mod tests { assert_eq!(missing_falls_back, home_dir.join(".codex")); } + #[test] + fn test_resolve_hermes_home_prefers_hermes_home() { + let hermes_home = OsString::from("~/custom hermes home"); + let home_dir = PathBuf::from("/tmp/home"); + + let resolved = + resolve_hermes_home_from_env(Some(home_dir), Some(hermes_home.clone())).unwrap(); + + assert_eq!(resolved, PathBuf::from(hermes_home)); + } + + #[test] + fn test_resolve_hermes_home_empty_env_falls_back_to_home() { + let home_dir = PathBuf::from("/tmp/home"); + + let empty_falls_back = + resolve_hermes_home_from_env(Some(home_dir.clone()), Some(OsString::new())).unwrap(); + let missing_falls_back = + resolve_hermes_home_from_env(Some(home_dir.clone()), None).unwrap(); + + assert_eq!(empty_falls_back, home_dir.join(".hermes")); + assert_eq!(missing_falls_back, home_dir.join(".hermes")); + } + #[test] fn test_uninstall_codex_at_is_idempotent() { let temp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index d11fcfac7a..d6d0aa4f34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,8 @@ pub enum AgentTarget { Kilocode, /// Google Antigravity Antigravity, + /// Hermes CLI + Hermes, } #[derive(Parser)] @@ -1348,6 +1350,27 @@ fn main() { std::process::exit(code); } +fn uninstall_init_dispatch( + agent: Option, + global: bool, + gemini: bool, + codex: bool, + ctx: hooks::init::InitContext, + uninstall_hermes: UninstallHermes, + uninstall_standard: UninstallStandard, +) -> Result<()> +where + UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, + UninstallStandard: FnOnce(bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, +{ + if agent == Some(AgentTarget::Hermes) { + uninstall_hermes(ctx) + } else { + let cursor = agent == Some(AgentTarget::Cursor); + uninstall_standard(global, gemini, codex, cursor, ctx) + } +} + fn run_cli() -> Result { // Fire-and-forget telemetry ping (1/day, non-blocking) core::telemetry::maybe_ping(); @@ -1776,8 +1799,15 @@ fn run_cli() -> Result { if show { hooks::init::show_config(codex)?; } else if uninstall { - let cursor = agent == Some(AgentTarget::Cursor); - hooks::init::uninstall(global, gemini, codex, cursor, ctx)?; + uninstall_init_dispatch( + agent, + global, + gemini, + codex, + ctx, + hooks::init::uninstall_hermes, + hooks::init::uninstall, + )?; } else if gemini { let patch_mode = if auto_patch { hooks::init::PatchMode::Auto @@ -1801,6 +1831,8 @@ fn run_cli() -> Result { ); } hooks::init::run_antigravity_mode(ctx)?; + } else if agent == Some(AgentTarget::Hermes) { + hooks::init::run_hermes_mode(ctx)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -2459,6 +2491,7 @@ fn is_operational_command(cmd: &Commands) -> bool { mod tests { use super::*; use clap::Parser; + use std::cell::Cell; #[test] fn test_git_commit_single_message() { @@ -2593,6 +2626,63 @@ mod tests { assert!(result.is_ok(), "git status should parse successfully"); } + #[test] + fn test_try_parse_init_agent_hermes() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes"]).unwrap(); + match cli.command { + Commands::Init { agent, .. } => { + assert_eq!(agent, Some(AgentTarget::Hermes)); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_try_parse_init_agent_hermes_uninstall() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes", "--uninstall"]).unwrap(); + match cli.command { + Commands::Init { + agent, uninstall, .. + } => { + assert_eq!(agent, Some(AgentTarget::Hermes)); + assert!(uninstall); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_init_uninstall_dispatch_routes_hermes_to_hermes_cleanup() { + let hermes_called = Cell::new(false); + let standard_called = Cell::new(false); + let ctx = hooks::init::InitContext { + verbose: 2, + dry_run: true, + }; + + let result = uninstall_init_dispatch( + Some(AgentTarget::Hermes), + true, + false, + false, + ctx, + |ctx| { + hermes_called.set(true); + assert_eq!(ctx.verbose, 2); + assert!(ctx.dry_run); + Ok(()) + }, + |_, _, _, _, _| { + standard_called.set(true); + Ok(()) + }, + ); + + assert!(result.is_ok()); + assert!(hermes_called.get()); + assert!(!standard_called.get()); + } + #[test] fn test_try_parse_help_is_display_help() { match Cli::try_parse_from(["rtk", "--help"]) { From f7c4bf6ba1db02631dd9c4bbc7d64e9837a06bbd Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Tue, 12 May 2026 00:32:48 +0800 Subject: [PATCH 30/33] style(hooks): format BOM helper assertion --- src/hooks/hook_cmd.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 5f64ac7b18..36825e3c5e 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -886,10 +886,7 @@ mod tests { assert_eq!(strip_leading_bom("hello"), "hello"); assert_eq!(strip_leading_bom("\u{feff}hello"), "hello"); assert_eq!(strip_leading_bom("\u{feff}\u{feff}hello"), "hello"); - assert_eq!( - strip_leading_bom("\u{feff}\u{feff}\u{feff}hello"), - "hello" - ); + assert_eq!(strip_leading_bom("\u{feff}\u{feff}\u{feff}hello"), "hello"); // BOM in the middle is preserved (not "leading"). assert_eq!(strip_leading_bom("a\u{feff}b"), "a\u{feff}b"); } From cd6ac2f47a008c6dca04b567faf68aaedfd87ca9 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 13 May 2026 15:42:11 +0200 Subject: [PATCH 31/33] fix(security): replace insecure tmp, lock git workflow perm --- .github/workflows/pr-target-check.yml | 1 + scripts/benchmark-sessions/lib/runner.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index 25a607b8b7..ef4d5fad7e 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -7,6 +7,7 @@ on: jobs: check-target: runs-on: ubuntu-latest + permissions: {} # Skip develop→master PRs (maintainer releases) if: >- github.event.pull_request.base.ref == 'master' && diff --git a/scripts/benchmark-sessions/lib/runner.py b/scripts/benchmark-sessions/lib/runner.py index 192fbcd41b..ccc301409e 100644 --- a/scripts/benchmark-sessions/lib/runner.py +++ b/scripts/benchmark-sessions/lib/runner.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import os import subprocess import tempfile from pathlib import Path @@ -21,7 +22,8 @@ def _create_tarball(source_dir: Path) -> str: - tarball = tempfile.mktemp(suffix=".tar.gz") + fd, tarball = tempfile.mkstemp(suffix=".tar.gz") + os.close(fd) subprocess.run( ["tar", "czf", tarball, "-C", str(source_dir), "."], check=True, From 26b96ec6c4f40f992ccffa190af9a4de8d7636b1 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 13 May 2026 17:13:44 +0200 Subject: [PATCH 32/33] fix(security): pin workflow actions to SHA, clean up tempfile on failure --- .github/workflows/pr-target-check.yml | 4 ++-- scripts/benchmark-sessions/lib/runner.py | 16 +++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index ef4d5fad7e..f66d532c08 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -13,7 +13,7 @@ jobs: github.event.pull_request.base.ref == 'master' && github.event.pull_request.head.ref != 'develop' steps: - - uses: actions/create-github-app-token@v3 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token with: client-id: ${{ secrets.APP_CLIENT_ID }} @@ -21,7 +21,7 @@ jobs: permission-pull-requests: write - name: Add wrong-base label and comment - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | diff --git a/scripts/benchmark-sessions/lib/runner.py b/scripts/benchmark-sessions/lib/runner.py index ccc301409e..bd02dc1d05 100644 --- a/scripts/benchmark-sessions/lib/runner.py +++ b/scripts/benchmark-sessions/lib/runner.py @@ -24,10 +24,14 @@ def _create_tarball(source_dir: Path) -> str: fd, tarball = tempfile.mkstemp(suffix=".tar.gz") os.close(fd) - subprocess.run( - ["tar", "czf", tarball, "-C", str(source_dir), "."], - check=True, - ) + try: + subprocess.run( + ["tar", "czf", tarball, "-C", str(source_dir), "."], + check=True, + ) + except Exception: + Path(tarball).unlink(missing_ok=True) + raise return tarball @@ -75,6 +79,7 @@ async def run_benchmark( total_steps = 5 if terminal_bench else 4 vm_names: list[str] = [] + local_tarball: str | None = None manifest = RunManifest( task_name=task.name, @@ -88,7 +93,6 @@ async def run_benchmark( print(f" VMs ready: {', '.join(vm_names)}") _print_step(2, total_steps, "Setting up codebases") - local_tarball = None if not task.codebase.is_github: local_tarball = _create_tarball(task.codebase.local_path()) @@ -149,6 +153,8 @@ async def run_benchmark( print(f"\n Manifest written to {output_dir / 'manifest.json'}") finally: + if local_tarball: + Path(local_tarball).unlink(missing_ok=True) if not keep_vms and vm_names: print("\nCleaning up VMs...") await destroy_vm_pool(vm_names) From 7e418fd66402b1ed654af37b94950cb119db183c Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 13 May 2026 19:01:37 +0200 Subject: [PATCH 33/33] Update pr-target-check.yml --- .github/workflows/pr-target-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index f66d532c08..ac3ec13666 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -36,7 +36,7 @@ jobs: }); // Post comment - const body = `Automatic message from CI checks : It seems like this branche is targeting the wrong branch, any contribution should target develop branch. + const body = `Automatic message from CI checks : It seems like this branch is targeting the wrong branch, any contribution should target develop branch. See [CONTRIBUTING.md](https://github.com/rtk-ai/rtk/blob/master/CONTRIBUTING.md) for details.`;