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