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