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 f747068..f21bb40 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: 48 modules** (30 command modules + 18 infrastructure modules) +**Total: 51 modules** (33 command modules + 18 infrastructure modules) ### Module Count Breakdown -- **Command Modules**: 29 (directly exposed to users) +- **Command Modules**: 33 (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) 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"); +} 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 "" 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/init.rs b/src/init.rs index 961e4ac..ec4efb3 100644 --- a/src/init.rs +++ b/src/init.rs @@ -4,8 +4,8 @@ use std::io::Write; use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; -// Embedded hook script (guards before set -euo pipefail) -const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); +// Native hook command (cross-platform, no bash/jq dependency) +const HOOK_COMMAND: &str = "rtk hook-rewrite"; // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md"); @@ -179,51 +179,86 @@ pub fn run( } } -/// Prepare hook directory and return paths (hook_dir, hook_path) -fn prepare_hook_paths() -> 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 bc46fdd..78a593f 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; mod hook_audit_cmd; mod init; mod json_cmd; @@ -527,6 +528,10 @@ enum Commands { args: Vec, }, + /// Hook rewrite for Claude Code PreToolUse (internal) + #[command(name = "hook-rewrite", hide = true)] + HookRewrite, + /// Show hook rewrite audit metrics (requires RTK_HOOK_AUDIT=1) #[command(name = "hook-audit")] HookAudit { @@ -1423,6 +1428,8 @@ fn main() -> Result<()> { golangci_cmd::run(&args, cli.verbose)?; } + Commands::HookRewrite => hook::run(), + Commands::HookAudit { since } => { hook_audit_cmd::run(since, cli.verbose)?; }