Skip to main content

lean_ctx/core/editor_registry/
writers.rs

1use serde_json::Value;
2
3use super::types::{ConfigType, EditorTarget};
4
5fn toml_quote(value: &str) -> String {
6    if value.contains('\\') {
7        format!("'{value}'")
8    } else {
9        format!("\"{value}\"")
10    }
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum WriteAction {
15    Created,
16    Updated,
17    Already,
18}
19
20#[derive(Debug, Clone, Copy, Default)]
21pub struct WriteOptions {
22    pub overwrite_invalid: bool,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct WriteResult {
27    pub action: WriteAction,
28    pub note: Option<String>,
29}
30
31pub fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
32    write_config_with_options(target, binary, WriteOptions::default())
33}
34
35pub fn write_config_with_options(
36    target: &EditorTarget,
37    binary: &str,
38    opts: WriteOptions,
39) -> Result<WriteResult, String> {
40    if let Some(parent) = target.config_path.parent() {
41        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
42    }
43
44    match target.config_type {
45        ConfigType::McpJson => write_mcp_json(target, binary, opts),
46        ConfigType::Zed => write_zed_config(target, binary, opts),
47        ConfigType::Codex => write_codex_config(target, binary),
48        ConfigType::VsCodeMcp => write_vscode_mcp(target, binary, opts),
49        ConfigType::OpenCode => write_opencode_config(target, binary, opts),
50        ConfigType::Crush => write_crush_config(target, binary, opts),
51        ConfigType::JetBrains => write_jetbrains_config(target, binary, opts),
52        ConfigType::Amp => write_amp_config(target, binary, opts),
53        ConfigType::HermesYaml => write_hermes_yaml(target, binary, opts),
54        ConfigType::GeminiSettings => write_gemini_settings(target, binary, opts),
55    }
56}
57
58pub fn auto_approve_tools() -> Vec<&'static str> {
59    vec![
60        "ctx_read",
61        "ctx_shell",
62        "ctx_search",
63        "ctx_tree",
64        "ctx_overview",
65        "ctx_preload",
66        "ctx_compress",
67        "ctx_metrics",
68        "ctx_session",
69        "ctx_knowledge",
70        "ctx_agent",
71        "ctx_share",
72        "ctx_analyze",
73        "ctx_benchmark",
74        "ctx_cache",
75        "ctx_discover",
76        "ctx_smart_read",
77        "ctx_delta",
78        "ctx_edit",
79        "ctx_dedup",
80        "ctx_fill",
81        "ctx_intent",
82        "ctx_response",
83        "ctx_context",
84        "ctx_graph",
85        "ctx_wrapped",
86        "ctx_multi_read",
87        "ctx_semantic_search",
88        "ctx_symbol",
89        "ctx_outline",
90        "ctx_callers",
91        "ctx_callees",
92        "ctx_routes",
93        "ctx_graph_diagram",
94        "ctx_cost",
95        "ctx_heatmap",
96        "ctx_task",
97        "ctx_impact",
98        "ctx_architecture",
99        "ctx_workflow",
100        "ctx",
101    ]
102}
103
104fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
105    let mut entry = serde_json::json!({
106        "command": binary,
107        "env": {
108            "LEAN_CTX_DATA_DIR": data_dir
109        }
110    });
111    if include_auto_approve {
112        entry["autoApprove"] = serde_json::json!(auto_approve_tools());
113    }
114    entry
115}
116
117const NO_AUTO_APPROVE_EDITORS: &[&str] = &["Antigravity"];
118
119fn default_data_dir() -> Result<String, String> {
120    Ok(crate::core::data_dir::lean_ctx_data_dir()?
121        .to_string_lossy()
122        .to_string())
123}
124
125fn write_mcp_json(
126    target: &EditorTarget,
127    binary: &str,
128    opts: WriteOptions,
129) -> Result<WriteResult, String> {
130    let data_dir = default_data_dir()?;
131    let include_aa = !NO_AUTO_APPROVE_EDITORS.contains(&target.name);
132    let desired = lean_ctx_server_entry(binary, &data_dir, include_aa);
133
134    // Claude Code manages ~/.claude.json and may overwrite it on first start.
135    // Prefer the official CLI integration when available.
136    if target.agent_key == "claude" || target.name == "Claude Code" {
137        if let Ok(result) = try_claude_mcp_add(&desired) {
138            return Ok(result);
139        }
140    }
141
142    if target.config_path.exists() {
143        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
144        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
145            Ok(v) => v,
146            Err(e) => {
147                if !opts.overwrite_invalid {
148                    return Err(e.to_string());
149                }
150                backup_invalid_file(&target.config_path)?;
151                return write_mcp_json_fresh(
152                    &target.config_path,
153                    &desired,
154                    Some("overwrote invalid JSON".to_string()),
155                );
156            }
157        };
158        let obj = json
159            .as_object_mut()
160            .ok_or_else(|| "root JSON must be an object".to_string())?;
161
162        let servers = obj
163            .entry("mcpServers")
164            .or_insert_with(|| serde_json::json!({}));
165        let servers_obj = servers
166            .as_object_mut()
167            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
168
169        let existing = servers_obj.get("lean-ctx").cloned();
170        if existing.as_ref() == Some(&desired) {
171            return Ok(WriteResult {
172                action: WriteAction::Already,
173                note: None,
174            });
175        }
176        servers_obj.insert("lean-ctx".to_string(), desired);
177
178        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
179        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
180        return Ok(WriteResult {
181            action: WriteAction::Updated,
182            note: None,
183        });
184    }
185
186    write_mcp_json_fresh(&target.config_path, &desired, None)
187}
188
189fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
190    use std::io::Write;
191    use std::process::{Command, Stdio};
192    use std::time::{Duration, Instant};
193
194    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
195
196    // On Windows, `claude` may be a `.cmd` shim and cannot be executed directly
197    // via CreateProcess; route through `cmd /C` for reliable invocation.
198    let mut cmd = if cfg!(windows) {
199        let mut c = Command::new("cmd");
200        c.args([
201            "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
202        ]);
203        c
204    } else {
205        let mut c = Command::new("claude");
206        c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
207        c
208    };
209
210    let mut child = cmd
211        .stdin(Stdio::piped())
212        .stdout(Stdio::null())
213        .stderr(Stdio::null())
214        .spawn()
215        .map_err(|e| e.to_string())?;
216
217    if let Some(mut stdin) = child.stdin.take() {
218        let _ = stdin.write_all(server_json.as_bytes());
219    }
220
221    let deadline = Duration::from_secs(3);
222    let start = Instant::now();
223    loop {
224        match child.try_wait() {
225            Ok(Some(status)) => {
226                return if status.success() {
227                    Ok(WriteResult {
228                        action: WriteAction::Updated,
229                        note: Some("via claude mcp add-json".to_string()),
230                    })
231                } else {
232                    Err("claude mcp add-json failed".to_string())
233                };
234            }
235            Ok(None) => {
236                if start.elapsed() > deadline {
237                    let _ = child.kill();
238                    let _ = child.wait();
239                    return Err("claude mcp add-json timed out".to_string());
240                }
241                std::thread::sleep(Duration::from_millis(20));
242            }
243            Err(e) => return Err(e.to_string()),
244        }
245    }
246}
247
248fn write_mcp_json_fresh(
249    path: &std::path::Path,
250    desired: &Value,
251    note: Option<String>,
252) -> Result<WriteResult, String> {
253    let content = serde_json::to_string_pretty(&serde_json::json!({
254        "mcpServers": { "lean-ctx": desired }
255    }))
256    .map_err(|e| e.to_string())?;
257    crate::config_io::write_atomic_with_backup(path, &content)?;
258    Ok(WriteResult {
259        action: if note.is_some() {
260            WriteAction::Updated
261        } else {
262            WriteAction::Created
263        },
264        note,
265    })
266}
267
268fn write_zed_config(
269    target: &EditorTarget,
270    binary: &str,
271    opts: WriteOptions,
272) -> Result<WriteResult, String> {
273    let desired = serde_json::json!({
274        "source": "custom",
275        "command": binary,
276        "args": [],
277        "env": {}
278    });
279
280    if target.config_path.exists() {
281        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
282        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
283            Ok(v) => v,
284            Err(e) => {
285                if !opts.overwrite_invalid {
286                    return Err(e.to_string());
287                }
288                backup_invalid_file(&target.config_path)?;
289                return write_zed_config_fresh(
290                    &target.config_path,
291                    &desired,
292                    Some("overwrote invalid JSON".to_string()),
293                );
294            }
295        };
296        let obj = json
297            .as_object_mut()
298            .ok_or_else(|| "root JSON must be an object".to_string())?;
299
300        let servers = obj
301            .entry("context_servers")
302            .or_insert_with(|| serde_json::json!({}));
303        let servers_obj = servers
304            .as_object_mut()
305            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
306
307        let existing = servers_obj.get("lean-ctx").cloned();
308        if existing.as_ref() == Some(&desired) {
309            return Ok(WriteResult {
310                action: WriteAction::Already,
311                note: None,
312            });
313        }
314        servers_obj.insert("lean-ctx".to_string(), desired);
315
316        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
317        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
318        return Ok(WriteResult {
319            action: WriteAction::Updated,
320            note: None,
321        });
322    }
323
324    write_zed_config_fresh(&target.config_path, &desired, None)
325}
326
327fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
328    if target.config_path.exists() {
329        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
330        let updated = upsert_codex_toml(&content, binary);
331        if updated == content {
332            return Ok(WriteResult {
333                action: WriteAction::Already,
334                note: None,
335            });
336        }
337        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
338        return Ok(WriteResult {
339            action: WriteAction::Updated,
340            note: None,
341        });
342    }
343
344    let content = format!(
345        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
346        toml_quote(binary)
347    );
348    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
349    Ok(WriteResult {
350        action: WriteAction::Created,
351        note: None,
352    })
353}
354
355fn write_zed_config_fresh(
356    path: &std::path::Path,
357    desired: &Value,
358    note: Option<String>,
359) -> Result<WriteResult, String> {
360    let content = serde_json::to_string_pretty(&serde_json::json!({
361        "context_servers": { "lean-ctx": desired }
362    }))
363    .map_err(|e| e.to_string())?;
364    crate::config_io::write_atomic_with_backup(path, &content)?;
365    Ok(WriteResult {
366        action: if note.is_some() {
367            WriteAction::Updated
368        } else {
369            WriteAction::Created
370        },
371        note,
372    })
373}
374
375fn write_vscode_mcp(
376    target: &EditorTarget,
377    binary: &str,
378    opts: WriteOptions,
379) -> Result<WriteResult, String> {
380    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
381        .map(|d| d.to_string_lossy().to_string())
382        .unwrap_or_default();
383    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
384
385    if target.config_path.exists() {
386        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
387        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
388            Ok(v) => v,
389            Err(e) => {
390                if !opts.overwrite_invalid {
391                    return Err(e.to_string());
392                }
393                backup_invalid_file(&target.config_path)?;
394                return write_vscode_mcp_fresh(
395                    &target.config_path,
396                    binary,
397                    Some("overwrote invalid JSON".to_string()),
398                );
399            }
400        };
401        let obj = json
402            .as_object_mut()
403            .ok_or_else(|| "root JSON must be an object".to_string())?;
404
405        let servers = obj
406            .entry("servers")
407            .or_insert_with(|| serde_json::json!({}));
408        let servers_obj = servers
409            .as_object_mut()
410            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
411
412        let existing = servers_obj.get("lean-ctx").cloned();
413        if existing.as_ref() == Some(&desired) {
414            return Ok(WriteResult {
415                action: WriteAction::Already,
416                note: None,
417            });
418        }
419        servers_obj.insert("lean-ctx".to_string(), desired);
420
421        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
422        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
423        return Ok(WriteResult {
424            action: WriteAction::Updated,
425            note: None,
426        });
427    }
428
429    write_vscode_mcp_fresh(&target.config_path, binary, None)
430}
431
432fn write_vscode_mcp_fresh(
433    path: &std::path::Path,
434    binary: &str,
435    note: Option<String>,
436) -> Result<WriteResult, String> {
437    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
438        .map(|d| d.to_string_lossy().to_string())
439        .unwrap_or_default();
440    let content = serde_json::to_string_pretty(&serde_json::json!({
441        "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
442    }))
443    .map_err(|e| e.to_string())?;
444    crate::config_io::write_atomic_with_backup(path, &content)?;
445    Ok(WriteResult {
446        action: if note.is_some() {
447            WriteAction::Updated
448        } else {
449            WriteAction::Created
450        },
451        note,
452    })
453}
454
455fn write_opencode_config(
456    target: &EditorTarget,
457    binary: &str,
458    opts: WriteOptions,
459) -> Result<WriteResult, String> {
460    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
461        .map(|d| d.to_string_lossy().to_string())
462        .unwrap_or_default();
463    let desired = serde_json::json!({
464        "type": "local",
465        "command": [binary],
466        "enabled": true,
467        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
468    });
469
470    if target.config_path.exists() {
471        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
472        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
473            Ok(v) => v,
474            Err(e) => {
475                if !opts.overwrite_invalid {
476                    return Err(e.to_string());
477                }
478                backup_invalid_file(&target.config_path)?;
479                return write_opencode_fresh(
480                    &target.config_path,
481                    binary,
482                    Some("overwrote invalid JSON".to_string()),
483                );
484            }
485        };
486        let obj = json
487            .as_object_mut()
488            .ok_or_else(|| "root JSON must be an object".to_string())?;
489        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
490        let mcp_obj = mcp
491            .as_object_mut()
492            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
493
494        let existing = mcp_obj.get("lean-ctx").cloned();
495        if existing.as_ref() == Some(&desired) {
496            return Ok(WriteResult {
497                action: WriteAction::Already,
498                note: None,
499            });
500        }
501        mcp_obj.insert("lean-ctx".to_string(), desired);
502
503        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
504        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
505        return Ok(WriteResult {
506            action: WriteAction::Updated,
507            note: None,
508        });
509    }
510
511    write_opencode_fresh(&target.config_path, binary, None)
512}
513
514fn write_opencode_fresh(
515    path: &std::path::Path,
516    binary: &str,
517    note: Option<String>,
518) -> Result<WriteResult, String> {
519    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
520        .map(|d| d.to_string_lossy().to_string())
521        .unwrap_or_default();
522    let content = serde_json::to_string_pretty(&serde_json::json!({
523        "$schema": "https://opencode.ai/config.json",
524        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
525    }))
526    .map_err(|e| e.to_string())?;
527    crate::config_io::write_atomic_with_backup(path, &content)?;
528    Ok(WriteResult {
529        action: if note.is_some() {
530            WriteAction::Updated
531        } else {
532            WriteAction::Created
533        },
534        note,
535    })
536}
537
538fn write_jetbrains_config(
539    target: &EditorTarget,
540    binary: &str,
541    opts: WriteOptions,
542) -> Result<WriteResult, String> {
543    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
544        .map(|d| d.to_string_lossy().to_string())
545        .unwrap_or_default();
546    let entry = serde_json::json!({
547        "name": "lean-ctx",
548        "command": binary,
549        "args": [],
550        "env": { "LEAN_CTX_DATA_DIR": data_dir }
551    });
552
553    if target.config_path.exists() {
554        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
555        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
556            Ok(v) => v,
557            Err(e) => {
558                if !opts.overwrite_invalid {
559                    return Err(e.to_string());
560                }
561                backup_invalid_file(&target.config_path)?;
562                let fresh = serde_json::json!({ "servers": [entry] });
563                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
564                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
565                return Ok(WriteResult {
566                    action: WriteAction::Updated,
567                    note: Some("overwrote invalid JSON".to_string()),
568                });
569            }
570        };
571        let obj = json
572            .as_object_mut()
573            .ok_or_else(|| "root JSON must be an object".to_string())?;
574        let servers = obj
575            .entry("servers")
576            .or_insert_with(|| serde_json::json!([]));
577        if let Some(arr) = servers.as_array_mut() {
578            let already = arr
579                .iter()
580                .any(|s| s.get("name").and_then(|n| n.as_str()) == Some("lean-ctx"));
581            if already {
582                return Ok(WriteResult {
583                    action: WriteAction::Already,
584                    note: None,
585                });
586            }
587            arr.push(entry);
588        }
589        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
590        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
591        return Ok(WriteResult {
592            action: WriteAction::Updated,
593            note: None,
594        });
595    }
596
597    let config = serde_json::json!({ "servers": [entry] });
598    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
599    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
600    Ok(WriteResult {
601        action: WriteAction::Created,
602        note: None,
603    })
604}
605
606fn write_amp_config(
607    target: &EditorTarget,
608    binary: &str,
609    opts: WriteOptions,
610) -> Result<WriteResult, String> {
611    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
612        .map(|d| d.to_string_lossy().to_string())
613        .unwrap_or_default();
614    let entry = serde_json::json!({
615        "command": binary,
616        "env": { "LEAN_CTX_DATA_DIR": data_dir }
617    });
618
619    if target.config_path.exists() {
620        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
621        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
622            Ok(v) => v,
623            Err(e) => {
624                if !opts.overwrite_invalid {
625                    return Err(e.to_string());
626                }
627                backup_invalid_file(&target.config_path)?;
628                let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
629                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
630                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
631                return Ok(WriteResult {
632                    action: WriteAction::Updated,
633                    note: Some("overwrote invalid JSON".to_string()),
634                });
635            }
636        };
637        let obj = json
638            .as_object_mut()
639            .ok_or_else(|| "root JSON must be an object".to_string())?;
640        let servers = obj
641            .entry("amp.mcpServers")
642            .or_insert_with(|| serde_json::json!({}));
643        let servers_obj = servers
644            .as_object_mut()
645            .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
646
647        let existing = servers_obj.get("lean-ctx").cloned();
648        if existing.as_ref() == Some(&entry) {
649            return Ok(WriteResult {
650                action: WriteAction::Already,
651                note: None,
652            });
653        }
654        servers_obj.insert("lean-ctx".to_string(), entry);
655
656        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
657        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
658        return Ok(WriteResult {
659            action: WriteAction::Updated,
660            note: None,
661        });
662    }
663
664    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
665    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
666    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
667    Ok(WriteResult {
668        action: WriteAction::Created,
669        note: None,
670    })
671}
672
673fn write_crush_config(
674    target: &EditorTarget,
675    binary: &str,
676    opts: WriteOptions,
677) -> Result<WriteResult, String> {
678    let desired = serde_json::json!({ "type": "stdio", "command": binary });
679
680    if target.config_path.exists() {
681        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
682        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
683            Ok(v) => v,
684            Err(e) => {
685                if !opts.overwrite_invalid {
686                    return Err(e.to_string());
687                }
688                backup_invalid_file(&target.config_path)?;
689                return write_crush_fresh(
690                    &target.config_path,
691                    &desired,
692                    Some("overwrote invalid JSON".to_string()),
693                );
694            }
695        };
696        let obj = json
697            .as_object_mut()
698            .ok_or_else(|| "root JSON must be an object".to_string())?;
699        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
700        let mcp_obj = mcp
701            .as_object_mut()
702            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
703
704        let existing = mcp_obj.get("lean-ctx").cloned();
705        if existing.as_ref() == Some(&desired) {
706            return Ok(WriteResult {
707                action: WriteAction::Already,
708                note: None,
709            });
710        }
711        mcp_obj.insert("lean-ctx".to_string(), desired);
712
713        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
714        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
715        return Ok(WriteResult {
716            action: WriteAction::Updated,
717            note: None,
718        });
719    }
720
721    write_crush_fresh(&target.config_path, &desired, None)
722}
723
724fn write_crush_fresh(
725    path: &std::path::Path,
726    desired: &Value,
727    note: Option<String>,
728) -> Result<WriteResult, String> {
729    let content = serde_json::to_string_pretty(&serde_json::json!({
730        "mcp": { "lean-ctx": desired }
731    }))
732    .map_err(|e| e.to_string())?;
733    crate::config_io::write_atomic_with_backup(path, &content)?;
734    Ok(WriteResult {
735        action: if note.is_some() {
736            WriteAction::Updated
737        } else {
738            WriteAction::Created
739        },
740        note,
741    })
742}
743
744fn upsert_codex_toml(existing: &str, binary: &str) -> String {
745    let mut out = String::with_capacity(existing.len() + 128);
746    let mut in_section = false;
747    let mut saw_section = false;
748    let mut wrote_command = false;
749    let mut wrote_args = false;
750
751    for line in existing.lines() {
752        let trimmed = line.trim();
753        if trimmed == "[]" {
754            continue;
755        }
756        if trimmed.starts_with('[') && trimmed.ends_with(']') {
757            if in_section && !wrote_command {
758                out.push_str(&format!("command = {}\n", toml_quote(binary)));
759                wrote_command = true;
760            }
761            if in_section && !wrote_args {
762                out.push_str("args = []\n");
763                wrote_args = true;
764            }
765            in_section = trimmed == "[mcp_servers.lean-ctx]";
766            if in_section {
767                saw_section = true;
768            }
769            out.push_str(line);
770            out.push('\n');
771            continue;
772        }
773
774        if in_section {
775            if trimmed.starts_with("command") && trimmed.contains('=') {
776                out.push_str(&format!("command = {}\n", toml_quote(binary)));
777                wrote_command = true;
778                continue;
779            }
780            if trimmed.starts_with("args") && trimmed.contains('=') {
781                out.push_str("args = []\n");
782                wrote_args = true;
783                continue;
784            }
785        }
786
787        out.push_str(line);
788        out.push('\n');
789    }
790
791    if saw_section {
792        if in_section && !wrote_command {
793            out.push_str(&format!("command = {}\n", toml_quote(binary)));
794        }
795        if in_section && !wrote_args {
796            out.push_str("args = []\n");
797        }
798        return out;
799    }
800
801    if !out.ends_with('\n') {
802        out.push('\n');
803    }
804    out.push_str("\n[mcp_servers.lean-ctx]\n");
805    out.push_str(&format!("command = {}\n", toml_quote(binary)));
806    out.push_str("args = []\n");
807    out
808}
809
810fn write_gemini_settings(
811    target: &EditorTarget,
812    binary: &str,
813    opts: WriteOptions,
814) -> Result<WriteResult, String> {
815    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
816        .map(|d| d.to_string_lossy().to_string())
817        .unwrap_or_default();
818    let entry = serde_json::json!({
819        "command": binary,
820        "env": { "LEAN_CTX_DATA_DIR": data_dir },
821        "trust": true,
822    });
823
824    if target.config_path.exists() {
825        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
826        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
827            Ok(v) => v,
828            Err(e) => {
829                if !opts.overwrite_invalid {
830                    return Err(e.to_string());
831                }
832                backup_invalid_file(&target.config_path)?;
833                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
834                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
835                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
836                return Ok(WriteResult {
837                    action: WriteAction::Updated,
838                    note: Some("overwrote invalid JSON".to_string()),
839                });
840            }
841        };
842        let obj = json
843            .as_object_mut()
844            .ok_or_else(|| "root JSON must be an object".to_string())?;
845        let servers = obj
846            .entry("mcpServers")
847            .or_insert_with(|| serde_json::json!({}));
848        let servers_obj = servers
849            .as_object_mut()
850            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
851
852        let existing = servers_obj.get("lean-ctx").cloned();
853        if existing.as_ref() == Some(&entry) {
854            return Ok(WriteResult {
855                action: WriteAction::Already,
856                note: None,
857            });
858        }
859        servers_obj.insert("lean-ctx".to_string(), entry);
860
861        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
862        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
863        return Ok(WriteResult {
864            action: WriteAction::Updated,
865            note: None,
866        });
867    }
868
869    let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
870    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
871    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
872    Ok(WriteResult {
873        action: WriteAction::Created,
874        note: None,
875    })
876}
877
878fn write_hermes_yaml(
879    target: &EditorTarget,
880    binary: &str,
881    _opts: WriteOptions,
882) -> Result<WriteResult, String> {
883    let data_dir = default_data_dir()?;
884
885    let lean_ctx_block = format!(
886        "  lean-ctx:\n    command: \"{binary}\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\""
887    );
888
889    if target.config_path.exists() {
890        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
891
892        if content.contains("lean-ctx") {
893            return Ok(WriteResult {
894                action: WriteAction::Already,
895                note: None,
896            });
897        }
898
899        let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
900        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
901        return Ok(WriteResult {
902            action: WriteAction::Updated,
903            note: None,
904        });
905    }
906
907    let content = format!("mcp_servers:\n{lean_ctx_block}\n");
908    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
909    Ok(WriteResult {
910        action: WriteAction::Created,
911        note: None,
912    })
913}
914
915fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
916    let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
917    let mut in_mcp_section = false;
918    let mut saw_mcp_child = false;
919    let mut inserted = false;
920    let lines: Vec<&str> = existing.lines().collect();
921
922    for line in &lines {
923        if !inserted && line.trim_end() == "mcp_servers:" {
924            in_mcp_section = true;
925            out.push_str(line);
926            out.push('\n');
927            continue;
928        }
929
930        if in_mcp_section && !inserted {
931            let is_child = line.starts_with("  ") && !line.trim().is_empty();
932            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
933
934            if is_child {
935                saw_mcp_child = true;
936                out.push_str(line);
937                out.push('\n');
938                continue;
939            }
940
941            if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
942                out.push_str(lean_ctx_block);
943                out.push('\n');
944                inserted = true;
945                in_mcp_section = false;
946            }
947        }
948
949        out.push_str(line);
950        out.push('\n');
951    }
952
953    if in_mcp_section && !inserted {
954        out.push_str(lean_ctx_block);
955        out.push('\n');
956        inserted = true;
957    }
958
959    if !inserted {
960        if !out.ends_with('\n') {
961            out.push('\n');
962        }
963        out.push_str("\nmcp_servers:\n");
964        out.push_str(lean_ctx_block);
965        out.push('\n');
966    }
967
968    out
969}
970
971fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
972    if !path.exists() {
973        return Ok(());
974    }
975    let parent = path
976        .parent()
977        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
978    let filename = path
979        .file_name()
980        .ok_or_else(|| "invalid path (no filename)".to_string())?
981        .to_string_lossy();
982    let pid = std::process::id();
983    let nanos = std::time::SystemTime::now()
984        .duration_since(std::time::UNIX_EPOCH)
985        .map_or(0, |d| d.as_nanos());
986    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
987    std::fs::rename(path, bak).map_err(|e| e.to_string())?;
988    Ok(())
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994    use std::path::PathBuf;
995
996    fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
997        EditorTarget {
998            name: "test",
999            agent_key: "test".to_string(),
1000            config_path: path,
1001            detect_path: PathBuf::from("/nonexistent"),
1002            config_type: ty,
1003        }
1004    }
1005
1006    #[test]
1007    fn mcp_json_upserts_and_preserves_other_servers() {
1008        let dir = tempfile::tempdir().unwrap();
1009        let path = dir.path().join("mcp.json");
1010        std::fs::write(
1011            &path,
1012            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1013        )
1014        .unwrap();
1015
1016        let t = target(path.clone(), ConfigType::McpJson);
1017        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1018        assert_eq!(res.action, WriteAction::Updated);
1019
1020        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1021        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1022        assert_eq!(
1023            json["mcpServers"]["lean-ctx"]["command"],
1024            "/new/path/lean-ctx"
1025        );
1026        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1027        assert!(
1028            json["mcpServers"]["lean-ctx"]["autoApprove"]
1029                .as_array()
1030                .unwrap()
1031                .len()
1032                > 5
1033        );
1034    }
1035
1036    #[test]
1037    fn crush_config_writes_mcp_root() {
1038        let dir = tempfile::tempdir().unwrap();
1039        let path = dir.path().join("crush.json");
1040        std::fs::write(
1041            &path,
1042            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1043        )
1044        .unwrap();
1045
1046        let t = target(path.clone(), ConfigType::Crush);
1047        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1048        assert_eq!(res.action, WriteAction::Updated);
1049
1050        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1051        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1052        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1053    }
1054
1055    #[test]
1056    fn codex_toml_upserts_existing_section() {
1057        let dir = tempfile::tempdir().unwrap();
1058        let path = dir.path().join("config.toml");
1059        std::fs::write(
1060            &path,
1061            r#"[mcp_servers.lean-ctx]
1062command = "old"
1063args = ["x"]
1064"#,
1065        )
1066        .unwrap();
1067
1068        let t = target(path.clone(), ConfigType::Codex);
1069        let res = write_codex_config(&t, "new").unwrap();
1070        assert_eq!(res.action, WriteAction::Updated);
1071
1072        let content = std::fs::read_to_string(&path).unwrap();
1073        assert!(content.contains(r#"command = "new""#));
1074        assert!(content.contains("args = []"));
1075    }
1076
1077    #[test]
1078    fn upsert_codex_toml_inserts_new_section_when_missing() {
1079        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1080        assert!(updated.contains("[mcp_servers.lean-ctx]"));
1081        assert!(updated.contains("command = \"lean-ctx\""));
1082        assert!(updated.contains("args = []"));
1083    }
1084
1085    #[test]
1086    fn codex_toml_uses_single_quotes_for_backslash_paths() {
1087        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1088        let updated = upsert_codex_toml("", win_path);
1089        assert!(
1090            updated.contains(&format!("command = '{win_path}'")),
1091            "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1092        );
1093    }
1094
1095    #[test]
1096    fn codex_toml_uses_double_quotes_for_unix_paths() {
1097        let unix_path = "/usr/local/bin/lean-ctx";
1098        let updated = upsert_codex_toml("", unix_path);
1099        assert!(
1100            updated.contains(&format!("command = \"{unix_path}\"")),
1101            "Unix paths should use double quotes: {updated}"
1102        );
1103    }
1104
1105    #[test]
1106    fn auto_approve_contains_core_tools() {
1107        let tools = auto_approve_tools();
1108        assert!(tools.contains(&"ctx_read"));
1109        assert!(tools.contains(&"ctx_shell"));
1110        assert!(tools.contains(&"ctx_search"));
1111        assert!(tools.contains(&"ctx_workflow"));
1112        assert!(tools.contains(&"ctx_cost"));
1113    }
1114
1115    #[test]
1116    fn antigravity_config_omits_auto_approve() {
1117        let dir = tempfile::tempdir().unwrap();
1118        let path = dir.path().join("mcp_config.json");
1119
1120        let t = EditorTarget {
1121            name: "Antigravity",
1122            agent_key: "gemini".to_string(),
1123            config_path: path.clone(),
1124            detect_path: PathBuf::from("/nonexistent"),
1125            config_type: ConfigType::McpJson,
1126        };
1127        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1128        assert_eq!(res.action, WriteAction::Created);
1129
1130        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1131        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1132        assert_eq!(
1133            json["mcpServers"]["lean-ctx"]["command"],
1134            "/usr/local/bin/lean-ctx"
1135        );
1136    }
1137
1138    #[test]
1139    fn hermes_yaml_inserts_into_existing_mcp_servers() {
1140        let existing = "model: anthropic/claude-sonnet-4\n\nmcp_servers:\n  github:\n    command: \"npx\"\n    args: [\"-y\", \"@modelcontextprotocol/server-github\"]\n\ntool_allowlist:\n  - terminal\n";
1141        let block = "  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1142        let result = upsert_hermes_yaml_mcp(existing, block);
1143        assert!(result.contains("lean-ctx"));
1144        assert!(result.contains("model: anthropic/claude-sonnet-4"));
1145        assert!(result.contains("tool_allowlist:"));
1146        assert!(result.contains("github:"));
1147    }
1148
1149    #[test]
1150    fn hermes_yaml_creates_mcp_servers_section() {
1151        let existing = "model: openai/gpt-4o\n";
1152        let block = "  lean-ctx:\n    command: \"lean-ctx\"";
1153        let result = upsert_hermes_yaml_mcp(existing, block);
1154        assert!(result.contains("mcp_servers:"));
1155        assert!(result.contains("lean-ctx"));
1156        assert!(result.contains("model: openai/gpt-4o"));
1157    }
1158
1159    #[test]
1160    fn hermes_yaml_skips_if_already_present() {
1161        let dir = tempfile::tempdir().unwrap();
1162        let path = dir.path().join("config.yaml");
1163        std::fs::write(
1164            &path,
1165            "mcp_servers:\n  lean-ctx:\n    command: \"lean-ctx\"\n",
1166        )
1167        .unwrap();
1168        let t = target(path.clone(), ConfigType::HermesYaml);
1169        let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1170        assert_eq!(res.action, WriteAction::Already);
1171    }
1172}