Skip to content
Closed
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
83 changes: 60 additions & 23 deletions .claude/hooks/rtk-rewrite.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
#!/bin/bash
# rtk-hook-version: 3
# RTK auto-rewrite hook for Claude Code PreToolUse:Bash
# Transparently rewrites raw commands to their RTK equivalents.
# Uses `rtk rewrite` as single source of truth — no duplicate mapping logic here.
#
# To add support for new commands, update src/discover/registry.rs (PATTERNS + RULES).
#
# 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 (Claude Code native deny handles it)
# 3 + stdout Ask rule matched → rewrite but let Claude Code prompt the user

# --- Audit logging (opt-in via RTK_HOOK_AUDIT=1) ---
_rtk_audit_log() {
Expand Down Expand Up @@ -37,34 +44,64 @@ case "$CMD" in
*'<<'*) _rtk_audit_log "skip:heredoc" "$CMD"; exit 0 ;;
esac

# Rewrite via rtk — single source of truth for all command mappings.
# Exit 1 = no RTK equivalent, pass through unchanged.
# Exit 0 = rewritten command (or already RTK, identical output).
REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || {
_rtk_audit_log "skip:no_match" "$CMD"
exit 0
}
# Rewrite via rtk — single source of truth for all command mappings and permission checks.
# Use "|| EXIT_CODE=$?" to capture non-zero exit codes without triggering set -e.
EXIT_CODE=0
REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || EXIT_CODE=$?

# If output is identical, command was already using RTK — nothing to do.
if [ "$CMD" = "$REWRITTEN" ]; then
_rtk_audit_log "skip:already_rtk" "$CMD"
exit 0
fi
case $EXIT_CODE in
0)
# Rewrite found, no permission rules matched — safe to auto-allow.
if [ "$CMD" = "$REWRITTEN" ]; then
_rtk_audit_log "skip:already_rtk" "$CMD"
exit 0
fi
;;
1)
# No RTK equivalent — pass through unchanged.
_rtk_audit_log "skip:no_match" "$CMD"
exit 0
;;
2)
# Deny rule matched — let Claude Code's native deny rule handle it.
_rtk_audit_log "skip:deny_rule" "$CMD"
exit 0
;;
3)
# Ask rule matched — rewrite the command but do NOT auto-allow so that
# Claude Code prompts the user for confirmation.
;;
*)
exit 0
;;
esac

_rtk_audit_log "rewrite" "$CMD" "$REWRITTEN"

# Build the updated tool_input with all original fields preserved, only command changed.
ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input')
UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd')

