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