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