# Output the rewrite instruction in Claude Code hook format.
jq -n \
--argjson updated "$UPDATED_INPUT" \
'{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": $updated
}
}'
if [ "$EXIT_CODE" -eq 3 ]; then
# Ask: rewrite the command, omit permissionDecision so Claude Code prompts.
jq -n \
--argjson updated "$UPDATED_INPUT" \
'{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": $updated
}
}'
else
# Allow: output the rewrite instruction in Claude Code hook format.
jq -n \
--argjson updated "$UPDATED_INPUT" \
'{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": $updated
}
}'
fi
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.33.0-rc.54"
".": "0.33.0"
}
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,14 +290,15 @@ SYSTEM init.rs init N/A ✓
gain.rs gain N/A ✓
config.rs (internal) N/A ✓
rewrite_cmd.rs rewrite N/A ✓
permissions.rs CC permission checks N/A ✓
SHARED utils.rs Helpers N/A ✓
filter.rs Language filters N/A ✓
tracking.rs Token tracking N/A ✓
tee.rs Full output recovery N/A ✓
```

**Total: 67 modules** (45 command modules + 22 infrastructure modules)
**Total: 71 modules** (49 command modules + 22 infrastructure modules)

### Module Count Breakdown

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Bug Fixes

* **hook:** respect Claude Code deny/ask permission rules on rewrite — hook now checks settings.json before rewriting commands, preventing bypass of user-configured deny/ask permissions
* **git:** replace symbol prefixes (`* branch`, `+ Staged:`, `~ Modified:`, `? Untracked:`) with plain lowercase labels (`branch:`, `staged:`, `modified:`, `untracked:`) in git status output
* **ruby:** use `rails test` instead of `rake test` when positional file args are passed — `rake test` ignores positional files and only supports `TEST=path`

### Features
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rtk"
version = "0.33.0-rc.54"
version = "0.33.0"
edition = "2021"
authors = ["Patrick Szymkowiak"]
description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption"
Expand Down
73 changes: 55 additions & 18 deletions hooks/rtk-rewrite.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
#!/usr/bin/env bash
# rtk-hook-version: 2
# rtk-hook-version: 3
# RTK Claude Code hook — rewrites commands to use rtk for token savings.
# Requires: rtk >= 0.23.0, jq
#
# 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 (Claude Code native deny handles it)
# 3 + stdout Ask rule matched → rewrite but let Claude Code prompt the user

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 @@ -37,25 +43,56 @@ 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) || exit 0
# Delegate all rewrite + permission logic to the Rust binary.
REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null)
EXIT_CODE=$?

# No change — nothing to do.
if [ "$CMD" = "$REWRITTEN" ]; then
exit 0
fi
case $EXIT_CODE in
0)
# Rewrite found, no permission rules matched — safe to auto-allow.
# If the output is identical, the command was already using RTK.
[ "$CMD" = "$REWRITTEN" ] && exit 0
;;
1)
# No RTK equivalent — pass through unchanged.
exit 0
;;
2)
# Deny rule matched — let Claude Code's native deny rule handle it.
exit 0
;;
3)
# Ask rule matched — rewrite the command but do NOT auto-allow so that
# Claude Code prompts the user for confirmation.
;;
*)
exit 0
;;
esac

ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input')
UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd')

jq -n \
--argjson updated "$UPDATED_INPUT" \
'{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": $updated
}
}'
if [ "$EXIT_CODE" -eq 3 ]; then
# Ask: rewrite the command, omit permissionDecision so Claude Code prompts.
jq -n \
--argjson updated "$UPDATED_INPUT" \
'{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": $updated
}
}'
else
# Allow: rewrite the command and auto-allow.
jq -n \
--argjson updated "$UPDATED_INPUT" \
'{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": $updated
}
}'
fi
90 changes: 74 additions & 16 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,9 +327,34 @@ pub fn strip_disabled_prefix(cmd: &str) -> &str {
trimmed[prefix_len..].trim_start()
}

/// Rewrite a raw command to its RTK equivalent.
///
/// Returns `Some(rewritten)` if the command has an RTK equivalent or is already RTK.
lazy_static! {
// Match trailing shell redirections:
// Alt 1: N>&M or N>&- (fd redirect/close): 2>&1, 1>&2, 2>&-
// Alt 2: &>file or &>>file (bash redirect both): &>/dev/null
// Alt 3: N>file or N>>file (fd to file): 2>/dev/null, >/tmp/out, 1>>log
// Note: [^(\\s] excludes process substitutions like >(tee) from false-positive matching
static ref TRAILING_REDIRECT: Regex =
Regex::new(r"\s+(?:[0-9]?>&[0-9-]|&>>?\S+|[0-9]?>>?\s*[^(\s]\S*)\s*$").unwrap();
}

/// Strip trailing stderr/stdout redirects from a command segment (#530).
/// Returns (command_without_redirects, redirect_suffix).
fn strip_trailing_redirects(cmd: &str) -> (&str, &str) {
if let Some(m) = TRAILING_REDIRECT.find(cmd) {
// Verify redirect is not inside quotes (single-pass count)
let before = &cmd[..m.start()];
let (sq, dq) = before.chars().fold((0u32, 0u32), |(s, d), c| match c {
'\'' => (s + 1, d),
'"' => (s, d + 1),
_ => (s, d),
});
if sq % 2 == 0 && dq % 2 == 0 {
return (&cmd[..m.start()], &cmd[m.start()..]);
}
}
(cmd, "")
}

/// Returns `None` if the command is unsupported or ignored (hook should pass through).
///
/// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently.
Expand Down Expand Up @@ -565,30 +590,34 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
return None;
}

// Strip trailing stderr/stdout redirects before matching (#530)
// e.g. "git status 2>&1" → match "git status", re-append " 2>&1"
let (cmd_part, redirect_suffix) = strip_trailing_redirects(trimmed);

// Already RTK — pass through unchanged
if trimmed.starts_with("rtk ") || trimmed == "rtk" {
if cmd_part.starts_with("rtk ") || cmd_part == "rtk" {
return Some(trimmed.to_string());
}

// Special case: `head -N file` / `head --lines=N file` → `rtk read file --max-lines N`
// Must intercept before generic prefix replacement, which would produce `rtk read -20 file`.
// Only intercept when head has a flag (-N, --lines=N, -c, etc.); plain `head file` falls
// through to the generic rewrite below and produces `rtk read file` as expected.
if trimmed.starts_with("head -") {
return rewrite_head_numeric(trimmed);
if cmd_part.starts_with("head -") {
return rewrite_head_numeric(cmd_part).map(|r| format!("{}{}", r, redirect_suffix));
}

// tail has several forms that are not compatible with generic prefix replacement.
// Only rewrite recognized numeric line forms; otherwise skip rewrite.
if trimmed.starts_with("tail ") {
return rewrite_tail_lines(trimmed);
if cmd_part.starts_with("tail ") {
return rewrite_tail_lines(cmd_part).map(|r| format!("{}{}", r, redirect_suffix));
}

// Use classify_command for correct ignore/prefix handling
let rtk_equivalent = match classify_command(trimmed) {
let rtk_equivalent = match classify_command(cmd_part) {
Classification::Supported { rtk_equivalent, .. } => {
// Check if the base command is excluded from rewriting (#243)
let base = trimmed.split_whitespace().next().unwrap_or("");
let base = cmd_part.split_whitespace().next().unwrap_or("");
if excluded.iter().any(|e| e == base) {
return None;
}
Expand All @@ -601,13 +630,13 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
let rule = RULES.iter().find(|r| r.rtk_cmd == rtk_equivalent)?;

// Extract env prefix (sudo, env VAR=val, etc.)
let stripped_cow = ENV_PREFIX.replace(trimmed, "");
let env_prefix_len = trimmed.len() - stripped_cow.len();
let env_prefix = &trimmed[..env_prefix_len];
let stripped_cow = ENV_PREFIX.replace(cmd_part, "");
let env_prefix_len = cmd_part.len() - stripped_cow.len();
let env_prefix = &cmd_part[..env_prefix_len];
let cmd_clean = stripped_cow.trim();

// #345: RTK_DISABLED=1 in env prefix → skip rewrite entirely
if has_rtk_disabled_prefix(trimmed) {
if has_rtk_disabled_prefix(cmd_part) {
return None;
}

Expand All @@ -627,9 +656,9 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
for &prefix in rule.rewrite_prefixes {
if let Some(rest) = strip_word_prefix(cmd_clean, prefix) {
let rewritten = if rest.is_empty() {
format!("{}{}", env_prefix, rule.rtk_cmd)
format!("{}{}{}", env_prefix, rule.rtk_cmd, redirect_suffix)
} else {
format!("{}{} {}", env_prefix, rule.rtk_cmd, rest)
format!("{}{} {}{}", env_prefix, rule.rtk_cmd, rest, redirect_suffix)
};
return Some(rewritten);
}
Expand Down Expand Up @@ -1285,6 +1314,35 @@ mod tests {
);
}

#[test]
fn test_rewrite_redirect_double() {
// Double redirect: only last one stripped, but full command rewrites correctly
assert_eq!(
rewrite_command("git status 2>&1 >/dev/null", &[]),
Some("rtk git status 2>&1 >/dev/null".into())
);
}

#[test]
fn test_rewrite_redirect_fd_close() {
// 2>&- (close stderr fd)
assert_eq!(
rewrite_command("git status 2>&-", &[]),
Some("rtk git status 2>&-".into())
);
}

#[test]
fn test_rewrite_redirect_quotes_not_stripped() {
// Redirect-like chars inside quotes should NOT be stripped
// Known limitation: apostrophes cause conservative no-strip (safe fallback)
let result = rewrite_command("git commit -m \"it's fixed\" 2>&1", &[]);
assert!(
result.is_some(),
"Should still rewrite even with apostrophe"
);
}

#[test]
fn test_rewrite_background_amp_non_regression() {
// background `&` must still work after redirect fix
Expand Down
2 changes: 1 addition & 1 deletion src/hook_check.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::path::PathBuf;

const CURRENT_HOOK_VERSION: u8 = 2;
const CURRENT_HOOK_VERSION: u8 = 3;
const WARN_INTERVAL_SECS: u64 = 24 * 3600;

/// Hook status for diagnostics and `rtk gain`.
Expand Down
Loading
Loading