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 mut out = String::with_capacity(existing.len());
324 let mut skipping = false;
325 for line in existing.lines() {
326 let trimmed = line.trim();
327 if !skipping && trimmed == header {
328 skipping = true;
329 continue;
330 }
331 if skipping {
332 if trimmed.starts_with('[') && trimmed.ends_with(']') {
333 skipping = false;
334 out.push_str(line);
335 out.push('\n');
336 }
337 continue;
338 }
339 out.push_str(line);
340 out.push('\n');
341 }
342 out
343}
344
345fn remove_lean_ctx_hermes_yaml_server(path: &std::path::Path) -> Result<WriteResult, String> {
346 if !path.exists() {
347 return Ok(WriteResult {
348 action: WriteAction::Already,
349 note: Some("hermes config not found".to_string()),
350 });
351 }
352 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
353 let updated = remove_hermes_yaml_mcp_server_block(&content, "lean-ctx");
354 if updated == content {
355 return Ok(WriteResult {
356 action: WriteAction::Already,
357 note: Some("lean-ctx not configured".to_string()),
358 });
359 }
360 crate::config_io::write_atomic_with_backup(path, &updated)?;
361 Ok(WriteResult {
362 action: WriteAction::Updated,
363 note: Some("removed lean-ctx from mcp_servers".to_string()),
364 })
365}
366
367fn remove_hermes_yaml_mcp_server_block(existing: &str, name: &str) -> String {
368 let mut out = String::with_capacity(existing.len());
369 let mut in_mcp = false;
370 let mut skipping = false;
371 for line in existing.lines() {
372 let trimmed = line.trim_end();
373 if trimmed == "mcp_servers:" {
374 in_mcp = true;
375 out.push_str(line);
376 out.push('\n');
377 continue;
378 }
379
380 if in_mcp {
381 let is_child = line.starts_with(" ") && !line.starts_with(" ");
382 let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
383
384 if is_toplevel {
385 in_mcp = false;
386 skipping = false;
387 }
388
389 if skipping {
390 if is_child || is_toplevel {
391 skipping = false;
392 out.push_str(line);
393 out.push('\n');
394 }
395 continue;
396 }
397
398 if is_child && line.trim() == format!("{name}:") {
399 skipping = true;
400 continue;
401 }
402 }
403
404 out.push_str(line);
405 out.push('\n');
406 }
407 out
408}
409
410pub fn auto_approve_tools() -> Vec<&'static str> {
411 vec![
412 "ctx_read",
413 "ctx_shell",
414 "ctx_search",
415 "ctx_tree",
416 "ctx_overview",
417 "ctx_preload",
418 "ctx_compress",
419 "ctx_metrics",
420 "ctx_session",
421 "ctx_knowledge",
422 "ctx_agent",
423 "ctx_share",
424 "ctx_analyze",
425 "ctx_benchmark",
426 "ctx_cache",
427 "ctx_discover",
428 "ctx_smart_read",
429 "ctx_delta",
430 "ctx_edit",
431 "ctx_dedup",
432 "ctx_fill",
433 "ctx_intent",
434 "ctx_response",
435 "ctx_context",
436 "ctx_graph",
437 "ctx_wrapped",
438 "ctx_multi_read",
439 "ctx_semantic_search",
440 "ctx_symbol",
441 "ctx_outline",
442 "ctx_callers",
443 "ctx_callees",
444 "ctx_callgraph",
445 "ctx_routes",
446 "ctx_graph_diagram",
447 "ctx_cost",
448 "ctx_heatmap",
449 "ctx_task",
450 "ctx_impact",
451 "ctx_architecture",
452 "ctx_workflow",
453 "ctx",
454 ]
455}
456
457fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
458 let mut entry = serde_json::json!({
459 "command": binary,
460 "env": {
461 "LEAN_CTX_DATA_DIR": data_dir
462 }
463 });
464 if include_auto_approve {
465 entry["autoApprove"] = serde_json::json!(auto_approve_tools());
466 }
467 entry
468}
469
470fn supports_auto_approve(target: &EditorTarget) -> bool {
471 crate::core::client_constraints::by_editor_name(target.name)
472 .is_some_and(|c| c.supports_auto_approve)
473}
474
475fn default_data_dir() -> Result<String, String> {
476 Ok(crate::core::data_dir::lean_ctx_data_dir()?
477 .to_string_lossy()
478 .to_string())
479}
480
481fn write_mcp_json(
482 target: &EditorTarget,
483 binary: &str,
484 opts: WriteOptions,
485) -> Result<WriteResult, String> {
486 let data_dir = default_data_dir()?;
487 let include_aa = supports_auto_approve(target);
488 let desired = lean_ctx_server_entry(binary, &data_dir, include_aa);
489
490 if (target.agent_key == "claude" || target.name == "Claude Code")
495 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
496 {
497 if let Ok(result) = try_claude_mcp_add(&desired) {
498 return Ok(result);
499 }
500 }
501
502 if target.config_path.exists() {
503 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
504 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
505 Ok(v) => v,
506 Err(e) => {
507 if !opts.overwrite_invalid {
508 return Err(e.to_string());
509 }
510 backup_invalid_file(&target.config_path)?;
511 return write_mcp_json_fresh(
512 &target.config_path,
513 &desired,
514 Some("overwrote invalid JSON".to_string()),
515 );
516 }
517 };
518 let obj = json
519 .as_object_mut()
520 .ok_or_else(|| "root JSON must be an object".to_string())?;
521
522 let servers = obj
523 .entry("mcpServers")
524 .or_insert_with(|| serde_json::json!({}));
525 let servers_obj = servers
526 .as_object_mut()
527 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
528
529 let existing = servers_obj.get("lean-ctx").cloned();
530 if existing.as_ref() == Some(&desired) {
531 return Ok(WriteResult {
532 action: WriteAction::Already,
533 note: None,
534 });
535 }
536 servers_obj.insert("lean-ctx".to_string(), desired);
537
538 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
539 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
540 return Ok(WriteResult {
541 action: WriteAction::Updated,
542 note: None,
543 });
544 }
545
546 write_mcp_json_fresh(&target.config_path, &desired, None)
547}
548
549fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
550 use std::io::Write;
551 use std::process::{Command, Stdio};
552 use std::time::{Duration, Instant};
553
554 let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
555
556 let mut cmd = if cfg!(windows) {
559 let mut c = Command::new("cmd");
560 c.args([
561 "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
562 ]);
563 c
564 } else {
565 let mut c = Command::new("claude");
566 c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
567 c
568 };
569
570 let mut child = cmd
571 .stdin(Stdio::piped())
572 .stdout(Stdio::null())
573 .stderr(Stdio::null())
574 .spawn()
575 .map_err(|e| e.to_string())?;
576
577 if let Some(mut stdin) = child.stdin.take() {
578 let _ = stdin.write_all(server_json.as_bytes());
579 }
580
581 let deadline = Duration::from_secs(3);
582 let start = Instant::now();
583 loop {
584 match child.try_wait() {
585 Ok(Some(status)) => {
586 return if status.success() {
587 Ok(WriteResult {
588 action: WriteAction::Updated,
589 note: Some("via claude mcp add-json".to_string()),
590 })
591 } else {
592 Err("claude mcp add-json failed".to_string())
593 };
594 }
595 Ok(None) => {
596 if start.elapsed() > deadline {
597 let _ = child.kill();
598 let _ = child.wait();
599 return Err("claude mcp add-json timed out".to_string());
600 }
601 std::thread::sleep(Duration::from_millis(20));
602 }
603 Err(e) => return Err(e.to_string()),
604 }
605 }
606}
607
608fn write_mcp_json_fresh(
609 path: &std::path::Path,
610 desired: &Value,
611 note: Option<String>,
612) -> Result<WriteResult, String> {
613 let content = serde_json::to_string_pretty(&serde_json::json!({
614 "mcpServers": { "lean-ctx": desired }
615 }))
616 .map_err(|e| e.to_string())?;
617 crate::config_io::write_atomic_with_backup(path, &content)?;
618 Ok(WriteResult {
619 action: if note.is_some() {
620 WriteAction::Updated
621 } else {
622 WriteAction::Created
623 },
624 note,
625 })
626}
627
628fn write_zed_config(
629 target: &EditorTarget,
630 binary: &str,
631 opts: WriteOptions,
632) -> Result<WriteResult, String> {
633 let desired = serde_json::json!({
634 "source": "custom",
635 "command": binary,
636 "args": [],
637 "env": {}
638 });
639
640 if target.config_path.exists() {
641 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
642 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
643 Ok(v) => v,
644 Err(e) => {
645 if !opts.overwrite_invalid {
646 return Err(e.to_string());
647 }
648 backup_invalid_file(&target.config_path)?;
649 return write_zed_config_fresh(
650 &target.config_path,
651 &desired,
652 Some("overwrote invalid JSON".to_string()),
653 );
654 }
655 };
656 let obj = json
657 .as_object_mut()
658 .ok_or_else(|| "root JSON must be an object".to_string())?;
659
660 let servers = obj
661 .entry("context_servers")
662 .or_insert_with(|| serde_json::json!({}));
663 let servers_obj = servers
664 .as_object_mut()
665 .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
666
667 let existing = servers_obj.get("lean-ctx").cloned();
668 if existing.as_ref() == Some(&desired) {
669 return Ok(WriteResult {
670 action: WriteAction::Already,
671 note: None,
672 });
673 }
674 servers_obj.insert("lean-ctx".to_string(), desired);
675
676 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
677 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
678 return Ok(WriteResult {
679 action: WriteAction::Updated,
680 note: None,
681 });
682 }
683
684 write_zed_config_fresh(&target.config_path, &desired, None)
685}
686
687fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
688 if target.config_path.exists() {
689 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
690 let updated = upsert_codex_toml(&content, binary);
691 if updated == content {
692 return Ok(WriteResult {
693 action: WriteAction::Already,
694 note: None,
695 });
696 }
697 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
698 return Ok(WriteResult {
699 action: WriteAction::Updated,
700 note: None,
701 });
702 }
703
704 let content = format!(
705 "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
706 toml_quote(binary)
707 );
708 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
709 Ok(WriteResult {
710 action: WriteAction::Created,
711 note: None,
712 })
713}
714
715fn write_zed_config_fresh(
716 path: &std::path::Path,
717 desired: &Value,
718 note: Option<String>,
719) -> Result<WriteResult, String> {
720 let content = serde_json::to_string_pretty(&serde_json::json!({
721 "context_servers": { "lean-ctx": desired }
722 }))
723 .map_err(|e| e.to_string())?;
724 crate::config_io::write_atomic_with_backup(path, &content)?;
725 Ok(WriteResult {
726 action: if note.is_some() {
727 WriteAction::Updated
728 } else {
729 WriteAction::Created
730 },
731 note,
732 })
733}
734
735fn write_vscode_mcp(
736 target: &EditorTarget,
737 binary: &str,
738 opts: WriteOptions,
739) -> Result<WriteResult, String> {
740 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
741 .map(|d| d.to_string_lossy().to_string())
742 .unwrap_or_default();
743 let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
744
745 if target.config_path.exists() {
746 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
747 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
748 Ok(v) => v,
749 Err(e) => {
750 if !opts.overwrite_invalid {
751 return Err(e.to_string());
752 }
753 backup_invalid_file(&target.config_path)?;
754 return write_vscode_mcp_fresh(
755 &target.config_path,
756 binary,
757 Some("overwrote invalid JSON".to_string()),
758 );
759 }
760 };
761 let obj = json
762 .as_object_mut()
763 .ok_or_else(|| "root JSON must be an object".to_string())?;
764
765 let servers = obj
766 .entry("servers")
767 .or_insert_with(|| serde_json::json!({}));
768 let servers_obj = servers
769 .as_object_mut()
770 .ok_or_else(|| "\"servers\" must be an object".to_string())?;
771
772 let existing = servers_obj.get("lean-ctx").cloned();
773 if existing.as_ref() == Some(&desired) {
774 return Ok(WriteResult {
775 action: WriteAction::Already,
776 note: None,
777 });
778 }
779 servers_obj.insert("lean-ctx".to_string(), desired);
780
781 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
782 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
783 return Ok(WriteResult {
784 action: WriteAction::Updated,
785 note: None,
786 });
787 }
788
789 write_vscode_mcp_fresh(&target.config_path, binary, None)
790}
791
792fn write_vscode_mcp_fresh(
793 path: &std::path::Path,
794 binary: &str,
795 note: Option<String>,
796) -> Result<WriteResult, String> {
797 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
798 .map(|d| d.to_string_lossy().to_string())
799 .unwrap_or_default();
800 let content = serde_json::to_string_pretty(&serde_json::json!({
801 "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
802 }))
803 .map_err(|e| e.to_string())?;
804 crate::config_io::write_atomic_with_backup(path, &content)?;
805 Ok(WriteResult {
806 action: if note.is_some() {
807 WriteAction::Updated
808 } else {
809 WriteAction::Created
810 },
811 note,
812 })
813}
814
815fn write_opencode_config(
816 target: &EditorTarget,
817 binary: &str,
818 opts: WriteOptions,
819) -> Result<WriteResult, String> {
820 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
821 .map(|d| d.to_string_lossy().to_string())
822 .unwrap_or_default();
823 let desired = serde_json::json!({
824 "type": "local",
825 "command": [binary],
826 "enabled": true,
827 "environment": { "LEAN_CTX_DATA_DIR": data_dir }
828 });
829
830 if target.config_path.exists() {
831 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
832 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
833 Ok(v) => v,
834 Err(e) => {
835 if !opts.overwrite_invalid {
836 return Err(e.to_string());
837 }
838 backup_invalid_file(&target.config_path)?;
839 return write_opencode_fresh(
840 &target.config_path,
841 binary,
842 Some("overwrote invalid JSON".to_string()),
843 );
844 }
845 };
846 let obj = json
847 .as_object_mut()
848 .ok_or_else(|| "root JSON must be an object".to_string())?;
849 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
850 let mcp_obj = mcp
851 .as_object_mut()
852 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
853
854 let existing = mcp_obj.get("lean-ctx").cloned();
855 if existing.as_ref() == Some(&desired) {
856 return Ok(WriteResult {
857 action: WriteAction::Already,
858 note: None,
859 });
860 }
861 mcp_obj.insert("lean-ctx".to_string(), desired);
862
863 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
864 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
865 return Ok(WriteResult {
866 action: WriteAction::Updated,
867 note: None,
868 });
869 }
870
871 write_opencode_fresh(&target.config_path, binary, None)
872}
873
874fn write_opencode_fresh(
875 path: &std::path::Path,
876 binary: &str,
877 note: Option<String>,
878) -> Result<WriteResult, String> {
879 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
880 .map(|d| d.to_string_lossy().to_string())
881 .unwrap_or_default();
882 let content = serde_json::to_string_pretty(&serde_json::json!({
883 "$schema": "https://opencode.ai/config.json",
884 "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
885 }))
886 .map_err(|e| e.to_string())?;
887 crate::config_io::write_atomic_with_backup(path, &content)?;
888 Ok(WriteResult {
889 action: if note.is_some() {
890 WriteAction::Updated
891 } else {
892 WriteAction::Created
893 },
894 note,
895 })
896}
897
898fn write_jetbrains_config(
899 target: &EditorTarget,
900 binary: &str,
901 opts: WriteOptions,
902) -> Result<WriteResult, String> {
903 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
904 .map(|d| d.to_string_lossy().to_string())
905 .unwrap_or_default();
906 let desired = serde_json::json!({
910 "command": binary,
911 "args": [],
912 "env": { "LEAN_CTX_DATA_DIR": data_dir }
913 });
914
915 if target.config_path.exists() {
916 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
917 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
918 Ok(v) => v,
919 Err(e) => {
920 if !opts.overwrite_invalid {
921 return Err(e.to_string());
922 }
923 backup_invalid_file(&target.config_path)?;
924 let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
925 let formatted = serde_json::to_string_pretty(&fresh).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: Some(
930 "overwrote invalid JSON (paste this snippet into JetBrains MCP settings)"
931 .to_string(),
932 ),
933 });
934 }
935 };
936 let obj = json
937 .as_object_mut()
938 .ok_or_else(|| "root JSON must be an object".to_string())?;
939
940 let servers = obj
941 .entry("mcpServers")
942 .or_insert_with(|| serde_json::json!({}));
943 let servers_obj = servers
944 .as_object_mut()
945 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
946
947 let existing = servers_obj.get("lean-ctx").cloned();
948 if existing.as_ref() == Some(&desired) {
949 return Ok(WriteResult {
950 action: WriteAction::Already,
951 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
952 });
953 }
954 servers_obj.insert("lean-ctx".to_string(), desired);
955
956 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
957 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
958 return Ok(WriteResult {
959 action: WriteAction::Updated,
960 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
961 });
962 }
963
964 let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
965 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
966 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
967 Ok(WriteResult {
968 action: WriteAction::Created,
969 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
970 })
971}
972
973fn write_amp_config(
974 target: &EditorTarget,
975 binary: &str,
976 opts: WriteOptions,
977) -> Result<WriteResult, String> {
978 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
979 .map(|d| d.to_string_lossy().to_string())
980 .unwrap_or_default();
981 let entry = serde_json::json!({
982 "command": binary,
983 "env": { "LEAN_CTX_DATA_DIR": data_dir }
984 });
985
986 if target.config_path.exists() {
987 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
988 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
989 Ok(v) => v,
990 Err(e) => {
991 if !opts.overwrite_invalid {
992 return Err(e.to_string());
993 }
994 backup_invalid_file(&target.config_path)?;
995 let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
996 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
997 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
998 return Ok(WriteResult {
999 action: WriteAction::Updated,
1000 note: Some("overwrote invalid JSON".to_string()),
1001 });
1002 }
1003 };
1004 let obj = json
1005 .as_object_mut()
1006 .ok_or_else(|| "root JSON must be an object".to_string())?;
1007 let servers = obj
1008 .entry("amp.mcpServers")
1009 .or_insert_with(|| serde_json::json!({}));
1010 let servers_obj = servers
1011 .as_object_mut()
1012 .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
1013
1014 let existing = servers_obj.get("lean-ctx").cloned();
1015 if existing.as_ref() == Some(&entry) {
1016 return Ok(WriteResult {
1017 action: WriteAction::Already,
1018 note: None,
1019 });
1020 }
1021 servers_obj.insert("lean-ctx".to_string(), entry);
1022
1023 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1024 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1025 return Ok(WriteResult {
1026 action: WriteAction::Updated,
1027 note: None,
1028 });
1029 }
1030
1031 let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1032 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1033 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1034 Ok(WriteResult {
1035 action: WriteAction::Created,
1036 note: None,
1037 })
1038}
1039
1040fn write_crush_config(
1041 target: &EditorTarget,
1042 binary: &str,
1043 opts: WriteOptions,
1044) -> Result<WriteResult, String> {
1045 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1046 .map(|d| d.to_string_lossy().to_string())
1047 .unwrap_or_default();
1048 let desired = serde_json::json!({
1049 "type": "stdio",
1050 "command": binary,
1051 "env": { "LEAN_CTX_DATA_DIR": data_dir }
1052 });
1053
1054 if target.config_path.exists() {
1055 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1056 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1057 Ok(v) => v,
1058 Err(e) => {
1059 if !opts.overwrite_invalid {
1060 return Err(e.to_string());
1061 }
1062 backup_invalid_file(&target.config_path)?;
1063 return write_crush_fresh(
1064 &target.config_path,
1065 &desired,
1066 Some("overwrote invalid JSON".to_string()),
1067 );
1068 }
1069 };
1070 let obj = json
1071 .as_object_mut()
1072 .ok_or_else(|| "root JSON must be an object".to_string())?;
1073 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1074 let mcp_obj = mcp
1075 .as_object_mut()
1076 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1077
1078 let existing = mcp_obj.get("lean-ctx").cloned();
1079 if existing.as_ref() == Some(&desired) {
1080 return Ok(WriteResult {
1081 action: WriteAction::Already,
1082 note: None,
1083 });
1084 }
1085 mcp_obj.insert("lean-ctx".to_string(), desired);
1086
1087 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1088 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1089 return Ok(WriteResult {
1090 action: WriteAction::Updated,
1091 note: None,
1092 });
1093 }
1094
1095 write_crush_fresh(&target.config_path, &desired, None)
1096}
1097
1098fn write_crush_fresh(
1099 path: &std::path::Path,
1100 desired: &Value,
1101 note: Option<String>,
1102) -> Result<WriteResult, String> {
1103 let content = serde_json::to_string_pretty(&serde_json::json!({
1104 "mcp": { "lean-ctx": desired }
1105 }))
1106 .map_err(|e| e.to_string())?;
1107 crate::config_io::write_atomic_with_backup(path, &content)?;
1108 Ok(WriteResult {
1109 action: if note.is_some() {
1110 WriteAction::Updated
1111 } else {
1112 WriteAction::Created
1113 },
1114 note,
1115 })
1116}
1117
1118fn upsert_codex_toml(existing: &str, binary: &str) -> String {
1119 let mut out = String::with_capacity(existing.len() + 128);
1120 let mut in_section = false;
1121 let mut saw_section = false;
1122 let mut wrote_command = false;
1123 let mut wrote_args = false;
1124
1125 for line in existing.lines() {
1126 let trimmed = line.trim();
1127 if trimmed == "[]" {
1128 continue;
1129 }
1130 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1131 if in_section && !wrote_command {
1132 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1133 wrote_command = true;
1134 }
1135 if in_section && !wrote_args {
1136 out.push_str("args = []\n");
1137 wrote_args = true;
1138 }
1139 in_section = trimmed == "[mcp_servers.lean-ctx]";
1140 if in_section {
1141 saw_section = true;
1142 }
1143 out.push_str(line);
1144 out.push('\n');
1145 continue;
1146 }
1147
1148 if in_section {
1149 if trimmed.starts_with("command") && trimmed.contains('=') {
1150 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1151 wrote_command = true;
1152 continue;
1153 }
1154 if trimmed.starts_with("args") && trimmed.contains('=') {
1155 out.push_str("args = []\n");
1156 wrote_args = true;
1157 continue;
1158 }
1159 }
1160
1161 out.push_str(line);
1162 out.push('\n');
1163 }
1164
1165 if saw_section {
1166 if in_section && !wrote_command {
1167 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1168 }
1169 if in_section && !wrote_args {
1170 out.push_str("args = []\n");
1171 }
1172 return out;
1173 }
1174
1175 if !out.ends_with('\n') {
1176 out.push('\n');
1177 }
1178 out.push_str("\n[mcp_servers.lean-ctx]\n");
1179 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1180 out.push_str("args = []\n");
1181 out
1182}
1183
1184fn write_gemini_settings(
1185 target: &EditorTarget,
1186 binary: &str,
1187 opts: WriteOptions,
1188) -> Result<WriteResult, String> {
1189 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1190 .map(|d| d.to_string_lossy().to_string())
1191 .unwrap_or_default();
1192 let entry = serde_json::json!({
1193 "command": binary,
1194 "env": { "LEAN_CTX_DATA_DIR": data_dir },
1195 "trust": true,
1196 });
1197
1198 if target.config_path.exists() {
1199 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1200 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1201 Ok(v) => v,
1202 Err(e) => {
1203 if !opts.overwrite_invalid {
1204 return Err(e.to_string());
1205 }
1206 backup_invalid_file(&target.config_path)?;
1207 let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1208 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1209 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1210 return Ok(WriteResult {
1211 action: WriteAction::Updated,
1212 note: Some("overwrote invalid JSON".to_string()),
1213 });
1214 }
1215 };
1216 let obj = json
1217 .as_object_mut()
1218 .ok_or_else(|| "root JSON must be an object".to_string())?;
1219 let servers = obj
1220 .entry("mcpServers")
1221 .or_insert_with(|| serde_json::json!({}));
1222 let servers_obj = servers
1223 .as_object_mut()
1224 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1225
1226 let existing = servers_obj.get("lean-ctx").cloned();
1227 if existing.as_ref() == Some(&entry) {
1228 return Ok(WriteResult {
1229 action: WriteAction::Already,
1230 note: None,
1231 });
1232 }
1233 servers_obj.insert("lean-ctx".to_string(), entry);
1234
1235 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1236 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1237 return Ok(WriteResult {
1238 action: WriteAction::Updated,
1239 note: None,
1240 });
1241 }
1242
1243 let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1244 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1245 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1246 Ok(WriteResult {
1247 action: WriteAction::Created,
1248 note: None,
1249 })
1250}
1251
1252fn write_hermes_yaml(
1253 target: &EditorTarget,
1254 binary: &str,
1255 _opts: WriteOptions,
1256) -> Result<WriteResult, String> {
1257 let data_dir = default_data_dir()?;
1258
1259 let lean_ctx_block = format!(
1260 " lean-ctx:\n command: \"{binary}\"\n env:\n LEAN_CTX_DATA_DIR: \"{data_dir}\""
1261 );
1262
1263 if target.config_path.exists() {
1264 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1265
1266 if content.contains("lean-ctx") {
1267 return Ok(WriteResult {
1268 action: WriteAction::Already,
1269 note: None,
1270 });
1271 }
1272
1273 let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1274 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1275 return Ok(WriteResult {
1276 action: WriteAction::Updated,
1277 note: None,
1278 });
1279 }
1280
1281 let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1282 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1283 Ok(WriteResult {
1284 action: WriteAction::Created,
1285 note: None,
1286 })
1287}
1288
1289fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1290 let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1291 let mut in_mcp_section = false;
1292 let mut saw_mcp_child = false;
1293 let mut inserted = false;
1294 let lines: Vec<&str> = existing.lines().collect();
1295
1296 for line in &lines {
1297 if !inserted && line.trim_end() == "mcp_servers:" {
1298 in_mcp_section = true;
1299 out.push_str(line);
1300 out.push('\n');
1301 continue;
1302 }
1303
1304 if in_mcp_section && !inserted {
1305 let is_child = line.starts_with(" ") && !line.trim().is_empty();
1306 let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1307
1308 if is_child {
1309 saw_mcp_child = true;
1310 out.push_str(line);
1311 out.push('\n');
1312 continue;
1313 }
1314
1315 if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1316 out.push_str(lean_ctx_block);
1317 out.push('\n');
1318 inserted = true;
1319 in_mcp_section = false;
1320 }
1321 }
1322
1323 out.push_str(line);
1324 out.push('\n');
1325 }
1326
1327 if in_mcp_section && !inserted {
1328 out.push_str(lean_ctx_block);
1329 out.push('\n');
1330 inserted = true;
1331 }
1332
1333 if !inserted {
1334 if !out.ends_with('\n') {
1335 out.push('\n');
1336 }
1337 out.push_str("\nmcp_servers:\n");
1338 out.push_str(lean_ctx_block);
1339 out.push('\n');
1340 }
1341
1342 out
1343}
1344
1345fn write_qoder_settings(
1346 target: &EditorTarget,
1347 binary: &str,
1348 opts: WriteOptions,
1349) -> Result<WriteResult, String> {
1350 let data_dir = default_data_dir()?;
1351 let desired = serde_json::json!({
1352 "command": binary,
1353 "args": [],
1354 "env": {
1355 "LEAN_CTX_DATA_DIR": data_dir,
1356 "LEAN_CTX_FULL_TOOLS": "1"
1357 }
1358 });
1359
1360 if target.config_path.exists() {
1361 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1362 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1363 Ok(v) => v,
1364 Err(e) => {
1365 if !opts.overwrite_invalid {
1366 return Err(e.to_string());
1367 }
1368 backup_invalid_file(&target.config_path)?;
1369 return write_mcp_json_fresh(
1370 &target.config_path,
1371 &desired,
1372 Some("overwrote invalid JSON".to_string()),
1373 );
1374 }
1375 };
1376 let obj = json
1377 .as_object_mut()
1378 .ok_or_else(|| "root JSON must be an object".to_string())?;
1379 let servers = obj
1380 .entry("mcpServers")
1381 .or_insert_with(|| serde_json::json!({}));
1382 let servers_obj = servers
1383 .as_object_mut()
1384 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1385
1386 let existing = servers_obj.get("lean-ctx").cloned();
1387 if existing.as_ref() == Some(&desired) {
1388 return Ok(WriteResult {
1389 action: WriteAction::Already,
1390 note: None,
1391 });
1392 }
1393 servers_obj.insert("lean-ctx".to_string(), desired);
1394
1395 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1396 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1397 return Ok(WriteResult {
1398 action: WriteAction::Updated,
1399 note: None,
1400 });
1401 }
1402
1403 write_mcp_json_fresh(&target.config_path, &desired, None)
1404}
1405
1406fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
1407 if !path.exists() {
1408 return Ok(());
1409 }
1410 let parent = path
1411 .parent()
1412 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1413 let filename = path
1414 .file_name()
1415 .ok_or_else(|| "invalid path (no filename)".to_string())?
1416 .to_string_lossy();
1417 let pid = std::process::id();
1418 let nanos = std::time::SystemTime::now()
1419 .duration_since(std::time::UNIX_EPOCH)
1420 .map_or(0, |d| d.as_nanos());
1421 let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1422 std::fs::rename(path, bak).map_err(|e| e.to_string())?;
1423 Ok(())
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428 use super::*;
1429 use std::path::PathBuf;
1430
1431 fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1432 EditorTarget {
1433 name,
1434 agent_key: "test".to_string(),
1435 config_path: path,
1436 detect_path: PathBuf::from("/nonexistent"),
1437 config_type: ty,
1438 }
1439 }
1440
1441 #[test]
1442 fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1443 let dir = tempfile::tempdir().unwrap();
1444 let path = dir.path().join("mcp.json");
1445 std::fs::write(
1446 &path,
1447 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1448 )
1449 .unwrap();
1450
1451 let t = target("test", path.clone(), ConfigType::McpJson);
1452 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1453 assert_eq!(res.action, WriteAction::Updated);
1454
1455 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1456 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1457 assert_eq!(
1458 json["mcpServers"]["lean-ctx"]["command"],
1459 "/new/path/lean-ctx"
1460 );
1461 assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1462 }
1463
1464 #[test]
1465 fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1466 let dir = tempfile::tempdir().unwrap();
1467 let path = dir.path().join("mcp.json");
1468 std::fs::write(
1469 &path,
1470 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1471 )
1472 .unwrap();
1473
1474 let t = target("Cursor", path.clone(), ConfigType::McpJson);
1475 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1476 assert_eq!(res.action, WriteAction::Updated);
1477
1478 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1479 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1480 assert_eq!(
1481 json["mcpServers"]["lean-ctx"]["command"],
1482 "/new/path/lean-ctx"
1483 );
1484 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1485 assert!(
1486 json["mcpServers"]["lean-ctx"]["autoApprove"]
1487 .as_array()
1488 .unwrap()
1489 .len()
1490 > 5
1491 );
1492 }
1493
1494 #[test]
1495 fn crush_config_writes_mcp_root() {
1496 let dir = tempfile::tempdir().unwrap();
1497 let path = dir.path().join("crush.json");
1498 std::fs::write(
1499 &path,
1500 r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1501 )
1502 .unwrap();
1503
1504 let t = target("test", path.clone(), ConfigType::Crush);
1505 let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1506 assert_eq!(res.action, WriteAction::Updated);
1507
1508 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1509 assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1510 assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1511 }
1512
1513 #[test]
1514 fn codex_toml_upserts_existing_section() {
1515 let dir = tempfile::tempdir().unwrap();
1516 let path = dir.path().join("config.toml");
1517 std::fs::write(
1518 &path,
1519 r#"[mcp_servers.lean-ctx]
1520command = "old"
1521args = ["x"]
1522"#,
1523 )
1524 .unwrap();
1525
1526 let t = target("test", path.clone(), ConfigType::Codex);
1527 let res = write_codex_config(&t, "new").unwrap();
1528 assert_eq!(res.action, WriteAction::Updated);
1529
1530 let content = std::fs::read_to_string(&path).unwrap();
1531 assert!(content.contains(r#"command = "new""#));
1532 assert!(content.contains("args = []"));
1533 }
1534
1535 #[test]
1536 fn upsert_codex_toml_inserts_new_section_when_missing() {
1537 let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1538 assert!(updated.contains("[mcp_servers.lean-ctx]"));
1539 assert!(updated.contains("command = \"lean-ctx\""));
1540 assert!(updated.contains("args = []"));
1541 }
1542
1543 #[test]
1544 fn codex_toml_uses_single_quotes_for_backslash_paths() {
1545 let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1546 let updated = upsert_codex_toml("", win_path);
1547 assert!(
1548 updated.contains(&format!("command = '{win_path}'")),
1549 "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1550 );
1551 }
1552
1553 #[test]
1554 fn codex_toml_uses_double_quotes_for_unix_paths() {
1555 let unix_path = "/usr/local/bin/lean-ctx";
1556 let updated = upsert_codex_toml("", unix_path);
1557 assert!(
1558 updated.contains(&format!("command = \"{unix_path}\"")),
1559 "Unix paths should use double quotes: {updated}"
1560 );
1561 }
1562
1563 #[test]
1564 fn auto_approve_contains_core_tools() {
1565 let tools = auto_approve_tools();
1566 assert!(tools.contains(&"ctx_read"));
1567 assert!(tools.contains(&"ctx_shell"));
1568 assert!(tools.contains(&"ctx_search"));
1569 assert!(tools.contains(&"ctx_workflow"));
1570 assert!(tools.contains(&"ctx_cost"));
1571 }
1572
1573 #[test]
1574 fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
1575 let dir = tempfile::tempdir().unwrap();
1576 let path = dir.path().join("mcp.json");
1577 std::fs::write(
1578 &path,
1579 r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
1580 )
1581 .unwrap();
1582
1583 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1584 let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1585 assert_eq!(res.action, WriteAction::Updated);
1586
1587 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1588 assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
1589 assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1590 assert_eq!(
1591 json["mcpServers"]["lean-ctx"]["args"],
1592 serde_json::json!([])
1593 );
1594 assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
1595 .as_str()
1596 .is_some_and(|s| !s.trim().is_empty()));
1597 assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
1598 assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
1599 assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
1600 }
1601
1602 #[test]
1603 fn qoder_mcp_config_is_idempotent() {
1604 let dir = tempfile::tempdir().unwrap();
1605 let path = dir.path().join("mcp.json");
1606 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1607
1608 let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1609 let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1610
1611 assert_eq!(first.action, WriteAction::Created);
1612 assert_eq!(second.action, WriteAction::Already);
1613 }
1614
1615 #[test]
1616 fn qoder_mcp_config_creates_missing_parent_directories() {
1617 let dir = tempfile::tempdir().unwrap();
1618 let path = dir
1619 .path()
1620 .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
1621 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1622
1623 let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
1624
1625 assert_eq!(res.action, WriteAction::Created);
1626 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1627 assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1628 }
1629
1630 #[test]
1631 fn antigravity_config_omits_auto_approve() {
1632 let dir = tempfile::tempdir().unwrap();
1633 let path = dir.path().join("mcp_config.json");
1634
1635 let t = EditorTarget {
1636 name: "Antigravity",
1637 agent_key: "gemini".to_string(),
1638 config_path: path.clone(),
1639 detect_path: PathBuf::from("/nonexistent"),
1640 config_type: ConfigType::McpJson,
1641 };
1642 let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1643 assert_eq!(res.action, WriteAction::Created);
1644
1645 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1646 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1647 assert_eq!(
1648 json["mcpServers"]["lean-ctx"]["command"],
1649 "/usr/local/bin/lean-ctx"
1650 );
1651 }
1652
1653 #[test]
1654 fn hermes_yaml_inserts_into_existing_mcp_servers() {
1655 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";
1656 let block = " lean-ctx:\n command: \"lean-ctx\"\n env:\n LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1657 let result = upsert_hermes_yaml_mcp(existing, block);
1658 assert!(result.contains("lean-ctx"));
1659 assert!(result.contains("model: anthropic/claude-sonnet-4"));
1660 assert!(result.contains("tool_allowlist:"));
1661 assert!(result.contains("github:"));
1662 }
1663
1664 #[test]
1665 fn hermes_yaml_creates_mcp_servers_section() {
1666 let existing = "model: openai/gpt-4o\n";
1667 let block = " lean-ctx:\n command: \"lean-ctx\"";
1668 let result = upsert_hermes_yaml_mcp(existing, block);
1669 assert!(result.contains("mcp_servers:"));
1670 assert!(result.contains("lean-ctx"));
1671 assert!(result.contains("model: openai/gpt-4o"));
1672 }
1673
1674 #[test]
1675 fn hermes_yaml_skips_if_already_present() {
1676 let dir = tempfile::tempdir().unwrap();
1677 let path = dir.path().join("config.yaml");
1678 std::fs::write(
1679 &path,
1680 "mcp_servers:\n lean-ctx:\n command: \"lean-ctx\"\n",
1681 )
1682 .unwrap();
1683 let t = target("test", path.clone(), ConfigType::HermesYaml);
1684 let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1685 assert_eq!(res.action, WriteAction::Already);
1686 }
1687}