Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 224 additions & 24 deletions src/hooks/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,85 @@ fn print_dry_run_footer() {
println!("\n[dry-run] Nothing written.");
}

fn claude_hook_command() -> Result<String> {
let exe = std::env::current_exe().context("Failed to resolve current rtk executable")?;
Ok(format!("{} hook claude", quote_command_path(&exe)))
}

#[cfg(windows)]
fn quote_command_path(path: &Path) -> String {
let path = path.to_string_lossy();
format!("\"{}\"", path.replace('"', "\\\""))
}

#[cfg(not(windows))]
fn quote_command_path(path: &Path) -> String {
let path = path.to_string_lossy();
if path.bytes().all(|b| {
matches!(
b,
b'a'..=b'z'
| b'A'..=b'Z'
| b'0'..=b'9'
| b'/'
| b'.'
| b'_'
| b'-'
)
}) {
path.into_owned()
} else {
format!("'{}'", path.replace('\'', "'\\''"))
}
}

fn unquote_command_path(path: &str) -> String {
let path = path.trim();
if path.len() >= 2 && path.starts_with('\'') && path.ends_with('\'') {
path[1..path.len() - 1].replace("'\\''", "'")
} else if path.len() >= 2 && path.starts_with('"') && path.ends_with('"') {
path[1..path.len() - 1].replace("\\\"", "\"")
} else {
path.to_string()
}
}

fn has_windows_absolute_prefix(path: &str) -> bool {
let bytes = path.as_bytes();
(bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/'))
|| path.starts_with(r"\\")
|| path.starts_with("//")
}

fn is_portable_absolute_path(path: &str) -> bool {
Path::new(path).is_absolute() || path.starts_with('/') || has_windows_absolute_prefix(path)
}

fn portable_file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().unwrap_or(path)
}

fn is_absolute_rtk_hook_command(command: &str) -> bool {
let Some(exe) = command.trim().strip_suffix(" hook claude") else {
return false;
};
let exe = unquote_command_path(exe);
let file_name = portable_file_name(&exe);

is_portable_absolute_path(&exe)
&& (file_name == "rtk" || file_name.eq_ignore_ascii_case("rtk.exe"))
}

fn is_claude_native_hook_command(command: &str) -> bool {
let command = command.trim();
command == CLAUDE_HOOK_COMMAND
|| claude_hook_command().is_ok_and(|current| command == current)
|| is_absolute_rtk_hook_command(command)
}

// Legacy full instructions for backward compatibility (--claude-md mode)
const RTK_INSTRUCTIONS: &str = r##"<!-- rtk-instructions v2 -->
# RTK (Rust Token Killer) - Token-Optimized Commands
Expand Down Expand Up @@ -546,7 +625,9 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool {
for hook in hooks_array {
if let Some(command) = hook.get("command").and_then(|c| c.as_str()) {
// Match both legacy script path and new binary command
if command.contains(REWRITE_HOOK_FILE) || command == CLAUDE_HOOK_COMMAND {
let is_rtk_hook = command.contains(REWRITE_HOOK_FILE)
|| is_claude_native_hook_command(command);
if is_rtk_hook {
return false;
}
}
Expand Down Expand Up @@ -1112,7 +1193,9 @@ fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool {
.flatten()
.filter_map(|hook| hook.get("command")?.as_str())
.any(|cmd| {
cmd == hook_command || cmd == CLAUDE_HOOK_COMMAND || cmd.contains(REWRITE_HOOK_FILE)
cmd == hook_command
|| cmd.contains(REWRITE_HOOK_FILE)
|| is_claude_native_hook_command(cmd)
})
}

Expand All @@ -1137,6 +1220,7 @@ fn run_default_mode(

// 1. Migrate old hook script if present
migrate_old_hook_script(ctx);
let hook_command = claude_hook_command()?;

// 2. Write RTK.md
write_if_changed(&rtk_md_path, RTK_SLIM, RTK_MD, ctx)?;
Expand All @@ -1155,7 +1239,7 @@ fn run_default_mode(
// 4. Print success message (skip in dry-run)
if !dry_run {
println!("\nRTK hook registered (global).\n");
println!(" Command: {}", CLAUDE_HOOK_COMMAND);
println!(" Command: {}", hook_command);
println!(" RTK.md: {} (10 lines)", rtk_md_path.display());
if let Some(path) = &opencode_plugin_path {
println!(" OpenCode: {}", path.display());
Expand All @@ -1170,7 +1254,7 @@ fn run_default_mode(

// 5. Patch settings.json with binary command
let patch_result =
patch_settings_json_command(CLAUDE_HOOK_COMMAND, patch_mode, install_opencode, ctx)?;
patch_settings_json_command(&hook_command, patch_mode, install_opencode, ctx)?;

// Report result
if !dry_run {
Expand Down Expand Up @@ -1431,6 +1515,7 @@ fn run_hook_only_mode(

// Migrate old hook script if present
migrate_old_hook_script(ctx);
let hook_command = claude_hook_command()?;

let opencode_plugin_path = if install_opencode {
let path = prepare_opencode_plugin_path()?;
Expand All @@ -1442,7 +1527,7 @@ fn run_hook_only_mode(

if !dry_run {
println!("\nRTK hook registered (hook-only mode).\n");
println!(" Command: {}", CLAUDE_HOOK_COMMAND);
println!(" Command: {}", hook_command);
if let Some(path) = &opencode_plugin_path {
println!(" OpenCode: {}", path.display());
}
Expand All @@ -1453,7 +1538,7 @@ fn run_hook_only_mode(

// Patch settings.json with binary command
let patch_result =
patch_settings_json_command(CLAUDE_HOOK_COMMAND, patch_mode, install_opencode, ctx)?;
patch_settings_json_command(&hook_command, patch_mode, install_opencode, ctx)?;

// Report result
if !dry_run {
Expand Down Expand Up @@ -5308,6 +5393,40 @@ mod tests {
assert!(hook_already_present(&json_content, CLAUDE_HOOK_COMMAND));
}

#[test]
fn test_hook_already_present_absolute_native_command() {
let json_content = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "/opt/homebrew/bin/rtk hook claude"
}]
}]
}
});

assert!(hook_already_present(&json_content, CLAUDE_HOOK_COMMAND));
}

#[test]
fn test_hook_already_present_windows_absolute_native_command() {
let json_content = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": r#""C:\Program Files\rtk\rtk.exe" hook claude"#
}]
}]
}
});

assert!(hook_already_present(&json_content, CLAUDE_HOOK_COMMAND));
}

#[test]
fn test_hook_not_present_other_hooks() {
let json_content = serde_json::json!({
Expand Down Expand Up @@ -5561,6 +5680,40 @@ mod tests {
);
}

#[test]
fn test_remove_hook_from_json_absolute_native_command() {
let mut json_content = serde_json::json!({
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "/some/other/hook.sh"
}]
},
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "/usr/local/bin/rtk hook claude"
}]
}
]
}
});

