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 }
56}
57
58pub fn auto_approve_tools() -> Vec<&'static str> {
59 vec![
60 "ctx_read",
61 "ctx_shell",
62 "ctx_search",
63 "ctx_tree",
64 "ctx_overview",
65 "ctx_preload",
66 "ctx_compress",
67 "ctx_metrics",
68 "ctx_session",
69 "ctx_knowledge",
70 "ctx_agent",
71 "ctx_share",
72 "ctx_analyze",
73 "ctx_benchmark",
74 "ctx_cache",
75 "ctx_discover",
76 "ctx_smart_read",
77 "ctx_delta",
78 "ctx_edit",
79 "ctx_dedup",
80 "ctx_fill",
81 "ctx_intent",
82 "ctx_response",
83 "ctx_context",
84 "ctx_graph",
85 "ctx_wrapped",
86 "ctx_multi_read",
87 "ctx_semantic_search",
88 "ctx_symbol",
89 "ctx_outline",
90 "ctx_callers",
91 "ctx_callees",
92 "ctx_routes",
93 "ctx_graph_diagram",
94 "ctx_cost",
95 "ctx_heatmap",
96 "ctx_task",
97 "ctx_impact",
98 "ctx_architecture",
99 "ctx_workflow",
100 "ctx",
101 ]
102}
103
104fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
105 let mut entry = serde_json::json!({
106 "command": binary,
107 "env": {
108 "LEAN_CTX_DATA_DIR": data_dir
109 }
110 });
111 if include_auto_approve {
112 entry["autoApprove"] = serde_json::json!(auto_approve_tools());
113 }
114 entry
115}
116
117const NO_AUTO_APPROVE_EDITORS: &[&str] = &["Antigravity"];
118
119fn default_data_dir() -> Result<String, String> {
120 Ok(crate::core::data_dir::lean_ctx_data_dir()?
121 .to_string_lossy()
122 .to_string())
123}
124
125fn write_mcp_json(
126 target: &EditorTarget,
127 binary: &str,
128 opts: WriteOptions,
129) -> Result<WriteResult, String> {
130 let data_dir = default_data_dir()?;
131 let include_aa = !NO_AUTO_APPROVE_EDITORS.contains(&target.name);
132 let desired = lean_ctx_server_entry(binary, &data_dir, include_aa);
133
134 if target.agent_key == "claude" || target.name == "Claude Code" {
137 if let Ok(result) = try_claude_mcp_add(&desired) {
138 return Ok(result);
139 }
140 }
141
142 if target.config_path.exists() {
143 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
144 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
145 Ok(v) => v,
146 Err(e) => {
147 if !opts.overwrite_invalid {
148 return Err(e.to_string());
149 }
150 backup_invalid_file(&target.config_path)?;
151 return write_mcp_json_fresh(
152 &target.config_path,
153 &desired,
154 Some("overwrote invalid JSON".to_string()),
155 );
156 }
157 };
158 let obj = json
159 .as_object_mut()
160 .ok_or_else(|| "root JSON must be an object".to_string())?;
161
162 let servers = obj
163 .entry("mcpServers")
164 .or_insert_with(|| serde_json::json!({}));
165 let servers_obj = servers
166 .as_object_mut()
167 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
168
169 let existing = servers_obj.get("lean-ctx").cloned();
170 if existing.as_ref() == Some(&desired) {
171 return Ok(WriteResult {
172 action: WriteAction::Already,
173 note: None,
174 });
175 }
176 servers_obj.insert("lean-ctx".to_string(), desired);
177
178 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
179 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
180 return Ok(WriteResult {
181 action: WriteAction::Updated,
182 note: None,
183 });
184 }
185
186 write_mcp_json_fresh(&target.config_path, &desired, None)
187}
188
189fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
190 use std::io::Write;
191 use std::process::{Command, Stdio};
192 use std::time::{Duration, Instant};
193
194 let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
195
196 let mut cmd = if cfg!(windows) {
199 let mut c = Command::new("cmd");
200 c.args([
201 "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
202 ]);
203 c
204 } else {
205 let mut c = Command::new("claude");
206 c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
207 c
208 };
209
210 let mut child = cmd
211 .stdin(Stdio::piped())
212 .stdout(Stdio::null())
213 .stderr(Stdio::null())
214 .spawn()
215 .map_err(|e| e.to_string())?;
216
217 if let Some(mut stdin) = child.stdin.take() {
218 let _ = stdin.write_all(server_json.as_bytes());
219 }
220
221 let deadline = Duration::from_secs(3);
222 let start = Instant::now();
223 loop {
224 match child.try_wait() {
225 Ok(Some(status)) => {
226 return if status.success() {
227 Ok(WriteResult {
228 action: WriteAction::Updated,
229 note: Some("via claude mcp add-json".to_string()),
230 })
231 } else {
232 Err("claude mcp add-json failed".to_string())
233 };
234 }
235 Ok(None) => {
236 if start.elapsed() > deadline {
237 let _ = child.kill();
238 let _ = child.wait();
239 return Err("claude mcp add-json timed out".to_string());
240 }
241 std::thread::sleep(Duration::from_millis(20));
242 }
243 Err(e) => return Err(e.to_string()),
244 }
245 }
246}
247
248fn write_mcp_json_fresh(
249 path: &std::path::Path,
250 desired: &Value,
251 note: Option<String>,
252) -> Result<WriteResult, String> {
253 let content = serde_json::to_string_pretty(&serde_json::json!({
254 "mcpServers": { "lean-ctx": desired }
255 }))
256 .map_err(|e| e.to_string())?;
257 crate::config_io::write_atomic_with_backup(path, &content)?;
258 Ok(WriteResult {
259 action: if note.is_some() {
260 WriteAction::Updated
261 } else {
262 WriteAction::Created
263 },
264 note,
265 })
266}
267
268fn write_zed_config(
269 target: &EditorTarget,
270 binary: &str,
271 opts: WriteOptions,
272) -> Result<WriteResult, String> {
273 let desired = serde_json::json!({
274 "source": "custom",
275 "command": binary,
276 "args": [],
277 "env": {}
278 });
279
280 if target.config_path.exists() {
281 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
282 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
283 Ok(v) => v,
284 Err(e) => {
285 if !opts.overwrite_invalid {
286 return Err(e.to_string());
287 }
288 backup_invalid_file(&target.config_path)?;
289 return write_zed_config_fresh(
290 &target.config_path,
291 &desired,
292 Some("overwrote invalid JSON".to_string()),
293 );
294 }
295 };
296 let obj = json
297 .as_object_mut()
298 .ok_or_else(|| "root JSON must be an object".to_string())?;
299
300 let servers = obj
301 .entry("context_servers")
302 .or_insert_with(|| serde_json::json!({}));
303 let servers_obj = servers
304 .as_object_mut()
305 .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
306
307 let existing = servers_obj.get("lean-ctx").cloned();
308 if existing.as_ref() == Some(&desired) {
309 return Ok(WriteResult {
310 action: WriteAction::Already,
311 note: None,
312 });
313 }
314 servers_obj.insert("lean-ctx".to_string(), desired);
315
316 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
317 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
318 return Ok(WriteResult {
319 action: WriteAction::Updated,
320 note: None,
321 });
322 }
323
324 write_zed_config_fresh(&target.config_path, &desired, None)
325}
326
327fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
328 if target.config_path.exists() {
329 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
330 let updated = upsert_codex_toml(&content, binary);
331 if updated == content {
332 return Ok(WriteResult {
333 action: WriteAction::Already,
334 note: None,
335 });
336 }
337 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
338 return Ok(WriteResult {
339 action: WriteAction::Updated,
340 note: None,
341 });
342 }
343
344 let content = format!(
345 "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
346 toml_quote(binary)
347 );
348 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
349 Ok(WriteResult {
350 action: WriteAction::Created,
351 note: None,
352 })
353}
354
355fn write_zed_config_fresh(
356 path: &std::path::Path,
357 desired: &Value,
358 note: Option<String>,
359) -> Result<WriteResult, String> {
360 let content = serde_json::to_string_pretty(&serde_json::json!({
361 "context_servers": { "lean-ctx": desired }
362 }))
363 .map_err(|e| e.to_string())?;
364 crate::config_io::write_atomic_with_backup(path, &content)?;
365 Ok(WriteResult {
366 action: if note.is_some() {
367 WriteAction::Updated
368 } else {
369 WriteAction::Created
370 },
371 note,
372 })
373}
374
375fn write_vscode_mcp(
376 target: &EditorTarget,
377 binary: &str,
378 opts: WriteOptions,
379) -> Result<WriteResult, String> {
380 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
381 .map(|d| d.to_string_lossy().to_string())
382 .unwrap_or_default();
383 let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
384
385 if target.config_path.exists() {
386 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
387 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
388 Ok(v) => v,
389 Err(e) => {
390 if !opts.overwrite_invalid {
391 return Err(e.to_string());
392 }
393 backup_invalid_file(&target.config_path)?;
394 return write_vscode_mcp_fresh(
395 &target.config_path,
396 binary,
397 Some("overwrote invalid JSON".to_string()),
398 );
399 }
400 };
401 let obj = json
402 .as_object_mut()
403 .ok_or_else(|| "root JSON must be an object".to_string())?;
404
405 let servers = obj
406 .entry("servers")
407 .or_insert_with(|| serde_json::json!({}));
408 let servers_obj = servers
409 .as_object_mut()
410 .ok_or_else(|| "\"servers\" must be an object".to_string())?;
411
412 let existing = servers_obj.get("lean-ctx").cloned();
413 if existing.as_ref() == Some(&desired) {
414 return Ok(WriteResult {
415 action: WriteAction::Already,
416 note: None,
417 });
418 }
419 servers_obj.insert("lean-ctx".to_string(), desired);
420
421 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
422 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
423 return Ok(WriteResult {
424 action: WriteAction::Updated,
425 note: None,
426 });
427 }
428
429 write_vscode_mcp_fresh(&target.config_path, binary, None)
430}
431
432fn write_vscode_mcp_fresh(
433 path: &std::path::Path,
434 binary: &str,
435 note: Option<String>,
436) -> Result<WriteResult, String> {
437 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
438 .map(|d| d.to_string_lossy().to_string())
439 .unwrap_or_default();
440 let content = serde_json::to_string_pretty(&serde_json::json!({
441 "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
442 }))
443 .map_err(|e| e.to_string())?;
444 crate::config_io::write_atomic_with_backup(path, &content)?;
445 Ok(WriteResult {
446 action: if note.is_some() {
447 WriteAction::Updated
448 } else {
449 WriteAction::Created
450 },
451 note,
452 })
453}
454
455fn write_opencode_config(
456 target: &EditorTarget,
457 binary: &str,
458 opts: WriteOptions,
459) -> Result<WriteResult, String> {
460 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
461 .map(|d| d.to_string_lossy().to_string())
462 .unwrap_or_default();
463 let desired = serde_json::json!({
464 "type": "local",
465 "command": [binary],
466 "enabled": true,
467 "environment": { "LEAN_CTX_DATA_DIR": data_dir }
468 });
469
470 if target.config_path.exists() {
471 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
472 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
473 Ok(v) => v,
474 Err(e) => {
475 if !opts.overwrite_invalid {
476 return Err(e.to_string());
477 }
478 backup_invalid_file(&target.config_path)?;
479 return write_opencode_fresh(
480 &target.config_path,
481 binary,
482 Some("overwrote invalid JSON".to_string()),
483 );
484 }
485 };
486 let obj = json
487 .as_object_mut()
488 .ok_or_else(|| "root JSON must be an object".to_string())?;
489 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
490 let mcp_obj = mcp
491 .as_object_mut()
492 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
493
494 let existing = mcp_obj.get("lean-ctx").cloned();
495 if existing.as_ref() == Some(&desired) {
496 return Ok(WriteResult {
497 action: WriteAction::Already,
498 note: None,
499 });
500 }
501 mcp_obj.insert("lean-ctx".to_string(), desired);
502
503 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
504 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
505 return Ok(WriteResult {
506 action: WriteAction::Updated,
507 note: None,
508 });
509 }
510
511 write_opencode_fresh(&target.config_path, binary, None)
512}
513
514fn write_opencode_fresh(
515 path: &std::path::Path,
516 binary: &str,
517 note: Option<String>,
518) -> Result<WriteResult, String> {
519 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
520 .map(|d| d.to_string_lossy().to_string())
521 .unwrap_or_default();
522 let content = serde_json::to_string_pretty(&serde_json::json!({
523 "$schema": "https://opencode.ai/config.json",
524 "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
525 }))
526 .map_err(|e| e.to_string())?;
527 crate::config_io::write_atomic_with_backup(path, &content)?;
528 Ok(WriteResult {
529 action: if note.is_some() {
530 WriteAction::Updated
531 } else {
532 WriteAction::Created
533 },
534 note,
535 })
536}
537
538fn write_jetbrains_config(
539 target: &EditorTarget,
540 binary: &str,
541 opts: WriteOptions,
542) -> Result<WriteResult, String> {
543 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
544 .map(|d| d.to_string_lossy().to_string())
545 .unwrap_or_default();
546 let entry = serde_json::json!({
547 "name": "lean-ctx",
548 "command": binary,
549 "args": [],
550 "env": { "LEAN_CTX_DATA_DIR": data_dir }
551 });
552
553 if target.config_path.exists() {
554 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
555 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
556 Ok(v) => v,
557 Err(e) => {
558 if !opts.overwrite_invalid {
559 return Err(e.to_string());
560 }
561 backup_invalid_file(&target.config_path)?;
562 let fresh = serde_json::json!({ "servers": [entry] });
563 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
564 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
565 return Ok(WriteResult {
566 action: WriteAction::Updated,
567 note: Some("overwrote invalid JSON".to_string()),
568 });
569 }
570 };
571 let obj = json
572 .as_object_mut()
573 .ok_or_else(|| "root JSON must be an object".to_string())?;
574 let servers = obj
575 .entry("servers")
576 .or_insert_with(|| serde_json::json!([]));
577 if let Some(arr) = servers.as_array_mut() {
578 let already = arr
579 .iter()
580 .any(|s| s.get("name").and_then(|n| n.as_str()) == Some("lean-ctx"));
581 if already {
582 return Ok(WriteResult {
583 action: WriteAction::Already,
584 note: None,
585 });
586 }
587 arr.push(entry);
588 }
589 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
590 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
591 return Ok(WriteResult {
592 action: WriteAction::Updated,
593 note: None,
594 });
595 }
596
597 let config = serde_json::json!({ "servers": [entry] });
598 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
599 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
600 Ok(WriteResult {
601 action: WriteAction::Created,
602 note: None,
603 })
604}
605
606fn write_amp_config(
607 target: &EditorTarget,
608 binary: &str,
609 opts: WriteOptions,
610) -> Result<WriteResult, String> {
611 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
612 .map(|d| d.to_string_lossy().to_string())
613 .unwrap_or_default();
614 let entry = serde_json::json!({
615 "command": binary,
616 "env": { "LEAN_CTX_DATA_DIR": data_dir }
617 });
618
619 if target.config_path.exists() {
620 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
621 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
622 Ok(v) => v,
623 Err(e) => {
624 if !opts.overwrite_invalid {
625 return Err(e.to_string());
626 }
627 backup_invalid_file(&target.config_path)?;
628 let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
629 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
630 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
631 return Ok(WriteResult {
632 action: WriteAction::Updated,
633 note: Some("overwrote invalid JSON".to_string()),
634 });
635 }
636 };
637 let obj = json
638 .as_object_mut()
639 .ok_or_else(|| "root JSON must be an object".to_string())?;
640 let servers = obj
641 .entry("amp.mcpServers")
642 .or_insert_with(|| serde_json::json!({}));
643 let servers_obj = servers
644 .as_object_mut()
645 .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
646
647 let existing = servers_obj.get("lean-ctx").cloned();
648 if existing.as_ref() == Some(&entry) {
649 return Ok(WriteResult {
650 action: WriteAction::Already,
651 note: None,
652 });
653 }
654 servers_obj.insert("lean-ctx".to_string(), entry);
655
656 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
657 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
658 return Ok(WriteResult {
659 action: WriteAction::Updated,
660 note: None,
661 });
662 }
663
664 let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
665 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
666 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
667 Ok(WriteResult {
668 action: WriteAction::Created,
669 note: None,
670 })
671}
672
673fn write_crush_config(
674 target: &EditorTarget,
675 binary: &str,
676 opts: WriteOptions,
677) -> Result<WriteResult, String> {
678 let desired = serde_json::json!({ "type": "stdio", "command": binary });
679
680 if target.config_path.exists() {
681 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
682 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
683 Ok(v) => v,
684 Err(e) => {
685 if !opts.overwrite_invalid {
686 return Err(e.to_string());
687 }
688 backup_invalid_file(&target.config_path)?;
689 return write_crush_fresh(
690 &target.config_path,
691 &desired,
692 Some("overwrote invalid JSON".to_string()),
693 );
694 }
695 };
696 let obj = json
697 .as_object_mut()
698 .ok_or_else(|| "root JSON must be an object".to_string())?;
699 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
700 let mcp_obj = mcp
701 .as_object_mut()
702 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
703
704 let existing = mcp_obj.get("lean-ctx").cloned();
705 if existing.as_ref() == Some(&desired) {
706 return Ok(WriteResult {
707 action: WriteAction::Already,
708 note: None,
709 });
710 }
711 mcp_obj.insert("lean-ctx".to_string(), desired);
712
713 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
714 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
715 return Ok(WriteResult {
716 action: WriteAction::Updated,
717 note: None,
718 });
719 }
720
721 write_crush_fresh(&target.config_path, &desired, None)
722}
723
724fn write_crush_fresh(
725 path: &std::path::Path,
726 desired: &Value,
727 note: Option<String>,
728) -> Result<WriteResult, String> {
729 let content = serde_json::to_string_pretty(&serde_json::json!({
730 "mcp": { "lean-ctx": desired }
731 }))
732 .map_err(|e| e.to_string())?;
733 crate::config_io::write_atomic_with_backup(path, &content)?;
734 Ok(WriteResult {
735 action: if note.is_some() {
736 WriteAction::Updated
737 } else {
738 WriteAction::Created
739 },
740 note,
741 })
742}
743
744fn upsert_codex_toml(existing: &str, binary: &str) -> String {
745 let mut out = String::with_capacity(existing.len() + 128);
746 let mut in_section = false;
747 let mut saw_section = false;
748 let mut wrote_command = false;
749 let mut wrote_args = false;
750
751 for line in existing.lines() {
752 let trimmed = line.trim();
753 if trimmed == "[]" {
754 continue;
755 }
756 if trimmed.starts_with('[') && trimmed.ends_with(']') {
757 if in_section && !wrote_command {
758 out.push_str(&format!("command = {}\n", toml_quote(binary)));
759 wrote_command = true;
760 }
761 if in_section && !wrote_args {
762 out.push_str("args = []\n");
763 wrote_args = true;
764 }
765 in_section = trimmed == "[mcp_servers.lean-ctx]";
766 if in_section {
767 saw_section = true;
768 }
769 out.push_str(line);
770 out.push('\n');
771 continue;
772 }
773
774 if in_section {
775 if trimmed.starts_with("command") && trimmed.contains('=') {
776 out.push_str(&format!("command = {}\n", toml_quote(binary)));
777 wrote_command = true;
778 continue;
779 }
780 if trimmed.starts_with("args") && trimmed.contains('=') {
781 out.push_str("args = []\n");
782 wrote_args = true;
783 continue;
784 }
785 }
786
787 out.push_str(line);
788 out.push('\n');
789 }
790
791 if saw_section {
792 if in_section && !wrote_command {
793 out.push_str(&format!("command = {}\n", toml_quote(binary)));
794 }
795 if in_section && !wrote_args {
796 out.push_str("args = []\n");
797 }
798 return out;
799 }
800
801 if !out.ends_with('\n') {
802 out.push('\n');
803 }
804 out.push_str("\n[mcp_servers.lean-ctx]\n");
805 out.push_str(&format!("command = {}\n", toml_quote(binary)));
806 out.push_str("args = []\n");
807 out
808}
809
810fn write_gemini_settings(
811 target: &EditorTarget,
812 binary: &str,
813 opts: WriteOptions,
814) -> Result<WriteResult, String> {
815 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
816 .map(|d| d.to_string_lossy().to_string())
817 .unwrap_or_default();
818 let entry = serde_json::json!({
819 "command": binary,
820 "env": { "LEAN_CTX_DATA_DIR": data_dir },
821 "trust": true,
822 });
823
824 if target.config_path.exists() {
825 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
826 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
827 Ok(v) => v,
828 Err(e) => {
829 if !opts.overwrite_invalid {
830 return Err(e.to_string());
831 }
832 backup_invalid_file(&target.config_path)?;
833 let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
834 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
835 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
836 return Ok(WriteResult {
837 action: WriteAction::Updated,
838 note: Some("overwrote invalid JSON".to_string()),
839 });
840 }
841 };
842 let obj = json
843 .as_object_mut()
844 .ok_or_else(|| "root JSON must be an object".to_string())?;
845 let servers = obj
846 .entry("mcpServers")
847 .or_insert_with(|| serde_json::json!({}));
848 let servers_obj = servers
849 .as_object_mut()
850 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
851
852 let existing = servers_obj.get("lean-ctx").cloned();
853 if existing.as_ref() == Some(&entry) {
854 return Ok(WriteResult {
855 action: WriteAction::Already,
856 note: None,
857 });
858 }
859 servers_obj.insert("lean-ctx".to_string(), entry);
860
861 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
862 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
863 return Ok(WriteResult {
864 action: WriteAction::Updated,
865 note: None,
866 });
867 }
868
869 let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
870 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
871 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
872 Ok(WriteResult {
873 action: WriteAction::Created,
874 note: None,
875 })
876}
877
878fn write_hermes_yaml(
879 target: &EditorTarget,
880 binary: &str,
881 _opts: WriteOptions,
882) -> Result<WriteResult, String> {
883 let data_dir = default_data_dir()?;
884
885 let lean_ctx_block = format!(
886 " lean-ctx:\n command: \"{binary}\"\n env:\n LEAN_CTX_DATA_DIR: \"{data_dir}\""
887 );
888
889 if target.config_path.exists() {
890 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
891
892 if content.contains("lean-ctx") {
893 return Ok(WriteResult {
894 action: WriteAction::Already,
895 note: None,
896 });
897 }
898
899 let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
900 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
901 return Ok(WriteResult {
902 action: WriteAction::Updated,
903 note: None,
904 });
905 }
906
907 let content = format!("mcp_servers:\n{lean_ctx_block}\n");
908 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
909 Ok(WriteResult {
910 action: WriteAction::Created,
911 note: None,
912 })
913}
914
915fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
916 let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
917 let mut in_mcp_section = false;
918 let mut saw_mcp_child = false;
919 let mut inserted = false;
920 let lines: Vec<&str> = existing.lines().collect();
921
922 for line in &lines {
923 if !inserted && line.trim_end() == "mcp_servers:" {
924 in_mcp_section = true;
925 out.push_str(line);
926 out.push('\n');
927 continue;
928 }
929
930 if in_mcp_section && !inserted {
931 let is_child = line.starts_with(" ") && !line.trim().is_empty();
932 let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
933
934 if is_child {
935 saw_mcp_child = true;
936 out.push_str(line);
937 out.push('\n');
938 continue;
939 }
940
941 if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
942 out.push_str(lean_ctx_block);
943 out.push('\n');
944 inserted = true;
945 in_mcp_section = false;
946 }
947 }
948
949 out.push_str(line);
950 out.push('\n');
951 }
952
953 if in_mcp_section && !inserted {
954 out.push_str(lean_ctx_block);
955 out.push('\n');
956 inserted = true;
957 }
958
959 if !inserted {
960 if !out.ends_with('\n') {
961 out.push('\n');
962 }
963 out.push_str("\nmcp_servers:\n");
964 out.push_str(lean_ctx_block);
965 out.push('\n');
966 }
967
968 out
969}
970
971fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
972 if !path.exists() {
973 return Ok(());
974 }
975 let parent = path
976 .parent()
977 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
978 let filename = path
979 .file_name()
980 .ok_or_else(|| "invalid path (no filename)".to_string())?
981 .to_string_lossy();
982 let pid = std::process::id();
983 let nanos = std::time::SystemTime::now()
984 .duration_since(std::time::UNIX_EPOCH)
985 .map_or(0, |d| d.as_nanos());
986 let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
987 std::fs::rename(path, bak).map_err(|e| e.to_string())?;
988 Ok(())
989}
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994 use std::path::PathBuf;
995
996 fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
997 EditorTarget {
998 name: "test",
999 agent_key: "test".to_string(),
1000 config_path: path,
1001 detect_path: PathBuf::from("/nonexistent"),
1002 config_type: ty,
1003 }
1004 }
1005
1006 #[test]
1007 fn mcp_json_upserts_and_preserves_other_servers() {
1008 let dir = tempfile::tempdir().unwrap();
1009 let path = dir.path().join("mcp.json");
1010 std::fs::write(
1011 &path,
1012 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1013 )
1014 .unwrap();
1015
1016 let t = target(path.clone(), ConfigType::McpJson);
1017 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1018 assert_eq!(res.action, WriteAction::Updated);
1019
1020 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1021 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1022 assert_eq!(
1023 json["mcpServers"]["lean-ctx"]["command"],
1024 "/new/path/lean-ctx"
1025 );
1026 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1027 assert!(
1028 json["mcpServers"]["lean-ctx"]["autoApprove"]
1029 .as_array()
1030 .unwrap()
1031 .len()
1032 > 5
1033 );
1034 }
1035
1036 #[test]
1037 fn crush_config_writes_mcp_root() {
1038 let dir = tempfile::tempdir().unwrap();
1039 let path = dir.path().join("crush.json");
1040 std::fs::write(
1041 &path,
1042 r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1043 )
1044 .unwrap();
1045
1046 let t = target(path.clone(), ConfigType::Crush);
1047 let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1048 assert_eq!(res.action, WriteAction::Updated);
1049
1050 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1051 assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1052 assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1053 }
1054
1055 #[test]
1056 fn codex_toml_upserts_existing_section() {
1057 let dir = tempfile::tempdir().unwrap();
1058 let path = dir.path().join("config.toml");
1059 std::fs::write(
1060 &path,
1061 r#"[mcp_servers.lean-ctx]
1062command = "old"
1063args = ["x"]
1064"#,
1065 )
1066 .unwrap();
1067
1068 let t = target(path.clone(), ConfigType::Codex);
1069 let res = write_codex_config(&t, "new").unwrap();
1070 assert_eq!(res.action, WriteAction::Updated);
1071
1072 let content = std::fs::read_to_string(&path).unwrap();
1073 assert!(content.contains(r#"command = "new""#));
1074 assert!(content.contains("args = []"));
1075 }
1076
1077 #[test]
1078 fn upsert_codex_toml_inserts_new_section_when_missing() {
1079 let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1080 assert!(updated.contains("[mcp_servers.lean-ctx]"));
1081 assert!(updated.contains("command = \"lean-ctx\""));
1082 assert!(updated.contains("args = []"));
1083 }
1084
1085 #[test]
1086 fn codex_toml_uses_single_quotes_for_backslash_paths() {
1087 let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1088 let updated = upsert_codex_toml("", win_path);
1089 assert!(
1090 updated.contains(&format!("command = '{win_path}'")),
1091 "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1092 );
1093 }
1094
1095 #[test]
1096 fn codex_toml_uses_double_quotes_for_unix_paths() {
1097 let unix_path = "/usr/local/bin/lean-ctx";
1098 let updated = upsert_codex_toml("", unix_path);
1099 assert!(
1100 updated.contains(&format!("command = \"{unix_path}\"")),
1101 "Unix paths should use double quotes: {updated}"
1102 );
1103 }
1104
1105 #[test]
1106 fn auto_approve_contains_core_tools() {
1107 let tools = auto_approve_tools();
1108 assert!(tools.contains(&"ctx_read"));
1109 assert!(tools.contains(&"ctx_shell"));
1110 assert!(tools.contains(&"ctx_search"));
1111 assert!(tools.contains(&"ctx_workflow"));
1112 assert!(tools.contains(&"ctx_cost"));
1113 }
1114
1115 #[test]
1116 fn antigravity_config_omits_auto_approve() {
1117 let dir = tempfile::tempdir().unwrap();
1118 let path = dir.path().join("mcp_config.json");
1119
1120 let t = EditorTarget {
1121 name: "Antigravity",
1122 agent_key: "gemini".to_string(),
1123 config_path: path.clone(),
1124 detect_path: PathBuf::from("/nonexistent"),
1125 config_type: ConfigType::McpJson,
1126 };
1127 let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1128 assert_eq!(res.action, WriteAction::Created);
1129
1130 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1131 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1132 assert_eq!(
1133 json["mcpServers"]["lean-ctx"]["command"],
1134 "/usr/local/bin/lean-ctx"
1135 );
1136 }
1137
1138 #[test]
1139 fn hermes_yaml_inserts_into_existing_mcp_servers() {
1140 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";
1141 let block = " lean-ctx:\n command: \"lean-ctx\"\n env:\n LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1142 let result = upsert_hermes_yaml_mcp(existing, block);
1143 assert!(result.contains("lean-ctx"));
1144 assert!(result.contains("model: anthropic/claude-sonnet-4"));
1145 assert!(result.contains("tool_allowlist:"));
1146 assert!(result.contains("github:"));
1147 }
1148
1149 #[test]
1150 fn hermes_yaml_creates_mcp_servers_section() {
1151 let existing = "model: openai/gpt-4o\n";
1152 let block = " lean-ctx:\n command: \"lean-ctx\"";
1153 let result = upsert_hermes_yaml_mcp(existing, block);
1154 assert!(result.contains("mcp_servers:"));
1155 assert!(result.contains("lean-ctx"));
1156 assert!(result.contains("model: openai/gpt-4o"));
1157 }
1158
1159 #[test]
1160 fn hermes_yaml_skips_if_already_present() {
1161 let dir = tempfile::tempdir().unwrap();
1162 let path = dir.path().join("config.yaml");
1163 std::fs::write(
1164 &path,
1165 "mcp_servers:\n lean-ctx:\n command: \"lean-ctx\"\n",
1166 )
1167 .unwrap();
1168 let t = target(path.clone(), ConfigType::HermesYaml);
1169 let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1170 assert_eq!(res.action, WriteAction::Already);
1171 }
1172}