Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions hooks/cursor/rtk-rewrite.sh
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
34 changes: 33 additions & 1 deletion src/core/toml_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1584,6 +1615,7 @@ match_command = "^make\\b"
"markdownlint",
"mix-compile",
"mix-format",
"ng-test",
"ping",
"pio-run",
"poetry-install",
Expand Down Expand Up @@ -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()
Expand Down
59 changes: 59 additions & 0 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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]
Expand Down
18 changes: 18 additions & 0 deletions src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
90 changes: 90 additions & 0 deletions src/filters/ng-test.toml
Original file line number Diff line number Diff line change
@@ -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 <Jasmine>
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 <Jasmine>
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
"""