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