Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Build
/target
.cargo-ok

# Environment & Secrets
.env
.env.*
settings.local.json
*.pem
*.key
*.crt
Expand Down
61 changes: 0 additions & 61 deletions hooks/rtk-rewrite.sh

This file was deleted.

97 changes: 18 additions & 79 deletions src/hook_check.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use std::path::PathBuf;

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

/// Hook status for diagnostics and `rtk gain`.
Expand All @@ -16,6 +15,7 @@ pub enum HookStatus {

/// Return the current hook status without printing anything.
/// Returns `Ok` if no Claude Code is detected (not applicable).
/// Returns `Ok` for native hook (built into rtk binary).
pub fn status() -> HookStatus {
// Don't warn users who don't have Claude Code installed
let home = match dirs::home_dir() {
Expand All @@ -26,17 +26,9 @@ pub fn status() -> HookStatus {
return HookStatus::Ok;
}

let Some(hook_path) = hook_installed_path() else {
return HookStatus::Missing;
};
let Ok(content) = std::fs::read_to_string(&hook_path) else {
return HookStatus::Outdated; // exists but unreadable — treat as needs-update
};
if parse_hook_version(&content) >= CURRENT_HOOK_VERSION {
HookStatus::Ok
} else {
HookStatus::Outdated
}
// Native hook is built into rtk binary - always available
// No file-based check needed
HookStatus::Ok
}

/// Check if the installed hook is missing or outdated, warn once per day.
Expand Down Expand Up @@ -74,27 +66,6 @@ fn check_and_warn() -> Option<()> {
Some(())
}

pub fn parse_hook_version(content: &str) -> u8 {
// Version tag must be in the first 5 lines (shebang + header convention)
for line in content.lines().take(5) {
if let Some(rest) = line.strip_prefix("# rtk-hook-version:") {
if let Ok(v) = rest.trim().parse::<u8>() {
return v;
}
}
}
0 // No version tag = version 0 (outdated)
}

fn hook_installed_path() -> Option<PathBuf> {
let home = dirs::home_dir()?;
let path = home.join(".claude").join("hooks").join("rtk-rewrite.sh");
if path.exists() {
Some(path)
} else {
None
}
}

fn warn_marker_path() -> Option<PathBuf> {
let data_dir = dirs::data_local_dir()?.join("rtk");
Expand All @@ -105,30 +76,6 @@ fn warn_marker_path() -> Option<PathBuf> {
mod tests {
use super::*;

#[test]
fn test_parse_hook_version_present() {
let content = "#!/usr/bin/env bash\n# rtk-hook-version: 2\n# some comment\n";
assert_eq!(parse_hook_version(content), 2);
}

#[test]
fn test_parse_hook_version_missing() {
let content = "#!/usr/bin/env bash\n# old hook without version\n";
assert_eq!(parse_hook_version(content), 0);
}

#[test]
fn test_parse_hook_version_future() {
let content = "#!/usr/bin/env bash\n# rtk-hook-version: 5\n";
assert_eq!(parse_hook_version(content), 5);
}

#[test]
fn test_parse_hook_version_no_tag() {
assert_eq!(parse_hook_version("no version here"), 0);
assert_eq!(parse_hook_version(""), 0);
}

#[test]
fn test_hook_status_enum() {
assert_ne!(HookStatus::Ok, HookStatus::Missing);
Expand All @@ -141,31 +88,23 @@ mod tests {

#[test]
fn test_status_returns_valid_variant() {
// Skip on machines without Claude Code or without hook
// Native hook is built into rtk binary - always returns Ok if Claude Code exists
let home = match dirs::home_dir() {
Some(h) => h,
None => return,
};
if !home
.join(".claude")
.join("hooks")
.join("rtk-rewrite.sh")
.exists()
{
// No hook — status should be Missing (if .claude exists) or Ok (if not)
let s = status();
if home.join(".claude").exists() {
assert_eq!(s, HookStatus::Missing);
} else {
assert_eq!(s, HookStatus::Ok);
None => {
// No home dir - should return Ok (not applicable)
assert_eq!(status(), HookStatus::Ok);
return;
}
return;
}
};

let s = status();
assert!(
s == HookStatus::Ok || s == HookStatus::Outdated,
"Expected Ok or Outdated when hook exists, got {:?}",
s
);
if home.join(".claude").exists() {
// Claude Code installed - native hook is always available
assert_eq!(s, HookStatus::Ok);
} else {
// Claude Code not installed - not applicable
assert_eq!(s, HookStatus::Ok);
}
}
}
82 changes: 82 additions & 0 deletions src/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,43 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> {
Ok(())
}

// ── Claude Code hook ───────────────────────────────────────────

/// Run Claude Code PreToolUse hook.
/// Reads JSON from stdin, rewrites shell commands to rtk equivalents,
/// outputs JSON to stdout in Claude Code format.
pub fn run_claude() -> Result<()> {
let mut input = String::new();
io::stdin()
.read_to_string(&mut input)
.context("Failed to read stdin")?;

let input = input.trim();
if input.is_empty() {
return Ok(());
}

let v: Value = match serde_json::from_str(input) {
Ok(v) => v,
Err(e) => {
eprintln!("[rtk hook] Failed to parse JSON input: {e}");
return Ok(());
}
};

// Extract command from tool_input.command
let cmd = match v
.pointer("/tool_input/command")
.and_then(|c| c.as_str())
.filter(|c| !c.is_empty())
{
Some(c) => c,
None => return Ok(()), // No command = pass through
};

handle_vscode(cmd)
}

// ── Gemini hook ───────────────────────────────────────────────

/// Run the Gemini CLI BeforeTool hook.
Expand Down Expand Up @@ -330,4 +367,49 @@ mod tests {
Some("RUST_LOG=debug rtk cargo test".into())
);
}

// --- Claude Code hook ---

#[test]
fn test_claude_hook_format_matches_vscode() {
// Claude Code uses same format as VS Code
let input = json!({
"tool_name": "Bash",
"tool_input": { "command": "git status" }
});
assert!(matches!(
detect_format(&input),
HookFormat::VsCode { .. }
));
}

#[test]
fn test_claude_hook_output_format() {
// Verify the output format matches expected Claude Code hook format
let cmd = "git status";
let rewritten = get_rewritten(cmd).unwrap();

let output = json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": { "command": rewritten }
}
});

let json: Value = serde_json::from_str(&output.to_string()).unwrap();
assert_eq!(
json["hookSpecificOutput"]["hookEventName"],
"PreToolUse"
);
assert_eq!(
json["hookSpecificOutput"]["permissionDecision"],
"allow"
);
assert_eq!(
json["hookSpecificOutput"]["updatedInput"]["command"],
"rtk git status"
);
}
}
Loading