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
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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,14 +286,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: 66 modules** (44 command modules + 22 infrastructure modules)
**Total: 67 modules** (44 command modules + 23 infrastructure modules)

### Module Count Breakdown

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
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
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ mod mypy_cmd;
mod next_cmd;
mod npm_cmd;
mod parser;
mod permissions;
mod pip_cmd;
mod playwright_cmd;
mod pnpm_cmd;
Expand Down
Loading
Loading