Skip to main content

rtk_cc_hook/
hook.rs

1//! `rtk-cc-hook` — the plugin's `PreToolUse` hook.
2//!
3//! Claude Code runs this binary before every `Bash` / `PowerShell` tool call,
4//! hands it the event as JSON on stdin, and reads a JSON verdict from stdout.
5//! The verdict is produced by `rtk hook claude`, which already speaks this exact
6//! schema; this file only moves bytes between the two.
7//!
8//! Two rules govern the whole file:
9//!
10//! 1. **The hook always exits 0.** Exit 2 would feed stderr back to Claude as a
11//!    blocking error — an absurd outcome for "I could not optimize your
12//!    command". Every failure degrades to silence instead.
13//! 2. **Empty stdout means "no opinion".** Claude Code then runs the command the
14//!    model actually wrote. That is also rtk's own signal for "nothing to
15//!    rewrite", so the fail-open path and the no-op path are the same path.
16
17use rtk_mcp_cc::{delegate, Config};
18use std::io::{Read, Write};
19use std::path::PathBuf;
20use std::process::{Command, Stdio};
21
22fn main() {
23    let args: Vec<String> = std::env::args().collect();
24    if args.iter().any(|a| a == "--version" || a == "-V") {
25        println!("rtk-cc-hook {}", env!("CARGO_PKG_VERSION"));
26        return;
27    }
28    if args.iter().any(|a| a == "--help" || a == "-h") {
29        print_help();
30        return;
31    }
32
33    // stderr only: stdout carries the hook's JSON verdict.
34    tracing_subscriber::fmt()
35        .with_writer(std::io::stderr)
36        .with_env_filter(
37            tracing_subscriber::EnvFilter::try_from_default_env()
38                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
39        )
40        .init();
41
42    let mut input = String::new();
43    if std::io::stdin().read_to_string(&mut input).is_err() {
44        tracing::warn!("could not read hook input from stdin; passing through");
45        return;
46    }
47
48    let project_dir = std::env::var("CLAUDE_PROJECT_DIR")
49        .map(PathBuf::from)
50        .or_else(|_| std::env::current_dir())
51        .unwrap_or_else(|_| PathBuf::from("."));
52
53    let config = Config::load(&project_dir);
54    let verdict = delegate(&input, &config, |args, payload| {
55        run_rtk(&config.rtk_bin, args, payload)
56    });
57
58    print!("{verdict}");
59}
60
61/// Spawn rtk, write `payload` to its stdin, and return its stdout on a clean
62/// exit.
63///
64/// `None` on any failure — binary not found, non-zero exit, non-UTF-8 output.
65/// rtk's stderr is left attached to ours rather than captured: its advisory
66/// notices belong in the session log, and Claude Code ignores hook stderr on a
67/// zero exit.
68fn run_rtk(rtk_bin: &str, args: &[String], payload: &str) -> Option<String> {
69    let mut child = match Command::new(rtk_bin)
70        .args(args)
71        .stdin(Stdio::piped())
72        .stdout(Stdio::piped())
73        .stderr(Stdio::inherit())
74        .spawn()
75    {
76        Ok(child) => child,
77        Err(e) => {
78            tracing::warn!(rtk_bin, "could not spawn rtk ({e}); passing through");
79            return None;
80        }
81    };
82
83    // A failed write is not fatal on its own: rtk may have exited early and
84    // closed the pipe. Let the exit status below decide.
85    if let Some(mut stdin) = child.stdin.take() {
86        if let Err(e) = stdin.write_all(payload.as_bytes()) {
87            tracing::warn!("could not write to rtk stdin ({e})");
88        }
89    }
90
91    let output = match child.wait_with_output() {
92        Ok(output) => output,
93        Err(e) => {
94            tracing::warn!("rtk did not complete ({e}); passing through");
95            return None;
96        }
97    };
98
99    if !output.status.success() {
100        tracing::warn!(status = ?output.status, "rtk exited non-zero; passing through");
101        return None;
102    }
103
104    match String::from_utf8(output.stdout) {
105        Ok(stdout) => Some(stdout),
106        Err(_) => {
107            tracing::warn!("rtk produced non-UTF-8 stdout; passing through");
108            None
109        }
110    }
111}
112
113fn print_help() {
114    println!(
115        "rtk-cc-hook {} — PreToolUse hook that routes shell commands through rtk",
116        env!("CARGO_PKG_VERSION")
117    );
118    println!();
119    println!("Usage: rtk-cc-hook");
120    println!("  Reads a Claude Code PreToolUse event as JSON on stdin, delegates to");
121    println!("  `rtk hook claude`, and writes the resulting verdict to stdout. Writes");
122    println!("  nothing — and always exits 0 — when rtk is unavailable or declines to");
123    println!("  rewrite, so the original command runs unchanged.");
124    println!();
125    println!("Environment:");
126    println!("  RTK_BIN               Path to the rtk executable (default: `rtk` on PATH)");
127    println!("  RTK_CC_DISABLE=1      Pass every command through untouched");
128    println!("  RTK_CC_ULTRA_COMPACT  Pass --ultra-compact to rtk");
129    println!("  RTK_CC_SKIP_ENV       Pass --skip-env to rtk");
130    println!();
131    println!("Settings file: .claude/rtk-mcp-cc.local.md (environment overrides it)");
132}