Skip to main content

lean_ctx/
hook_handlers.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);
8
9// ---------------------------------------------------------------------------
10// Observe handler — records ALL hook events for context awareness
11// ---------------------------------------------------------------------------
12
13/// Unified observe handler for all IDE hook events.
14/// Reads JSON from stdin, normalizes to `ObserveEvent`, counts tokens,
15/// appends to `context_radar.jsonl`, and exits immediately.
16pub fn handle_observe() {
17    if is_disabled() {
18        return;
19    }
20    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
21        return;
22    };
23    let Some(event) = parse_observe_event(&input) else {
24        return;
25    };
26    append_radar_event(&event);
27}
28
29#[derive(serde::Serialize)]
30struct ObserveEvent {
31    ts: u64,
32    event_type: &'static str,
33    tokens: usize,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    tool_name: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    detail: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    content: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    model: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    conversation_id: Option<String>,
44}
45
46const MAX_CONTENT_CHARS: usize = 50_000;
47
48fn parse_observe_event(input: &str) -> Option<ObserveEvent> {
49    let v: serde_json::Value = serde_json::from_str(input).ok()?;
50
51    let ts = std::time::SystemTime::now()
52        .duration_since(std::time::UNIX_EPOCH)
53        .unwrap_or_default()
54        .as_secs();
55
56    let model = v
57        .get("model")
58        .and_then(|m| m.as_str())
59        .filter(|m| !m.is_empty())
60        .map(String::from);
61    let conversation_id = v
62        .get("conversation_id")
63        .and_then(|c| c.as_str())
64        .filter(|c| !c.is_empty())
65        .map(String::from);
66
67    let transcript_path = v
68        .get("transcript_path")
69        .and_then(|t| t.as_str())
70        .filter(|t| !t.is_empty())
71        .map(String::from);
72
73    if let Some(ref m) = model {
74        persist_detected_model(m);
75    }
76    if let Some(ref tp) = transcript_path {
77        persist_transcript_path(tp, conversation_id.as_deref());
78    }
79
80    let mut event = detect_event_type(&v, ts)?;
81    event.model = model;
82    event.conversation_id = conversation_id;
83    Some(event)
84}
85
86fn detect_event_type(v: &serde_json::Value, ts: u64) -> Option<ObserveEvent> {
87    if let Some(result) = v.get("result_json").or_else(|| v.get("result")) {
88        let tool = v
89            .get("tool_name")
90            .and_then(|t| t.as_str())
91            .unwrap_or("unknown");
92        let tokens = estimate_tokens_json(result);
93        let content_str = match result {
94            serde_json::Value::String(s) => s.clone(),
95            other => other.to_string(),
96        };
97        return Some(ObserveEvent {
98            ts,
99            event_type: "mcp_call",
100            tokens,
101            tool_name: Some(tool.to_string()),
102            detail: v
103                .get("server_name")
104                .and_then(|s| s.as_str())
105                .map(String::from),
106            content: Some(cap_content(&content_str)),
107            model: None,
108            conversation_id: None,
109        });
110    }
111
112    if let Some(output) = v.get("output") {
113        let cmd = v
114            .get("command")
115            .and_then(|c| c.as_str())
116            .unwrap_or("")
117            .to_string();
118        let tokens = estimate_tokens_value(output);
119        let out_str = match output {
120            serde_json::Value::String(s) => s.clone(),
121            other => other.to_string(),
122        };
123        return Some(ObserveEvent {
124            ts,
125            event_type: "shell",
126            tokens,
127            tool_name: None,
128            detail: Some(truncate_str(&cmd, 80)),
129            content: Some(cap_content(&format!("$ {cmd}\n{out_str}"))),
130            model: None,
131            conversation_id: None,
132        });
133    }
134
135    if v.get("content").is_some() && v.get("file_path").is_some() {
136        let path = v
137            .get("file_path")
138            .and_then(|p| p.as_str())
139            .unwrap_or("")
140            .to_string();
141        let file_content = v.get("content").and_then(|c| c.as_str()).unwrap_or("");
142        let tokens = file_content.len() / 4;
143        return Some(ObserveEvent {
144            ts,
145            event_type: "file_read",
146            tokens,
147            tool_name: None,
148            detail: Some(truncate_str(&path, 120)),
149            content: Some(cap_content(file_content)),
150            model: None,
151            conversation_id: None,
152        });
153    }
154
155    if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
156        let has_duration = v.get("duration_ms").is_some();
157        let event_type = if has_duration {
158            "thinking"
159        } else {
160            "agent_response"
161        };
162        let tokens = text.len() / 4;
163        return Some(ObserveEvent {
164            ts,
165            event_type,
166            tokens,
167            tool_name: None,
168            detail: None,
169            content: Some(cap_content(text)),
170            model: None,
171            conversation_id: None,
172        });
173    }
174
175    if let Some(prompt) = v.get("prompt").and_then(|p| p.as_str()) {
176        let tokens = prompt.len() / 4;
177        let mut full = prompt.to_string();
178        if let Some(attachments) = v.get("attachments").and_then(|a| a.as_array()) {
179            if !attachments.is_empty() {
180                full.push_str(&format!("\n\n[{} attachments]", attachments.len()));
181                for att in attachments {
182                    if let Some(name) = att.get("name").and_then(|n| n.as_str()) {
183                        full.push_str(&format!("\n  - {name}"));
184                    }
185                }
186            }
187        }
188        return Some(ObserveEvent {
189            ts,
190            event_type: "user_message",
191            tokens,
192            tool_name: None,
193            detail: v
194                .get("attachments")
195                .and_then(|a| a.as_array())
196                .map(|a| format!("{} attachments", a.len())),
197            content: Some(cap_content(&full)),
198            model: None,
199            conversation_id: None,
200        });
201    }
202
203    if v.get("tool_name").is_some() || v.get("tool_input").is_some() {
204        let tool = v
205            .get("tool_name")
206            .and_then(|t| t.as_str())
207            .unwrap_or("unknown")
208            .to_string();
209        let tokens = v.get("tool_input").map_or(0, estimate_tokens_json);
210        let input_str = v
211            .get("tool_input")
212            .map(std::string::ToString::to_string)
213            .unwrap_or_default();
214        return Some(ObserveEvent {
215            ts,
216            event_type: "native_tool",
217            tokens,
218            tool_name: Some(tool),
219            detail: None,
220            content: if input_str.is_empty() {
221                None
222            } else {
223                Some(cap_content(&input_str))
224            },
225            model: None,
226            conversation_id: None,
227        });
228    }
229
230    if v.get("session_id").is_some() {
231        return Some(ObserveEvent {
232            ts,
233            event_type: "session",
234            tokens: 0,
235            tool_name: None,
236            detail: v
237                .get("session_id")
238                .and_then(|s| s.as_str())
239                .map(String::from),
240            content: None,
241            model: None,
242            conversation_id: None,
243        });
244    }
245
246    let is_compaction = v.get("compaction").is_some()
247        || v.get("messages_count").is_some()
248        || v.get("event")
249            .and_then(|e| e.as_str())
250            .is_some_and(|e| e == "compaction" || e == "compact");
251    if is_compaction {
252        return Some(ObserveEvent {
253            ts,
254            event_type: "compaction",
255            tokens: 0,
256            tool_name: None,
257            detail: None,
258            content: None,
259            model: None,
260            conversation_id: None,
261        });
262    }
263
264    None
265}
266
267fn estimate_tokens_json(v: &serde_json::Value) -> usize {
268    match v {
269        serde_json::Value::String(s) => s.len() / 4,
270        _ => v.to_string().len() / 4,
271    }
272}
273
274fn estimate_tokens_value(v: &serde_json::Value) -> usize {
275    match v {
276        serde_json::Value::String(s) => s.len() / 4,
277        _ => v.to_string().len() / 4,
278    }
279}
280
281fn persist_detected_model(model: &str) {
282    let m = model.to_lowercase();
283    let is_bg_model = m.contains("flash")
284        || m.contains("mini")
285        || m.contains("haiku")
286        || m.contains("fast")
287        || m.contains("nano")
288        || m.contains("small");
289    if is_bg_model {
290        return;
291    }
292
293    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
294        return;
295    };
296    let path = data_dir.join("detected_model.json");
297    let ts = std::time::SystemTime::now()
298        .duration_since(std::time::UNIX_EPOCH)
299        .unwrap_or_default()
300        .as_secs();
301    let window = model_context_window(model);
302    let payload = serde_json::json!({
303        "model": model,
304        "window_size": window,
305        "detected_at": ts,
306    });
307    if let Ok(json) = serde_json::to_string_pretty(&payload) {
308        let tmp = path.with_extension("tmp");
309        if std::fs::write(&tmp, &json).is_ok() {
310            let _ = std::fs::rename(&tmp, &path);
311        }
312    }
313}
314
315pub fn model_context_window(model: &str) -> usize {
316    crate::core::model_registry::context_window_for_model(model)
317}
318
319pub fn load_detected_model() -> Option<(String, usize)> {
320    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
321    let path = data_dir.join("detected_model.json");
322    let content = std::fs::read_to_string(&path).ok()?;
323    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
324    let model = v.get("model")?.as_str()?.to_string();
325    let window = v.get("window_size")?.as_u64()? as usize;
326    let detected_at = v.get("detected_at")?.as_u64()?;
327    let now = std::time::SystemTime::now()
328        .duration_since(std::time::UNIX_EPOCH)
329        .unwrap_or_default()
330        .as_secs();
331    if now.saturating_sub(detected_at) > 7200 {
332        return None;
333    }
334    Some((model, window))
335}
336
337fn persist_transcript_path(path: &str, conversation_id: Option<&str>) {
338    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
339        return;
340    };
341    let meta_path = data_dir.join("active_transcript.json");
342    let ts = std::time::SystemTime::now()
343        .duration_since(std::time::UNIX_EPOCH)
344        .unwrap_or_default()
345        .as_secs();
346    let payload = serde_json::json!({
347        "transcript_path": path,
348        "conversation_id": conversation_id,
349        "updated_at": ts,
350    });
351    if let Ok(json) = serde_json::to_string_pretty(&payload) {
352        let tmp = meta_path.with_extension("tmp");
353        if std::fs::write(&tmp, &json).is_ok() {
354            let _ = std::fs::rename(&tmp, &meta_path);
355        }
356    }
357}
358
359pub fn load_active_transcript() -> Option<(String, Option<String>)> {
360    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
361    let path = data_dir.join("active_transcript.json");
362    let content = std::fs::read_to_string(&path).ok()?;
363    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
364    let tp = v.get("transcript_path")?.as_str()?.to_string();
365    let conv = v
366        .get("conversation_id")
367        .and_then(|c| c.as_str())
368        .map(String::from);
369    let updated = v.get("updated_at")?.as_u64()?;
370    let now = std::time::SystemTime::now()
371        .duration_since(std::time::UNIX_EPOCH)
372        .unwrap_or_default()
373        .as_secs();
374    if now.saturating_sub(updated) > 7200 {
375        return None;
376    }
377    Some((tp, conv))
378}
379
380fn cap_content(s: &str) -> String {
381    if s.len() <= MAX_CONTENT_CHARS {
382        s.to_string()
383    } else {
384        format!(
385            "{}…\n\n[truncated: {} total chars]",
386            &s[..MAX_CONTENT_CHARS],
387            s.len()
388        )
389    }
390}
391
392fn truncate_str(s: &str, max: usize) -> String {
393    if s.len() <= max {
394        s.to_string()
395    } else {
396        format!("{}...", &s[..max])
397    }
398}
399
400fn append_radar_event(event: &ObserveEvent) {
401    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
402        return;
403    };
404    let radar_path = data_dir.join("context_radar.jsonl");
405
406    if event.event_type == "session" {
407        if let Ok(meta) = std::fs::metadata(&radar_path) {
408            const MAX_RADAR_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
409            if meta.len() > MAX_RADAR_SIZE {
410                let prev = data_dir.join("context_radar.prev.jsonl");
411                let _ = std::fs::rename(&radar_path, &prev);
412            }
413        }
414    }
415
416    let Ok(line) = serde_json::to_string(event) else {
417        return;
418    };
419
420    use std::fs::OpenOptions;
421    use std::io::Write;
422    if let Ok(mut f) = OpenOptions::new()
423        .create(true)
424        .append(true)
425        .open(&radar_path)
426    {
427        let _ = writeln!(f, "{line}");
428    }
429}
430
431fn is_disabled() -> bool {
432    std::env::var("LEAN_CTX_DISABLED").is_ok()
433}
434
435fn is_harden_active() -> bool {
436    matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
437}
438
439fn is_quiet() -> bool {
440    matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
441}
442
443/// Mark this process as a hook child so the daemon-client never auto-starts
444/// the daemon from inside a hook (which would create zombie processes).
445pub fn mark_hook_environment() {
446    std::env::set_var("LEAN_CTX_HOOK_CHILD", "1");
447}
448
449/// Arms a watchdog that force-exits the process after the given duration.
450/// Prevents hook processes from becoming zombies when stdin pipes break or
451/// the IDE cancels the call. Since hooks MUST NOT spawn child processes
452/// (to avoid orphan zombies), a simple exit(1) suffices.
453pub fn arm_watchdog(timeout: Duration) {
454    std::thread::spawn(move || {
455        std::thread::sleep(timeout);
456        eprintln!(
457            "[lean-ctx hook] watchdog timeout after {}s — force exit",
458            timeout.as_secs()
459        );
460        std::process::exit(1);
461    });
462}
463
464/// Reads all of stdin with a timeout. Returns None if stdin is empty, broken, or times out.
465fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
466    let (tx, rx) = mpsc::channel();
467    std::thread::spawn(move || {
468        let mut buf = String::new();
469        let result = std::io::stdin().read_to_string(&mut buf);
470        let _ = tx.send(result.ok().map(|_| buf));
471    });
472    match rx.recv_timeout(timeout) {
473        Ok(Some(s)) if !s.is_empty() => Some(s),
474        _ => None,
475    }
476}
477
478fn build_dual_deny_output(reason: &str) -> String {
479    serde_json::json!({
480        "permission": "deny",
481        "reason": reason,
482        "hookSpecificOutput": {
483            "hookEventName": "PreToolUse",
484            "permissionDecision": "deny",
485        }
486    })
487    .to_string()
488}
489
490fn build_dual_allow_output() -> String {
491    serde_json::json!({
492        "permission": "allow",
493        "hookSpecificOutput": {
494            "hookEventName": "PreToolUse",
495            "permissionDecision": "allow"
496        }
497    })
498    .to_string()
499}
500
501fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
502    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
503        let mut m = obj.clone();
504        m.insert(
505            "command".to_string(),
506            serde_json::Value::String(rewritten.to_string()),
507        );
508        serde_json::Value::Object(m)
509    } else {
510        serde_json::json!({ "command": rewritten })
511    };
512
513    serde_json::json!({
514        // Cursor hook output format
515        "permission": "allow",
516        "updated_input": updated_input,
517        // Claude Code hook output format (extra fields are ignored by other hosts)
518        "hookSpecificOutput": {
519            "hookEventName": "PreToolUse",
520            "permissionDecision": "allow",
521            "updatedInput": {
522                "command": rewritten
523            }
524        }
525    })
526    .to_string()
527}
528
529pub fn handle_rewrite() {
530    if is_disabled() {
531        return;
532    }
533    let binary = resolve_binary();
534    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
535        return;
536    };
537
538    let v: serde_json::Value = if let Ok(v) = serde_json::from_str(&input) {
539        v
540    } else {
541        print!("{}", build_dual_deny_output("invalid JSON hook payload"));
542        return;
543    };
544
545    let tool = v.get("tool_name").and_then(|t| t.as_str());
546    let Some(tool_name) = tool else {
547        return;
548    };
549
550    // Claude Code uses Bash; Cursor uses Shell; Copilot uses runInTerminal.
551    let is_shell_tool = matches!(
552        tool_name,
553        "Bash" | "bash" | "Shell" | "shell" | "runInTerminal" | "run_in_terminal" | "terminal"
554    );
555    if !is_shell_tool {
556        return;
557    }
558
559    let tool_input = v.get("tool_input");
560    let Some(cmd) = tool_input
561        .and_then(|ti| ti.get("command"))
562        .and_then(|c| c.as_str())
563        .or_else(|| v.get("command").and_then(|c| c.as_str()))
564    else {
565        return;
566    };
567
568    if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
569        print!("{}", build_dual_rewrite_output(tool_input, &rewritten));
570    } else {
571        // Always return a valid allow JSON for hosts that require JSON on exit 0.
572        print!("{}", build_dual_allow_output());
573    }
574}
575
576fn is_rewritable(cmd: &str) -> bool {
577    rewrite_registry::is_rewritable_command(cmd)
578}
579
580fn wrap_single_command(cmd: &str, binary: &str) -> String {
581    let shell_escaped = cmd.replace('\'', "'\\''");
582    format!("{binary} -c '{shell_escaped}'")
583}
584
585fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
586    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
587        return None;
588    }
589
590    // Heredocs cannot survive the quoting round-trip through `lean-ctx -c '...'`.
591    // Newlines get escaped, breaking the heredoc syntax entirely (GitHub #140).
592    if cmd.contains("<<") {
593        return None;
594    }
595
596    if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
597        return Some(rewritten);
598    }
599
600    if let Some(rewritten) = rewrite_search_command(cmd, binary) {
601        return Some(rewritten);
602    }
603
604    if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
605        return Some(rewritten);
606    }
607
608    if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
609        return Some(rewritten);
610    }
611
612    if is_rewritable(cmd) {
613        return Some(wrap_single_command(cmd, binary));
614    }
615
616    None
617}
618
619/// Rewrites cat/head/tail to lean-ctx read with appropriate arguments.
620fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
621    if !rewrite_registry::is_file_read_command(cmd) {
622        return None;
623    }
624
625    let parts: Vec<&str> = cmd.split_whitespace().collect();
626    if parts.len() < 2 {
627        return None;
628    }
629
630    match parts[0] {
631        "cat" => {
632            let path = parts[1..].join(" ");
633            Some(format!("{binary} read {path}"))
634        }
635        "head" => {
636            let (n, path) = parse_head_tail_args(&parts[1..]);
637            let path = path?;
638            match n {
639                Some(lines) => Some(format!("{binary} read {path} -m lines:1-{lines}")),
640                None => Some(format!("{binary} read {path} -m lines:1-10")),
641            }
642        }
643        "tail" => {
644            let (n, path) = parse_head_tail_args(&parts[1..]);
645            let path = path?;
646            let lines = n.unwrap_or(10);
647            Some(format!("{binary} read {path} -m lines:-{lines}"))
648        }
649        _ => None,
650    }
651}
652
653/// Rewrites `rg <pattern> [path]` to `lean-ctx grep <pattern> [path]` for simple forms.
654///
655/// Falls back to `lean-ctx -c 'rg ...'` for flags/complex quoting (handled elsewhere).
656fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
657    let parts: Vec<&str> = cmd.split_whitespace().collect();
658    if parts.first().copied() != Some("rg") {
659        return None;
660    }
661    if parts.len() < 2 {
662        return None;
663    }
664    if parts[1].starts_with('-') {
665        return None;
666    }
667    if parts.len() > 3 {
668        return None;
669    }
670    let pattern = parts[1];
671    let path = parts.get(2).copied();
672    match path {
673        Some(p) if p.starts_with('-') => None,
674        Some(p) => Some(format!("{binary} grep {pattern} {p}")),
675        None => Some(format!("{binary} grep {pattern}")),
676    }
677}
678
679/// Rewrites simple `ls [path]` to `lean-ctx ls [path]`.
680///
681/// Falls back to `lean-ctx -c 'ls ...'` for flags (handled elsewhere).
682fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
683    let parts: Vec<&str> = cmd.split_whitespace().collect();
684    if parts.first().copied() != Some("ls") {
685        return None;
686    }
687    match parts.len() {
688        1 => Some(format!("{binary} ls")),
689        2 if !parts[1].starts_with('-') => Some(format!("{binary} ls {}", parts[1])),
690        _ => None,
691    }
692}
693
694fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
695    let mut n: Option<usize> = None;
696    let mut path: Option<&str> = None;
697
698    let mut i = 0;
699    while i < args.len() {
700        if args[i] == "-n" && i + 1 < args.len() {
701            n = args[i + 1].parse().ok();
702            i += 2;
703        } else if let Some(num) = args[i].strip_prefix("-n") {
704            n = num.parse().ok();
705            i += 1;
706        } else if args[i].starts_with('-') && args[i].len() > 1 {
707            if let Ok(num) = args[i][1..].parse::<usize>() {
708                n = Some(num);
709            }
710            i += 1;
711        } else {
712            path = Some(args[i]);
713            i += 1;
714        }
715    }
716
717    (n, path)
718}
719
720fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
721    compound_lexer::rewrite_compound(cmd, |segment| {
722        if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
723            return None;
724        }
725        if is_rewritable(segment) {
726            Some(wrap_single_command(segment, binary))
727        } else {
728            None
729        }
730    })
731}
732
733fn emit_rewrite(rewritten: &str) {
734    let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
735    print!(
736        "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
737    );
738}
739
740pub fn handle_redirect() {
741    if is_disabled() {
742        let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
743        print!("{}", build_dual_allow_output());
744        return;
745    }
746
747    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
748        return;
749    };
750
751    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
752        print!("{}", build_dual_deny_output("invalid JSON hook payload"));
753        return;
754    };
755
756    let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
757    let tool_input = v.get("tool_input");
758
759    match tool_name {
760        "Read" | "read" | "read_file" => redirect_read(tool_input),
761        "Grep" | "grep" | "search" | "ripgrep" => redirect_grep(tool_input),
762        _ => print!("{}", build_dual_allow_output()),
763    }
764}
765
766/// Redirect Read through lean-ctx for compression + caching.
767/// Safe because `mark_hook_environment()` sets LEAN_CTX_HOOK_CHILD=1 which
768/// prevents daemon auto-start. The subprocess uses the fast local-only path.
769fn redirect_read(tool_input: Option<&serde_json::Value>) {
770    let path = tool_input
771        .and_then(|ti| ti.get("path"))
772        .and_then(|p| p.as_str())
773        .unwrap_or("");
774
775    if path.is_empty() || should_passthrough(path) {
776        print!("{}", build_dual_allow_output());
777        return;
778    }
779
780    if is_harden_active() {
781        print!(
782            "{}",
783            build_dual_deny_output(
784                "Use ctx_read instead of native Read. lean-ctx harden mode is active."
785            )
786        );
787        return;
788    }
789
790    let binary = resolve_binary();
791    let temp_path = redirect_temp_path(path);
792
793    if let Some(output) = run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT) {
794        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
795            let temp_str = temp_path.to_str().unwrap_or("");
796            print!("{}", build_redirect_output(tool_input, "path", temp_str));
797            return;
798        }
799    }
800
801    print!("{}", build_dual_allow_output());
802}
803
804/// Redirect Grep through lean-ctx for compressed results.
805fn redirect_grep(tool_input: Option<&serde_json::Value>) {
806    let pattern = tool_input
807        .and_then(|ti| ti.get("pattern"))
808        .and_then(|p| p.as_str())
809        .unwrap_or("");
810    let search_path = tool_input
811        .and_then(|ti| ti.get("path"))
812        .and_then(|p| p.as_str())
813        .unwrap_or(".");
814
815    if pattern.is_empty() {
816        print!("{}", build_dual_allow_output());
817        return;
818    }
819
820    if is_harden_active() {
821        print!(
822            "{}",
823            build_dual_deny_output(
824                "Use ctx_search instead of native Grep. lean-ctx harden mode is active."
825            )
826        );
827        return;
828    }
829
830    let binary = resolve_binary();
831    let key = format!("grep:{pattern}:{search_path}");
832    let temp_path = redirect_temp_path(&key);
833
834    if let Some(output) = run_with_timeout(
835        &binary,
836        &["grep", pattern, search_path],
837        REDIRECT_SUBPROCESS_TIMEOUT,
838    ) {
839        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
840            let temp_str = temp_path.to_str().unwrap_or("");
841            print!("{}", build_redirect_output(tool_input, "path", temp_str));
842            return;
843        }
844    }
845
846    print!("{}", build_dual_allow_output());
847}
848
849const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
850
851/// Run a lean-ctx subprocess with a hard timeout. Returns stdout on success.
852/// Kills the child if it exceeds the timeout to prevent orphan processes.
853fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
854    let mut child = std::process::Command::new(binary)
855        .args(args)
856        .stdout(std::process::Stdio::piped())
857        .stderr(std::process::Stdio::null())
858        .spawn()
859        .ok()?;
860
861    let deadline = std::time::Instant::now() + timeout;
862    loop {
863        match child.try_wait() {
864            Ok(Some(status)) if status.success() => {
865                let mut stdout = Vec::new();
866                if let Some(mut out) = child.stdout.take() {
867                    let _ = out.read_to_end(&mut stdout);
868                }
869                return if stdout.is_empty() {
870                    None
871                } else {
872                    Some(stdout)
873                };
874            }
875            Ok(Some(_)) | Err(_) => return None,
876            Ok(None) => {
877                if std::time::Instant::now() > deadline {
878                    let _ = child.kill();
879                    let _ = child.wait();
880                    return None;
881                }
882                std::thread::sleep(Duration::from_millis(10));
883            }
884        }
885    }
886}
887
888fn redirect_temp_path(key: &str) -> std::path::PathBuf {
889    use std::collections::hash_map::DefaultHasher;
890    use std::hash::{Hash, Hasher};
891
892    let mut hasher = DefaultHasher::new();
893    key.hash(&mut hasher);
894    std::process::id().hash(&mut hasher);
895    let hash = hasher.finish();
896
897    let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
898    let _ = std::fs::create_dir_all(&temp_dir);
899    #[cfg(unix)]
900    {
901        use std::os::unix::fs::PermissionsExt;
902        let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
903    }
904    temp_dir.join(format!("{hash:016x}.lctx"))
905}
906
907fn build_redirect_output(
908    tool_input: Option<&serde_json::Value>,
909    field: &str,
910    temp_path: &str,
911) -> String {
912    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
913        let mut m = obj.clone();
914        m.insert(
915            field.to_string(),
916            serde_json::Value::String(temp_path.to_string()),
917        );
918        serde_json::Value::Object(m)
919    } else {
920        serde_json::json!({ field: temp_path })
921    };
922
923    serde_json::json!({
924        "permission": "allow",
925        "updated_input": updated_input,
926        "hookSpecificOutput": {
927            "hookEventName": "PreToolUse",
928            "permissionDecision": "allow",
929            "updatedInput": { field: temp_path }
930        }
931    })
932    .to_string()
933}
934
935const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
936    ".cursorrules",
937    ".cursor/rules",
938    ".cursor/hooks",
939    "skill.md",
940    "agents.md",
941    ".env",
942    "hooks.json",
943    "node_modules",
944];
945
946const PASSTHROUGH_EXTENSIONS: &[&str] = &[
947    "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
948];
949
950fn should_passthrough(path: &str) -> bool {
951    let p = path.to_lowercase();
952
953    if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
954        return true;
955    }
956
957    std::path::Path::new(&p)
958        .extension()
959        .and_then(|ext| ext.to_str())
960        .is_some_and(|ext| {
961            PASSTHROUGH_EXTENSIONS
962                .iter()
963                .any(|e| ext.eq_ignore_ascii_case(e))
964        })
965}
966
967fn codex_reroute_message(rewritten: &str) -> String {
968    format!(
969        "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: {rewritten}"
970    )
971}
972
973pub fn handle_codex_pretooluse() {
974    if is_disabled() {
975        return;
976    }
977    let binary = resolve_binary();
978    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
979        return;
980    };
981
982    let tool = extract_json_field(&input, "tool_name");
983    if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
984        return;
985    }
986
987    let Some(cmd) = extract_json_field(&input, "command") else {
988        return;
989    };
990
991    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
992        if is_quiet() {
993            eprintln!("Re-run: {rewritten}");
994        } else {
995            eprintln!("{}", codex_reroute_message(&rewritten));
996        }
997        std::process::exit(2);
998    }
999}
1000
1001pub fn handle_codex_session_start() {
1002    if is_quiet() {
1003        return;
1004    }
1005    println!(
1006        "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."
1007    );
1008}
1009
1010/// Copilot-specific PreToolUse handler.
1011/// VS Code Copilot Chat uses the same hook format as Claude Code.
1012/// Tool names differ: "runInTerminal" / "editFile" instead of "Bash" / "Read".
1013pub fn handle_copilot() {
1014    if is_disabled() {
1015        return;
1016    }
1017    let binary = resolve_binary();
1018    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1019        return;
1020    };
1021
1022    let tool = extract_json_field(&input, "tool_name");
1023    let Some(tool_name) = tool.as_deref() else {
1024        return;
1025    };
1026
1027    let is_shell_tool = matches!(
1028        tool_name,
1029        "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
1030    );
1031    if !is_shell_tool {
1032        return;
1033    }
1034
1035    let Some(cmd) = extract_json_field(&input, "command") else {
1036        return;
1037    };
1038
1039    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1040        emit_rewrite(&rewritten);
1041    }
1042}
1043
1044/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
1045/// Used by the OpenCode TS plugin where the command is passed as an argument,
1046/// not via stdin JSON. Uses native OS paths (not MSYS) because the calling
1047/// shell may be PowerShell or cmd on Windows.
1048pub fn handle_rewrite_inline() {
1049    if is_disabled() {
1050        return;
1051    }
1052    let binary = resolve_binary_native();
1053    let args: Vec<String> = std::env::args().collect();
1054    // args: [binary, "hook", "rewrite-inline", ...command parts]
1055    if args.len() < 4 {
1056        return;
1057    }
1058    let cmd = args[3..].join(" ");
1059
1060    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1061        print!("{rewritten}");
1062        return;
1063    }
1064
1065    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
1066        print!("{cmd}");
1067        return;
1068    }
1069
1070    print!("{cmd}");
1071}
1072
1073fn resolve_binary() -> String {
1074    let path = crate::core::portable_binary::resolve_portable_binary();
1075    crate::hooks::to_bash_compatible_path(&path)
1076}
1077
1078fn resolve_binary_native() -> String {
1079    crate::core::portable_binary::resolve_portable_binary()
1080}
1081
1082fn extract_json_field(input: &str, field: &str) -> Option<String> {
1083    let key = format!("\"{field}\":");
1084    let key_pos = input.find(&key)?;
1085    let after_colon = &input[key_pos + key.len()..];
1086    let trimmed = after_colon.trim_start();
1087    if !trimmed.starts_with('"') {
1088        return None;
1089    }
1090    let rest = &trimmed[1..];
1091    let bytes = rest.as_bytes();
1092    let mut end = 0;
1093    while end < bytes.len() {
1094        if bytes[end] == b'\\' && end + 1 < bytes.len() {
1095            end += 2;
1096            continue;
1097        }
1098        if bytes[end] == b'"' {
1099            break;
1100        }
1101        end += 1;
1102    }
1103    if end >= bytes.len() {
1104        return None;
1105    }
1106    let raw = &rest[..end];
1107    Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113
1114    #[test]
1115    fn is_rewritable_basic() {
1116        assert!(is_rewritable("git status"));
1117        assert!(is_rewritable("cargo test --lib"));
1118        assert!(is_rewritable("npm run build"));
1119        assert!(!is_rewritable("echo hello"));
1120        assert!(!is_rewritable("cd src"));
1121        assert!(!is_rewritable("cat file.rs"));
1122    }
1123
1124    #[test]
1125    fn file_read_rewrite_cat() {
1126        let r = rewrite_file_read_command("cat src/main.rs", "lean-ctx");
1127        assert_eq!(r, Some("lean-ctx read src/main.rs".to_string()));
1128    }
1129
1130    #[test]
1131    fn file_read_rewrite_head_with_n() {
1132        let r = rewrite_file_read_command("head -n 20 src/main.rs", "lean-ctx");
1133        assert_eq!(
1134            r,
1135            Some("lean-ctx read src/main.rs -m lines:1-20".to_string())
1136        );
1137    }
1138
1139    #[test]
1140    fn file_read_rewrite_head_short() {
1141        let r = rewrite_file_read_command("head -50 src/main.rs", "lean-ctx");
1142        assert_eq!(
1143            r,
1144            Some("lean-ctx read src/main.rs -m lines:1-50".to_string())
1145        );
1146    }
1147
1148    #[test]
1149    fn file_read_rewrite_tail() {
1150        let r = rewrite_file_read_command("tail -n 10 src/main.rs", "lean-ctx");
1151        assert_eq!(
1152            r,
1153            Some("lean-ctx read src/main.rs -m lines:-10".to_string())
1154        );
1155    }
1156
1157    #[test]
1158    fn file_read_rewrite_not_git() {
1159        assert_eq!(rewrite_file_read_command("git status", "lean-ctx"), None);
1160    }
1161
1162    #[test]
1163    fn parse_head_tail_args_basic() {
1164        let (n, path) = parse_head_tail_args(&["-n", "20", "file.rs"]);
1165        assert_eq!(n, Some(20));
1166        assert_eq!(path, Some("file.rs"));
1167    }
1168
1169    #[test]
1170    fn parse_head_tail_args_combined() {
1171        let (n, path) = parse_head_tail_args(&["-n20", "file.rs"]);
1172        assert_eq!(n, Some(20));
1173        assert_eq!(path, Some("file.rs"));
1174    }
1175
1176    #[test]
1177    fn parse_head_tail_args_short_flag() {
1178        let (n, path) = parse_head_tail_args(&["-50", "file.rs"]);
1179        assert_eq!(n, Some(50));
1180        assert_eq!(path, Some("file.rs"));
1181    }
1182
1183    #[test]
1184    fn should_passthrough_rules_files() {
1185        assert!(should_passthrough("/home/user/.cursorrules"));
1186        assert!(should_passthrough("/project/.cursor/rules/test.mdc"));
1187        assert!(should_passthrough("/home/.cursor/hooks/hooks.json"));
1188        assert!(should_passthrough("/project/SKILL.md"));
1189        assert!(should_passthrough("/project/AGENTS.md"));
1190        assert!(should_passthrough("/project/icon.png"));
1191        assert!(!should_passthrough("/project/src/main.rs"));
1192        assert!(!should_passthrough("/project/src/lib.ts"));
1193    }
1194
1195    #[test]
1196    fn wrap_single() {
1197        let r = wrap_single_command("git status", "lean-ctx");
1198        assert_eq!(r, "lean-ctx -c 'git status'");
1199    }
1200
1201    #[test]
1202    fn wrap_with_quotes() {
1203        let r = wrap_single_command(r#"curl -H "Auth" https://api.com"#, "lean-ctx");
1204        assert_eq!(r, r#"lean-ctx -c 'curl -H "Auth" https://api.com'"#);
1205    }
1206
1207    #[test]
1208    fn rewrite_candidate_returns_none_for_existing_lean_ctx_command() {
1209        assert_eq!(
1210            rewrite_candidate("lean-ctx -c git status", "lean-ctx"),
1211            None
1212        );
1213    }
1214
1215    #[test]
1216    fn rewrite_candidate_wraps_single_command() {
1217        assert_eq!(
1218            rewrite_candidate("git status", "lean-ctx"),
1219            Some("lean-ctx -c 'git status'".to_string())
1220        );
1221    }
1222
1223    #[test]
1224    fn rewrite_candidate_passes_through_heredoc() {
1225        assert_eq!(
1226            rewrite_candidate(
1227                "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
1228                "lean-ctx"
1229            ),
1230            None
1231        );
1232    }
1233
1234    #[test]
1235    fn rewrite_candidate_passes_through_heredoc_compound() {
1236        assert_eq!(
1237            rewrite_candidate(
1238                "git add . && git commit -m \"$(cat <<EOF\nfeat: add\nEOF\n)\"",
1239                "lean-ctx"
1240            ),
1241            None
1242        );
1243    }
1244
1245    #[test]
1246    fn codex_reroute_message_includes_exact_rewritten_command() {
1247        let message = codex_reroute_message("lean-ctx -c 'git status'");
1248        assert_eq!(
1249            message,
1250            "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: lean-ctx -c 'git status'"
1251        );
1252    }
1253
1254    #[test]
1255    fn compound_rewrite_and_chain() {
1256        let result = build_rewrite_compound("cd src && git status && echo done", "lean-ctx");
1257        assert_eq!(
1258            result,
1259            Some("cd src && lean-ctx -c 'git status' && echo done".into())
1260        );
1261    }
1262
1263    #[test]
1264    fn compound_rewrite_pipe() {
1265        let result = build_rewrite_compound("git log --oneline | head -5", "lean-ctx");
1266        assert_eq!(
1267            result,
1268            Some("lean-ctx -c 'git log --oneline' | head -5".into())
1269        );
1270    }
1271
1272    #[test]
1273    fn compound_rewrite_no_match() {
1274        let result = build_rewrite_compound("cd src && echo done", "lean-ctx");
1275        assert_eq!(result, None);
1276    }
1277
1278    #[test]
1279    fn compound_rewrite_multiple_rewritable() {
1280        let result = build_rewrite_compound("git add . && cargo test && npm run lint", "lean-ctx");
1281        assert_eq!(
1282            result,
1283            Some(
1284                "lean-ctx -c 'git add .' && lean-ctx -c 'cargo test' && lean-ctx -c 'npm run lint'"
1285                    .into()
1286            )
1287        );
1288    }
1289
1290    #[test]
1291    fn compound_rewrite_semicolons() {
1292        let result = build_rewrite_compound("git add .; git commit -m 'fix'", "lean-ctx");
1293        assert_eq!(
1294            result,
1295            Some("lean-ctx -c 'git add .' ; lean-ctx -c 'git commit -m '\\''fix'\\'''".into())
1296        );
1297    }
1298
1299    #[test]
1300    fn compound_rewrite_or_chain() {
1301        let result = build_rewrite_compound("git pull || echo failed", "lean-ctx");
1302        assert_eq!(result, Some("lean-ctx -c 'git pull' || echo failed".into()));
1303    }
1304
1305    #[test]
1306    fn compound_skips_already_rewritten() {
1307        let result = build_rewrite_compound("lean-ctx -c git status && git diff", "lean-ctx");
1308        assert_eq!(
1309            result,
1310            Some("lean-ctx -c git status && lean-ctx -c 'git diff'".into())
1311        );
1312    }
1313
1314    #[test]
1315    fn single_command_not_compound() {
1316        let result = build_rewrite_compound("git status", "lean-ctx");
1317        assert_eq!(result, None);
1318    }
1319
1320    #[test]
1321    fn extract_field_works() {
1322        let input = r#"{"tool_name":"Bash","command":"git status"}"#;
1323        assert_eq!(
1324            extract_json_field(input, "tool_name"),
1325            Some("Bash".to_string())
1326        );
1327        assert_eq!(
1328            extract_json_field(input, "command"),
1329            Some("git status".to_string())
1330        );
1331    }
1332
1333    #[test]
1334    fn extract_field_with_spaces_after_colon() {
1335        let input = r#"{"tool_name": "Bash", "tool_input": {"command": "git status"}}"#;
1336        assert_eq!(
1337            extract_json_field(input, "tool_name"),
1338            Some("Bash".to_string())
1339        );
1340        assert_eq!(
1341            extract_json_field(input, "command"),
1342            Some("git status".to_string())
1343        );
1344    }
1345
1346    #[test]
1347    fn extract_field_pretty_printed() {
1348        let input = "{\n  \"tool_name\": \"Bash\",\n  \"tool_input\": {\n    \"command\": \"npm test\"\n  }\n}";
1349        assert_eq!(
1350            extract_json_field(input, "tool_name"),
1351            Some("Bash".to_string())
1352        );
1353        assert_eq!(
1354            extract_json_field(input, "command"),
1355            Some("npm test".to_string())
1356        );
1357    }
1358
1359    #[test]
1360    fn extract_field_handles_escaped_quotes() {
1361        let input = r#"{"tool_name":"Bash","command":"grep -r \"TODO\" src/"}"#;
1362        assert_eq!(
1363            extract_json_field(input, "command"),
1364            Some(r#"grep -r "TODO" src/"#.to_string())
1365        );
1366    }
1367
1368    #[test]
1369    fn extract_field_handles_escaped_backslash() {
1370        let input = r#"{"tool_name":"Bash","command":"echo \\\"hello\\\""}"#;
1371        assert_eq!(
1372            extract_json_field(input, "command"),
1373            Some(r#"echo \"hello\""#.to_string())
1374        );
1375    }
1376
1377    #[test]
1378    fn extract_field_handles_complex_curl() {
1379        let input = r#"{"tool_name":"Bash","command":"curl -H \"Authorization: Bearer token\" https://api.com"}"#;
1380        assert_eq!(
1381            extract_json_field(input, "command"),
1382            Some(r#"curl -H "Authorization: Bearer token" https://api.com"#.to_string())
1383        );
1384    }
1385
1386    #[test]
1387    fn to_bash_compatible_path_windows_drive() {
1388        let p = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
1389        assert_eq!(p, "/e/packages/lean-ctx.exe");
1390    }
1391
1392    #[test]
1393    fn to_bash_compatible_path_backslashes() {
1394        let p = crate::hooks::to_bash_compatible_path(r"C:\Users\test\bin\lean-ctx.exe");
1395        assert_eq!(p, "/c/Users/test/bin/lean-ctx.exe");
1396    }
1397
1398    #[test]
1399    fn to_bash_compatible_path_unix_unchanged() {
1400        let p = crate::hooks::to_bash_compatible_path("/usr/local/bin/lean-ctx");
1401        assert_eq!(p, "/usr/local/bin/lean-ctx");
1402    }
1403
1404    #[test]
1405    fn to_bash_compatible_path_msys2_unchanged() {
1406        let p = crate::hooks::to_bash_compatible_path("/e/packages/lean-ctx.exe");
1407        assert_eq!(p, "/e/packages/lean-ctx.exe");
1408    }
1409
1410    #[test]
1411    fn wrap_command_with_bash_path() {
1412        let binary = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
1413        let result = wrap_single_command("git status", &binary);
1414        assert!(
1415            !result.contains('\\'),
1416            "wrapped command must not contain backslashes, got: {result}"
1417        );
1418        assert!(
1419            result.starts_with("/e/packages/lean-ctx.exe"),
1420            "must use bash-compatible path, got: {result}"
1421        );
1422    }
1423
1424    #[test]
1425    fn wrap_single_command_em_dash() {
1426        let r = wrap_single_command("gh --comment \"closing — see #407\"", "lean-ctx");
1427        assert_eq!(r, "lean-ctx -c 'gh --comment \"closing — see #407\"'");
1428    }
1429
1430    #[test]
1431    fn wrap_single_command_dollar_sign() {
1432        let r = wrap_single_command("echo $HOME", "lean-ctx");
1433        assert_eq!(r, "lean-ctx -c 'echo $HOME'");
1434    }
1435
1436    #[test]
1437    fn wrap_single_command_backticks() {
1438        let r = wrap_single_command("echo `date`", "lean-ctx");
1439        assert_eq!(r, "lean-ctx -c 'echo `date`'");
1440    }
1441
1442    #[test]
1443    fn wrap_single_command_nested_single_quotes() {
1444        let r = wrap_single_command("echo 'hello world'", "lean-ctx");
1445        assert_eq!(r, r"lean-ctx -c 'echo '\''hello world'\'''");
1446    }
1447
1448    #[test]
1449    fn wrap_single_command_exclamation_mark() {
1450        let r = wrap_single_command("echo hello!", "lean-ctx");
1451        assert_eq!(r, "lean-ctx -c 'echo hello!'");
1452    }
1453
1454    #[test]
1455    fn wrap_single_command_find_with_many_excludes() {
1456        let r = wrap_single_command(
1457            "find . -not -path ./node_modules -not -path ./.git -not -path ./dist",
1458            "lean-ctx",
1459        );
1460        assert_eq!(
1461            r,
1462            "lean-ctx -c 'find . -not -path ./node_modules -not -path ./.git -not -path ./dist'"
1463        );
1464    }
1465}