let removed = remove_hook_from_json(&mut json_content);
assert!(removed);

let pre_tool_use = json_content["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre_tool_use.len(), 1);
assert_eq!(
pre_tool_use[0]["hooks"][0]["command"].as_str().unwrap(),
"/some/other/hook.sh"
);
}

#[test]
fn test_remove_hook_when_not_present() {
let mut json_content = serde_json::json!({
Expand Down Expand Up @@ -5897,6 +6050,40 @@ mod tests {
}
}

fn claude_settings_commands(claude_dir: &Path) -> Vec<String> {
let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap();
let root: serde_json::Value = serde_json::from_str(&settings).unwrap();
root["hooks"][PRE_TOOL_USE_KEY]
.as_array()
.unwrap()
.iter()
.filter_map(|entry| entry["hooks"][0]["command"].as_str())
.map(str::to_string)
.collect()
}

fn first_claude_settings_command(claude_dir: &Path) -> String {
claude_settings_commands(claude_dir)
.into_iter()
.next()
.unwrap()
}

fn assert_absolute_claude_hook_command(command: &str) {
assert_ne!(
command, CLAUDE_HOOK_COMMAND,
"Claude hook must not rely on PATH lookup"
);
let exe = command
.strip_suffix(" hook claude")
.expect("hook command must keep hook claude arguments");
let exe = exe.trim_matches('"').trim_matches('\'');
assert!(
Path::new(exe).is_absolute(),
"hook executable must be absolute, got {command:?}"
);
}

#[test]
fn test_global_default_mode_creates_artifacts() {
let tmp = TempDir::new().unwrap();
Expand All @@ -5911,11 +6098,19 @@ mod tests {

let settings = claude_dir.join(SETTINGS_JSON);
assert!(settings.exists(), "settings.json must be created");
let content = fs::read_to_string(&settings).unwrap();
assert!(
content.contains(CLAUDE_HOOK_COMMAND),
"settings.json must contain hook command"
);
let command = first_claude_settings_command(claude_dir);
assert_absolute_claude_hook_command(&command);
});
}

#[test]
fn test_global_default_mode_uses_absolute_claude_hook_command() {
let tmp = TempDir::new().unwrap();
with_claude_dir_override(&tmp, |claude_dir| {
run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap();

let command = first_claude_settings_command(claude_dir);
assert_absolute_claude_hook_command(&command);
});
}

Expand Down Expand Up @@ -5943,9 +6138,9 @@ mod tests {
run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap();
run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap();

let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap();
let count = settings.matches(CLAUDE_HOOK_COMMAND).count();
assert_eq!(count, 1, "hook command must appear exactly once");
let commands = claude_settings_commands(claude_dir);
assert_eq!(commands.len(), 1, "hook command must appear exactly once");
assert_absolute_claude_hook_command(&commands[0]);
});
}

Expand All @@ -5963,11 +6158,8 @@ mod tests {
run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap();

assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created");
let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap();
assert!(
settings.contains(CLAUDE_HOOK_COMMAND),
"hook must be in settings.json after upgrade"
);
let command = first_claude_settings_command(claude_dir);
assert_absolute_claude_hook_command(&command);
});
}

Expand Down Expand Up @@ -6002,11 +6194,19 @@ mod tests {
!claude_dir.join(RTK_MD).exists(),
"RTK.md must NOT be created in hook-only mode"
);
let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap();
assert!(
settings.contains(CLAUDE_HOOK_COMMAND),
"settings.json must contain hook command"
);
let command = first_claude_settings_command(claude_dir);
assert_absolute_claude_hook_command(&command);
});
}

#[test]
fn test_global_hook_only_mode_uses_absolute_claude_hook_command() {
let tmp = TempDir::new().unwrap();
with_claude_dir_override(&tmp, |claude_dir| {
run_hook_only_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap();

let command = first_claude_settings_command(claude_dir);
assert_absolute_claude_hook_command(&command);
});
}

Expand Down
Loading