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