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::CopilotCli => write_copilot_cli(target, binary, opts),
50        ConfigType::OpenCode => write_opencode_config(target, binary, opts),
51        ConfigType::Crush => write_crush_config(target, binary, opts),
52        ConfigType::JetBrains => write_jetbrains_config(target, binary, opts),
53        ConfigType::Amp => write_amp_config(target, binary, opts),
54        ConfigType::HermesYaml => write_hermes_yaml(target, binary, opts),
55        ConfigType::GeminiSettings => write_gemini_settings(target, binary, opts),
56        ConfigType::QoderSettings => write_qoder_settings(target, binary, opts),
57        ConfigType::AugmentVsCode => write_augment_vscode(target, binary, opts),
58    }
59}
60
61pub fn remove_lean_ctx_mcp_server(
62    path: &std::path::Path,
63    opts: WriteOptions,
64) -> Result<WriteResult, String> {
65    if !path.exists() {
66        return Ok(WriteResult {
67            action: WriteAction::Already,
68            note: Some("mcp.json not found".to_string()),
69        });
70    }
71
72    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
73    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
74        Ok(v) => v,
75        Err(e) => {
76            if !opts.overwrite_invalid {
77                return Err(e.to_string());
78            }
79            eprintln!(
80                "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors — skipping removal.",
81                path.display()
82            );
83            return Ok(WriteResult {
84                action: WriteAction::Already,
85                note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
86            });
87        }
88    };
89
90    let obj = json
91        .as_object_mut()
92        .ok_or_else(|| "root JSON must be an object".to_string())?;
93
94    let Some(servers) = obj.get_mut("mcpServers") else {
95        return Ok(WriteResult {
96            action: WriteAction::Already,
97            note: Some("no mcpServers key".to_string()),
98        });
99    };
100    let servers_obj = servers
101        .as_object_mut()
102        .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
103
104    if servers_obj.remove("lean-ctx").is_none() {
105        return Ok(WriteResult {
106            action: WriteAction::Already,
107            note: Some("lean-ctx not configured".to_string()),
108        });
109    }
110
111    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
112    crate::config_io::write_atomic_with_backup(path, &formatted)?;
113    Ok(WriteResult {
114        action: WriteAction::Updated,
115        note: Some("removed lean-ctx from mcpServers".to_string()),
116    })
117}
118
119pub fn remove_lean_ctx_server(
120    target: &EditorTarget,
121    opts: WriteOptions,
122) -> Result<WriteResult, String> {
123    match target.config_type {
124        ConfigType::McpJson
125        | ConfigType::JetBrains
126        | ConfigType::GeminiSettings
127        | ConfigType::QoderSettings => remove_lean_ctx_mcp_server(&target.config_path, opts),
128        ConfigType::VsCodeMcp | ConfigType::CopilotCli => {
129            remove_lean_ctx_vscode_server(&target.config_path, opts)
130        }
131        ConfigType::Codex => remove_lean_ctx_codex_server(&target.config_path),
132        ConfigType::OpenCode | ConfigType::Crush => {
133            remove_lean_ctx_named_json_server(&target.config_path, "mcp", opts)
134        }
135        ConfigType::Zed => {
136            remove_lean_ctx_named_json_server(&target.config_path, "context_servers", opts)
137        }
138        ConfigType::Amp => remove_lean_ctx_amp_server(&target.config_path, opts),
139        ConfigType::HermesYaml => remove_lean_ctx_hermes_yaml_server(&target.config_path),
140        ConfigType::AugmentVsCode => {
141            remove_lean_ctx_augment_vscode_server(&target.config_path, opts)
142        }
143    }
144}
145
146fn remove_lean_ctx_vscode_server(
147    path: &std::path::Path,
148    opts: WriteOptions,
149) -> Result<WriteResult, String> {
150    if !path.exists() {
151        return Ok(WriteResult {
152            action: WriteAction::Already,
153            note: Some("vscode mcp.json not found".to_string()),
154        });
155    }
156
157    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
158    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
159        Ok(v) => v,
160        Err(e) => {
161            if !opts.overwrite_invalid {
162                return Err(e.to_string());
163            }
164            eprintln!(
165                "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors — skipping removal.",
166                path.display()
167            );
168            return Ok(WriteResult {
169                action: WriteAction::Already,
170                note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
171            });
172        }
173    };
174
175    let obj = json
176        .as_object_mut()
177        .ok_or_else(|| "root JSON must be an object".to_string())?;
178
179    let Some(servers) = obj.get_mut("servers") else {
180        return Ok(WriteResult {
181            action: WriteAction::Already,
182            note: Some("no servers key".to_string()),
183        });
184    };
185    let servers_obj = servers
186        .as_object_mut()
187        .ok_or_else(|| "\"servers\" must be an object".to_string())?;
188
189    if servers_obj.remove("lean-ctx").is_none() {
190        return Ok(WriteResult {
191            action: WriteAction::Already,
192            note: Some("lean-ctx not configured".to_string()),
193        });
194    }
195
196    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
197    crate::config_io::write_atomic_with_backup(path, &formatted)?;
198    Ok(WriteResult {
199        action: WriteAction::Updated,
200        note: Some("removed lean-ctx from servers".to_string()),
201    })
202}
203
204fn remove_lean_ctx_amp_server(
205    path: &std::path::Path,
206    opts: WriteOptions,
207) -> Result<WriteResult, String> {
208    if !path.exists() {
209        return Ok(WriteResult {
210            action: WriteAction::Already,
211            note: Some("amp settings not found".to_string()),
212        });
213    }
214
215    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
216    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
217        Ok(v) => v,
218        Err(e) => {
219            if !opts.overwrite_invalid {
220                return Err(e.to_string());
221            }
222            eprintln!(
223                "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors — skipping removal.",
224                path.display()
225            );
226            return Ok(WriteResult {
227                action: WriteAction::Already,
228                note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
229            });
230        }
231    };
232
233    let obj = json
234        .as_object_mut()
235        .ok_or_else(|| "root JSON must be an object".to_string())?;
236    let Some(servers) = obj.get_mut("amp.mcpServers") else {
237        return Ok(WriteResult {
238            action: WriteAction::Already,
239            note: Some("no amp.mcpServers key".to_string()),
240        });
241    };
242    let servers_obj = servers
243        .as_object_mut()
244        .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
245
246    if servers_obj.remove("lean-ctx").is_none() {
247        return Ok(WriteResult {
248            action: WriteAction::Already,
249            note: Some("lean-ctx not configured".to_string()),
250        });
251    }
252
253    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
254    crate::config_io::write_atomic_with_backup(path, &formatted)?;
255    Ok(WriteResult {
256        action: WriteAction::Updated,
257        note: Some("removed lean-ctx from amp.mcpServers".to_string()),
258    })
259}
260
261fn remove_lean_ctx_named_json_server(
262    path: &std::path::Path,
263    container_key: &str,
264    opts: WriteOptions,
265) -> Result<WriteResult, String> {
266    if !path.exists() {
267        return Ok(WriteResult {
268            action: WriteAction::Already,
269            note: Some("config not found".to_string()),
270        });
271    }
272
273    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
274    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
275        Ok(v) => v,
276        Err(e) => {
277            if !opts.overwrite_invalid {
278                return Err(e.to_string());
279            }
280            eprintln!(
281                "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors — skipping removal.",
282                path.display()
283            );
284            return Ok(WriteResult {
285                action: WriteAction::Already,
286                note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
287            });
288        }
289    };
290
291    let obj = json
292        .as_object_mut()
293        .ok_or_else(|| "root JSON must be an object".to_string())?;
294    let Some(container) = obj.get_mut(container_key) else {
295        return Ok(WriteResult {
296            action: WriteAction::Already,
297            note: Some(format!("no {container_key} key")),
298        });
299    };
300    let container_obj = container
301        .as_object_mut()
302        .ok_or_else(|| format!("\"{container_key}\" must be an object"))?;
303
304    if container_obj.remove("lean-ctx").is_none() {
305        return Ok(WriteResult {
306            action: WriteAction::Already,
307            note: Some("lean-ctx not configured".to_string()),
308        });
309    }
310
311    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
312    crate::config_io::write_atomic_with_backup(path, &formatted)?;
313    Ok(WriteResult {
314        action: WriteAction::Updated,
315        note: Some(format!("removed lean-ctx from {container_key}")),
316    })
317}
318
319fn remove_lean_ctx_codex_server(path: &std::path::Path) -> Result<WriteResult, String> {
320    if !path.exists() {
321        return Ok(WriteResult {
322            action: WriteAction::Already,
323            note: Some("codex config not found".to_string()),
324        });
325    }
326    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
327    let updated = remove_codex_toml_section(&content, "[mcp_servers.lean-ctx]");
328    if updated == content {
329        return Ok(WriteResult {
330            action: WriteAction::Already,
331            note: Some("lean-ctx not configured".to_string()),
332        });
333    }
334    crate::config_io::write_atomic_with_backup(path, &updated)?;
335    Ok(WriteResult {
336        action: WriteAction::Updated,
337        note: Some("removed [mcp_servers.lean-ctx]".to_string()),
338    })
339}
340
341fn remove_codex_toml_section(existing: &str, header: &str) -> String {
342    let prefix = header.trim_end_matches(']');
343    let mut out = String::with_capacity(existing.len());
344    let mut skipping = false;
345    for line in existing.lines() {
346        let trimmed = line.trim();
347        if trimmed.starts_with('[') && trimmed.ends_with(']') {
348            if trimmed == header || trimmed.starts_with(&format!("{prefix}.")) {
349                skipping = true;
350                continue;
351            }
352            skipping = false;
353        }
354        if skipping {
355            continue;
356        }
357        out.push_str(line);
358        out.push('\n');
359    }
360    out
361}
362
363fn remove_lean_ctx_hermes_yaml_server(path: &std::path::Path) -> Result<WriteResult, String> {
364    if !path.exists() {
365        return Ok(WriteResult {
366            action: WriteAction::Already,
367            note: Some("hermes config not found".to_string()),
368        });
369    }
370    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
371    let updated = remove_hermes_yaml_mcp_server_block(&content, "lean-ctx");
372    if updated == content {
373        return Ok(WriteResult {
374            action: WriteAction::Already,
375            note: Some("lean-ctx not configured".to_string()),
376        });
377    }
378    crate::config_io::write_atomic_with_backup(path, &updated)?;
379    Ok(WriteResult {
380        action: WriteAction::Updated,
381        note: Some("removed lean-ctx from mcp_servers".to_string()),
382    })
383}
384
385fn remove_hermes_yaml_mcp_server_block(existing: &str, name: &str) -> String {
386    let mut out = String::with_capacity(existing.len());
387    let mut in_mcp = false;
388    let mut skipping = false;
389    for line in existing.lines() {
390        let trimmed = line.trim_end();
391        if trimmed == "mcp_servers:" {
392            in_mcp = true;
393            out.push_str(line);
394            out.push('\n');
395            continue;
396        }
397
398        if in_mcp {
399            let is_child = line.starts_with("  ") && !line.starts_with("    ");
400            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
401
402            if is_toplevel {
403                in_mcp = false;
404                skipping = false;
405            }
406
407            if skipping {
408                if is_child || is_toplevel {
409                    skipping = false;
410                    out.push_str(line);
411                    out.push('\n');
412                }
413                continue;
414            }
415
416            if is_child && line.trim() == format!("{name}:") {
417                skipping = true;
418                continue;
419            }
420        }
421
422        out.push_str(line);
423        out.push('\n');
424    }
425    out
426}
427
428pub fn auto_approve_tools() -> Vec<&'static str> {
429    vec![
430        "ctx_read",
431        "ctx_shell",
432        "ctx_search",
433        "ctx_tree",
434        "ctx_overview",
435        "ctx_preload",
436        "ctx_compress",
437        "ctx_metrics",
438        "ctx_session",
439        "ctx_knowledge",
440        "ctx_agent",
441        "ctx_share",
442        "ctx_analyze",
443        "ctx_benchmark",
444        "ctx_cache",
445        "ctx_discover",
446        "ctx_smart_read",
447        "ctx_delta",
448        "ctx_edit",
449        "ctx_dedup",
450        "ctx_fill",
451        "ctx_intent",
452        "ctx_response",
453        "ctx_context",
454        "ctx_graph",
455        "ctx_multi_read",
456        "ctx_semantic_search",
457        "ctx_symbol",
458        "ctx_outline",
459        "ctx_callgraph",
460        "ctx_refactor",
461        "ctx_routes",
462        "ctx_cost",
463        "ctx_heatmap",
464        "ctx_gain",
465        "ctx_expand",
466        "ctx_task",
467        "ctx_impact",
468        "ctx_architecture",
469        "ctx_workflow",
470        "ctx_review",
471        "ctx_pack",
472        "ctx_index",
473        "ctx_artifacts",
474        "ctx_smells",
475        "ctx_proof",
476        "ctx_verify",
477        "ctx_execute",
478        "ctx_handoff",
479        "ctx_feedback",
480        "ctx_control",
481        "ctx_plan",
482        "ctx_compile",
483        "ctx_discover_tools",
484        "ctx_provider",
485        "ctx_radar",
486        "ctx_retrieve",
487        "ctx_compress_memory",
488        "ctx_load_tools",
489        "ctx",
490    ]
491}
492
493fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
494    let mut entry = serde_json::json!({
495        "command": binary,
496        "env": {
497            "LEAN_CTX_DATA_DIR": data_dir
498        }
499    });
500    if include_auto_approve {
501        entry["autoApprove"] = serde_json::json!(auto_approve_tools());
502    }
503    entry
504}
505
506fn lean_ctx_server_entry_with_instructions(
507    binary: &str,
508    data_dir: &str,
509    include_auto_approve: bool,
510    agent_key: &str,
511) -> Value {
512    let mut entry = lean_ctx_server_entry(binary, data_dir, include_auto_approve);
513    let mode = crate::core::rules_canonical::Mode::from_hook_mode(
514        &crate::hooks::recommend_hook_mode(agent_key),
515    );
516    let instructions = crate::core::rules_canonical::mcp_instructions(mode);
517
518    let constraints = crate::core::client_constraints::by_client_id(agent_key);
519    if let Some(max_chars) = constraints.and_then(|c| c.mcp_instructions_max_chars) {
520        let truncated = if instructions.len() > max_chars {
521            &instructions[..max_chars]
522        } else {
523            instructions
524        };
525        entry["instructions"] = serde_json::json!(truncated);
526    }
527    entry
528}
529
530fn supports_auto_approve(target: &EditorTarget) -> bool {
531    crate::core::client_constraints::by_editor_name(target.name)
532        .is_some_and(|c| c.supports_auto_approve)
533}
534
535fn default_data_dir() -> Result<String, String> {
536    Ok(crate::core::data_dir::lean_ctx_data_dir()?
537        .to_string_lossy()
538        .to_string())
539}
540
541fn write_mcp_json(
542    target: &EditorTarget,
543    binary: &str,
544    opts: WriteOptions,
545) -> Result<WriteResult, String> {
546    let data_dir = default_data_dir()?;
547    let include_aa = supports_auto_approve(target);
548    let desired = if target.agent_key.is_empty() {
549        lean_ctx_server_entry(binary, &data_dir, include_aa)
550    } else {
551        lean_ctx_server_entry_with_instructions(binary, &data_dir, include_aa, &target.agent_key)
552    };
553
554    // Claude Code manages ~/.claude.json and may overwrite it on first start.
555    // Prefer the official CLI integration when available.
556    // Skip when LEAN_CTX_QUIET=1 (bootstrap --json / setup --json) to avoid
557    // spawning `claude mcp add-json` which can stall in non-interactive CI.
558    if (target.agent_key == "claude" || target.name == "Claude Code")
559        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
560    {
561        if let Ok(result) = try_claude_mcp_add(&desired) {
562            return Ok(result);
563        }
564    }
565
566    if target.config_path.exists() {
567        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
568        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
569            Ok(v) => v,
570            Err(_e) => {
571                return handle_invalid_json_write(
572                    &target.config_path,
573                    &content,
574                    "mcpServers",
575                    "lean-ctx",
576                    &desired,
577                    opts.overwrite_invalid,
578                );
579            }
580        };
581        let obj = json
582            .as_object_mut()
583            .ok_or_else(|| "root JSON must be an object".to_string())?;
584
585        let servers = obj
586            .entry("mcpServers")
587            .or_insert_with(|| serde_json::json!({}));
588        let servers_obj = servers
589            .as_object_mut()
590            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
591
592        let existing = servers_obj.get("lean-ctx").cloned();
593        if existing.as_ref() == Some(&desired) {
594            return Ok(WriteResult {
595                action: WriteAction::Already,
596                note: None,
597            });
598        }
599        servers_obj.insert("lean-ctx".to_string(), desired);
600
601        let formatted = serde_json::to_string_pretty(&json).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: None,
606        });
607    }
608
609    write_mcp_json_fresh(&target.config_path, &desired, None)
610}
611
612fn find_in_path(binary: &str) -> Option<std::path::PathBuf> {
613    let path_var = std::env::var("PATH").ok()?;
614    for dir in std::env::split_paths(&path_var) {
615        let candidate = dir.join(binary);
616        if candidate.is_file() {
617            return Some(candidate);
618        }
619    }
620    None
621}
622
623fn validate_claude_binary() -> Result<std::path::PathBuf, String> {
624    let path = find_in_path("claude").ok_or("claude binary not found in PATH")?;
625
626    let canonical =
627        std::fs::canonicalize(&path).map_err(|e| format!("cannot resolve claude path: {e}"))?;
628
629    let canonical_str = canonical.to_string_lossy();
630    let is_trusted = canonical_str.contains("/.claude/")
631        || canonical_str.contains("\\AppData\\")
632        || canonical_str.contains("/usr/local/bin/")
633        || canonical_str.contains("/opt/homebrew/")
634        || canonical_str.contains("/nix/store/")
635        || canonical_str.contains("/.npm/")
636        || canonical_str.contains("/.nvm/")
637        || canonical_str.contains("/node_modules/.bin/")
638        || std::env::var("LEAN_CTX_TRUST_CLAUDE_PATH").is_ok();
639
640    if !is_trusted {
641        return Err(format!(
642            "claude binary resolved to untrusted path: {canonical_str} — set LEAN_CTX_TRUST_CLAUDE_PATH=1 to override"
643        ));
644    }
645    Ok(canonical)
646}
647
648fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
649    use std::io::Write;
650    use std::process::{Command, Stdio};
651    use std::time::{Duration, Instant};
652
653    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
654
655    let mut cmd = if cfg!(windows) {
656        let mut c = Command::new("cmd");
657        c.args([
658            "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
659        ]);
660        c
661    } else {
662        let claude_path = validate_claude_binary()?;
663        let mut c = Command::new(claude_path);
664        c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
665        c
666    };
667
668    let mut child = cmd
669        .stdin(Stdio::piped())
670        .stdout(Stdio::null())
671        .stderr(Stdio::null())
672        .spawn()
673        .map_err(|e| e.to_string())?;
674
675    if let Some(mut stdin) = child.stdin.take() {
676        let _ = stdin.write_all(server_json.as_bytes());
677    }
678
679    let deadline = Duration::from_secs(3);
680    let start = Instant::now();
681    loop {
682        match child.try_wait() {
683            Ok(Some(status)) => {
684                return if status.success() {
685                    Ok(WriteResult {
686                        action: WriteAction::Updated,
687                        note: Some("via claude mcp add-json".to_string()),
688                    })
689                } else {
690                    Err("claude mcp add-json failed".to_string())
691                };
692            }
693            Ok(None) => {
694                if start.elapsed() > deadline {
695                    let _ = child.kill();
696                    let _ = child.wait();
697                    return Err("claude mcp add-json timed out".to_string());
698                }
699                std::thread::sleep(Duration::from_millis(20));
700            }
701            Err(e) => return Err(e.to_string()),
702        }
703    }
704}
705
706fn write_mcp_json_fresh(
707    path: &std::path::Path,
708    desired: &Value,
709    note: Option<String>,
710) -> Result<WriteResult, String> {
711    let content = serde_json::to_string_pretty(&serde_json::json!({
712        "mcpServers": { "lean-ctx": desired }
713    }))
714    .map_err(|e| e.to_string())?;
715    crate::config_io::write_atomic_with_backup(path, &content)?;
716    Ok(WriteResult {
717        action: if note.is_some() {
718            WriteAction::Updated
719        } else {
720            WriteAction::Created
721        },
722        note,
723    })
724}
725
726fn write_zed_config(
727    target: &EditorTarget,
728    binary: &str,
729    opts: WriteOptions,
730) -> Result<WriteResult, String> {
731    let desired = serde_json::json!({
732        "command": binary,
733        "args": [],
734        "env": {}
735    });
736
737    if target.config_path.exists() {
738        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
739        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
740            Ok(v) => v,
741            Err(_e) => {
742                return handle_invalid_json_write(
743                    &target.config_path,
744                    &content,
745                    "context_servers",
746                    "lean-ctx",
747                    &desired,
748                    opts.overwrite_invalid,
749                );
750            }
751        };
752        let obj = json
753            .as_object_mut()
754            .ok_or_else(|| "root JSON must be an object".to_string())?;
755
756        let servers = obj
757            .entry("context_servers")
758            .or_insert_with(|| serde_json::json!({}));
759        let servers_obj = servers
760            .as_object_mut()
761            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
762
763        let existing = servers_obj.get("lean-ctx").cloned();
764        if existing.as_ref() == Some(&desired) {
765            return Ok(WriteResult {
766                action: WriteAction::Already,
767                note: None,
768            });
769        }
770        servers_obj.insert("lean-ctx".to_string(), desired);
771
772        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
773        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
774        return Ok(WriteResult {
775            action: WriteAction::Updated,
776            note: None,
777        });
778    }
779
780    write_zed_config_fresh(&target.config_path, &desired, None)
781}
782
783fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
784    if target.config_path.exists() {
785        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
786        let updated = upsert_codex_toml(&content, binary);
787        if updated == content {
788            return Ok(WriteResult {
789                action: WriteAction::Already,
790                note: None,
791            });
792        }
793        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
794        return Ok(WriteResult {
795            action: WriteAction::Updated,
796            note: None,
797        });
798    }
799
800    let content = format!(
801        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
802        toml_quote(binary)
803    );
804    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
805    Ok(WriteResult {
806        action: WriteAction::Created,
807        note: None,
808    })
809}
810
811fn write_zed_config_fresh(
812    path: &std::path::Path,
813    desired: &Value,
814    note: Option<String>,
815) -> Result<WriteResult, String> {
816    let content = serde_json::to_string_pretty(&serde_json::json!({
817        "context_servers": { "lean-ctx": desired }
818    }))
819    .map_err(|e| e.to_string())?;
820    crate::config_io::write_atomic_with_backup(path, &content)?;
821    Ok(WriteResult {
822        action: if note.is_some() {
823            WriteAction::Updated
824        } else {
825            WriteAction::Created
826        },
827        note,
828    })
829}
830
831fn write_vscode_mcp(
832    target: &EditorTarget,
833    binary: &str,
834    opts: WriteOptions,
835) -> Result<WriteResult, String> {
836    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
837        .map(|d| d.to_string_lossy().to_string())
838        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
839    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
840
841    if target.config_path.exists() {
842        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
843        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
844            Ok(v) => v,
845            Err(_e) => {
846                return handle_invalid_json_write(
847                    &target.config_path,
848                    &content,
849                    "servers",
850                    "lean-ctx",
851                    &desired,
852                    opts.overwrite_invalid,
853                );
854            }
855        };
856        let obj = json
857            .as_object_mut()
858            .ok_or_else(|| "root JSON must be an object".to_string())?;
859
860        let servers = obj
861            .entry("servers")
862            .or_insert_with(|| serde_json::json!({}));
863        let servers_obj = servers
864            .as_object_mut()
865            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
866
867        let existing = servers_obj.get("lean-ctx").cloned();
868        if existing.as_ref() == Some(&desired) {
869            return Ok(WriteResult {
870                action: WriteAction::Already,
871                note: None,
872            });
873        }
874        servers_obj.insert("lean-ctx".to_string(), desired);
875
876        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
877        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
878        return Ok(WriteResult {
879            action: WriteAction::Updated,
880            note: None,
881        });
882    }
883
884    write_vscode_mcp_fresh(&target.config_path, binary, None)
885}
886
887fn write_vscode_mcp_fresh(
888    path: &std::path::Path,
889    binary: &str,
890    note: Option<String>,
891) -> Result<WriteResult, String> {
892    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
893        .map(|d| d.to_string_lossy().to_string())
894        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
895    let content = serde_json::to_string_pretty(&serde_json::json!({
896        "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
897    }))
898    .map_err(|e| e.to_string())?;
899    crate::config_io::write_atomic_with_backup(path, &content)?;
900    Ok(WriteResult {
901        action: if note.is_some() {
902            WriteAction::Updated
903        } else {
904            WriteAction::Created
905        },
906        note,
907    })
908}
909
910fn write_copilot_cli(
911    target: &EditorTarget,
912    binary: &str,
913    opts: WriteOptions,
914) -> Result<WriteResult, String> {
915    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
916        .map(|d| d.to_string_lossy().to_string())
917        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
918    let desired = serde_json::json!({
919        "type": "local",
920        "command": binary,
921        "args": ["mcp"],
922        "env": { "LEAN_CTX_DATA_DIR": data_dir },
923        "tools": ["*"]
924    });
925
926    if target.config_path.exists() {
927        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
928        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
929            Ok(v) => v,
930            Err(_e) => {
931                return handle_invalid_json_write(
932                    &target.config_path,
933                    &content,
934                    "mcpServers",
935                    "lean-ctx",
936                    &desired,
937                    opts.overwrite_invalid,
938                );
939            }
940        };
941        let obj = json
942            .as_object_mut()
943            .ok_or_else(|| "root JSON must be an object".to_string())?;
944
945        let servers = obj
946            .entry("mcpServers")
947            .or_insert_with(|| serde_json::json!({}));
948        let servers_obj = servers
949            .as_object_mut()
950            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
951
952        let existing = servers_obj.get("lean-ctx").cloned();
953        if existing.as_ref() == Some(&desired) {
954            return Ok(WriteResult {
955                action: WriteAction::Already,
956                note: None,
957            });
958        }
959
960        servers_obj.insert("lean-ctx".to_string(), desired);
961        let out = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
962        crate::config_io::write_atomic_with_backup(&target.config_path, &out)?;
963        return Ok(WriteResult {
964            action: WriteAction::Updated,
965            note: None,
966        });
967    }
968
969    // Fresh write
970    if let Some(parent) = target.config_path.parent() {
971        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
972    }
973    let content = serde_json::to_string_pretty(&serde_json::json!({
974        "mcpServers": {
975            "lean-ctx": {
976                "type": "local",
977                "command": binary,
978                "args": ["mcp"],
979                "env": { "LEAN_CTX_DATA_DIR": data_dir },
980                "tools": ["*"]
981            }
982        }
983    }))
984    .map_err(|e| e.to_string())?;
985    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
986    Ok(WriteResult {
987        action: WriteAction::Created,
988        note: None,
989    })
990}
991
992fn write_opencode_config(
993    target: &EditorTarget,
994    binary: &str,
995    opts: WriteOptions,
996) -> Result<WriteResult, String> {
997    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
998        .map(|d| d.to_string_lossy().to_string())
999        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1000    let desired = serde_json::json!({
1001        "type": "local",
1002        "command": [binary],
1003        "enabled": true,
1004        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
1005    });
1006
1007    if target.config_path.exists() {
1008        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1009        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1010            Ok(v) => v,
1011            Err(_e) => {
1012                return handle_invalid_json_write(
1013                    &target.config_path,
1014                    &content,
1015                    "mcp",
1016                    "lean-ctx",
1017                    &desired,
1018                    opts.overwrite_invalid,
1019                );
1020            }
1021        };
1022        let obj = json
1023            .as_object_mut()
1024            .ok_or_else(|| "root JSON must be an object".to_string())?;
1025        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1026        let mcp_obj = mcp
1027            .as_object_mut()
1028            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1029
1030        let existing = mcp_obj.get("lean-ctx").cloned();
1031        if existing.as_ref() == Some(&desired) {
1032            return Ok(WriteResult {
1033                action: WriteAction::Already,
1034                note: None,
1035            });
1036        }
1037        mcp_obj.insert("lean-ctx".to_string(), desired);
1038
1039        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1040        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1041        return Ok(WriteResult {
1042            action: WriteAction::Updated,
1043            note: None,
1044        });
1045    }
1046
1047    write_opencode_fresh(&target.config_path, binary, None)
1048}
1049
1050fn write_opencode_fresh(
1051    path: &std::path::Path,
1052    binary: &str,
1053    note: Option<String>,
1054) -> Result<WriteResult, String> {
1055    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1056        .map(|d| d.to_string_lossy().to_string())
1057        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1058    let content = serde_json::to_string_pretty(&serde_json::json!({
1059        "$schema": "https://opencode.ai/config.json",
1060        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
1061    }))
1062    .map_err(|e| e.to_string())?;
1063    crate::config_io::write_atomic_with_backup(path, &content)?;
1064    Ok(WriteResult {
1065        action: if note.is_some() {
1066            WriteAction::Updated
1067        } else {
1068            WriteAction::Created
1069        },
1070        note,
1071    })
1072}
1073
1074fn write_jetbrains_config(
1075    target: &EditorTarget,
1076    binary: &str,
1077    opts: WriteOptions,
1078) -> Result<WriteResult, String> {
1079    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1080        .map(|d| d.to_string_lossy().to_string())
1081        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1082    // JetBrains AI Assistant expects an "mcpServers" mapping in the JSON snippet
1083    // you paste into Settings | Tools | AI Assistant | Model Context Protocol (MCP).
1084    // We write that snippet to a file for easy copy/paste.
1085    let desired = serde_json::json!({
1086        "command": binary,
1087        "args": [],
1088        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1089    });
1090
1091    if target.config_path.exists() {
1092        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1093        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1094            Ok(v) => v,
1095            Err(_e) => {
1096                return handle_invalid_json_write(
1097                    &target.config_path,
1098                    &content,
1099                    "mcpServers",
1100                    "lean-ctx",
1101                    &desired,
1102                    opts.overwrite_invalid,
1103                );
1104            }
1105        };
1106        let obj = json
1107            .as_object_mut()
1108            .ok_or_else(|| "root JSON must be an object".to_string())?;
1109
1110        let servers = obj
1111            .entry("mcpServers")
1112            .or_insert_with(|| serde_json::json!({}));
1113        let servers_obj = servers
1114            .as_object_mut()
1115            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1116
1117        let existing = servers_obj.get("lean-ctx").cloned();
1118        if existing.as_ref() == Some(&desired) {
1119            return Ok(WriteResult {
1120                action: WriteAction::Already,
1121                note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1122            });
1123        }
1124        servers_obj.insert("lean-ctx".to_string(), desired);
1125
1126        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1127        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1128        return Ok(WriteResult {
1129            action: WriteAction::Updated,
1130            note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1131        });
1132    }
1133
1134    let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
1135    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1136    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1137    Ok(WriteResult {
1138        action: WriteAction::Created,
1139        note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1140    })
1141}
1142
1143fn write_amp_config(
1144    target: &EditorTarget,
1145    binary: &str,
1146    opts: WriteOptions,
1147) -> Result<WriteResult, String> {
1148    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1149        .map(|d| d.to_string_lossy().to_string())
1150        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1151    let entry = serde_json::json!({
1152        "command": binary,
1153        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1154    });
1155
1156    if target.config_path.exists() {
1157        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1158        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1159            Ok(v) => v,
1160            Err(_e) => {
1161                return handle_invalid_json_write(
1162                    &target.config_path,
1163                    &content,
1164                    "amp.mcpServers",
1165                    "lean-ctx",
1166                    &entry,
1167                    opts.overwrite_invalid,
1168                );
1169            }
1170        };
1171        let obj = json
1172            .as_object_mut()
1173            .ok_or_else(|| "root JSON must be an object".to_string())?;
1174        let servers = obj
1175            .entry("amp.mcpServers")
1176            .or_insert_with(|| serde_json::json!({}));
1177        let servers_obj = servers
1178            .as_object_mut()
1179            .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
1180
1181        let existing = servers_obj.get("lean-ctx").cloned();
1182        if existing.as_ref() == Some(&entry) {
1183            return Ok(WriteResult {
1184                action: WriteAction::Already,
1185                note: None,
1186            });
1187        }
1188        servers_obj.insert("lean-ctx".to_string(), entry);
1189
1190        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1191        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1192        return Ok(WriteResult {
1193            action: WriteAction::Updated,
1194            note: None,
1195        });
1196    }
1197
1198    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1199    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1200    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1201    Ok(WriteResult {
1202        action: WriteAction::Created,
1203        note: None,
1204    })
1205}
1206
1207fn write_crush_config(
1208    target: &EditorTarget,
1209    binary: &str,
1210    opts: WriteOptions,
1211) -> Result<WriteResult, String> {
1212    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1213        .map(|d| d.to_string_lossy().to_string())
1214        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1215    let desired = serde_json::json!({
1216        "type": "stdio",
1217        "command": binary,
1218        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1219    });
1220
1221    if target.config_path.exists() {
1222        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1223        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1224            Ok(v) => v,
1225            Err(_e) => {
1226                return handle_invalid_json_write(
1227                    &target.config_path,
1228                    &content,
1229                    "mcp",
1230                    "lean-ctx",
1231                    &desired,
1232                    opts.overwrite_invalid,
1233                );
1234            }
1235        };
1236        let obj = json
1237            .as_object_mut()
1238            .ok_or_else(|| "root JSON must be an object".to_string())?;
1239        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1240        let mcp_obj = mcp
1241            .as_object_mut()
1242            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1243
1244        let existing = mcp_obj.get("lean-ctx").cloned();
1245        if existing.as_ref() == Some(&desired) {
1246            return Ok(WriteResult {
1247                action: WriteAction::Already,
1248                note: None,
1249            });
1250        }
1251        mcp_obj.insert("lean-ctx".to_string(), desired);
1252
1253        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1254        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1255        return Ok(WriteResult {
1256            action: WriteAction::Updated,
1257            note: None,
1258        });
1259    }
1260
1261    write_crush_fresh(&target.config_path, &desired, None)
1262}
1263
1264fn write_crush_fresh(
1265    path: &std::path::Path,
1266    desired: &Value,
1267    note: Option<String>,
1268) -> Result<WriteResult, String> {
1269    let content = serde_json::to_string_pretty(&serde_json::json!({
1270        "mcp": { "lean-ctx": desired }
1271    }))
1272    .map_err(|e| e.to_string())?;
1273    crate::config_io::write_atomic_with_backup(path, &content)?;
1274    Ok(WriteResult {
1275        action: if note.is_some() {
1276            WriteAction::Updated
1277        } else {
1278            WriteAction::Created
1279        },
1280        note,
1281    })
1282}
1283
1284fn upsert_codex_toml(existing: &str, binary: &str) -> String {
1285    let mut out = String::with_capacity(existing.len() + 128);
1286    let mut in_section = false;
1287    let mut saw_section = false;
1288    let mut wrote_command = false;
1289    let mut wrote_args = false;
1290    let mut inserted_parent_before_subtable = false;
1291
1292    let parent_block = format!(
1293        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n\n",
1294        toml_quote(binary)
1295    );
1296
1297    for line in existing.lines() {
1298        let trimmed = line.trim();
1299        if trimmed == "[]" {
1300            continue;
1301        }
1302        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1303            if in_section && !wrote_command {
1304                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1305                wrote_command = true;
1306            }
1307            if in_section && !wrote_args {
1308                out.push_str("args = []\n");
1309                wrote_args = true;
1310            }
1311            in_section = trimmed == "[mcp_servers.lean-ctx]";
1312            if in_section {
1313                saw_section = true;
1314            } else if !saw_section
1315                && !inserted_parent_before_subtable
1316                && trimmed.starts_with("[mcp_servers.lean-ctx.")
1317            {
1318                out.push_str(&parent_block);
1319                inserted_parent_before_subtable = true;
1320            }
1321            out.push_str(line);
1322            out.push('\n');
1323            continue;
1324        }
1325
1326        if in_section {
1327            if trimmed.starts_with("command") && trimmed.contains('=') {
1328                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1329                wrote_command = true;
1330                continue;
1331            }
1332            if trimmed.starts_with("args") && trimmed.contains('=') {
1333                out.push_str("args = []\n");
1334                wrote_args = true;
1335                continue;
1336            }
1337        }
1338
1339        out.push_str(line);
1340        out.push('\n');
1341    }
1342
1343    if saw_section {
1344        if in_section && !wrote_command {
1345            out.push_str(&format!("command = {}\n", toml_quote(binary)));
1346        }
1347        if in_section && !wrote_args {
1348            out.push_str("args = []\n");
1349        }
1350        return out;
1351    }
1352
1353    if inserted_parent_before_subtable {
1354        return out;
1355    }
1356
1357    if !out.ends_with('\n') {
1358        out.push('\n');
1359    }
1360    out.push_str("\n[mcp_servers.lean-ctx]\n");
1361    out.push_str(&format!("command = {}\n", toml_quote(binary)));
1362    out.push_str("args = []\n");
1363    out
1364}
1365
1366fn write_gemini_settings(
1367    target: &EditorTarget,
1368    binary: &str,
1369    opts: WriteOptions,
1370) -> Result<WriteResult, String> {
1371    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1372        .map(|d| d.to_string_lossy().to_string())
1373        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1374    let entry = serde_json::json!({
1375        "command": binary,
1376        "env": { "LEAN_CTX_DATA_DIR": data_dir },
1377        "trust": true,
1378    });
1379
1380    if target.config_path.exists() {
1381        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1382        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1383            Ok(v) => v,
1384            Err(_e) => {
1385                return handle_invalid_json_write(
1386                    &target.config_path,
1387                    &content,
1388                    "mcpServers",
1389                    "lean-ctx",
1390                    &entry,
1391                    opts.overwrite_invalid,
1392                );
1393            }
1394        };
1395        let obj = json
1396            .as_object_mut()
1397            .ok_or_else(|| "root JSON must be an object".to_string())?;
1398        let servers = obj
1399            .entry("mcpServers")
1400            .or_insert_with(|| serde_json::json!({}));
1401        let servers_obj = servers
1402            .as_object_mut()
1403            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1404
1405        let existing = servers_obj.get("lean-ctx").cloned();
1406        if existing.as_ref() == Some(&entry) {
1407            return Ok(WriteResult {
1408                action: WriteAction::Already,
1409                note: None,
1410            });
1411        }
1412        servers_obj.insert("lean-ctx".to_string(), entry);
1413
1414        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1415        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1416        return Ok(WriteResult {
1417            action: WriteAction::Updated,
1418            note: None,
1419        });
1420    }
1421
1422    let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1423    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1424    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1425    Ok(WriteResult {
1426        action: WriteAction::Created,
1427        note: None,
1428    })
1429}
1430
1431fn write_hermes_yaml(
1432    target: &EditorTarget,
1433    binary: &str,
1434    _opts: WriteOptions,
1435) -> Result<WriteResult, String> {
1436    let data_dir = default_data_dir()?;
1437
1438    let lean_ctx_block = format!(
1439        "  lean-ctx:\n    command: \"{binary}\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\""
1440    );
1441
1442    if target.config_path.exists() {
1443        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1444
1445        if content.contains("lean-ctx") {
1446            let has_correct_binary = content.contains(binary);
1447            let has_correct_data_dir = content.contains(&data_dir);
1448            if has_correct_binary && has_correct_data_dir {
1449                return Ok(WriteResult {
1450                    action: WriteAction::Already,
1451                    note: None,
1452                });
1453            }
1454            let cleaned = remove_hermes_yaml_lean_ctx_block(&content);
1455            let updated = upsert_hermes_yaml_mcp(&cleaned, &lean_ctx_block);
1456            crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1457            return Ok(WriteResult {
1458                action: WriteAction::Updated,
1459                note: None,
1460            });
1461        }
1462
1463        let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1464        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1465        return Ok(WriteResult {
1466            action: WriteAction::Updated,
1467            note: None,
1468        });
1469    }
1470
1471    let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1472    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1473    Ok(WriteResult {
1474        action: WriteAction::Created,
1475        note: None,
1476    })
1477}
1478
1479fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1480    let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1481    let mut in_mcp_section = false;
1482    let mut saw_mcp_child = false;
1483    let mut inserted = false;
1484    let lines: Vec<&str> = existing.lines().collect();
1485
1486    for line in &lines {
1487        if !inserted && line.trim_end() == "mcp_servers:" {
1488            in_mcp_section = true;
1489            out.push_str(line);
1490            out.push('\n');
1491            continue;
1492        }
1493
1494        if in_mcp_section && !inserted {
1495            let is_child = line.starts_with("  ") && !line.trim().is_empty();
1496            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1497
1498            if is_child {
1499                saw_mcp_child = true;
1500                out.push_str(line);
1501                out.push('\n');
1502                continue;
1503            }
1504
1505            if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1506                out.push_str(lean_ctx_block);
1507                out.push('\n');
1508                inserted = true;
1509                in_mcp_section = false;
1510            }
1511        }
1512
1513        out.push_str(line);
1514        out.push('\n');
1515    }
1516
1517    if in_mcp_section && !inserted {
1518        out.push_str(lean_ctx_block);
1519        out.push('\n');
1520        inserted = true;
1521    }
1522
1523    if !inserted {
1524        if !out.ends_with('\n') {
1525            out.push('\n');
1526        }
1527        out.push_str("\nmcp_servers:\n");
1528        out.push_str(lean_ctx_block);
1529        out.push('\n');
1530    }
1531
1532    out
1533}
1534
1535fn remove_hermes_yaml_lean_ctx_block(content: &str) -> String {
1536    let mut out = String::with_capacity(content.len());
1537    let mut skip = false;
1538    for line in content.lines() {
1539        if line.trim_start().starts_with("lean-ctx:")
1540            && (line.starts_with("  ") || line.starts_with('\t'))
1541        {
1542            skip = true;
1543            continue;
1544        }
1545        if skip {
1546            let indented = line.starts_with("    ") || line.starts_with("\t\t");
1547            let empty = line.trim().is_empty();
1548            if indented || empty {
1549                continue;
1550            }
1551            skip = false;
1552        }
1553        out.push_str(line);
1554        out.push('\n');
1555    }
1556    out
1557}
1558
1559fn write_qoder_settings(
1560    target: &EditorTarget,
1561    binary: &str,
1562    opts: WriteOptions,
1563) -> Result<WriteResult, String> {
1564    let data_dir = default_data_dir()?;
1565    let desired = serde_json::json!({
1566        "command": binary,
1567        "args": [],
1568        "env": {
1569            "LEAN_CTX_DATA_DIR": data_dir,
1570            "LEAN_CTX_FULL_TOOLS": "1"
1571        }
1572    });
1573
1574    if target.config_path.exists() {
1575        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1576        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1577            Ok(v) => v,
1578            Err(_e) => {
1579                return handle_invalid_json_write(
1580                    &target.config_path,
1581                    &content,
1582                    "mcpServers",
1583                    "lean-ctx",
1584                    &desired,
1585                    opts.overwrite_invalid,
1586                );
1587            }
1588        };
1589        let obj = json
1590            .as_object_mut()
1591            .ok_or_else(|| "root JSON must be an object".to_string())?;
1592        let servers = obj
1593            .entry("mcpServers")
1594            .or_insert_with(|| serde_json::json!({}));
1595        let servers_obj = servers
1596            .as_object_mut()
1597            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1598
1599        let existing = servers_obj.get("lean-ctx").cloned();
1600        if existing.as_ref() == Some(&desired) {
1601            return Ok(WriteResult {
1602                action: WriteAction::Already,
1603                note: None,
1604            });
1605        }
1606        servers_obj.insert("lean-ctx".to_string(), desired);
1607
1608        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1609        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1610        return Ok(WriteResult {
1611            action: WriteAction::Updated,
1612            note: None,
1613        });
1614    }
1615
1616    write_mcp_json_fresh(&target.config_path, &desired, None)
1617}
1618
1619// ---------------------------------------------------------------------------
1620// Augment VS Code extension writer
1621//
1622// `augment.vscode-augment` persists registered MCP servers as a top-level JSON
1623// array under its globalStorage directory. The extension keys entries by the
1624// `id` field (a UUID), and we need that id to stay stable so repeated
1625// `init --agent augment` calls don't litter the list with duplicates.
1626//
1627// Schema (validated empirically 2026-05-21):
1628//   { type, id, name, disabled, command, args, env, useShellInterpolation }
1629// ---------------------------------------------------------------------------
1630
1631/// Fixed UUIDv4-shaped id reserved for lean-ctx in Augment's VS Code MCP list.
1632/// The first segment hex-encodes "lean" (6c 65 61 6e) and the last segment
1633/// hex-encodes "leanct" (6c 65 61 6e 63 74) — a 6-byte ASCII tag that fits
1634/// exactly in the 12-hex-char node field. The middle bytes preserve the
1635/// version-4 / variant-RFC-4122 nibbles so the value parses as a valid UUID.
1636/// Only stability matters — the writer uses this id to locate and update its
1637/// own entry idempotently without colliding with user-added servers.
1638const LEAN_CTX_AUGMENT_VSCODE_ID: &str = "6c65616e-c747-4000-8000-6c65616e6374";
1639
1640fn lean_ctx_augment_vscode_entry(binary: &str, data_dir: &str) -> Value {
1641    serde_json::json!({
1642        "type": "stdio",
1643        "id": LEAN_CTX_AUGMENT_VSCODE_ID,
1644        "name": "lean-ctx",
1645        "disabled": false,
1646        "command": binary,
1647        "args": [],
1648        "useShellInterpolation": false,
1649        "env": {
1650            "LEAN_CTX_DATA_DIR": data_dir
1651        }
1652    })
1653}
1654
1655fn write_augment_vscode(
1656    target: &EditorTarget,
1657    binary: &str,
1658    opts: WriteOptions,
1659) -> Result<WriteResult, String> {
1660    let data_dir = default_data_dir()?;
1661    let desired = lean_ctx_augment_vscode_entry(binary, &data_dir);
1662
1663    if !target.config_path.exists() {
1664        let arr = serde_json::Value::Array(vec![desired]);
1665        let content = serde_json::to_string_pretty(&arr).map_err(|e| e.to_string())?;
1666        crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1667        return Ok(WriteResult {
1668            action: WriteAction::Created,
1669            note: None,
1670        });
1671    }
1672
1673    let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1674    let mut json: Value = match crate::core::jsonc::parse_jsonc(&content) {
1675        Ok(v) => v,
1676        Err(e) => {
1677            if !opts.overwrite_invalid {
1678                return Err(e.to_string());
1679            }
1680            eprintln!(
1681                "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors — replacing with a clean array.",
1682                target.config_path.display()
1683            );
1684            backup_invalid_file(&target.config_path)?;
1685            let arr = serde_json::Value::Array(vec![desired]);
1686            let content = serde_json::to_string_pretty(&arr).map_err(|e| e.to_string())?;
1687            crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1688            return Ok(WriteResult {
1689                action: WriteAction::Updated,
1690                note: Some("replaced invalid JSON with clean array".to_string()),
1691            });
1692        }
1693    };
1694
1695    let arr = json.as_array_mut().ok_or_else(|| {
1696        "augment vscode mcpServers.json must contain a top-level JSON array".to_string()
1697    })?;
1698
1699    if let Some(existing) = arr.iter_mut().find(|entry| {
1700        entry.get("name").and_then(|n| n.as_str()) == Some("lean-ctx")
1701            || entry.get("id").and_then(|i| i.as_str()) == Some(LEAN_CTX_AUGMENT_VSCODE_ID)
1702    }) {
1703        if *existing == desired {
1704            return Ok(WriteResult {
1705                action: WriteAction::Already,
1706                note: None,
1707            });
1708        }
1709        *existing = desired;
1710    } else {
1711        arr.push(desired);
1712    }
1713
1714    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1715    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1716    Ok(WriteResult {
1717        action: WriteAction::Updated,
1718        note: None,
1719    })
1720}
1721
1722fn remove_lean_ctx_augment_vscode_server(
1723    path: &std::path::Path,
1724    opts: WriteOptions,
1725) -> Result<WriteResult, String> {
1726    if !path.exists() {
1727        return Ok(WriteResult {
1728            action: WriteAction::Already,
1729            note: Some("augment vscode mcpServers.json not found".to_string()),
1730        });
1731    }
1732
1733    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1734    let mut json: Value = match crate::core::jsonc::parse_jsonc(&content) {
1735        Ok(v) => v,
1736        Err(e) => {
1737            if !opts.overwrite_invalid {
1738                return Err(e.to_string());
1739            }
1740            eprintln!(
1741                "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors — skipping removal.",
1742                path.display()
1743            );
1744            return Ok(WriteResult {
1745                action: WriteAction::Already,
1746                note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
1747            });
1748        }
1749    };
1750
1751    let arr = json
1752        .as_array_mut()
1753        .ok_or_else(|| "augment vscode mcpServers.json must be a JSON array".to_string())?;
1754
1755    let before = arr.len();
1756    arr.retain(|entry| {
1757        let name_match = entry.get("name").and_then(|n| n.as_str()) == Some("lean-ctx");
1758        let id_match = entry.get("id").and_then(|i| i.as_str()) == Some(LEAN_CTX_AUGMENT_VSCODE_ID);
1759        !(name_match || id_match)
1760    });
1761    if arr.len() == before {
1762        return Ok(WriteResult {
1763            action: WriteAction::Already,
1764            note: Some("lean-ctx not configured".to_string()),
1765        });
1766    }
1767
1768    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1769    crate::config_io::write_atomic_with_backup(path, &formatted)?;
1770    Ok(WriteResult {
1771        action: WriteAction::Updated,
1772        note: Some("removed lean-ctx from augment vscode mcp list".to_string()),
1773    })
1774}
1775
1776fn backup_invalid_file(path: &std::path::Path) -> Result<std::path::PathBuf, String> {
1777    if !path.exists() {
1778        return Ok(path.to_path_buf());
1779    }
1780    let parent = path
1781        .parent()
1782        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1783    let filename = path
1784        .file_name()
1785        .ok_or_else(|| "invalid path (no filename)".to_string())?
1786        .to_string_lossy();
1787    let pid = std::process::id();
1788    let nanos = std::time::SystemTime::now()
1789        .duration_since(std::time::UNIX_EPOCH)
1790        .map_or(0, |d| d.as_nanos());
1791    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1792    std::fs::copy(path, &bak).map_err(|e| e.to_string())?;
1793    Ok(bak)
1794}
1795
1796/// Safe handler for invalid JSON config files. NEVER silently overwrites.
1797/// Strategy:
1798/// 1. If lean-ctx is already present in text → skip (no-op)
1799/// 2. Try text-based injection into the container key
1800/// 3. If injection fails → warn user with clear instructions, do NOT modify file
1801fn handle_invalid_json_write(
1802    path: &std::path::Path,
1803    content: &str,
1804    container_key: &str,
1805    entry_key: &str,
1806    value: &serde_json::Value,
1807    allow_inject: bool,
1808) -> Result<WriteResult, String> {
1809    if content.contains(&format!("\"{entry_key}\"")) {
1810        eprintln!(
1811            "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors but already contains \"{entry_key}\".",
1812            path.display()
1813        );
1814        eprintln!("   Skipping — your config is untouched.");
1815        return Ok(WriteResult {
1816            action: WriteAction::Already,
1817            note: Some(format!("invalid JSON, {entry_key} already present")),
1818        });
1819    }
1820
1821    if !allow_inject {
1822        return Err(format!(
1823            "{} contains invalid JSON. Fix the syntax and re-run lean-ctx setup.\n  Path: {}",
1824            path.display(),
1825            path.display()
1826        ));
1827    }
1828
1829    // Try text-based injection
1830    if let Some(patched) = try_text_inject_mcp_entry(content, container_key, entry_key, value) {
1831        let bak = backup_invalid_file(path)?;
1832        crate::config_io::write_atomic_with_backup(path, &patched)?;
1833        eprintln!(
1834            "\x1b[32m✓\x1b[0m  Added {entry_key} to {} (text-based; file has syntax errors).",
1835            path.display()
1836        );
1837        eprintln!("   \x1b[33mNote:\x1b[0m Your config has JSON syntax errors — please fix them.");
1838        eprintln!("   Backup: {}", bak.display());
1839        return Ok(WriteResult {
1840            action: WriteAction::Updated,
1841            note: Some(format!(
1842                "text-injected into invalid JSON (backup: {})",
1843                bak.display()
1844            )),
1845        });
1846    }
1847
1848    // Cannot safely modify — inform user
1849    eprintln!(
1850        "\x1b[33m⚠\x1b[0m  {} contains invalid JSON that lean-ctx cannot safely modify.",
1851        path.display()
1852    );
1853    eprintln!("   \x1b[1mYour config was NOT changed.\x1b[0m");
1854    eprintln!("   To fix:");
1855    eprintln!(
1856        "     1. Open {} and correct the JSON syntax errors",
1857        path.display()
1858    );
1859    eprintln!("     2. Re-run: lean-ctx setup");
1860    eprintln!("   (Common issue: trailing commas, missing quotes, unmatched braces)");
1861    Ok(WriteResult {
1862        action: WriteAction::Already,
1863        note: Some(format!(
1864            "invalid JSON — user must fix manually: {}",
1865            path.display()
1866        )),
1867    })
1868}
1869
1870/// Attempt to inject an MCP entry into a JSON file using text manipulation.
1871/// Preserves the original file content even if it has syntax errors.
1872/// Returns None if text structure doesn't allow safe injection.
1873fn try_text_inject_mcp_entry(
1874    content: &str,
1875    container_key: &str,
1876    entry_key: &str,
1877    value: &serde_json::Value,
1878) -> Option<String> {
1879    let entry = serde_json::to_string_pretty(value).ok()?;
1880    let indented_entry = entry
1881        .lines()
1882        .enumerate()
1883        .map(|(i, line)| {
1884            if i == 0 {
1885                format!("    \"{entry_key}\": {line}")
1886            } else {
1887                format!("    {line}")
1888            }
1889        })
1890        .collect::<Vec<_>>()
1891        .join("\n");
1892
1893    // Strategy 1: find the target container key and inject after its opening brace.
1894    // Prioritize the exact container_key, then fall back to common alternatives.
1895    let quoted_container = format!("\"{container_key}\"");
1896    let search_keys: Vec<&str> = std::iter::once(quoted_container.as_str())
1897        .chain(
1898            [
1899                "\"mcp\"",
1900                "\"mcpServers\"",
1901                "\"servers\"",
1902                "\"context_servers\"",
1903            ]
1904            .iter()
1905            .filter(|k| **k != quoted_container.as_str())
1906            .copied(),
1907        )
1908        .collect();
1909
1910    for container in &search_keys {
1911        if let Some(pos) = content.find(container) {
1912            let after = &content[pos..];
1913            if let Some(brace_offset) = after.find('{') {
1914                let insert_pos = pos + brace_offset + 1;
1915                let before = &content[..insert_pos];
1916                let rest = &content[insert_pos..];
1917                let needs_comma = !rest.trim_start().starts_with('}');
1918                let injection = if needs_comma {
1919                    format!("\n{indented_entry},")
1920                } else {
1921                    format!("\n{indented_entry}\n  ")
1922                };
1923                return Some(format!("{before}{injection}{rest}"));
1924            }
1925        }
1926    }
1927
1928    // Strategy 2: inject a new container block before the closing root brace
1929    if let Some(last_brace) = content.rfind('}') {
1930        let before = &content[..last_brace];
1931        let after = &content[last_brace..];
1932        let needs_comma = before.trim_end().ends_with('}')
1933            || before.trim_end().ends_with('"')
1934            || before.trim_end().ends_with(']');
1935        let comma = if needs_comma { "," } else { "" };
1936        let block = format!("{comma}\n  \"{container_key}\": {{\n{indented_entry}\n  }}\n");
1937        return Some(format!("{before}{block}{after}"));
1938    }
1939
1940    None
1941}
1942
1943#[cfg(test)]
1944mod tests {
1945    use super::*;
1946    use std::path::PathBuf;
1947
1948    fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1949        EditorTarget {
1950            name,
1951            agent_key: "test".to_string(),
1952            config_path: path,
1953            detect_path: PathBuf::from("/nonexistent"),
1954            config_type: ty,
1955        }
1956    }
1957
1958    #[test]
1959    fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1960        let dir = tempfile::tempdir().unwrap();
1961        let path = dir.path().join("mcp.json");
1962        std::fs::write(
1963            &path,
1964            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1965        )
1966        .unwrap();
1967
1968        let t = target("test", path.clone(), ConfigType::McpJson);
1969        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1970        assert_eq!(res.action, WriteAction::Updated);
1971
1972        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1973        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1974        assert_eq!(
1975            json["mcpServers"]["lean-ctx"]["command"],
1976            "/new/path/lean-ctx"
1977        );
1978        assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1979    }
1980
1981    #[test]
1982    fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1983        let dir = tempfile::tempdir().unwrap();
1984        let path = dir.path().join("mcp.json");
1985        std::fs::write(
1986            &path,
1987            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1988        )
1989        .unwrap();
1990
1991        let t = target("Cursor", path.clone(), ConfigType::McpJson);
1992        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1993        assert_eq!(res.action, WriteAction::Updated);
1994
1995        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1996        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1997        assert_eq!(
1998            json["mcpServers"]["lean-ctx"]["command"],
1999            "/new/path/lean-ctx"
2000        );
2001        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
2002        assert!(
2003            json["mcpServers"]["lean-ctx"]["autoApprove"]
2004                .as_array()
2005                .unwrap()
2006                .len()
2007                > 5
2008        );
2009    }
2010
2011    #[test]
2012    fn crush_config_writes_mcp_root() {
2013        let dir = tempfile::tempdir().unwrap();
2014        let path = dir.path().join("crush.json");
2015        std::fs::write(
2016            &path,
2017            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
2018        )
2019        .unwrap();
2020
2021        let t = target("test", path.clone(), ConfigType::Crush);
2022        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
2023        assert_eq!(res.action, WriteAction::Updated);
2024
2025        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2026        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
2027        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
2028    }
2029
2030    #[test]
2031    fn codex_toml_upserts_existing_section() {
2032        let dir = tempfile::tempdir().unwrap();
2033        let path = dir.path().join("config.toml");
2034        std::fs::write(
2035            &path,
2036            r#"[mcp_servers.lean-ctx]
2037command = "old"
2038args = ["x"]
2039"#,
2040        )
2041        .unwrap();
2042
2043        let t = target("test", path.clone(), ConfigType::Codex);
2044        let res = write_codex_config(&t, "new").unwrap();
2045        assert_eq!(res.action, WriteAction::Updated);
2046
2047        let content = std::fs::read_to_string(&path).unwrap();
2048        assert!(content.contains(r#"command = "new""#));
2049        assert!(content.contains("args = []"));
2050    }
2051
2052    #[test]
2053    fn upsert_codex_toml_inserts_new_section_when_missing() {
2054        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
2055        assert!(updated.contains("[mcp_servers.lean-ctx]"));
2056        assert!(updated.contains("command = \"lean-ctx\""));
2057        assert!(updated.contains("args = []"));
2058    }
2059
2060    #[test]
2061    fn codex_toml_uses_single_quotes_for_backslash_paths() {
2062        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
2063        let updated = upsert_codex_toml("", win_path);
2064        assert!(
2065            updated.contains(&format!("command = '{win_path}'")),
2066            "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
2067        );
2068    }
2069
2070    #[test]
2071    fn codex_toml_uses_double_quotes_for_unix_paths() {
2072        let unix_path = "/usr/local/bin/lean-ctx";
2073        let updated = upsert_codex_toml("", unix_path);
2074        assert!(
2075            updated.contains(&format!("command = \"{unix_path}\"")),
2076            "Unix paths should use double quotes: {updated}"
2077        );
2078    }
2079
2080    #[test]
2081    fn upsert_codex_toml_inserts_parent_before_orphaned_tool_subtables() {
2082        let input = "\
2083[mcp_servers.lean-ctx.tools.ctx_multi_read]
2084approval_mode = \"approve\"
2085
2086[mcp_servers.lean-ctx.tools.ctx_read]
2087approval_mode = \"approve\"
2088";
2089        let updated = upsert_codex_toml(input, "lean-ctx");
2090        let parent_pos = updated
2091            .find("[mcp_servers.lean-ctx]\n")
2092            .expect("parent section must be inserted");
2093        let tools_pos = updated
2094            .find("[mcp_servers.lean-ctx.tools.")
2095            .expect("tool sub-tables must be preserved");
2096        assert!(
2097            parent_pos < tools_pos,
2098            "parent must come before tool sub-tables:\n{updated}"
2099        );
2100        assert!(updated.contains("command = \"lean-ctx\""));
2101        assert!(updated.contains("args = []"));
2102        assert!(updated.contains("approval_mode = \"approve\""));
2103    }
2104
2105    #[test]
2106    fn upsert_codex_toml_handles_issue_191_windows_scenario() {
2107        let input = "\
2108[mcp_servers.lean-ctx.tools.ctx_multi_read]
2109approval_mode = \"approve\"
2110
2111[mcp_servers.lean-ctx.tools.ctx_read]
2112approval_mode = \"approve\"
2113
2114[mcp_servers.lean-ctx.tools.ctx_search]
2115approval_mode = \"approve\"
2116
2117[mcp_servers.lean-ctx.tools.ctx_tree]
2118approval_mode = \"approve\"
2119";
2120        let win_path = r"C:\Users\wudon\AppData\Roaming\npm\lean-ctx.cmd";
2121        let updated = upsert_codex_toml(input, win_path);
2122        assert!(
2123            updated.contains(&format!("command = '{win_path}'")),
2124            "Windows path must use single quotes: {updated}"
2125        );
2126        let parent_pos = updated.find("[mcp_servers.lean-ctx]\n").unwrap();
2127        let first_tool = updated.find("[mcp_servers.lean-ctx.tools.").unwrap();
2128        assert!(parent_pos < first_tool);
2129        assert_eq!(
2130            updated.matches("[mcp_servers.lean-ctx]\n").count(),
2131            1,
2132            "parent section must appear exactly once"
2133        );
2134    }
2135
2136    #[test]
2137    fn upsert_codex_toml_does_not_duplicate_parent_when_present() {
2138        let input = "\
2139[mcp_servers.lean-ctx]
2140command = \"old\"
2141args = [\"x\"]
2142
2143[mcp_servers.lean-ctx.tools.ctx_read]
2144approval_mode = \"approve\"
2145";
2146        let updated = upsert_codex_toml(input, "new");
2147        assert_eq!(
2148            updated.matches("[mcp_servers.lean-ctx]").count(),
2149            1,
2150            "must not duplicate parent section"
2151        );
2152        assert!(updated.contains("command = \"new\""));
2153        assert!(updated.contains("args = []"));
2154        assert!(updated.contains("approval_mode = \"approve\""));
2155    }
2156
2157    #[test]
2158    fn auto_approve_contains_core_tools() {
2159        let tools = auto_approve_tools();
2160        assert!(tools.contains(&"ctx_read"));
2161        assert!(tools.contains(&"ctx_shell"));
2162        assert!(tools.contains(&"ctx_search"));
2163        assert!(tools.contains(&"ctx_workflow"));
2164        assert!(tools.contains(&"ctx_cost"));
2165    }
2166
2167    #[test]
2168    fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
2169        let dir = tempfile::tempdir().unwrap();
2170        let path = dir.path().join("mcp.json");
2171        std::fs::write(
2172            &path,
2173            r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
2174        )
2175        .unwrap();
2176
2177        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2178        let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2179        assert_eq!(res.action, WriteAction::Updated);
2180
2181        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2182        assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
2183        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
2184        assert_eq!(
2185            json["mcpServers"]["lean-ctx"]["args"],
2186            serde_json::json!([])
2187        );
2188        assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
2189            .as_str()
2190            .is_some_and(|s| !s.trim().is_empty()));
2191        assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
2192        assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
2193        assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
2194    }
2195
2196    #[test]
2197    fn qoder_mcp_config_is_idempotent() {
2198        let dir = tempfile::tempdir().unwrap();
2199        let path = dir.path().join("mcp.json");
2200        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2201
2202        let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2203        let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2204
2205        assert_eq!(first.action, WriteAction::Created);
2206        assert_eq!(second.action, WriteAction::Already);
2207    }
2208
2209    #[test]
2210    fn qoder_mcp_config_creates_missing_parent_directories() {
2211        let dir = tempfile::tempdir().unwrap();
2212        let path = dir
2213            .path()
2214            .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
2215        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2216
2217        let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
2218
2219        assert_eq!(res.action, WriteAction::Created);
2220        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2221        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
2222    }
2223
2224    #[test]
2225    fn antigravity_config_omits_auto_approve() {
2226        let dir = tempfile::tempdir().unwrap();
2227        let path = dir.path().join("mcp_config.json");
2228
2229        let t = EditorTarget {
2230            name: "Antigravity",
2231            agent_key: "gemini".to_string(),
2232            config_path: path.clone(),
2233            detect_path: PathBuf::from("/nonexistent"),
2234            config_type: ConfigType::McpJson,
2235        };
2236        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
2237        assert_eq!(res.action, WriteAction::Created);
2238
2239        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2240        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
2241        assert_eq!(
2242            json["mcpServers"]["lean-ctx"]["command"],
2243            "/usr/local/bin/lean-ctx"
2244        );
2245    }
2246
2247    #[test]
2248    fn hermes_yaml_inserts_into_existing_mcp_servers() {
2249        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";
2250        let block = "  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
2251        let result = upsert_hermes_yaml_mcp(existing, block);
2252        assert!(result.contains("lean-ctx"));
2253        assert!(result.contains("model: anthropic/claude-sonnet-4"));
2254        assert!(result.contains("tool_allowlist:"));
2255        assert!(result.contains("github:"));
2256    }
2257
2258    #[test]
2259    fn hermes_yaml_creates_mcp_servers_section() {
2260        let existing = "model: openai/gpt-4o\n";
2261        let block = "  lean-ctx:\n    command: \"lean-ctx\"";
2262        let result = upsert_hermes_yaml_mcp(existing, block);
2263        assert!(result.contains("mcp_servers:"));
2264        assert!(result.contains("lean-ctx"));
2265        assert!(result.contains("model: openai/gpt-4o"));
2266    }
2267
2268    #[test]
2269    fn hermes_yaml_skips_if_already_present() {
2270        let dir = tempfile::tempdir().unwrap();
2271        let path = dir.path().join("config.yaml");
2272        let data_dir = crate::core::data_dir::lean_ctx_data_dir()
2273            .map(|d| d.to_string_lossy().to_string())
2274            .unwrap_or_default();
2275        std::fs::write(
2276            &path,
2277            format!("mcp_servers:\n  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\"\n"),
2278        )
2279        .unwrap();
2280        let t = target("test", path.clone(), ConfigType::HermesYaml);
2281        let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
2282        assert_eq!(res.action, WriteAction::Already);
2283    }
2284
2285    #[test]
2286    fn remove_codex_section_also_removes_env_subtable() {
2287        let input = "\
2288[other]
2289x = 1
2290
2291[mcp_servers.lean-ctx]
2292args = []
2293command = \"/usr/local/bin/lean-ctx\"
2294
2295[mcp_servers.lean-ctx.env]
2296LEAN_CTX_DATA_DIR = \"/home/user/.lean-ctx\"
2297
2298[features]
2299codex_hooks = true
2300";
2301        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2302        assert!(
2303            !result.contains("[mcp_servers.lean-ctx]"),
2304            "parent section must be removed"
2305        );
2306        assert!(
2307            !result.contains("LEAN_CTX_DATA_DIR"),
2308            "env sub-table must be removed too"
2309        );
2310        assert!(result.contains("[other]"), "unrelated sections preserved");
2311        assert!(
2312            result.contains("[features]"),
2313            "sections after must be preserved"
2314        );
2315    }
2316
2317    #[test]
2318    fn remove_codex_section_preserves_other_mcp_servers() {
2319        let input = "\
2320[mcp_servers.lean-ctx]
2321command = \"lean-ctx\"
2322
2323[mcp_servers.lean-ctx.env]
2324X = \"1\"
2325
2326[mcp_servers.other]
2327command = \"other\"
2328";
2329        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2330        assert!(!result.contains("[mcp_servers.lean-ctx]"));
2331        assert!(
2332            result.contains("[mcp_servers.other]"),
2333            "other MCP servers must be preserved"
2334        );
2335        assert!(result.contains("command = \"other\""));
2336    }
2337
2338    #[test]
2339    fn remove_codex_section_does_not_remove_similarly_named_server() {
2340        let input = "\
2341[mcp_servers.lean-ctx]
2342command = \"lean-ctx\"
2343
2344[mcp_servers.lean-ctx-probe]
2345command = \"probe\"
2346";
2347        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2348        assert!(
2349            !result.contains("[mcp_servers.lean-ctx]\n"),
2350            "target section must be removed"
2351        );
2352        assert!(
2353            result.contains("[mcp_servers.lean-ctx-probe]"),
2354            "similarly-named server must NOT be removed"
2355        );
2356        assert!(result.contains("command = \"probe\""));
2357    }
2358
2359    #[test]
2360    fn remove_codex_section_handles_no_match() {
2361        let input = "[other]\nx = 1\n";
2362        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2363        assert_eq!(result, "[other]\nx = 1\n");
2364    }
2365
2366    #[test]
2367    fn text_inject_into_existing_mcp_object() {
2368        let content = r#"{
2369  "mcp": {}
2370}"#;
2371        let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2372        let result = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2373        assert!(result.is_some());
2374        let patched = result.unwrap();
2375        assert!(patched.contains("\"lean-ctx\""));
2376        assert!(patched.contains("\"type\": \"local\""));
2377    }
2378
2379    #[test]
2380    fn text_inject_creates_container_when_missing() {
2381        let content = r#"{
2382  "some_other_key": "value"
2383}"#;
2384        let value = serde_json::json!({"command": "lean-ctx"});
2385        // For mcpServers container
2386        let result = try_text_inject_mcp_entry(content, "mcpServers", "lean-ctx", &value);
2387        assert!(result.is_some());
2388        let patched = result.unwrap();
2389        assert!(patched.contains("\"mcpServers\""));
2390        assert!(patched.contains("\"lean-ctx\""));
2391
2392        // For mcp container (OpenCode)
2393        let result2 = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2394        assert!(result2.is_some());
2395        let patched2 = result2.unwrap();
2396        assert!(patched2.contains("\"mcp\""));
2397        assert!(patched2.contains("\"lean-ctx\""));
2398
2399        // For context_servers container (Zed)
2400        let result3 = try_text_inject_mcp_entry(content, "context_servers", "lean-ctx", &value);
2401        assert!(result3.is_some());
2402        let patched3 = result3.unwrap();
2403        assert!(patched3.contains("\"context_servers\""));
2404        assert!(patched3.contains("\"lean-ctx\""));
2405    }
2406
2407    #[test]
2408    fn text_inject_into_populated_mcp_object() {
2409        let content = r#"{
2410  "mcp": {
2411    "other-server": {"type": "local"}
2412  }
2413}"#;
2414        let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2415        let result = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2416        assert!(result.is_some());
2417        let patched = result.unwrap();
2418        assert!(patched.contains("\"lean-ctx\""));
2419        assert!(patched.contains("\"other-server\""));
2420    }
2421
2422    #[test]
2423    fn handle_invalid_json_skips_when_entry_already_present() {
2424        let content = r#"{ invalid json "lean-ctx": stuff }"#;
2425        let value = serde_json::json!({"type": "local"});
2426        let result = handle_invalid_json_write(
2427            std::path::Path::new("/tmp/test.json"),
2428            content,
2429            "mcp",
2430            "lean-ctx",
2431            &value,
2432            true,
2433        );
2434        assert!(result.is_ok());
2435        let r = result.unwrap();
2436        assert_eq!(r.action, WriteAction::Already);
2437    }
2438
2439    #[test]
2440    fn handle_invalid_json_returns_error_when_inject_disabled() {
2441        let content = r"{ invalid json without key }";
2442        let value = serde_json::json!({"type": "local"});
2443        let result = handle_invalid_json_write(
2444            std::path::Path::new("/tmp/test.json"),
2445            content,
2446            "mcp",
2447            "lean-ctx",
2448            &value,
2449            false,
2450        );
2451        assert!(result.is_err());
2452    }
2453
2454    #[test]
2455    fn handle_invalid_json_does_not_overwrite_file() {
2456        let dir = tempfile::tempdir().unwrap();
2457        let path = dir.path().join("opencode.json");
2458        let invalid_content = r#"{ "mcp": { BROKEN "other": true } }"#;
2459        std::fs::write(&path, invalid_content).unwrap();
2460
2461        let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2462        let result =
2463            handle_invalid_json_write(&path, invalid_content, "mcp", "lean-ctx", &value, true);
2464        assert!(result.is_ok());
2465        let r = result.unwrap();
2466        assert_eq!(r.action, WriteAction::Updated);
2467
2468        // Original file should still exist (not deleted/renamed)
2469        let final_content = std::fs::read_to_string(&path).unwrap();
2470        assert!(
2471            final_content.contains("lean-ctx"),
2472            "lean-ctx should be injected"
2473        );
2474        assert!(
2475            final_content.contains("BROKEN"),
2476            "original content preserved"
2477        );
2478    }
2479
2480    // -----------------------------------------------------------------------
2481    // Augment VS Code extension (top-level JSON array) writer/remover tests
2482    // -----------------------------------------------------------------------
2483
2484    #[test]
2485    fn augment_vscode_creates_array_with_lean_ctx_entry() {
2486        let dir = tempfile::tempdir().unwrap();
2487        let path = dir.path().join("mcpServers.json");
2488        let t = target("Augment (VS Code)", path.clone(), ConfigType::AugmentVsCode);
2489
2490        let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
2491        assert_eq!(res.action, WriteAction::Created);
2492
2493        let arr: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2494        let entries = arr.as_array().expect("top-level must be array");
2495        assert_eq!(entries.len(), 1);
2496        let e = &entries[0];
2497        assert_eq!(e["name"], "lean-ctx");
2498        assert_eq!(e["type"], "stdio");
2499        assert_eq!(e["command"], "lean-ctx");
2500        assert_eq!(e["disabled"], false);
2501        assert_eq!(e["useShellInterpolation"], false);
2502        assert!(e["id"].as_str().is_some());
2503        assert!(e["env"]["LEAN_CTX_DATA_DIR"].as_str().is_some());
2504    }
2505
2506    #[test]
2507    fn augment_vscode_preserves_existing_entries_and_upserts() {
2508        let dir = tempfile::tempdir().unwrap();
2509        let path = dir.path().join("mcpServers.json");
2510        std::fs::write(
2511            &path,
2512            r#"[{"type":"stdio","id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","name":"github","disabled":false,"command":"gh-mcp","args":[],"env":{}}]"#,
2513        )
2514        .unwrap();
2515
2516        let t = target("Augment (VS Code)", path.clone(), ConfigType::AugmentVsCode);
2517        let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
2518        assert_eq!(res.action, WriteAction::Updated);
2519
2520        let arr: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2521        let entries = arr.as_array().unwrap();
2522        assert_eq!(entries.len(), 2, "github entry must be preserved");
2523        assert!(entries.iter().any(|e| e["name"] == "github"));
2524        assert!(entries.iter().any(|e| e["name"] == "lean-ctx"));
2525    }
2526
2527    #[test]
2528    fn augment_vscode_is_idempotent() {
2529        let dir = tempfile::tempdir().unwrap();
2530        let path = dir.path().join("mcpServers.json");
2531        let t = target("Augment (VS Code)", path.clone(), ConfigType::AugmentVsCode);
2532
2533        let first = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
2534        let second = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
2535        assert_eq!(first.action, WriteAction::Created);
2536        assert_eq!(second.action, WriteAction::Already);
2537
2538        let arr: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2539        let entries = arr.as_array().unwrap();
2540        assert_eq!(
2541            entries.iter().filter(|e| e["name"] == "lean-ctx").count(),
2542            1,
2543            "lean-ctx must not duplicate"
2544        );
2545    }
2546
2547    #[test]
2548    fn augment_vscode_remove_only_drops_lean_ctx_entry() {
2549        let dir = tempfile::tempdir().unwrap();
2550        let path = dir.path().join("mcpServers.json");
2551        let t = target("Augment (VS Code)", path.clone(), ConfigType::AugmentVsCode);
2552
2553        // Seed: github + lean-ctx (via the writer so the id matches).
2554        std::fs::write(
2555            &path,
2556            r#"[{"type":"stdio","id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","name":"github","disabled":false,"command":"gh-mcp","args":[],"env":{}}]"#,
2557        )
2558        .unwrap();
2559        write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
2560
2561        let res = remove_lean_ctx_server(&t, WriteOptions::default()).unwrap();
2562        assert_eq!(res.action, WriteAction::Updated);
2563
2564        let arr: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2565        let entries = arr.as_array().unwrap();
2566        assert_eq!(entries.len(), 1);
2567        assert_eq!(entries[0]["name"], "github");
2568    }
2569
2570    #[test]
2571    fn augment_vscode_remove_is_noop_when_missing() {
2572        let dir = tempfile::tempdir().unwrap();
2573        let path = dir.path().join("mcpServers.json");
2574        std::fs::write(&path, "[]").unwrap();
2575        let t = target("Augment (VS Code)", path.clone(), ConfigType::AugmentVsCode);
2576
2577        let res = remove_lean_ctx_server(&t, WriteOptions::default()).unwrap();
2578        assert_eq!(res.action, WriteAction::Already);
2579    }
2580}