From fc295ca839fd6246a350dfc53be4a57d4fa12eb0 Mon Sep 17 00:00:00 2001 From: Ayoub KHEMISSI Date: Mon, 16 Feb 2026 20:38:30 +0100 Subject: [PATCH 1/4] feat(hook): add native cross-platform hook-rewrite command --- src/gain.rs | 15 +- src/hook_cmd.rs | 1126 +++++++++++++++++++++++++++++++++++++++++++++++ src/init.rs | 420 +++++++++++------- src/main.rs | 7 + 4 files changed, 1410 insertions(+), 158 deletions(-) create mode 100644 src/hook_cmd.rs diff --git a/src/gain.rs b/src/gain.rs index fae773c..f715296 100644 --- a/src/gain.rs +++ b/src/gain.rs @@ -96,8 +96,19 @@ pub fn run( .unwrap_or(6) .max(6); - let table_width = - 3 + 2 + cmd_width + 2 + count_width + 2 + saved_width + 2 + 6 + 2 + time_width + 2 + impact_width; + let table_width = 3 + + 2 + + cmd_width + + 2 + + count_width + + 2 + + saved_width + + 2 + + 6 + + 2 + + time_width + + 2 + + impact_width; println!("{}", "─".repeat(table_width)); println!( "{:>3} {:count_width$} {:>saved_width$} {:>6} {:>time_width$} {: ! { + // Read all stdin + let mut input = String::new(); + if std::io::stdin().read_to_string(&mut input).is_err() { + std::process::exit(0); + } + + // Parse JSON + let root: serde_json::Value = match serde_json::from_str(&input) { + Ok(v) => v, + Err(_) => std::process::exit(0), + }; + + // Extract command + let cmd = match root + .get("tool_input") + .and_then(|ti| ti.get("command")) + .and_then(|c| c.as_str()) + { + Some(c) if !c.is_empty() => c, + _ => std::process::exit(0), + }; + + // Skip heredocs + if cmd.contains("<<") { + std::process::exit(0); + } + + // Rewrite each segment of the command chain independently + // (already-rtk check is done per-segment in rewrite_segment) + let rewritten = match rewrite_chain(cmd) { + Some(r) => r, + None => std::process::exit(0), + }; + + // Build output JSON: preserve all original tool_input fields, override command + let mut updated_input = match root.get("tool_input").cloned() { + Some(v) => v, + None => std::process::exit(0), + }; + if let Some(obj) = updated_input.as_object_mut() { + obj.insert("command".to_string(), serde_json::Value::String(rewritten)); + } + + let output = serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": updated_input + } + }); + + println!("{}", serde_json::to_string(&output).unwrap_or_default()); + std::process::exit(0); +} + +/// Rewrite a command chain: split on " && " / " ; ", rewrite each segment. +/// Returns Some(rewritten) if at least one segment was rewritten, None otherwise. +fn rewrite_chain(cmd: &str) -> Option { + let segments = split_chain_segments(cmd); + + let mut any_rewritten = false; + let mut result = String::with_capacity(cmd.len() + 32); + + for (i, (segment, separator)) in segments.iter().enumerate() { + if i > 0 { + // Write the separator from the previous segment + if let Some(sep) = &segments[i - 1].1 { + result.push_str(sep); + } + } + + // Try to rewrite this segment + match rewrite_segment(segment) { + Some(rewritten) => { + result.push_str(&rewritten); + any_rewritten = true; + } + None => { + result.push_str(segment); + } + } + } + + if any_rewritten { + Some(result) + } else { + None + } +} + +/// Try to rewrite a single command segment (with env prefix handling). +fn rewrite_segment(segment: &str) -> Option { + let trimmed = segment.trim(); + if trimmed.is_empty() || trimmed.starts_with("rtk ") || trimmed.contains("/rtk ") { + return None; + } + + let (env_prefix, match_cmd, cmd_body) = strip_env_prefix(trimmed); + try_rewrite(match_cmd, cmd_body).map(|r| { + // Preserve leading whitespace from original segment + let leading_ws = &segment[..segment.len() - segment.trim_start().len()]; + format!("{}{}{}", leading_ws, env_prefix, r) + }) +} + +/// Split a command on " && " and " ; " separators, respecting quotes. +/// Returns Vec of (segment, Option). +fn split_chain_segments(cmd: &str) -> Vec<(&str, Option<&str>)> { + let mut segments = Vec::new(); + let bytes = cmd.as_bytes(); + let mut start = 0; + let mut i = 0; + let mut in_single_quote = false; + let mut in_double_quote = false; + + while i < bytes.len() { + match bytes[i] { + b'\'' if !in_double_quote => in_single_quote = !in_single_quote, + b'"' if !in_single_quote => in_double_quote = !in_double_quote, + b' ' if !in_single_quote && !in_double_quote => { + let rest = &cmd[i..]; + if rest.starts_with(" && ") { + segments.push((&cmd[start..i], Some(" && "))); + i += 4; + start = i; + continue; + } + if rest.starts_with(" ; ") { + segments.push((&cmd[start..i], Some(" ; "))); + i += 3; + start = i; + continue; + } + } + _ => {} + } + i += 1; + } + + // Last segment + segments.push((&cmd[start..], None)); + segments +} + +/// Strip leading env var assignments (e.g. "FOO=bar BAZ=1 cmd args") +/// Returns (env_prefix, match_cmd, cmd_body) +/// - env_prefix: "FOO=bar BAZ=1 " (with trailing space) +/// - match_cmd: "cmd args" (for pattern matching) +/// - cmd_body: "cmd args" (same as match_cmd, used for rewriting) +fn strip_env_prefix(cmd: &str) -> (&str, &str, &str) { + if let Some(m) = ENV_PREFIX_RE.find(cmd) { + let prefix = &cmd[..m.end()]; + let rest = &cmd[m.end()..]; + (prefix, rest, rest) + } else { + ("", cmd, cmd) + } +} + +/// Attempt to rewrite a command. Returns Some(rewritten_body) or None. +fn try_rewrite(match_cmd: &str, cmd_body: &str) -> Option { + // --- Git --- + if match_cmd.starts_with("git ") || match_cmd == "git" { + return try_rewrite_git(match_cmd, cmd_body); + } + + // --- GitHub CLI --- + if match_cmd.starts_with("gh ") { + return try_rewrite_gh(match_cmd, cmd_body); + } + + // --- Cargo --- + if match_cmd.starts_with("cargo ") { + return try_rewrite_cargo(match_cmd, cmd_body); + } + + // --- File operations --- + if match_cmd.starts_with("cat ") { + return Some(replace_prefix(cmd_body, "cat ", "rtk read ")); + } + if match_cmd.starts_with("rg ") { + return Some(replace_prefix(cmd_body, "rg ", "rtk grep ")); + } + if match_cmd.starts_with("grep ") { + return Some(replace_prefix(cmd_body, "grep ", "rtk grep ")); + } + if match_cmd == "ls" || match_cmd.starts_with("ls ") { + return Some(replace_prefix(cmd_body, "ls", "rtk ls")); + } + if match_cmd == "tree" || match_cmd.starts_with("tree ") { + return Some(replace_prefix(cmd_body, "tree", "rtk tree")); + } + if match_cmd.starts_with("find ") { + return Some(replace_prefix(cmd_body, "find ", "rtk find ")); + } + if match_cmd.starts_with("diff ") { + return Some(replace_prefix(cmd_body, "diff ", "rtk diff ")); + } + if match_cmd.starts_with("head ") { + return try_rewrite_head(match_cmd); + } + + // --- JS/TS tooling --- + if let Some(r) = try_rewrite_js_ts(match_cmd, cmd_body) { + return Some(r); + } + + // --- Containers --- + if match_cmd.starts_with("docker ") { + return try_rewrite_docker(match_cmd, cmd_body); + } + if match_cmd.starts_with("kubectl ") { + return try_rewrite_kubectl(match_cmd, cmd_body); + } + + // --- Network --- + if match_cmd.starts_with("curl ") { + return Some(replace_prefix(cmd_body, "curl ", "rtk curl ")); + } + if match_cmd.starts_with("wget ") { + return Some(replace_prefix(cmd_body, "wget ", "rtk wget ")); + } + + // --- pnpm package management --- + if let Some(r) = try_rewrite_pnpm_pkg(match_cmd, cmd_body) { + return Some(r); + } + + // --- Python --- + if let Some(r) = try_rewrite_python(match_cmd, cmd_body) { + return Some(r); + } + + // --- Go --- + if let Some(r) = try_rewrite_go(match_cmd, cmd_body) { + return Some(r); + } + + None +} + +// --- Category-specific rewriters --- + +fn try_rewrite_git(match_cmd: &str, cmd_body: &str) -> Option { + // Strip git global flags to find the subcommand + let after_git = match_cmd.strip_prefix("git ").unwrap_or(""); + let stripped = GIT_GLOBAL_FLAGS_RE.replace_all(after_git, ""); + let stripped = stripped.trim_start(); + + let subcmd = stripped.split_whitespace().next().unwrap_or(""); + match subcmd { + "status" | "diff" | "log" | "add" | "commit" | "push" | "pull" | "branch" | "fetch" + | "stash" | "show" | "worktree" => Some(format!("rtk {}", cmd_body)), + _ => None, + } +} + +fn try_rewrite_gh(match_cmd: &str, cmd_body: &str) -> Option { + let after_gh = match_cmd.strip_prefix("gh ").unwrap_or(""); + let subcmd = after_gh.split_whitespace().next().unwrap_or(""); + match subcmd { + "pr" | "issue" | "run" | "api" | "release" => { + Some(replace_prefix(cmd_body, "gh ", "rtk gh ")) + } + _ => None, + } +} + +fn try_rewrite_cargo(match_cmd: &str, cmd_body: &str) -> Option { + let after_cargo = match_cmd.strip_prefix("cargo ").unwrap_or(""); + // Strip toolchain prefix (+nightly, +stable, etc.) + let after_toolchain = CARGO_TOOLCHAIN_RE.replace(after_cargo, ""); + let subcmd = after_toolchain.split_whitespace().next().unwrap_or(""); + match subcmd { + "test" | "build" | "clippy" | "check" | "install" | "fmt" => { + Some(format!("rtk {}", cmd_body)) + } + _ => None, + } +} + +fn try_rewrite_head(match_cmd: &str) -> Option { + // head -N file → rtk read file --max-lines N + if let Some(caps) = HEAD_DASH_N_RE.captures(match_cmd) { + let lines = &caps[1]; + let file = &caps[2]; + return Some(format!("rtk read {} --max-lines {}", file, lines)); + } + // head --lines=N file + if let Some(caps) = HEAD_LINES_RE.captures(match_cmd) { + let lines = &caps[1]; + let file = &caps[2]; + return Some(format!("rtk read {} --max-lines {}", file, lines)); + } + None +} + +fn try_rewrite_js_ts(match_cmd: &str, cmd_body: &str) -> Option { + // vitest (with optional pnpm/npx prefix) + if starts_with_any(match_cmd, &["vitest", "pnpm vitest", "npx vitest"]) { + // Strip prefixes, replace with "rtk vitest run" + let rest = match_cmd + .trim_start_matches("pnpm ") + .trim_start_matches("npx ") + .trim_start_matches("vitest") + .trim_start_matches(" run") + .trim_start(); + return Some(if rest.is_empty() { + "rtk vitest run".to_string() + } else { + format!("rtk vitest run {}", rest) + }); + } + + // pnpm test → rtk vitest run + if match_cmd == "pnpm test" || match_cmd.starts_with("pnpm test ") { + let rest = match_cmd.strip_prefix("pnpm test").unwrap_or("").trim(); + return Some(if rest.is_empty() { + "rtk vitest run".to_string() + } else { + format!("rtk vitest run {}", rest) + }); + } + + // npm test + if match_cmd == "npm test" || match_cmd.starts_with("npm test ") { + return Some(replace_prefix(cmd_body, "npm test", "rtk npm test")); + } + + // npm run + if match_cmd.starts_with("npm run ") { + return Some(replace_prefix(cmd_body, "npm run ", "rtk npm ")); + } + + // vue-tsc (with optional npx prefix) + if match_cmd == "vue-tsc" + || match_cmd.starts_with("vue-tsc ") + || match_cmd == "npx vue-tsc" + || match_cmd.starts_with("npx vue-tsc ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("vue-tsc") + .trim_start(); + return Some(if rest.is_empty() { + "rtk tsc".to_string() + } else { + format!("rtk tsc {}", rest) + }); + } + + // pnpm tsc + if match_cmd == "pnpm tsc" || match_cmd.starts_with("pnpm tsc ") { + let rest = match_cmd.strip_prefix("pnpm tsc").unwrap_or("").trim(); + return Some(if rest.is_empty() { + "rtk tsc".to_string() + } else { + format!("rtk tsc {}", rest) + }); + } + + // tsc (with optional npx prefix) + if match_cmd == "tsc" + || match_cmd.starts_with("tsc ") + || match_cmd == "npx tsc" + || match_cmd.starts_with("npx tsc ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("tsc") + .trim_start(); + return Some(if rest.is_empty() { + "rtk tsc".to_string() + } else { + format!("rtk tsc {}", rest) + }); + } + + // pnpm lint + if match_cmd == "pnpm lint" || match_cmd.starts_with("pnpm lint ") { + let rest = match_cmd.strip_prefix("pnpm lint").unwrap_or("").trim(); + return Some(if rest.is_empty() { + "rtk lint".to_string() + } else { + format!("rtk lint {}", rest) + }); + } + + // eslint (with optional npx prefix) + if match_cmd == "eslint" + || match_cmd.starts_with("eslint ") + || match_cmd == "npx eslint" + || match_cmd.starts_with("npx eslint ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("eslint") + .trim_start(); + return Some(if rest.is_empty() { + "rtk lint".to_string() + } else { + format!("rtk lint {}", rest) + }); + } + + // prettier (with optional npx prefix) + if match_cmd == "prettier" + || match_cmd.starts_with("prettier ") + || match_cmd == "npx prettier" + || match_cmd.starts_with("npx prettier ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("prettier") + .trim_start(); + return Some(if rest.is_empty() { + "rtk prettier".to_string() + } else { + format!("rtk prettier {}", rest) + }); + } + + // playwright (with optional npx/pnpm prefix) + if match_cmd == "playwright" + || match_cmd.starts_with("playwright ") + || match_cmd == "npx playwright" + || match_cmd.starts_with("npx playwright ") + || match_cmd == "pnpm playwright" + || match_cmd.starts_with("pnpm playwright ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("pnpm ") + .trim_start_matches("playwright") + .trim_start(); + return Some(if rest.is_empty() { + "rtk playwright".to_string() + } else { + format!("rtk playwright {}", rest) + }); + } + + // prisma (with optional npx prefix) + if match_cmd == "prisma" + || match_cmd.starts_with("prisma ") + || match_cmd == "npx prisma" + || match_cmd.starts_with("npx prisma ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("prisma") + .trim_start(); + return Some(if rest.is_empty() { + "rtk prisma".to_string() + } else { + format!("rtk prisma {}", rest) + }); + } + + None +} + +fn try_rewrite_docker(match_cmd: &str, cmd_body: &str) -> Option { + let after_docker = match_cmd.strip_prefix("docker ").unwrap_or(""); + + // docker compose → always rewrite + if after_docker.starts_with("compose") { + return Some(replace_prefix(cmd_body, "docker ", "rtk docker ")); + } + + // Strip docker global flags + let stripped = DOCKER_GLOBAL_FLAGS_RE.replace_all(after_docker, ""); + let stripped = stripped.trim_start(); + let subcmd = stripped.split_whitespace().next().unwrap_or(""); + match subcmd { + "ps" | "images" | "logs" | "run" | "build" | "exec" => { + Some(replace_prefix(cmd_body, "docker ", "rtk docker ")) + } + _ => None, + } +} + +fn try_rewrite_kubectl(match_cmd: &str, cmd_body: &str) -> Option { + let after_kubectl = match_cmd.strip_prefix("kubectl ").unwrap_or(""); + let stripped = KUBECTL_GLOBAL_FLAGS_RE.replace_all(after_kubectl, ""); + let stripped = stripped.trim_start(); + let subcmd = stripped.split_whitespace().next().unwrap_or(""); + match subcmd { + "get" | "logs" | "describe" | "apply" => { + Some(replace_prefix(cmd_body, "kubectl ", "rtk kubectl ")) + } + _ => None, + } +} + +fn try_rewrite_pnpm_pkg(match_cmd: &str, cmd_body: &str) -> Option { + if !match_cmd.starts_with("pnpm ") { + return None; + } + let after_pnpm = match_cmd.strip_prefix("pnpm ").unwrap_or(""); + let subcmd = after_pnpm.split_whitespace().next().unwrap_or(""); + match subcmd { + "list" | "ls" | "outdated" => Some(replace_prefix(cmd_body, "pnpm ", "rtk pnpm ")), + _ => None, + } +} + +fn try_rewrite_python(match_cmd: &str, cmd_body: &str) -> Option { + // pytest + if match_cmd == "pytest" || match_cmd.starts_with("pytest ") { + return Some(replace_prefix(cmd_body, "pytest", "rtk pytest")); + } + + // python -m pytest + if match_cmd.starts_with("python -m pytest") { + let rest = match_cmd + .strip_prefix("python -m pytest") + .unwrap_or("") + .trim_start(); + return Some(if rest.is_empty() { + "rtk pytest".to_string() + } else { + format!("rtk pytest {}", rest) + }); + } + + // ruff check/format + if match_cmd.starts_with("ruff ") { + let after_ruff = match_cmd.strip_prefix("ruff ").unwrap_or(""); + let subcmd = after_ruff.split_whitespace().next().unwrap_or(""); + if subcmd == "check" || subcmd == "format" { + return Some(replace_prefix(cmd_body, "ruff ", "rtk ruff ")); + } + } + + // pip list/outdated/install/show + if match_cmd.starts_with("pip ") { + let after_pip = match_cmd.strip_prefix("pip ").unwrap_or(""); + let subcmd = after_pip.split_whitespace().next().unwrap_or(""); + if matches!(subcmd, "list" | "outdated" | "install" | "show") { + return Some(replace_prefix(cmd_body, "pip ", "rtk pip ")); + } + } + + // uv pip list/outdated/install/show + if match_cmd.starts_with("uv pip ") { + let after_uv_pip = match_cmd.strip_prefix("uv pip ").unwrap_or(""); + let subcmd = after_uv_pip.split_whitespace().next().unwrap_or(""); + if matches!(subcmd, "list" | "outdated" | "install" | "show") { + return Some(replace_prefix(cmd_body, "uv pip ", "rtk pip ")); + } + } + + None +} + +fn try_rewrite_go(match_cmd: &str, cmd_body: &str) -> Option { + // go test/build/vet + if match_cmd.starts_with("go ") { + let after_go = match_cmd.strip_prefix("go ").unwrap_or(""); + let subcmd = after_go.split_whitespace().next().unwrap_or(""); + match subcmd { + "test" => return Some(replace_prefix(cmd_body, "go test", "rtk go test")), + "build" => return Some(replace_prefix(cmd_body, "go build", "rtk go build")), + "vet" => return Some(replace_prefix(cmd_body, "go vet", "rtk go vet")), + _ => {} + } + } + + // golangci-lint + if match_cmd == "golangci-lint" || match_cmd.starts_with("golangci-lint ") { + return Some(replace_prefix( + cmd_body, + "golangci-lint", + "rtk golangci-lint", + )); + } + + None +} + +// --- Helpers --- + +/// Replace a prefix in cmd_body. Simple string replacement of first occurrence. +fn replace_prefix(cmd_body: &str, old_prefix: &str, new_prefix: &str) -> String { + if let Some(rest) = cmd_body.strip_prefix(old_prefix) { + format!("{}{}", new_prefix, rest) + } else { + // Fallback: just prepend rtk + format!("rtk {}", cmd_body) + } +} + +/// Check if s starts with any of the given prefixes (exact or followed by space) +fn starts_with_any(s: &str, prefixes: &[&str]) -> bool { + prefixes + .iter() + .any(|p| s == *p || s.starts_with(&format!("{} ", p))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: simulate rewrite logic without stdin/stdout + fn rewrite(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + rewrite_chain(cmd) + } + + // --- Git --- + #[test] + fn test_git_status() { + assert_eq!(rewrite("git status"), Some("rtk git status".into())); + } + + #[test] + fn test_git_diff_cached() { + assert_eq!( + rewrite("git diff --cached"), + Some("rtk git diff --cached".into()) + ); + } + + #[test] + fn test_git_log_with_flags() { + assert_eq!( + rewrite("git log --oneline -10"), + Some("rtk git log --oneline -10".into()) + ); + } + + #[test] + fn test_git_with_global_flags() { + assert_eq!( + rewrite("git --no-pager diff"), + Some("rtk git --no-pager diff".into()) + ); + } + + #[test] + fn test_git_add() { + assert_eq!(rewrite("git add ."), Some("rtk git add .".into())); + } + + #[test] + fn test_git_commit() { + assert_eq!( + rewrite("git commit -m \"msg\""), + Some("rtk git commit -m \"msg\"".into()) + ); + } + + #[test] + fn test_git_push() { + assert_eq!(rewrite("git push"), Some("rtk git push".into())); + } + + #[test] + fn test_git_checkout_no_match() { + assert_eq!(rewrite("git checkout main"), None); + } + + // --- GitHub CLI --- + #[test] + fn test_gh_pr_view() { + assert_eq!(rewrite("gh pr view 123"), Some("rtk gh pr view 123".into())); + } + + #[test] + fn test_gh_issue_list() { + assert_eq!(rewrite("gh issue list"), Some("rtk gh issue list".into())); + } + + #[test] + fn test_gh_repo_no_match() { + assert_eq!(rewrite("gh repo clone foo"), None); + } + + // --- Cargo --- + #[test] + fn test_cargo_test() { + assert_eq!(rewrite("cargo test"), Some("rtk cargo test".into())); + } + + #[test] + fn test_cargo_build_release() { + assert_eq!( + rewrite("cargo build --release"), + Some("rtk cargo build --release".into()) + ); + } + + #[test] + fn test_cargo_with_toolchain() { + assert_eq!( + rewrite("cargo +nightly build"), + Some("rtk cargo +nightly build".into()) + ); + } + + #[test] + fn test_cargo_run_no_match() { + assert_eq!(rewrite("cargo run"), None); + } + + // --- File operations --- + #[test] + fn test_cat_to_read() { + assert_eq!( + rewrite("cat src/main.rs"), + Some("rtk read src/main.rs".into()) + ); + } + + #[test] + fn test_rg_to_grep() { + assert_eq!( + rewrite("rg pattern src/"), + Some("rtk grep pattern src/".into()) + ); + } + + #[test] + fn test_grep_to_rtk_grep() { + assert_eq!(rewrite("grep -r TODO ."), Some("rtk grep -r TODO .".into())); + } + + #[test] + fn test_ls() { + assert_eq!(rewrite("ls -la"), Some("rtk ls -la".into())); + } + + #[test] + fn test_ls_bare() { + assert_eq!(rewrite("ls"), Some("rtk ls".into())); + } + + #[test] + fn test_find() { + assert_eq!( + rewrite("find . -name '*.rs'"), + Some("rtk find . -name '*.rs'".into()) + ); + } + + #[test] + fn test_head_dash_n() { + assert_eq!( + rewrite("head -20 src/main.rs"), + Some("rtk read src/main.rs --max-lines 20".into()) + ); + } + + #[test] + fn test_head_lines_eq() { + assert_eq!( + rewrite("head --lines=50 README.md"), + Some("rtk read README.md --max-lines 50".into()) + ); + } + + // --- JS/TS --- + #[test] + fn test_vitest() { + assert_eq!(rewrite("vitest run"), Some("rtk vitest run".into())); + } + + #[test] + fn test_npx_vitest() { + assert_eq!(rewrite("npx vitest"), Some("rtk vitest run".into())); + } + + #[test] + fn test_pnpm_test() { + assert_eq!(rewrite("pnpm test"), Some("rtk vitest run".into())); + } + + #[test] + fn test_npm_test() { + assert_eq!(rewrite("npm test"), Some("rtk npm test".into())); + } + + #[test] + fn test_npm_run() { + assert_eq!(rewrite("npm run build"), Some("rtk npm build".into())); + } + + #[test] + fn test_tsc() { + assert_eq!(rewrite("tsc --noEmit"), Some("rtk tsc --noEmit".into())); + } + + #[test] + fn test_npx_tsc() { + assert_eq!(rewrite("npx tsc --noEmit"), Some("rtk tsc --noEmit".into())); + } + + #[test] + fn test_eslint() { + assert_eq!(rewrite("eslint src/"), Some("rtk lint src/".into())); + } + + #[test] + fn test_prettier() { + assert_eq!( + rewrite("prettier --check ."), + Some("rtk prettier --check .".into()) + ); + } + + #[test] + fn test_playwright() { + assert_eq!( + rewrite("npx playwright test"), + Some("rtk playwright test".into()) + ); + } + + #[test] + fn test_prisma() { + assert_eq!( + rewrite("npx prisma generate"), + Some("rtk prisma generate".into()) + ); + } + + // --- Containers --- + #[test] + fn test_docker_ps() { + assert_eq!(rewrite("docker ps"), Some("rtk docker ps".into())); + } + + #[test] + fn test_docker_compose() { + assert_eq!( + rewrite("docker compose up -d"), + Some("rtk docker compose up -d".into()) + ); + } + + #[test] + fn test_kubectl_get() { + assert_eq!( + rewrite("kubectl get pods"), + Some("rtk kubectl get pods".into()) + ); + } + + // --- Network --- + #[test] + fn test_curl() { + assert_eq!( + rewrite("curl https://api.example.com"), + Some("rtk curl https://api.example.com".into()) + ); + } + + #[test] + fn test_wget() { + assert_eq!( + rewrite("wget https://example.com/file"), + Some("rtk wget https://example.com/file".into()) + ); + } + + // --- pnpm package management --- + #[test] + fn test_pnpm_list() { + assert_eq!(rewrite("pnpm list"), Some("rtk pnpm list".into())); + } + + #[test] + fn test_pnpm_outdated() { + assert_eq!(rewrite("pnpm outdated"), Some("rtk pnpm outdated".into())); + } + + // --- Python --- + #[test] + fn test_pytest() { + assert_eq!(rewrite("pytest -x"), Some("rtk pytest -x".into())); + } + + #[test] + fn test_python_m_pytest() { + assert_eq!( + rewrite("python -m pytest tests/"), + Some("rtk pytest tests/".into()) + ); + } + + #[test] + fn test_ruff_check() { + assert_eq!( + rewrite("ruff check src/"), + Some("rtk ruff check src/".into()) + ); + } + + #[test] + fn test_pip_list() { + assert_eq!(rewrite("pip list"), Some("rtk pip list".into())); + } + + #[test] + fn test_uv_pip_install() { + assert_eq!( + rewrite("uv pip install flask"), + Some("rtk pip install flask".into()) + ); + } + + // --- Go --- + #[test] + fn test_go_test() { + assert_eq!(rewrite("go test ./..."), Some("rtk go test ./...".into())); + } + + #[test] + fn test_go_build() { + assert_eq!(rewrite("go build"), Some("rtk go build".into())); + } + + #[test] + fn test_go_vet() { + assert_eq!(rewrite("go vet ./..."), Some("rtk go vet ./...".into())); + } + + #[test] + fn test_golangci_lint() { + assert_eq!( + rewrite("golangci-lint run"), + Some("rtk golangci-lint run".into()) + ); + } + + // --- Edge cases --- + #[test] + fn test_already_rtk() { + assert_eq!(rewrite("rtk git status"), None); + } + + #[test] + fn test_heredoc_skip() { + assert_eq!(rewrite("cat < Result<(PathBuf, PathBuf)> { +/// Return path to the old bash hook file (for migration/cleanup) +fn old_hook_path() -> Result { 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)) + Ok(claude_dir.join("hooks").join("rtk-rewrite.sh")) } -/// 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()))?; +/// Migrate from old bash hook to native hook-rewrite command. +/// Removes the old .sh file and replaces the reference in settings.json. +/// Returns true if migration was performed. +fn migrate_old_hook(verbose: u8) -> Result { + let old_path = old_hook_path()?; + let mut migrated = false; - 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()))?; + // Remove old .sh file if it exists + if old_path.exists() { + fs::remove_file(&old_path) + .with_context(|| format!("Failed to remove old hook: {}", old_path.display()))?; if verbose > 0 { - eprintln!("Created hook: {}", hook_path.display()); + eprintln!("Removed old hook: {}", old_path.display()); + } + migrated = true; + } + + // Replace old hook reference in settings.json + let claude_dir = resolve_claude_dir()?; + let settings_path = claude_dir.join("settings.json"); + if settings_path.exists() { + let content = fs::read_to_string(&settings_path) + .with_context(|| format!("Failed to read {}", settings_path.display()))?; + if content.contains("rtk-rewrite.sh") { + if let Ok(mut root) = serde_json::from_str::(&content) { + if replace_old_hook_in_json(&mut root) { + let backup_path = settings_path.with_extension("json.bak"); + fs::copy(&settings_path, &backup_path).with_context(|| { + format!("Failed to backup to {}", backup_path.display()) + })?; + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize settings.json")?; + atomic_write(&settings_path, &serialized)?; + if verbose > 0 { + eprintln!("Migrated settings.json: rtk-rewrite.sh → rtk hook-rewrite"); + } + migrated = true; + } + } } - true + } + + Ok(migrated) +} + +/// Replace old "rtk-rewrite.sh" command references with "rtk hook-rewrite" in settings JSON +fn replace_old_hook_in_json(root: &mut serde_json::Value) -> bool { + let pre_tool_use_array = match root + .get_mut("hooks") + .and_then(|h| h.get_mut("PreToolUse")) + .and_then(|p| p.as_array_mut()) + { + Some(arr) => arr, + None => return false, }; - // 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()))?; + let mut replaced = false; + for entry in pre_tool_use_array.iter_mut() { + if let Some(hooks_array) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) { + for hook in hooks_array.iter_mut() { + if let Some(command) = hook.get("command").and_then(|c| c.as_str()) { + if command.contains("rtk-rewrite.sh") { + hook.as_object_mut().unwrap().insert( + "command".to_string(), + serde_json::Value::String(HOOK_COMMAND.to_string()), + ); + replaced = true; + } + } + } + } + } - Ok(changed) + replaced } /// Idempotent file write: create or update if content differs @@ -311,13 +346,13 @@ fn prompt_user_consent(settings_path: &Path) -> Result { } /// Print manual instructions for settings.json patching -fn print_manual_instructions(hook_path: &Path) { +fn print_manual_instructions() { 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!(" }}"); @@ -325,6 +360,7 @@ fn print_manual_instructions(hook_path: &Path) { } /// Remove RTK hook entry from settings.json +/// Matches both old (.sh) and new (native) formats /// Returns true if hook was found and removed fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { let hooks = match root.get_mut("hooks").and_then(|h| h.get_mut("PreToolUse")) { @@ -337,13 +373,13 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { None => return false, }; - // Find and remove RTK entry + // Find and remove RTK entry (both old .sh and new native formats) let original_len = pre_tool_use_array.len(); pre_tool_use_array.retain(|entry| { if let Some(hooks_array) = entry.get("hooks").and_then(|h| h.as_array()) { for hook in hooks_array { if let Some(command) = hook.get("command").and_then(|c| c.as_str()) { - if command.contains("rtk-rewrite.sh") { + if command.contains("rtk-rewrite.sh") || command == HOOK_COMMAND { return false; // Remove this entry } } @@ -408,12 +444,12 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { let claude_dir = resolve_claude_dir()?; let mut removed = Vec::new(); - // 1. Remove hook file - let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); - if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; - removed.push(format!("Hook: {}", hook_path.display())); + // 1. Remove old hook file (if exists) + let old_hook = claude_dir.join("hooks").join("rtk-rewrite.sh"); + if old_hook.exists() { + fs::remove_file(&old_hook) + .with_context(|| format!("Failed to remove hook: {}", old_hook.display()))?; + removed.push(format!("Hook: {}", old_hook.display())); } // 2. Remove RTK.md @@ -468,12 +504,9 @@ 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 { +fn patch_settings_json(hook_command: &str, mode: PatchMode, verbose: u8) -> 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")?; // Read or create settings.json let mut root = if settings_path.exists() { @@ -491,7 +524,7 @@ fn patch_settings_json(hook_path: &Path, mode: PatchMode, verbose: u8) -> Result }; // 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"); } @@ -501,12 +534,12 @@ fn patch_settings_json(hook_path: &Path, mode: PatchMode, verbose: u8) -> Result // Handle mode match mode { PatchMode::Skip => { - print_manual_instructions(hook_path); + print_manual_instructions(); return Ok(PatchResult::Skipped); } PatchMode::Ask => { if !prompt_user_consent(&settings_path)? { - print_manual_instructions(hook_path); + print_manual_instructions(); return Ok(PatchResult::Declined); } } @@ -516,7 +549,7 @@ fn patch_settings_json(hook_path: &Path, mode: PatchMode, verbose: u8) -> Result } // Deep-merge hook - insert_hook_entry(&mut root, &hook_command); + insert_hook_entry(&mut root, hook_command); // Backup original if settings_path.exists() { @@ -615,7 +648,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 +/// Matches on rtk-rewrite.sh substring OR "rtk hook-rewrite" to handle all formats fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { let pre_tool_use_array = match root .get("hooks") @@ -632,22 +665,17 @@ 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 + // Exact match cmd == hook_command + // Both contain old .sh path || (cmd.contains("rtk-rewrite.sh") && hook_command.contains("rtk-rewrite.sh")) + // Either is the native hook command + || cmd == HOOK_COMMAND + || cmd.contains("rtk-rewrite.sh") }) } -/// Default mode: hook + slim RTK.md + @RTK.md reference -#[cfg(not(unix))] -fn run_default_mode(_global: bool, _patch_mode: PatchMode, _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) -} - -#[cfg(unix)] +/// Default mode: native hook + slim RTK.md + @RTK.md reference (cross-platform) fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { if !global { // Local init: unchanged behavior (full injection into ./CLAUDE.md) @@ -658,29 +686,31 @@ fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result< 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()?; - ensure_hook_installed(&hook_path, verbose)?; + // 1. Migrate old bash hook if present + let hook_migrated = migrate_old_hook(verbose)?; // 2. Write RTK.md write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; // 3. Patch CLAUDE.md (add @RTK.md, migrate if needed) - let migrated = patch_claude_md(&claude_md_path, verbose)?; + let block_migrated = patch_claude_md(&claude_md_path, verbose)?; // 4. Print success message println!("\nRTK hook installed (global).\n"); - println!(" Hook: {}", hook_path.display()); + println!(" Hook: {} (native, cross-platform)", HOOK_COMMAND); println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); println!(" CLAUDE.md: @RTK.md reference added"); - if migrated { - println!("\n ✅ Migrated: removed 137-line RTK block from CLAUDE.md"); - println!(" replaced with @RTK.md (10 lines)"); + if block_migrated { + println!("\n Migrated: removed 137-line RTK block from CLAUDE.md"); + println!(" replaced with @RTK.md (10 lines)"); + } + if hook_migrated { + println!(" Migrated: rtk-rewrite.sh → rtk hook-rewrite (native)"); } // 5. Patch settings.json - let patch_result = patch_settings_json(&hook_path, patch_mode, verbose)?; + let patch_result = patch_settings_json(HOOK_COMMAND, patch_mode, verbose)?; // Report result match patch_result { @@ -701,32 +731,25 @@ fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result< Ok(()) } -/// 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<()> { - anyhow::bail!("Hook install requires Unix (macOS/Linux). Use WSL or --claude-md mode.") -} - -#[cfg(unix)] +/// Hook-only mode: just the hook, no RTK.md (cross-platform) fn run_hook_only_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { if !global { - eprintln!("⚠️ Warning: --hook-only only makes sense with --global"); + eprintln!("Warning: --hook-only only makes sense with --global"); eprintln!(" For local projects, use default mode or --claude-md"); return Ok(()); } - // Prepare and install hook - let (_hook_dir, hook_path) = prepare_hook_paths()?; - ensure_hook_installed(&hook_path, verbose)?; + // Migrate old bash hook if present + migrate_old_hook(verbose)?; println!("\nRTK hook installed (hook-only mode).\n"); - println!(" Hook: {}", hook_path.display()); + println!(" Hook: {} (native, cross-platform)", HOOK_COMMAND); println!( " Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)." ); // Patch settings.json - let patch_result = patch_settings_json(&hook_path, patch_mode, verbose)?; + let patch_result = patch_settings_json(HOOK_COMMAND, patch_mode, verbose)?; // Report result match patch_result { @@ -983,44 +1006,32 @@ fn resolve_claude_dir() -> Result { /// Show current rtk configuration pub fn show_config() -> Result<()> { let claude_dir = resolve_claude_dir()?; - let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); + let old_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"); + 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"); - - if is_executable && has_guards { - println!("✅ Hook: {} (executable, with guards)", hook_path.display()); - } else if !is_executable { - println!( - "⚠️ Hook: {} (NOT executable - run: chmod +x)", - hook_path.display() - ); - } else { - println!("⚠️ Hook: {} (no guards - outdated)", hook_path.display()); - } - } + // Check hook: detect native vs legacy + let settings_path = claude_dir.join("settings.json"); + let has_native_hook = if settings_path.exists() { + let content = fs::read_to_string(&settings_path).unwrap_or_default(); + content.contains(HOOK_COMMAND) + } else { + false + }; + let has_legacy_hook = old_hook_path.exists(); - #[cfg(not(unix))] - { - println!("✅ Hook: {} (exists)", hook_path.display()); - } + if has_native_hook { + println!("OK Hook: {} (native, cross-platform)", HOOK_COMMAND); + } else if has_legacy_hook { + println!( + "WARN Hook: {} (legacy bash - run: rtk init -g to migrate)", + old_hook_path.display() + ); } else { - println!("⚪ Hook: not found"); + println!("-- Hook: not configured"); } // Check RTK.md @@ -1059,26 +1070,24 @@ pub fn show_config() -> Result<()> { } // 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!("✅ settings.json: RTK hook configured"); + if hook_already_present(&root, HOOK_COMMAND) { + println!("OK settings.json: RTK hook configured"); } else { - println!("⚠️ settings.json: exists but RTK hook not configured"); + println!("WARN settings.json: exists but RTK hook not configured"); println!(" Run: rtk init -g --auto-patch"); } } else { - println!("⚠️ settings.json: exists but invalid JSON"); + println!("WARN settings.json: exists but invalid JSON"); } } else { - println!("⚪ settings.json: empty"); + println!("-- settings.json: empty"); } } else { - println!("⚪ settings.json: not found"); + println!("-- settings.json: not found"); } println!("\nUsage:"); @@ -1133,16 +1142,8 @@ mod tests { } #[test] - fn test_hook_has_guards() { - assert!(REWRITE_HOOK.contains("command -v rtk")); - assert!(REWRITE_HOOK.contains("command -v jq")); - // Guards must be BEFORE set -euo pipefail - let guard_pos = REWRITE_HOOK.find("command -v rtk").unwrap(); - let set_pos = REWRITE_HOOK.find("set -euo pipefail").unwrap(); - assert!( - guard_pos < set_pos, - "Guards must come before set -euo pipefail" - ); + fn test_hook_command_constant() { + assert_eq!(HOOK_COMMAND, "rtk hook-rewrite"); } #[test] @@ -1171,23 +1172,11 @@ More content"#; } #[test] - #[cfg(unix)] - fn test_default_mode_creates_hook_and_rtk_md() { - let temp = TempDir::new().unwrap(); - 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(&rtk_md_path, RTK_SLIM).unwrap(); - - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap(); - - assert!(hook_path.exists()); - assert!(rtk_md_path.exists()); - - let metadata = fs::metadata(&hook_path).unwrap(); - assert!(metadata.permissions().mode() & 0o111 != 0); + fn test_native_hook_no_file_needed() { + // Native hook is a command, not a file — no file creation needed + assert_eq!(HOOK_COMMAND, "rtk hook-rewrite"); + // RTK.md content should be non-empty + assert!(!RTK_SLIM.is_empty()); } #[test] @@ -1310,6 +1299,41 @@ More notes assert!(hook_already_present(&json_content, hook_command)); } + #[test] + fn test_hook_already_present_native() { + let json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "rtk hook-rewrite" + }] + }] + } + }); + + assert!(hook_already_present(&json_content, HOOK_COMMAND)); + } + + #[test] + fn test_hook_already_present_detects_legacy_when_checking_native() { + // When checking for native hook, should also detect old .sh as "present" + let json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/Users/test/.claude/hooks/rtk-rewrite.sh" + }] + }] + } + }); + + assert!(hook_already_present(&json_content, HOOK_COMMAND)); + } + #[test] fn test_hook_not_present_empty() { let json_content = serde_json::json!({}); @@ -1511,4 +1535,88 @@ More notes let removed = remove_hook_from_json(&mut json_content); assert!(!removed); } + + #[test] + fn test_remove_hook_native_format() { + 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": "rtk hook-rewrite" + }] + } + ] + } + }); + + 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); + let command = pre_tool_use[0]["hooks"][0]["command"].as_str().unwrap(); + assert_eq!(command, "/some/other/hook.sh"); + } + + #[test] + fn test_replace_old_hook_in_json() { + let mut json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/Users/test/.claude/hooks/rtk-rewrite.sh" + }] + }] + } + }); + + let replaced = replace_old_hook_in_json(&mut json_content); + assert!(replaced); + + let command = json_content["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + assert_eq!(command, HOOK_COMMAND); + } + + #[test] + fn test_replace_old_hook_no_old_hook() { + let mut json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "rtk hook-rewrite" + }] + }] + } + }); + + let replaced = replace_old_hook_in_json(&mut json_content); + assert!(!replaced); + } + + #[test] + fn test_insert_hook_entry_native() { + let mut json_content = serde_json::json!({}); + insert_hook_entry(&mut json_content, HOOK_COMMAND); + + let command = json_content["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + assert_eq!(command, HOOK_COMMAND); + } } diff --git a/src/main.rs b/src/main.rs index cef7f3e..3a8aae8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod git; mod go_cmd; mod golangci_cmd; mod grep_cmd; +mod hook_cmd; mod init; mod json_cmd; mod learn; @@ -516,6 +517,10 @@ enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + + /// Hook rewrite for Claude Code PreToolUse (internal) + #[command(name = "hook-rewrite", hide = true)] + HookRewrite, } #[derive(Subcommand)] @@ -1358,6 +1363,8 @@ fn main() -> Result<()> { golangci_cmd::run(&args, cli.verbose)?; } + Commands::HookRewrite => hook_cmd::run(), + Commands::Proxy { args } => { use std::process::Command; From 74b767d792607ae6e6dcbdc677e4164362d9e91a Mon Sep 17 00:00:00 2001 From: Ayoub KHEMISSI Date: Mon, 16 Feb 2026 23:11:41 +0100 Subject: [PATCH 2/4] fix(windows): set 8 MB stack size to prevent debug build overflow Windows default stack is 1 MB vs 8 MB on Unix. The large Commands enum (30+ Clap variants) overflows in unoptimized debug builds. Add build.rs with /STACK linker flag for Windows only, zero runtime overhead. Co-Authored-By: Claude Opus 4.6 --- build.rs | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 build.rs diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..c32576d --- /dev/null +++ b/build.rs @@ -0,0 +1,7 @@ +fn main() { + // Windows default stack is 1 MB, which overflows in debug builds + // due to the large Commands enum (30+ Clap variants) and match routing. + // Set 8 MB stack to match Unix defaults. + #[cfg(target_os = "windows")] + println!("cargo:rustc-link-arg=/STACK:8388608"); +} From 5eb013fcca3efe67b870c996375b59539184498b Mon Sep 17 00:00:00 2001 From: Ayoub KHEMISSI Date: Thu, 19 Feb 2026 19:20:20 +0100 Subject: [PATCH 3/4] refactor(hook): split hook_cmd.rs into modular directory structure Split the monolithic 1127-line hook_cmd.rs into 8 focused modules under src/hook/ for better maintainability and code review: - mod.rs: entry point, chain parsing, orchestrator - git.rs: git + GitHub CLI rewriters - cargo.rs: cargo rewriter - files.rs: file ops (cat, grep, ls, tree, find, diff, head) + network - js_ts.rs: JS/TS tooling (vitest, tsc, eslint, prettier, playwright, prisma, pnpm) - containers.rs: Docker + kubectl rewriters - python.rs: pytest, ruff, pip/uv rewriters - go.rs: go test/build/vet + golangci-lint rewriters - helpers.rs: shared utility functions (replace_prefix, starts_with_any) All 71 hook tests passing. No behavioral changes. --- src/hook/cargo.rs | 58 +++ src/hook/containers.rs | 79 +++ src/hook/files.rs | 149 ++++++ src/hook/git.rs | 112 ++++ src/hook/go.rs | 61 +++ src/hook/helpers.rs | 16 + src/hook/js_ts.rs | 283 ++++++++++ src/hook/mod.rs | 382 ++++++++++++++ src/hook/python.rs | 96 ++++ src/hook_cmd.rs | 1126 ---------------------------------------- src/main.rs | 4 +- 11 files changed, 1238 insertions(+), 1128 deletions(-) create mode 100644 src/hook/cargo.rs create mode 100644 src/hook/containers.rs create mode 100644 src/hook/files.rs create mode 100644 src/hook/git.rs create mode 100644 src/hook/go.rs create mode 100644 src/hook/helpers.rs create mode 100644 src/hook/js_ts.rs create mode 100644 src/hook/mod.rs create mode 100644 src/hook/python.rs delete mode 100644 src/hook_cmd.rs diff --git a/src/hook/cargo.rs b/src/hook/cargo.rs new file mode 100644 index 0000000..f03833e --- /dev/null +++ b/src/hook/cargo.rs @@ -0,0 +1,58 @@ +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + /// Cargo toolchain prefix: cargo +nightly ... + static ref CARGO_TOOLCHAIN_RE: Regex = + Regex::new(r"^\+\S+\s+").unwrap(); +} + +pub fn try_rewrite_cargo(match_cmd: &str, cmd_body: &str) -> Option { + let after_cargo = match_cmd.strip_prefix("cargo ").unwrap_or(""); + let after_toolchain = CARGO_TOOLCHAIN_RE.replace(after_cargo, ""); + let subcmd = after_toolchain.split_whitespace().next().unwrap_or(""); + match subcmd { + "test" | "build" | "clippy" | "check" | "install" | "fmt" => { + Some(format!("rtk {}", cmd_body)) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::super::rewrite_chain; + + fn rewrite(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + rewrite_chain(cmd) + } + + #[test] + fn test_cargo_test() { + assert_eq!(rewrite("cargo test"), Some("rtk cargo test".into())); + } + + #[test] + fn test_cargo_build_release() { + assert_eq!( + rewrite("cargo build --release"), + Some("rtk cargo build --release".into()) + ); + } + + #[test] + fn test_cargo_with_toolchain() { + assert_eq!( + rewrite("cargo +nightly build"), + Some("rtk cargo +nightly build".into()) + ); + } + + #[test] + fn test_cargo_run_no_match() { + assert_eq!(rewrite("cargo run"), None); + } +} diff --git a/src/hook/containers.rs b/src/hook/containers.rs new file mode 100644 index 0000000..f41a666 --- /dev/null +++ b/src/hook/containers.rs @@ -0,0 +1,79 @@ +use super::helpers::replace_prefix; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + /// Strips docker global flags: -H, --context, --config + static ref DOCKER_GLOBAL_FLAGS_RE: Regex = + Regex::new(r"(?:(-H|--context|--config)\s+\S+\s*|--[a-z-]+=\S+\s*)").unwrap(); + + /// Strips kubectl global flags: --context, --kubeconfig, --namespace, -n + static ref KUBECTL_GLOBAL_FLAGS_RE: Regex = + Regex::new(r"(?:(--context|--kubeconfig|--namespace|-n)\s+\S+\s*|--[a-z-]+=\S+\s*)").unwrap(); +} + +pub fn try_rewrite_docker(match_cmd: &str, cmd_body: &str) -> Option { + let after_docker = match_cmd.strip_prefix("docker ").unwrap_or(""); + + // docker compose → always rewrite + if after_docker.starts_with("compose") { + return Some(replace_prefix(cmd_body, "docker ", "rtk docker ")); + } + + // Strip docker global flags + let stripped = DOCKER_GLOBAL_FLAGS_RE.replace_all(after_docker, ""); + let stripped = stripped.trim_start(); + let subcmd = stripped.split_whitespace().next().unwrap_or(""); + match subcmd { + "ps" | "images" | "logs" | "run" | "build" | "exec" => { + Some(replace_prefix(cmd_body, "docker ", "rtk docker ")) + } + _ => None, + } +} + +pub fn try_rewrite_kubectl(match_cmd: &str, cmd_body: &str) -> Option { + let after_kubectl = match_cmd.strip_prefix("kubectl ").unwrap_or(""); + let stripped = KUBECTL_GLOBAL_FLAGS_RE.replace_all(after_kubectl, ""); + let stripped = stripped.trim_start(); + let subcmd = stripped.split_whitespace().next().unwrap_or(""); + match subcmd { + "get" | "logs" | "describe" | "apply" => { + Some(replace_prefix(cmd_body, "kubectl ", "rtk kubectl ")) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::super::rewrite_chain; + + fn rewrite(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + rewrite_chain(cmd) + } + + #[test] + fn test_docker_ps() { + assert_eq!(rewrite("docker ps"), Some("rtk docker ps".into())); + } + + #[test] + fn test_docker_compose() { + assert_eq!( + rewrite("docker compose up -d"), + Some("rtk docker compose up -d".into()) + ); + } + + #[test] + fn test_kubectl_get() { + assert_eq!( + rewrite("kubectl get pods"), + Some("rtk kubectl get pods".into()) + ); + } +} diff --git a/src/hook/files.rs b/src/hook/files.rs new file mode 100644 index 0000000..74eb781 --- /dev/null +++ b/src/hook/files.rs @@ -0,0 +1,149 @@ +use super::helpers::replace_prefix; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + /// head -N file pattern + static ref HEAD_DASH_N_RE: Regex = + Regex::new(r"^head\s+-(\d+)\s+(.+)$").unwrap(); + + /// head --lines=N file pattern + static ref HEAD_LINES_RE: Regex = + Regex::new(r"^head\s+--lines=(\d+)\s+(.+)$").unwrap(); +} + +pub fn try_rewrite_head(match_cmd: &str) -> Option { + // head -N file → rtk read file --max-lines N + if let Some(caps) = HEAD_DASH_N_RE.captures(match_cmd) { + let lines = &caps[1]; + let file = &caps[2]; + return Some(format!("rtk read {} --max-lines {}", file, lines)); + } + // head --lines=N file + if let Some(caps) = HEAD_LINES_RE.captures(match_cmd) { + let lines = &caps[1]; + let file = &caps[2]; + return Some(format!("rtk read {} --max-lines {}", file, lines)); + } + None +} + +/// Try to rewrite file operation commands (cat, grep, ls, tree, find, diff, curl, wget). +/// Returns Some(rewritten) if matched, None otherwise. +pub fn try_rewrite_file_cmd(match_cmd: &str, cmd_body: &str) -> Option { + if match_cmd.starts_with("cat ") { + return Some(replace_prefix(cmd_body, "cat ", "rtk read ")); + } + if match_cmd.starts_with("rg ") { + return Some(replace_prefix(cmd_body, "rg ", "rtk grep ")); + } + if match_cmd.starts_with("grep ") { + return Some(replace_prefix(cmd_body, "grep ", "rtk grep ")); + } + if match_cmd == "ls" || match_cmd.starts_with("ls ") { + return Some(replace_prefix(cmd_body, "ls", "rtk ls")); + } + if match_cmd == "tree" || match_cmd.starts_with("tree ") { + return Some(replace_prefix(cmd_body, "tree", "rtk tree")); + } + if match_cmd.starts_with("find ") { + return Some(replace_prefix(cmd_body, "find ", "rtk find ")); + } + if match_cmd.starts_with("diff ") { + return Some(replace_prefix(cmd_body, "diff ", "rtk diff ")); + } + if match_cmd.starts_with("head ") { + return try_rewrite_head(match_cmd); + } + // Network commands + if match_cmd.starts_with("curl ") { + return Some(replace_prefix(cmd_body, "curl ", "rtk curl ")); + } + if match_cmd.starts_with("wget ") { + return Some(replace_prefix(cmd_body, "wget ", "rtk wget ")); + } + None +} + +#[cfg(test)] +mod tests { + use super::super::rewrite_chain; + + fn rewrite(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + rewrite_chain(cmd) + } + + #[test] + fn test_cat_to_read() { + assert_eq!( + rewrite("cat src/main.rs"), + Some("rtk read src/main.rs".into()) + ); + } + + #[test] + fn test_rg_to_grep() { + assert_eq!( + rewrite("rg pattern src/"), + Some("rtk grep pattern src/".into()) + ); + } + + #[test] + fn test_grep_to_rtk_grep() { + assert_eq!(rewrite("grep -r TODO ."), Some("rtk grep -r TODO .".into())); + } + + #[test] + fn test_ls() { + assert_eq!(rewrite("ls -la"), Some("rtk ls -la".into())); + } + + #[test] + fn test_ls_bare() { + assert_eq!(rewrite("ls"), Some("rtk ls".into())); + } + + #[test] + fn test_find() { + assert_eq!( + rewrite("find . -name '*.rs'"), + Some("rtk find . -name '*.rs'".into()) + ); + } + + #[test] + fn test_head_dash_n() { + assert_eq!( + rewrite("head -20 src/main.rs"), + Some("rtk read src/main.rs --max-lines 20".into()) + ); + } + + #[test] + fn test_head_lines_eq() { + assert_eq!( + rewrite("head --lines=50 README.md"), + Some("rtk read README.md --max-lines 50".into()) + ); + } + + #[test] + fn test_curl() { + assert_eq!( + rewrite("curl https://api.example.com"), + Some("rtk curl https://api.example.com".into()) + ); + } + + #[test] + fn test_wget() { + assert_eq!( + rewrite("wget https://example.com/file"), + Some("rtk wget https://example.com/file".into()) + ); + } +} diff --git a/src/hook/git.rs b/src/hook/git.rs new file mode 100644 index 0000000..6925f91 --- /dev/null +++ b/src/hook/git.rs @@ -0,0 +1,112 @@ +use super::helpers::replace_prefix; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + /// Strips git global flags before subcommand: -C path, -c key=val, --no-pager, etc. + static ref GIT_GLOBAL_FLAGS_RE: Regex = + Regex::new(r"(?:(-C|-c)\s+\S+\s*|--[a-z-]+=\S+\s*|--(no-pager|no-optional-locks|bare|literal-pathspecs)\s*)").unwrap(); +} + +pub fn try_rewrite_git(_match_cmd: &str, cmd_body: &str) -> Option { + let after_git = _match_cmd.strip_prefix("git ").unwrap_or(""); + let stripped = GIT_GLOBAL_FLAGS_RE.replace_all(after_git, ""); + let stripped = stripped.trim_start(); + + let subcmd = stripped.split_whitespace().next().unwrap_or(""); + match subcmd { + "status" | "diff" | "log" | "add" | "commit" | "push" | "pull" | "branch" | "fetch" + | "stash" | "show" | "worktree" => Some(format!("rtk {}", cmd_body)), + _ => None, + } +} + +pub fn try_rewrite_gh(match_cmd: &str, cmd_body: &str) -> Option { + let after_gh = match_cmd.strip_prefix("gh ").unwrap_or(""); + let subcmd = after_gh.split_whitespace().next().unwrap_or(""); + match subcmd { + "pr" | "issue" | "run" | "api" | "release" => { + Some(replace_prefix(cmd_body, "gh ", "rtk gh ")) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::super::rewrite_chain; + + fn rewrite(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + rewrite_chain(cmd) + } + + #[test] + fn test_git_status() { + assert_eq!(rewrite("git status"), Some("rtk git status".into())); + } + + #[test] + fn test_git_diff_cached() { + assert_eq!( + rewrite("git diff --cached"), + Some("rtk git diff --cached".into()) + ); + } + + #[test] + fn test_git_log_with_flags() { + assert_eq!( + rewrite("git log --oneline -10"), + Some("rtk git log --oneline -10".into()) + ); + } + + #[test] + fn test_git_with_global_flags() { + assert_eq!( + rewrite("git --no-pager diff"), + Some("rtk git --no-pager diff".into()) + ); + } + + #[test] + fn test_git_add() { + assert_eq!(rewrite("git add ."), Some("rtk git add .".into())); + } + + #[test] + fn test_git_commit() { + assert_eq!( + rewrite("git commit -m \"msg\""), + Some("rtk git commit -m \"msg\"".into()) + ); + } + + #[test] + fn test_git_push() { + assert_eq!(rewrite("git push"), Some("rtk git push".into())); + } + + #[test] + fn test_git_checkout_no_match() { + assert_eq!(rewrite("git checkout main"), None); + } + + #[test] + fn test_gh_pr_view() { + assert_eq!(rewrite("gh pr view 123"), Some("rtk gh pr view 123".into())); + } + + #[test] + fn test_gh_issue_list() { + assert_eq!(rewrite("gh issue list"), Some("rtk gh issue list".into())); + } + + #[test] + fn test_gh_repo_no_match() { + assert_eq!(rewrite("gh repo clone foo"), None); + } +} diff --git a/src/hook/go.rs b/src/hook/go.rs new file mode 100644 index 0000000..9def26c --- /dev/null +++ b/src/hook/go.rs @@ -0,0 +1,61 @@ +use super::helpers::replace_prefix; + +pub fn try_rewrite_go(match_cmd: &str, cmd_body: &str) -> Option { + // go test/build/vet + if match_cmd.starts_with("go ") { + let after_go = match_cmd.strip_prefix("go ").unwrap_or(""); + let subcmd = after_go.split_whitespace().next().unwrap_or(""); + match subcmd { + "test" => return Some(replace_prefix(cmd_body, "go test", "rtk go test")), + "build" => return Some(replace_prefix(cmd_body, "go build", "rtk go build")), + "vet" => return Some(replace_prefix(cmd_body, "go vet", "rtk go vet")), + _ => {} + } + } + + // golangci-lint + if match_cmd == "golangci-lint" || match_cmd.starts_with("golangci-lint ") { + return Some(replace_prefix( + cmd_body, + "golangci-lint", + "rtk golangci-lint", + )); + } + + None +} + +#[cfg(test)] +mod tests { + use super::super::rewrite_chain; + + fn rewrite(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + rewrite_chain(cmd) + } + + #[test] + fn test_go_test() { + assert_eq!(rewrite("go test ./..."), Some("rtk go test ./...".into())); + } + + #[test] + fn test_go_build() { + assert_eq!(rewrite("go build"), Some("rtk go build".into())); + } + + #[test] + fn test_go_vet() { + assert_eq!(rewrite("go vet ./..."), Some("rtk go vet ./...".into())); + } + + #[test] + fn test_golangci_lint() { + assert_eq!( + rewrite("golangci-lint run"), + Some("rtk golangci-lint run".into()) + ); + } +} diff --git a/src/hook/helpers.rs b/src/hook/helpers.rs new file mode 100644 index 0000000..d822e4c --- /dev/null +++ b/src/hook/helpers.rs @@ -0,0 +1,16 @@ +/// Replace a prefix in cmd_body. Simple string replacement of first occurrence. +pub fn replace_prefix(cmd_body: &str, old_prefix: &str, new_prefix: &str) -> String { + if let Some(rest) = cmd_body.strip_prefix(old_prefix) { + format!("{}{}", new_prefix, rest) + } else { + // Fallback: just prepend rtk + format!("rtk {}", cmd_body) + } +} + +/// Check if s starts with any of the given prefixes (exact or followed by space) +pub fn starts_with_any(s: &str, prefixes: &[&str]) -> bool { + prefixes + .iter() + .any(|p| s == *p || s.starts_with(&format!("{} ", p))) +} diff --git a/src/hook/js_ts.rs b/src/hook/js_ts.rs new file mode 100644 index 0000000..52fd06c --- /dev/null +++ b/src/hook/js_ts.rs @@ -0,0 +1,283 @@ +use super::helpers::{replace_prefix, starts_with_any}; + +pub fn try_rewrite_js_ts(match_cmd: &str, cmd_body: &str) -> Option { + // vitest (with optional pnpm/npx prefix) + if starts_with_any(match_cmd, &["vitest", "pnpm vitest", "npx vitest"]) { + let rest = match_cmd + .trim_start_matches("pnpm ") + .trim_start_matches("npx ") + .trim_start_matches("vitest") + .trim_start_matches(" run") + .trim_start(); + return Some(if rest.is_empty() { + "rtk vitest run".to_string() + } else { + format!("rtk vitest run {}", rest) + }); + } + + // pnpm test → rtk vitest run + if match_cmd == "pnpm test" || match_cmd.starts_with("pnpm test ") { + let rest = match_cmd.strip_prefix("pnpm test").unwrap_or("").trim(); + return Some(if rest.is_empty() { + "rtk vitest run".to_string() + } else { + format!("rtk vitest run {}", rest) + }); + } + + // npm test + if match_cmd == "npm test" || match_cmd.starts_with("npm test ") { + return Some(replace_prefix(cmd_body, "npm test", "rtk npm test")); + } + + // npm run + if match_cmd.starts_with("npm run ") { + return Some(replace_prefix(cmd_body, "npm run ", "rtk npm ")); + } + + // vue-tsc (with optional npx prefix) + if match_cmd == "vue-tsc" + || match_cmd.starts_with("vue-tsc ") + || match_cmd == "npx vue-tsc" + || match_cmd.starts_with("npx vue-tsc ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("vue-tsc") + .trim_start(); + return Some(if rest.is_empty() { + "rtk tsc".to_string() + } else { + format!("rtk tsc {}", rest) + }); + } + + // pnpm tsc + if match_cmd == "pnpm tsc" || match_cmd.starts_with("pnpm tsc ") { + let rest = match_cmd.strip_prefix("pnpm tsc").unwrap_or("").trim(); + return Some(if rest.is_empty() { + "rtk tsc".to_string() + } else { + format!("rtk tsc {}", rest) + }); + } + + // tsc (with optional npx prefix) + if match_cmd == "tsc" + || match_cmd.starts_with("tsc ") + || match_cmd == "npx tsc" + || match_cmd.starts_with("npx tsc ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("tsc") + .trim_start(); + return Some(if rest.is_empty() { + "rtk tsc".to_string() + } else { + format!("rtk tsc {}", rest) + }); + } + + // pnpm lint + if match_cmd == "pnpm lint" || match_cmd.starts_with("pnpm lint ") { + let rest = match_cmd.strip_prefix("pnpm lint").unwrap_or("").trim(); + return Some(if rest.is_empty() { + "rtk lint".to_string() + } else { + format!("rtk lint {}", rest) + }); + } + + // pnpm eslint (direct binary invocation) + if match_cmd == "pnpm eslint" || match_cmd.starts_with("pnpm eslint ") { + let rest = match_cmd.strip_prefix("pnpm eslint").unwrap_or("").trim(); + return Some(if rest.is_empty() { + "rtk lint".to_string() + } else { + format!("rtk lint {}", rest) + }); + } + + // eslint (with optional npx prefix) + if match_cmd == "eslint" + || match_cmd.starts_with("eslint ") + || match_cmd == "npx eslint" + || match_cmd.starts_with("npx eslint ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("eslint") + .trim_start(); + return Some(if rest.is_empty() { + "rtk lint".to_string() + } else { + format!("rtk lint {}", rest) + }); + } + + // prettier (with optional npx prefix) + if match_cmd == "prettier" + || match_cmd.starts_with("prettier ") + || match_cmd == "npx prettier" + || match_cmd.starts_with("npx prettier ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("prettier") + .trim_start(); + return Some(if rest.is_empty() { + "rtk prettier".to_string() + } else { + format!("rtk prettier {}", rest) + }); + } + + // playwright (with optional npx/pnpm prefix) + if match_cmd == "playwright" + || match_cmd.starts_with("playwright ") + || match_cmd == "npx playwright" + || match_cmd.starts_with("npx playwright ") + || match_cmd == "pnpm playwright" + || match_cmd.starts_with("pnpm playwright ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("pnpm ") + .trim_start_matches("playwright") + .trim_start(); + return Some(if rest.is_empty() { + "rtk playwright".to_string() + } else { + format!("rtk playwright {}", rest) + }); + } + + // prisma (with optional npx prefix) + if match_cmd == "prisma" + || match_cmd.starts_with("prisma ") + || match_cmd == "npx prisma" + || match_cmd.starts_with("npx prisma ") + { + let rest = match_cmd + .trim_start_matches("npx ") + .trim_start_matches("prisma") + .trim_start(); + return Some(if rest.is_empty() { + "rtk prisma".to_string() + } else { + format!("rtk prisma {}", rest) + }); + } + + None +} + +pub fn try_rewrite_pnpm_pkg(match_cmd: &str, cmd_body: &str) -> Option { + if !match_cmd.starts_with("pnpm ") { + return None; + } + let after_pnpm = match_cmd.strip_prefix("pnpm ").unwrap_or(""); + let subcmd = after_pnpm.split_whitespace().next().unwrap_or(""); + match subcmd { + "list" | "ls" | "outdated" => Some(replace_prefix(cmd_body, "pnpm ", "rtk pnpm ")), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::super::rewrite_chain; + + fn rewrite(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + rewrite_chain(cmd) + } + + #[test] + fn test_vitest() { + assert_eq!(rewrite("vitest run"), Some("rtk vitest run".into())); + } + + #[test] + fn test_npx_vitest() { + assert_eq!(rewrite("npx vitest"), Some("rtk vitest run".into())); + } + + #[test] + fn test_pnpm_test() { + assert_eq!(rewrite("pnpm test"), Some("rtk vitest run".into())); + } + + #[test] + fn test_npm_test() { + assert_eq!(rewrite("npm test"), Some("rtk npm test".into())); + } + + #[test] + fn test_npm_run() { + assert_eq!(rewrite("npm run build"), Some("rtk npm build".into())); + } + + #[test] + fn test_tsc() { + assert_eq!(rewrite("tsc --noEmit"), Some("rtk tsc --noEmit".into())); + } + + #[test] + fn test_npx_tsc() { + assert_eq!(rewrite("npx tsc --noEmit"), Some("rtk tsc --noEmit".into())); + } + + #[test] + fn test_eslint() { + assert_eq!(rewrite("eslint src/"), Some("rtk lint src/".into())); + } + + #[test] + fn test_pnpm_eslint() { + assert_eq!(rewrite("pnpm eslint ."), Some("rtk lint .".into())); + } + + #[test] + fn test_pnpm_eslint_bare() { + assert_eq!(rewrite("pnpm eslint"), Some("rtk lint".into())); + } + + #[test] + fn test_prettier() { + assert_eq!( + rewrite("prettier --check ."), + Some("rtk prettier --check .".into()) + ); + } + + #[test] + fn test_playwright() { + assert_eq!( + rewrite("npx playwright test"), + Some("rtk playwright test".into()) + ); + } + + #[test] + fn test_prisma() { + assert_eq!( + rewrite("npx prisma generate"), + Some("rtk prisma generate".into()) + ); + } + + #[test] + fn test_pnpm_list() { + assert_eq!(rewrite("pnpm list"), Some("rtk pnpm list".into())); + } + + #[test] + fn test_pnpm_outdated() { + assert_eq!(rewrite("pnpm outdated"), Some("rtk pnpm outdated".into())); + } +} diff --git a/src/hook/mod.rs b/src/hook/mod.rs new file mode 100644 index 0000000..7bbde35 --- /dev/null +++ b/src/hook/mod.rs @@ -0,0 +1,382 @@ +mod cargo; +mod containers; +mod files; +mod git; +mod go; +pub mod helpers; +mod js_ts; +mod python; + +use lazy_static::lazy_static; +use regex::Regex; +use std::io::Read; + +lazy_static! { + /// Matches leading env var assignments: FOO=bar BAZ=qux + static ref ENV_PREFIX_RE: Regex = + Regex::new(r"^([A-Za-z_][A-Za-z0-9_]*=[^ ]* +)+").unwrap(); +} + +/// Entry point for `rtk hook-rewrite`. +/// Reads JSON from stdin, rewrites command if it matches known patterns. +/// Never returns — always exits with code 0. +pub fn run() -> ! { + // Read all stdin + let mut input = String::new(); + if std::io::stdin().read_to_string(&mut input).is_err() { + std::process::exit(0); + } + + // Parse JSON + let root: serde_json::Value = match serde_json::from_str(&input) { + Ok(v) => v, + Err(_) => std::process::exit(0), + }; + + // Extract command + let cmd = match root + .get("tool_input") + .and_then(|ti| ti.get("command")) + .and_then(|c| c.as_str()) + { + Some(c) if !c.is_empty() => c, + _ => std::process::exit(0), + }; + + // Skip heredocs + if cmd.contains("<<") { + std::process::exit(0); + } + + // Rewrite each segment of the command chain independently + let rewritten = match rewrite_chain(cmd) { + Some(r) => r, + None => std::process::exit(0), + }; + + // Build output JSON: preserve all original tool_input fields, override command + let mut updated_input = match root.get("tool_input").cloned() { + Some(v) => v, + None => std::process::exit(0), + }; + if let Some(obj) = updated_input.as_object_mut() { + obj.insert("command".to_string(), serde_json::Value::String(rewritten)); + } + + let output = serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": updated_input + } + }); + + println!("{}", serde_json::to_string(&output).unwrap_or_default()); + std::process::exit(0); +} + +/// Rewrite a command chain: split on " && " / " ; ", rewrite each segment. +/// Returns Some(rewritten) if at least one segment was rewritten, None otherwise. +pub(crate) fn rewrite_chain(cmd: &str) -> Option { + let segments = split_chain_segments(cmd); + + let mut any_rewritten = false; + let mut result = String::with_capacity(cmd.len() + 32); + + for (i, (segment, _separator)) in segments.iter().enumerate() { + if i > 0 { + if let Some(sep) = &segments[i - 1].1 { + result.push_str(sep); + } + } + + match rewrite_segment(segment) { + Some(rewritten) => { + result.push_str(&rewritten); + any_rewritten = true; + } + None => { + result.push_str(segment); + } + } + } + + if any_rewritten { + Some(result) + } else { + None + } +} + +/// Try to rewrite a single command segment (with env prefix handling). +fn rewrite_segment(segment: &str) -> Option { + let trimmed = segment.trim(); + if trimmed.is_empty() || trimmed.starts_with("rtk ") || trimmed.contains("/rtk ") { + return None; + } + + let (env_prefix, match_cmd, cmd_body) = strip_env_prefix(trimmed); + try_rewrite(match_cmd, cmd_body).map(|r| { + let leading_ws = &segment[..segment.len() - segment.trim_start().len()]; + format!("{}{}{}", leading_ws, env_prefix, r) + }) +} + +/// Split a command on " && " and " ; " separators, respecting quotes. +fn split_chain_segments(cmd: &str) -> Vec<(&str, Option<&str>)> { + let mut segments = Vec::new(); + let bytes = cmd.as_bytes(); + let mut start = 0; + let mut i = 0; + let mut in_single_quote = false; + let mut in_double_quote = false; + + while i < bytes.len() { + match bytes[i] { + b'\'' if !in_double_quote => in_single_quote = !in_single_quote, + b'"' if !in_single_quote => in_double_quote = !in_double_quote, + b' ' if !in_single_quote && !in_double_quote => { + let rest = &cmd[i..]; + if rest.starts_with(" && ") { + segments.push((&cmd[start..i], Some(" && "))); + i += 4; + start = i; + continue; + } + if rest.starts_with(" ; ") { + segments.push((&cmd[start..i], Some(" ; "))); + i += 3; + start = i; + continue; + } + } + _ => {} + } + i += 1; + } + + segments.push((&cmd[start..], None)); + segments +} + +/// Strip leading env var assignments (e.g. "FOO=bar BAZ=1 cmd args") +fn strip_env_prefix(cmd: &str) -> (&str, &str, &str) { + if let Some(m) = ENV_PREFIX_RE.find(cmd) { + let prefix = &cmd[..m.end()]; + let rest = &cmd[m.end()..]; + (prefix, rest, rest) + } else { + ("", cmd, cmd) + } +} + +/// Attempt to rewrite a command. Returns Some(rewritten_body) or None. +fn try_rewrite(match_cmd: &str, cmd_body: &str) -> Option { + // --- Git --- + if match_cmd.starts_with("git ") || match_cmd == "git" { + return git::try_rewrite_git(match_cmd, cmd_body); + } + + // --- GitHub CLI --- + if match_cmd.starts_with("gh ") { + return git::try_rewrite_gh(match_cmd, cmd_body); + } + + // --- Cargo --- + if match_cmd.starts_with("cargo ") { + return cargo::try_rewrite_cargo(match_cmd, cmd_body); + } + + // --- File operations + network --- + if let Some(r) = files::try_rewrite_file_cmd(match_cmd, cmd_body) { + return Some(r); + } + + // --- JS/TS tooling --- + if let Some(r) = js_ts::try_rewrite_js_ts(match_cmd, cmd_body) { + return Some(r); + } + + // --- Containers --- + if match_cmd.starts_with("docker ") { + return containers::try_rewrite_docker(match_cmd, cmd_body); + } + if match_cmd.starts_with("kubectl ") { + return containers::try_rewrite_kubectl(match_cmd, cmd_body); + } + + // --- pnpm package management --- + if let Some(r) = js_ts::try_rewrite_pnpm_pkg(match_cmd, cmd_body) { + return Some(r); + } + + // --- Python --- + if let Some(r) = python::try_rewrite_python(match_cmd, cmd_body) { + return Some(r); + } + + // --- Go --- + if let Some(r) = go::try_rewrite_go(match_cmd, cmd_body) { + return Some(r); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rewrite(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + rewrite_chain(cmd) + } + + // --- Edge cases --- + #[test] + fn test_already_rtk() { + assert_eq!(rewrite("rtk git status"), None); + } + + #[test] + fn test_heredoc_skip() { + assert_eq!(rewrite("cat < Option { + // pytest + if match_cmd == "pytest" || match_cmd.starts_with("pytest ") { + return Some(replace_prefix(cmd_body, "pytest", "rtk pytest")); + } + + // python -m pytest + if match_cmd.starts_with("python -m pytest") { + let rest = match_cmd + .strip_prefix("python -m pytest") + .unwrap_or("") + .trim_start(); + return Some(if rest.is_empty() { + "rtk pytest".to_string() + } else { + format!("rtk pytest {}", rest) + }); + } + + // ruff check/format + if match_cmd.starts_with("ruff ") { + let after_ruff = match_cmd.strip_prefix("ruff ").unwrap_or(""); + let subcmd = after_ruff.split_whitespace().next().unwrap_or(""); + if subcmd == "check" || subcmd == "format" { + return Some(replace_prefix(cmd_body, "ruff ", "rtk ruff ")); + } + } + + // pip list/outdated/install/show + if match_cmd.starts_with("pip ") { + let after_pip = match_cmd.strip_prefix("pip ").unwrap_or(""); + let subcmd = after_pip.split_whitespace().next().unwrap_or(""); + if matches!(subcmd, "list" | "outdated" | "install" | "show") { + return Some(replace_prefix(cmd_body, "pip ", "rtk pip ")); + } + } + + // uv pip list/outdated/install/show + if match_cmd.starts_with("uv pip ") { + let after_uv_pip = match_cmd.strip_prefix("uv pip ").unwrap_or(""); + let subcmd = after_uv_pip.split_whitespace().next().unwrap_or(""); + if matches!(subcmd, "list" | "outdated" | "install" | "show") { + return Some(replace_prefix(cmd_body, "uv pip ", "rtk pip ")); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::super::rewrite_chain; + + fn rewrite(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + rewrite_chain(cmd) + } + + #[test] + fn test_pytest() { + assert_eq!(rewrite("pytest -x"), Some("rtk pytest -x".into())); + } + + #[test] + fn test_python_m_pytest() { + assert_eq!( + rewrite("python -m pytest tests/"), + Some("rtk pytest tests/".into()) + ); + } + + #[test] + fn test_ruff_check() { + assert_eq!( + rewrite("ruff check src/"), + Some("rtk ruff check src/".into()) + ); + } + + #[test] + fn test_pip_list() { + assert_eq!(rewrite("pip list"), Some("rtk pip list".into())); + } + + #[test] + fn test_uv_pip_install() { + assert_eq!( + rewrite("uv pip install flask"), + Some("rtk pip install flask".into()) + ); + } +} diff --git a/src/hook_cmd.rs b/src/hook_cmd.rs deleted file mode 100644 index 7f3ed32..0000000 --- a/src/hook_cmd.rs +++ /dev/null @@ -1,1126 +0,0 @@ -use lazy_static::lazy_static; -use regex::Regex; -use std::io::Read; - -lazy_static! { - /// Matches leading env var assignments: FOO=bar BAZ=qux - static ref ENV_PREFIX_RE: Regex = - Regex::new(r"^([A-Za-z_][A-Za-z0-9_]*=[^ ]* +)+").unwrap(); - - /// Strips git global flags before subcommand: -C path, -c key=val, --no-pager, etc. - static ref GIT_GLOBAL_FLAGS_RE: Regex = - Regex::new(r"(?:(-C|-c)\s+\S+\s*|--[a-z-]+=\S+\s*|--(no-pager|no-optional-locks|bare|literal-pathspecs)\s*)").unwrap(); - - /// Strips docker global flags: -H, --context, --config - static ref DOCKER_GLOBAL_FLAGS_RE: Regex = - Regex::new(r"(?:(-H|--context|--config)\s+\S+\s*|--[a-z-]+=\S+\s*)").unwrap(); - - /// Strips kubectl global flags: --context, --kubeconfig, --namespace, -n - static ref KUBECTL_GLOBAL_FLAGS_RE: Regex = - Regex::new(r"(?:(--context|--kubeconfig|--namespace|-n)\s+\S+\s*|--[a-z-]+=\S+\s*)").unwrap(); - - /// Cargo toolchain prefix: cargo +nightly ... - static ref CARGO_TOOLCHAIN_RE: Regex = - Regex::new(r"^\+\S+\s+").unwrap(); - - /// head -N file pattern - static ref HEAD_DASH_N_RE: Regex = - Regex::new(r"^head\s+-(\d+)\s+(.+)$").unwrap(); - - /// head --lines=N file pattern - static ref HEAD_LINES_RE: Regex = - Regex::new(r"^head\s+--lines=(\d+)\s+(.+)$").unwrap(); -} - -/// Entry point for `rtk hook-rewrite`. -/// Reads JSON from stdin, rewrites command if it matches known patterns. -/// Never returns — always exits with code 0. -pub fn run() -> ! { - // Read all stdin - let mut input = String::new(); - if std::io::stdin().read_to_string(&mut input).is_err() { - std::process::exit(0); - } - - // Parse JSON - let root: serde_json::Value = match serde_json::from_str(&input) { - Ok(v) => v, - Err(_) => std::process::exit(0), - }; - - // Extract command - let cmd = match root - .get("tool_input") - .and_then(|ti| ti.get("command")) - .and_then(|c| c.as_str()) - { - Some(c) if !c.is_empty() => c, - _ => std::process::exit(0), - }; - - // Skip heredocs - if cmd.contains("<<") { - std::process::exit(0); - } - - // Rewrite each segment of the command chain independently - // (already-rtk check is done per-segment in rewrite_segment) - let rewritten = match rewrite_chain(cmd) { - Some(r) => r, - None => std::process::exit(0), - }; - - // Build output JSON: preserve all original tool_input fields, override command - let mut updated_input = match root.get("tool_input").cloned() { - Some(v) => v, - None => std::process::exit(0), - }; - if let Some(obj) = updated_input.as_object_mut() { - obj.insert("command".to_string(), serde_json::Value::String(rewritten)); - } - - let output = serde_json::json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": updated_input - } - }); - - println!("{}", serde_json::to_string(&output).unwrap_or_default()); - std::process::exit(0); -} - -/// Rewrite a command chain: split on " && " / " ; ", rewrite each segment. -/// Returns Some(rewritten) if at least one segment was rewritten, None otherwise. -fn rewrite_chain(cmd: &str) -> Option { - let segments = split_chain_segments(cmd); - - let mut any_rewritten = false; - let mut result = String::with_capacity(cmd.len() + 32); - - for (i, (segment, separator)) in segments.iter().enumerate() { - if i > 0 { - // Write the separator from the previous segment - if let Some(sep) = &segments[i - 1].1 { - result.push_str(sep); - } - } - - // Try to rewrite this segment - match rewrite_segment(segment) { - Some(rewritten) => { - result.push_str(&rewritten); - any_rewritten = true; - } - None => { - result.push_str(segment); - } - } - } - - if any_rewritten { - Some(result) - } else { - None - } -} - -/// Try to rewrite a single command segment (with env prefix handling). -fn rewrite_segment(segment: &str) -> Option { - let trimmed = segment.trim(); - if trimmed.is_empty() || trimmed.starts_with("rtk ") || trimmed.contains("/rtk ") { - return None; - } - - let (env_prefix, match_cmd, cmd_body) = strip_env_prefix(trimmed); - try_rewrite(match_cmd, cmd_body).map(|r| { - // Preserve leading whitespace from original segment - let leading_ws = &segment[..segment.len() - segment.trim_start().len()]; - format!("{}{}{}", leading_ws, env_prefix, r) - }) -} - -/// Split a command on " && " and " ; " separators, respecting quotes. -/// Returns Vec of (segment, Option). -fn split_chain_segments(cmd: &str) -> Vec<(&str, Option<&str>)> { - let mut segments = Vec::new(); - let bytes = cmd.as_bytes(); - let mut start = 0; - let mut i = 0; - let mut in_single_quote = false; - let mut in_double_quote = false; - - while i < bytes.len() { - match bytes[i] { - b'\'' if !in_double_quote => in_single_quote = !in_single_quote, - b'"' if !in_single_quote => in_double_quote = !in_double_quote, - b' ' if !in_single_quote && !in_double_quote => { - let rest = &cmd[i..]; - if rest.starts_with(" && ") { - segments.push((&cmd[start..i], Some(" && "))); - i += 4; - start = i; - continue; - } - if rest.starts_with(" ; ") { - segments.push((&cmd[start..i], Some(" ; "))); - i += 3; - start = i; - continue; - } - } - _ => {} - } - i += 1; - } - - // Last segment - segments.push((&cmd[start..], None)); - segments -} - -/// Strip leading env var assignments (e.g. "FOO=bar BAZ=1 cmd args") -/// Returns (env_prefix, match_cmd, cmd_body) -/// - env_prefix: "FOO=bar BAZ=1 " (with trailing space) -/// - match_cmd: "cmd args" (for pattern matching) -/// - cmd_body: "cmd args" (same as match_cmd, used for rewriting) -fn strip_env_prefix(cmd: &str) -> (&str, &str, &str) { - if let Some(m) = ENV_PREFIX_RE.find(cmd) { - let prefix = &cmd[..m.end()]; - let rest = &cmd[m.end()..]; - (prefix, rest, rest) - } else { - ("", cmd, cmd) - } -} - -/// Attempt to rewrite a command. Returns Some(rewritten_body) or None. -fn try_rewrite(match_cmd: &str, cmd_body: &str) -> Option { - // --- Git --- - if match_cmd.starts_with("git ") || match_cmd == "git" { - return try_rewrite_git(match_cmd, cmd_body); - } - - // --- GitHub CLI --- - if match_cmd.starts_with("gh ") { - return try_rewrite_gh(match_cmd, cmd_body); - } - - // --- Cargo --- - if match_cmd.starts_with("cargo ") { - return try_rewrite_cargo(match_cmd, cmd_body); - } - - // --- File operations --- - if match_cmd.starts_with("cat ") { - return Some(replace_prefix(cmd_body, "cat ", "rtk read ")); - } - if match_cmd.starts_with("rg ") { - return Some(replace_prefix(cmd_body, "rg ", "rtk grep ")); - } - if match_cmd.starts_with("grep ") { - return Some(replace_prefix(cmd_body, "grep ", "rtk grep ")); - } - if match_cmd == "ls" || match_cmd.starts_with("ls ") { - return Some(replace_prefix(cmd_body, "ls", "rtk ls")); - } - if match_cmd == "tree" || match_cmd.starts_with("tree ") { - return Some(replace_prefix(cmd_body, "tree", "rtk tree")); - } - if match_cmd.starts_with("find ") { - return Some(replace_prefix(cmd_body, "find ", "rtk find ")); - } - if match_cmd.starts_with("diff ") { - return Some(replace_prefix(cmd_body, "diff ", "rtk diff ")); - } - if match_cmd.starts_with("head ") { - return try_rewrite_head(match_cmd); - } - - // --- JS/TS tooling --- - if let Some(r) = try_rewrite_js_ts(match_cmd, cmd_body) { - return Some(r); - } - - // --- Containers --- - if match_cmd.starts_with("docker ") { - return try_rewrite_docker(match_cmd, cmd_body); - } - if match_cmd.starts_with("kubectl ") { - return try_rewrite_kubectl(match_cmd, cmd_body); - } - - // --- Network --- - if match_cmd.starts_with("curl ") { - return Some(replace_prefix(cmd_body, "curl ", "rtk curl ")); - } - if match_cmd.starts_with("wget ") { - return Some(replace_prefix(cmd_body, "wget ", "rtk wget ")); - } - - // --- pnpm package management --- - if let Some(r) = try_rewrite_pnpm_pkg(match_cmd, cmd_body) { - return Some(r); - } - - // --- Python --- - if let Some(r) = try_rewrite_python(match_cmd, cmd_body) { - return Some(r); - } - - // --- Go --- - if let Some(r) = try_rewrite_go(match_cmd, cmd_body) { - return Some(r); - } - - None -} - -// --- Category-specific rewriters --- - -fn try_rewrite_git(match_cmd: &str, cmd_body: &str) -> Option { - // Strip git global flags to find the subcommand - let after_git = match_cmd.strip_prefix("git ").unwrap_or(""); - let stripped = GIT_GLOBAL_FLAGS_RE.replace_all(after_git, ""); - let stripped = stripped.trim_start(); - - let subcmd = stripped.split_whitespace().next().unwrap_or(""); - match subcmd { - "status" | "diff" | "log" | "add" | "commit" | "push" | "pull" | "branch" | "fetch" - | "stash" | "show" | "worktree" => Some(format!("rtk {}", cmd_body)), - _ => None, - } -} - -fn try_rewrite_gh(match_cmd: &str, cmd_body: &str) -> Option { - let after_gh = match_cmd.strip_prefix("gh ").unwrap_or(""); - let subcmd = after_gh.split_whitespace().next().unwrap_or(""); - match subcmd { - "pr" | "issue" | "run" | "api" | "release" => { - Some(replace_prefix(cmd_body, "gh ", "rtk gh ")) - } - _ => None, - } -} - -fn try_rewrite_cargo(match_cmd: &str, cmd_body: &str) -> Option { - let after_cargo = match_cmd.strip_prefix("cargo ").unwrap_or(""); - // Strip toolchain prefix (+nightly, +stable, etc.) - let after_toolchain = CARGO_TOOLCHAIN_RE.replace(after_cargo, ""); - let subcmd = after_toolchain.split_whitespace().next().unwrap_or(""); - match subcmd { - "test" | "build" | "clippy" | "check" | "install" | "fmt" => { - Some(format!("rtk {}", cmd_body)) - } - _ => None, - } -} - -fn try_rewrite_head(match_cmd: &str) -> Option { - // head -N file → rtk read file --max-lines N - if let Some(caps) = HEAD_DASH_N_RE.captures(match_cmd) { - let lines = &caps[1]; - let file = &caps[2]; - return Some(format!("rtk read {} --max-lines {}", file, lines)); - } - // head --lines=N file - if let Some(caps) = HEAD_LINES_RE.captures(match_cmd) { - let lines = &caps[1]; - let file = &caps[2]; - return Some(format!("rtk read {} --max-lines {}", file, lines)); - } - None -} - -fn try_rewrite_js_ts(match_cmd: &str, cmd_body: &str) -> Option { - // vitest (with optional pnpm/npx prefix) - if starts_with_any(match_cmd, &["vitest", "pnpm vitest", "npx vitest"]) { - // Strip prefixes, replace with "rtk vitest run" - let rest = match_cmd - .trim_start_matches("pnpm ") - .trim_start_matches("npx ") - .trim_start_matches("vitest") - .trim_start_matches(" run") - .trim_start(); - return Some(if rest.is_empty() { - "rtk vitest run".to_string() - } else { - format!("rtk vitest run {}", rest) - }); - } - - // pnpm test → rtk vitest run - if match_cmd == "pnpm test" || match_cmd.starts_with("pnpm test ") { - let rest = match_cmd.strip_prefix("pnpm test").unwrap_or("").trim(); - return Some(if rest.is_empty() { - "rtk vitest run".to_string() - } else { - format!("rtk vitest run {}", rest) - }); - } - - // npm test - if match_cmd == "npm test" || match_cmd.starts_with("npm test ") { - return Some(replace_prefix(cmd_body, "npm test", "rtk npm test")); - } - - // npm run - if match_cmd.starts_with("npm run ") { - return Some(replace_prefix(cmd_body, "npm run ", "rtk npm ")); - } - - // vue-tsc (with optional npx prefix) - if match_cmd == "vue-tsc" - || match_cmd.starts_with("vue-tsc ") - || match_cmd == "npx vue-tsc" - || match_cmd.starts_with("npx vue-tsc ") - { - let rest = match_cmd - .trim_start_matches("npx ") - .trim_start_matches("vue-tsc") - .trim_start(); - return Some(if rest.is_empty() { - "rtk tsc".to_string() - } else { - format!("rtk tsc {}", rest) - }); - } - - // pnpm tsc - if match_cmd == "pnpm tsc" || match_cmd.starts_with("pnpm tsc ") { - let rest = match_cmd.strip_prefix("pnpm tsc").unwrap_or("").trim(); - return Some(if rest.is_empty() { - "rtk tsc".to_string() - } else { - format!("rtk tsc {}", rest) - }); - } - - // tsc (with optional npx prefix) - if match_cmd == "tsc" - || match_cmd.starts_with("tsc ") - || match_cmd == "npx tsc" - || match_cmd.starts_with("npx tsc ") - { - let rest = match_cmd - .trim_start_matches("npx ") - .trim_start_matches("tsc") - .trim_start(); - return Some(if rest.is_empty() { - "rtk tsc".to_string() - } else { - format!("rtk tsc {}", rest) - }); - } - - // pnpm lint - if match_cmd == "pnpm lint" || match_cmd.starts_with("pnpm lint ") { - let rest = match_cmd.strip_prefix("pnpm lint").unwrap_or("").trim(); - return Some(if rest.is_empty() { - "rtk lint".to_string() - } else { - format!("rtk lint {}", rest) - }); - } - - // eslint (with optional npx prefix) - if match_cmd == "eslint" - || match_cmd.starts_with("eslint ") - || match_cmd == "npx eslint" - || match_cmd.starts_with("npx eslint ") - { - let rest = match_cmd - .trim_start_matches("npx ") - .trim_start_matches("eslint") - .trim_start(); - return Some(if rest.is_empty() { - "rtk lint".to_string() - } else { - format!("rtk lint {}", rest) - }); - } - - // prettier (with optional npx prefix) - if match_cmd == "prettier" - || match_cmd.starts_with("prettier ") - || match_cmd == "npx prettier" - || match_cmd.starts_with("npx prettier ") - { - let rest = match_cmd - .trim_start_matches("npx ") - .trim_start_matches("prettier") - .trim_start(); - return Some(if rest.is_empty() { - "rtk prettier".to_string() - } else { - format!("rtk prettier {}", rest) - }); - } - - // playwright (with optional npx/pnpm prefix) - if match_cmd == "playwright" - || match_cmd.starts_with("playwright ") - || match_cmd == "npx playwright" - || match_cmd.starts_with("npx playwright ") - || match_cmd == "pnpm playwright" - || match_cmd.starts_with("pnpm playwright ") - { - let rest = match_cmd - .trim_start_matches("npx ") - .trim_start_matches("pnpm ") - .trim_start_matches("playwright") - .trim_start(); - return Some(if rest.is_empty() { - "rtk playwright".to_string() - } else { - format!("rtk playwright {}", rest) - }); - } - - // prisma (with optional npx prefix) - if match_cmd == "prisma" - || match_cmd.starts_with("prisma ") - || match_cmd == "npx prisma" - || match_cmd.starts_with("npx prisma ") - { - let rest = match_cmd - .trim_start_matches("npx ") - .trim_start_matches("prisma") - .trim_start(); - return Some(if rest.is_empty() { - "rtk prisma".to_string() - } else { - format!("rtk prisma {}", rest) - }); - } - - None -} - -fn try_rewrite_docker(match_cmd: &str, cmd_body: &str) -> Option { - let after_docker = match_cmd.strip_prefix("docker ").unwrap_or(""); - - // docker compose → always rewrite - if after_docker.starts_with("compose") { - return Some(replace_prefix(cmd_body, "docker ", "rtk docker ")); - } - - // Strip docker global flags - let stripped = DOCKER_GLOBAL_FLAGS_RE.replace_all(after_docker, ""); - let stripped = stripped.trim_start(); - let subcmd = stripped.split_whitespace().next().unwrap_or(""); - match subcmd { - "ps" | "images" | "logs" | "run" | "build" | "exec" => { - Some(replace_prefix(cmd_body, "docker ", "rtk docker ")) - } - _ => None, - } -} - -fn try_rewrite_kubectl(match_cmd: &str, cmd_body: &str) -> Option { - let after_kubectl = match_cmd.strip_prefix("kubectl ").unwrap_or(""); - let stripped = KUBECTL_GLOBAL_FLAGS_RE.replace_all(after_kubectl, ""); - let stripped = stripped.trim_start(); - let subcmd = stripped.split_whitespace().next().unwrap_or(""); - match subcmd { - "get" | "logs" | "describe" | "apply" => { - Some(replace_prefix(cmd_body, "kubectl ", "rtk kubectl ")) - } - _ => None, - } -} - -fn try_rewrite_pnpm_pkg(match_cmd: &str, cmd_body: &str) -> Option { - if !match_cmd.starts_with("pnpm ") { - return None; - } - let after_pnpm = match_cmd.strip_prefix("pnpm ").unwrap_or(""); - let subcmd = after_pnpm.split_whitespace().next().unwrap_or(""); - match subcmd { - "list" | "ls" | "outdated" => Some(replace_prefix(cmd_body, "pnpm ", "rtk pnpm ")), - _ => None, - } -} - -fn try_rewrite_python(match_cmd: &str, cmd_body: &str) -> Option { - // pytest - if match_cmd == "pytest" || match_cmd.starts_with("pytest ") { - return Some(replace_prefix(cmd_body, "pytest", "rtk pytest")); - } - - // python -m pytest - if match_cmd.starts_with("python -m pytest") { - let rest = match_cmd - .strip_prefix("python -m pytest") - .unwrap_or("") - .trim_start(); - return Some(if rest.is_empty() { - "rtk pytest".to_string() - } else { - format!("rtk pytest {}", rest) - }); - } - - // ruff check/format - if match_cmd.starts_with("ruff ") { - let after_ruff = match_cmd.strip_prefix("ruff ").unwrap_or(""); - let subcmd = after_ruff.split_whitespace().next().unwrap_or(""); - if subcmd == "check" || subcmd == "format" { - return Some(replace_prefix(cmd_body, "ruff ", "rtk ruff ")); - } - } - - // pip list/outdated/install/show - if match_cmd.starts_with("pip ") { - let after_pip = match_cmd.strip_prefix("pip ").unwrap_or(""); - let subcmd = after_pip.split_whitespace().next().unwrap_or(""); - if matches!(subcmd, "list" | "outdated" | "install" | "show") { - return Some(replace_prefix(cmd_body, "pip ", "rtk pip ")); - } - } - - // uv pip list/outdated/install/show - if match_cmd.starts_with("uv pip ") { - let after_uv_pip = match_cmd.strip_prefix("uv pip ").unwrap_or(""); - let subcmd = after_uv_pip.split_whitespace().next().unwrap_or(""); - if matches!(subcmd, "list" | "outdated" | "install" | "show") { - return Some(replace_prefix(cmd_body, "uv pip ", "rtk pip ")); - } - } - - None -} - -fn try_rewrite_go(match_cmd: &str, cmd_body: &str) -> Option { - // go test/build/vet - if match_cmd.starts_with("go ") { - let after_go = match_cmd.strip_prefix("go ").unwrap_or(""); - let subcmd = after_go.split_whitespace().next().unwrap_or(""); - match subcmd { - "test" => return Some(replace_prefix(cmd_body, "go test", "rtk go test")), - "build" => return Some(replace_prefix(cmd_body, "go build", "rtk go build")), - "vet" => return Some(replace_prefix(cmd_body, "go vet", "rtk go vet")), - _ => {} - } - } - - // golangci-lint - if match_cmd == "golangci-lint" || match_cmd.starts_with("golangci-lint ") { - return Some(replace_prefix( - cmd_body, - "golangci-lint", - "rtk golangci-lint", - )); - } - - None -} - -// --- Helpers --- - -/// Replace a prefix in cmd_body. Simple string replacement of first occurrence. -fn replace_prefix(cmd_body: &str, old_prefix: &str, new_prefix: &str) -> String { - if let Some(rest) = cmd_body.strip_prefix(old_prefix) { - format!("{}{}", new_prefix, rest) - } else { - // Fallback: just prepend rtk - format!("rtk {}", cmd_body) - } -} - -/// Check if s starts with any of the given prefixes (exact or followed by space) -fn starts_with_any(s: &str, prefixes: &[&str]) -> bool { - prefixes - .iter() - .any(|p| s == *p || s.starts_with(&format!("{} ", p))) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Helper: simulate rewrite logic without stdin/stdout - fn rewrite(cmd: &str) -> Option { - if cmd.contains("<<") { - return None; - } - rewrite_chain(cmd) - } - - // --- Git --- - #[test] - fn test_git_status() { - assert_eq!(rewrite("git status"), Some("rtk git status".into())); - } - - #[test] - fn test_git_diff_cached() { - assert_eq!( - rewrite("git diff --cached"), - Some("rtk git diff --cached".into()) - ); - } - - #[test] - fn test_git_log_with_flags() { - assert_eq!( - rewrite("git log --oneline -10"), - Some("rtk git log --oneline -10".into()) - ); - } - - #[test] - fn test_git_with_global_flags() { - assert_eq!( - rewrite("git --no-pager diff"), - Some("rtk git --no-pager diff".into()) - ); - } - - #[test] - fn test_git_add() { - assert_eq!(rewrite("git add ."), Some("rtk git add .".into())); - } - - #[test] - fn test_git_commit() { - assert_eq!( - rewrite("git commit -m \"msg\""), - Some("rtk git commit -m \"msg\"".into()) - ); - } - - #[test] - fn test_git_push() { - assert_eq!(rewrite("git push"), Some("rtk git push".into())); - } - - #[test] - fn test_git_checkout_no_match() { - assert_eq!(rewrite("git checkout main"), None); - } - - // --- GitHub CLI --- - #[test] - fn test_gh_pr_view() { - assert_eq!(rewrite("gh pr view 123"), Some("rtk gh pr view 123".into())); - } - - #[test] - fn test_gh_issue_list() { - assert_eq!(rewrite("gh issue list"), Some("rtk gh issue list".into())); - } - - #[test] - fn test_gh_repo_no_match() { - assert_eq!(rewrite("gh repo clone foo"), None); - } - - // --- Cargo --- - #[test] - fn test_cargo_test() { - assert_eq!(rewrite("cargo test"), Some("rtk cargo test".into())); - } - - #[test] - fn test_cargo_build_release() { - assert_eq!( - rewrite("cargo build --release"), - Some("rtk cargo build --release".into()) - ); - } - - #[test] - fn test_cargo_with_toolchain() { - assert_eq!( - rewrite("cargo +nightly build"), - Some("rtk cargo +nightly build".into()) - ); - } - - #[test] - fn test_cargo_run_no_match() { - assert_eq!(rewrite("cargo run"), None); - } - - // --- File operations --- - #[test] - fn test_cat_to_read() { - assert_eq!( - rewrite("cat src/main.rs"), - Some("rtk read src/main.rs".into()) - ); - } - - #[test] - fn test_rg_to_grep() { - assert_eq!( - rewrite("rg pattern src/"), - Some("rtk grep pattern src/".into()) - ); - } - - #[test] - fn test_grep_to_rtk_grep() { - assert_eq!(rewrite("grep -r TODO ."), Some("rtk grep -r TODO .".into())); - } - - #[test] - fn test_ls() { - assert_eq!(rewrite("ls -la"), Some("rtk ls -la".into())); - } - - #[test] - fn test_ls_bare() { - assert_eq!(rewrite("ls"), Some("rtk ls".into())); - } - - #[test] - fn test_find() { - assert_eq!( - rewrite("find . -name '*.rs'"), - Some("rtk find . -name '*.rs'".into()) - ); - } - - #[test] - fn test_head_dash_n() { - assert_eq!( - rewrite("head -20 src/main.rs"), - Some("rtk read src/main.rs --max-lines 20".into()) - ); - } - - #[test] - fn test_head_lines_eq() { - assert_eq!( - rewrite("head --lines=50 README.md"), - Some("rtk read README.md --max-lines 50".into()) - ); - } - - // --- JS/TS --- - #[test] - fn test_vitest() { - assert_eq!(rewrite("vitest run"), Some("rtk vitest run".into())); - } - - #[test] - fn test_npx_vitest() { - assert_eq!(rewrite("npx vitest"), Some("rtk vitest run".into())); - } - - #[test] - fn test_pnpm_test() { - assert_eq!(rewrite("pnpm test"), Some("rtk vitest run".into())); - } - - #[test] - fn test_npm_test() { - assert_eq!(rewrite("npm test"), Some("rtk npm test".into())); - } - - #[test] - fn test_npm_run() { - assert_eq!(rewrite("npm run build"), Some("rtk npm build".into())); - } - - #[test] - fn test_tsc() { - assert_eq!(rewrite("tsc --noEmit"), Some("rtk tsc --noEmit".into())); - } - - #[test] - fn test_npx_tsc() { - assert_eq!(rewrite("npx tsc --noEmit"), Some("rtk tsc --noEmit".into())); - } - - #[test] - fn test_eslint() { - assert_eq!(rewrite("eslint src/"), Some("rtk lint src/".into())); - } - - #[test] - fn test_prettier() { - assert_eq!( - rewrite("prettier --check ."), - Some("rtk prettier --check .".into()) - ); - } - - #[test] - fn test_playwright() { - assert_eq!( - rewrite("npx playwright test"), - Some("rtk playwright test".into()) - ); - } - - #[test] - fn test_prisma() { - assert_eq!( - rewrite("npx prisma generate"), - Some("rtk prisma generate".into()) - ); - } - - // --- Containers --- - #[test] - fn test_docker_ps() { - assert_eq!(rewrite("docker ps"), Some("rtk docker ps".into())); - } - - #[test] - fn test_docker_compose() { - assert_eq!( - rewrite("docker compose up -d"), - Some("rtk docker compose up -d".into()) - ); - } - - #[test] - fn test_kubectl_get() { - assert_eq!( - rewrite("kubectl get pods"), - Some("rtk kubectl get pods".into()) - ); - } - - // --- Network --- - #[test] - fn test_curl() { - assert_eq!( - rewrite("curl https://api.example.com"), - Some("rtk curl https://api.example.com".into()) - ); - } - - #[test] - fn test_wget() { - assert_eq!( - rewrite("wget https://example.com/file"), - Some("rtk wget https://example.com/file".into()) - ); - } - - // --- pnpm package management --- - #[test] - fn test_pnpm_list() { - assert_eq!(rewrite("pnpm list"), Some("rtk pnpm list".into())); - } - - #[test] - fn test_pnpm_outdated() { - assert_eq!(rewrite("pnpm outdated"), Some("rtk pnpm outdated".into())); - } - - // --- Python --- - #[test] - fn test_pytest() { - assert_eq!(rewrite("pytest -x"), Some("rtk pytest -x".into())); - } - - #[test] - fn test_python_m_pytest() { - assert_eq!( - rewrite("python -m pytest tests/"), - Some("rtk pytest tests/".into()) - ); - } - - #[test] - fn test_ruff_check() { - assert_eq!( - rewrite("ruff check src/"), - Some("rtk ruff check src/".into()) - ); - } - - #[test] - fn test_pip_list() { - assert_eq!(rewrite("pip list"), Some("rtk pip list".into())); - } - - #[test] - fn test_uv_pip_install() { - assert_eq!( - rewrite("uv pip install flask"), - Some("rtk pip install flask".into()) - ); - } - - // --- Go --- - #[test] - fn test_go_test() { - assert_eq!(rewrite("go test ./..."), Some("rtk go test ./...".into())); - } - - #[test] - fn test_go_build() { - assert_eq!(rewrite("go build"), Some("rtk go build".into())); - } - - #[test] - fn test_go_vet() { - assert_eq!(rewrite("go vet ./..."), Some("rtk go vet ./...".into())); - } - - #[test] - fn test_golangci_lint() { - assert_eq!( - rewrite("golangci-lint run"), - Some("rtk golangci-lint run".into()) - ); - } - - // --- Edge cases --- - #[test] - fn test_already_rtk() { - assert_eq!(rewrite("rtk git status"), None); - } - - #[test] - fn test_heredoc_skip() { - assert_eq!(rewrite("cat < Result<()> { golangci_cmd::run(&args, cli.verbose)?; } - Commands::HookRewrite => hook_cmd::run(), + Commands::HookRewrite => hook::run(), Commands::HookAudit { since } => { hook_audit_cmd::run(since, cli.verbose)?; From e598c41c9c3257a5ba77026842e40505cb29eb4b Mon Sep 17 00:00:00 2001 From: Ayoub KHEMISSI Date: Thu, 19 Feb 2026 19:21:56 +0100 Subject: [PATCH 4/4] fix(ci): update docs version to 0.19.0 and validate-docs for native hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update version references from 0.18.1 to 0.19.0 in README.md, CLAUDE.md, and ARCHITECTURE.md - Update module count in ARCHITECTURE.md (47 → 49) - Update validate-docs workflow and script to check native hook module (src/hook/) instead of deprecated bash script (.claude/hooks/rtk-rewrite.sh) --- .github/workflows/validate-docs.yml | 12 ++++++------ ARCHITECTURE.md | 6 +++--- CLAUDE.md | 2 +- README.md | 2 +- scripts/validate-docs.sh | 16 ++++++++-------- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml index 27879bc..ff0e96b 100644 --- a/.github/workflows/validate-docs.yml +++ b/.github/workflows/validate-docs.yml @@ -6,7 +6,6 @@ on: - 'src/**/*.rs' - 'Cargo.toml' - '**.md' - - '.claude/hooks/*.sh' push: branches: - master @@ -54,15 +53,16 @@ jobs: - name: Verify hook coverage run: | - HOOK_FILE=".claude/hooks/rtk-rewrite.sh" - if [ ! -f "$HOOK_FILE" ]; then - echo "❌ Hook file not found: $HOOK_FILE" + # Check native hook module (src/hook/) for command coverage + HOOK_DIR="src/hook" + if [ ! -d "$HOOK_DIR" ]; then + echo "❌ Hook module directory not found: $HOOK_DIR" exit 1 fi for cmd in ruff pytest pip "go " golangci; do - if ! grep -q "$cmd" "$HOOK_FILE"; then - echo "❌ Hook missing rewrite for: $cmd" + if ! grep -rq "$cmd" "$HOOK_DIR"; then + echo "❌ Hook module missing rewrite for: $cmd" exit 1 fi done diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5f71a7b..02ff80c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -240,11 +240,11 @@ SHARED utils.rs Helpers N/A ✓ tee.rs Full output recovery N/A ✓ ``` -**Total: 47 modules** (29 command modules + 18 infrastructure modules) +**Total: 49 modules** (31 command modules + 18 infrastructure modules) ### Module Count Breakdown -- **Command Modules**: 29 (directly exposed to users) +- **Command Modules**: 31 (directly exposed to users) - **Infrastructure Modules**: 18 (utils, filter, tracking, tee, config, init, gain, etc.) - **Git Commands**: 7 operations (status, diff, log, add, commit, push, branch/checkout) - **JS/TS Tooling**: 8 modules (modern frontend/fullstack development) @@ -1435,4 +1435,4 @@ When implementing a new command, consider: **Last Updated**: 2026-02-12 **Architecture Version**: 2.1 -**rtk Version**: 0.18.0 +**rtk Version**: 0.19.0 diff --git a/CLAUDE.md b/CLAUDE.md index fc8002e..e6e35a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ This is a fork with critical fixes for git argument parsing and modern JavaScrip **Verify correct installation:** ```bash -rtk --version # Should show "rtk 0.18.1" (or newer) +rtk --version # Should show "rtk 0.19.0" (or newer) rtk gain # Should show token savings stats (NOT "command not found") ``` diff --git a/README.md b/README.md index 4960e85..a285080 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ rtk filters and compresses command outputs before they reach your LLM context, s **How to verify you have the correct rtk:** ```bash -rtk --version # Should show "rtk 0.18.1" +rtk --version # Should show "rtk 0.19.0" rtk gain # Should show token savings stats ``` diff --git a/scripts/validate-docs.sh b/scripts/validate-docs.sh index 554750f..159fb92 100755 --- a/scripts/validate-docs.sh +++ b/scripts/validate-docs.sh @@ -55,18 +55,18 @@ for cmd in "${PYTHON_GO_CMDS[@]}"; do done echo "✅ Python/Go commands: documented in README.md and CLAUDE.md" -# 4. Hooks cohérents avec doc -HOOK_FILE=".claude/hooks/rtk-rewrite.sh" -if [ -f "$HOOK_FILE" ]; then - echo "🪝 Checking hook rewrites..." +# 4. Hooks cohérents avec doc (native hook module) +HOOK_DIR="src/hook" +if [ -d "$HOOK_DIR" ]; then + echo "🪝 Checking native hook rewrites..." for cmd in "${PYTHON_GO_CMDS[@]}"; do - if ! grep -q "$cmd" "$HOOK_FILE"; then - echo "⚠️ Hook may not rewrite $cmd (verify manually)" + if ! grep -rq "$cmd" "$HOOK_DIR"; then + echo "⚠️ Hook module may not rewrite $cmd (verify manually)" fi done - echo "✅ Hook file exists and mentions Python/Go commands" + echo "✅ Hook module exists and mentions Python/Go commands" else - echo "⚠️ Hook file not found: $HOOK_FILE" + echo "⚠️ Hook module directory not found: $HOOK_DIR" fi echo ""