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::CopilotCli => write_copilot_cli(target, binary, opts),
50 ConfigType::OpenCode => write_opencode_config(target, binary, opts),
51 ConfigType::Crush => write_crush_config(target, binary, opts),
52 ConfigType::JetBrains => write_jetbrains_config(target, binary, opts),
53 ConfigType::Amp => write_amp_config(target, binary, opts),
54 ConfigType::HermesYaml => write_hermes_yaml(target, binary, opts),
55 ConfigType::GeminiSettings => write_gemini_settings(target, binary, opts),
56 ConfigType::QoderSettings => write_qoder_settings(target, binary, opts),
57 }
58}
59
60pub fn remove_lean_ctx_mcp_server(
61 path: &std::path::Path,
62 opts: WriteOptions,
63) -> Result<WriteResult, String> {
64 if !path.exists() {
65 return Ok(WriteResult {
66 action: WriteAction::Already,
67 note: Some("mcp.json not found".to_string()),
68 });
69 }
70
71 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
72 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
73 Ok(v) => v,
74 Err(e) => {
75 if !opts.overwrite_invalid {
76 return Err(e.to_string());
77 }
78 eprintln!(
79 "\x1b[33m⚠\x1b[0m {} has JSON syntax errors — skipping removal.",
80 path.display()
81 );
82 return Ok(WriteResult {
83 action: WriteAction::Already,
84 note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
85 });
86 }
87 };
88
89 let obj = json
90 .as_object_mut()
91 .ok_or_else(|| "root JSON must be an object".to_string())?;
92
93 let Some(servers) = obj.get_mut("mcpServers") else {
94 return Ok(WriteResult {
95 action: WriteAction::Already,
96 note: Some("no mcpServers key".to_string()),
97 });
98 };
99 let servers_obj = servers
100 .as_object_mut()
101 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
102
103 if servers_obj.remove("lean-ctx").is_none() {
104 return Ok(WriteResult {
105 action: WriteAction::Already,
106 note: Some("lean-ctx not configured".to_string()),
107 });
108 }
109
110 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
111 crate::config_io::write_atomic_with_backup(path, &formatted)?;
112 Ok(WriteResult {
113 action: WriteAction::Updated,
114 note: Some("removed lean-ctx from mcpServers".to_string()),
115 })
116}
117
118pub fn remove_lean_ctx_server(
119 target: &EditorTarget,
120 opts: WriteOptions,
121) -> Result<WriteResult, String> {
122 match target.config_type {
123 ConfigType::McpJson
124 | ConfigType::JetBrains
125 | ConfigType::GeminiSettings
126 | ConfigType::QoderSettings => remove_lean_ctx_mcp_server(&target.config_path, opts),
127 ConfigType::VsCodeMcp | ConfigType::CopilotCli => {
128 remove_lean_ctx_vscode_server(&target.config_path, opts)
129 }
130 ConfigType::Codex => remove_lean_ctx_codex_server(&target.config_path),
131 ConfigType::OpenCode | ConfigType::Crush => {
132 remove_lean_ctx_named_json_server(&target.config_path, "mcp", opts)
133 }
134 ConfigType::Zed => {
135 remove_lean_ctx_named_json_server(&target.config_path, "context_servers", opts)
136 }
137 ConfigType::Amp => remove_lean_ctx_amp_server(&target.config_path, opts),
138 ConfigType::HermesYaml => remove_lean_ctx_hermes_yaml_server(&target.config_path),
139 }
140}
141
142fn remove_lean_ctx_vscode_server(
143 path: &std::path::Path,
144 opts: WriteOptions,
145) -> Result<WriteResult, String> {
146 if !path.exists() {
147 return Ok(WriteResult {
148 action: WriteAction::Already,
149 note: Some("vscode mcp.json not found".to_string()),
150 });
151 }
152
153 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
154 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
155 Ok(v) => v,
156 Err(e) => {
157 if !opts.overwrite_invalid {
158 return Err(e.to_string());
159 }
160 eprintln!(
161 "\x1b[33m⚠\x1b[0m {} has JSON syntax errors — skipping removal.",
162 path.display()
163 );
164 return Ok(WriteResult {
165 action: WriteAction::Already,
166 note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
167 });
168 }
169 };
170
171 let obj = json
172 .as_object_mut()
173 .ok_or_else(|| "root JSON must be an object".to_string())?;
174
175 let Some(servers) = obj.get_mut("servers") else {
176 return Ok(WriteResult {
177 action: WriteAction::Already,
178 note: Some("no servers key".to_string()),
179 });
180 };
181 let servers_obj = servers
182 .as_object_mut()
183 .ok_or_else(|| "\"servers\" must be an object".to_string())?;
184
185 if servers_obj.remove("lean-ctx").is_none() {
186 return Ok(WriteResult {
187 action: WriteAction::Already,
188 note: Some("lean-ctx not configured".to_string()),
189 });
190 }
191
192 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
193 crate::config_io::write_atomic_with_backup(path, &formatted)?;
194 Ok(WriteResult {
195 action: WriteAction::Updated,
196 note: Some("removed lean-ctx from servers".to_string()),
197 })
198}
199
200fn remove_lean_ctx_amp_server(
201 path: &std::path::Path,
202 opts: WriteOptions,
203) -> Result<WriteResult, String> {
204 if !path.exists() {
205 return Ok(WriteResult {
206 action: WriteAction::Already,
207 note: Some("amp settings not found".to_string()),
208 });
209 }
210
211 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
212 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
213 Ok(v) => v,
214 Err(e) => {
215 if !opts.overwrite_invalid {
216 return Err(e.to_string());
217 }
218 eprintln!(
219 "\x1b[33m⚠\x1b[0m {} has JSON syntax errors — skipping removal.",
220 path.display()
221 );
222 return Ok(WriteResult {
223 action: WriteAction::Already,
224 note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
225 });
226 }
227 };
228
229 let obj = json
230 .as_object_mut()
231 .ok_or_else(|| "root JSON must be an object".to_string())?;
232 let Some(servers) = obj.get_mut("amp.mcpServers") else {
233 return Ok(WriteResult {
234 action: WriteAction::Already,
235 note: Some("no amp.mcpServers key".to_string()),
236 });
237 };
238 let servers_obj = servers
239 .as_object_mut()
240 .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
241
242 if servers_obj.remove("lean-ctx").is_none() {
243 return Ok(WriteResult {
244 action: WriteAction::Already,
245 note: Some("lean-ctx not configured".to_string()),
246 });
247 }
248
249 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
250 crate::config_io::write_atomic_with_backup(path, &formatted)?;
251 Ok(WriteResult {
252 action: WriteAction::Updated,
253 note: Some("removed lean-ctx from amp.mcpServers".to_string()),
254 })
255}
256
257fn remove_lean_ctx_named_json_server(
258 path: &std::path::Path,
259 container_key: &str,
260 opts: WriteOptions,
261) -> Result<WriteResult, String> {
262 if !path.exists() {
263 return Ok(WriteResult {
264 action: WriteAction::Already,
265 note: Some("config not found".to_string()),
266 });
267 }
268
269 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
270 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
271 Ok(v) => v,
272 Err(e) => {
273 if !opts.overwrite_invalid {
274 return Err(e.to_string());
275 }
276 eprintln!(
277 "\x1b[33m⚠\x1b[0m {} has JSON syntax errors — skipping removal.",
278 path.display()
279 );
280 return Ok(WriteResult {
281 action: WriteAction::Already,
282 note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
283 });
284 }
285 };
286
287 let obj = json
288 .as_object_mut()
289 .ok_or_else(|| "root JSON must be an object".to_string())?;
290 let Some(container) = obj.get_mut(container_key) else {
291 return Ok(WriteResult {
292 action: WriteAction::Already,
293 note: Some(format!("no {container_key} key")),
294 });
295 };
296 let container_obj = container
297 .as_object_mut()
298 .ok_or_else(|| format!("\"{container_key}\" must be an object"))?;
299
300 if container_obj.remove("lean-ctx").is_none() {
301 return Ok(WriteResult {
302 action: WriteAction::Already,
303 note: Some("lean-ctx not configured".to_string()),
304 });
305 }
306
307 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
308 crate::config_io::write_atomic_with_backup(path, &formatted)?;
309 Ok(WriteResult {
310 action: WriteAction::Updated,
311 note: Some(format!("removed lean-ctx from {container_key}")),
312 })
313}
314
315fn remove_lean_ctx_codex_server(path: &std::path::Path) -> Result<WriteResult, String> {
316 if !path.exists() {
317 return Ok(WriteResult {
318 action: WriteAction::Already,
319 note: Some("codex config not found".to_string()),
320 });
321 }
322 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
323 let updated = remove_codex_toml_section(&content, "[mcp_servers.lean-ctx]");
324 if updated == content {
325 return Ok(WriteResult {
326 action: WriteAction::Already,
327 note: Some("lean-ctx not configured".to_string()),
328 });
329 }
330 crate::config_io::write_atomic_with_backup(path, &updated)?;
331 Ok(WriteResult {
332 action: WriteAction::Updated,
333 note: Some("removed [mcp_servers.lean-ctx]".to_string()),
334 })
335}
336
337fn remove_codex_toml_section(existing: &str, header: &str) -> String {
338 let prefix = header.trim_end_matches(']');
339 let mut out = String::with_capacity(existing.len());
340 let mut skipping = false;
341 for line in existing.lines() {
342 let trimmed = line.trim();
343 if trimmed.starts_with('[') && trimmed.ends_with(']') {
344 if trimmed == header || trimmed.starts_with(&format!("{prefix}.")) {
345 skipping = true;
346 continue;
347 }
348 skipping = false;
349 }
350 if skipping {
351 continue;
352 }
353 out.push_str(line);
354 out.push('\n');
355 }
356 out
357}
358
359fn remove_lean_ctx_hermes_yaml_server(path: &std::path::Path) -> Result<WriteResult, String> {
360 if !path.exists() {
361 return Ok(WriteResult {
362 action: WriteAction::Already,
363 note: Some("hermes config not found".to_string()),
364 });
365 }
366 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
367 let updated = remove_hermes_yaml_mcp_server_block(&content, "lean-ctx");
368 if updated == content {
369 return Ok(WriteResult {
370 action: WriteAction::Already,
371 note: Some("lean-ctx not configured".to_string()),
372 });
373 }
374 crate::config_io::write_atomic_with_backup(path, &updated)?;
375 Ok(WriteResult {
376 action: WriteAction::Updated,
377 note: Some("removed lean-ctx from mcp_servers".to_string()),
378 })
379}
380
381fn remove_hermes_yaml_mcp_server_block(existing: &str, name: &str) -> String {
382 let mut out = String::with_capacity(existing.len());
383 let mut in_mcp = false;
384 let mut skipping = false;
385 for line in existing.lines() {
386 let trimmed = line.trim_end();
387 if trimmed == "mcp_servers:" {
388 in_mcp = true;
389 out.push_str(line);
390 out.push('\n');
391 continue;
392 }
393
394 if in_mcp {
395 let is_child = line.starts_with(" ") && !line.starts_with(" ");
396 let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
397
398 if is_toplevel {
399 in_mcp = false;
400 skipping = false;
401 }
402
403 if skipping {
404 if is_child || is_toplevel {
405 skipping = false;
406 out.push_str(line);
407 out.push('\n');
408 }
409 continue;
410 }
411
412 if is_child && line.trim() == format!("{name}:") {
413 skipping = true;
414 continue;
415 }
416 }
417
418 out.push_str(line);
419 out.push('\n');
420 }
421 out
422}
423
424pub fn auto_approve_tools() -> Vec<&'static str> {
425 vec![
426 "ctx_read",
427 "ctx_shell",
428 "ctx_search",
429 "ctx_tree",
430 "ctx_overview",
431 "ctx_preload",
432 "ctx_compress",
433 "ctx_metrics",
434 "ctx_session",
435 "ctx_knowledge",
436 "ctx_agent",
437 "ctx_share",
438 "ctx_analyze",
439 "ctx_benchmark",
440 "ctx_cache",
441 "ctx_discover",
442 "ctx_smart_read",
443 "ctx_delta",
444 "ctx_edit",
445 "ctx_dedup",
446 "ctx_fill",
447 "ctx_intent",
448 "ctx_response",
449 "ctx_context",
450 "ctx_graph",
451 "ctx_multi_read",
452 "ctx_semantic_search",
453 "ctx_symbol",
454 "ctx_outline",
455 "ctx_callgraph",
456 "ctx_refactor",
457 "ctx_routes",
458 "ctx_cost",
459 "ctx_heatmap",
460 "ctx_gain",
461 "ctx_expand",
462 "ctx_task",
463 "ctx_impact",
464 "ctx_architecture",
465 "ctx_workflow",
466 "ctx_review",
467 "ctx_pack",
468 "ctx_index",
469 "ctx_artifacts",
470 "ctx_smells",
471 "ctx_proof",
472 "ctx_verify",
473 "ctx_execute",
474 "ctx_handoff",
475 "ctx_feedback",
476 "ctx_control",
477 "ctx_plan",
478 "ctx_compile",
479 "ctx_discover_tools",
480 "ctx_provider",
481 "ctx_radar",
482 "ctx_retrieve",
483 "ctx_compress_memory",
484 "ctx_load_tools",
485 "ctx",
486 ]
487}
488
489fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
490 let mut entry = serde_json::json!({
491 "command": binary,
492 "env": {
493 "LEAN_CTX_DATA_DIR": data_dir
494 }
495 });
496 if include_auto_approve {
497 entry["autoApprove"] = serde_json::json!(auto_approve_tools());
498 }
499 entry
500}
501
502fn lean_ctx_server_entry_with_instructions(
503 binary: &str,
504 data_dir: &str,
505 include_auto_approve: bool,
506 agent_key: &str,
507) -> Value {
508 let mut entry = lean_ctx_server_entry(binary, data_dir, include_auto_approve);
509 let mode = crate::core::rules_canonical::Mode::from_hook_mode(
510 &crate::hooks::recommend_hook_mode(agent_key),
511 );
512 let instructions = crate::core::rules_canonical::mcp_instructions(mode);
513
514 let constraints = crate::core::client_constraints::by_client_id(agent_key);
515 if let Some(max_chars) = constraints.and_then(|c| c.mcp_instructions_max_chars) {
516 let truncated = if instructions.len() > max_chars {
517 &instructions[..max_chars]
518 } else {
519 instructions
520 };
521 entry["instructions"] = serde_json::json!(truncated);
522 }
523 entry
524}
525
526fn supports_auto_approve(target: &EditorTarget) -> bool {
527 crate::core::client_constraints::by_editor_name(target.name)
528 .is_some_and(|c| c.supports_auto_approve)
529}
530
531fn default_data_dir() -> Result<String, String> {
532 Ok(crate::core::data_dir::lean_ctx_data_dir()?
533 .to_string_lossy()
534 .to_string())
535}
536
537fn write_mcp_json(
538 target: &EditorTarget,
539 binary: &str,
540 opts: WriteOptions,
541) -> Result<WriteResult, String> {
542 let data_dir = default_data_dir()?;
543 let include_aa = supports_auto_approve(target);
544 let desired = if target.agent_key.is_empty() {
545 lean_ctx_server_entry(binary, &data_dir, include_aa)
546 } else {
547 lean_ctx_server_entry_with_instructions(binary, &data_dir, include_aa, &target.agent_key)
548 };
549
550 if (target.agent_key == "claude" || target.name == "Claude Code")
555 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
556 {
557 if let Ok(result) = try_claude_mcp_add(&desired) {
558 return Ok(result);
559 }
560 }
561
562 if target.config_path.exists() {
563 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
564 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
565 Ok(v) => v,
566 Err(_e) => {
567 return handle_invalid_json_write(
568 &target.config_path,
569 &content,
570 "mcpServers",
571 "lean-ctx",
572 &desired,
573 opts.overwrite_invalid,
574 );
575 }
576 };
577 let obj = json
578 .as_object_mut()
579 .ok_or_else(|| "root JSON must be an object".to_string())?;
580
581 let servers = obj
582 .entry("mcpServers")
583 .or_insert_with(|| serde_json::json!({}));
584 let servers_obj = servers
585 .as_object_mut()
586 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
587
588 let existing = servers_obj.get("lean-ctx").cloned();
589 if existing.as_ref() == Some(&desired) {
590 return Ok(WriteResult {
591 action: WriteAction::Already,
592 note: None,
593 });
594 }
595 servers_obj.insert("lean-ctx".to_string(), desired);
596
597 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
598 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
599 return Ok(WriteResult {
600 action: WriteAction::Updated,
601 note: None,
602 });
603 }
604
605 write_mcp_json_fresh(&target.config_path, &desired, None)
606}
607
608fn find_in_path(binary: &str) -> Option<std::path::PathBuf> {
609 let path_var = std::env::var("PATH").ok()?;
610 for dir in std::env::split_paths(&path_var) {
611 let candidate = dir.join(binary);
612 if candidate.is_file() {
613 return Some(candidate);
614 }
615 }
616 None
617}
618
619fn validate_claude_binary() -> Result<std::path::PathBuf, String> {
620 let path = find_in_path("claude").ok_or("claude binary not found in PATH")?;
621
622 let canonical =
623 std::fs::canonicalize(&path).map_err(|e| format!("cannot resolve claude path: {e}"))?;
624
625 let canonical_str = canonical.to_string_lossy();
626 let is_trusted = canonical_str.contains("/.claude/")
627 || canonical_str.contains("\\AppData\\")
628 || canonical_str.contains("/usr/local/bin/")
629 || canonical_str.contains("/opt/homebrew/")
630 || canonical_str.contains("/nix/store/")
631 || canonical_str.contains("/.npm/")
632 || canonical_str.contains("/.nvm/")
633 || canonical_str.contains("/node_modules/.bin/")
634 || std::env::var("LEAN_CTX_TRUST_CLAUDE_PATH").is_ok();
635
636 if !is_trusted {
637 return Err(format!(
638 "claude binary resolved to untrusted path: {canonical_str} — set LEAN_CTX_TRUST_CLAUDE_PATH=1 to override"
639 ));
640 }
641 Ok(canonical)
642}
643
644fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
645 use std::io::Write;
646 use std::process::{Command, Stdio};
647 use std::time::{Duration, Instant};
648
649 let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
650
651 let mut cmd = if cfg!(windows) {
652 let mut c = Command::new("cmd");
653 c.args([
654 "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
655 ]);
656 c
657 } else {
658 let claude_path = validate_claude_binary()?;
659 let mut c = Command::new(claude_path);
660 c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
661 c
662 };
663
664 let mut child = cmd
665 .stdin(Stdio::piped())
666 .stdout(Stdio::null())
667 .stderr(Stdio::null())
668 .spawn()
669 .map_err(|e| e.to_string())?;
670
671 if let Some(mut stdin) = child.stdin.take() {
672 let _ = stdin.write_all(server_json.as_bytes());
673 }
674
675 let deadline = Duration::from_secs(3);
676 let start = Instant::now();
677 loop {
678 match child.try_wait() {
679 Ok(Some(status)) => {
680 return if status.success() {
681 Ok(WriteResult {
682 action: WriteAction::Updated,
683 note: Some("via claude mcp add-json".to_string()),
684 })
685 } else {
686 Err("claude mcp add-json failed".to_string())
687 };
688 }
689 Ok(None) => {
690 if start.elapsed() > deadline {
691 let _ = child.kill();
692 let _ = child.wait();
693 return Err("claude mcp add-json timed out".to_string());
694 }
695 std::thread::sleep(Duration::from_millis(20));
696 }
697 Err(e) => return Err(e.to_string()),
698 }
699 }
700}
701
702fn write_mcp_json_fresh(
703 path: &std::path::Path,
704 desired: &Value,
705 note: Option<String>,
706) -> Result<WriteResult, String> {
707 let content = serde_json::to_string_pretty(&serde_json::json!({
708 "mcpServers": { "lean-ctx": desired }
709 }))
710 .map_err(|e| e.to_string())?;
711 crate::config_io::write_atomic_with_backup(path, &content)?;
712 Ok(WriteResult {
713 action: if note.is_some() {
714 WriteAction::Updated
715 } else {
716 WriteAction::Created
717 },
718 note,
719 })
720}
721
722fn write_zed_config(
723 target: &EditorTarget,
724 binary: &str,
725 opts: WriteOptions,
726) -> Result<WriteResult, String> {
727 let desired = serde_json::json!({
728 "command": binary,
729 "args": [],
730 "env": {}
731 });
732
733 if target.config_path.exists() {
734 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
735 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
736 Ok(v) => v,
737 Err(_e) => {
738 return handle_invalid_json_write(
739 &target.config_path,
740 &content,
741 "context_servers",
742 "lean-ctx",
743 &desired,
744 opts.overwrite_invalid,
745 );
746 }
747 };
748 let obj = json
749 .as_object_mut()
750 .ok_or_else(|| "root JSON must be an object".to_string())?;
751
752 let servers = obj
753 .entry("context_servers")
754 .or_insert_with(|| serde_json::json!({}));
755 let servers_obj = servers
756 .as_object_mut()
757 .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
758
759 let existing = servers_obj.get("lean-ctx").cloned();
760 if existing.as_ref() == Some(&desired) {
761 return Ok(WriteResult {
762 action: WriteAction::Already,
763 note: None,
764 });
765 }
766 servers_obj.insert("lean-ctx".to_string(), desired);
767
768 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
769 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
770 return Ok(WriteResult {
771 action: WriteAction::Updated,
772 note: None,
773 });
774 }
775
776 write_zed_config_fresh(&target.config_path, &desired, None)
777}
778
779fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
780 if target.config_path.exists() {
781 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
782 let updated = upsert_codex_toml(&content, binary);
783 if updated == content {
784 return Ok(WriteResult {
785 action: WriteAction::Already,
786 note: None,
787 });
788 }
789 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
790 return Ok(WriteResult {
791 action: WriteAction::Updated,
792 note: None,
793 });
794 }
795
796 let content = format!(
797 "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
798 toml_quote(binary)
799 );
800 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
801 Ok(WriteResult {
802 action: WriteAction::Created,
803 note: None,
804 })
805}
806
807fn write_zed_config_fresh(
808 path: &std::path::Path,
809 desired: &Value,
810 note: Option<String>,
811) -> Result<WriteResult, String> {
812 let content = serde_json::to_string_pretty(&serde_json::json!({
813 "context_servers": { "lean-ctx": desired }
814 }))
815 .map_err(|e| e.to_string())?;
816 crate::config_io::write_atomic_with_backup(path, &content)?;
817 Ok(WriteResult {
818 action: if note.is_some() {
819 WriteAction::Updated
820 } else {
821 WriteAction::Created
822 },
823 note,
824 })
825}
826
827fn write_vscode_mcp(
828 target: &EditorTarget,
829 binary: &str,
830 opts: WriteOptions,
831) -> Result<WriteResult, String> {
832 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
833 .map(|d| d.to_string_lossy().to_string())
834 .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
835 let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
836
837 if target.config_path.exists() {
838 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
839 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
840 Ok(v) => v,
841 Err(_e) => {
842 return handle_invalid_json_write(
843 &target.config_path,
844 &content,
845 "servers",
846 "lean-ctx",
847 &desired,
848 opts.overwrite_invalid,
849 );
850 }
851 };
852 let obj = json
853 .as_object_mut()
854 .ok_or_else(|| "root JSON must be an object".to_string())?;
855
856 let servers = obj
857 .entry("servers")
858 .or_insert_with(|| serde_json::json!({}));
859 let servers_obj = servers
860 .as_object_mut()
861 .ok_or_else(|| "\"servers\" must be an object".to_string())?;
862
863 let existing = servers_obj.get("lean-ctx").cloned();
864 if existing.as_ref() == Some(&desired) {
865 return Ok(WriteResult {
866 action: WriteAction::Already,
867 note: None,
868 });
869 }
870 servers_obj.insert("lean-ctx".to_string(), desired);
871
872 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
873 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
874 return Ok(WriteResult {
875 action: WriteAction::Updated,
876 note: None,
877 });
878 }
879
880 write_vscode_mcp_fresh(&target.config_path, binary, None)
881}
882
883fn write_vscode_mcp_fresh(
884 path: &std::path::Path,
885 binary: &str,
886 note: Option<String>,
887) -> Result<WriteResult, String> {
888 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
889 .map(|d| d.to_string_lossy().to_string())
890 .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
891 let content = serde_json::to_string_pretty(&serde_json::json!({
892 "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
893 }))
894 .map_err(|e| e.to_string())?;
895 crate::config_io::write_atomic_with_backup(path, &content)?;
896 Ok(WriteResult {
897 action: if note.is_some() {
898 WriteAction::Updated
899 } else {
900 WriteAction::Created
901 },
902 note,
903 })
904}
905
906fn write_copilot_cli(
907 target: &EditorTarget,
908 binary: &str,
909 opts: WriteOptions,
910) -> Result<WriteResult, String> {
911 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
912 .map(|d| d.to_string_lossy().to_string())
913 .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
914 let desired = serde_json::json!({
915 "type": "local",
916 "command": binary,
917 "args": ["mcp"],
918 "env": { "LEAN_CTX_DATA_DIR": data_dir },
919 "tools": ["*"]
920 });
921
922 if target.config_path.exists() {
923 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
924 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
925 Ok(v) => v,
926 Err(_e) => {
927 return handle_invalid_json_write(
928 &target.config_path,
929 &content,
930 "mcpServers",
931 "lean-ctx",
932 &desired,
933 opts.overwrite_invalid,
934 );
935 }
936 };
937 let obj = json
938 .as_object_mut()
939 .ok_or_else(|| "root JSON must be an object".to_string())?;
940
941 let servers = obj
942 .entry("mcpServers")
943 .or_insert_with(|| serde_json::json!({}));
944 let servers_obj = servers
945 .as_object_mut()
946 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
947
948 let existing = servers_obj.get("lean-ctx").cloned();
949 if existing.as_ref() == Some(&desired) {
950 return Ok(WriteResult {
951 action: WriteAction::Already,
952 note: None,
953 });
954 }
955
956 servers_obj.insert("lean-ctx".to_string(), desired);
957 let out = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
958 crate::config_io::write_atomic_with_backup(&target.config_path, &out)?;
959 return Ok(WriteResult {
960 action: WriteAction::Updated,
961 note: None,
962 });
963 }
964
965 if let Some(parent) = target.config_path.parent() {
967 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
968 }
969 let content = serde_json::to_string_pretty(&serde_json::json!({
970 "mcpServers": {
971 "lean-ctx": {
972 "type": "local",
973 "command": binary,
974 "args": ["mcp"],
975 "env": { "LEAN_CTX_DATA_DIR": data_dir },
976 "tools": ["*"]
977 }
978 }
979 }))
980 .map_err(|e| e.to_string())?;
981 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
982 Ok(WriteResult {
983 action: WriteAction::Created,
984 note: None,
985 })
986}
987
988fn write_opencode_config(
989 target: &EditorTarget,
990 binary: &str,
991 opts: WriteOptions,
992) -> Result<WriteResult, String> {
993 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
994 .map(|d| d.to_string_lossy().to_string())
995 .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
996 let desired = serde_json::json!({
997 "type": "local",
998 "command": [binary],
999 "enabled": true,
1000 "environment": { "LEAN_CTX_DATA_DIR": data_dir }
1001 });
1002
1003 if target.config_path.exists() {
1004 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1005 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1006 Ok(v) => v,
1007 Err(_e) => {
1008 return handle_invalid_json_write(
1009 &target.config_path,
1010 &content,
1011 "mcp",
1012 "lean-ctx",
1013 &desired,
1014 opts.overwrite_invalid,
1015 );
1016 }
1017 };
1018 let obj = json
1019 .as_object_mut()
1020 .ok_or_else(|| "root JSON must be an object".to_string())?;
1021 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1022 let mcp_obj = mcp
1023 .as_object_mut()
1024 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1025
1026 let existing = mcp_obj.get("lean-ctx").cloned();
1027 if existing.as_ref() == Some(&desired) {
1028 return Ok(WriteResult {
1029 action: WriteAction::Already,
1030 note: None,
1031 });
1032 }
1033 mcp_obj.insert("lean-ctx".to_string(), desired);
1034
1035 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1036 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1037 return Ok(WriteResult {
1038 action: WriteAction::Updated,
1039 note: None,
1040 });
1041 }
1042
1043 write_opencode_fresh(&target.config_path, binary, None)
1044}
1045
1046fn write_opencode_fresh(
1047 path: &std::path::Path,
1048 binary: &str,
1049 note: Option<String>,
1050) -> Result<WriteResult, String> {
1051 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1052 .map(|d| d.to_string_lossy().to_string())
1053 .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1054 let content = serde_json::to_string_pretty(&serde_json::json!({
1055 "$schema": "https://opencode.ai/config.json",
1056 "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
1057 }))
1058 .map_err(|e| e.to_string())?;
1059 crate::config_io::write_atomic_with_backup(path, &content)?;
1060 Ok(WriteResult {
1061 action: if note.is_some() {
1062 WriteAction::Updated
1063 } else {
1064 WriteAction::Created
1065 },
1066 note,
1067 })
1068}
1069
1070fn write_jetbrains_config(
1071 target: &EditorTarget,
1072 binary: &str,
1073 opts: WriteOptions,
1074) -> Result<WriteResult, String> {
1075 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1076 .map(|d| d.to_string_lossy().to_string())
1077 .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1078 let desired = serde_json::json!({
1082 "command": binary,
1083 "args": [],
1084 "env": { "LEAN_CTX_DATA_DIR": data_dir }
1085 });
1086
1087 if target.config_path.exists() {
1088 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1089 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1090 Ok(v) => v,
1091 Err(_e) => {
1092 return handle_invalid_json_write(
1093 &target.config_path,
1094 &content,
1095 "mcpServers",
1096 "lean-ctx",
1097 &desired,
1098 opts.overwrite_invalid,
1099 );
1100 }
1101 };
1102 let obj = json
1103 .as_object_mut()
1104 .ok_or_else(|| "root JSON must be an object".to_string())?;
1105
1106 let servers = obj
1107 .entry("mcpServers")
1108 .or_insert_with(|| serde_json::json!({}));
1109 let servers_obj = servers
1110 .as_object_mut()
1111 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1112
1113 let existing = servers_obj.get("lean-ctx").cloned();
1114 if existing.as_ref() == Some(&desired) {
1115 return Ok(WriteResult {
1116 action: WriteAction::Already,
1117 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1118 });
1119 }
1120 servers_obj.insert("lean-ctx".to_string(), desired);
1121
1122 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1123 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1124 return Ok(WriteResult {
1125 action: WriteAction::Updated,
1126 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1127 });
1128 }
1129
1130 let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
1131 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1132 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1133 Ok(WriteResult {
1134 action: WriteAction::Created,
1135 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1136 })
1137}
1138
1139fn write_amp_config(
1140 target: &EditorTarget,
1141 binary: &str,
1142 opts: WriteOptions,
1143) -> Result<WriteResult, String> {
1144 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1145 .map(|d| d.to_string_lossy().to_string())
1146 .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1147 let entry = serde_json::json!({
1148 "command": binary,
1149 "env": { "LEAN_CTX_DATA_DIR": data_dir }
1150 });
1151
1152 if target.config_path.exists() {
1153 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1154 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1155 Ok(v) => v,
1156 Err(_e) => {
1157 return handle_invalid_json_write(
1158 &target.config_path,
1159 &content,
1160 "amp.mcpServers",
1161 "lean-ctx",
1162 &entry,
1163 opts.overwrite_invalid,
1164 );
1165 }
1166 };
1167 let obj = json
1168 .as_object_mut()
1169 .ok_or_else(|| "root JSON must be an object".to_string())?;
1170 let servers = obj
1171 .entry("amp.mcpServers")
1172 .or_insert_with(|| serde_json::json!({}));
1173 let servers_obj = servers
1174 .as_object_mut()
1175 .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
1176
1177 let existing = servers_obj.get("lean-ctx").cloned();
1178 if existing.as_ref() == Some(&entry) {
1179 return Ok(WriteResult {
1180 action: WriteAction::Already,
1181 note: None,
1182 });
1183 }
1184 servers_obj.insert("lean-ctx".to_string(), entry);
1185
1186 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1187 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1188 return Ok(WriteResult {
1189 action: WriteAction::Updated,
1190 note: None,
1191 });
1192 }
1193
1194 let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1195 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1196 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1197 Ok(WriteResult {
1198 action: WriteAction::Created,
1199 note: None,
1200 })
1201}
1202
1203fn write_crush_config(
1204 target: &EditorTarget,
1205 binary: &str,
1206 opts: WriteOptions,
1207) -> Result<WriteResult, String> {
1208 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1209 .map(|d| d.to_string_lossy().to_string())
1210 .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1211 let desired = serde_json::json!({
1212 "type": "stdio",
1213 "command": binary,
1214 "env": { "LEAN_CTX_DATA_DIR": data_dir }
1215 });
1216
1217 if target.config_path.exists() {
1218 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1219 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1220 Ok(v) => v,
1221 Err(_e) => {
1222 return handle_invalid_json_write(
1223 &target.config_path,
1224 &content,
1225 "mcp",
1226 "lean-ctx",
1227 &desired,
1228 opts.overwrite_invalid,
1229 );
1230 }
1231 };
1232 let obj = json
1233 .as_object_mut()
1234 .ok_or_else(|| "root JSON must be an object".to_string())?;
1235 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1236 let mcp_obj = mcp
1237 .as_object_mut()
1238 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1239
1240 let existing = mcp_obj.get("lean-ctx").cloned();
1241 if existing.as_ref() == Some(&desired) {
1242 return Ok(WriteResult {
1243 action: WriteAction::Already,
1244 note: None,
1245 });
1246 }
1247 mcp_obj.insert("lean-ctx".to_string(), desired);
1248
1249 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1250 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1251 return Ok(WriteResult {
1252 action: WriteAction::Updated,
1253 note: None,
1254 });
1255 }
1256
1257 write_crush_fresh(&target.config_path, &desired, None)
1258}
1259
1260fn write_crush_fresh(
1261 path: &std::path::Path,
1262 desired: &Value,
1263 note: Option<String>,
1264) -> Result<WriteResult, String> {
1265 let content = serde_json::to_string_pretty(&serde_json::json!({
1266 "mcp": { "lean-ctx": desired }
1267 }))
1268 .map_err(|e| e.to_string())?;
1269 crate::config_io::write_atomic_with_backup(path, &content)?;
1270 Ok(WriteResult {
1271 action: if note.is_some() {
1272 WriteAction::Updated
1273 } else {
1274 WriteAction::Created
1275 },
1276 note,
1277 })
1278}
1279
1280fn upsert_codex_toml(existing: &str, binary: &str) -> String {
1281 let mut out = String::with_capacity(existing.len() + 128);
1282 let mut in_section = false;
1283 let mut saw_section = false;
1284 let mut wrote_command = false;
1285 let mut wrote_args = false;
1286 let mut inserted_parent_before_subtable = false;
1287
1288 let parent_block = format!(
1289 "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n\n",
1290 toml_quote(binary)
1291 );
1292
1293 for line in existing.lines() {
1294 let trimmed = line.trim();
1295 if trimmed == "[]" {
1296 continue;
1297 }
1298 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1299 if in_section && !wrote_command {
1300 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1301 wrote_command = true;
1302 }
1303 if in_section && !wrote_args {
1304 out.push_str("args = []\n");
1305 wrote_args = true;
1306 }
1307 in_section = trimmed == "[mcp_servers.lean-ctx]";
1308 if in_section {
1309 saw_section = true;
1310 } else if !saw_section
1311 && !inserted_parent_before_subtable
1312 && trimmed.starts_with("[mcp_servers.lean-ctx.")
1313 {
1314 out.push_str(&parent_block);
1315 inserted_parent_before_subtable = true;
1316 }
1317 out.push_str(line);
1318 out.push('\n');
1319 continue;
1320 }
1321
1322 if in_section {
1323 if trimmed.starts_with("command") && trimmed.contains('=') {
1324 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1325 wrote_command = true;
1326 continue;
1327 }
1328 if trimmed.starts_with("args") && trimmed.contains('=') {
1329 out.push_str("args = []\n");
1330 wrote_args = true;
1331 continue;
1332 }
1333 }
1334
1335 out.push_str(line);
1336 out.push('\n');
1337 }
1338
1339 if saw_section {
1340 if in_section && !wrote_command {
1341 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1342 }
1343 if in_section && !wrote_args {
1344 out.push_str("args = []\n");
1345 }
1346 return out;
1347 }
1348
1349 if inserted_parent_before_subtable {
1350 return out;
1351 }
1352
1353 if !out.ends_with('\n') {
1354 out.push('\n');
1355 }
1356 out.push_str("\n[mcp_servers.lean-ctx]\n");
1357 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1358 out.push_str("args = []\n");
1359 out
1360}
1361
1362fn write_gemini_settings(
1363 target: &EditorTarget,
1364 binary: &str,
1365 opts: WriteOptions,
1366) -> Result<WriteResult, String> {
1367 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1368 .map(|d| d.to_string_lossy().to_string())
1369 .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
1370 let entry = serde_json::json!({
1371 "command": binary,
1372 "env": { "LEAN_CTX_DATA_DIR": data_dir },
1373 "trust": true,
1374 });
1375
1376 if target.config_path.exists() {
1377 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1378 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1379 Ok(v) => v,
1380 Err(_e) => {
1381 return handle_invalid_json_write(
1382 &target.config_path,
1383 &content,
1384 "mcpServers",
1385 "lean-ctx",
1386 &entry,
1387 opts.overwrite_invalid,
1388 );
1389 }
1390 };
1391 let obj = json
1392 .as_object_mut()
1393 .ok_or_else(|| "root JSON must be an object".to_string())?;
1394 let servers = obj
1395 .entry("mcpServers")
1396 .or_insert_with(|| serde_json::json!({}));
1397 let servers_obj = servers
1398 .as_object_mut()
1399 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1400
1401 let existing = servers_obj.get("lean-ctx").cloned();
1402 if existing.as_ref() == Some(&entry) {
1403 return Ok(WriteResult {
1404 action: WriteAction::Already,
1405 note: None,
1406 });
1407 }
1408 servers_obj.insert("lean-ctx".to_string(), entry);
1409
1410 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1411 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1412 return Ok(WriteResult {
1413 action: WriteAction::Updated,
1414 note: None,
1415 });
1416 }
1417
1418 let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1419 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1420 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1421 Ok(WriteResult {
1422 action: WriteAction::Created,
1423 note: None,
1424 })
1425}
1426
1427fn write_hermes_yaml(
1428 target: &EditorTarget,
1429 binary: &str,
1430 _opts: WriteOptions,
1431) -> Result<WriteResult, String> {
1432 let data_dir = default_data_dir()?;
1433
1434 let lean_ctx_block = format!(
1435 " lean-ctx:\n command: \"{binary}\"\n env:\n LEAN_CTX_DATA_DIR: \"{data_dir}\""
1436 );
1437
1438 if target.config_path.exists() {
1439 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1440
1441 if content.contains("lean-ctx") {
1442 let has_correct_binary = content.contains(binary);
1443 let has_correct_data_dir = content.contains(&data_dir);
1444 if has_correct_binary && has_correct_data_dir {
1445 return Ok(WriteResult {
1446 action: WriteAction::Already,
1447 note: None,
1448 });
1449 }
1450 let cleaned = remove_hermes_yaml_lean_ctx_block(&content);
1451 let updated = upsert_hermes_yaml_mcp(&cleaned, &lean_ctx_block);
1452 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1453 return Ok(WriteResult {
1454 action: WriteAction::Updated,
1455 note: None,
1456 });
1457 }
1458
1459 let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1460 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1461 return Ok(WriteResult {
1462 action: WriteAction::Updated,
1463 note: None,
1464 });
1465 }
1466
1467 let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1468 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1469 Ok(WriteResult {
1470 action: WriteAction::Created,
1471 note: None,
1472 })
1473}
1474
1475fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1476 let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1477 let mut in_mcp_section = false;
1478 let mut saw_mcp_child = false;
1479 let mut inserted = false;
1480 let lines: Vec<&str> = existing.lines().collect();
1481
1482 for line in &lines {
1483 if !inserted && line.trim_end() == "mcp_servers:" {
1484 in_mcp_section = true;
1485 out.push_str(line);
1486 out.push('\n');
1487 continue;
1488 }
1489
1490 if in_mcp_section && !inserted {
1491 let is_child = line.starts_with(" ") && !line.trim().is_empty();
1492 let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1493
1494 if is_child {
1495 saw_mcp_child = true;
1496 out.push_str(line);
1497 out.push('\n');
1498 continue;
1499 }
1500
1501 if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1502 out.push_str(lean_ctx_block);
1503 out.push('\n');
1504 inserted = true;
1505 in_mcp_section = false;
1506 }
1507 }
1508
1509 out.push_str(line);
1510 out.push('\n');
1511 }
1512
1513 if in_mcp_section && !inserted {
1514 out.push_str(lean_ctx_block);
1515 out.push('\n');
1516 inserted = true;
1517 }
1518
1519 if !inserted {
1520 if !out.ends_with('\n') {
1521 out.push('\n');
1522 }
1523 out.push_str("\nmcp_servers:\n");
1524 out.push_str(lean_ctx_block);
1525 out.push('\n');
1526 }
1527
1528 out
1529}
1530
1531fn remove_hermes_yaml_lean_ctx_block(content: &str) -> String {
1532 let mut out = String::with_capacity(content.len());
1533 let mut skip = false;
1534 for line in content.lines() {
1535 if line.trim_start().starts_with("lean-ctx:")
1536 && (line.starts_with(" ") || line.starts_with('\t'))
1537 {
1538 skip = true;
1539 continue;
1540 }
1541 if skip {
1542 let indented = line.starts_with(" ") || line.starts_with("\t\t");
1543 let empty = line.trim().is_empty();
1544 if indented || empty {
1545 continue;
1546 }
1547 skip = false;
1548 }
1549 out.push_str(line);
1550 out.push('\n');
1551 }
1552 out
1553}
1554
1555fn write_qoder_settings(
1556 target: &EditorTarget,
1557 binary: &str,
1558 opts: WriteOptions,
1559) -> Result<WriteResult, String> {
1560 let data_dir = default_data_dir()?;
1561 let desired = serde_json::json!({
1562 "command": binary,
1563 "args": [],
1564 "env": {
1565 "LEAN_CTX_DATA_DIR": data_dir,
1566 "LEAN_CTX_FULL_TOOLS": "1"
1567 }
1568 });
1569
1570 if target.config_path.exists() {
1571 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1572 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1573 Ok(v) => v,
1574 Err(_e) => {
1575 return handle_invalid_json_write(
1576 &target.config_path,
1577 &content,
1578 "mcpServers",
1579 "lean-ctx",
1580 &desired,
1581 opts.overwrite_invalid,
1582 );
1583 }
1584 };
1585 let obj = json
1586 .as_object_mut()
1587 .ok_or_else(|| "root JSON must be an object".to_string())?;
1588 let servers = obj
1589 .entry("mcpServers")
1590 .or_insert_with(|| serde_json::json!({}));
1591 let servers_obj = servers
1592 .as_object_mut()
1593 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1594
1595 let existing = servers_obj.get("lean-ctx").cloned();
1596 if existing.as_ref() == Some(&desired) {
1597 return Ok(WriteResult {
1598 action: WriteAction::Already,
1599 note: None,
1600 });
1601 }
1602 servers_obj.insert("lean-ctx".to_string(), desired);
1603
1604 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1605 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1606 return Ok(WriteResult {
1607 action: WriteAction::Updated,
1608 note: None,
1609 });
1610 }
1611
1612 write_mcp_json_fresh(&target.config_path, &desired, None)
1613}
1614
1615fn backup_invalid_file(path: &std::path::Path) -> Result<std::path::PathBuf, String> {
1616 if !path.exists() {
1617 return Ok(path.to_path_buf());
1618 }
1619 let parent = path
1620 .parent()
1621 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1622 let filename = path
1623 .file_name()
1624 .ok_or_else(|| "invalid path (no filename)".to_string())?
1625 .to_string_lossy();
1626 let pid = std::process::id();
1627 let nanos = std::time::SystemTime::now()
1628 .duration_since(std::time::UNIX_EPOCH)
1629 .map_or(0, |d| d.as_nanos());
1630 let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1631 std::fs::copy(path, &bak).map_err(|e| e.to_string())?;
1632 Ok(bak)
1633}
1634
1635fn handle_invalid_json_write(
1641 path: &std::path::Path,
1642 content: &str,
1643 container_key: &str,
1644 entry_key: &str,
1645 value: &serde_json::Value,
1646 allow_inject: bool,
1647) -> Result<WriteResult, String> {
1648 if content.contains(&format!("\"{entry_key}\"")) {
1649 eprintln!(
1650 "\x1b[33m⚠\x1b[0m {} has JSON syntax errors but already contains \"{entry_key}\".",
1651 path.display()
1652 );
1653 eprintln!(" Skipping — your config is untouched.");
1654 return Ok(WriteResult {
1655 action: WriteAction::Already,
1656 note: Some(format!("invalid JSON, {entry_key} already present")),
1657 });
1658 }
1659
1660 if !allow_inject {
1661 return Err(format!(
1662 "{} contains invalid JSON. Fix the syntax and re-run lean-ctx setup.\n Path: {}",
1663 path.display(),
1664 path.display()
1665 ));
1666 }
1667
1668 if let Some(patched) = try_text_inject_mcp_entry(content, container_key, entry_key, value) {
1670 let bak = backup_invalid_file(path)?;
1671 crate::config_io::write_atomic_with_backup(path, &patched)?;
1672 eprintln!(
1673 "\x1b[32m✓\x1b[0m Added {entry_key} to {} (text-based; file has syntax errors).",
1674 path.display()
1675 );
1676 eprintln!(" \x1b[33mNote:\x1b[0m Your config has JSON syntax errors — please fix them.");
1677 eprintln!(" Backup: {}", bak.display());
1678 return Ok(WriteResult {
1679 action: WriteAction::Updated,
1680 note: Some(format!(
1681 "text-injected into invalid JSON (backup: {})",
1682 bak.display()
1683 )),
1684 });
1685 }
1686
1687 eprintln!(
1689 "\x1b[33m⚠\x1b[0m {} contains invalid JSON that lean-ctx cannot safely modify.",
1690 path.display()
1691 );
1692 eprintln!(" \x1b[1mYour config was NOT changed.\x1b[0m");
1693 eprintln!(" To fix:");
1694 eprintln!(
1695 " 1. Open {} and correct the JSON syntax errors",
1696 path.display()
1697 );
1698 eprintln!(" 2. Re-run: lean-ctx setup");
1699 eprintln!(" (Common issue: trailing commas, missing quotes, unmatched braces)");
1700 Ok(WriteResult {
1701 action: WriteAction::Already,
1702 note: Some(format!(
1703 "invalid JSON — user must fix manually: {}",
1704 path.display()
1705 )),
1706 })
1707}
1708
1709fn try_text_inject_mcp_entry(
1713 content: &str,
1714 container_key: &str,
1715 entry_key: &str,
1716 value: &serde_json::Value,
1717) -> Option<String> {
1718 let entry = serde_json::to_string_pretty(value).ok()?;
1719 let indented_entry = entry
1720 .lines()
1721 .enumerate()
1722 .map(|(i, line)| {
1723 if i == 0 {
1724 format!(" \"{entry_key}\": {line}")
1725 } else {
1726 format!(" {line}")
1727 }
1728 })
1729 .collect::<Vec<_>>()
1730 .join("\n");
1731
1732 let quoted_container = format!("\"{container_key}\"");
1735 let search_keys: Vec<&str> = std::iter::once(quoted_container.as_str())
1736 .chain(
1737 [
1738 "\"mcp\"",
1739 "\"mcpServers\"",
1740 "\"servers\"",
1741 "\"context_servers\"",
1742 ]
1743 .iter()
1744 .filter(|k| **k != quoted_container.as_str())
1745 .copied(),
1746 )
1747 .collect();
1748
1749 for container in &search_keys {
1750 if let Some(pos) = content.find(container) {
1751 let after = &content[pos..];
1752 if let Some(brace_offset) = after.find('{') {
1753 let insert_pos = pos + brace_offset + 1;
1754 let before = &content[..insert_pos];
1755 let rest = &content[insert_pos..];
1756 let needs_comma = !rest.trim_start().starts_with('}');
1757 let injection = if needs_comma {
1758 format!("\n{indented_entry},")
1759 } else {
1760 format!("\n{indented_entry}\n ")
1761 };
1762 return Some(format!("{before}{injection}{rest}"));
1763 }
1764 }
1765 }
1766
1767 if let Some(last_brace) = content.rfind('}') {
1769 let before = &content[..last_brace];
1770 let after = &content[last_brace..];
1771 let needs_comma = before.trim_end().ends_with('}')
1772 || before.trim_end().ends_with('"')
1773 || before.trim_end().ends_with(']');
1774 let comma = if needs_comma { "," } else { "" };
1775 let block = format!("{comma}\n \"{container_key}\": {{\n{indented_entry}\n }}\n");
1776 return Some(format!("{before}{block}{after}"));
1777 }
1778
1779 None
1780}
1781
1782#[cfg(test)]
1783mod tests {
1784 use super::*;
1785 use std::path::PathBuf;
1786
1787 fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1788 EditorTarget {
1789 name,
1790 agent_key: "test".to_string(),
1791 config_path: path,
1792 detect_path: PathBuf::from("/nonexistent"),
1793 config_type: ty,
1794 }
1795 }
1796
1797 #[test]
1798 fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1799 let dir = tempfile::tempdir().unwrap();
1800 let path = dir.path().join("mcp.json");
1801 std::fs::write(
1802 &path,
1803 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1804 )
1805 .unwrap();
1806
1807 let t = target("test", path.clone(), ConfigType::McpJson);
1808 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1809 assert_eq!(res.action, WriteAction::Updated);
1810
1811 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1812 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1813 assert_eq!(
1814 json["mcpServers"]["lean-ctx"]["command"],
1815 "/new/path/lean-ctx"
1816 );
1817 assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1818 }
1819
1820 #[test]
1821 fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1822 let dir = tempfile::tempdir().unwrap();
1823 let path = dir.path().join("mcp.json");
1824 std::fs::write(
1825 &path,
1826 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1827 )
1828 .unwrap();
1829
1830 let t = target("Cursor", path.clone(), ConfigType::McpJson);
1831 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1832 assert_eq!(res.action, WriteAction::Updated);
1833
1834 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1835 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1836 assert_eq!(
1837 json["mcpServers"]["lean-ctx"]["command"],
1838 "/new/path/lean-ctx"
1839 );
1840 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1841 assert!(
1842 json["mcpServers"]["lean-ctx"]["autoApprove"]
1843 .as_array()
1844 .unwrap()
1845 .len()
1846 > 5
1847 );
1848 }
1849
1850 #[test]
1851 fn crush_config_writes_mcp_root() {
1852 let dir = tempfile::tempdir().unwrap();
1853 let path = dir.path().join("crush.json");
1854 std::fs::write(
1855 &path,
1856 r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1857 )
1858 .unwrap();
1859
1860 let t = target("test", path.clone(), ConfigType::Crush);
1861 let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1862 assert_eq!(res.action, WriteAction::Updated);
1863
1864 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1865 assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1866 assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1867 }
1868
1869 #[test]
1870 fn codex_toml_upserts_existing_section() {
1871 let dir = tempfile::tempdir().unwrap();
1872 let path = dir.path().join("config.toml");
1873 std::fs::write(
1874 &path,
1875 r#"[mcp_servers.lean-ctx]
1876command = "old"
1877args = ["x"]
1878"#,
1879 )
1880 .unwrap();
1881
1882 let t = target("test", path.clone(), ConfigType::Codex);
1883 let res = write_codex_config(&t, "new").unwrap();
1884 assert_eq!(res.action, WriteAction::Updated);
1885
1886 let content = std::fs::read_to_string(&path).unwrap();
1887 assert!(content.contains(r#"command = "new""#));
1888 assert!(content.contains("args = []"));
1889 }
1890
1891 #[test]
1892 fn upsert_codex_toml_inserts_new_section_when_missing() {
1893 let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1894 assert!(updated.contains("[mcp_servers.lean-ctx]"));
1895 assert!(updated.contains("command = \"lean-ctx\""));
1896 assert!(updated.contains("args = []"));
1897 }
1898
1899 #[test]
1900 fn codex_toml_uses_single_quotes_for_backslash_paths() {
1901 let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1902 let updated = upsert_codex_toml("", win_path);
1903 assert!(
1904 updated.contains(&format!("command = '{win_path}'")),
1905 "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1906 );
1907 }
1908
1909 #[test]
1910 fn codex_toml_uses_double_quotes_for_unix_paths() {
1911 let unix_path = "/usr/local/bin/lean-ctx";
1912 let updated = upsert_codex_toml("", unix_path);
1913 assert!(
1914 updated.contains(&format!("command = \"{unix_path}\"")),
1915 "Unix paths should use double quotes: {updated}"
1916 );
1917 }
1918
1919 #[test]
1920 fn upsert_codex_toml_inserts_parent_before_orphaned_tool_subtables() {
1921 let input = "\
1922[mcp_servers.lean-ctx.tools.ctx_multi_read]
1923approval_mode = \"approve\"
1924
1925[mcp_servers.lean-ctx.tools.ctx_read]
1926approval_mode = \"approve\"
1927";
1928 let updated = upsert_codex_toml(input, "lean-ctx");
1929 let parent_pos = updated
1930 .find("[mcp_servers.lean-ctx]\n")
1931 .expect("parent section must be inserted");
1932 let tools_pos = updated
1933 .find("[mcp_servers.lean-ctx.tools.")
1934 .expect("tool sub-tables must be preserved");
1935 assert!(
1936 parent_pos < tools_pos,
1937 "parent must come before tool sub-tables:\n{updated}"
1938 );
1939 assert!(updated.contains("command = \"lean-ctx\""));
1940 assert!(updated.contains("args = []"));
1941 assert!(updated.contains("approval_mode = \"approve\""));
1942 }
1943
1944 #[test]
1945 fn upsert_codex_toml_handles_issue_191_windows_scenario() {
1946 let input = "\
1947[mcp_servers.lean-ctx.tools.ctx_multi_read]
1948approval_mode = \"approve\"
1949
1950[mcp_servers.lean-ctx.tools.ctx_read]
1951approval_mode = \"approve\"
1952
1953[mcp_servers.lean-ctx.tools.ctx_search]
1954approval_mode = \"approve\"
1955
1956[mcp_servers.lean-ctx.tools.ctx_tree]
1957approval_mode = \"approve\"
1958";
1959 let win_path = r"C:\Users\wudon\AppData\Roaming\npm\lean-ctx.cmd";
1960 let updated = upsert_codex_toml(input, win_path);
1961 assert!(
1962 updated.contains(&format!("command = '{win_path}'")),
1963 "Windows path must use single quotes: {updated}"
1964 );
1965 let parent_pos = updated.find("[mcp_servers.lean-ctx]\n").unwrap();
1966 let first_tool = updated.find("[mcp_servers.lean-ctx.tools.").unwrap();
1967 assert!(parent_pos < first_tool);
1968 assert_eq!(
1969 updated.matches("[mcp_servers.lean-ctx]\n").count(),
1970 1,
1971 "parent section must appear exactly once"
1972 );
1973 }
1974
1975 #[test]
1976 fn upsert_codex_toml_does_not_duplicate_parent_when_present() {
1977 let input = "\
1978[mcp_servers.lean-ctx]
1979command = \"old\"
1980args = [\"x\"]
1981
1982[mcp_servers.lean-ctx.tools.ctx_read]
1983approval_mode = \"approve\"
1984";
1985 let updated = upsert_codex_toml(input, "new");
1986 assert_eq!(
1987 updated.matches("[mcp_servers.lean-ctx]").count(),
1988 1,
1989 "must not duplicate parent section"
1990 );
1991 assert!(updated.contains("command = \"new\""));
1992 assert!(updated.contains("args = []"));
1993 assert!(updated.contains("approval_mode = \"approve\""));
1994 }
1995
1996 #[test]
1997 fn auto_approve_contains_core_tools() {
1998 let tools = auto_approve_tools();
1999 assert!(tools.contains(&"ctx_read"));
2000 assert!(tools.contains(&"ctx_shell"));
2001 assert!(tools.contains(&"ctx_search"));
2002 assert!(tools.contains(&"ctx_workflow"));
2003 assert!(tools.contains(&"ctx_cost"));
2004 }
2005
2006 #[test]
2007 fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
2008 let dir = tempfile::tempdir().unwrap();
2009 let path = dir.path().join("mcp.json");
2010 std::fs::write(
2011 &path,
2012 r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
2013 )
2014 .unwrap();
2015
2016 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2017 let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2018 assert_eq!(res.action, WriteAction::Updated);
2019
2020 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2021 assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
2022 assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
2023 assert_eq!(
2024 json["mcpServers"]["lean-ctx"]["args"],
2025 serde_json::json!([])
2026 );
2027 assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
2028 .as_str()
2029 .is_some_and(|s| !s.trim().is_empty()));
2030 assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
2031 assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
2032 assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
2033 }
2034
2035 #[test]
2036 fn qoder_mcp_config_is_idempotent() {
2037 let dir = tempfile::tempdir().unwrap();
2038 let path = dir.path().join("mcp.json");
2039 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2040
2041 let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2042 let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2043
2044 assert_eq!(first.action, WriteAction::Created);
2045 assert_eq!(second.action, WriteAction::Already);
2046 }
2047
2048 #[test]
2049 fn qoder_mcp_config_creates_missing_parent_directories() {
2050 let dir = tempfile::tempdir().unwrap();
2051 let path = dir
2052 .path()
2053 .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
2054 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2055
2056 let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
2057
2058 assert_eq!(res.action, WriteAction::Created);
2059 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2060 assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
2061 }
2062
2063 #[test]
2064 fn antigravity_config_omits_auto_approve() {
2065 let dir = tempfile::tempdir().unwrap();
2066 let path = dir.path().join("mcp_config.json");
2067
2068 let t = EditorTarget {
2069 name: "Antigravity",
2070 agent_key: "gemini".to_string(),
2071 config_path: path.clone(),
2072 detect_path: PathBuf::from("/nonexistent"),
2073 config_type: ConfigType::McpJson,
2074 };
2075 let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
2076 assert_eq!(res.action, WriteAction::Created);
2077
2078 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2079 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
2080 assert_eq!(
2081 json["mcpServers"]["lean-ctx"]["command"],
2082 "/usr/local/bin/lean-ctx"
2083 );
2084 }
2085
2086 #[test]
2087 fn hermes_yaml_inserts_into_existing_mcp_servers() {
2088 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";
2089 let block = " lean-ctx:\n command: \"lean-ctx\"\n env:\n LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
2090 let result = upsert_hermes_yaml_mcp(existing, block);
2091 assert!(result.contains("lean-ctx"));
2092 assert!(result.contains("model: anthropic/claude-sonnet-4"));
2093 assert!(result.contains("tool_allowlist:"));
2094 assert!(result.contains("github:"));
2095 }
2096
2097 #[test]
2098 fn hermes_yaml_creates_mcp_servers_section() {
2099 let existing = "model: openai/gpt-4o\n";
2100 let block = " lean-ctx:\n command: \"lean-ctx\"";
2101 let result = upsert_hermes_yaml_mcp(existing, block);
2102 assert!(result.contains("mcp_servers:"));
2103 assert!(result.contains("lean-ctx"));
2104 assert!(result.contains("model: openai/gpt-4o"));
2105 }
2106
2107 #[test]
2108 fn hermes_yaml_skips_if_already_present() {
2109 let dir = tempfile::tempdir().unwrap();
2110 let path = dir.path().join("config.yaml");
2111 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
2112 .map(|d| d.to_string_lossy().to_string())
2113 .unwrap_or_default();
2114 std::fs::write(
2115 &path,
2116 format!("mcp_servers:\n lean-ctx:\n command: \"lean-ctx\"\n env:\n LEAN_CTX_DATA_DIR: \"{data_dir}\"\n"),
2117 )
2118 .unwrap();
2119 let t = target("test", path.clone(), ConfigType::HermesYaml);
2120 let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
2121 assert_eq!(res.action, WriteAction::Already);
2122 }
2123
2124 #[test]
2125 fn remove_codex_section_also_removes_env_subtable() {
2126 let input = "\
2127[other]
2128x = 1
2129
2130[mcp_servers.lean-ctx]
2131args = []
2132command = \"/usr/local/bin/lean-ctx\"
2133
2134[mcp_servers.lean-ctx.env]
2135LEAN_CTX_DATA_DIR = \"/home/user/.lean-ctx\"
2136
2137[features]
2138codex_hooks = true
2139";
2140 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2141 assert!(
2142 !result.contains("[mcp_servers.lean-ctx]"),
2143 "parent section must be removed"
2144 );
2145 assert!(
2146 !result.contains("LEAN_CTX_DATA_DIR"),
2147 "env sub-table must be removed too"
2148 );
2149 assert!(result.contains("[other]"), "unrelated sections preserved");
2150 assert!(
2151 result.contains("[features]"),
2152 "sections after must be preserved"
2153 );
2154 }
2155
2156 #[test]
2157 fn remove_codex_section_preserves_other_mcp_servers() {
2158 let input = "\
2159[mcp_servers.lean-ctx]
2160command = \"lean-ctx\"
2161
2162[mcp_servers.lean-ctx.env]
2163X = \"1\"
2164
2165[mcp_servers.other]
2166command = \"other\"
2167";
2168 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2169 assert!(!result.contains("[mcp_servers.lean-ctx]"));
2170 assert!(
2171 result.contains("[mcp_servers.other]"),
2172 "other MCP servers must be preserved"
2173 );
2174 assert!(result.contains("command = \"other\""));
2175 }
2176
2177 #[test]
2178 fn remove_codex_section_does_not_remove_similarly_named_server() {
2179 let input = "\
2180[mcp_servers.lean-ctx]
2181command = \"lean-ctx\"
2182
2183[mcp_servers.lean-ctx-probe]
2184command = \"probe\"
2185";
2186 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2187 assert!(
2188 !result.contains("[mcp_servers.lean-ctx]\n"),
2189 "target section must be removed"
2190 );
2191 assert!(
2192 result.contains("[mcp_servers.lean-ctx-probe]"),
2193 "similarly-named server must NOT be removed"
2194 );
2195 assert!(result.contains("command = \"probe\""));
2196 }
2197
2198 #[test]
2199 fn remove_codex_section_handles_no_match() {
2200 let input = "[other]\nx = 1\n";
2201 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2202 assert_eq!(result, "[other]\nx = 1\n");
2203 }
2204
2205 #[test]
2206 fn text_inject_into_existing_mcp_object() {
2207 let content = r#"{
2208 "mcp": {}
2209}"#;
2210 let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2211 let result = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2212 assert!(result.is_some());
2213 let patched = result.unwrap();
2214 assert!(patched.contains("\"lean-ctx\""));
2215 assert!(patched.contains("\"type\": \"local\""));
2216 }
2217
2218 #[test]
2219 fn text_inject_creates_container_when_missing() {
2220 let content = r#"{
2221 "some_other_key": "value"
2222}"#;
2223 let value = serde_json::json!({"command": "lean-ctx"});
2224 let result = try_text_inject_mcp_entry(content, "mcpServers", "lean-ctx", &value);
2226 assert!(result.is_some());
2227 let patched = result.unwrap();
2228 assert!(patched.contains("\"mcpServers\""));
2229 assert!(patched.contains("\"lean-ctx\""));
2230
2231 let result2 = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2233 assert!(result2.is_some());
2234 let patched2 = result2.unwrap();
2235 assert!(patched2.contains("\"mcp\""));
2236 assert!(patched2.contains("\"lean-ctx\""));
2237
2238 let result3 = try_text_inject_mcp_entry(content, "context_servers", "lean-ctx", &value);
2240 assert!(result3.is_some());
2241 let patched3 = result3.unwrap();
2242 assert!(patched3.contains("\"context_servers\""));
2243 assert!(patched3.contains("\"lean-ctx\""));
2244 }
2245
2246 #[test]
2247 fn text_inject_into_populated_mcp_object() {
2248 let content = r#"{
2249 "mcp": {
2250 "other-server": {"type": "local"}
2251 }
2252}"#;
2253 let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2254 let result = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2255 assert!(result.is_some());
2256 let patched = result.unwrap();
2257 assert!(patched.contains("\"lean-ctx\""));
2258 assert!(patched.contains("\"other-server\""));
2259 }
2260
2261 #[test]
2262 fn handle_invalid_json_skips_when_entry_already_present() {
2263 let content = r#"{ invalid json "lean-ctx": stuff }"#;
2264 let value = serde_json::json!({"type": "local"});
2265 let result = handle_invalid_json_write(
2266 std::path::Path::new("/tmp/test.json"),
2267 content,
2268 "mcp",
2269 "lean-ctx",
2270 &value,
2271 true,
2272 );
2273 assert!(result.is_ok());
2274 let r = result.unwrap();
2275 assert_eq!(r.action, WriteAction::Already);
2276 }
2277
2278 #[test]
2279 fn handle_invalid_json_returns_error_when_inject_disabled() {
2280 let content = r"{ invalid json without key }";
2281 let value = serde_json::json!({"type": "local"});
2282 let result = handle_invalid_json_write(
2283 std::path::Path::new("/tmp/test.json"),
2284 content,
2285 "mcp",
2286 "lean-ctx",
2287 &value,
2288 false,
2289 );
2290 assert!(result.is_err());
2291 }
2292
2293 #[test]
2294 fn handle_invalid_json_does_not_overwrite_file() {
2295 let dir = tempfile::tempdir().unwrap();
2296 let path = dir.path().join("opencode.json");
2297 let invalid_content = r#"{ "mcp": { BROKEN "other": true } }"#;
2298 std::fs::write(&path, invalid_content).unwrap();
2299
2300 let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2301 let result =
2302 handle_invalid_json_write(&path, invalid_content, "mcp", "lean-ctx", &value, true);
2303 assert!(result.is_ok());
2304 let r = result.unwrap();
2305 assert_eq!(r.action, WriteAction::Updated);
2306
2307 let final_content = std::fs::read_to_string(&path).unwrap();
2309 assert!(
2310 final_content.contains("lean-ctx"),
2311 "lean-ctx should be injected"
2312 );
2313 assert!(
2314 final_content.contains("BROKEN"),
2315 "original content preserved"
2316 );
2317 }
2318}