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
43 changes: 13 additions & 30 deletions hooks/rtk-rewrite.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
#!/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
# Requires: rtk >= 0.29.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.
# This is a thin delegating hook: ALL logic (rewrite rules, permission
# decisions, config loading) lives in `rtk rewrite --hook-json`.
# To change behavior, edit ~/.config/rtk/config.toml — not this file.

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 All @@ -17,15 +17,13 @@ if ! command -v rtk &>/dev/null; then
exit 0
fi

# Version guard: rtk rewrite was added in 0.23.0.
# Older binaries: warn once and exit cleanly (no silent failure).
# Version guard: --hook-json was added in 0.29.0.
RTK_VERSION=$(rtk --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
if [ -n "$RTK_VERSION" ]; then
MAJOR=$(echo "$RTK_VERSION" | cut -d. -f1)
MINOR=$(echo "$RTK_VERSION" | cut -d. -f2)
# Require >= 0.23.0
if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 23 ]; then
echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.23.0). Upgrade: cargo install rtk" >&2
if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 29 ]; then
echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.29.0). Upgrade: cargo install rtk" >&2
exit 0
fi
fi
Expand All @@ -37,25 +35,10 @@ 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 everything to Rust: rewrite decision, config, permission, JSON output.
# Empty output = no rewrite (pass through silently).
RESULT=$(rtk rewrite --hook-json "$CMD" 2>/dev/null) || exit 0

# No change — nothing to do.
if [ "$CMD" = "$REWRITTEN" ]; then
exit 0
if [ -n "$RESULT" ]; then
echo "$RESULT"
fi

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
}
}'
156 changes: 150 additions & 6 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use anyhow::Result;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::path::{Path, PathBuf};

#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
Expand All @@ -18,12 +18,73 @@ pub struct Config {
pub hooks: HooksConfig,
}

#[derive(Debug, Serialize, Deserialize, Default)]
#[derive(Debug, Serialize, Deserialize)]
pub struct HooksConfig {
/// Commands to exclude from auto-rewrite (e.g. ["curl", "playwright"]).
/// Survives `rtk init -g` re-runs since config.toml is user-owned.
#[serde(default)]
pub exclude_commands: Vec<String>,

/// When true, rewritten commands bypass Claude Code's permission prompt.
/// When false, commands are still rewritten but the user is prompted before execution.
/// Override: RTK_HOOK_AUTO_APPROVE=0 (or =1)
#[serde(default = "default_true")]
pub auto_approve: bool,

/// Claude Code data directory for hook installation.
/// Override: CLAUDE_CONFIG_DIR env var or --claude-dir CLI flag.
#[serde(skip_serializing_if = "Option::is_none")]
pub claude_dir: Option<PathBuf>,
}

fn default_true() -> bool {
true
}

impl Default for HooksConfig {
fn default() -> Self {
Self {
exclude_commands: vec![],
auto_approve: true,
claude_dir: None,
}
}
}

/// Resolve the Claude Code data directory.
/// Priority: cli_override > CLAUDE_CONFIG_DIR env var > config.toml > ~/.claude
pub fn resolve_claude_dir(cli_override: Option<&Path>) -> anyhow::Result<PathBuf> {
if let Some(dir) = cli_override {
return Ok(dir.to_path_buf());
}
if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
if !dir.is_empty() {
return Ok(PathBuf::from(dir));
}
}
if let Ok(config) = Config::load() {
if let Some(dir) = config.hooks.claude_dir {
return Ok(dir);
}
}
dirs::home_dir()
.map(|h| h.join(".claude"))
.context("Cannot determine home directory. Is $HOME set?")
}

/// Parse a boolean-ish env var value. Accepts "1", "true", "yes" as truthy (case-insensitive).
/// Everything else (including empty string) is falsy.
fn parse_bool_env(val: &str) -> bool {
matches!(val.to_lowercase().as_str(), "1" | "true" | "yes")
}

