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        ConfigType::QoderSettings => write_qoder_settings(target, binary, opts),
56    }
57}
58
59pub fn remove_lean_ctx_mcp_server(
60    path: &std::path::Path,
61    opts: WriteOptions,
62) -> Result<WriteResult, String> {
63    if !path.exists() {
64        return Ok(WriteResult {
65            action: WriteAction::Already,
66            note: Some("mcp.json not found".to_string()),
67        });
68    }
69
70    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
71    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
72        Ok(v) => v,
73        Err(e) => {
74            if !opts.overwrite_invalid {
75                return Err(e.to_string());
76            }
77            backup_invalid_file(path)?;
78            return Ok(WriteResult {
79                action: WriteAction::Updated,
80                note: Some("backed up invalid JSON; did not modify".to_string()),
81            });
82        }
83    };
84
85    let obj = json
86        .as_object_mut()
87        .ok_or_else(|| "root JSON must be an object".to_string())?;
88
89    let Some(servers) = obj.get_mut("mcpServers") else {
90        return Ok(WriteResult {
91            action: WriteAction::Already,
92            note: Some("no mcpServers key".to_string()),
93        });
94    };
95    let servers_obj = servers
96        .as_object_mut()
97        .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
98
99    if servers_obj.remove("lean-ctx").is_none() {
100        return Ok(WriteResult {
101            action: WriteAction::Already,
102            note: Some("lean-ctx not configured".to_string()),
103        });
104    }
105
106    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
107    crate::config_io::write_atomic_with_backup(path, &formatted)?;
108    Ok(WriteResult {
109        action: WriteAction::Updated,
110        note: Some("removed lean-ctx from mcpServers".to_string()),
111    })
112}
113
114pub fn remove_lean_ctx_server(
115    target: &EditorTarget,
116    opts: WriteOptions,
117) -> Result<WriteResult, String> {
118    match target.config_type {
119        ConfigType::McpJson
120        | ConfigType::JetBrains
121        | ConfigType::GeminiSettings
122        | ConfigType::QoderSettings => remove_lean_ctx_mcp_server(&target.config_path, opts),
123        ConfigType::VsCodeMcp => remove_lean_ctx_vscode_server(&target.config_path, opts),
124        ConfigType::Codex => remove_lean_ctx_codex_server(&target.config_path),
125        ConfigType::OpenCode | ConfigType::Crush => {
126            remove_lean_ctx_named_json_server(&target.config_path, "mcp", opts)
127        }
128        ConfigType::Zed => {
129            remove_lean_ctx_named_json_server(&target.config_path, "context_servers", opts)
130        }
131        ConfigType::Amp => remove_lean_ctx_amp_server(&target.config_path, opts),
132        ConfigType::HermesYaml => remove_lean_ctx_hermes_yaml_server(&target.config_path),
133    }
134}
135
136fn remove_lean_ctx_vscode_server(
137    path: &std::path::Path,
138    opts: WriteOptions,
139) -> Result<WriteResult, String> {
140    if !path.exists() {
141        return Ok(WriteResult {
142            action: WriteAction::Already,
143            note: Some("vscode mcp.json not found".to_string()),
144        });
145    }
146
147    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
148    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
149        Ok(v) => v,
150        Err(e) => {
151            if !opts.overwrite_invalid {
152                return Err(e.to_string());
153            }
154            backup_invalid_file(path)?;
155            return Ok(WriteResult {
156                action: WriteAction::Updated,
157                note: Some("backed up invalid JSON; did not modify".to_string()),
158            });
159        }
160    };
161
162    let obj = json
163        .as_object_mut()
164        .ok_or_else(|| "root JSON must be an object".to_string())?;
165
166    let Some(servers) = obj.get_mut("servers") else {
167        return Ok(WriteResult {
168            action: WriteAction::Already,
169            note: Some("no servers key".to_string()),
170        });
171    };
172    let servers_obj = servers
173        .as_object_mut()
174        .ok_or_else(|| "\"servers\" must be an object".to_string())?;
175
176    if servers_obj.remove("lean-ctx").is_none() {
177        return Ok(WriteResult {
178            action: WriteAction::Already,
179            note: Some("lean-ctx not configured".to_string()),
180        });
181    }
182
183    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
184    crate::config_io::write_atomic_with_backup(path, &formatted)?;
185    Ok(WriteResult {
186        action: WriteAction::Updated,
187        note: Some("removed lean-ctx from servers".to_string()),
188    })
189}
190
191fn remove_lean_ctx_amp_server(
192    path: &std::path::Path,
193    opts: WriteOptions,
194) -> Result<WriteResult, String> {
195    if !path.exists() {
196        return Ok(WriteResult {
197            action: WriteAction::Already,
198            note: Some("amp settings not found".to_string()),
199        });
200    }
201
202    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
203    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
204        Ok(v) => v,
205        Err(e) => {
206            if !opts.overwrite_invalid {
207                return Err(e.to_string());
208            }
209            backup_invalid_file(path)?;
210            return Ok(WriteResult {
211                action: WriteAction::Updated,
212                note: Some("backed up invalid JSON; did not modify".to_string()),
213            });
214        }
215    };
216
217    let obj = json
218        .as_object_mut()
219        .ok_or_else(|| "root JSON must be an object".to_string())?;
220    let Some(servers) = obj.get_mut("amp.mcpServers") else {
221        return Ok(WriteResult {
222            action: WriteAction::Already,
223            note: Some("no amp.mcpServers key".to_string()),
224        });
225    };
226    let servers_obj = servers
227        .as_object_mut()
228        .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
229
230    if servers_obj.remove("lean-ctx").is_none() {
231        return Ok(WriteResult {
232            action: WriteAction::Already,
233            note: Some("lean-ctx not configured".to_string()),
234        });
235    }
236
237    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
238    crate::config_io::write_atomic_with_backup(path, &formatted)?;
239    Ok(WriteResult {
240        action: WriteAction::Updated,
241        note: Some("removed lean-ctx from amp.mcpServers".to_string()),
242    })
243}
244
245fn remove_lean_ctx_named_json_server(
246    path: &std::path::Path,
247    container_key: &str,
248    opts: WriteOptions,
249) -> Result<WriteResult, String> {
250    if !path.exists() {
251        return Ok(WriteResult {
252            action: WriteAction::Already,
253            note: Some("config not found".to_string()),
254        });
255    }
256
257    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
258    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
259        Ok(v) => v,
260        Err(e) => {
261            if !opts.overwrite_invalid {
262                return Err(e.to_string());
263            }
264            backup_invalid_file(path)?;
265            return Ok(WriteResult {
266                action: WriteAction::Updated,
267                note: Some("backed up invalid JSON; did not modify".to_string()),
268            });
269        }
270    };
271
272    let obj = json
273        .as_object_mut()
274        .ok_or_else(|| "root JSON must be an object".to_string())?;
275    let Some(container) = obj.get_mut(container_key) else {
276        return Ok(WriteResult {
277            action: WriteAction::Already,
278            note: Some(format!("no {container_key} key")),
279        });
280    };
281    let container_obj = container
282        .as_object_mut()
283        .ok_or_else(|| format!("\"{container_key}\" must be an object"))?;
284
285    if container_obj.remove("lean-ctx").is_none() {
286        return Ok(WriteResult {
287            action: WriteAction::Already,
288            note: Some("lean-ctx not configured".to_string()),
289        });
290    }
291
292    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
293    crate::config_io::write_atomic_with_backup(path, &formatted)?;
294    Ok(WriteResult {
295        action: WriteAction::Updated,
296        note: Some(format!("removed lean-ctx from {container_key}")),
297    })
298}
299
300fn remove_lean_ctx_codex_server(path: &std::path::Path) -> Result<WriteResult, String> {
301    if !path.exists() {
302        return Ok(WriteResult {
303            action: WriteAction::Already,
304            note: Some("codex config not found".to_string()),
305        });
306    }
307    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
308    let updated = remove_codex_toml_section(&content, "[mcp_servers.lean-ctx]");
309    if updated == content {
310        return Ok(WriteResult {
311            action: WriteAction::Already,
312            note: Some("lean-ctx not configured".to_string()),
313        });
314    }
315    crate::config_io::write_atomic_with_backup(path, &updated)?;
316    Ok(WriteResult {
317        action: WriteAction::Updated,
318        note: Some("removed [mcp_servers.lean-ctx]".to_string()),
319    })
320}
321
322fn remove_codex_toml_section(existing: &str, header: &str) -> String {
323    let prefix = header.trim_end_matches(']');
324    let mut out = String::with_capacity(existing.len());
325    let mut skipping = false;
326    for line in existing.lines() {
327        let trimmed = line.trim();
328        if trimmed.starts_with('[') && trimmed.ends_with(']') {
329            if trimmed == header || trimmed.starts_with(&format!("{prefix}.")) {
330                skipping = true;
331                continue;
332            }
333            skipping = false;
334        }
335        if skipping {
336            continue;
337        }
338        out.push_str(line);
339        out.push('\n');
340    }
341    out
342}
343
344fn remove_lean_ctx_hermes_yaml_server(path: &std::path::Path) -> Result<WriteResult, String> {
345    if !path.exists() {
346        return Ok(WriteResult {
347            action: WriteAction::Already,
348            note: Some("hermes config not found".to_string()),
349        });
350    }
351    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
352    let updated = remove_hermes_yaml_mcp_server_block(&content, "lean-ctx");
353    if updated == content {
354        return Ok(WriteResult {
355            action: WriteAction::Already,
356            note: Some("lean-ctx not configured".to_string()),
357        });
358    }
359    crate::config_io::write_atomic_with_backup(path, &updated)?;
360    Ok(WriteResult {
361        action: WriteAction::Updated,
362        note: Some("removed lean-ctx from mcp_servers".to_string()),
363    })
364}
365
366fn remove_hermes_yaml_mcp_server_block(existing: &str, name: &str) -> String {
367    let mut out = String::with_capacity(existing.len());
368    let mut in_mcp = false;
369    let mut skipping = false;
370    for line in existing.lines() {
371        let trimmed = line.trim_end();
372        if trimmed == "mcp_servers:" {
373            in_mcp = true;
374            out.push_str(line);
375            out.push('\n');
376            continue;
377        }
378
379        if in_mcp {
380            let is_child = line.starts_with("  ") && !line.starts_with("    ");
381            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
382
383            if is_toplevel {
384                in_mcp = false;
385                skipping = false;
386            }
387
388            if skipping {
389                if is_child || is_toplevel {
390                    skipping = false;
391                    out.push_str(line);
392                    out.push('\n');
393                }
394                continue;
395            }
396
397            if is_child && line.trim() == format!("{name}:") {
398                skipping = true;
399                continue;
400            }
401        }
402
403        out.push_str(line);
404        out.push('\n');
405    }
406    out
407}
408
409pub fn auto_approve_tools() -> Vec<&'static str> {
410    vec![
411        "ctx_read",
412        "ctx_shell",
413        "ctx_search",
414        "ctx_tree",
415        "ctx_overview",
416        "ctx_preload",
417        "ctx_compress",
418        "ctx_metrics",
419        "ctx_session",
420        "ctx_knowledge",
421        "ctx_agent",
422        "ctx_share",
423        "ctx_analyze",
424        "ctx_benchmark",
425        "ctx_cache",
426        "ctx_discover",
427        "ctx_smart_read",
428        "ctx_delta",
429        "ctx_edit",
430        "ctx_dedup",
431        "ctx_fill",
432        "ctx_intent",
433        "ctx_response",
434        "ctx_context",
435        "ctx_graph",
436        "ctx_multi_read",
437        "ctx_semantic_search",
438        "ctx_symbol",
439        "ctx_outline",
440        "ctx_callgraph",
441        "ctx_refactor",
442        "ctx_routes",
443        "ctx_cost",
444        "ctx_heatmap",
445        "ctx_gain",
446        "ctx_expand",
447        "ctx_task",
448        "ctx_impact",
449        "ctx_architecture",
450        "ctx_workflow",
451        "ctx_review",
452        "ctx_pack",
453        "ctx_index",
454        "ctx_artifacts",
455        "ctx_smells",
456        "ctx_proof",
457        "ctx_verify",
458        "ctx_execute",
459        "ctx_handoff",
460        "ctx_feedback",
461        "ctx_control",
462        "ctx_plan",
463        "ctx_compile",
464        "ctx_discover_tools",
465        "ctx_provider",
466        "ctx_radar",
467        "ctx_retrieve",
468        "ctx_compress_memory",
469        "ctx_load_tools",
470        "ctx",
471    ]
472}
473
474fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
475    let mut entry = serde_json::json!({
476        "command": binary,
477        "env": {
478            "LEAN_CTX_DATA_DIR": data_dir
479        }
480    });
481    if include_auto_approve {
482        entry["autoApprove"] = serde_json::json!(auto_approve_tools());
483    }
484    entry
485}
486
487fn lean_ctx_server_entry_with_instructions(
488    binary: &str,
489    data_dir: &str,
490    include_auto_approve: bool,
491    agent_key: &str,
492) -> Value {
493    let mut entry = lean_ctx_server_entry(binary, data_dir, include_auto_approve);
494    let mode = crate::core::rules_canonical::Mode::from_hook_mode(
495        &crate::hooks::recommend_hook_mode(agent_key),
496    );
497    let instructions = crate::core::rules_canonical::mcp_instructions(mode);
498
499    let constraints = crate::core::client_constraints::by_client_id(agent_key);
500    if let Some(max_chars) = constraints.and_then(|c| c.mcp_instructions_max_chars) {
501        let truncated = if instructions.len() > max_chars {
502            &instructions[..max_chars]
503        } else {
504            instructions
505        };
506        entry["instructions"] = serde_json::json!(truncated);
507    }
508    entry
509}
510
511fn supports_auto_approve(target: &EditorTarget) -> bool {
512    crate::core::client_constraints::by_editor_name(target.name)
513        .is_some_and(|c| c.supports_auto_approve)
514}
515
516fn default_data_dir() -> Result<String, String> {
517    Ok(crate::core::data_dir::lean_ctx_data_dir()?
518        .to_string_lossy()
519        .to_string())
520}
521
522fn write_mcp_json(
523    target: &EditorTarget,
524    binary: &str,
525    opts: WriteOptions,
526) -> Result<WriteResult, String> {
527    let data_dir = default_data_dir()?;
528    let include_aa = supports_auto_approve(target);
529    let desired = if target.agent_key.is_empty() {
530        lean_ctx_server_entry(binary, &data_dir, include_aa)
531    } else {
532        lean_ctx_server_entry_with_instructions(binary, &data_dir, include_aa, &target.agent_key)
533    };
534
535    // Claude Code manages ~/.claude.json and may overwrite it on first start.
536    // Prefer the official CLI integration when available.
537    // Skip when LEAN_CTX_QUIET=1 (bootstrap --json / setup --json) to avoid
538    // spawning `claude mcp add-json` which can stall in non-interactive CI.
539    if (target.agent_key == "claude" || target.name == "Claude Code")
540        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
541    {
542        if let Ok(result) = try_claude_mcp_add(&desired) {
543            return Ok(result);
544        }
545    }
546
547    if target.config_path.exists() {
548        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
549        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
550            Ok(v) => v,
551            Err(e) => {
552                if !opts.overwrite_invalid {
553                    return Err(e.to_string());
554                }
555                backup_invalid_file(&target.config_path)?;
556                return write_mcp_json_fresh(
557                    &target.config_path,
558                    &desired,
559                    Some("overwrote invalid JSON".to_string()),
560                );
561            }
562        };
563        let obj = json
564            .as_object_mut()
565            .ok_or_else(|| "root JSON must be an object".to_string())?;
566
567        let servers = obj
568            .entry("mcpServers")
569            .or_insert_with(|| serde_json::json!({}));
570        let servers_obj = servers
571            .as_object_mut()
572            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
573
574        let existing = servers_obj.get("lean-ctx").cloned();
575        if existing.as_ref() == Some(&desired) {
576            return Ok(WriteResult {
577                action: WriteAction::Already,
578                note: None,
579            });
580        }
581        servers_obj.insert("lean-ctx".to_string(), desired);
582
583        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
584        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
585        return Ok(WriteResult {
586            action: WriteAction::Updated,
587            note: None,
588        });
589    }
590
591    write_mcp_json_fresh(&target.config_path, &desired, None)
592}
593
594fn find_in_path(binary: &str) -> Option<std::path::PathBuf> {
595    let path_var = std::env::var("PATH").ok()?;
596    for dir in std::env::split_paths(&path_var) {
597        let candidate = dir.join(binary);
598        if candidate.is_file() {
599            return Some(candidate);
600        }
601    }
602    None
603}
604
605fn validate_claude_binary() -> Result<std::path::PathBuf, String> {
606    let path = find_in_path("claude").ok_or("claude binary not found in PATH")?;
607
608    let canonical =
609        std::fs::canonicalize(&path).map_err(|e| format!("cannot resolve claude path: {e}"))?;
610
611    let canonical_str = canonical.to_string_lossy();
612    let is_trusted = canonical_str.contains("/.claude/")
613        || canonical_str.contains("\\AppData\\")
614        || canonical_str.contains("/usr/local/bin/")
615        || canonical_str.contains("/opt/homebrew/")
616        || canonical_str.contains("/nix/store/")
617        || canonical_str.contains("/.npm/")
618        || canonical_str.contains("/.nvm/")
619        || canonical_str.contains("/node_modules/.bin/")
620        || std::env::var("LEAN_CTX_TRUST_CLAUDE_PATH").is_ok();
621
622    if !is_trusted {
623        return Err(format!(
624            "claude binary resolved to untrusted path: {canonical_str} — set LEAN_CTX_TRUST_CLAUDE_PATH=1 to override"
625        ));
626    }
627    Ok(canonical)
628}
629
630fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
631    use std::io::Write;
632    use std::process::{Command, Stdio};
633    use std::time::{Duration, Instant};
634
635    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
636
637    let mut cmd = if cfg!(windows) {
638        let mut c = Command::new("cmd");
639        c.args([
640            "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
641        ]);
642        c
643    } else {
644        let claude_path = validate_claude_binary()?;
645        let mut c = Command::new(claude_path);
646        c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
647        c
648    };
649
650    let mut child = cmd
651        .stdin(Stdio::piped())
652        .stdout(Stdio::null())
653        .stderr(Stdio::null())
654        .spawn()
655        .map_err(|e| e.to_string())?;
656
657    if let Some(mut stdin) = child.stdin.take() {
658        let _ = stdin.write_all(server_json.as_bytes());
659    }
660
661    let deadline = Duration::from_secs(3);
662    let start = Instant::now();
663    loop {
664        match child.try_wait() {
665            Ok(Some(status)) => {
666                return if status.success() {
667                    Ok(WriteResult {
668                        action: WriteAction::Updated,
669                        note: Some("via claude mcp add-json".to_string()),
670                    })
671                } else {
672                    Err("claude mcp add-json failed".to_string())
673                };
674            }
675            Ok(None) => {
676                if start.elapsed() > deadline {
677                    let _ = child.kill();
678                    let _ = child.wait();
679                    return Err("claude mcp add-json timed out".to_string());
680                }
681                std::thread::sleep(Duration::from_millis(20));
682            }
683            Err(e) => return Err(e.to_string()),
684        }
685    }
686}
687
688fn write_mcp_json_fresh(
689    path: &std::path::Path,
690    desired: &Value,
691    note: Option<String>,
692) -> Result<WriteResult, String> {
693    let content = serde_json::to_string_pretty(&serde_json::json!({
694        "mcpServers": { "lean-ctx": desired }
695    }))
696    .map_err(|e| e.to_string())?;
697    crate::config_io::write_atomic_with_backup(path, &content)?;
698    Ok(WriteResult {
699        action: if note.is_some() {
700            WriteAction::Updated
701        } else {
702            WriteAction::Created
703        },
704        note,
705    })
706}
707
708fn write_zed_config(
709    target: &EditorTarget,
710    binary: &str,
711    opts: WriteOptions,
712) -> Result<WriteResult, String> {
713    let desired = serde_json::json!({
714        "command": binary,
715        "args": [],
716        "env": {}
717    });
718
719    if target.config_path.exists() {
720        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
721        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
722            Ok(v) => v,
723            Err(e) => {
724                if !opts.overwrite_invalid {
725                    return Err(e.to_string());
726                }
727                backup_invalid_file(&target.config_path)?;
728                return write_zed_config_fresh(
729                    &target.config_path,
730                    &desired,
731                    Some("overwrote invalid JSON".to_string()),
732                );
733            }
734        };
735        let obj = json
736            .as_object_mut()
737            .ok_or_else(|| "root JSON must be an object".to_string())?;
738
739        let servers = obj
740            .entry("context_servers")
741            .or_insert_with(|| serde_json::json!({}));
742        let servers_obj = servers
743            .as_object_mut()
744            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
745
746        let existing = servers_obj.get("lean-ctx").cloned();
747        if existing.as_ref() == Some(&desired) {
748            return Ok(WriteResult {
749                action: WriteAction::Already,
750                note: None,
751            });
752        }
753        servers_obj.insert("lean-ctx".to_string(), desired);
754
755        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
756        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
757        return Ok(WriteResult {
758            action: WriteAction::Updated,
759            note: None,
760        });
761    }
762
763    write_zed_config_fresh(&target.config_path, &desired, None)
764}
765
766fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
767    if target.config_path.exists() {
768        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
769        let updated = upsert_codex_toml(&content, binary);
770        if updated == content {
771            return Ok(WriteResult {
772                action: WriteAction::Already,
773                note: None,
774            });
775        }
776        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
777        return Ok(WriteResult {
778            action: WriteAction::Updated,
779            note: None,
780        });
781    }
782
783    let content = format!(
784        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
785        toml_quote(binary)
786    );
787    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
788    Ok(WriteResult {
789        action: WriteAction::Created,
790        note: None,
791    })
792}
793
794fn write_zed_config_fresh(
795    path: &std::path::Path,
796    desired: &Value,
797    note: Option<String>,
798) -> Result<WriteResult, String> {
799    let content = serde_json::to_string_pretty(&serde_json::json!({
800        "context_servers": { "lean-ctx": desired }
801    }))
802    .map_err(|e| e.to_string())?;
803    crate::config_io::write_atomic_with_backup(path, &content)?;
804    Ok(WriteResult {
805        action: if note.is_some() {
806            WriteAction::Updated
807        } else {
808            WriteAction::Created
809        },
810        note,
811    })
812}
813
814fn write_vscode_mcp(
815    target: &EditorTarget,
816    binary: &str,
817    opts: WriteOptions,
818) -> Result<WriteResult, String> {
819    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
820        .map(|d| d.to_string_lossy().to_string())
821        .unwrap_or_default();
822    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
823
824    if target.config_path.exists() {
825        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
826        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
827            Ok(v) => v,
828            Err(e) => {
829                if !opts.overwrite_invalid {
830                    return Err(e.to_string());
831                }
832                backup_invalid_file(&target.config_path)?;
833                return write_vscode_mcp_fresh(
834                    &target.config_path,
835                    binary,
836                    Some("overwrote invalid JSON".to_string()),
837                );
838            }
839        };
840        let obj = json
841            .as_object_mut()
842            .ok_or_else(|| "root JSON must be an object".to_string())?;
843
844        let servers = obj
845            .entry("servers")
846            .or_insert_with(|| serde_json::json!({}));
847        let servers_obj = servers
848            .as_object_mut()
849            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
850
851        let existing = servers_obj.get("lean-ctx").cloned();
852        if existing.as_ref() == Some(&desired) {
853            return Ok(WriteResult {
854                action: WriteAction::Already,
855                note: None,
856            });
857        }
858        servers_obj.insert("lean-ctx".to_string(), desired);
859
860        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
861        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
862        return Ok(WriteResult {
863            action: WriteAction::Updated,
864            note: None,
865        });
866    }
867
868    write_vscode_mcp_fresh(&target.config_path, binary, None)
869}
870
871fn write_vscode_mcp_fresh(
872    path: &std::path::Path,
873    binary: &str,
874    note: Option<String>,
875) -> Result<WriteResult, String> {
876    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
877        .map(|d| d.to_string_lossy().to_string())
878        .unwrap_or_default();
879    let content = serde_json::to_string_pretty(&serde_json::json!({
880        "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
881    }))
882    .map_err(|e| e.to_string())?;
883    crate::config_io::write_atomic_with_backup(path, &content)?;
884    Ok(WriteResult {
885        action: if note.is_some() {
886            WriteAction::Updated
887        } else {
888            WriteAction::Created
889        },
890        note,
891    })
892}
893
894fn write_opencode_config(
895    target: &EditorTarget,
896    binary: &str,
897    opts: WriteOptions,
898) -> Result<WriteResult, String> {
899    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
900        .map(|d| d.to_string_lossy().to_string())
901        .unwrap_or_default();
902    let desired = serde_json::json!({
903        "type": "local",
904        "command": [binary],
905        "enabled": true,
906        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
907    });
908
909    if target.config_path.exists() {
910        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
911        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
912            Ok(v) => v,
913            Err(e) => {
914                if !opts.overwrite_invalid {
915                    return Err(e.to_string());
916                }
917                backup_invalid_file(&target.config_path)?;
918                return write_opencode_fresh(
919                    &target.config_path,
920                    binary,
921                    Some("overwrote invalid JSON".to_string()),
922                );
923            }
924        };
925        let obj = json
926            .as_object_mut()
927            .ok_or_else(|| "root JSON must be an object".to_string())?;
928        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
929        let mcp_obj = mcp
930            .as_object_mut()
931            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
932
933        let existing = mcp_obj.get("lean-ctx").cloned();
934        if existing.as_ref() == Some(&desired) {
935            return Ok(WriteResult {
936                action: WriteAction::Already,
937                note: None,
938            });
939        }
940        mcp_obj.insert("lean-ctx".to_string(), desired);
941
942        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
943        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
944        return Ok(WriteResult {
945            action: WriteAction::Updated,
946            note: None,
947        });
948    }
949
950    write_opencode_fresh(&target.config_path, binary, None)
951}
952
953fn write_opencode_fresh(
954    path: &std::path::Path,
955    binary: &str,
956    note: Option<String>,
957) -> Result<WriteResult, String> {
958    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
959        .map(|d| d.to_string_lossy().to_string())
960        .unwrap_or_default();
961    let content = serde_json::to_string_pretty(&serde_json::json!({
962        "$schema": "https://opencode.ai/config.json",
963        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
964    }))
965    .map_err(|e| e.to_string())?;
966    crate::config_io::write_atomic_with_backup(path, &content)?;
967    Ok(WriteResult {
968        action: if note.is_some() {
969            WriteAction::Updated
970        } else {
971            WriteAction::Created
972        },
973        note,
974    })
975}
976
977fn write_jetbrains_config(
978    target: &EditorTarget,
979    binary: &str,
980    opts: WriteOptions,
981) -> Result<WriteResult, String> {
982    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
983        .map(|d| d.to_string_lossy().to_string())
984        .unwrap_or_default();
985    // JetBrains AI Assistant expects an "mcpServers" mapping in the JSON snippet
986    // you paste into Settings | Tools | AI Assistant | Model Context Protocol (MCP).
987    // We write that snippet to a file for easy copy/paste.
988    let desired = serde_json::json!({
989        "command": binary,
990        "args": [],
991        "env": { "LEAN_CTX_DATA_DIR": data_dir }
992    });
993
994    if target.config_path.exists() {
995        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
996        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
997            Ok(v) => v,
998            Err(e) => {
999                if !opts.overwrite_invalid {
1000                    return Err(e.to_string());
1001                }
1002                backup_invalid_file(&target.config_path)?;
1003                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
1004                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1005                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1006                return Ok(WriteResult {
1007                    action: WriteAction::Updated,
1008                    note: Some(
1009                        "overwrote invalid JSON (paste this snippet into JetBrains MCP settings)"
1010                            .to_string(),
1011                    ),
1012                });
1013            }
1014        };
1015        let obj = json
1016            .as_object_mut()
1017            .ok_or_else(|| "root JSON must be an object".to_string())?;
1018
1019        let servers = obj
1020            .entry("mcpServers")
1021            .or_insert_with(|| serde_json::json!({}));
1022        let servers_obj = servers
1023            .as_object_mut()
1024            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1025
1026        let existing = servers_obj.get("lean-ctx").cloned();
1027        if existing.as_ref() == Some(&desired) {
1028            return Ok(WriteResult {
1029                action: WriteAction::Already,
1030                note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1031            });
1032        }
1033        servers_obj.insert("lean-ctx".to_string(), desired);
1034
1035        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1036        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1037        return Ok(WriteResult {
1038            action: WriteAction::Updated,
1039            note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1040        });
1041    }
1042
1043    let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
1044    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1045    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1046    Ok(WriteResult {
1047        action: WriteAction::Created,
1048        note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1049    })
1050}
1051
1052fn write_amp_config(
1053    target: &EditorTarget,
1054    binary: &str,
1055    opts: WriteOptions,
1056) -> Result<WriteResult, String> {
1057    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1058        .map(|d| d.to_string_lossy().to_string())
1059        .unwrap_or_default();
1060    let entry = serde_json::json!({
1061        "command": binary,
1062        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1063    });
1064
1065    if target.config_path.exists() {
1066        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1067        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1068            Ok(v) => v,
1069            Err(e) => {
1070                if !opts.overwrite_invalid {
1071                    return Err(e.to_string());
1072                }
1073                backup_invalid_file(&target.config_path)?;
1074                let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1075                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1076                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1077                return Ok(WriteResult {
1078                    action: WriteAction::Updated,
1079                    note: Some("overwrote invalid JSON".to_string()),
1080                });
1081            }
1082        };
1083        let obj = json
1084            .as_object_mut()
1085            .ok_or_else(|| "root JSON must be an object".to_string())?;
1086        let servers = obj
1087            .entry("amp.mcpServers")
1088            .or_insert_with(|| serde_json::json!({}));
1089        let servers_obj = servers
1090            .as_object_mut()
1091            .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
1092
1093        let existing = servers_obj.get("lean-ctx").cloned();
1094        if existing.as_ref() == Some(&entry) {
1095            return Ok(WriteResult {
1096                action: WriteAction::Already,
1097                note: None,
1098            });
1099        }
1100        servers_obj.insert("lean-ctx".to_string(), entry);
1101
1102        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1103        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1104        return Ok(WriteResult {
1105            action: WriteAction::Updated,
1106            note: None,
1107        });
1108    }
1109
1110    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1111    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1112    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1113    Ok(WriteResult {
1114        action: WriteAction::Created,
1115        note: None,
1116    })
1117}
1118
1119fn write_crush_config(
1120    target: &EditorTarget,
1121    binary: &str,
1122    opts: WriteOptions,
1123) -> Result<WriteResult, String> {
1124    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1125        .map(|d| d.to_string_lossy().to_string())
1126        .unwrap_or_default();
1127    let desired = serde_json::json!({
1128        "type": "stdio",
1129        "command": binary,
1130        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1131    });
1132
1133    if target.config_path.exists() {
1134        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1135        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1136            Ok(v) => v,
1137            Err(e) => {
1138                if !opts.overwrite_invalid {
1139                    return Err(e.to_string());
1140                }
1141                backup_invalid_file(&target.config_path)?;
1142                return write_crush_fresh(
1143                    &target.config_path,
1144                    &desired,
1145                    Some("overwrote invalid JSON".to_string()),
1146                );
1147            }
1148        };
1149        let obj = json
1150            .as_object_mut()
1151            .ok_or_else(|| "root JSON must be an object".to_string())?;
1152        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1153        let mcp_obj = mcp
1154            .as_object_mut()
1155            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1156
1157        let existing = mcp_obj.get("lean-ctx").cloned();
1158        if existing.as_ref() == Some(&desired) {
1159            return Ok(WriteResult {
1160                action: WriteAction::Already,
1161                note: None,
1162            });
1163        }
1164        mcp_obj.insert("lean-ctx".to_string(), desired);
1165
1166        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1167        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1168        return Ok(WriteResult {
1169            action: WriteAction::Updated,
1170            note: None,
1171        });
1172    }
1173
1174    write_crush_fresh(&target.config_path, &desired, None)
1175}
1176
1177fn write_crush_fresh(
1178    path: &std::path::Path,
1179    desired: &Value,
1180    note: Option<String>,
1181) -> Result<WriteResult, String> {
1182    let content = serde_json::to_string_pretty(&serde_json::json!({
1183        "mcp": { "lean-ctx": desired }
1184    }))
1185    .map_err(|e| e.to_string())?;
1186    crate::config_io::write_atomic_with_backup(path, &content)?;
1187    Ok(WriteResult {
1188        action: if note.is_some() {
1189            WriteAction::Updated
1190        } else {
1191            WriteAction::Created
1192        },
1193        note,
1194    })
1195}
1196
1197fn upsert_codex_toml(existing: &str, binary: &str) -> String {
1198    let mut out = String::with_capacity(existing.len() + 128);
1199    let mut in_section = false;
1200    let mut saw_section = false;
1201    let mut wrote_command = false;
1202    let mut wrote_args = false;
1203    let mut inserted_parent_before_subtable = false;
1204
1205    let parent_block = format!(
1206        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n\n",
1207        toml_quote(binary)
1208    );
1209
1210    for line in existing.lines() {
1211        let trimmed = line.trim();
1212        if trimmed == "[]" {
1213            continue;
1214        }
1215        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1216            if in_section && !wrote_command {
1217                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1218                wrote_command = true;
1219            }
1220            if in_section && !wrote_args {
1221                out.push_str("args = []\n");
1222                wrote_args = true;
1223            }
1224            in_section = trimmed == "[mcp_servers.lean-ctx]";
1225            if in_section {
1226                saw_section = true;
1227            } else if !saw_section
1228                && !inserted_parent_before_subtable
1229                && trimmed.starts_with("[mcp_servers.lean-ctx.")
1230            {
1231                out.push_str(&parent_block);
1232                inserted_parent_before_subtable = true;
1233            }
1234            out.push_str(line);
1235            out.push('\n');
1236            continue;
1237        }
1238
1239        if in_section {
1240            if trimmed.starts_with("command") && trimmed.contains('=') {
1241                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1242                wrote_command = true;
1243                continue;
1244            }
1245            if trimmed.starts_with("args") && trimmed.contains('=') {
1246                out.push_str("args = []\n");
1247                wrote_args = true;
1248                continue;
1249            }
1250        }
1251
1252        out.push_str(line);
1253        out.push('\n');
1254    }
1255
1256    if saw_section {
1257        if in_section && !wrote_command {
1258            out.push_str(&format!("command = {}\n", toml_quote(binary)));
1259        }
1260        if in_section && !wrote_args {
1261            out.push_str("args = []\n");
1262        }
1263        return out;
1264    }
1265
1266    if inserted_parent_before_subtable {
1267        return out;
1268    }
1269
1270    if !out.ends_with('\n') {
1271        out.push('\n');
1272    }
1273    out.push_str("\n[mcp_servers.lean-ctx]\n");
1274    out.push_str(&format!("command = {}\n", toml_quote(binary)));
1275    out.push_str("args = []\n");
1276    out
1277}
1278
1279fn write_gemini_settings(
1280    target: &EditorTarget,
1281    binary: &str,
1282    opts: WriteOptions,
1283) -> Result<WriteResult, String> {
1284    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1285        .map(|d| d.to_string_lossy().to_string())
1286        .unwrap_or_default();
1287    let entry = serde_json::json!({
1288        "command": binary,
1289        "env": { "LEAN_CTX_DATA_DIR": data_dir },
1290        "trust": true,
1291    });
1292
1293    if target.config_path.exists() {
1294        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1295        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1296            Ok(v) => v,
1297            Err(e) => {
1298                if !opts.overwrite_invalid {
1299                    return Err(e.to_string());
1300                }
1301                backup_invalid_file(&target.config_path)?;
1302                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1303                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1304                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1305                return Ok(WriteResult {
1306                    action: WriteAction::Updated,
1307                    note: Some("overwrote invalid JSON".to_string()),
1308                });
1309            }
1310        };
1311        let obj = json
1312            .as_object_mut()
1313            .ok_or_else(|| "root JSON must be an object".to_string())?;
1314        let servers = obj
1315            .entry("mcpServers")
1316            .or_insert_with(|| serde_json::json!({}));
1317        let servers_obj = servers
1318            .as_object_mut()
1319            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1320
1321        let existing = servers_obj.get("lean-ctx").cloned();
1322        if existing.as_ref() == Some(&entry) {
1323            return Ok(WriteResult {
1324                action: WriteAction::Already,
1325                note: None,
1326            });
1327        }
1328        servers_obj.insert("lean-ctx".to_string(), entry);
1329
1330        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1331        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1332        return Ok(WriteResult {
1333            action: WriteAction::Updated,
1334            note: None,
1335        });
1336    }
1337
1338    let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1339    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1340    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1341    Ok(WriteResult {
1342        action: WriteAction::Created,
1343        note: None,
1344    })
1345}
1346
1347fn write_hermes_yaml(
1348    target: &EditorTarget,
1349    binary: &str,
1350    _opts: WriteOptions,
1351) -> Result<WriteResult, String> {
1352    let data_dir = default_data_dir()?;
1353
1354    let lean_ctx_block = format!(
1355        "  lean-ctx:\n    command: \"{binary}\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\""
1356    );
1357
1358    if target.config_path.exists() {
1359        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1360
1361        if content.contains("lean-ctx") {
1362            return Ok(WriteResult {
1363                action: WriteAction::Already,
1364                note: None,
1365            });
1366        }
1367
1368        let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1369        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1370        return Ok(WriteResult {
1371            action: WriteAction::Updated,
1372            note: None,
1373        });
1374    }
1375
1376    let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1377    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1378    Ok(WriteResult {
1379        action: WriteAction::Created,
1380        note: None,
1381    })
1382}
1383
1384fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1385    let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1386    let mut in_mcp_section = false;
1387    let mut saw_mcp_child = false;
1388    let mut inserted = false;
1389    let lines: Vec<&str> = existing.lines().collect();
1390
1391    for line in &lines {
1392        if !inserted && line.trim_end() == "mcp_servers:" {
1393            in_mcp_section = true;
1394            out.push_str(line);
1395            out.push('\n');
1396            continue;
1397        }
1398
1399        if in_mcp_section && !inserted {
1400            let is_child = line.starts_with("  ") && !line.trim().is_empty();
1401            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1402
1403            if is_child {
1404                saw_mcp_child = true;
1405                out.push_str(line);
1406                out.push('\n');
1407                continue;
1408            }
1409
1410            if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1411                out.push_str(lean_ctx_block);
1412                out.push('\n');
1413                inserted = true;
1414                in_mcp_section = false;
1415            }
1416        }
1417
1418        out.push_str(line);
1419        out.push('\n');
1420    }
1421
1422    if in_mcp_section && !inserted {
1423        out.push_str(lean_ctx_block);
1424        out.push('\n');
1425        inserted = true;
1426    }
1427
1428    if !inserted {
1429        if !out.ends_with('\n') {
1430            out.push('\n');
1431        }
1432        out.push_str("\nmcp_servers:\n");
1433        out.push_str(lean_ctx_block);
1434        out.push('\n');
1435    }
1436
1437    out
1438}
1439
1440fn write_qoder_settings(
1441    target: &EditorTarget,
1442    binary: &str,
1443    opts: WriteOptions,
1444) -> Result<WriteResult, String> {
1445    let data_dir = default_data_dir()?;
1446    let desired = serde_json::json!({
1447        "command": binary,
1448        "args": [],
1449        "env": {
1450            "LEAN_CTX_DATA_DIR": data_dir,
1451            "LEAN_CTX_FULL_TOOLS": "1"
1452        }
1453    });
1454
1455    if target.config_path.exists() {
1456        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1457        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1458            Ok(v) => v,
1459            Err(e) => {
1460                if !opts.overwrite_invalid {
1461                    return Err(e.to_string());
1462                }
1463                backup_invalid_file(&target.config_path)?;
1464                return write_mcp_json_fresh(
1465                    &target.config_path,
1466                    &desired,
1467                    Some("overwrote invalid JSON".to_string()),
1468                );
1469            }
1470        };
1471        let obj = json
1472            .as_object_mut()
1473            .ok_or_else(|| "root JSON must be an object".to_string())?;
1474        let servers = obj
1475            .entry("mcpServers")
1476            .or_insert_with(|| serde_json::json!({}));
1477        let servers_obj = servers
1478            .as_object_mut()
1479            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1480
1481        let existing = servers_obj.get("lean-ctx").cloned();
1482        if existing.as_ref() == Some(&desired) {
1483            return Ok(WriteResult {
1484                action: WriteAction::Already,
1485                note: None,
1486            });
1487        }
1488        servers_obj.insert("lean-ctx".to_string(), desired);
1489
1490        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1491        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1492        return Ok(WriteResult {
1493            action: WriteAction::Updated,
1494            note: None,
1495        });
1496    }
1497
1498    write_mcp_json_fresh(&target.config_path, &desired, None)
1499}
1500
1501fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
1502    if !path.exists() {
1503        return Ok(());
1504    }
1505    let parent = path
1506        .parent()
1507        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1508    let filename = path
1509        .file_name()
1510        .ok_or_else(|| "invalid path (no filename)".to_string())?
1511        .to_string_lossy();
1512    let pid = std::process::id();
1513    let nanos = std::time::SystemTime::now()
1514        .duration_since(std::time::UNIX_EPOCH)
1515        .map_or(0, |d| d.as_nanos());
1516    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1517    std::fs::rename(path, bak).map_err(|e| e.to_string())?;
1518    Ok(())
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523    use super::*;
1524    use std::path::PathBuf;
1525
1526    fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1527        EditorTarget {
1528            name,
1529            agent_key: "test".to_string(),
1530            config_path: path,
1531            detect_path: PathBuf::from("/nonexistent"),
1532            config_type: ty,
1533        }
1534    }
1535
1536    #[test]
1537    fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1538        let dir = tempfile::tempdir().unwrap();
1539        let path = dir.path().join("mcp.json");
1540        std::fs::write(
1541            &path,
1542            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1543        )
1544        .unwrap();
1545
1546        let t = target("test", path.clone(), ConfigType::McpJson);
1547        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1548        assert_eq!(res.action, WriteAction::Updated);
1549
1550        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1551        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1552        assert_eq!(
1553            json["mcpServers"]["lean-ctx"]["command"],
1554            "/new/path/lean-ctx"
1555        );
1556        assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1557    }
1558
1559    #[test]
1560    fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1561        let dir = tempfile::tempdir().unwrap();
1562        let path = dir.path().join("mcp.json");
1563        std::fs::write(
1564            &path,
1565            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1566        )
1567        .unwrap();
1568
1569        let t = target("Cursor", path.clone(), ConfigType::McpJson);
1570        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1571        assert_eq!(res.action, WriteAction::Updated);
1572
1573        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1574        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1575        assert_eq!(
1576            json["mcpServers"]["lean-ctx"]["command"],
1577            "/new/path/lean-ctx"
1578        );
1579        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1580        assert!(
1581            json["mcpServers"]["lean-ctx"]["autoApprove"]
1582                .as_array()
1583                .unwrap()
1584                .len()
1585                > 5
1586        );
1587    }
1588
1589    #[test]
1590    fn crush_config_writes_mcp_root() {
1591        let dir = tempfile::tempdir().unwrap();
1592        let path = dir.path().join("crush.json");
1593        std::fs::write(
1594            &path,
1595            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1596        )
1597        .unwrap();
1598
1599        let t = target("test", path.clone(), ConfigType::Crush);
1600        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1601        assert_eq!(res.action, WriteAction::Updated);
1602
1603        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1604        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1605        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1606    }
1607
1608    #[test]
1609    fn codex_toml_upserts_existing_section() {
1610        let dir = tempfile::tempdir().unwrap();
1611        let path = dir.path().join("config.toml");
1612        std::fs::write(
1613            &path,
1614            r#"[mcp_servers.lean-ctx]
1615command = "old"
1616args = ["x"]
1617"#,
1618        )
1619        .unwrap();
1620
1621        let t = target("test", path.clone(), ConfigType::Codex);
1622        let res = write_codex_config(&t, "new").unwrap();
1623        assert_eq!(res.action, WriteAction::Updated);
1624
1625        let content = std::fs::read_to_string(&path).unwrap();
1626        assert!(content.contains(r#"command = "new""#));
1627        assert!(content.contains("args = []"));
1628    }
1629
1630    #[test]
1631    fn upsert_codex_toml_inserts_new_section_when_missing() {
1632        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1633        assert!(updated.contains("[mcp_servers.lean-ctx]"));
1634        assert!(updated.contains("command = \"lean-ctx\""));
1635        assert!(updated.contains("args = []"));
1636    }
1637
1638    #[test]
1639    fn codex_toml_uses_single_quotes_for_backslash_paths() {
1640        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1641        let updated = upsert_codex_toml("", win_path);
1642        assert!(
1643            updated.contains(&format!("command = '{win_path}'")),
1644            "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1645        );
1646    }
1647
1648    #[test]
1649    fn codex_toml_uses_double_quotes_for_unix_paths() {
1650        let unix_path = "/usr/local/bin/lean-ctx";
1651        let updated = upsert_codex_toml("", unix_path);
1652        assert!(
1653            updated.contains(&format!("command = \"{unix_path}\"")),
1654            "Unix paths should use double quotes: {updated}"
1655        );
1656    }
1657
1658    #[test]
1659    fn upsert_codex_toml_inserts_parent_before_orphaned_tool_subtables() {
1660        let input = "\
1661[mcp_servers.lean-ctx.tools.ctx_multi_read]
1662approval_mode = \"approve\"
1663
1664[mcp_servers.lean-ctx.tools.ctx_read]
1665approval_mode = \"approve\"
1666";
1667        let updated = upsert_codex_toml(input, "lean-ctx");
1668        let parent_pos = updated
1669            .find("[mcp_servers.lean-ctx]\n")
1670            .expect("parent section must be inserted");
1671        let tools_pos = updated
1672            .find("[mcp_servers.lean-ctx.tools.")
1673            .expect("tool sub-tables must be preserved");
1674        assert!(
1675            parent_pos < tools_pos,
1676            "parent must come before tool sub-tables:\n{updated}"
1677        );
1678        assert!(updated.contains("command = \"lean-ctx\""));
1679        assert!(updated.contains("args = []"));
1680        assert!(updated.contains("approval_mode = \"approve\""));
1681    }
1682
1683    #[test]
1684    fn upsert_codex_toml_handles_issue_191_windows_scenario() {
1685        let input = "\
1686[mcp_servers.lean-ctx.tools.ctx_multi_read]
1687approval_mode = \"approve\"
1688
1689[mcp_servers.lean-ctx.tools.ctx_read]
1690approval_mode = \"approve\"
1691
1692[mcp_servers.lean-ctx.tools.ctx_search]
1693approval_mode = \"approve\"
1694
1695[mcp_servers.lean-ctx.tools.ctx_tree]
1696approval_mode = \"approve\"
1697";
1698        let win_path = r"C:\Users\wudon\AppData\Roaming\npm\lean-ctx.cmd";
1699        let updated = upsert_codex_toml(input, win_path);
1700        assert!(
1701            updated.contains(&format!("command = '{win_path}'")),
1702            "Windows path must use single quotes: {updated}"
1703        );
1704        let parent_pos = updated.find("[mcp_servers.lean-ctx]\n").unwrap();
1705        let first_tool = updated.find("[mcp_servers.lean-ctx.tools.").unwrap();
1706        assert!(parent_pos < first_tool);
1707        assert_eq!(
1708            updated.matches("[mcp_servers.lean-ctx]\n").count(),
1709            1,
1710            "parent section must appear exactly once"
1711        );
1712    }
1713
1714    #[test]
1715    fn upsert_codex_toml_does_not_duplicate_parent_when_present() {
1716        let input = "\
1717[mcp_servers.lean-ctx]
1718command = \"old\"
1719args = [\"x\"]
1720
1721[mcp_servers.lean-ctx.tools.ctx_read]
1722approval_mode = \"approve\"
1723";
1724        let updated = upsert_codex_toml(input, "new");
1725        assert_eq!(
1726            updated.matches("[mcp_servers.lean-ctx]").count(),
1727            1,
1728            "must not duplicate parent section"
1729        );
1730        assert!(updated.contains("command = \"new\""));
1731        assert!(updated.contains("args = []"));
1732        assert!(updated.contains("approval_mode = \"approve\""));
1733    }
1734
1735    #[test]
1736    fn auto_approve_contains_core_tools() {
1737        let tools = auto_approve_tools();
1738        assert!(tools.contains(&"ctx_read"));
1739        assert!(tools.contains(&"ctx_shell"));
1740        assert!(tools.contains(&"ctx_search"));
1741        assert!(tools.contains(&"ctx_workflow"));
1742        assert!(tools.contains(&"ctx_cost"));
1743    }
1744
1745    #[test]
1746    fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
1747        let dir = tempfile::tempdir().unwrap();
1748        let path = dir.path().join("mcp.json");
1749        std::fs::write(
1750            &path,
1751            r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
1752        )
1753        .unwrap();
1754
1755        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1756        let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1757        assert_eq!(res.action, WriteAction::Updated);
1758
1759        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1760        assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
1761        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1762        assert_eq!(
1763            json["mcpServers"]["lean-ctx"]["args"],
1764            serde_json::json!([])
1765        );
1766        assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
1767            .as_str()
1768            .is_some_and(|s| !s.trim().is_empty()));
1769        assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
1770        assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
1771        assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
1772    }
1773
1774    #[test]
1775    fn qoder_mcp_config_is_idempotent() {
1776        let dir = tempfile::tempdir().unwrap();
1777        let path = dir.path().join("mcp.json");
1778        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1779
1780        let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1781        let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1782
1783        assert_eq!(first.action, WriteAction::Created);
1784        assert_eq!(second.action, WriteAction::Already);
1785    }
1786
1787    #[test]
1788    fn qoder_mcp_config_creates_missing_parent_directories() {
1789        let dir = tempfile::tempdir().unwrap();
1790        let path = dir
1791            .path()
1792            .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
1793        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1794
1795        let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
1796
1797        assert_eq!(res.action, WriteAction::Created);
1798        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1799        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1800    }
1801
1802    #[test]
1803    fn antigravity_config_omits_auto_approve() {
1804        let dir = tempfile::tempdir().unwrap();
1805        let path = dir.path().join("mcp_config.json");
1806
1807        let t = EditorTarget {
1808            name: "Antigravity",
1809            agent_key: "gemini".to_string(),
1810            config_path: path.clone(),
1811            detect_path: PathBuf::from("/nonexistent"),
1812            config_type: ConfigType::McpJson,
1813        };
1814        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1815        assert_eq!(res.action, WriteAction::Created);
1816
1817        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1818        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1819        assert_eq!(
1820            json["mcpServers"]["lean-ctx"]["command"],
1821            "/usr/local/bin/lean-ctx"
1822        );
1823    }
1824
1825    #[test]
1826    fn hermes_yaml_inserts_into_existing_mcp_servers() {
1827        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";
1828        let block = "  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1829        let result = upsert_hermes_yaml_mcp(existing, block);
1830        assert!(result.contains("lean-ctx"));
1831        assert!(result.contains("model: anthropic/claude-sonnet-4"));
1832        assert!(result.contains("tool_allowlist:"));
1833        assert!(result.contains("github:"));
1834    }
1835
1836    #[test]
1837    fn hermes_yaml_creates_mcp_servers_section() {
1838        let existing = "model: openai/gpt-4o\n";
1839        let block = "  lean-ctx:\n    command: \"lean-ctx\"";
1840        let result = upsert_hermes_yaml_mcp(existing, block);
1841        assert!(result.contains("mcp_servers:"));
1842        assert!(result.contains("lean-ctx"));
1843        assert!(result.contains("model: openai/gpt-4o"));
1844    }
1845
1846    #[test]
1847    fn hermes_yaml_skips_if_already_present() {
1848        let dir = tempfile::tempdir().unwrap();
1849        let path = dir.path().join("config.yaml");
1850        std::fs::write(
1851            &path,
1852            "mcp_servers:\n  lean-ctx:\n    command: \"lean-ctx\"\n",
1853        )
1854        .unwrap();
1855        let t = target("test", path.clone(), ConfigType::HermesYaml);
1856        let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1857        assert_eq!(res.action, WriteAction::Already);
1858    }
1859
1860    #[test]
1861    fn remove_codex_section_also_removes_env_subtable() {
1862        let input = "\
1863[other]
1864x = 1
1865
1866[mcp_servers.lean-ctx]
1867args = []
1868command = \"/usr/local/bin/lean-ctx\"
1869
1870[mcp_servers.lean-ctx.env]
1871LEAN_CTX_DATA_DIR = \"/home/user/.lean-ctx\"
1872
1873[features]
1874codex_hooks = true
1875";
1876        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1877        assert!(
1878            !result.contains("[mcp_servers.lean-ctx]"),
1879            "parent section must be removed"
1880        );
1881        assert!(
1882            !result.contains("LEAN_CTX_DATA_DIR"),
1883            "env sub-table must be removed too"
1884        );
1885        assert!(result.contains("[other]"), "unrelated sections preserved");
1886        assert!(
1887            result.contains("[features]"),
1888            "sections after must be preserved"
1889        );
1890    }
1891
1892    #[test]
1893    fn remove_codex_section_preserves_other_mcp_servers() {
1894        let input = "\
1895[mcp_servers.lean-ctx]
1896command = \"lean-ctx\"
1897
1898[mcp_servers.lean-ctx.env]
1899X = \"1\"
1900
1901[mcp_servers.other]
1902command = \"other\"
1903";
1904        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1905        assert!(!result.contains("[mcp_servers.lean-ctx]"));
1906        assert!(
1907            result.contains("[mcp_servers.other]"),
1908            "other MCP servers must be preserved"
1909        );
1910        assert!(result.contains("command = \"other\""));
1911    }
1912
1913    #[test]
1914    fn remove_codex_section_does_not_remove_similarly_named_server() {
1915        let input = "\
1916[mcp_servers.lean-ctx]
1917command = \"lean-ctx\"
1918
1919[mcp_servers.lean-ctx-probe]
1920command = \"probe\"
1921";
1922        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1923        assert!(
1924            !result.contains("[mcp_servers.lean-ctx]\n"),
1925            "target section must be removed"
1926        );
1927        assert!(
1928            result.contains("[mcp_servers.lean-ctx-probe]"),
1929            "similarly-named server must NOT be removed"
1930        );
1931        assert!(result.contains("command = \"probe\""));
1932    }
1933
1934    #[test]
1935    fn remove_codex_section_handles_no_match() {
1936        let input = "[other]\nx = 1\n";
1937        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1938        assert_eq!(result, "[other]\nx = 1\n");
1939    }
1940}