diff --git a/hooks/cursor/rtk-rewrite.sh b/hooks/cursor/rtk-rewrite.sh index 4b80b260c..a4cc3adbf 100644 --- a/hooks/cursor/rtk-rewrite.sh +++ b/hooks/cursor/rtk-rewrite.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# rtk-hook-version: 1 +# rtk-hook-version: 2 # RTK Cursor Agent hook — rewrites shell commands to use rtk for token savings. # Works with both Cursor editor and cursor-cli (they share ~/.cursor/hooks.json). # Cursor preToolUse hook format: receives JSON on stdin, returns JSON on stdout. @@ -8,6 +8,12 @@ # This is a thin delegating hook: all rewrite logic lives in `rtk rewrite`, # which is the single source of truth (src/discover/registry.rs). # To add or change rewrite rules, edit the Rust registry — not this file. +# +# Exit code protocol for `rtk rewrite`: +# 0 + stdout Rewrite found, no deny/ask rule matched → auto-allow +# 1 No RTK equivalent → pass through unchanged +# 2 Deny rule matched → pass through unchanged +# 3 + stdout Ask rule matched → rewrite but omit auto-allow (Cursor prompts) if ! command -v jq &>/dev/null; then echo "[rtk] WARNING: jq is not installed. Hook cannot rewrite commands. Install jq: https://jqlang.github.io/jq/download/" >&2 @@ -38,15 +44,19 @@ if [ -z "$CMD" ]; then exit 0 fi -# Delegate all rewrite logic to the Rust binary. -# rtk rewrite exits 1 when there's no rewrite — hook passes through silently. -REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || { echo '{}'; exit 0; } +# Capture exit code explicitly — exit 3 (ask) still carries rewritten stdout. +EXIT_CODE=0 +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || EXIT_CODE=$? -# No change — nothing to do. -if [ "$CMD" = "$REWRITTEN" ]; then - echo '{}' - exit 0 -fi +case $EXIT_CODE in + 0|3) + [ -z "$REWRITTEN" ] || [ "$CMD" = "$REWRITTEN" ] && { echo '{}'; exit 0; } + ;; + *) + echo '{}' + exit 0 + ;; +esac jq -n --arg cmd "$REWRITTEN" '{ "permission": "allow", diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index f752a2169..282a6f63b 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -665,6 +665,22 @@ fn collect_test_outcomes( // Convenience wrapper (uses singleton — for run_fallback) // --------------------------------------------------------------------------- +/// Normalize a command string for TOML filter lookup — same basename logic as +/// `run_fallback` so `/path/to/ng test` matches the `ng test` filter. +pub fn lookup_command_for_filter(command: &str) -> String { + let trimmed = command.trim(); + let first_space = trimmed.find(char::is_whitespace); + let (first_word, rest) = match first_space { + Some(pos) => (&trimmed[..pos], &trimmed[pos..]), + None => (trimmed, ""), + }; + let base = std::path::Path::new(first_word) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| first_word.to_string()); + format!("{}{}", base, rest) +} + /// Find a matching filter from the global registry. Initialises the registry /// lazily on first call. Returns `None` if no filter matches. pub fn find_matching_filter(command: &str) -> Option<&'static CompiledFilter> { @@ -1005,6 +1021,21 @@ match_command = "^terraform" assert!(found.is_none()); } + #[test] + fn test_lookup_command_for_filter_uses_basename() { + assert_eq!( + lookup_command_for_filter("node_modules/.bin/ng test auth"), + "ng test auth" + ); + } + + #[test] + fn test_builtin_ng_test_filter_matches_path_invocation() { + let lookup = lookup_command_for_filter("node_modules/.bin/ng test auth"); + let found = find_matching_filter(&lookup).expect("ng-test built-in filter"); + assert_eq!(found.name, "ng-test"); + } + #[test] fn test_project_filters_priority_over_builtin() { // Project filter has same name but different max_lines — project wins @@ -1584,6 +1615,7 @@ match_command = "^make\\b" "markdownlint", "mix-compile", "mix-format", + "ng-test", "ping", "pio-run", "poetry-install", @@ -1622,7 +1654,7 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 58, + 59, "Expected exactly 58 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() diff --git a/src/discover/registry.rs b/src/discover/registry.rs index fc18b6be0..e9736fa29 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -857,6 +857,15 @@ fn rewrite_segment_inner( } } + // TOML-only tools invoked via a path or wrapper (e.g. node_modules/.bin/ng). + // Prepend `rtk` to the original command so run_fallback executes the real binary path. + let lookup = crate::core::toml_filter::lookup_command_for_filter(cmd_part); + if crate::core::toml_filter::find_matching_filter(&lookup).is_some() + || crate::core::toml_filter::find_matching_filter(cmd_part).is_some() + { + return Some(format!("rtk {}{}", cmd_part, redirect_suffix)); + } + None } @@ -3061,6 +3070,56 @@ mod tests { ); } + #[test] + fn test_classify_ng_test() { + assert!(matches!( + classify_command("ng test auth --no-watch --browsers=ChromeHeadless"), + Classification::Supported { + rtk_equivalent: "rtk ng", + category: "Tests", + .. + } + )); + } + + #[test] + fn test_rewrite_ng_test() { + assert_eq!( + rewrite_command_no_prefixes( + "ng test auth --no-watch --browsers=ChromeHeadless", + &[] + ), + Some("rtk ng test auth --no-watch --browsers=ChromeHeadless".to_string()), + ); + } + + #[test] + fn test_rewrite_yarn_ng_test() { + assert_eq!( + rewrite_command_no_prefixes( + "yarn ng test auth --no-watch --browsers=ChromeHeadless", + &[] + ), + Some( + "rtk yarn ng test auth --no-watch --browsers=ChromeHeadless".to_string() + ), + ); + } + + #[test] + fn test_rewrite_ng_bin_path() { + assert_eq!( + rewrite_command_no_prefixes( + "node_modules/.bin/ng test auth --no-watch --browsers=ChromeHeadless", + &[] + ), + Some( + "rtk node_modules/.bin/ng test auth --no-watch --browsers=ChromeHeadless" + .to_string() + ), + ); + } + // --- Gradle --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 78d5e0058..9df80677f 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -321,6 +321,24 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + pattern: r"^yarn\s+ng\s+test\b", + rtk_cmd: "rtk yarn", + rewrite_prefixes: &["yarn"], + category: "Tests", + savings_pct: 85.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:[^\s]+/)*ng\s+test\b", + rtk_cmd: "rtk ng", + rewrite_prefixes: &["ng"], + category: "Tests", + savings_pct: 85.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^((p?np(m|x)|p?npm\s+(exec|run|run-script)|npm\s+(rum|urn|x)|pnpm\s+dlx)\s+)?playwright", rtk_cmd: "rtk playwright", diff --git a/src/filters/ng-test.toml b/src/filters/ng-test.toml new file mode 100644 index 000000000..60f873ad5 --- /dev/null +++ b/src/filters/ng-test.toml @@ -0,0 +1,90 @@ +schema_version = 1 + +[filters.ng-test] +description = "Angular Karma (ng test) — failures, stack traces, summary" +match_command = "\\bng test\\b" +strip_ansi = true +filter_stderr = true +strip_lines_matching = [ + "^yarn run v", + "^\\$ ng ", + "Tailwind CSS configuration", + "^- Generating browser", + "^✔ Browser application bundle", + "^DEBUG:", + "^WARN:", + ":INFO \\[", + "Executed [0-9]+ of [0-9]+", + "^Done in [0-9]", + "^\\s*$", +] +max_lines = 80 +on_empty = "ng test: ok" + +[[tests.ng-test]] +name = "all passed — summary only" +input = """ +yarn run v1.22.22 +$ ng test auth --no-watch --browsers=ChromeHeadless +✔ Browser application bundle generation complete. +22 06 2026 22:09:14.380:INFO [karma-server]: Karma v6.4.4 server started at http://localhost:9877/ +22 06 2026 22:09:15.245:INFO [Chrome Headless 134.0.0.0 (Mac OS 10.15.7)]: Connected on socket q3EZc_BXbPE9KZu5AAAB with id 3020021 +Chrome Headless 134.0.0.0 (Mac OS 10.15.7): Executed 1 of 57 SUCCESS (0 secs / 0.007 secs) +Chrome Headless 134.0.0.0 (Mac OS 10.15.7): Executed 2 of 57 SUCCESS (0 secs / 0.009 secs) +Chrome Headless 134.0.0.0 (Mac OS 10.15.7): Executed 57 of 57 SUCCESS (5.078 secs / 5.056 secs) +TOTAL: 57 SUCCESS +Done in 12.34s. +""" +expected = """ +TOTAL: 57 SUCCESS +""" + +[[tests.ng-test]] +name = "mixed failures with stack traces" +input = """ +yarn run v1.22.22 +$ ng test auth --no-watch --browsers=ChromeHeadless +✔ Browser application bundle generation complete. +22 06 2026 22:09:15.245:INFO [Chrome Headless 134.0.0.0 (Mac OS 10.15.7)]: Connected on socket q3EZc_BXbPE9KZu5AAAB with id 3020021 +Chrome Headless 134.0.0.0 (Mac OS 10.15.7): Executed 1 of 57 SUCCESS (0 secs / 0.007 secs) +Chrome Headless 134.0.0.0 (Mac OS 10.15.7): Executed 47 of 57 SUCCESS (0 secs / 0.036 secs) +Chrome Headless 134.0.0.0 (Mac OS 10.15.7) AuthService initiateExternalLogin$ should initiate external login and return URL FAILED + Failed: Authorization URL not found in response + at call (libs/auth/src/auth.service.ts:570:19) + at onNext (node_modules/rxjs/dist/esm/internal/operators/map.js:7:37) +Chrome Headless 134.0.0.0 (Mac OS 10.15.7): Executed 48 of 57 (1 FAILED) (0 secs / 0.037 secs) +Chrome Headless 134.0.0.0 (Mac OS 10.15.7) AuthService verifyCode$ should verify code successfully and set session FAILED + Expected spy Router.navigate to have been called. + at + Error: Timeout - Async function did not complete within 5000ms (set by jasmine.DEFAULT_TIMEOUT_INTERVAL) +Chrome Headless 134.0.0.0 (Mac OS 10.15.7): Executed 57 of 57 (2 FAILED) (5.078 secs / 5.056 secs) +TOTAL: 2 FAILED, 55 SUCCESS +error Command failed with exit code 1. +""" +expected = """ +Chrome Headless 134.0.0.0 (Mac OS 10.15.7) AuthService initiateExternalLogin$ should initiate external login and return URL FAILED + Failed: Authorization URL not found in response + at call (libs/auth/src/auth.service.ts:570:19) + at onNext (node_modules/rxjs/dist/esm/internal/operators/map.js:7:37) +Chrome Headless 134.0.0.0 (Mac OS 10.15.7) AuthService verifyCode$ should verify code successfully and set session FAILED + Expected spy Router.navigate to have been called. + at + Error: Timeout - Async function did not complete within 5000ms (set by jasmine.DEFAULT_TIMEOUT_INTERVAL) +TOTAL: 2 FAILED, 55 SUCCESS +error Command failed with exit code 1. +""" + +[[tests.ng-test]] +name = "strips repeated bundle complete and tailwind stderr noise" +input = """ +Tailwind CSS configuration file found (tailwind.config.js) but the 'tailwindcss' package is not installed. +- Generating browser application bundles (phase: setup)... +✔ Browser application bundle generation complete. +✔ Browser application bundle generation complete. +✔ Browser application bundle generation complete. +Chrome Headless 134.0.0.0 (Mac OS 10.15.7): Executed 57 of 57 SUCCESS (0.065 secs / 0.049 secs) +TOTAL: 57 SUCCESS +""" +expected = """ +TOTAL: 57 SUCCESS +"""