diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index c9c00f47..b75a9782 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# rtk-hook-version: 2 +# rtk-hook-version: 3 # RTK Claude Code hook — rewrites commands to use rtk for token savings. -# Requires: rtk >= 0.23.0, jq +# Requires: rtk >= 0.29.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. +# This is a thin delegating hook: ALL logic (rewrite rules, permission +# decisions, config loading) lives in `rtk rewrite --hook-json`. +# To change behavior, edit ~/.config/rtk/config.toml — 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 @@ -17,15 +17,13 @@ if ! command -v rtk &>/dev/null; then exit 0 fi -# Version guard: rtk rewrite was added in 0.23.0. -# Older binaries: warn once and exit cleanly (no silent failure). +# Version guard: --hook-json was added in 0.29.0. 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 + if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 29 ]; then + echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.29.0). Upgrade: cargo install rtk" >&2 exit 0 fi fi @@ -37,25 +35,10 @@ 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 +# Delegate everything to Rust: rewrite decision, config, permission, JSON output. +# Empty output = no rewrite (pass through silently). +RESULT=$(rtk rewrite --hook-json "$CMD" 2>/dev/null) || exit 0 -# No change — nothing to do. -if [ "$CMD" = "$REWRITTEN" ]; then - exit 0 +if [ -n "$RESULT" ]; then + echo "$RESULT" 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/config.rs b/src/config.rs index 94917a5e..051ded17 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[derive(Debug, Serialize, Deserialize, Default)] pub struct Config { @@ -18,12 +18,73 @@ pub struct Config { pub hooks: HooksConfig, } -#[derive(Debug, Serialize, Deserialize, Default)] +#[derive(Debug, Serialize, Deserialize)] pub struct HooksConfig { /// Commands to exclude from auto-rewrite (e.g. ["curl", "playwright"]). /// Survives `rtk init -g` re-runs since config.toml is user-owned. #[serde(default)] pub exclude_commands: Vec, + + /// When true, rewritten commands bypass Claude Code's permission prompt. + /// When false, commands are still rewritten but the user is prompted before execution. + /// Override: RTK_HOOK_AUTO_APPROVE=0 (or =1) + #[serde(default = "default_true")] + pub auto_approve: bool, + + /// Claude Code data directory for hook installation. + /// Override: CLAUDE_CONFIG_DIR env var or --claude-dir CLI flag. + #[serde(skip_serializing_if = "Option::is_none")] + pub claude_dir: Option, +} + +fn default_true() -> bool { + true +} + +impl Default for HooksConfig { + fn default() -> Self { + Self { + exclude_commands: vec![], + auto_approve: true, + claude_dir: None, + } + } +} + +/// Resolve the Claude Code data directory. +/// Priority: cli_override > CLAUDE_CONFIG_DIR env var > config.toml > ~/.claude +pub fn resolve_claude_dir(cli_override: Option<&Path>) -> anyhow::Result { + if let Some(dir) = cli_override { + return Ok(dir.to_path_buf()); + } + if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") { + if !dir.is_empty() { + return Ok(PathBuf::from(dir)); + } + } + if let Ok(config) = Config::load() { + if let Some(dir) = config.hooks.claude_dir { + return Ok(dir); + } + } + dirs::home_dir() + .map(|h| h.join(".claude")) + .context("Cannot determine home directory. Is $HOME set?") +} + +/// Parse a boolean-ish env var value. Accepts "1", "true", "yes" as truthy (case-insensitive). +/// Everything else (including empty string) is falsy. +fn parse_bool_env(val: &str) -> bool { + matches!(val.to_lowercase().as_str(), "1" | "true" | "yes") +} + +/// Resolve hooks.auto_approve with env var override, using an already-loaded config. +/// Priority: RTK_HOOK_AUTO_APPROVE env var > config value. +pub fn resolve_auto_approve_with(hooks: &HooksConfig) -> bool { + if let Ok(val) = std::env::var("RTK_HOOK_AUTO_APPROVE") { + return parse_bool_env(&val); + } + hooks.auto_approve } #[derive(Debug, Serialize, Deserialize)] @@ -94,9 +155,14 @@ impl Default for TelemetryConfig { } } -/// Check if telemetry is enabled in config. Returns None if config can't be loaded. -pub fn telemetry_enabled() -> Option { - Config::load().ok().map(|c| c.telemetry.enabled) +/// Resolve telemetry.enabled with env var override. +/// Priority: RTK_TELEMETRY_DISABLED env var > config.toml > default (true). +/// Note: env var uses inverted polarity — "1"/"true"/"yes" means DISABLED. +pub fn resolve_telemetry_enabled() -> bool { + if let Ok(val) = std::env::var("RTK_TELEMETRY_DISABLED") { + return !parse_bool_env(&val); + } + Config::load().map(|c| c.telemetry.enabled).unwrap_or(true) } impl Config { @@ -166,12 +232,26 @@ exclude_commands = ["curl", "gh"] "#; let config: Config = toml::from_str(toml).expect("valid toml"); assert_eq!(config.hooks.exclude_commands, vec!["curl", "gh"]); + // auto_approve defaults to true when omitted + assert!(config.hooks.auto_approve); + } + + #[test] + fn test_hooks_config_auto_approve_false() { + let toml = r#" +[hooks] +auto_approve = false +exclude_commands = [] +"#; + let config: Config = toml::from_str(toml).expect("valid toml"); + assert!(!config.hooks.auto_approve); } #[test] fn test_hooks_config_default_empty() { let config = Config::default(); assert!(config.hooks.exclude_commands.is_empty()); + assert!(config.hooks.auto_approve); } #[test] @@ -183,5 +263,69 @@ history_days = 90 "#; let config: Config = toml::from_str(toml).expect("valid toml"); assert!(config.hooks.exclude_commands.is_empty()); + assert!(config.hooks.auto_approve); + } + + #[test] + fn test_hooks_config_claude_dir() { + let toml = r#" +[hooks] +claude_dir = "/custom/claude" +"#; + let config: Config = toml::from_str(toml).expect("valid toml"); + assert_eq!( + config.hooks.claude_dir, + Some(PathBuf::from("/custom/claude")) + ); + } + + #[test] + fn test_parse_bool_env_truthy() { + assert!(parse_bool_env("1")); + assert!(parse_bool_env("true")); + assert!(parse_bool_env("TRUE")); + assert!(parse_bool_env("True")); + assert!(parse_bool_env("yes")); + assert!(parse_bool_env("YES")); + } + + #[test] + fn test_parse_bool_env_falsy() { + assert!(!parse_bool_env("0")); + assert!(!parse_bool_env("false")); + assert!(!parse_bool_env("no")); + assert!(!parse_bool_env("")); + assert!(!parse_bool_env("anything_else")); + } + + #[test] + fn test_resolve_auto_approve_with_config_default() { + let hooks = HooksConfig::default(); + // Without env var set, uses config value (true by default) + assert!(resolve_auto_approve_with(&hooks)); + } + + #[test] + fn test_resolve_auto_approve_with_config_false() { + let hooks = HooksConfig { + auto_approve: false, + ..Default::default() + }; + assert!(!resolve_auto_approve_with(&hooks)); + } + + #[test] + fn test_resolve_claude_dir_cli_override() { + let override_path = Path::new("/custom/override"); + let result = resolve_claude_dir(Some(override_path)).unwrap(); + assert_eq!(result, PathBuf::from("/custom/override")); + } + + #[test] + fn test_resolve_claude_dir_cli_wins_over_env() { + // CLI override takes priority even when CLAUDE_CONFIG_DIR is set + let override_path = Path::new("/explicit/override"); + let result = resolve_claude_dir(Some(override_path)).unwrap(); + assert_eq!(result, PathBuf::from("/explicit/override")); } } diff --git a/src/init.rs b/src/init.rs index 63e0f0c1..7f7e78c4 100644 --- a/src/init.rs +++ b/src/init.rs @@ -202,19 +202,20 @@ pub fn run( claude_md: bool, hook_only: bool, patch_mode: PatchMode, + claude_dir: Option<&Path>, verbose: u8, ) -> Result<()> { // Mode selection match (claude_md, hook_only) { - (true, _) => run_claude_md_mode(global, verbose), - (false, true) => run_hook_only_mode(global, patch_mode, verbose), - (false, false) => run_default_mode(global, patch_mode, verbose), + (true, _) => run_claude_md_mode(global, claude_dir, verbose), + (false, true) => run_hook_only_mode(global, patch_mode, claude_dir, verbose), + (false, false) => run_default_mode(global, patch_mode, claude_dir, verbose), } } /// Prepare hook directory and return paths (hook_dir, hook_path) -fn prepare_hook_paths() -> Result<(PathBuf, PathBuf)> { - let claude_dir = resolve_claude_dir()?; +fn prepare_hook_paths(claude_dir_override: Option<&Path>) -> Result<(PathBuf, PathBuf)> { + let claude_dir = resolve_claude_dir(claude_dir_override)?; let hook_dir = claude_dir.join("hooks"); fs::create_dir_all(&hook_dir) .with_context(|| format!("Failed to create hook directory: {}", hook_dir.display()))?; @@ -399,8 +400,8 @@ 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 { - let claude_dir = resolve_claude_dir()?; +fn remove_hook_from_settings(claude_dir_override: Option<&Path>, verbose: u8) -> Result { + let claude_dir = resolve_claude_dir(claude_dir_override)?; let settings_path = claude_dir.join("settings.json"); if !settings_path.exists() { @@ -442,12 +443,12 @@ fn remove_hook_from_settings(verbose: u8) -> Result { } /// Full uninstall: remove hook, RTK.md, @RTK.md reference, settings.json entry -pub fn uninstall(global: bool, verbose: u8) -> Result<()> { +pub fn uninstall(global: bool, claude_dir_override: Option<&Path>, verbose: u8) -> Result<()> { if !global { anyhow::bail!("Uninstall only works with --global flag. For local projects, manually remove RTK from CLAUDE.md"); } - let claude_dir = resolve_claude_dir()?; + let claude_dir = resolve_claude_dir(claude_dir_override)?; let mut removed = Vec::new(); // 1. Remove hook file @@ -495,7 +496,7 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { } // 4. Remove hook entry from settings.json - if remove_hook_from_settings(verbose)? { + if remove_hook_from_settings(claude_dir_override, verbose)? { removed.push("settings.json: removed RTK hook entry".to_string()); } @@ -515,8 +516,13 @@ pub fn uninstall(global: bool, 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) -> Result { - let claude_dir = resolve_claude_dir()?; +fn patch_settings_json( + hook_path: &Path, + mode: PatchMode, + claude_dir_override: Option<&Path>, + verbose: u8, +) -> Result { + let claude_dir = resolve_claude_dir(claude_dir_override)?; let settings_path = claude_dir.join("settings.json"); let hook_command = hook_path .to_str() @@ -687,28 +693,38 @@ fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { /// Default mode: hook + slim RTK.md + @RTK.md reference #[cfg(not(unix))] -fn run_default_mode(_global: bool, _patch_mode: PatchMode, _verbose: u8) -> Result<()> { +fn run_default_mode( + _global: bool, + _patch_mode: PatchMode, + _claude_dir_override: Option<&Path>, + _verbose: u8, +) -> Result<()> { eprintln!("⚠️ 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) + run_claude_md_mode(_global, _claude_dir_override, _verbose) } #[cfg(unix)] -fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { +fn run_default_mode( + global: bool, + patch_mode: PatchMode, + claude_dir_override: Option<&Path>, + verbose: u8, +) -> Result<()> { if !global { // Local init: inject CLAUDE.md + generate project-local filters template - run_claude_md_mode(false, verbose)?; + run_claude_md_mode(false, claude_dir_override, verbose)?; generate_project_filters_template(verbose)?; return Ok(()); } - let claude_dir = resolve_claude_dir()?; + let claude_dir = resolve_claude_dir(claude_dir_override)?; 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_dir, hook_path) = prepare_hook_paths(claude_dir_override)?; let hook_changed = ensure_hook_installed(&hook_path, verbose)?; // 2. Write RTK.md @@ -734,7 +750,7 @@ fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result< } // 5. Patch settings.json - let patch_result = patch_settings_json(&hook_path, patch_mode, verbose)?; + let patch_result = patch_settings_json(&hook_path, patch_mode, claude_dir_override, verbose)?; // Report result match patch_result { @@ -809,12 +825,22 @@ fn generate_global_filters_template(verbose: u8) -> Result<()> { /// Hook-only mode: just the hook, no RTK.md #[cfg(not(unix))] -fn run_hook_only_mode(_global: bool, _patch_mode: PatchMode, _verbose: u8) -> Result<()> { +fn run_hook_only_mode( + _global: bool, + _patch_mode: PatchMode, + _claude_dir_override: Option<&Path>, + _verbose: u8, +) -> Result<()> { anyhow::bail!("Hook install requires Unix (macOS/Linux). Use WSL or --claude-md mode.") } #[cfg(unix)] -fn run_hook_only_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { +fn run_hook_only_mode( + global: bool, + patch_mode: PatchMode, + claude_dir_override: Option<&Path>, + verbose: u8, +) -> Result<()> { if !global { eprintln!("⚠️ Warning: --hook-only only makes sense with --global"); eprintln!(" For local projects, use default mode or --claude-md"); @@ -822,7 +848,7 @@ fn run_hook_only_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Resul } // Prepare and install hook - let (_hook_dir, hook_path) = prepare_hook_paths()?; + let (_hook_dir, hook_path) = prepare_hook_paths(claude_dir_override)?; let hook_changed = ensure_hook_installed(&hook_path, verbose)?; let hook_status = if hook_changed { @@ -837,7 +863,7 @@ fn run_hook_only_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Resul ); // Patch settings.json - let patch_result = patch_settings_json(&hook_path, patch_mode, verbose)?; + let patch_result = patch_settings_json(&hook_path, patch_mode, claude_dir_override, verbose)?; // Report result match patch_result { @@ -859,9 +885,9 @@ fn run_hook_only_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Resul } /// Legacy mode: full 137-line injection into CLAUDE.md -fn run_claude_md_mode(global: bool, verbose: u8) -> Result<()> { +fn run_claude_md_mode(global: bool, claude_dir_override: Option<&Path>, verbose: u8) -> Result<()> { let path = if global { - resolve_claude_dir()?.join("CLAUDE.md") + resolve_claude_dir(claude_dir_override)?.join("CLAUDE.md") } else { PathBuf::from("CLAUDE.md") }; @@ -1084,16 +1110,14 @@ fn remove_rtk_block(content: &str) -> (String, bool) { } } -/// Resolve ~/.claude directory with proper home expansion -fn resolve_claude_dir() -> Result { - dirs::home_dir() - .map(|h| h.join(".claude")) - .context("Cannot determine home directory. Is $HOME set?") +/// Resolve Claude Code data directory, delegating to config cascade. +fn resolve_claude_dir(cli_override: Option<&Path>) -> Result { + crate::config::resolve_claude_dir(cli_override) } /// Show current rtk configuration -pub fn show_config() -> Result<()> { - let claude_dir = resolve_claude_dir()?; +pub fn show_config(claude_dir_override: Option<&Path>) -> Result<()> { + let claude_dir = resolve_claude_dir(claude_dir_override)?; 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"); @@ -1284,7 +1308,9 @@ mod tests { // 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(); + let rtk_delegate_pos = REWRITE_HOOK + .find("rtk rewrite --hook-json \"$CMD\"") + .unwrap(); assert!( jq_pos < rtk_delegate_pos, "Guards must appear before rtk rewrite delegation" diff --git a/src/main.rs b/src/main.rs index 2ca89466..00a3e4d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -336,6 +336,10 @@ enum Commands { /// Remove all RTK artifacts (hook, RTK.md, CLAUDE.md reference, settings.json entry) #[arg(long)] uninstall: bool, + + /// Claude Code data directory (default: ~/.claude, env: CLAUDE_CONFIG_DIR) + #[arg(long = "claude-dir")] + claude_dir: Option, }, /// Download with compact output (strips progress bars) @@ -628,6 +632,10 @@ enum Commands { Rewrite { /// Raw command to rewrite (e.g. "git status", "cargo test && git push") cmd: String, + + /// Output full Claude Code hook JSON (for use as a hook pipe target) + #[arg(long)] + hook_json: bool, }, } @@ -1492,11 +1500,12 @@ fn main() -> Result<()> { auto_patch, no_patch, uninstall, + claude_dir, } => { if show { - init::show_config()?; + init::show_config(claude_dir.as_deref())?; } else if uninstall { - init::uninstall(global, cli.verbose)?; + init::uninstall(global, claude_dir.as_deref(), cli.verbose)?; } else { let patch_mode = if auto_patch { init::PatchMode::Auto @@ -1505,7 +1514,14 @@ fn main() -> Result<()> { } else { init::PatchMode::Ask }; - init::run(global, claude_md, hook_only, patch_mode, cli.verbose)?; + init::run( + global, + claude_md, + hook_only, + patch_mode, + claude_dir.as_deref(), + cli.verbose, + )?; } } @@ -1840,8 +1856,8 @@ fn main() -> Result<()> { hook_audit_cmd::run(since, cli.verbose)?; } - Commands::Rewrite { cmd } => { - rewrite_cmd::run(&cmd)?; + Commands::Rewrite { cmd, hook_json } => { + rewrite_cmd::run(&cmd, hook_json)?; } Commands::Proxy { args } => { diff --git a/src/rewrite_cmd.rs b/src/rewrite_cmd.rs index 754f51a9..85283d74 100644 --- a/src/rewrite_cmd.rs +++ b/src/rewrite_cmd.rs @@ -1,21 +1,31 @@ +use crate::config; use crate::discover::registry; /// Run the `rtk rewrite` command. /// +/// **Default mode** (no flags): /// Prints the RTK-rewritten command to stdout and exits 0. /// Exits 1 (without output) if the command has no RTK equivalent. /// -/// Used by shell hooks to rewrite commands transparently: -/// ```bash -/// REWRITTEN=$(rtk rewrite "$CMD") || exit 0 -/// [ "$CMD" = "$REWRITTEN" ] && exit 0 # already RTK, skip -/// ``` -pub fn run(cmd: &str) -> anyhow::Result<()> { - let excluded = crate::config::Config::load() - .map(|c| c.hooks.exclude_commands) - .unwrap_or_default(); - - match registry::rewrite_command(cmd, &excluded) { +/// **`--hook-json` mode**: +/// Outputs the full Claude Code PreToolUse hook JSON response including +/// the rewritten command and permission decision (controlled by +/// `hooks.auto_approve` in config.toml / RTK_HOOK_AUTO_APPROVE env var). +/// Exits 0 with no output if the command has no RTK equivalent. +pub fn run(cmd: &str, hook_json: bool) -> anyhow::Result<()> { + let config = config::Config::load().unwrap_or_default(); + let excluded = &config.hooks.exclude_commands; + + if hook_json { + run_hook_json(cmd, &config) + } else { + run_plain(cmd, excluded) + } +} + +/// Original behavior: print rewritten command or exit 1. +fn run_plain(cmd: &str, excluded: &[String]) -> anyhow::Result<()> { + match registry::rewrite_command(cmd, excluded) { Some(rewritten) => { print!("{}", rewritten); Ok(()) @@ -26,6 +36,55 @@ pub fn run(cmd: &str) -> anyhow::Result<()> { } } +/// Build the PreToolUse hook JSON response. +/// Extracted as a pure function for testability. +fn build_hook_response(rewritten: &str, auto_approve: bool) -> serde_json::Value { + if auto_approve { + serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": { + "command": rewritten + } + } + }) + } else { + serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": { + "command": rewritten + } + } + }) + } +} + +/// Hook-json mode: output full Claude Code PreToolUse response JSON. +/// The command string comes from the CLI arg (extracted by the hook script +/// via jq before invoking `rtk rewrite --hook-json "$CMD"`). +fn run_hook_json(cmd: &str, config: &config::Config) -> anyhow::Result<()> { + let excluded = &config.hooks.exclude_commands; + + let rewritten = match registry::rewrite_command(cmd, excluded) { + Some(r) => r, + None => return Ok(()), // no rewrite — silent exit 0, hook passes through + }; + + // Already rtk-prefixed or compound where all segments matched — nothing to do + if cmd == rewritten { + return Ok(()); + } + + let auto_approve = config::resolve_auto_approve_with(&config.hooks); + + let response = build_hook_response(&rewritten, auto_approve); + println!("{}", serde_json::to_string(&response)?); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -47,4 +106,20 @@ mod tests { Some("rtk git status".into()) ); } + + #[test] + fn test_build_hook_response_auto_approve() { + let response = build_hook_response("rtk git status", true); + let output = response["hookSpecificOutput"].as_object().unwrap(); + assert_eq!(output["permissionDecision"], "allow"); + assert_eq!(output["updatedInput"]["command"], "rtk git status"); + } + + #[test] + fn test_build_hook_response_no_auto_approve() { + let response = build_hook_response("rtk git status", false); + let output = response["hookSpecificOutput"].as_object().unwrap(); + assert!(!output.contains_key("permissionDecision")); + assert_eq!(output["updatedInput"]["command"], "rtk git status"); + } } diff --git a/src/telemetry.rs b/src/telemetry.rs index 36b1e724..1adc938d 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -15,13 +15,8 @@ pub fn maybe_ping() { return; } - // Check opt-out: env var - if std::env::var("RTK_TELEMETRY_DISABLED").unwrap_or_default() == "1" { - return; - } - - // Check opt-out: config.toml - if let Some(false) = config::telemetry_enabled() { + // Check opt-out: env var > config.toml > default + if !config::resolve_telemetry_enabled() { return; }