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 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}