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