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