Skip to main content

lean_ctx/hook_handlers/
mod.rs

1use crate::compound_lexer;
2use crate::rewrite_registry;
3use std::io::Read;
4use std::sync::mpsc;
5use std::time::Duration;
6
7const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);
8mod observe;
9pub use observe::*;
10#[cfg(test)]
11mod tests;
12
13fn is_disabled() -> bool {
14    std::env::var("LEAN_CTX_DISABLED").is_ok()
15}
16
17fn is_harden_active() -> bool {
18    matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
19}
20
21fn is_shadow_mode_active() -> bool {
22    if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
23        return true;
24    }
25    crate::core::config::Config::load().shadow_mode
26}
27
28fn log_shadow_intercept(tool: &str, detail: &str) {
29    if !is_shadow_mode_active() {
30        return;
31    }
32    let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
33        return;
34    };
35    let log_path = data_dir.join("shadow.log");
36    let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
37    let line = format!("[{ts}] intercepted {tool}: {detail}\n");
38    let _ = std::fs::OpenOptions::new()
39        .create(true)
40        .append(true)
41        .open(log_path)
42        .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
43}
44
45fn is_quiet() -> bool {
46    matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
47}
48
49/// Mark this process as a hook child so the daemon-client never auto-starts
50/// the daemon from inside a hook (which would create zombie processes).
51pub fn mark_hook_environment() {
52    std::env::set_var("LEAN_CTX_HOOK_CHILD", "1");
53}
54
55/// Arms a watchdog that force-exits the process after the given duration.
56/// Prevents hook processes from becoming zombies when stdin pipes break or
57/// the IDE cancels the call. Since hooks MUST NOT spawn child processes
58/// (to avoid orphan zombies), a simple exit(1) suffices.
59pub fn arm_watchdog(timeout: Duration) {
60    std::thread::spawn(move || {
61        std::thread::sleep(timeout);
62        eprintln!(
63            "[lean-ctx hook] watchdog timeout after {}s — force exit",
64            timeout.as_secs()
65        );
66        std::process::exit(1);
67    });
68}
69
70/// Reads all of stdin with a timeout. Returns None if stdin is empty, broken, or times out.
71fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
72    let (tx, rx) = mpsc::channel();
73    std::thread::spawn(move || {
74        let mut buf = String::new();
75        let result = std::io::stdin().read_to_string(&mut buf);
76        let _ = tx.send(result.ok().map(|_| buf));
77    });
78    match rx.recv_timeout(timeout) {
79        Ok(Some(s)) if !s.is_empty() => Some(s),
80        _ => None,
81    }
82}
83
84fn build_dual_allow_output() -> String {
85    serde_json::json!({
86        "permission": "allow",
87        "hookSpecificOutput": {
88            "hookEventName": "PreToolUse",
89            "permissionDecision": "allow"
90        }
91    })
92    .to_string()
93}
94
95fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
96    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
97        let mut m = obj.clone();
98        m.insert(
99            "command".to_string(),
100            serde_json::Value::String(rewritten.to_string()),
101        );
102        serde_json::Value::Object(m)
103    } else {
104        serde_json::json!({ "command": rewritten })
105    };
106
107    serde_json::json!({
108        // Cursor hook output format
109        "permission": "allow",
110        "updated_input": updated_input,
111        // Claude Code hook output format (extra fields are ignored by other hosts)
112        "hookSpecificOutput": {
113            "hookEventName": "PreToolUse",
114            "permissionDecision": "allow",
115            "updatedInput": {
116                "command": rewritten
117            }
118        }
119    })
120    .to_string()
121}
122
123pub fn handle_rewrite() {
124    let allow = build_dual_allow_output();
125    if is_disabled() {
126        print!("{allow}");
127        return;
128    }
129    let binary = resolve_binary();
130    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
131        print!("{allow}");
132        return;
133    };
134
135    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
136        tracing::warn!("[hook rewrite] invalid JSON payload, allowing passthrough");
137        print!("{allow}");
138        return;
139    };
140
141    let tool = v.get("tool_name").and_then(|t| t.as_str());
142    let Some(tool_name) = tool else {
143        print!("{allow}");
144        return;
145    };
146
147    let is_shell_tool = matches!(
148        tool_name,
149        "Bash" | "bash" | "Shell" | "shell" | "runInTerminal" | "run_in_terminal" | "terminal"
150    );
151    if !is_shell_tool {
152        print!("{allow}");
153        return;
154    }
155
156    let tool_input = v.get("tool_input");
157    let Some(cmd) = tool_input
158        .and_then(|ti| ti.get("command"))
159        .and_then(|c| c.as_str())
160        .or_else(|| v.get("command").and_then(|c| c.as_str()))
161    else {
162        print!("{allow}");
163        return;
164    };
165
166    if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
167        print!("{}", build_dual_rewrite_output(tool_input, &rewritten));
168    } else {
169        print!("{allow}");
170    }
171}
172
173fn is_rewritable(cmd: &str) -> bool {
174    rewrite_registry::is_rewritable_command(cmd)
175}
176
177fn wrap_single_command(cmd: &str, binary: &str) -> String {
178    if cfg!(windows) {
179        let escaped = cmd.replace('"', "\\\"");
180        format!("{binary} -c \"{escaped}\"")
181    } else {
182        let shell_escaped = cmd.replace('\'', "'\\''");
183        format!("{binary} -c '{shell_escaped}'")
184    }
185}
186
187fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
188    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
189        return None;
190    }
191
192    // Heredocs cannot survive the quoting round-trip through `lean-ctx -c '...'`.
193    // Newlines get escaped, breaking the heredoc syntax entirely (GitHub #140).
194    if cmd.contains("<<") {
195        return None;
196    }
197
198    if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
199        return Some(rewritten);
200    }
201
202    if let Some(rewritten) = rewrite_search_command(cmd, binary) {
203        return Some(rewritten);
204    }
205
206    if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
207        return Some(rewritten);
208    }
209
210    if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
211        return Some(rewritten);
212    }
213
214    if is_rewritable(cmd) {
215        return Some(wrap_single_command(cmd, binary));
216    }
217
218    None
219}
220
221/// Rewrites cat/head/tail to lean-ctx read with appropriate arguments.
222/// Only rewrites simple single-file reads within the project scope.
223fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
224    if !rewrite_registry::is_file_read_command(cmd) {
225        return None;
226    }
227
228    // Compound commands (pipes, chains) should not be rewritten as file reads.
229    if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
230        return None;
231    }
232
233    // Shell redirections indicate complex usage — don't rewrite.
234    if cmd.contains(">&") || cmd.contains(">>") || cmd.contains(" >") {
235        return None;
236    }
237
238    let parts = shell_tokenize(cmd);
239    if parts.len() < 2 {
240        return None;
241    }
242
243    match parts[0].as_str() {
244        "cat" => {
245            let path = parts[1..].join(" ");
246            if is_outside_project_path(&path) {
247                return None;
248            }
249            Some(format!("{binary} read {}", shell_quote(&path)))
250        }
251        "head" => {
252            let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
253            let (n, path) = parse_head_tail_args(&refs);
254            let path = path?;
255            if is_outside_project_path(path) {
256                return None;
257            }
258            let qp = shell_quote(path);
259            match n {
260                Some(lines) => Some(format!("{binary} read {qp} -m lines:1-{lines}")),
261                None => Some(format!("{binary} read {qp} -m lines:1-10")),
262            }
263        }
264        "tail" => {
265            let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
266            let (n, path) = parse_head_tail_args(&refs);
267            let path = path?;
268            if is_outside_project_path(path) {
269                return None;
270            }
271            let qp = shell_quote(path);
272            let lines = n.unwrap_or(10);
273            Some(format!("{binary} read {qp} -m lines:-{lines}"))
274        }
275        _ => None,
276    }
277}
278
279/// Returns true if the path clearly points outside the current project.
280/// Paths starting with `~`, `$`, or absolute paths that don't resolve
281/// within the working directory should not be intercepted.
282fn is_outside_project_path(path: &str) -> bool {
283    let trimmed = path.trim();
284
285    // Home-relative paths are always outside the project
286    if trimmed.starts_with('~') {
287        return true;
288    }
289
290    // Environment variable expansion — too complex, pass through
291    if trimmed.starts_with('$') {
292        return true;
293    }
294
295    // /proc, /sys, /dev, /tmp, /var — system paths
296    if trimmed.starts_with("/proc/")
297        || trimmed.starts_with("/sys/")
298        || trimmed.starts_with("/dev/")
299        || trimmed.starts_with("/tmp/")
300        || trimmed.starts_with("/var/")
301    {
302        return true;
303    }
304
305    // Absolute paths: only pass through if they clearly point outside.
306    // We can't know the project root here (hooks are stateless), but we can
307    // detect common external patterns.
308    if trimmed.starts_with('/') {
309        // Home directory paths (e.g. /Users/*/Library, /home/*/.config)
310        if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
311            return true;
312        }
313        // lean-ctx's own data directories
314        if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
315            return true;
316        }
317    }
318
319    false
320}
321
322/// Rewrites `rg <pattern> [path]` to `lean-ctx grep <pattern> [path]` for simple forms.
323fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
324    let parts = shell_tokenize(cmd);
325    if parts.first().map(String::as_str) != Some("rg") {
326        return None;
327    }
328    if parts.len() < 2 || parts.len() > 3 {
329        return None;
330    }
331    if parts[1].starts_with('-') {
332        return None;
333    }
334    let pattern = &parts[1];
335    match parts.get(2) {
336        Some(p) if p.starts_with('-') => None,
337        Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(p))),
338        None => Some(format!("{binary} grep {pattern}")),
339    }
340}
341
342/// Rewrites simple `ls [path]` to `lean-ctx ls [path]`.
343fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
344    let parts = shell_tokenize(cmd);
345    if parts.first().map(String::as_str) != Some("ls") {
346        return None;
347    }
348    match parts.len() {
349        1 => Some(format!("{binary} ls")),
350        2 if !parts[1].starts_with('-') => Some(format!("{binary} ls {}", shell_quote(&parts[1]))),
351        _ => None,
352    }
353}
354
355/// Tokenize a shell command respecting single/double quotes and backslash escapes.
356pub fn shell_tokenize(input: &str) -> Vec<String> {
357    let mut tokens = Vec::new();
358    let mut current = String::new();
359    let mut chars = input.chars().peekable();
360    let mut in_single = false;
361    let mut in_double = false;
362
363    while let Some(c) = chars.next() {
364        match c {
365            '\'' if !in_double => in_single = !in_single,
366            '"' if !in_single => in_double = !in_double,
367            '\\' if !in_single => {
368                if let Some(next) = chars.next() {
369                    current.push(next);
370                }
371            }
372            c if c.is_whitespace() && !in_single && !in_double => {
373                if !current.is_empty() {
374                    tokens.push(std::mem::take(&mut current));
375                }
376            }
377            _ => current.push(c),
378        }
379    }
380    if !current.is_empty() {
381        tokens.push(current);
382    }
383    tokens
384}
385
386/// Quote a path/arg for shell if it contains spaces or special chars.
387pub fn shell_quote(s: &str) -> String {
388    if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
389        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
390    } else {
391        s.to_string()
392    }
393}
394
395fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
396    let mut n: Option<usize> = None;
397    let mut path: Option<&str> = None;
398
399    let mut i = 0;
400    while i < args.len() {
401        if args[i] == "-n" && i + 1 < args.len() {
402            n = args[i + 1].parse().ok();
403            i += 2;
404        } else if let Some(num) = args[i].strip_prefix("-n") {
405            n = num.parse().ok();
406            i += 1;
407        } else if args[i].starts_with('-') && args[i].len() > 1 {
408            if let Ok(num) = args[i][1..].parse::<usize>() {
409                n = Some(num);
410            }
411            i += 1;
412        } else {
413            path = Some(args[i]);
414            i += 1;
415        }
416    }
417
418    (n, path)
419}
420
421fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
422    compound_lexer::rewrite_compound(cmd, |segment| {
423        if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
424            return None;
425        }
426        if is_rewritable(segment) {
427            Some(wrap_single_command(segment, binary))
428        } else {
429            None
430        }
431    })
432}
433
434fn emit_rewrite(rewritten: &str) {
435    let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
436    print!(
437        "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
438    );
439}
440
441pub fn handle_redirect() {
442    let allow = build_dual_allow_output();
443    if is_disabled() {
444        let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
445        print!("{allow}");
446        return;
447    }
448
449    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
450        print!("{allow}");
451        return;
452    };
453
454    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
455        tracing::warn!("[hook redirect] invalid JSON payload, allowing passthrough");
456        print!("{allow}");
457        return;
458    };
459
460    let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
461    let tool_input = v.get("tool_input");
462
463    match tool_name {
464        "Read" | "read" | "read_file" => redirect_read(tool_input),
465        "Grep" | "grep" | "search" | "ripgrep" => redirect_grep(tool_input),
466        _ => print!("{allow}"),
467    }
468}
469
470/// Redirect Read through lean-ctx for compression + caching.
471/// Safe because `mark_hook_environment()` sets LEAN_CTX_HOOK_CHILD=1 which
472/// prevents daemon auto-start. The subprocess uses the fast local-only path.
473fn redirect_read(tool_input: Option<&serde_json::Value>) {
474    let path = tool_input
475        .and_then(|ti| ti.get("path"))
476        .and_then(|p| p.as_str())
477        .unwrap_or("");
478
479    if path.is_empty() || should_passthrough(path) {
480        print!("{}", build_dual_allow_output());
481        return;
482    }
483
484    let shadow = is_shadow_mode_active();
485    if is_harden_active() || shadow {
486        tracing::info!(
487            "[hook redirect] {} active, redirecting Read through lean-ctx",
488            if shadow { "shadow mode" } else { "harden mode" }
489        );
490    }
491
492    let binary = resolve_binary();
493    let temp_path = redirect_temp_path(path);
494
495    if let Some(mut output) =
496        run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT)
497    {
498        if shadow {
499            let header = format!(
500                "[shadow-mode: Read intercepted → ctx_read(\"{path}\", \"full\"). Use ctx_read directly for better performance.]\n\n"
501            );
502            let mut prefixed = header.into_bytes();
503            prefixed.append(&mut output);
504            output = prefixed;
505        }
506        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
507            let temp_str = temp_path.to_str().unwrap_or("");
508            print!("{}", build_redirect_output(tool_input, "path", temp_str));
509            log_shadow_intercept("Read", path);
510            return;
511        }
512    }
513
514    print!("{}", build_dual_allow_output());
515}
516
517/// Redirect Grep through lean-ctx for compressed results.
518fn redirect_grep(tool_input: Option<&serde_json::Value>) {
519    let pattern = tool_input
520        .and_then(|ti| ti.get("pattern"))
521        .and_then(|p| p.as_str())
522        .unwrap_or("");
523    let search_path = tool_input
524        .and_then(|ti| ti.get("path"))
525        .and_then(|p| p.as_str())
526        .unwrap_or(".");
527
528    if pattern.is_empty() {
529        print!("{}", build_dual_allow_output());
530        return;
531    }
532
533    let shadow = is_shadow_mode_active();
534    if is_harden_active() || shadow {
535        tracing::info!(
536            "[hook redirect] {} active, redirecting Grep through lean-ctx",
537            if shadow { "shadow mode" } else { "harden mode" }
538        );
539    }
540
541    let binary = resolve_binary();
542    let key = format!("grep:{pattern}:{search_path}");
543    let temp_path = redirect_temp_path(&key);
544
545    if let Some(mut output) = run_with_timeout(
546        &binary,
547        &["grep", pattern, search_path],
548        REDIRECT_SUBPROCESS_TIMEOUT,
549    ) {
550        if shadow {
551            let header = format!(
552                "[shadow-mode: Grep intercepted → ctx_search(\"{pattern}\", \"{search_path}\"). Use ctx_search directly for better performance.]\n\n"
553            );
554            let mut prefixed = header.into_bytes();
555            prefixed.append(&mut output);
556            output = prefixed;
557        }
558        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
559            let temp_str = temp_path.to_str().unwrap_or("");
560            print!("{}", build_redirect_output(tool_input, "path", temp_str));
561            log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
562            return;
563        }
564    }
565
566    print!("{}", build_dual_allow_output());
567}
568
569const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
570
571/// Run a lean-ctx subprocess with a hard timeout. Returns stdout on success.
572/// Kills the child if it exceeds the timeout to prevent orphan processes.
573fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
574    let mut child = std::process::Command::new(binary)
575        .args(args)
576        .stdout(std::process::Stdio::piped())
577        .stderr(std::process::Stdio::null())
578        .spawn()
579        .ok()?;
580
581    let deadline = std::time::Instant::now() + timeout;
582    loop {
583        match child.try_wait() {
584            Ok(Some(status)) if status.success() => {
585                let mut stdout = Vec::new();
586                if let Some(mut out) = child.stdout.take() {
587                    let _ = out.read_to_end(&mut stdout);
588                }
589                return if stdout.is_empty() {
590                    None
591                } else {
592                    Some(stdout)
593                };
594            }
595            Ok(Some(_)) | Err(_) => return None,
596            Ok(None) => {
597                if std::time::Instant::now() > deadline {
598                    let _ = child.kill();
599                    let _ = child.wait();
600                    return None;
601                }
602                std::thread::sleep(Duration::from_millis(10));
603            }
604        }
605    }
606}
607
608fn redirect_temp_path(key: &str) -> std::path::PathBuf {
609    use std::collections::hash_map::DefaultHasher;
610    use std::hash::{Hash, Hasher};
611
612    let mut hasher = DefaultHasher::new();
613    key.hash(&mut hasher);
614    std::process::id().hash(&mut hasher);
615    let hash = hasher.finish();
616
617    let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
618    let _ = std::fs::create_dir_all(&temp_dir);
619    #[cfg(unix)]
620    {
621        use std::os::unix::fs::PermissionsExt;
622        let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
623    }
624    temp_dir.join(format!("{hash:016x}.lctx"))
625}
626
627fn build_redirect_output(
628    tool_input: Option<&serde_json::Value>,
629    field: &str,
630    temp_path: &str,
631) -> String {
632    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
633        let mut m = obj.clone();
634        m.insert(
635            field.to_string(),
636            serde_json::Value::String(temp_path.to_string()),
637        );
638        serde_json::Value::Object(m)
639    } else {
640        serde_json::json!({ field: temp_path })
641    };
642
643    serde_json::json!({
644        "permission": "allow",
645        "updated_input": updated_input,
646        "hookSpecificOutput": {
647            "hookEventName": "PreToolUse",
648            "permissionDecision": "allow",
649            "updatedInput": { field: temp_path }
650        }
651    })
652    .to_string()
653}
654
655const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
656    ".cursorrules",
657    ".cursor/rules",
658    ".cursor/hooks",
659    "skill.md",
660    "agents.md",
661    ".env",
662    "hooks.json",
663    "node_modules",
664];
665
666const PASSTHROUGH_EXTENSIONS: &[&str] = &[
667    "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
668];
669
670fn should_passthrough(path: &str) -> bool {
671    let p = path.to_lowercase();
672
673    if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
674        return true;
675    }
676
677    std::path::Path::new(&p)
678        .extension()
679        .and_then(|ext| ext.to_str())
680        .is_some_and(|ext| {
681            PASSTHROUGH_EXTENSIONS
682                .iter()
683                .any(|e| ext.eq_ignore_ascii_case(e))
684        })
685}
686
687fn codex_reroute_message(rewritten: &str) -> String {
688    format!(
689        "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: {rewritten}"
690    )
691}
692
693pub fn handle_codex_pretooluse() {
694    if is_disabled() {
695        return;
696    }
697    let binary = resolve_binary();
698    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
699        return;
700    };
701
702    let tool = extract_json_field(&input, "tool_name");
703    if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
704        return;
705    }
706
707    let Some(cmd) = extract_json_field(&input, "command") else {
708        return;
709    };
710
711    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
712        if is_quiet() {
713            eprintln!("Re-run: {rewritten}");
714        } else {
715            eprintln!("{}", codex_reroute_message(&rewritten));
716        }
717        std::process::exit(2);
718    }
719}
720
721pub fn handle_codex_session_start() {
722    if is_quiet() {
723        return;
724    }
725    println!(
726        "For shell commands matched by lean-ctx compression rules, prefer `lean-ctx -c \"<command>\"`. If a Bash call is blocked, rerun it with the exact command suggested by the hook."
727    );
728}
729
730/// Copilot-specific PreToolUse handler.
731/// VS Code Copilot Chat uses the same hook format as Claude Code.
732/// Tool names differ: "runInTerminal" / "editFile" instead of "Bash" / "Read".
733pub fn handle_copilot() {
734    if is_disabled() {
735        return;
736    }
737    let binary = resolve_binary();
738    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
739        return;
740    };
741
742    let tool = extract_json_field(&input, "tool_name");
743    let Some(tool_name) = tool.as_deref() else {
744        return;
745    };
746
747    let is_shell_tool = matches!(
748        tool_name,
749        "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
750    );
751    if !is_shell_tool {
752        return;
753    }
754
755    let Some(cmd) = extract_json_field(&input, "command") else {
756        return;
757    };
758
759    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
760        emit_rewrite(&rewritten);
761    }
762}
763
764/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
765/// Used by the OpenCode TS plugin where the command is passed as an argument,
766/// not via stdin JSON. Uses native OS paths (not MSYS) because the calling
767/// shell may be PowerShell or cmd on Windows.
768pub fn handle_rewrite_inline() {
769    if is_disabled() {
770        return;
771    }
772    let binary = resolve_binary_native();
773    let args: Vec<String> = std::env::args().collect();
774    // args: [binary, "hook", "rewrite-inline", ...command parts]
775    if args.len() < 4 {
776        return;
777    }
778    let cmd = args[3..].join(" ");
779
780    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
781        print!("{rewritten}");
782        return;
783    }
784
785    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
786        print!("{cmd}");
787        return;
788    }
789
790    print!("{cmd}");
791}
792
793fn resolve_binary() -> String {
794    let path = crate::core::portable_binary::resolve_portable_binary();
795    crate::hooks::to_bash_compatible_path(&path)
796}
797
798fn resolve_binary_native() -> String {
799    crate::core::portable_binary::resolve_portable_binary()
800}
801
802fn extract_json_field(input: &str, field: &str) -> Option<String> {
803    let key = format!("\"{field}\":");
804    let key_pos = input.find(&key)?;
805    let after_colon = &input[key_pos + key.len()..];
806    let trimmed = after_colon.trim_start();
807    if !trimmed.starts_with('"') {
808        return None;
809    }
810    let rest = &trimmed[1..];
811    let bytes = rest.as_bytes();
812    let mut end = 0;
813    while end < bytes.len() {
814        if bytes[end] == b'\\' && end + 1 < bytes.len() {
815            end += 2;
816            continue;
817        }
818        if bytes[end] == b'"' {
819            break;
820        }
821        end += 1;
822    }
823    if end >= bytes.len() {
824        return None;
825    }
826    let raw = &rest[..end];
827    Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
828}