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