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