diff --git a/.gitignore b/.gitignore index fdba0978..9a8b2c7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ # Build /target +.cargo-ok # Environment & Secrets .env .env.* +settings.local.json *.pem *.key *.crt diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh deleted file mode 100644 index c9c00f47..00000000 --- a/hooks/rtk-rewrite.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# rtk-hook-version: 2 -# RTK Claude Code hook — rewrites commands to use rtk for token savings. -# Requires: rtk >= 0.23.0, jq -# -# This is a thin delegating hook: all rewrite logic lives in `rtk rewrite`, -# which is the single source of truth (src/discover/registry.rs). -# To add or change rewrite rules, edit the Rust registry — not this file. - -if ! command -v jq &>/dev/null; then - echo "[rtk] WARNING: jq is not installed. Hook cannot rewrite commands. Install jq: https://jqlang.github.io/jq/download/" >&2 - exit 0 -fi - -if ! command -v rtk &>/dev/null; then - echo "[rtk] WARNING: rtk is not installed or not in PATH. Hook cannot rewrite commands. Install: https://github.com/rtk-ai/rtk#installation" >&2 - exit 0 -fi - -# Version guard: rtk rewrite was added in 0.23.0. -# Older binaries: warn once and exit cleanly (no silent failure). -RTK_VERSION=$(rtk --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) -if [ -n "$RTK_VERSION" ]; then - MAJOR=$(echo "$RTK_VERSION" | cut -d. -f1) - MINOR=$(echo "$RTK_VERSION" | cut -d. -f2) - # Require >= 0.23.0 - if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 23 ]; then - echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.23.0). Upgrade: cargo install rtk" >&2 - exit 0 - fi -fi - -INPUT=$(cat) -CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') - -if [ -z "$CMD" ]; then - exit 0 -fi - -# Delegate all rewrite logic to the Rust binary. -# rtk rewrite exits 1 when there's no rewrite — hook passes through silently. -REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || exit 0 - -# No change — nothing to do. -if [ "$CMD" = "$REWRITTEN" ]; then - exit 0 -fi - -ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input') -UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd') - -jq -n \ - --argjson updated "$UPDATED_INPUT" \ - '{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": $updated - } - }' diff --git a/src/hook_check.rs b/src/hook_check.rs index 2716ec15..3a80a77f 100644 --- a/src/hook_check.rs +++ b/src/hook_check.rs @@ -1,6 +1,5 @@ use std::path::PathBuf; -const CURRENT_HOOK_VERSION: u8 = 2; const WARN_INTERVAL_SECS: u64 = 24 * 3600; /// Hook status for diagnostics and `rtk gain`. @@ -16,6 +15,7 @@ pub enum HookStatus { /// Return the current hook status without printing anything. /// Returns `Ok` if no Claude Code is detected (not applicable). +/// Returns `Ok` for native hook (built into rtk binary). pub fn status() -> HookStatus { // Don't warn users who don't have Claude Code installed let home = match dirs::home_dir() { @@ -26,17 +26,9 @@ pub fn status() -> HookStatus { return HookStatus::Ok; } - let Some(hook_path) = hook_installed_path() else { - return HookStatus::Missing; - }; - let Ok(content) = std::fs::read_to_string(&hook_path) else { - return HookStatus::Outdated; // exists but unreadable — treat as needs-update - }; - if parse_hook_version(&content) >= CURRENT_HOOK_VERSION { - HookStatus::Ok - } else { - HookStatus::Outdated - } + // Native hook is built into rtk binary - always available + // No file-based check needed + HookStatus::Ok } /// Check if the installed hook is missing or outdated, warn once per day. @@ -74,27 +66,6 @@ fn check_and_warn() -> Option<()> { Some(()) } -pub fn parse_hook_version(content: &str) -> u8 { - // Version tag must be in the first 5 lines (shebang + header convention) - for line in content.lines().take(5) { - if let Some(rest) = line.strip_prefix("# rtk-hook-version:") { - if let Ok(v) = rest.trim().parse::() { - return v; - } - } - } - 0 // No version tag = version 0 (outdated) -} - -fn hook_installed_path() -> Option { - let home = dirs::home_dir()?; - let path = home.join(".claude").join("hooks").join("rtk-rewrite.sh"); - if path.exists() { - Some(path) - } else { - None - } -} fn warn_marker_path() -> Option { let data_dir = dirs::data_local_dir()?.join("rtk"); @@ -105,30 +76,6 @@ fn warn_marker_path() -> Option { mod tests { use super::*; - #[test] - fn test_parse_hook_version_present() { - let content = "#!/usr/bin/env bash\n# rtk-hook-version: 2\n# some comment\n"; - assert_eq!(parse_hook_version(content), 2); - } - - #[test] - fn test_parse_hook_version_missing() { - let content = "#!/usr/bin/env bash\n# old hook without version\n"; - assert_eq!(parse_hook_version(content), 0); - } - - #[test] - fn test_parse_hook_version_future() { - let content = "#!/usr/bin/env bash\n# rtk-hook-version: 5\n"; - assert_eq!(parse_hook_version(content), 5); - } - - #[test] - fn test_parse_hook_version_no_tag() { - assert_eq!(parse_hook_version("no version here"), 0); - assert_eq!(parse_hook_version(""), 0); - } - #[test] fn test_hook_status_enum() { assert_ne!(HookStatus::Ok, HookStatus::Missing); @@ -141,31 +88,23 @@ mod tests { #[test] fn test_status_returns_valid_variant() { - // Skip on machines without Claude Code or without hook + // Native hook is built into rtk binary - always returns Ok if Claude Code exists let home = match dirs::home_dir() { Some(h) => h, - None => return, - }; - if !home - .join(".claude") - .join("hooks") - .join("rtk-rewrite.sh") - .exists() - { - // No hook — status should be Missing (if .claude exists) or Ok (if not) - let s = status(); - if home.join(".claude").exists() { - assert_eq!(s, HookStatus::Missing); - } else { - assert_eq!(s, HookStatus::Ok); + None => { + // No home dir - should return Ok (not applicable) + assert_eq!(status(), HookStatus::Ok); + return; } - return; - } + }; + let s = status(); - assert!( - s == HookStatus::Ok || s == HookStatus::Outdated, - "Expected Ok or Outdated when hook exists, got {:?}", - s - ); + if home.join(".claude").exists() { + // Claude Code installed - native hook is always available + assert_eq!(s, HookStatus::Ok); + } else { + // Claude Code not installed - not applicable + assert_eq!(s, HookStatus::Ok); + } } } diff --git a/src/hook_cmd.rs b/src/hook_cmd.rs index 29a7365d..143f115f 100644 --- a/src/hook_cmd.rs +++ b/src/hook_cmd.rs @@ -137,6 +137,43 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { Ok(()) } +// ── Claude Code hook ─────────────────────────────────────────── + +/// Run Claude Code PreToolUse hook. +/// Reads JSON from stdin, rewrites shell commands to rtk equivalents, +/// outputs JSON to stdout in Claude Code format. +pub fn run_claude() -> Result<()> { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("Failed to read stdin")?; + + let input = input.trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(e) => { + eprintln!("[rtk hook] Failed to parse JSON input: {e}"); + return Ok(()); + } + }; + + // Extract command from tool_input.command + let cmd = match v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + Some(c) => c, + None => return Ok(()), // No command = pass through + }; + + handle_vscode(cmd) +} + // ── Gemini hook ─────────────────────────────────────────────── /// Run the Gemini CLI BeforeTool hook. @@ -330,4 +367,49 @@ mod tests { Some("RUST_LOG=debug rtk cargo test".into()) ); } + + // --- Claude Code hook --- + + #[test] + fn test_claude_hook_format_matches_vscode() { + // Claude Code uses same format as VS Code + let input = json!({ + "tool_name": "Bash", + "tool_input": { "command": "git status" } + }); + assert!(matches!( + detect_format(&input), + HookFormat::VsCode { .. } + )); + } + + #[test] + fn test_claude_hook_output_format() { + // Verify the output format matches expected Claude Code hook format + let cmd = "git status"; + let rewritten = get_rewritten(cmd).unwrap(); + + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": { "command": rewritten } + } + }); + + let json: Value = serde_json::from_str(&output.to_string()).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["hookEventName"], + "PreToolUse" + ); + assert_eq!( + json["hookSpecificOutput"]["permissionDecision"], + "allow" + ); + assert_eq!( + json["hookSpecificOutput"]["updatedInput"]["command"], + "rtk git status" + ); + } } diff --git a/src/init.rs b/src/init.rs index 241a7ef5..9afac245 100644 --- a/src/init.rs +++ b/src/init.rs @@ -6,9 +6,6 @@ use tempfile::NamedTempFile; use crate::integrity; -// Embedded hook script (guards before set -euo pipefail) -const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); - // Embedded Cursor hook script (preToolUse format) const CURSOR_REWRITE_HOOK: &str = include_str!("../hooks/cursor-rtk-rewrite.sh"); @@ -282,62 +279,6 @@ pub fn run( Ok(()) } -/// Prepare hook directory and return paths (hook_dir, hook_path) -fn prepare_hook_paths() -> Result<(PathBuf, PathBuf)> { - let claude_dir = resolve_claude_dir()?; - let hook_dir = claude_dir.join("hooks"); - fs::create_dir_all(&hook_dir) - .with_context(|| format!("Failed to create hook directory: {}", hook_dir.display()))?; - let hook_path = hook_dir.join("rtk-rewrite.sh"); - Ok((hook_dir, hook_path)) -} - -/// Write hook file if missing or outdated, return true if changed -#[cfg(unix)] -fn ensure_hook_installed(hook_path: &Path, verbose: u8) -> Result { - let changed = if hook_path.exists() { - let existing = fs::read_to_string(hook_path) - .with_context(|| format!("Failed to read existing hook: {}", hook_path.display()))?; - - if existing == REWRITE_HOOK { - if verbose > 0 { - eprintln!("Hook already up to date: {}", hook_path.display()); - } - false - } else { - fs::write(hook_path, REWRITE_HOOK) - .with_context(|| format!("Failed to write hook to {}", hook_path.display()))?; - if verbose > 0 { - eprintln!("Updated hook: {}", hook_path.display()); - } - true - } - } else { - fs::write(hook_path, REWRITE_HOOK) - .with_context(|| format!("Failed to write hook to {}", hook_path.display()))?; - if verbose > 0 { - eprintln!("Created hook: {}", hook_path.display()); - } - true - }; - - // Set executable permissions - 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 SHA-256 hash for runtime integrity verification. - // Always store (idempotent) to ensure baseline exists even for - // hooks installed before integrity checks were added. - integrity::store_hash(hook_path) - .with_context(|| format!("Failed to store integrity hash for {}", hook_path.display()))?; - if verbose > 0 && changed { - eprintln!("Stored integrity hash for hook"); - } - - Ok(changed) -} - /// Idempotent file write: create or update if content differs fn write_if_changed(path: &Path, content: &str, name: &str, verbose: u8) -> Result { if path.exists() { @@ -423,13 +364,16 @@ fn prompt_user_consent(settings_path: &Path) -> Result { } /// Print manual instructions for settings.json patching -fn print_manual_instructions(hook_path: &Path, include_opencode: bool) { +fn print_manual_instructions(include_opencode: bool) { + // Use native rtk hook command (no external scripts needed) + let hook_command = "rtk hook claude"; + println!("\n MANUAL STEP: Add this to ~/.claude/settings.json:"); println!(" {{"); println!(" \"hooks\": {{ \"PreToolUse\": [{{"); println!(" \"matcher\": \"Bash\","); println!(" \"hooks\": [{{ \"type\": \"command\","); - println!(" \"command\": \"{}\"", hook_path.display()); + println!(" \"command\": \"{}\"", hook_command); println!(" }}]"); println!(" }}]}}"); println!(" }}"); @@ -562,7 +506,7 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: return Ok(()); } - // 1. Remove hook file + // 1. Remove legacy hook file (.sh script) let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); if hook_path.exists() { fs::remove_file(&hook_path) @@ -681,16 +625,15 @@ fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { /// Orchestrator: patch settings.json with RTK hook /// Handles reading, checking, prompting, merging, backing up, and atomic writing fn patch_settings_json( - hook_path: &Path, mode: PatchMode, verbose: u8, include_opencode: bool, ) -> Result { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join("settings.json"); - let hook_command = hook_path - .to_str() - .context("Hook path contains invalid UTF-8")?; + + // Use native rtk hook command (no external scripts needed) + let hook_command = "rtk hook claude".to_string(); // Read or create settings.json let mut root = if settings_path.exists() { @@ -708,7 +651,7 @@ fn patch_settings_json( }; // Check idempotency - if hook_already_present(&root, hook_command) { + if hook_already_present(&root, &hook_command) { if verbose > 0 { eprintln!("settings.json: hook already present"); } @@ -718,12 +661,12 @@ fn patch_settings_json( // Handle mode match mode { PatchMode::Skip => { - print_manual_instructions(hook_path, include_opencode); + print_manual_instructions(include_opencode); return Ok(PatchResult::Skipped); } PatchMode::Ask => { if !prompt_user_consent(&settings_path)? { - print_manual_instructions(hook_path, include_opencode); + print_manual_instructions(include_opencode); return Ok(PatchResult::Declined); } } @@ -733,7 +676,7 @@ fn patch_settings_json( } // Deep-merge hook - insert_hook_entry(&mut root, hook_command); + insert_hook_entry(&mut root, &hook_command); // Backup original if settings_path.exists() { @@ -833,7 +776,7 @@ fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) { } /// Check if RTK hook is already present in settings.json -/// Matches on rtk-rewrite.sh substring to handle different path formats +/// Checks for "rtk hook claude" command fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { let pre_tool_use_array = match root .get("hooks") @@ -850,24 +793,85 @@ fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { .flatten() .filter_map(|hook| hook.get("command")?.as_str()) .any(|cmd| { - // Exact match OR both contain rtk-rewrite.sh - cmd == hook_command - || (cmd.contains("rtk-rewrite.sh") && hook_command.contains("rtk-rewrite.sh")) + // Exact match OR command contains "rtk hook" + cmd == hook_command || cmd.contains("rtk hook") }) } /// Default mode: hook + slim RTK.md + @RTK.md reference #[cfg(not(unix))] fn run_default_mode( - _global: bool, - _patch_mode: PatchMode, - _verbose: u8, - _install_opencode: bool, + global: bool, + patch_mode: PatchMode, + verbose: u8, + install_opencode: 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) + 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)?; + return Ok(()); + } + + let claude_dir = resolve_claude_dir()?; + let rtk_md_path = claude_dir.join("RTK.md"); + let claude_md_path = claude_dir.join("CLAUDE.md"); + + // 1. Write RTK.md (hook is now built-in to rtk) + write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; + + let opencode_plugin_path = if install_opencode { + let path = prepare_opencode_plugin_path()?; + ensure_opencode_plugin_installed(&path, verbose)?; + 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 installed (global).\n"); + println!(" Hook: rtk hook claude (built-in)"); + 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)"); + } + + // 5. Patch settings.json + let patch_result = patch_settings_json(patch_mode, verbose, install_opencode)?; + + // Report result + match patch_result { + PatchResult::Patched => { + // Already printed by patch_settings_json + } + 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 by patch_settings_json + } + } + + // 6. Generate user-global filters template (~/.config/rtk/filters.toml) + generate_global_filters_template(verbose)?; + + println!(); // Final newline + + Ok(()) } #[cfg(unix)] @@ -888,11 +892,7 @@ fn run_default_mode( let rtk_md_path = claude_dir.join("RTK.md"); let claude_md_path = claude_dir.join("CLAUDE.md"); - // 1. Prepare hook directory and install hook - let (_hook_dir, hook_path) = prepare_hook_paths()?; - let hook_changed = ensure_hook_installed(&hook_path, verbose)?; - - // 2. Write RTK.md + // 1. Write RTK.md (hook is now built-in to rtk) write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; let opencode_plugin_path = if install_opencode { @@ -907,13 +907,8 @@ fn run_default_mode( let migrated = patch_claude_md(&claude_md_path, verbose)?; // 4. Print success message - let hook_status = if hook_changed { - "installed/updated" - } else { - "already up to date" - }; - println!("\nRTK hook {} (global).\n", hook_status); - println!(" Hook: {}", hook_path.display()); + println!("\nRTK hook installed (global).\n"); + println!(" Hook: rtk hook claude (built-in)"); println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); if let Some(path) = &opencode_plugin_path { println!(" OpenCode: {}", path.display()); @@ -926,7 +921,7 @@ fn run_default_mode( } // 5. Patch settings.json - let patch_result = patch_settings_json(&hook_path, patch_mode, verbose, install_opencode)?; + let patch_result = patch_settings_json(patch_mode, verbose, install_opencode)?; // Report result match patch_result { @@ -1027,10 +1022,7 @@ fn run_hook_only_mode( return Ok(()); } - // Prepare and install hook - let (_hook_dir, hook_path) = prepare_hook_paths()?; - let hook_changed = ensure_hook_installed(&hook_path, verbose)?; - + // Hook is now built-in to rtk let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; ensure_opencode_plugin_installed(&path, verbose)?; @@ -1039,13 +1031,8 @@ fn run_hook_only_mode( None }; - let hook_status = if hook_changed { - "installed/updated" - } else { - "already up to date" - }; - println!("\nRTK hook {} (hook-only mode).\n", hook_status); - println!(" Hook: {}", hook_path.display()); + println!("\nRTK hook installed (hook-only mode).\n"); + println!(" Hook: rtk hook claude (built-in)"); if let Some(path) = &opencode_plugin_path { println!(" OpenCode: {}", path.display()); } @@ -1800,61 +1787,34 @@ pub fn show_config(codex: bool) -> Result<()> { fn show_claude_config() -> Result<()> { let claude_dir = resolve_claude_dir()?; - let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); let rtk_md_path = claude_dir.join("RTK.md"); let global_claude_md = claude_dir.join("CLAUDE.md"); let local_claude_md = PathBuf::from("CLAUDE.md"); println!("rtk Configuration:\n"); - // Check hook - if hook_path.exists() { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let metadata = fs::metadata(&hook_path)?; - let perms = metadata.permissions(); - let is_executable = perms.mode() & 0o111 != 0; - - let hook_content = fs::read_to_string(&hook_path)?; - let has_guards = - hook_content.contains("command -v rtk") && hook_content.contains("command -v jq"); - let is_thin_delegator = hook_content.contains("rtk rewrite"); - let hook_version = crate::hook_check::parse_hook_version(&hook_content); - - if !is_executable { - println!( - "[warn] Hook: {} (NOT executable - run: chmod +x)", - hook_path.display() - ); - } else if !is_thin_delegator { - println!( - "[warn] Hook: {} (outdated — inline logic, not thin delegator)", - hook_path.display() - ); - println!( - " → Run `rtk init --global` to upgrade to the single source of truth hook" - ); - } else if is_executable && has_guards { - println!( - "[ok] Hook: {} (thin delegator, version {})", - hook_path.display(), - hook_version - ); + // Check native hook (built into rtk binary via settings.json) + let settings_path = claude_dir.join("settings.json"); + let hook_command = "rtk hook claude"; + + if settings_path.exists() { + let content = fs::read_to_string(&settings_path)?; + if !content.trim().is_empty() { + if let Ok(root) = serde_json::from_str::(&content) { + if hook_already_present(&root, hook_command) { + println!("[ok] Hook: rtk hook claude (built-in)"); + } else { + println!("[warn] Hook: settings.json exists but RTK hook not configured"); + println!(" Run: rtk init -g --auto-patch"); + } } else { - println!( - "[warn] Hook: {} (no guards - outdated)", - hook_path.display() - ); + println!("[warn] Hook: settings.json exists but invalid JSON"); } - } - - #[cfg(not(unix))] - { - println!("[ok] Hook: {} (exists)", hook_path.display()); + } else { + println!("[--] Hook: settings.json empty"); } } else { - println!("[--] Hook: not found"); + println!("[--] Hook: settings.json not found (run: rtk init -g)"); } // Check RTK.md @@ -1864,26 +1824,6 @@ fn show_claude_config() -> Result<()> { println!("[--] RTK.md: not found"); } - // Check hook integrity - match integrity::verify_hook_at(&hook_path) { - Ok(integrity::IntegrityStatus::Verified) => { - println!("[ok] Integrity: hook hash verified"); - } - Ok(integrity::IntegrityStatus::Tampered { .. }) => { - println!("[FAIL] Integrity: hook modified outside rtk init (run: rtk verify)"); - } - Ok(integrity::IntegrityStatus::NoBaseline) => { - println!("[warn] Integrity: no baseline hash (run: rtk init -g to establish)"); - } - Ok(integrity::IntegrityStatus::NotInstalled) - | Ok(integrity::IntegrityStatus::OrphanedHash) => { - // Don't show integrity line if hook isn't installed - } - Err(_) => { - println!("[warn] Integrity: check failed"); - } - } - // Check global CLAUDE.md if global_claude_md.exists() { let content = fs::read_to_string(&global_claude_md)?; @@ -1912,29 +1852,6 @@ fn show_claude_config() -> Result<()> { println!("[--] Local (./CLAUDE.md): not found"); } - // Check settings.json - let settings_path = claude_dir.join("settings.json"); - if settings_path.exists() { - let content = fs::read_to_string(&settings_path)?; - if !content.trim().is_empty() { - if let Ok(root) = serde_json::from_str::(&content) { - let hook_command = hook_path.display().to_string(); - if hook_already_present(&root, &hook_command) { - println!("[ok] settings.json: RTK hook configured"); - } else { - println!("[warn] settings.json: exists but RTK hook not configured"); - println!(" Run: rtk init -g --auto-patch"); - } - } else { - println!("[warn] settings.json: exists but invalid JSON"); - } - } else { - println!("[--] settings.json: empty"); - } - } else { - println!("[--] settings.json: not found"); - } - // Check OpenCode plugin if let Ok(opencode_dir) = resolve_opencode_dir() { let plugin = opencode_plugin_path(&opencode_dir); @@ -2341,19 +2258,8 @@ mod tests { ); } - #[test] - fn test_hook_has_guards() { - assert!(REWRITE_HOOK.contains("command -v rtk")); - assert!(REWRITE_HOOK.contains("command -v jq")); - // Guards (rtk/jq availability checks) must appear before the actual delegation call. - // The thin delegating hook no longer uses set -euo pipefail. - let jq_pos = REWRITE_HOOK.find("command -v jq").unwrap(); - let rtk_delegate_pos = REWRITE_HOOK.find("rtk rewrite \"$CMD\"").unwrap(); - assert!( - jq_pos < rtk_delegate_pos, - "Guards must appear before rtk rewrite delegation" - ); - } + // Hook guards test removed - hook is now native, no script file + // Use `rtk hook claude` directly instead of script files #[test] fn test_migration_removes_old_block() { @@ -2421,7 +2327,7 @@ More content"#; let hook_path = temp.path().join("rtk-rewrite.sh"); let rtk_md_path = temp.path().join("RTK.md"); - fs::write(&hook_path, REWRITE_HOOK).unwrap(); + fs::write(&hook_path, REWRITE_HOOK_SH).unwrap(); fs::write(&rtk_md_path, RTK_SLIM).unwrap(); use std::os::unix::fs::PermissionsExt; @@ -2647,6 +2553,7 @@ More notes assert!(hook_already_present(&json_content, hook_command)); } + #[cfg(unix)] #[test] fn test_hook_already_present_different_path() { let json_content = serde_json::json!({ diff --git a/src/integrity.rs b/src/integrity.rs index 41bcf4e8..fb92a50a 100644 --- a/src/integrity.rs +++ b/src/integrity.rs @@ -180,9 +180,10 @@ fn read_stored_hash(path: &Path) -> Result { /// Resolve the default hook path (~/.claude/hooks/rtk-rewrite.sh) pub fn resolve_hook_path() -> Result { - dirs::home_dir() - .map(|h| h.join(".claude").join("hooks").join("rtk-rewrite.sh")) - .context("Cannot determine home directory. Is $HOME set?") + let home = dirs::home_dir().context("Cannot determine home directory. Is $HOME set?")?; + // On Windows: use .py hook; on Unix: use .sh hook + let extension = if cfg!(windows) { "py" } else { "sh" }; + Ok(home.join(".claude").join("hooks").join(format!("rtk-rewrite.{}", extension))) } /// Run integrity check and print results (for `rtk verify` subcommand) diff --git a/src/main.rs b/src/main.rs index 2bbc4bb2..d842020a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -703,6 +703,8 @@ enum HookCommands { Gemini, /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin) Copilot, + /// Process Claude Code hook (reads JSON from stdin, outputs JSON) + Claude, } #[derive(Subcommand)] @@ -2044,6 +2046,9 @@ fn main() -> Result<()> { HookCommands::Copilot => { hook_cmd::run_copilot()?; } + HookCommands::Claude => { + hook_cmd::run_claude()?; + } }, Commands::Rewrite { args } => {