Skip to main content

lean_ctx/core/editor_registry/
writers.rs

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