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