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