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