/// Resolve hooks.auto_approve with env var override, using an already-loaded config.
/// Priority: RTK_HOOK_AUTO_APPROVE env var > config value.
pub fn resolve_auto_approve_with(hooks: &HooksConfig) -> bool {
if let Ok(val) = std::env::var("RTK_HOOK_AUTO_APPROVE") {
return parse_bool_env(&val);
}
hooks.auto_approve
}

#[derive(Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -94,9 +155,14 @@ impl Default for TelemetryConfig {
}
}

/// Check if telemetry is enabled in config. Returns None if config can't be loaded.
pub fn telemetry_enabled() -> Option<bool> {
Config::load().ok().map(|c| c.telemetry.enabled)
/// Resolve telemetry.enabled with env var override.
/// Priority: RTK_TELEMETRY_DISABLED env var > config.toml > default (true).
/// Note: env var uses inverted polarity — "1"/"true"/"yes" means DISABLED.
pub fn resolve_telemetry_enabled() -> bool {
if let Ok(val) = std::env::var("RTK_TELEMETRY_DISABLED") {
return !parse_bool_env(&val);
}
Config::load().map(|c| c.telemetry.enabled).unwrap_or(true)
}

impl Config {
Expand Down Expand Up @@ -166,12 +232,26 @@ exclude_commands = ["curl", "gh"]
"#;
let config: Config = toml::from_str(toml).expect("valid toml");
assert_eq!(config.hooks.exclude_commands, vec!["curl", "gh"]);
// auto_approve defaults to true when omitted
assert!(config.hooks.auto_approve);
}

#[test]
fn test_hooks_config_auto_approve_false() {
let toml = r#"
[hooks]
auto_approve = false
exclude_commands = []
"#;
let config: Config = toml::from_str(toml).expect("valid toml");
assert!(!config.hooks.auto_approve);
}

#[test]
fn test_hooks_config_default_empty() {
let config = Config::default();
assert!(config.hooks.exclude_commands.is_empty());
assert!(config.hooks.auto_approve);
}

#[test]
Expand All @@ -183,5 +263,69 @@ history_days = 90
"#;
let config: Config = toml::from_str(toml).expect("valid toml");
assert!(config.hooks.exclude_commands.is_empty());
assert!(config.hooks.auto_approve);
}

#[test]
fn test_hooks_config_claude_dir() {
let toml = r#"
[hooks]
claude_dir = "/custom/claude"
"#;
let config: Config = toml::from_str(toml).expect("valid toml");
assert_eq!(
config.hooks.claude_dir,
Some(PathBuf::from("/custom/claude"))
);
}

#[test]
fn test_parse_bool_env_truthy() {
assert!(parse_bool_env("1"));
assert!(parse_bool_env("true"));
assert!(parse_bool_env("TRUE"));
assert!(parse_bool_env("True"));
assert!(parse_bool_env("yes"));
assert!(parse_bool_env("YES"));
}

#[test]
fn test_parse_bool_env_falsy() {
assert!(!parse_bool_env("0"));
assert!(!parse_bool_env("false"));
assert!(!parse_bool_env("no"));
assert!(!parse_bool_env(""));
assert!(!parse_bool_env("anything_else"));
}

#[test]
fn test_resolve_auto_approve_with_config_default() {
let hooks = HooksConfig::default();
// Without env var set, uses config value (true by default)
assert!(resolve_auto_approve_with(&hooks));
}

#[test]
fn test_resolve_auto_approve_with_config_false() {
let hooks = HooksConfig {
auto_approve: false,
..Default::default()
};
assert!(!resolve_auto_approve_with(&hooks));
}

#[test]
fn test_resolve_claude_dir_cli_override() {
let override_path = Path::new("/custom/override");
let result = resolve_claude_dir(Some(override_path)).unwrap();
assert_eq!(result, PathBuf::from("/custom/override"));
}

#[test]
fn test_resolve_claude_dir_cli_wins_over_env() {
// CLI override takes priority even when CLAUDE_CONFIG_DIR is set
let override_path = Path::new("/explicit/override");
let result = resolve_claude_dir(Some(override_path)).unwrap();
assert_eq!(result, PathBuf::from("/explicit/override"));
}
}
Loading