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 .unwrap_or_default();
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 .unwrap_or_default();
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 .unwrap_or_default();
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 .unwrap_or_default();
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 .unwrap_or_default();
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 .unwrap_or_default();
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 .unwrap_or_default();
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 .unwrap_or_default();
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 .unwrap_or_default();
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 return Ok(WriteResult {
1443 action: WriteAction::Already,
1444 note: None,
1445 });
1446 }
1447
1448 let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1449 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1450 return Ok(WriteResult {
1451 action: WriteAction::Updated,
1452 note: None,
1453 });
1454 }
1455
1456 let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1457 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1458 Ok(WriteResult {
1459 action: WriteAction::Created,
1460 note: None,
1461 })
1462}
1463
1464fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1465 let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1466 let mut in_mcp_section = false;
1467 let mut saw_mcp_child = false;
1468 let mut inserted = false;
1469 let lines: Vec<&str> = existing.lines().collect();
1470
1471 for line in &lines {
1472 if !inserted && line.trim_end() == "mcp_servers:" {
1473 in_mcp_section = true;
1474 out.push_str(line);
1475 out.push('\n');
1476 continue;
1477 }
1478
1479 if in_mcp_section && !inserted {
1480 let is_child = line.starts_with(" ") && !line.trim().is_empty();
1481 let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1482
1483 if is_child {
1484 saw_mcp_child = true;
1485 out.push_str(line);
1486 out.push('\n');
1487 continue;
1488 }
1489
1490 if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1491 out.push_str(lean_ctx_block);
1492 out.push('\n');
1493 inserted = true;
1494 in_mcp_section = false;
1495 }
1496 }
1497
1498 out.push_str(line);
1499 out.push('\n');
1500 }
1501
1502 if in_mcp_section && !inserted {
1503 out.push_str(lean_ctx_block);
1504 out.push('\n');
1505 inserted = true;
1506 }
1507
1508 if !inserted {
1509 if !out.ends_with('\n') {
1510 out.push('\n');
1511 }
1512 out.push_str("\nmcp_servers:\n");
1513 out.push_str(lean_ctx_block);
1514 out.push('\n');
1515 }
1516
1517 out
1518}
1519
1520fn write_qoder_settings(
1521 target: &EditorTarget,
1522 binary: &str,
1523 opts: WriteOptions,
1524) -> Result<WriteResult, String> {
1525 let data_dir = default_data_dir()?;
1526 let desired = serde_json::json!({
1527 "command": binary,
1528 "args": [],
1529 "env": {
1530 "LEAN_CTX_DATA_DIR": data_dir,
1531 "LEAN_CTX_FULL_TOOLS": "1"
1532 }
1533 });
1534
1535 if target.config_path.exists() {
1536 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1537 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1538 Ok(v) => v,
1539 Err(_e) => {
1540 return handle_invalid_json_write(
1541 &target.config_path,
1542 &content,
1543 "mcpServers",
1544 "lean-ctx",
1545 &desired,
1546 opts.overwrite_invalid,
1547 );
1548 }
1549 };
1550 let obj = json
1551 .as_object_mut()
1552 .ok_or_else(|| "root JSON must be an object".to_string())?;
1553 let servers = obj
1554 .entry("mcpServers")
1555 .or_insert_with(|| serde_json::json!({}));
1556 let servers_obj = servers
1557 .as_object_mut()
1558 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1559
1560 let existing = servers_obj.get("lean-ctx").cloned();
1561 if existing.as_ref() == Some(&desired) {
1562 return Ok(WriteResult {
1563 action: WriteAction::Already,
1564 note: None,
1565 });
1566 }
1567 servers_obj.insert("lean-ctx".to_string(), desired);
1568
1569 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1570 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1571 return Ok(WriteResult {
1572 action: WriteAction::Updated,
1573 note: None,
1574 });
1575 }
1576
1577 write_mcp_json_fresh(&target.config_path, &desired, None)
1578}
1579
1580fn backup_invalid_file(path: &std::path::Path) -> Result<std::path::PathBuf, String> {
1581 if !path.exists() {
1582 return Ok(path.to_path_buf());
1583 }
1584 let parent = path
1585 .parent()
1586 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1587 let filename = path
1588 .file_name()
1589 .ok_or_else(|| "invalid path (no filename)".to_string())?
1590 .to_string_lossy();
1591 let pid = std::process::id();
1592 let nanos = std::time::SystemTime::now()
1593 .duration_since(std::time::UNIX_EPOCH)
1594 .map_or(0, |d| d.as_nanos());
1595 let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1596 std::fs::copy(path, &bak).map_err(|e| e.to_string())?;
1597 Ok(bak)
1598}
1599
1600fn handle_invalid_json_write(
1606 path: &std::path::Path,
1607 content: &str,
1608 container_key: &str,
1609 entry_key: &str,
1610 value: &serde_json::Value,
1611 allow_inject: bool,
1612) -> Result<WriteResult, String> {
1613 if content.contains(&format!("\"{entry_key}\"")) {
1614 eprintln!(
1615 "\x1b[33m⚠\x1b[0m {} has JSON syntax errors but already contains \"{entry_key}\".",
1616 path.display()
1617 );
1618 eprintln!(" Skipping — your config is untouched.");
1619 return Ok(WriteResult {
1620 action: WriteAction::Already,
1621 note: Some(format!("invalid JSON, {entry_key} already present")),
1622 });
1623 }
1624
1625 if !allow_inject {
1626 return Err(format!(
1627 "{} contains invalid JSON. Fix the syntax and re-run lean-ctx setup.\n Path: {}",
1628 path.display(),
1629 path.display()
1630 ));
1631 }
1632
1633 if let Some(patched) = try_text_inject_mcp_entry(content, container_key, entry_key, value) {
1635 let bak = backup_invalid_file(path)?;
1636 crate::config_io::write_atomic_with_backup(path, &patched)?;
1637 eprintln!(
1638 "\x1b[32m✓\x1b[0m Added {entry_key} to {} (text-based; file has syntax errors).",
1639 path.display()
1640 );
1641 eprintln!(" \x1b[33mNote:\x1b[0m Your config has JSON syntax errors — please fix them.");
1642 eprintln!(" Backup: {}", bak.display());
1643 return Ok(WriteResult {
1644 action: WriteAction::Updated,
1645 note: Some(format!(
1646 "text-injected into invalid JSON (backup: {})",
1647 bak.display()
1648 )),
1649 });
1650 }
1651
1652 eprintln!(
1654 "\x1b[33m⚠\x1b[0m {} contains invalid JSON that lean-ctx cannot safely modify.",
1655 path.display()
1656 );
1657 eprintln!(" \x1b[1mYour config was NOT changed.\x1b[0m");
1658 eprintln!(" To fix:");
1659 eprintln!(
1660 " 1. Open {} and correct the JSON syntax errors",
1661 path.display()
1662 );
1663 eprintln!(" 2. Re-run: lean-ctx setup");
1664 eprintln!(" (Common issue: trailing commas, missing quotes, unmatched braces)");
1665 Ok(WriteResult {
1666 action: WriteAction::Already,
1667 note: Some(format!(
1668 "invalid JSON — user must fix manually: {}",
1669 path.display()
1670 )),
1671 })
1672}
1673
1674fn try_text_inject_mcp_entry(
1678 content: &str,
1679 container_key: &str,
1680 entry_key: &str,
1681 value: &serde_json::Value,
1682) -> Option<String> {
1683 let entry = serde_json::to_string_pretty(value).ok()?;
1684 let indented_entry = entry
1685 .lines()
1686 .enumerate()
1687 .map(|(i, line)| {
1688 if i == 0 {
1689 format!(" \"{entry_key}\": {line}")
1690 } else {
1691 format!(" {line}")
1692 }
1693 })
1694 .collect::<Vec<_>>()
1695 .join("\n");
1696
1697 let quoted_container = format!("\"{container_key}\"");
1700 let search_keys: Vec<&str> = std::iter::once(quoted_container.as_str())
1701 .chain(
1702 [
1703 "\"mcp\"",
1704 "\"mcpServers\"",
1705 "\"servers\"",
1706 "\"context_servers\"",
1707 ]
1708 .iter()
1709 .filter(|k| **k != quoted_container.as_str())
1710 .copied(),
1711 )
1712 .collect();
1713
1714 for container in &search_keys {
1715 if let Some(pos) = content.find(container) {
1716 let after = &content[pos..];
1717 if let Some(brace_offset) = after.find('{') {
1718 let insert_pos = pos + brace_offset + 1;
1719 let before = &content[..insert_pos];
1720 let rest = &content[insert_pos..];
1721 let needs_comma = !rest.trim_start().starts_with('}');
1722 let injection = if needs_comma {
1723 format!("\n{indented_entry},")
1724 } else {
1725 format!("\n{indented_entry}\n ")
1726 };
1727 return Some(format!("{before}{injection}{rest}"));
1728 }
1729 }
1730 }
1731
1732 if let Some(last_brace) = content.rfind('}') {
1734 let before = &content[..last_brace];
1735 let after = &content[last_brace..];
1736 let needs_comma = before.trim_end().ends_with('}')
1737 || before.trim_end().ends_with('"')
1738 || before.trim_end().ends_with(']');
1739 let comma = if needs_comma { "," } else { "" };
1740 let block = format!("{comma}\n \"{container_key}\": {{\n{indented_entry}\n }}\n");
1741 return Some(format!("{before}{block}{after}"));
1742 }
1743
1744 None
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749 use super::*;
1750 use std::path::PathBuf;
1751
1752 fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1753 EditorTarget {
1754 name,
1755 agent_key: "test".to_string(),
1756 config_path: path,
1757 detect_path: PathBuf::from("/nonexistent"),
1758 config_type: ty,
1759 }
1760 }
1761
1762 #[test]
1763 fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1764 let dir = tempfile::tempdir().unwrap();
1765 let path = dir.path().join("mcp.json");
1766 std::fs::write(
1767 &path,
1768 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1769 )
1770 .unwrap();
1771
1772 let t = target("test", path.clone(), ConfigType::McpJson);
1773 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1774 assert_eq!(res.action, WriteAction::Updated);
1775
1776 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1777 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1778 assert_eq!(
1779 json["mcpServers"]["lean-ctx"]["command"],
1780 "/new/path/lean-ctx"
1781 );
1782 assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1783 }
1784
1785 #[test]
1786 fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1787 let dir = tempfile::tempdir().unwrap();
1788 let path = dir.path().join("mcp.json");
1789 std::fs::write(
1790 &path,
1791 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1792 )
1793 .unwrap();
1794
1795 let t = target("Cursor", path.clone(), ConfigType::McpJson);
1796 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1797 assert_eq!(res.action, WriteAction::Updated);
1798
1799 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1800 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1801 assert_eq!(
1802 json["mcpServers"]["lean-ctx"]["command"],
1803 "/new/path/lean-ctx"
1804 );
1805 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1806 assert!(
1807 json["mcpServers"]["lean-ctx"]["autoApprove"]
1808 .as_array()
1809 .unwrap()
1810 .len()
1811 > 5
1812 );
1813 }
1814
1815 #[test]
1816 fn crush_config_writes_mcp_root() {
1817 let dir = tempfile::tempdir().unwrap();
1818 let path = dir.path().join("crush.json");
1819 std::fs::write(
1820 &path,
1821 r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1822 )
1823 .unwrap();
1824
1825 let t = target("test", path.clone(), ConfigType::Crush);
1826 let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1827 assert_eq!(res.action, WriteAction::Updated);
1828
1829 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1830 assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1831 assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1832 }
1833
1834 #[test]
1835 fn codex_toml_upserts_existing_section() {
1836 let dir = tempfile::tempdir().unwrap();
1837 let path = dir.path().join("config.toml");
1838 std::fs::write(
1839 &path,
1840 r#"[mcp_servers.lean-ctx]
1841command = "old"
1842args = ["x"]
1843"#,
1844 )
1845 .unwrap();
1846
1847 let t = target("test", path.clone(), ConfigType::Codex);
1848 let res = write_codex_config(&t, "new").unwrap();
1849 assert_eq!(res.action, WriteAction::Updated);
1850
1851 let content = std::fs::read_to_string(&path).unwrap();
1852 assert!(content.contains(r#"command = "new""#));
1853 assert!(content.contains("args = []"));
1854 }
1855
1856 #[test]
1857 fn upsert_codex_toml_inserts_new_section_when_missing() {
1858 let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1859 assert!(updated.contains("[mcp_servers.lean-ctx]"));
1860 assert!(updated.contains("command = \"lean-ctx\""));
1861 assert!(updated.contains("args = []"));
1862 }
1863
1864 #[test]
1865 fn codex_toml_uses_single_quotes_for_backslash_paths() {
1866 let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1867 let updated = upsert_codex_toml("", win_path);
1868 assert!(
1869 updated.contains(&format!("command = '{win_path}'")),
1870 "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1871 );
1872 }
1873
1874 #[test]
1875 fn codex_toml_uses_double_quotes_for_unix_paths() {
1876 let unix_path = "/usr/local/bin/lean-ctx";
1877 let updated = upsert_codex_toml("", unix_path);
1878 assert!(
1879 updated.contains(&format!("command = \"{unix_path}\"")),
1880 "Unix paths should use double quotes: {updated}"
1881 );
1882 }
1883
1884 #[test]
1885 fn upsert_codex_toml_inserts_parent_before_orphaned_tool_subtables() {
1886 let input = "\
1887[mcp_servers.lean-ctx.tools.ctx_multi_read]
1888approval_mode = \"approve\"
1889
1890[mcp_servers.lean-ctx.tools.ctx_read]
1891approval_mode = \"approve\"
1892";
1893 let updated = upsert_codex_toml(input, "lean-ctx");
1894 let parent_pos = updated
1895 .find("[mcp_servers.lean-ctx]\n")
1896 .expect("parent section must be inserted");
1897 let tools_pos = updated
1898 .find("[mcp_servers.lean-ctx.tools.")
1899 .expect("tool sub-tables must be preserved");
1900 assert!(
1901 parent_pos < tools_pos,
1902 "parent must come before tool sub-tables:\n{updated}"
1903 );
1904 assert!(updated.contains("command = \"lean-ctx\""));
1905 assert!(updated.contains("args = []"));
1906 assert!(updated.contains("approval_mode = \"approve\""));
1907 }
1908
1909 #[test]
1910 fn upsert_codex_toml_handles_issue_191_windows_scenario() {
1911 let input = "\
1912[mcp_servers.lean-ctx.tools.ctx_multi_read]
1913approval_mode = \"approve\"
1914
1915[mcp_servers.lean-ctx.tools.ctx_read]
1916approval_mode = \"approve\"
1917
1918[mcp_servers.lean-ctx.tools.ctx_search]
1919approval_mode = \"approve\"
1920
1921[mcp_servers.lean-ctx.tools.ctx_tree]
1922approval_mode = \"approve\"
1923";
1924 let win_path = r"C:\Users\wudon\AppData\Roaming\npm\lean-ctx.cmd";
1925 let updated = upsert_codex_toml(input, win_path);
1926 assert!(
1927 updated.contains(&format!("command = '{win_path}'")),
1928 "Windows path must use single quotes: {updated}"
1929 );
1930 let parent_pos = updated.find("[mcp_servers.lean-ctx]\n").unwrap();
1931 let first_tool = updated.find("[mcp_servers.lean-ctx.tools.").unwrap();
1932 assert!(parent_pos < first_tool);
1933 assert_eq!(
1934 updated.matches("[mcp_servers.lean-ctx]\n").count(),
1935 1,
1936 "parent section must appear exactly once"
1937 );
1938 }
1939
1940 #[test]
1941 fn upsert_codex_toml_does_not_duplicate_parent_when_present() {
1942 let input = "\
1943[mcp_servers.lean-ctx]
1944command = \"old\"
1945args = [\"x\"]
1946
1947[mcp_servers.lean-ctx.tools.ctx_read]
1948approval_mode = \"approve\"
1949";
1950 let updated = upsert_codex_toml(input, "new");
1951 assert_eq!(
1952 updated.matches("[mcp_servers.lean-ctx]").count(),
1953 1,
1954 "must not duplicate parent section"
1955 );
1956 assert!(updated.contains("command = \"new\""));
1957 assert!(updated.contains("args = []"));
1958 assert!(updated.contains("approval_mode = \"approve\""));
1959 }
1960
1961 #[test]
1962 fn auto_approve_contains_core_tools() {
1963 let tools = auto_approve_tools();
1964 assert!(tools.contains(&"ctx_read"));
1965 assert!(tools.contains(&"ctx_shell"));
1966 assert!(tools.contains(&"ctx_search"));
1967 assert!(tools.contains(&"ctx_workflow"));
1968 assert!(tools.contains(&"ctx_cost"));
1969 }
1970
1971 #[test]
1972 fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
1973 let dir = tempfile::tempdir().unwrap();
1974 let path = dir.path().join("mcp.json");
1975 std::fs::write(
1976 &path,
1977 r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
1978 )
1979 .unwrap();
1980
1981 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1982 let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1983 assert_eq!(res.action, WriteAction::Updated);
1984
1985 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1986 assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
1987 assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1988 assert_eq!(
1989 json["mcpServers"]["lean-ctx"]["args"],
1990 serde_json::json!([])
1991 );
1992 assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
1993 .as_str()
1994 .is_some_and(|s| !s.trim().is_empty()));
1995 assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
1996 assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
1997 assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
1998 }
1999
2000 #[test]
2001 fn qoder_mcp_config_is_idempotent() {
2002 let dir = tempfile::tempdir().unwrap();
2003 let path = dir.path().join("mcp.json");
2004 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2005
2006 let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2007 let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2008
2009 assert_eq!(first.action, WriteAction::Created);
2010 assert_eq!(second.action, WriteAction::Already);
2011 }
2012
2013 #[test]
2014 fn qoder_mcp_config_creates_missing_parent_directories() {
2015 let dir = tempfile::tempdir().unwrap();
2016 let path = dir
2017 .path()
2018 .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
2019 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2020
2021 let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
2022
2023 assert_eq!(res.action, WriteAction::Created);
2024 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2025 assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
2026 }
2027
2028 #[test]
2029 fn antigravity_config_omits_auto_approve() {
2030 let dir = tempfile::tempdir().unwrap();
2031 let path = dir.path().join("mcp_config.json");
2032
2033 let t = EditorTarget {
2034 name: "Antigravity",
2035 agent_key: "gemini".to_string(),
2036 config_path: path.clone(),
2037 detect_path: PathBuf::from("/nonexistent"),
2038 config_type: ConfigType::McpJson,
2039 };
2040 let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
2041 assert_eq!(res.action, WriteAction::Created);
2042
2043 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2044 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
2045 assert_eq!(
2046 json["mcpServers"]["lean-ctx"]["command"],
2047 "/usr/local/bin/lean-ctx"
2048 );
2049 }
2050
2051 #[test]
2052 fn hermes_yaml_inserts_into_existing_mcp_servers() {
2053 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";
2054 let block = " lean-ctx:\n command: \"lean-ctx\"\n env:\n LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
2055 let result = upsert_hermes_yaml_mcp(existing, block);
2056 assert!(result.contains("lean-ctx"));
2057 assert!(result.contains("model: anthropic/claude-sonnet-4"));
2058 assert!(result.contains("tool_allowlist:"));
2059 assert!(result.contains("github:"));
2060 }
2061
2062 #[test]
2063 fn hermes_yaml_creates_mcp_servers_section() {
2064 let existing = "model: openai/gpt-4o\n";
2065 let block = " lean-ctx:\n command: \"lean-ctx\"";
2066 let result = upsert_hermes_yaml_mcp(existing, block);
2067 assert!(result.contains("mcp_servers:"));
2068 assert!(result.contains("lean-ctx"));
2069 assert!(result.contains("model: openai/gpt-4o"));
2070 }
2071
2072 #[test]
2073 fn hermes_yaml_skips_if_already_present() {
2074 let dir = tempfile::tempdir().unwrap();
2075 let path = dir.path().join("config.yaml");
2076 std::fs::write(
2077 &path,
2078 "mcp_servers:\n lean-ctx:\n command: \"lean-ctx\"\n",
2079 )
2080 .unwrap();
2081 let t = target("test", path.clone(), ConfigType::HermesYaml);
2082 let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
2083 assert_eq!(res.action, WriteAction::Already);
2084 }
2085
2086 #[test]
2087 fn remove_codex_section_also_removes_env_subtable() {
2088 let input = "\
2089[other]
2090x = 1
2091
2092[mcp_servers.lean-ctx]
2093args = []
2094command = \"/usr/local/bin/lean-ctx\"
2095
2096[mcp_servers.lean-ctx.env]
2097LEAN_CTX_DATA_DIR = \"/home/user/.lean-ctx\"
2098
2099[features]
2100codex_hooks = true
2101";
2102 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2103 assert!(
2104 !result.contains("[mcp_servers.lean-ctx]"),
2105 "parent section must be removed"
2106 );
2107 assert!(
2108 !result.contains("LEAN_CTX_DATA_DIR"),
2109 "env sub-table must be removed too"
2110 );
2111 assert!(result.contains("[other]"), "unrelated sections preserved");
2112 assert!(
2113 result.contains("[features]"),
2114 "sections after must be preserved"
2115 );
2116 }
2117
2118 #[test]
2119 fn remove_codex_section_preserves_other_mcp_servers() {
2120 let input = "\
2121[mcp_servers.lean-ctx]
2122command = \"lean-ctx\"
2123
2124[mcp_servers.lean-ctx.env]
2125X = \"1\"
2126
2127[mcp_servers.other]
2128command = \"other\"
2129";
2130 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2131 assert!(!result.contains("[mcp_servers.lean-ctx]"));
2132 assert!(
2133 result.contains("[mcp_servers.other]"),
2134 "other MCP servers must be preserved"
2135 );
2136 assert!(result.contains("command = \"other\""));
2137 }
2138
2139 #[test]
2140 fn remove_codex_section_does_not_remove_similarly_named_server() {
2141 let input = "\
2142[mcp_servers.lean-ctx]
2143command = \"lean-ctx\"
2144
2145[mcp_servers.lean-ctx-probe]
2146command = \"probe\"
2147";
2148 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2149 assert!(
2150 !result.contains("[mcp_servers.lean-ctx]\n"),
2151 "target section must be removed"
2152 );
2153 assert!(
2154 result.contains("[mcp_servers.lean-ctx-probe]"),
2155 "similarly-named server must NOT be removed"
2156 );
2157 assert!(result.contains("command = \"probe\""));
2158 }
2159
2160 #[test]
2161 fn remove_codex_section_handles_no_match() {
2162 let input = "[other]\nx = 1\n";
2163 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2164 assert_eq!(result, "[other]\nx = 1\n");
2165 }
2166
2167 #[test]
2168 fn text_inject_into_existing_mcp_object() {
2169 let content = r#"{
2170 "mcp": {}
2171}"#;
2172 let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2173 let result = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2174 assert!(result.is_some());
2175 let patched = result.unwrap();
2176 assert!(patched.contains("\"lean-ctx\""));
2177 assert!(patched.contains("\"type\": \"local\""));
2178 }
2179
2180 #[test]
2181 fn text_inject_creates_container_when_missing() {
2182 let content = r#"{
2183 "some_other_key": "value"
2184}"#;
2185 let value = serde_json::json!({"command": "lean-ctx"});
2186 let result = try_text_inject_mcp_entry(content, "mcpServers", "lean-ctx", &value);
2188 assert!(result.is_some());
2189 let patched = result.unwrap();
2190 assert!(patched.contains("\"mcpServers\""));
2191 assert!(patched.contains("\"lean-ctx\""));
2192
2193 let result2 = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2195 assert!(result2.is_some());
2196 let patched2 = result2.unwrap();
2197 assert!(patched2.contains("\"mcp\""));
2198 assert!(patched2.contains("\"lean-ctx\""));
2199
2200 let result3 = try_text_inject_mcp_entry(content, "context_servers", "lean-ctx", &value);
2202 assert!(result3.is_some());
2203 let patched3 = result3.unwrap();
2204 assert!(patched3.contains("\"context_servers\""));
2205 assert!(patched3.contains("\"lean-ctx\""));
2206 }
2207
2208 #[test]
2209 fn text_inject_into_populated_mcp_object() {
2210 let content = r#"{
2211 "mcp": {
2212 "other-server": {"type": "local"}
2213 }
2214}"#;
2215 let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2216 let result = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2217 assert!(result.is_some());
2218 let patched = result.unwrap();
2219 assert!(patched.contains("\"lean-ctx\""));
2220 assert!(patched.contains("\"other-server\""));
2221 }
2222
2223 #[test]
2224 fn handle_invalid_json_skips_when_entry_already_present() {
2225 let content = r#"{ invalid json "lean-ctx": stuff }"#;
2226 let value = serde_json::json!({"type": "local"});
2227 let result = handle_invalid_json_write(
2228 std::path::Path::new("/tmp/test.json"),
2229 content,
2230 "mcp",
2231 "lean-ctx",
2232 &value,
2233 true,
2234 );
2235 assert!(result.is_ok());
2236 let r = result.unwrap();
2237 assert_eq!(r.action, WriteAction::Already);
2238 }
2239
2240 #[test]
2241 fn handle_invalid_json_returns_error_when_inject_disabled() {
2242 let content = r"{ invalid json without key }";
2243 let value = serde_json::json!({"type": "local"});
2244 let result = handle_invalid_json_write(
2245 std::path::Path::new("/tmp/test.json"),
2246 content,
2247 "mcp",
2248 "lean-ctx",
2249 &value,
2250 false,
2251 );
2252 assert!(result.is_err());
2253 }
2254
2255 #[test]
2256 fn handle_invalid_json_does_not_overwrite_file() {
2257 let dir = tempfile::tempdir().unwrap();
2258 let path = dir.path().join("opencode.json");
2259 let invalid_content = r#"{ "mcp": { BROKEN "other": true } }"#;
2260 std::fs::write(&path, invalid_content).unwrap();
2261
2262 let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2263 let result =
2264 handle_invalid_json_write(&path, invalid_content, "mcp", "lean-ctx", &value, true);
2265 assert!(result.is_ok());
2266 let r = result.unwrap();
2267 assert_eq!(r.action, WriteAction::Updated);
2268
2269 let final_content = std::fs::read_to_string(&path).unwrap();
2271 assert!(
2272 final_content.contains("lean-ctx"),
2273 "lean-ctx should be injected"
2274 );
2275 assert!(
2276 final_content.contains("BROKEN"),
2277 "original content preserved"
2278 );
2279 }
2280}