Skip to main content

lean_ctx/
hook_handlers.rs

1use crate::compound_lexer;
2use crate::rewrite_registry;
3use std::io::Read;
4
5pub fn handle_rewrite() {
6    let binary = resolve_binary();
7    let mut input = String::new();
8    if std::io::stdin().read_to_string(&mut input).is_err() {
9        return;
10    }
11
12    let tool = extract_json_field(&input, "tool_name");
13    if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
14        return;
15    }
16
17    let cmd = match extract_json_field(&input, "command") {
18        Some(c) => c,
19        None => return,
20    };
21
22    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
23        emit_rewrite(&rewritten);
24    }
25}
26
27fn is_rewritable(cmd: &str) -> bool {
28    rewrite_registry::is_rewritable_command(cmd)
29}
30
31fn wrap_single_command(cmd: &str, binary: &str) -> String {
32    let shell_escaped = cmd.replace('\\', "\\\\").replace('"', "\\\"");
33    format!("{binary} -c \"{shell_escaped}\"")
34}
35
36fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
37    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
38        return None;
39    }
40
41    if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
42        return Some(rewritten);
43    }
44
45    if is_rewritable(cmd) {
46        return Some(wrap_single_command(cmd, binary));
47    }
48
49    None
50}
51
52fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
53    compound_lexer::rewrite_compound(cmd, |segment| {
54        if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
55            return None;
56        }
57        if is_rewritable(segment) {
58            Some(wrap_single_command(segment, binary))
59        } else {
60            None
61        }
62    })
63}
64
65fn emit_rewrite(rewritten: &str) {
66    let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
67    print!(
68        "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
69    );
70}
71
72pub fn handle_redirect() {
73    // Allow all native tools (Read, Grep, ListFiles) to pass through.
74    // Blocking them breaks Edit (which requires native Read) and causes
75    // unnecessary friction. The MCP instructions already guide the AI
76    // to prefer ctx_read/ctx_search/ctx_tree.
77}
78
79fn codex_reroute_message(rewritten: &str) -> String {
80    format!(
81        "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: {rewritten}"
82    )
83}
84
85pub fn handle_codex_pretooluse() {
86    let binary = resolve_binary();
87    let mut input = String::new();
88    if std::io::stdin().read_to_string(&mut input).is_err() {
89        return;
90    }
91
92    let tool = extract_json_field(&input, "tool_name");
93    if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
94        return;
95    }
96
97    let cmd = match extract_json_field(&input, "command") {
98        Some(c) => c,
99        None => return,
100    };
101
102    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
103        eprintln!("{}", codex_reroute_message(&rewritten));
104        std::process::exit(2);
105    }
106}
107
108pub fn handle_codex_session_start() {
109    println!(
110        "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."
111    );
112}
113
114/// Copilot-specific PreToolUse handler.
115/// VS Code Copilot Chat uses the same hook format as Claude Code.
116/// Tool names differ: "runInTerminal" / "editFile" instead of "Bash" / "Read".
117pub fn handle_copilot() {
118    let binary = resolve_binary();
119    let mut input = String::new();
120    if std::io::stdin().read_to_string(&mut input).is_err() {
121        return;
122    }
123
124    let tool = extract_json_field(&input, "tool_name");
125    let tool_name = match tool.as_deref() {
126        Some(name) => name,
127        None => return,
128    };
129
130    let is_shell_tool = matches!(
131        tool_name,
132        "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
133    );
134    if !is_shell_tool {
135        return;
136    }
137
138    let cmd = match extract_json_field(&input, "command") {
139        Some(c) => c,
140        None => return,
141    };
142
143    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
144        emit_rewrite(&rewritten);
145    }
146}
147
148/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
149/// Used by the OpenCode TS plugin where the command is passed as an argument,
150/// not via stdin JSON.
151pub fn handle_rewrite_inline() {
152    let binary = resolve_binary();
153    let args: Vec<String> = std::env::args().collect();
154    // args: [binary, "hook", "rewrite-inline", ...command parts]
155    if args.len() < 4 {
156        return;
157    }
158    let cmd = args[3..].join(" ");
159
160    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
161        print!("{rewritten}");
162        return;
163    }
164
165    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
166        print!("{cmd}");
167        return;
168    }
169
170    print!("{cmd}");
171}
172
173fn resolve_binary() -> String {
174    let path = crate::core::portable_binary::resolve_portable_binary();
175    crate::hooks::to_bash_compatible_path(&path)
176}
177
178fn extract_json_field(input: &str, field: &str) -> Option<String> {
179    let pattern = format!("\"{}\":\"", field);
180    let start = input.find(&pattern)? + pattern.len();
181    let rest = &input[start..];
182    let bytes = rest.as_bytes();
183    let mut end = 0;
184    while end < bytes.len() {
185        if bytes[end] == b'\\' && end + 1 < bytes.len() {
186            end += 2;
187            continue;
188        }
189        if bytes[end] == b'"' {
190            break;
191        }
192        end += 1;
193    }
194    if end >= bytes.len() {
195        return None;
196    }
197    let raw = &rest[..end];
198    Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn is_rewritable_basic() {
207        assert!(is_rewritable("git status"));
208        assert!(is_rewritable("cargo test --lib"));
209        assert!(is_rewritable("npm run build"));
210        assert!(!is_rewritable("echo hello"));
211        assert!(!is_rewritable("cd src"));
212    }
213
214    #[test]
215    fn wrap_single() {
216        let r = wrap_single_command("git status", "lean-ctx");
217        assert_eq!(r, r#"lean-ctx -c "git status""#);
218    }
219
220    #[test]
221    fn wrap_with_quotes() {
222        let r = wrap_single_command(r#"curl -H "Auth" https://api.com"#, "lean-ctx");
223        assert_eq!(r, r#"lean-ctx -c "curl -H \"Auth\" https://api.com""#);
224    }
225
226    #[test]
227    fn rewrite_candidate_returns_none_for_existing_lean_ctx_command() {
228        assert_eq!(
229            rewrite_candidate("lean-ctx -c git status", "lean-ctx"),
230            None
231        );
232    }
233
234    #[test]
235    fn rewrite_candidate_wraps_single_command() {
236        assert_eq!(
237            rewrite_candidate("git status", "lean-ctx"),
238            Some(r#"lean-ctx -c "git status""#.to_string())
239        );
240    }
241
242    #[test]
243    fn codex_reroute_message_includes_exact_rewritten_command() {
244        let message = codex_reroute_message(r#"lean-ctx -c "git status""#);
245        assert_eq!(
246            message,
247            r#"Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: lean-ctx -c "git status""#
248        );
249    }
250
251    #[test]
252    fn compound_rewrite_and_chain() {
253        let result = build_rewrite_compound("cd src && git status && echo done", "lean-ctx");
254        assert_eq!(
255            result,
256            Some(r#"cd src && lean-ctx -c "git status" && echo done"#.into())
257        );
258    }
259
260    #[test]
261    fn compound_rewrite_pipe() {
262        let result = build_rewrite_compound("git log --oneline | head -5", "lean-ctx");
263        assert_eq!(
264            result,
265            Some(r#"lean-ctx -c "git log --oneline" | head -5"#.into())
266        );
267    }
268
269    #[test]
270    fn compound_rewrite_no_match() {
271        let result = build_rewrite_compound("cd src && echo done", "lean-ctx");
272        assert_eq!(result, None);
273    }
274
275    #[test]
276    fn compound_rewrite_multiple_rewritable() {
277        let result = build_rewrite_compound("git add . && cargo test && npm run lint", "lean-ctx");
278        assert_eq!(
279            result,
280            Some(
281                r#"lean-ctx -c "git add ." && lean-ctx -c "cargo test" && lean-ctx -c "npm run lint""#
282                    .into()
283            )
284        );
285    }
286
287    #[test]
288    fn compound_rewrite_semicolons() {
289        let result = build_rewrite_compound("git add .; git commit -m 'fix'", "lean-ctx");
290        assert_eq!(
291            result,
292            Some(r#"lean-ctx -c "git add ." ; lean-ctx -c "git commit -m 'fix'""#.into())
293        );
294    }
295
296    #[test]
297    fn compound_rewrite_or_chain() {
298        let result = build_rewrite_compound("git pull || echo failed", "lean-ctx");
299        assert_eq!(
300            result,
301            Some(r#"lean-ctx -c "git pull" || echo failed"#.into())
302        );
303    }
304
305    #[test]
306    fn compound_skips_already_rewritten() {
307        let result = build_rewrite_compound("lean-ctx -c git status && git diff", "lean-ctx");
308        assert_eq!(
309            result,
310            Some(r#"lean-ctx -c git status && lean-ctx -c "git diff""#.into())
311        );
312    }
313
314    #[test]
315    fn single_command_not_compound() {
316        let result = build_rewrite_compound("git status", "lean-ctx");
317        assert_eq!(result, None);
318    }
319
320    #[test]
321    fn extract_field_works() {
322        let input = r#"{"tool_name":"Bash","command":"git status"}"#;
323        assert_eq!(
324            extract_json_field(input, "tool_name"),
325            Some("Bash".to_string())
326        );
327        assert_eq!(
328            extract_json_field(input, "command"),
329            Some("git status".to_string())
330        );
331    }
332
333    #[test]
334    fn extract_field_handles_escaped_quotes() {
335        let input = r#"{"tool_name":"Bash","command":"grep -r \"TODO\" src/"}"#;
336        assert_eq!(
337            extract_json_field(input, "command"),
338            Some(r#"grep -r "TODO" src/"#.to_string())
339        );
340    }
341
342    #[test]
343    fn extract_field_handles_escaped_backslash() {
344        let input = r#"{"tool_name":"Bash","command":"echo \\\"hello\\\""}"#;
345        assert_eq!(
346            extract_json_field(input, "command"),
347            Some(r#"echo \"hello\""#.to_string())
348        );
349    }
350
351    #[test]
352    fn extract_field_handles_complex_curl() {
353        let input = r#"{"tool_name":"Bash","command":"curl -H \"Authorization: Bearer token\" https://api.com"}"#;
354        assert_eq!(
355            extract_json_field(input, "command"),
356            Some(r#"curl -H "Authorization: Bearer token" https://api.com"#.to_string())
357        );
358    }
359
360    #[test]
361    fn to_bash_compatible_path_windows_drive() {
362        let p = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
363        assert_eq!(p, "/e/packages/lean-ctx.exe");
364    }
365
366    #[test]
367    fn to_bash_compatible_path_backslashes() {
368        let p = crate::hooks::to_bash_compatible_path(r"C:\Users\test\bin\lean-ctx.exe");
369        assert_eq!(p, "/c/Users/test/bin/lean-ctx.exe");
370    }
371
372    #[test]
373    fn to_bash_compatible_path_unix_unchanged() {
374        let p = crate::hooks::to_bash_compatible_path("/usr/local/bin/lean-ctx");
375        assert_eq!(p, "/usr/local/bin/lean-ctx");
376    }
377
378    #[test]
379    fn to_bash_compatible_path_msys2_unchanged() {
380        let p = crate::hooks::to_bash_compatible_path("/e/packages/lean-ctx.exe");
381        assert_eq!(p, "/e/packages/lean-ctx.exe");
382    }
383
384    #[test]
385    fn wrap_command_with_bash_path() {
386        let binary = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
387        let result = wrap_single_command("git status", &binary);
388        assert!(
389            !result.contains('\\'),
390            "wrapped command must not contain backslashes, got: {result}"
391        );
392        assert!(
393            result.starts_with("/e/packages/lean-ctx.exe"),
394            "must use bash-compatible path, got: {result}"
395        );
396    }
397}