Skip to main content

lean_ctx/core/editor_registry/
writers.rs

1use serde_json::Value;
2
3use super::types::{ConfigType, EditorTarget};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum WriteAction {
7    Created,
8    Updated,
9    Already,
10}
11
12#[derive(Debug, Clone, Copy, Default)]
13pub struct WriteOptions {
14    pub overwrite_invalid: bool,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct WriteResult {
19    pub action: WriteAction,
20    pub note: Option<String>,
21}
22
23pub fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
24    write_config_with_options(target, binary, WriteOptions::default())
25}
26
27pub fn write_config_with_options(
28    target: &EditorTarget,
29    binary: &str,
30    opts: WriteOptions,
31) -> Result<WriteResult, String> {
32    if let Some(parent) = target.config_path.parent() {
33        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
34    }
35
36    match target.config_type {
37        ConfigType::McpJson => write_mcp_json(target, binary, opts),
38        ConfigType::Zed => write_zed_config(target, binary, opts),
39        ConfigType::Codex => write_codex_config(target, binary),
40        ConfigType::VsCodeMcp => write_vscode_mcp(target, binary, opts),
41        ConfigType::OpenCode => write_opencode_config(target, binary, opts),
42        ConfigType::Crush => write_crush_config(target, binary, opts),
43    }
44}
45
46fn auto_approve_tools() -> Vec<&'static str> {
47    vec![
48        "ctx_read",
49        "ctx_shell",
50        "ctx_search",
51        "ctx_tree",
52        "ctx_overview",
53        "ctx_preload",
54        "ctx_compress",
55        "ctx_metrics",
56        "ctx_session",
57        "ctx_knowledge",
58        "ctx_agent",
59        "ctx_share",
60        "ctx_analyze",
61        "ctx_benchmark",
62        "ctx_cache",
63        "ctx_discover",
64        "ctx_smart_read",
65        "ctx_delta",
66        "ctx_edit",
67        "ctx_dedup",
68        "ctx_fill",
69        "ctx_intent",
70        "ctx_response",
71        "ctx_context",
72        "ctx_graph",
73        "ctx_wrapped",
74        "ctx_multi_read",
75        "ctx_semantic_search",
76        "ctx_symbol",
77        "ctx_outline",
78        "ctx_callers",
79        "ctx_callees",
80        "ctx_routes",
81        "ctx_graph_diagram",
82        "ctx_cost",
83        "ctx_heatmap",
84        "ctx_task",
85        "ctx_impact",
86        "ctx_architecture",
87        "ctx_workflow",
88        "ctx",
89    ]
90}
91
92fn lean_ctx_server_entry(binary: &str, data_dir: &str) -> Value {
93    serde_json::json!({
94        "command": binary,
95        "env": {
96            "LEAN_CTX_DATA_DIR": data_dir
97        },
98        "autoApprove": auto_approve_tools()
99    })
100}
101
102fn default_data_dir() -> Result<String, String> {
103    Ok(crate::core::data_dir::lean_ctx_data_dir()?
104        .to_string_lossy()
105        .to_string())
106}
107
108fn write_mcp_json(
109    target: &EditorTarget,
110    binary: &str,
111    opts: WriteOptions,
112) -> Result<WriteResult, String> {
113    let data_dir = default_data_dir()?;
114    let desired = lean_ctx_server_entry(binary, &data_dir);
115
116    // Claude Code manages ~/.claude.json and may overwrite it on first start.
117    // Prefer the official CLI integration when available.
118    if target.agent_key == "claude" || target.name == "Claude Code" {
119        if let Ok(result) = try_claude_mcp_add(&desired) {
120            return Ok(result);
121        }
122    }
123
124    if target.config_path.exists() {
125        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
126        let mut json = match serde_json::from_str::<Value>(&content) {
127            Ok(v) => v,
128            Err(e) => {
129                if !opts.overwrite_invalid {
130                    return Err(e.to_string());
131                }
132                backup_invalid_file(&target.config_path)?;
133                return write_mcp_json_fresh(
134                    &target.config_path,
135                    desired,
136                    Some("overwrote invalid JSON".to_string()),
137                );
138            }
139        };
140        let obj = json
141            .as_object_mut()
142            .ok_or_else(|| "root JSON must be an object".to_string())?;
143
144        let servers = obj
145            .entry("mcpServers")
146            .or_insert_with(|| serde_json::json!({}));
147        let servers_obj = servers
148            .as_object_mut()
149            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
150
151        let existing = servers_obj.get("lean-ctx").cloned();
152        if existing.as_ref() == Some(&desired) {
153            return Ok(WriteResult {
154                action: WriteAction::Already,
155                note: None,
156            });
157        }
158        servers_obj.insert("lean-ctx".to_string(), desired);
159
160        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
161        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
162        return Ok(WriteResult {
163            action: WriteAction::Updated,
164            note: None,
165        });
166    }
167
168    write_mcp_json_fresh(&target.config_path, desired, None)
169}
170
171fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
172    use std::io::Write;
173    use std::process::{Command, Stdio};
174
175    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
176
177    let mut child = Command::new("claude")
178        .args(["mcp", "add-json", "--scope", "user", "lean-ctx"])
179        .stdin(Stdio::piped())
180        .stdout(Stdio::null())
181        .stderr(Stdio::null())
182        .spawn()
183        .map_err(|e| e.to_string())?;
184
185    if let Some(stdin) = child.stdin.as_mut() {
186        stdin
187            .write_all(server_json.as_bytes())
188            .map_err(|e| e.to_string())?;
189    }
190    let status = child.wait().map_err(|e| e.to_string())?;
191
192    if status.success() {
193        Ok(WriteResult {
194            action: WriteAction::Updated,
195            note: Some("via claude mcp add-json".to_string()),
196        })
197    } else {
198        Err("claude mcp add-json failed".to_string())
199    }
200}
201
202fn write_mcp_json_fresh(
203    path: &std::path::Path,
204    desired: Value,
205    note: Option<String>,
206) -> Result<WriteResult, String> {
207    let content = serde_json::to_string_pretty(&serde_json::json!({
208        "mcpServers": { "lean-ctx": desired }
209    }))
210    .map_err(|e| e.to_string())?;
211    crate::config_io::write_atomic_with_backup(path, &content)?;
212    Ok(WriteResult {
213        action: if note.is_some() {
214            WriteAction::Updated
215        } else {
216            WriteAction::Created
217        },
218        note,
219    })
220}
221
222fn write_zed_config(
223    target: &EditorTarget,
224    binary: &str,
225    opts: WriteOptions,
226) -> Result<WriteResult, String> {
227    let desired = serde_json::json!({
228        "source": "custom",
229        "command": binary,
230        "args": [],
231        "env": {}
232    });
233
234    if target.config_path.exists() {
235        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
236        let mut json = match serde_json::from_str::<Value>(&content) {
237            Ok(v) => v,
238            Err(e) => {
239                if !opts.overwrite_invalid {
240                    return Err(e.to_string());
241                }
242                backup_invalid_file(&target.config_path)?;
243                return write_zed_config_fresh(
244                    &target.config_path,
245                    desired,
246                    Some("overwrote invalid JSON".to_string()),
247                );
248            }
249        };
250        let obj = json
251            .as_object_mut()
252            .ok_or_else(|| "root JSON must be an object".to_string())?;
253
254        let servers = obj
255            .entry("context_servers")
256            .or_insert_with(|| serde_json::json!({}));
257        let servers_obj = servers
258            .as_object_mut()
259            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
260
261        let existing = servers_obj.get("lean-ctx").cloned();
262        if existing.as_ref() == Some(&desired) {
263            return Ok(WriteResult {
264                action: WriteAction::Already,
265                note: None,
266            });
267        }
268        servers_obj.insert("lean-ctx".to_string(), desired);
269
270        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
271        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
272        return Ok(WriteResult {
273            action: WriteAction::Updated,
274            note: None,
275        });
276    }
277
278    write_zed_config_fresh(&target.config_path, desired, None)
279}
280
281fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
282    if target.config_path.exists() {
283        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
284        let updated = upsert_codex_toml(&content, binary);
285        if updated == content {
286            return Ok(WriteResult {
287                action: WriteAction::Already,
288                note: None,
289            });
290        }
291        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
292        return Ok(WriteResult {
293            action: WriteAction::Updated,
294            note: None,
295        });
296    }
297
298    let content = format!(
299        "[mcp_servers.lean-ctx]\ncommand = \"{}\"\nargs = []\n",
300        binary
301    );
302    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
303    Ok(WriteResult {
304        action: WriteAction::Created,
305        note: None,
306    })
307}
308
309fn write_zed_config_fresh(
310    path: &std::path::Path,
311    desired: Value,
312    note: Option<String>,
313) -> Result<WriteResult, String> {
314    let content = serde_json::to_string_pretty(&serde_json::json!({
315        "context_servers": { "lean-ctx": desired }
316    }))
317    .map_err(|e| e.to_string())?;
318    crate::config_io::write_atomic_with_backup(path, &content)?;
319    Ok(WriteResult {
320        action: if note.is_some() {
321            WriteAction::Updated
322        } else {
323            WriteAction::Created
324        },
325        note,
326    })
327}
328
329fn write_vscode_mcp(
330    target: &EditorTarget,
331    binary: &str,
332    opts: WriteOptions,
333) -> Result<WriteResult, String> {
334    let desired = serde_json::json!({ "command": binary, "args": [] });
335
336    if target.config_path.exists() {
337        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
338        let mut json = match serde_json::from_str::<Value>(&content) {
339            Ok(v) => v,
340            Err(e) => {
341                if !opts.overwrite_invalid {
342                    return Err(e.to_string());
343                }
344                backup_invalid_file(&target.config_path)?;
345                return write_vscode_mcp_fresh(
346                    &target.config_path,
347                    binary,
348                    Some("overwrote invalid JSON".to_string()),
349                );
350            }
351        };
352        let obj = json
353            .as_object_mut()
354            .ok_or_else(|| "root JSON must be an object".to_string())?;
355
356        let servers = obj
357            .entry("servers")
358            .or_insert_with(|| serde_json::json!({}));
359        let servers_obj = servers
360            .as_object_mut()
361            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
362
363        let existing = servers_obj.get("lean-ctx").cloned();
364        if existing.as_ref() == Some(&desired) {
365            return Ok(WriteResult {
366                action: WriteAction::Already,
367                note: None,
368            });
369        }
370        servers_obj.insert("lean-ctx".to_string(), desired);
371
372        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
373        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
374        return Ok(WriteResult {
375            action: WriteAction::Updated,
376            note: None,
377        });
378    }
379
380    write_vscode_mcp_fresh(&target.config_path, binary, None)
381}
382
383fn write_vscode_mcp_fresh(
384    path: &std::path::Path,
385    binary: &str,
386    note: Option<String>,
387) -> Result<WriteResult, String> {
388    let content = serde_json::to_string_pretty(&serde_json::json!({
389        "servers": { "lean-ctx": { "command": binary, "args": [] } }
390    }))
391    .map_err(|e| e.to_string())?;
392    crate::config_io::write_atomic_with_backup(path, &content)?;
393    Ok(WriteResult {
394        action: if note.is_some() {
395            WriteAction::Updated
396        } else {
397            WriteAction::Created
398        },
399        note,
400    })
401}
402
403fn write_opencode_config(
404    target: &EditorTarget,
405    binary: &str,
406    opts: WriteOptions,
407) -> Result<WriteResult, String> {
408    let desired = serde_json::json!({
409        "type": "local",
410        "command": [binary],
411        "enabled": true
412    });
413
414    if target.config_path.exists() {
415        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
416        let mut json = match serde_json::from_str::<Value>(&content) {
417            Ok(v) => v,
418            Err(e) => {
419                if !opts.overwrite_invalid {
420                    return Err(e.to_string());
421                }
422                backup_invalid_file(&target.config_path)?;
423                return write_opencode_fresh(
424                    &target.config_path,
425                    binary,
426                    Some("overwrote invalid JSON".to_string()),
427                );
428            }
429        };
430        let obj = json
431            .as_object_mut()
432            .ok_or_else(|| "root JSON must be an object".to_string())?;
433        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
434        let mcp_obj = mcp
435            .as_object_mut()
436            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
437
438        let existing = mcp_obj.get("lean-ctx").cloned();
439        if existing.as_ref() == Some(&desired) {
440            return Ok(WriteResult {
441                action: WriteAction::Already,
442                note: None,
443            });
444        }
445        mcp_obj.insert("lean-ctx".to_string(), desired);
446
447        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
448        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
449        return Ok(WriteResult {
450            action: WriteAction::Updated,
451            note: None,
452        });
453    }
454
455    write_opencode_fresh(&target.config_path, binary, None)
456}
457
458fn write_opencode_fresh(
459    path: &std::path::Path,
460    binary: &str,
461    note: Option<String>,
462) -> Result<WriteResult, String> {
463    let content = serde_json::to_string_pretty(&serde_json::json!({
464        "$schema": "https://opencode.ai/config.json",
465        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true } }
466    }))
467    .map_err(|e| e.to_string())?;
468    crate::config_io::write_atomic_with_backup(path, &content)?;
469    Ok(WriteResult {
470        action: if note.is_some() {
471            WriteAction::Updated
472        } else {
473            WriteAction::Created
474        },
475        note,
476    })
477}
478
479fn write_crush_config(
480    target: &EditorTarget,
481    binary: &str,
482    opts: WriteOptions,
483) -> Result<WriteResult, String> {
484    let desired = serde_json::json!({ "type": "stdio", "command": binary });
485
486    if target.config_path.exists() {
487        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
488        let mut json = match serde_json::from_str::<Value>(&content) {
489            Ok(v) => v,
490            Err(e) => {
491                if !opts.overwrite_invalid {
492                    return Err(e.to_string());
493                }
494                backup_invalid_file(&target.config_path)?;
495                return write_crush_fresh(
496                    &target.config_path,
497                    desired,
498                    Some("overwrote invalid JSON".to_string()),
499                );
500            }
501        };
502        let obj = json
503            .as_object_mut()
504            .ok_or_else(|| "root JSON must be an object".to_string())?;
505        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
506        let mcp_obj = mcp
507            .as_object_mut()
508            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
509
510        let existing = mcp_obj.get("lean-ctx").cloned();
511        if existing.as_ref() == Some(&desired) {
512            return Ok(WriteResult {
513                action: WriteAction::Already,
514                note: None,
515            });
516        }
517        mcp_obj.insert("lean-ctx".to_string(), desired);
518
519        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
520        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
521        return Ok(WriteResult {
522            action: WriteAction::Updated,
523            note: None,
524        });
525    }
526
527    write_crush_fresh(&target.config_path, desired, None)
528}
529
530fn write_crush_fresh(
531    path: &std::path::Path,
532    desired: Value,
533    note: Option<String>,
534) -> Result<WriteResult, String> {
535    let content = serde_json::to_string_pretty(&serde_json::json!({
536        "mcp": { "lean-ctx": desired }
537    }))
538    .map_err(|e| e.to_string())?;
539    crate::config_io::write_atomic_with_backup(path, &content)?;
540    Ok(WriteResult {
541        action: if note.is_some() {
542            WriteAction::Updated
543        } else {
544            WriteAction::Created
545        },
546        note,
547    })
548}
549
550fn upsert_codex_toml(existing: &str, binary: &str) -> String {
551    let mut out = String::with_capacity(existing.len() + 128);
552    let mut in_section = false;
553    let mut saw_section = false;
554    let mut wrote_command = false;
555    let mut wrote_args = false;
556
557    for line in existing.lines() {
558        let trimmed = line.trim();
559        if trimmed.starts_with('[') && trimmed.ends_with(']') {
560            if in_section && !wrote_command {
561                out.push_str(&format!("command = \"{}\"\n", binary));
562                wrote_command = true;
563            }
564            if in_section && !wrote_args {
565                out.push_str("args = []\n");
566                wrote_args = true;
567            }
568            in_section = trimmed == "[mcp_servers.lean-ctx]";
569            if in_section {
570                saw_section = true;
571            }
572            out.push_str(line);
573            out.push('\n');
574            continue;
575        }
576
577        if in_section {
578            if trimmed.starts_with("command") && trimmed.contains('=') {
579                out.push_str(&format!("command = \"{}\"\n", binary));
580                wrote_command = true;
581                continue;
582            }
583            if trimmed.starts_with("args") && trimmed.contains('=') {
584                out.push_str("args = []\n");
585                wrote_args = true;
586                continue;
587            }
588        }
589
590        out.push_str(line);
591        out.push('\n');
592    }
593
594    if saw_section {
595        if in_section && !wrote_command {
596            out.push_str(&format!("command = \"{}\"\n", binary));
597        }
598        if in_section && !wrote_args {
599            out.push_str("args = []\n");
600        }
601        return out;
602    }
603
604    if !out.ends_with('\n') {
605        out.push('\n');
606    }
607    out.push_str("\n[mcp_servers.lean-ctx]\n");
608    out.push_str(&format!("command = \"{}\"\n", binary));
609    out.push_str("args = []\n");
610    out
611}
612
613fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
614    if !path.exists() {
615        return Ok(());
616    }
617    let parent = path
618        .parent()
619        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
620    let filename = path
621        .file_name()
622        .ok_or_else(|| "invalid path (no filename)".to_string())?
623        .to_string_lossy();
624    let pid = std::process::id();
625    let nanos = std::time::SystemTime::now()
626        .duration_since(std::time::UNIX_EPOCH)
627        .map(|d| d.as_nanos())
628        .unwrap_or(0);
629    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
630    std::fs::rename(path, bak).map_err(|e| e.to_string())?;
631    Ok(())
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use std::path::PathBuf;
638
639    fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
640        EditorTarget {
641            name: "test",
642            agent_key: "test".to_string(),
643            config_path: path,
644            detect_path: PathBuf::from("/nonexistent"),
645            config_type: ty,
646        }
647    }
648
649    #[test]
650    fn mcp_json_upserts_and_preserves_other_servers() {
651        let dir = tempfile::tempdir().unwrap();
652        let path = dir.path().join("mcp.json");
653        std::fs::write(
654            &path,
655            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
656        )
657        .unwrap();
658
659        let t = target(path.clone(), ConfigType::McpJson);
660        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
661        assert_eq!(res.action, WriteAction::Updated);
662
663        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
664        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
665        assert_eq!(
666            json["mcpServers"]["lean-ctx"]["command"],
667            "/new/path/lean-ctx"
668        );
669        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
670        assert!(
671            json["mcpServers"]["lean-ctx"]["autoApprove"]
672                .as_array()
673                .unwrap()
674                .len()
675                > 5
676        );
677    }
678
679    #[test]
680    fn crush_config_writes_mcp_root() {
681        let dir = tempfile::tempdir().unwrap();
682        let path = dir.path().join("crush.json");
683        std::fs::write(
684            &path,
685            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
686        )
687        .unwrap();
688
689        let t = target(path.clone(), ConfigType::Crush);
690        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
691        assert_eq!(res.action, WriteAction::Updated);
692
693        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
694        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
695        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
696    }
697
698    #[test]
699    fn codex_toml_upserts_existing_section() {
700        let dir = tempfile::tempdir().unwrap();
701        let path = dir.path().join("config.toml");
702        std::fs::write(
703            &path,
704            r#"[mcp_servers.lean-ctx]
705command = "old"
706args = ["x"]
707"#,
708        )
709        .unwrap();
710
711        let t = target(path.clone(), ConfigType::Codex);
712        let res = write_codex_config(&t, "new").unwrap();
713        assert_eq!(res.action, WriteAction::Updated);
714
715        let content = std::fs::read_to_string(&path).unwrap();
716        assert!(content.contains(r#"command = "new""#));
717        assert!(content.contains("args = []"));
718    }
719
720    #[test]
721    fn upsert_codex_toml_inserts_new_section_when_missing() {
722        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
723        assert!(updated.contains("[mcp_servers.lean-ctx]"));
724        assert!(updated.contains("command = \"lean-ctx\""));
725        assert!(updated.contains("args = []"));
726    }
727
728    #[test]
729    fn auto_approve_contains_core_tools() {
730        let tools = auto_approve_tools();
731        assert!(tools.contains(&"ctx_read"));
732        assert!(tools.contains(&"ctx_shell"));
733        assert!(tools.contains(&"ctx_search"));
734        assert!(tools.contains(&"ctx_workflow"));
735        assert!(tools.contains(&"ctx_cost"));
736    }
737}