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