Skip to main content

lean_ctx/core/editor_registry/
writers.rs

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