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 desired = serde_json::json!({
551 "command": binary,
552 "args": [],
553 "env": { "LEAN_CTX_DATA_DIR": data_dir }
554 });
555
556 if target.config_path.exists() {
557 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
558 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
559 Ok(v) => v,
560 Err(e) => {
561 if !opts.overwrite_invalid {
562 return Err(e.to_string());
563 }
564 backup_invalid_file(&target.config_path)?;
565 let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
566 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
567 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
568 return Ok(WriteResult {
569 action: WriteAction::Updated,
570 note: Some(
571 "overwrote invalid JSON (paste this snippet into JetBrains MCP settings)"
572 .to_string(),
573 ),
574 });
575 }
576 };
577 let obj = json
578 .as_object_mut()
579 .ok_or_else(|| "root JSON must be an object".to_string())?;
580
581 let servers = obj
582 .entry("mcpServers")
583 .or_insert_with(|| serde_json::json!({}));
584 let servers_obj = servers
585 .as_object_mut()
586 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
587
588 let existing = servers_obj.get("lean-ctx").cloned();
589 if existing.as_ref() == Some(&desired) {
590 return Ok(WriteResult {
591 action: WriteAction::Already,
592 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
593 });
594 }
595 servers_obj.insert("lean-ctx".to_string(), desired);
596
597 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
598 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
599 return Ok(WriteResult {
600 action: WriteAction::Updated,
601 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
602 });
603 }
604
605 let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
606 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
607 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
608 Ok(WriteResult {
609 action: WriteAction::Created,
610 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
611 })
612}
613
614fn write_amp_config(
615 target: &EditorTarget,
616 binary: &str,
617 opts: WriteOptions,
618) -> Result<WriteResult, String> {
619 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
620 .map(|d| d.to_string_lossy().to_string())
621 .unwrap_or_default();
622 let entry = serde_json::json!({
623 "command": binary,
624 "env": { "LEAN_CTX_DATA_DIR": data_dir }
625 });
626
627 if target.config_path.exists() {
628 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
629 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
630 Ok(v) => v,
631 Err(e) => {
632 if !opts.overwrite_invalid {
633 return Err(e.to_string());
634 }
635 backup_invalid_file(&target.config_path)?;
636 let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
637 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
638 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
639 return Ok(WriteResult {
640 action: WriteAction::Updated,
641 note: Some("overwrote invalid JSON".to_string()),
642 });
643 }
644 };
645 let obj = json
646 .as_object_mut()
647 .ok_or_else(|| "root JSON must be an object".to_string())?;
648 let servers = obj
649 .entry("amp.mcpServers")
650 .or_insert_with(|| serde_json::json!({}));
651 let servers_obj = servers
652 .as_object_mut()
653 .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
654
655 let existing = servers_obj.get("lean-ctx").cloned();
656 if existing.as_ref() == Some(&entry) {
657 return Ok(WriteResult {
658 action: WriteAction::Already,
659 note: None,
660 });
661 }
662 servers_obj.insert("lean-ctx".to_string(), entry);
663
664 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
665 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
666 return Ok(WriteResult {
667 action: WriteAction::Updated,
668 note: None,
669 });
670 }
671
672 let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
673 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
674 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
675 Ok(WriteResult {
676 action: WriteAction::Created,
677 note: None,
678 })
679}
680
681fn write_crush_config(
682 target: &EditorTarget,
683 binary: &str,
684 opts: WriteOptions,
685) -> Result<WriteResult, String> {
686 let desired = serde_json::json!({ "type": "stdio", "command": binary });
687
688 if target.config_path.exists() {
689 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
690 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
691 Ok(v) => v,
692 Err(e) => {
693 if !opts.overwrite_invalid {
694 return Err(e.to_string());
695 }
696 backup_invalid_file(&target.config_path)?;
697 return write_crush_fresh(
698 &target.config_path,
699 &desired,
700 Some("overwrote invalid JSON".to_string()),
701 );
702 }
703 };
704 let obj = json
705 .as_object_mut()
706 .ok_or_else(|| "root JSON must be an object".to_string())?;
707 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
708 let mcp_obj = mcp
709 .as_object_mut()
710 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
711
712 let existing = mcp_obj.get("lean-ctx").cloned();
713 if existing.as_ref() == Some(&desired) {
714 return Ok(WriteResult {
715 action: WriteAction::Already,
716 note: None,
717 });
718 }
719 mcp_obj.insert("lean-ctx".to_string(), desired);
720
721 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
722 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
723 return Ok(WriteResult {
724 action: WriteAction::Updated,
725 note: None,
726 });
727 }
728
729 write_crush_fresh(&target.config_path, &desired, None)
730}
731
732fn write_crush_fresh(
733 path: &std::path::Path,
734 desired: &Value,
735 note: Option<String>,
736) -> Result<WriteResult, String> {
737 let content = serde_json::to_string_pretty(&serde_json::json!({
738 "mcp": { "lean-ctx": desired }
739 }))
740 .map_err(|e| e.to_string())?;
741 crate::config_io::write_atomic_with_backup(path, &content)?;
742 Ok(WriteResult {
743 action: if note.is_some() {
744 WriteAction::Updated
745 } else {
746 WriteAction::Created
747 },
748 note,
749 })
750}
751
752fn upsert_codex_toml(existing: &str, binary: &str) -> String {
753 let mut out = String::with_capacity(existing.len() + 128);
754 let mut in_section = false;
755 let mut saw_section = false;
756 let mut wrote_command = false;
757 let mut wrote_args = false;
758
759 for line in existing.lines() {
760 let trimmed = line.trim();
761 if trimmed == "[]" {
762 continue;
763 }
764 if trimmed.starts_with('[') && trimmed.ends_with(']') {
765 if in_section && !wrote_command {
766 out.push_str(&format!("command = {}\n", toml_quote(binary)));
767 wrote_command = true;
768 }
769 if in_section && !wrote_args {
770 out.push_str("args = []\n");
771 wrote_args = true;
772 }
773 in_section = trimmed == "[mcp_servers.lean-ctx]";
774 if in_section {
775 saw_section = true;
776 }
777 out.push_str(line);
778 out.push('\n');
779 continue;
780 }
781
782 if in_section {
783 if trimmed.starts_with("command") && trimmed.contains('=') {
784 out.push_str(&format!("command = {}\n", toml_quote(binary)));
785 wrote_command = true;
786 continue;
787 }
788 if trimmed.starts_with("args") && trimmed.contains('=') {
789 out.push_str("args = []\n");
790 wrote_args = true;
791 continue;
792 }
793 }
794
795 out.push_str(line);
796 out.push('\n');
797 }
798
799 if saw_section {
800 if in_section && !wrote_command {
801 out.push_str(&format!("command = {}\n", toml_quote(binary)));
802 }
803 if in_section && !wrote_args {
804 out.push_str("args = []\n");
805 }
806 return out;
807 }
808
809 if !out.ends_with('\n') {
810 out.push('\n');
811 }
812 out.push_str("\n[mcp_servers.lean-ctx]\n");
813 out.push_str(&format!("command = {}\n", toml_quote(binary)));
814 out.push_str("args = []\n");
815 out
816}
817
818fn write_gemini_settings(
819 target: &EditorTarget,
820 binary: &str,
821 opts: WriteOptions,
822) -> Result<WriteResult, String> {
823 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
824 .map(|d| d.to_string_lossy().to_string())
825 .unwrap_or_default();
826 let entry = serde_json::json!({
827 "command": binary,
828 "env": { "LEAN_CTX_DATA_DIR": data_dir },
829 "trust": true,
830 });
831
832 if target.config_path.exists() {
833 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
834 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
835 Ok(v) => v,
836 Err(e) => {
837 if !opts.overwrite_invalid {
838 return Err(e.to_string());
839 }
840 backup_invalid_file(&target.config_path)?;
841 let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
842 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
843 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
844 return Ok(WriteResult {
845 action: WriteAction::Updated,
846 note: Some("overwrote invalid JSON".to_string()),
847 });
848 }
849 };
850 let obj = json
851 .as_object_mut()
852 .ok_or_else(|| "root JSON must be an object".to_string())?;
853 let servers = obj
854 .entry("mcpServers")
855 .or_insert_with(|| serde_json::json!({}));
856 let servers_obj = servers
857 .as_object_mut()
858 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
859
860 let existing = servers_obj.get("lean-ctx").cloned();
861 if existing.as_ref() == Some(&entry) {
862 return Ok(WriteResult {
863 action: WriteAction::Already,
864 note: None,
865 });
866 }
867 servers_obj.insert("lean-ctx".to_string(), entry);
868
869 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
870 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
871 return Ok(WriteResult {
872 action: WriteAction::Updated,
873 note: None,
874 });
875 }
876
877 let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
878 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
879 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
880 Ok(WriteResult {
881 action: WriteAction::Created,
882 note: None,
883 })
884}
885
886fn write_hermes_yaml(
887 target: &EditorTarget,
888 binary: &str,
889 _opts: WriteOptions,
890) -> Result<WriteResult, String> {
891 let data_dir = default_data_dir()?;
892
893 let lean_ctx_block = format!(
894 " lean-ctx:\n command: \"{binary}\"\n env:\n LEAN_CTX_DATA_DIR: \"{data_dir}\""
895 );
896
897 if target.config_path.exists() {
898 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
899
900 if content.contains("lean-ctx") {
901 return Ok(WriteResult {
902 action: WriteAction::Already,
903 note: None,
904 });
905 }
906
907 let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
908 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
909 return Ok(WriteResult {
910 action: WriteAction::Updated,
911 note: None,
912 });
913 }
914
915 let content = format!("mcp_servers:\n{lean_ctx_block}\n");
916 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
917 Ok(WriteResult {
918 action: WriteAction::Created,
919 note: None,
920 })
921}
922
923fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
924 let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
925 let mut in_mcp_section = false;
926 let mut saw_mcp_child = false;
927 let mut inserted = false;
928 let lines: Vec<&str> = existing.lines().collect();
929
930 for line in &lines {
931 if !inserted && line.trim_end() == "mcp_servers:" {
932 in_mcp_section = true;
933 out.push_str(line);
934 out.push('\n');
935 continue;
936 }
937
938 if in_mcp_section && !inserted {
939 let is_child = line.starts_with(" ") && !line.trim().is_empty();
940 let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
941
942 if is_child {
943 saw_mcp_child = true;
944 out.push_str(line);
945 out.push('\n');
946 continue;
947 }
948
949 if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
950 out.push_str(lean_ctx_block);
951 out.push('\n');
952 inserted = true;
953 in_mcp_section = false;
954 }
955 }
956
957 out.push_str(line);
958 out.push('\n');
959 }
960
961 if in_mcp_section && !inserted {
962 out.push_str(lean_ctx_block);
963 out.push('\n');
964 inserted = true;
965 }
966
967 if !inserted {
968 if !out.ends_with('\n') {
969 out.push('\n');
970 }
971 out.push_str("\nmcp_servers:\n");
972 out.push_str(lean_ctx_block);
973 out.push('\n');
974 }
975
976 out
977}
978
979fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
980 if !path.exists() {
981 return Ok(());
982 }
983 let parent = path
984 .parent()
985 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
986 let filename = path
987 .file_name()
988 .ok_or_else(|| "invalid path (no filename)".to_string())?
989 .to_string_lossy();
990 let pid = std::process::id();
991 let nanos = std::time::SystemTime::now()
992 .duration_since(std::time::UNIX_EPOCH)
993 .map_or(0, |d| d.as_nanos());
994 let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
995 std::fs::rename(path, bak).map_err(|e| e.to_string())?;
996 Ok(())
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002 use std::path::PathBuf;
1003
1004 fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
1005 EditorTarget {
1006 name: "test",
1007 agent_key: "test".to_string(),
1008 config_path: path,
1009 detect_path: PathBuf::from("/nonexistent"),
1010 config_type: ty,
1011 }
1012 }
1013
1014 #[test]
1015 fn mcp_json_upserts_and_preserves_other_servers() {
1016 let dir = tempfile::tempdir().unwrap();
1017 let path = dir.path().join("mcp.json");
1018 std::fs::write(
1019 &path,
1020 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1021 )
1022 .unwrap();
1023
1024 let t = target(path.clone(), ConfigType::McpJson);
1025 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1026 assert_eq!(res.action, WriteAction::Updated);
1027
1028 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1029 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1030 assert_eq!(
1031 json["mcpServers"]["lean-ctx"]["command"],
1032 "/new/path/lean-ctx"
1033 );
1034 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1035 assert!(
1036 json["mcpServers"]["lean-ctx"]["autoApprove"]
1037 .as_array()
1038 .unwrap()
1039 .len()
1040 > 5
1041 );
1042 }
1043
1044 #[test]
1045 fn crush_config_writes_mcp_root() {
1046 let dir = tempfile::tempdir().unwrap();
1047 let path = dir.path().join("crush.json");
1048 std::fs::write(
1049 &path,
1050 r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1051 )
1052 .unwrap();
1053
1054 let t = target(path.clone(), ConfigType::Crush);
1055 let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1056 assert_eq!(res.action, WriteAction::Updated);
1057
1058 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1059 assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1060 assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1061 }
1062
1063 #[test]
1064 fn codex_toml_upserts_existing_section() {
1065 let dir = tempfile::tempdir().unwrap();
1066 let path = dir.path().join("config.toml");
1067 std::fs::write(
1068 &path,
1069 r#"[mcp_servers.lean-ctx]
1070command = "old"
1071args = ["x"]
1072"#,
1073 )
1074 .unwrap();
1075
1076 let t = target(path.clone(), ConfigType::Codex);
1077 let res = write_codex_config(&t, "new").unwrap();
1078 assert_eq!(res.action, WriteAction::Updated);
1079
1080 let content = std::fs::read_to_string(&path).unwrap();
1081 assert!(content.contains(r#"command = "new""#));
1082 assert!(content.contains("args = []"));
1083 }
1084
1085 #[test]
1086 fn upsert_codex_toml_inserts_new_section_when_missing() {
1087 let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1088 assert!(updated.contains("[mcp_servers.lean-ctx]"));
1089 assert!(updated.contains("command = \"lean-ctx\""));
1090 assert!(updated.contains("args = []"));
1091 }
1092
1093 #[test]
1094 fn codex_toml_uses_single_quotes_for_backslash_paths() {
1095 let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1096 let updated = upsert_codex_toml("", win_path);
1097 assert!(
1098 updated.contains(&format!("command = '{win_path}'")),
1099 "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1100 );
1101 }
1102
1103 #[test]
1104 fn codex_toml_uses_double_quotes_for_unix_paths() {
1105 let unix_path = "/usr/local/bin/lean-ctx";
1106 let updated = upsert_codex_toml("", unix_path);
1107 assert!(
1108 updated.contains(&format!("command = \"{unix_path}\"")),
1109 "Unix paths should use double quotes: {updated}"
1110 );
1111 }
1112
1113 #[test]
1114 fn auto_approve_contains_core_tools() {
1115 let tools = auto_approve_tools();
1116 assert!(tools.contains(&"ctx_read"));
1117 assert!(tools.contains(&"ctx_shell"));
1118 assert!(tools.contains(&"ctx_search"));
1119 assert!(tools.contains(&"ctx_workflow"));
1120 assert!(tools.contains(&"ctx_cost"));
1121 }
1122
1123 #[test]
1124 fn antigravity_config_omits_auto_approve() {
1125 let dir = tempfile::tempdir().unwrap();
1126 let path = dir.path().join("mcp_config.json");
1127
1128 let t = EditorTarget {
1129 name: "Antigravity",
1130 agent_key: "gemini".to_string(),
1131 config_path: path.clone(),
1132 detect_path: PathBuf::from("/nonexistent"),
1133 config_type: ConfigType::McpJson,
1134 };
1135 let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1136 assert_eq!(res.action, WriteAction::Created);
1137
1138 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1139 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1140 assert_eq!(
1141 json["mcpServers"]["lean-ctx"]["command"],
1142 "/usr/local/bin/lean-ctx"
1143 );
1144 }
1145
1146 #[test]
1147 fn hermes_yaml_inserts_into_existing_mcp_servers() {
1148 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";
1149 let block = " lean-ctx:\n command: \"lean-ctx\"\n env:\n LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1150 let result = upsert_hermes_yaml_mcp(existing, block);
1151 assert!(result.contains("lean-ctx"));
1152 assert!(result.contains("model: anthropic/claude-sonnet-4"));
1153 assert!(result.contains("tool_allowlist:"));
1154 assert!(result.contains("github:"));
1155 }
1156
1157 #[test]
1158 fn hermes_yaml_creates_mcp_servers_section() {
1159 let existing = "model: openai/gpt-4o\n";
1160 let block = " lean-ctx:\n command: \"lean-ctx\"";
1161 let result = upsert_hermes_yaml_mcp(existing, block);
1162 assert!(result.contains("mcp_servers:"));
1163 assert!(result.contains("lean-ctx"));
1164 assert!(result.contains("model: openai/gpt-4o"));
1165 }
1166
1167 #[test]
1168 fn hermes_yaml_skips_if_already_present() {
1169 let dir = tempfile::tempdir().unwrap();
1170 let path = dir.path().join("config.yaml");
1171 std::fs::write(
1172 &path,
1173 "mcp_servers:\n lean-ctx:\n command: \"lean-ctx\"\n",
1174 )
1175 .unwrap();
1176 let t = target(path.clone(), ConfigType::HermesYaml);
1177 let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1178 assert_eq!(res.action, WriteAction::Already);
1179 }
1180}