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 ConfigType::QoderSettings => write_qoder_settings(target, binary, opts),
56 }
57}
58
59pub fn remove_lean_ctx_mcp_server(
60 path: &std::path::Path,
61 opts: WriteOptions,
62) -> Result<WriteResult, String> {
63 if !path.exists() {
64 return Ok(WriteResult {
65 action: WriteAction::Already,
66 note: Some("mcp.json not found".to_string()),
67 });
68 }
69
70 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
71 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
72 Ok(v) => v,
73 Err(e) => {
74 if !opts.overwrite_invalid {
75 return Err(e.to_string());
76 }
77 backup_invalid_file(path)?;
78 return Ok(WriteResult {
79 action: WriteAction::Updated,
80 note: Some("backed up invalid JSON; did not modify".to_string()),
81 });
82 }
83 };
84
85 let obj = json
86 .as_object_mut()
87 .ok_or_else(|| "root JSON must be an object".to_string())?;
88
89 let Some(servers) = obj.get_mut("mcpServers") else {
90 return Ok(WriteResult {
91 action: WriteAction::Already,
92 note: Some("no mcpServers key".to_string()),
93 });
94 };
95 let servers_obj = servers
96 .as_object_mut()
97 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
98
99 if servers_obj.remove("lean-ctx").is_none() {
100 return Ok(WriteResult {
101 action: WriteAction::Already,
102 note: Some("lean-ctx not configured".to_string()),
103 });
104 }
105
106 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
107 crate::config_io::write_atomic_with_backup(path, &formatted)?;
108 Ok(WriteResult {
109 action: WriteAction::Updated,
110 note: Some("removed lean-ctx from mcpServers".to_string()),
111 })
112}
113
114pub fn remove_lean_ctx_server(
115 target: &EditorTarget,
116 opts: WriteOptions,
117) -> Result<WriteResult, String> {
118 match target.config_type {
119 ConfigType::McpJson
120 | ConfigType::JetBrains
121 | ConfigType::GeminiSettings
122 | ConfigType::QoderSettings => remove_lean_ctx_mcp_server(&target.config_path, opts),
123 ConfigType::VsCodeMcp => remove_lean_ctx_vscode_server(&target.config_path, opts),
124 ConfigType::Codex => remove_lean_ctx_codex_server(&target.config_path),
125 ConfigType::OpenCode | ConfigType::Crush => {
126 remove_lean_ctx_named_json_server(&target.config_path, "mcp", opts)
127 }
128 ConfigType::Zed => {
129 remove_lean_ctx_named_json_server(&target.config_path, "context_servers", opts)
130 }
131 ConfigType::Amp => remove_lean_ctx_amp_server(&target.config_path, opts),
132 ConfigType::HermesYaml => remove_lean_ctx_hermes_yaml_server(&target.config_path),
133 }
134}
135
136fn remove_lean_ctx_vscode_server(
137 path: &std::path::Path,
138 opts: WriteOptions,
139) -> Result<WriteResult, String> {
140 if !path.exists() {
141 return Ok(WriteResult {
142 action: WriteAction::Already,
143 note: Some("vscode mcp.json not found".to_string()),
144 });
145 }
146
147 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
148 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
149 Ok(v) => v,
150 Err(e) => {
151 if !opts.overwrite_invalid {
152 return Err(e.to_string());
153 }
154 backup_invalid_file(path)?;
155 return Ok(WriteResult {
156 action: WriteAction::Updated,
157 note: Some("backed up invalid JSON; did not modify".to_string()),
158 });
159 }
160 };
161
162 let obj = json
163 .as_object_mut()
164 .ok_or_else(|| "root JSON must be an object".to_string())?;
165
166 let Some(servers) = obj.get_mut("servers") else {
167 return Ok(WriteResult {
168 action: WriteAction::Already,
169 note: Some("no servers key".to_string()),
170 });
171 };
172 let servers_obj = servers
173 .as_object_mut()
174 .ok_or_else(|| "\"servers\" must be an object".to_string())?;
175
176 if servers_obj.remove("lean-ctx").is_none() {
177 return Ok(WriteResult {
178 action: WriteAction::Already,
179 note: Some("lean-ctx not configured".to_string()),
180 });
181 }
182
183 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
184 crate::config_io::write_atomic_with_backup(path, &formatted)?;
185 Ok(WriteResult {
186 action: WriteAction::Updated,
187 note: Some("removed lean-ctx from servers".to_string()),
188 })
189}
190
191fn remove_lean_ctx_amp_server(
192 path: &std::path::Path,
193 opts: WriteOptions,
194) -> Result<WriteResult, String> {
195 if !path.exists() {
196 return Ok(WriteResult {
197 action: WriteAction::Already,
198 note: Some("amp settings not found".to_string()),
199 });
200 }
201
202 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
203 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
204 Ok(v) => v,
205 Err(e) => {
206 if !opts.overwrite_invalid {
207 return Err(e.to_string());
208 }
209 backup_invalid_file(path)?;
210 return Ok(WriteResult {
211 action: WriteAction::Updated,
212 note: Some("backed up invalid JSON; did not modify".to_string()),
213 });
214 }
215 };
216
217 let obj = json
218 .as_object_mut()
219 .ok_or_else(|| "root JSON must be an object".to_string())?;
220 let Some(servers) = obj.get_mut("amp.mcpServers") else {
221 return Ok(WriteResult {
222 action: WriteAction::Already,
223 note: Some("no amp.mcpServers key".to_string()),
224 });
225 };
226 let servers_obj = servers
227 .as_object_mut()
228 .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
229
230 if servers_obj.remove("lean-ctx").is_none() {
231 return Ok(WriteResult {
232 action: WriteAction::Already,
233 note: Some("lean-ctx not configured".to_string()),
234 });
235 }
236
237 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
238 crate::config_io::write_atomic_with_backup(path, &formatted)?;
239 Ok(WriteResult {
240 action: WriteAction::Updated,
241 note: Some("removed lean-ctx from amp.mcpServers".to_string()),
242 })
243}
244
245fn remove_lean_ctx_named_json_server(
246 path: &std::path::Path,
247 container_key: &str,
248 opts: WriteOptions,
249) -> Result<WriteResult, String> {
250 if !path.exists() {
251 return Ok(WriteResult {
252 action: WriteAction::Already,
253 note: Some("config not found".to_string()),
254 });
255 }
256
257 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
258 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
259 Ok(v) => v,
260 Err(e) => {
261 if !opts.overwrite_invalid {
262 return Err(e.to_string());
263 }
264 backup_invalid_file(path)?;
265 return Ok(WriteResult {
266 action: WriteAction::Updated,
267 note: Some("backed up invalid JSON; did not modify".to_string()),
268 });
269 }
270 };
271
272 let obj = json
273 .as_object_mut()
274 .ok_or_else(|| "root JSON must be an object".to_string())?;
275 let Some(container) = obj.get_mut(container_key) else {
276 return Ok(WriteResult {
277 action: WriteAction::Already,
278 note: Some(format!("no {container_key} key")),
279 });
280 };
281 let container_obj = container
282 .as_object_mut()
283 .ok_or_else(|| format!("\"{container_key}\" must be an object"))?;
284
285 if container_obj.remove("lean-ctx").is_none() {
286 return Ok(WriteResult {
287 action: WriteAction::Already,
288 note: Some("lean-ctx not configured".to_string()),
289 });
290 }
291
292 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
293 crate::config_io::write_atomic_with_backup(path, &formatted)?;
294 Ok(WriteResult {
295 action: WriteAction::Updated,
296 note: Some(format!("removed lean-ctx from {container_key}")),
297 })
298}
299
300fn remove_lean_ctx_codex_server(path: &std::path::Path) -> Result<WriteResult, String> {
301 if !path.exists() {
302 return Ok(WriteResult {
303 action: WriteAction::Already,
304 note: Some("codex config not found".to_string()),
305 });
306 }
307 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
308 let updated = remove_codex_toml_section(&content, "[mcp_servers.lean-ctx]");
309 if updated == content {
310 return Ok(WriteResult {
311 action: WriteAction::Already,
312 note: Some("lean-ctx not configured".to_string()),
313 });
314 }
315 crate::config_io::write_atomic_with_backup(path, &updated)?;
316 Ok(WriteResult {
317 action: WriteAction::Updated,
318 note: Some("removed [mcp_servers.lean-ctx]".to_string()),
319 })
320}
321
322fn remove_codex_toml_section(existing: &str, header: &str) -> String {
323 let prefix = header.trim_end_matches(']');
324 let mut out = String::with_capacity(existing.len());
325 let mut skipping = false;
326 for line in existing.lines() {
327 let trimmed = line.trim();
328 if trimmed.starts_with('[') && trimmed.ends_with(']') {
329 if trimmed == header || trimmed.starts_with(&format!("{prefix}.")) {
330 skipping = true;
331 continue;
332 }
333 skipping = false;
334 }
335 if skipping {
336 continue;
337 }
338 out.push_str(line);
339 out.push('\n');
340 }
341 out
342}
343
344fn remove_lean_ctx_hermes_yaml_server(path: &std::path::Path) -> Result<WriteResult, String> {
345 if !path.exists() {
346 return Ok(WriteResult {
347 action: WriteAction::Already,
348 note: Some("hermes config not found".to_string()),
349 });
350 }
351 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
352 let updated = remove_hermes_yaml_mcp_server_block(&content, "lean-ctx");
353 if updated == content {
354 return Ok(WriteResult {
355 action: WriteAction::Already,
356 note: Some("lean-ctx not configured".to_string()),
357 });
358 }
359 crate::config_io::write_atomic_with_backup(path, &updated)?;
360 Ok(WriteResult {
361 action: WriteAction::Updated,
362 note: Some("removed lean-ctx from mcp_servers".to_string()),
363 })
364}
365
366fn remove_hermes_yaml_mcp_server_block(existing: &str, name: &str) -> String {
367 let mut out = String::with_capacity(existing.len());
368 let mut in_mcp = false;
369 let mut skipping = false;
370 for line in existing.lines() {
371 let trimmed = line.trim_end();
372 if trimmed == "mcp_servers:" {
373 in_mcp = true;
374 out.push_str(line);
375 out.push('\n');
376 continue;
377 }
378
379 if in_mcp {
380 let is_child = line.starts_with(" ") && !line.starts_with(" ");
381 let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
382
383 if is_toplevel {
384 in_mcp = false;
385 skipping = false;
386 }
387
388 if skipping {
389 if is_child || is_toplevel {
390 skipping = false;
391 out.push_str(line);
392 out.push('\n');
393 }
394 continue;
395 }
396
397 if is_child && line.trim() == format!("{name}:") {
398 skipping = true;
399 continue;
400 }
401 }
402
403 out.push_str(line);
404 out.push('\n');
405 }
406 out
407}
408
409pub fn auto_approve_tools() -> Vec<&'static str> {
410 vec![
411 "ctx_read",
412 "ctx_shell",
413 "ctx_search",
414 "ctx_tree",
415 "ctx_overview",
416 "ctx_preload",
417 "ctx_compress",
418 "ctx_metrics",
419 "ctx_session",
420 "ctx_knowledge",
421 "ctx_agent",
422 "ctx_share",
423 "ctx_analyze",
424 "ctx_benchmark",
425 "ctx_cache",
426 "ctx_discover",
427 "ctx_smart_read",
428 "ctx_delta",
429 "ctx_edit",
430 "ctx_dedup",
431 "ctx_fill",
432 "ctx_intent",
433 "ctx_response",
434 "ctx_context",
435 "ctx_graph",
436 "ctx_multi_read",
437 "ctx_semantic_search",
438 "ctx_symbol",
439 "ctx_outline",
440 "ctx_callgraph",
441 "ctx_refactor",
442 "ctx_routes",
443 "ctx_cost",
444 "ctx_heatmap",
445 "ctx_gain",
446 "ctx_expand",
447 "ctx_task",
448 "ctx_impact",
449 "ctx_architecture",
450 "ctx_workflow",
451 "ctx_review",
452 "ctx_pack",
453 "ctx_index",
454 "ctx_artifacts",
455 "ctx_smells",
456 "ctx_proof",
457 "ctx_verify",
458 "ctx_execute",
459 "ctx_handoff",
460 "ctx_feedback",
461 "ctx_control",
462 "ctx_plan",
463 "ctx_compile",
464 "ctx_discover_tools",
465 "ctx_provider",
466 "ctx_radar",
467 "ctx_retrieve",
468 "ctx_compress_memory",
469 "ctx_load_tools",
470 "ctx",
471 ]
472}
473
474fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
475 let mut entry = serde_json::json!({
476 "command": binary,
477 "env": {
478 "LEAN_CTX_DATA_DIR": data_dir
479 }
480 });
481 if include_auto_approve {
482 entry["autoApprove"] = serde_json::json!(auto_approve_tools());
483 }
484 entry
485}
486
487fn lean_ctx_server_entry_with_instructions(
488 binary: &str,
489 data_dir: &str,
490 include_auto_approve: bool,
491 agent_key: &str,
492) -> Value {
493 let mut entry = lean_ctx_server_entry(binary, data_dir, include_auto_approve);
494 let mode = crate::core::rules_canonical::Mode::from_hook_mode(
495 &crate::hooks::recommend_hook_mode(agent_key),
496 );
497 let instructions = crate::core::rules_canonical::mcp_instructions(mode);
498
499 let constraints = crate::core::client_constraints::by_client_id(agent_key);
500 if let Some(max_chars) = constraints.and_then(|c| c.mcp_instructions_max_chars) {
501 let truncated = if instructions.len() > max_chars {
502 &instructions[..max_chars]
503 } else {
504 instructions
505 };
506 entry["instructions"] = serde_json::json!(truncated);
507 }
508 entry
509}
510
511fn supports_auto_approve(target: &EditorTarget) -> bool {
512 crate::core::client_constraints::by_editor_name(target.name)
513 .is_some_and(|c| c.supports_auto_approve)
514}
515
516fn default_data_dir() -> Result<String, String> {
517 Ok(crate::core::data_dir::lean_ctx_data_dir()?
518 .to_string_lossy()
519 .to_string())
520}
521
522fn write_mcp_json(
523 target: &EditorTarget,
524 binary: &str,
525 opts: WriteOptions,
526) -> Result<WriteResult, String> {
527 let data_dir = default_data_dir()?;
528 let include_aa = supports_auto_approve(target);
529 let desired = if target.agent_key.is_empty() {
530 lean_ctx_server_entry(binary, &data_dir, include_aa)
531 } else {
532 lean_ctx_server_entry_with_instructions(binary, &data_dir, include_aa, &target.agent_key)
533 };
534
535 if (target.agent_key == "claude" || target.name == "Claude Code")
540 && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
541 {
542 if let Ok(result) = try_claude_mcp_add(&desired) {
543 return Ok(result);
544 }
545 }
546
547 if target.config_path.exists() {
548 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
549 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
550 Ok(v) => v,
551 Err(e) => {
552 if !opts.overwrite_invalid {
553 return Err(e.to_string());
554 }
555 backup_invalid_file(&target.config_path)?;
556 return write_mcp_json_fresh(
557 &target.config_path,
558 &desired,
559 Some("overwrote invalid JSON".to_string()),
560 );
561 }
562 };
563 let obj = json
564 .as_object_mut()
565 .ok_or_else(|| "root JSON must be an object".to_string())?;
566
567 let servers = obj
568 .entry("mcpServers")
569 .or_insert_with(|| serde_json::json!({}));
570 let servers_obj = servers
571 .as_object_mut()
572 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
573
574 let existing = servers_obj.get("lean-ctx").cloned();
575 if existing.as_ref() == Some(&desired) {
576 return Ok(WriteResult {
577 action: WriteAction::Already,
578 note: None,
579 });
580 }
581 servers_obj.insert("lean-ctx".to_string(), desired);
582
583 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
584 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
585 return Ok(WriteResult {
586 action: WriteAction::Updated,
587 note: None,
588 });
589 }
590
591 write_mcp_json_fresh(&target.config_path, &desired, None)
592}
593
594fn find_in_path(binary: &str) -> Option<std::path::PathBuf> {
595 let path_var = std::env::var("PATH").ok()?;
596 for dir in std::env::split_paths(&path_var) {
597 let candidate = dir.join(binary);
598 if candidate.is_file() {
599 return Some(candidate);
600 }
601 }
602 None
603}
604
605fn validate_claude_binary() -> Result<std::path::PathBuf, String> {
606 let path = find_in_path("claude").ok_or("claude binary not found in PATH")?;
607
608 let canonical =
609 std::fs::canonicalize(&path).map_err(|e| format!("cannot resolve claude path: {e}"))?;
610
611 let canonical_str = canonical.to_string_lossy();
612 let is_trusted = canonical_str.contains("/.claude/")
613 || canonical_str.contains("\\AppData\\")
614 || canonical_str.contains("/usr/local/bin/")
615 || canonical_str.contains("/opt/homebrew/")
616 || canonical_str.contains("/nix/store/")
617 || canonical_str.contains("/.npm/")
618 || canonical_str.contains("/.nvm/")
619 || canonical_str.contains("/node_modules/.bin/")
620 || std::env::var("LEAN_CTX_TRUST_CLAUDE_PATH").is_ok();
621
622 if !is_trusted {
623 return Err(format!(
624 "claude binary resolved to untrusted path: {canonical_str} — set LEAN_CTX_TRUST_CLAUDE_PATH=1 to override"
625 ));
626 }
627 Ok(canonical)
628}
629
630fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
631 use std::io::Write;
632 use std::process::{Command, Stdio};
633 use std::time::{Duration, Instant};
634
635 let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
636
637 let mut cmd = if cfg!(windows) {
638 let mut c = Command::new("cmd");
639 c.args([
640 "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
641 ]);
642 c
643 } else {
644 let claude_path = validate_claude_binary()?;
645 let mut c = Command::new(claude_path);
646 c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
647 c
648 };
649
650 let mut child = cmd
651 .stdin(Stdio::piped())
652 .stdout(Stdio::null())
653 .stderr(Stdio::null())
654 .spawn()
655 .map_err(|e| e.to_string())?;
656
657 if let Some(mut stdin) = child.stdin.take() {
658 let _ = stdin.write_all(server_json.as_bytes());
659 }
660
661 let deadline = Duration::from_secs(3);
662 let start = Instant::now();
663 loop {
664 match child.try_wait() {
665 Ok(Some(status)) => {
666 return if status.success() {
667 Ok(WriteResult {
668 action: WriteAction::Updated,
669 note: Some("via claude mcp add-json".to_string()),
670 })
671 } else {
672 Err("claude mcp add-json failed".to_string())
673 };
674 }
675 Ok(None) => {
676 if start.elapsed() > deadline {
677 let _ = child.kill();
678 let _ = child.wait();
679 return Err("claude mcp add-json timed out".to_string());
680 }
681 std::thread::sleep(Duration::from_millis(20));
682 }
683 Err(e) => return Err(e.to_string()),
684 }
685 }
686}
687
688fn write_mcp_json_fresh(
689 path: &std::path::Path,
690 desired: &Value,
691 note: Option<String>,
692) -> Result<WriteResult, String> {
693 let content = serde_json::to_string_pretty(&serde_json::json!({
694 "mcpServers": { "lean-ctx": desired }
695 }))
696 .map_err(|e| e.to_string())?;
697 crate::config_io::write_atomic_with_backup(path, &content)?;
698 Ok(WriteResult {
699 action: if note.is_some() {
700 WriteAction::Updated
701 } else {
702 WriteAction::Created
703 },
704 note,
705 })
706}
707
708fn write_zed_config(
709 target: &EditorTarget,
710 binary: &str,
711 opts: WriteOptions,
712) -> Result<WriteResult, String> {
713 let desired = serde_json::json!({
714 "command": binary,
715 "args": [],
716 "env": {}
717 });
718
719 if target.config_path.exists() {
720 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
721 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
722 Ok(v) => v,
723 Err(e) => {
724 if !opts.overwrite_invalid {
725 return Err(e.to_string());
726 }
727 backup_invalid_file(&target.config_path)?;
728 return write_zed_config_fresh(
729 &target.config_path,
730 &desired,
731 Some("overwrote invalid JSON".to_string()),
732 );
733 }
734 };
735 let obj = json
736 .as_object_mut()
737 .ok_or_else(|| "root JSON must be an object".to_string())?;
738
739 let servers = obj
740 .entry("context_servers")
741 .or_insert_with(|| serde_json::json!({}));
742 let servers_obj = servers
743 .as_object_mut()
744 .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
745
746 let existing = servers_obj.get("lean-ctx").cloned();
747 if existing.as_ref() == Some(&desired) {
748 return Ok(WriteResult {
749 action: WriteAction::Already,
750 note: None,
751 });
752 }
753 servers_obj.insert("lean-ctx".to_string(), desired);
754
755 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
756 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
757 return Ok(WriteResult {
758 action: WriteAction::Updated,
759 note: None,
760 });
761 }
762
763 write_zed_config_fresh(&target.config_path, &desired, None)
764}
765
766fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
767 if target.config_path.exists() {
768 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
769 let updated = upsert_codex_toml(&content, binary);
770 if updated == content {
771 return Ok(WriteResult {
772 action: WriteAction::Already,
773 note: None,
774 });
775 }
776 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
777 return Ok(WriteResult {
778 action: WriteAction::Updated,
779 note: None,
780 });
781 }
782
783 let content = format!(
784 "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
785 toml_quote(binary)
786 );
787 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
788 Ok(WriteResult {
789 action: WriteAction::Created,
790 note: None,
791 })
792}
793
794fn write_zed_config_fresh(
795 path: &std::path::Path,
796 desired: &Value,
797 note: Option<String>,
798) -> Result<WriteResult, String> {
799 let content = serde_json::to_string_pretty(&serde_json::json!({
800 "context_servers": { "lean-ctx": desired }
801 }))
802 .map_err(|e| e.to_string())?;
803 crate::config_io::write_atomic_with_backup(path, &content)?;
804 Ok(WriteResult {
805 action: if note.is_some() {
806 WriteAction::Updated
807 } else {
808 WriteAction::Created
809 },
810 note,
811 })
812}
813
814fn write_vscode_mcp(
815 target: &EditorTarget,
816 binary: &str,
817 opts: WriteOptions,
818) -> Result<WriteResult, String> {
819 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
820 .map(|d| d.to_string_lossy().to_string())
821 .unwrap_or_default();
822 let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
823
824 if target.config_path.exists() {
825 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
826 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
827 Ok(v) => v,
828 Err(e) => {
829 if !opts.overwrite_invalid {
830 return Err(e.to_string());
831 }
832 backup_invalid_file(&target.config_path)?;
833 return write_vscode_mcp_fresh(
834 &target.config_path,
835 binary,
836 Some("overwrote invalid JSON".to_string()),
837 );
838 }
839 };
840 let obj = json
841 .as_object_mut()
842 .ok_or_else(|| "root JSON must be an object".to_string())?;
843
844 let servers = obj
845 .entry("servers")
846 .or_insert_with(|| serde_json::json!({}));
847 let servers_obj = servers
848 .as_object_mut()
849 .ok_or_else(|| "\"servers\" must be an object".to_string())?;
850
851 let existing = servers_obj.get("lean-ctx").cloned();
852 if existing.as_ref() == Some(&desired) {
853 return Ok(WriteResult {
854 action: WriteAction::Already,
855 note: None,
856 });
857 }
858 servers_obj.insert("lean-ctx".to_string(), desired);
859
860 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
861 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
862 return Ok(WriteResult {
863 action: WriteAction::Updated,
864 note: None,
865 });
866 }
867
868 write_vscode_mcp_fresh(&target.config_path, binary, None)
869}
870
871fn write_vscode_mcp_fresh(
872 path: &std::path::Path,
873 binary: &str,
874 note: Option<String>,
875) -> Result<WriteResult, String> {
876 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
877 .map(|d| d.to_string_lossy().to_string())
878 .unwrap_or_default();
879 let content = serde_json::to_string_pretty(&serde_json::json!({
880 "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
881 }))
882 .map_err(|e| e.to_string())?;
883 crate::config_io::write_atomic_with_backup(path, &content)?;
884 Ok(WriteResult {
885 action: if note.is_some() {
886 WriteAction::Updated
887 } else {
888 WriteAction::Created
889 },
890 note,
891 })
892}
893
894fn write_opencode_config(
895 target: &EditorTarget,
896 binary: &str,
897 opts: WriteOptions,
898) -> Result<WriteResult, String> {
899 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
900 .map(|d| d.to_string_lossy().to_string())
901 .unwrap_or_default();
902 let desired = serde_json::json!({
903 "type": "local",
904 "command": [binary],
905 "enabled": true,
906 "environment": { "LEAN_CTX_DATA_DIR": data_dir }
907 });
908
909 if target.config_path.exists() {
910 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
911 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
912 Ok(v) => v,
913 Err(e) => {
914 if !opts.overwrite_invalid {
915 return Err(e.to_string());
916 }
917 backup_invalid_file(&target.config_path)?;
918 return write_opencode_fresh(
919 &target.config_path,
920 binary,
921 Some("overwrote invalid JSON".to_string()),
922 );
923 }
924 };
925 let obj = json
926 .as_object_mut()
927 .ok_or_else(|| "root JSON must be an object".to_string())?;
928 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
929 let mcp_obj = mcp
930 .as_object_mut()
931 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
932
933 let existing = mcp_obj.get("lean-ctx").cloned();
934 if existing.as_ref() == Some(&desired) {
935 return Ok(WriteResult {
936 action: WriteAction::Already,
937 note: None,
938 });
939 }
940 mcp_obj.insert("lean-ctx".to_string(), desired);
941
942 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
943 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
944 return Ok(WriteResult {
945 action: WriteAction::Updated,
946 note: None,
947 });
948 }
949
950 write_opencode_fresh(&target.config_path, binary, None)
951}
952
953fn write_opencode_fresh(
954 path: &std::path::Path,
955 binary: &str,
956 note: Option<String>,
957) -> Result<WriteResult, String> {
958 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
959 .map(|d| d.to_string_lossy().to_string())
960 .unwrap_or_default();
961 let content = serde_json::to_string_pretty(&serde_json::json!({
962 "$schema": "https://opencode.ai/config.json",
963 "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
964 }))
965 .map_err(|e| e.to_string())?;
966 crate::config_io::write_atomic_with_backup(path, &content)?;
967 Ok(WriteResult {
968 action: if note.is_some() {
969 WriteAction::Updated
970 } else {
971 WriteAction::Created
972 },
973 note,
974 })
975}
976
977fn write_jetbrains_config(
978 target: &EditorTarget,
979 binary: &str,
980 opts: WriteOptions,
981) -> Result<WriteResult, String> {
982 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
983 .map(|d| d.to_string_lossy().to_string())
984 .unwrap_or_default();
985 let desired = serde_json::json!({
989 "command": binary,
990 "args": [],
991 "env": { "LEAN_CTX_DATA_DIR": data_dir }
992 });
993
994 if target.config_path.exists() {
995 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
996 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
997 Ok(v) => v,
998 Err(e) => {
999 if !opts.overwrite_invalid {
1000 return Err(e.to_string());
1001 }
1002 backup_invalid_file(&target.config_path)?;
1003 let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
1004 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1005 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1006 return Ok(WriteResult {
1007 action: WriteAction::Updated,
1008 note: Some(
1009 "overwrote invalid JSON (paste this snippet into JetBrains MCP settings)"
1010 .to_string(),
1011 ),
1012 });
1013 }
1014 };
1015 let obj = json
1016 .as_object_mut()
1017 .ok_or_else(|| "root JSON must be an object".to_string())?;
1018
1019 let servers = obj
1020 .entry("mcpServers")
1021 .or_insert_with(|| serde_json::json!({}));
1022 let servers_obj = servers
1023 .as_object_mut()
1024 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1025
1026 let existing = servers_obj.get("lean-ctx").cloned();
1027 if existing.as_ref() == Some(&desired) {
1028 return Ok(WriteResult {
1029 action: WriteAction::Already,
1030 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1031 });
1032 }
1033 servers_obj.insert("lean-ctx".to_string(), desired);
1034
1035 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1036 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1037 return Ok(WriteResult {
1038 action: WriteAction::Updated,
1039 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1040 });
1041 }
1042
1043 let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
1044 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1045 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1046 Ok(WriteResult {
1047 action: WriteAction::Created,
1048 note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1049 })
1050}
1051
1052fn write_amp_config(
1053 target: &EditorTarget,
1054 binary: &str,
1055 opts: WriteOptions,
1056) -> Result<WriteResult, String> {
1057 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1058 .map(|d| d.to_string_lossy().to_string())
1059 .unwrap_or_default();
1060 let entry = serde_json::json!({
1061 "command": binary,
1062 "env": { "LEAN_CTX_DATA_DIR": data_dir }
1063 });
1064
1065 if target.config_path.exists() {
1066 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1067 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1068 Ok(v) => v,
1069 Err(e) => {
1070 if !opts.overwrite_invalid {
1071 return Err(e.to_string());
1072 }
1073 backup_invalid_file(&target.config_path)?;
1074 let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1075 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1076 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1077 return Ok(WriteResult {
1078 action: WriteAction::Updated,
1079 note: Some("overwrote invalid JSON".to_string()),
1080 });
1081 }
1082 };
1083 let obj = json
1084 .as_object_mut()
1085 .ok_or_else(|| "root JSON must be an object".to_string())?;
1086 let servers = obj
1087 .entry("amp.mcpServers")
1088 .or_insert_with(|| serde_json::json!({}));
1089 let servers_obj = servers
1090 .as_object_mut()
1091 .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
1092
1093 let existing = servers_obj.get("lean-ctx").cloned();
1094 if existing.as_ref() == Some(&entry) {
1095 return Ok(WriteResult {
1096 action: WriteAction::Already,
1097 note: None,
1098 });
1099 }
1100 servers_obj.insert("lean-ctx".to_string(), entry);
1101
1102 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1103 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1104 return Ok(WriteResult {
1105 action: WriteAction::Updated,
1106 note: None,
1107 });
1108 }
1109
1110 let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1111 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1112 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1113 Ok(WriteResult {
1114 action: WriteAction::Created,
1115 note: None,
1116 })
1117}
1118
1119fn write_crush_config(
1120 target: &EditorTarget,
1121 binary: &str,
1122 opts: WriteOptions,
1123) -> Result<WriteResult, String> {
1124 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1125 .map(|d| d.to_string_lossy().to_string())
1126 .unwrap_or_default();
1127 let desired = serde_json::json!({
1128 "type": "stdio",
1129 "command": binary,
1130 "env": { "LEAN_CTX_DATA_DIR": data_dir }
1131 });
1132
1133 if target.config_path.exists() {
1134 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1135 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1136 Ok(v) => v,
1137 Err(e) => {
1138 if !opts.overwrite_invalid {
1139 return Err(e.to_string());
1140 }
1141 backup_invalid_file(&target.config_path)?;
1142 return write_crush_fresh(
1143 &target.config_path,
1144 &desired,
1145 Some("overwrote invalid JSON".to_string()),
1146 );
1147 }
1148 };
1149 let obj = json
1150 .as_object_mut()
1151 .ok_or_else(|| "root JSON must be an object".to_string())?;
1152 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1153 let mcp_obj = mcp
1154 .as_object_mut()
1155 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1156
1157 let existing = mcp_obj.get("lean-ctx").cloned();
1158 if existing.as_ref() == Some(&desired) {
1159 return Ok(WriteResult {
1160 action: WriteAction::Already,
1161 note: None,
1162 });
1163 }
1164 mcp_obj.insert("lean-ctx".to_string(), desired);
1165
1166 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1167 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1168 return Ok(WriteResult {
1169 action: WriteAction::Updated,
1170 note: None,
1171 });
1172 }
1173
1174 write_crush_fresh(&target.config_path, &desired, None)
1175}
1176
1177fn write_crush_fresh(
1178 path: &std::path::Path,
1179 desired: &Value,
1180 note: Option<String>,
1181) -> Result<WriteResult, String> {
1182 let content = serde_json::to_string_pretty(&serde_json::json!({
1183 "mcp": { "lean-ctx": desired }
1184 }))
1185 .map_err(|e| e.to_string())?;
1186 crate::config_io::write_atomic_with_backup(path, &content)?;
1187 Ok(WriteResult {
1188 action: if note.is_some() {
1189 WriteAction::Updated
1190 } else {
1191 WriteAction::Created
1192 },
1193 note,
1194 })
1195}
1196
1197fn upsert_codex_toml(existing: &str, binary: &str) -> String {
1198 let mut out = String::with_capacity(existing.len() + 128);
1199 let mut in_section = false;
1200 let mut saw_section = false;
1201 let mut wrote_command = false;
1202 let mut wrote_args = false;
1203 let mut inserted_parent_before_subtable = false;
1204
1205 let parent_block = format!(
1206 "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n\n",
1207 toml_quote(binary)
1208 );
1209
1210 for line in existing.lines() {
1211 let trimmed = line.trim();
1212 if trimmed == "[]" {
1213 continue;
1214 }
1215 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1216 if in_section && !wrote_command {
1217 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1218 wrote_command = true;
1219 }
1220 if in_section && !wrote_args {
1221 out.push_str("args = []\n");
1222 wrote_args = true;
1223 }
1224 in_section = trimmed == "[mcp_servers.lean-ctx]";
1225 if in_section {
1226 saw_section = true;
1227 } else if !saw_section
1228 && !inserted_parent_before_subtable
1229 && trimmed.starts_with("[mcp_servers.lean-ctx.")
1230 {
1231 out.push_str(&parent_block);
1232 inserted_parent_before_subtable = true;
1233 }
1234 out.push_str(line);
1235 out.push('\n');
1236 continue;
1237 }
1238
1239 if in_section {
1240 if trimmed.starts_with("command") && trimmed.contains('=') {
1241 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1242 wrote_command = true;
1243 continue;
1244 }
1245 if trimmed.starts_with("args") && trimmed.contains('=') {
1246 out.push_str("args = []\n");
1247 wrote_args = true;
1248 continue;
1249 }
1250 }
1251
1252 out.push_str(line);
1253 out.push('\n');
1254 }
1255
1256 if saw_section {
1257 if in_section && !wrote_command {
1258 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1259 }
1260 if in_section && !wrote_args {
1261 out.push_str("args = []\n");
1262 }
1263 return out;
1264 }
1265
1266 if inserted_parent_before_subtable {
1267 return out;
1268 }
1269
1270 if !out.ends_with('\n') {
1271 out.push('\n');
1272 }
1273 out.push_str("\n[mcp_servers.lean-ctx]\n");
1274 out.push_str(&format!("command = {}\n", toml_quote(binary)));
1275 out.push_str("args = []\n");
1276 out
1277}
1278
1279fn write_gemini_settings(
1280 target: &EditorTarget,
1281 binary: &str,
1282 opts: WriteOptions,
1283) -> Result<WriteResult, String> {
1284 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1285 .map(|d| d.to_string_lossy().to_string())
1286 .unwrap_or_default();
1287 let entry = serde_json::json!({
1288 "command": binary,
1289 "env": { "LEAN_CTX_DATA_DIR": data_dir },
1290 "trust": true,
1291 });
1292
1293 if target.config_path.exists() {
1294 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1295 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1296 Ok(v) => v,
1297 Err(e) => {
1298 if !opts.overwrite_invalid {
1299 return Err(e.to_string());
1300 }
1301 backup_invalid_file(&target.config_path)?;
1302 let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1303 let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1304 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1305 return Ok(WriteResult {
1306 action: WriteAction::Updated,
1307 note: Some("overwrote invalid JSON".to_string()),
1308 });
1309 }
1310 };
1311 let obj = json
1312 .as_object_mut()
1313 .ok_or_else(|| "root JSON must be an object".to_string())?;
1314 let servers = obj
1315 .entry("mcpServers")
1316 .or_insert_with(|| serde_json::json!({}));
1317 let servers_obj = servers
1318 .as_object_mut()
1319 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1320
1321 let existing = servers_obj.get("lean-ctx").cloned();
1322 if existing.as_ref() == Some(&entry) {
1323 return Ok(WriteResult {
1324 action: WriteAction::Already,
1325 note: None,
1326 });
1327 }
1328 servers_obj.insert("lean-ctx".to_string(), entry);
1329
1330 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1331 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1332 return Ok(WriteResult {
1333 action: WriteAction::Updated,
1334 note: None,
1335 });
1336 }
1337
1338 let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1339 let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1340 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1341 Ok(WriteResult {
1342 action: WriteAction::Created,
1343 note: None,
1344 })
1345}
1346
1347fn write_hermes_yaml(
1348 target: &EditorTarget,
1349 binary: &str,
1350 _opts: WriteOptions,
1351) -> Result<WriteResult, String> {
1352 let data_dir = default_data_dir()?;
1353
1354 let lean_ctx_block = format!(
1355 " lean-ctx:\n command: \"{binary}\"\n env:\n LEAN_CTX_DATA_DIR: \"{data_dir}\""
1356 );
1357
1358 if target.config_path.exists() {
1359 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1360
1361 if content.contains("lean-ctx") {
1362 return Ok(WriteResult {
1363 action: WriteAction::Already,
1364 note: None,
1365 });
1366 }
1367
1368 let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1369 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1370 return Ok(WriteResult {
1371 action: WriteAction::Updated,
1372 note: None,
1373 });
1374 }
1375
1376 let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1377 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1378 Ok(WriteResult {
1379 action: WriteAction::Created,
1380 note: None,
1381 })
1382}
1383
1384fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1385 let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1386 let mut in_mcp_section = false;
1387 let mut saw_mcp_child = false;
1388 let mut inserted = false;
1389 let lines: Vec<&str> = existing.lines().collect();
1390
1391 for line in &lines {
1392 if !inserted && line.trim_end() == "mcp_servers:" {
1393 in_mcp_section = true;
1394 out.push_str(line);
1395 out.push('\n');
1396 continue;
1397 }
1398
1399 if in_mcp_section && !inserted {
1400 let is_child = line.starts_with(" ") && !line.trim().is_empty();
1401 let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1402
1403 if is_child {
1404 saw_mcp_child = true;
1405 out.push_str(line);
1406 out.push('\n');
1407 continue;
1408 }
1409
1410 if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1411 out.push_str(lean_ctx_block);
1412 out.push('\n');
1413 inserted = true;
1414 in_mcp_section = false;
1415 }
1416 }
1417
1418 out.push_str(line);
1419 out.push('\n');
1420 }
1421
1422 if in_mcp_section && !inserted {
1423 out.push_str(lean_ctx_block);
1424 out.push('\n');
1425 inserted = true;
1426 }
1427
1428 if !inserted {
1429 if !out.ends_with('\n') {
1430 out.push('\n');
1431 }
1432 out.push_str("\nmcp_servers:\n");
1433 out.push_str(lean_ctx_block);
1434 out.push('\n');
1435 }
1436
1437 out
1438}
1439
1440fn write_qoder_settings(
1441 target: &EditorTarget,
1442 binary: &str,
1443 opts: WriteOptions,
1444) -> Result<WriteResult, String> {
1445 let data_dir = default_data_dir()?;
1446 let desired = serde_json::json!({
1447 "command": binary,
1448 "args": [],
1449 "env": {
1450 "LEAN_CTX_DATA_DIR": data_dir,
1451 "LEAN_CTX_FULL_TOOLS": "1"
1452 }
1453 });
1454
1455 if target.config_path.exists() {
1456 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1457 let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1458 Ok(v) => v,
1459 Err(e) => {
1460 if !opts.overwrite_invalid {
1461 return Err(e.to_string());
1462 }
1463 backup_invalid_file(&target.config_path)?;
1464 return write_mcp_json_fresh(
1465 &target.config_path,
1466 &desired,
1467 Some("overwrote invalid JSON".to_string()),
1468 );
1469 }
1470 };
1471 let obj = json
1472 .as_object_mut()
1473 .ok_or_else(|| "root JSON must be an object".to_string())?;
1474 let servers = obj
1475 .entry("mcpServers")
1476 .or_insert_with(|| serde_json::json!({}));
1477 let servers_obj = servers
1478 .as_object_mut()
1479 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1480
1481 let existing = servers_obj.get("lean-ctx").cloned();
1482 if existing.as_ref() == Some(&desired) {
1483 return Ok(WriteResult {
1484 action: WriteAction::Already,
1485 note: None,
1486 });
1487 }
1488 servers_obj.insert("lean-ctx".to_string(), desired);
1489
1490 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1491 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1492 return Ok(WriteResult {
1493 action: WriteAction::Updated,
1494 note: None,
1495 });
1496 }
1497
1498 write_mcp_json_fresh(&target.config_path, &desired, None)
1499}
1500
1501fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
1502 if !path.exists() {
1503 return Ok(());
1504 }
1505 let parent = path
1506 .parent()
1507 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1508 let filename = path
1509 .file_name()
1510 .ok_or_else(|| "invalid path (no filename)".to_string())?
1511 .to_string_lossy();
1512 let pid = std::process::id();
1513 let nanos = std::time::SystemTime::now()
1514 .duration_since(std::time::UNIX_EPOCH)
1515 .map_or(0, |d| d.as_nanos());
1516 let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1517 std::fs::rename(path, bak).map_err(|e| e.to_string())?;
1518 Ok(())
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523 use super::*;
1524 use std::path::PathBuf;
1525
1526 fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1527 EditorTarget {
1528 name,
1529 agent_key: "test".to_string(),
1530 config_path: path,
1531 detect_path: PathBuf::from("/nonexistent"),
1532 config_type: ty,
1533 }
1534 }
1535
1536 #[test]
1537 fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1538 let dir = tempfile::tempdir().unwrap();
1539 let path = dir.path().join("mcp.json");
1540 std::fs::write(
1541 &path,
1542 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1543 )
1544 .unwrap();
1545
1546 let t = target("test", path.clone(), ConfigType::McpJson);
1547 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1548 assert_eq!(res.action, WriteAction::Updated);
1549
1550 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1551 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1552 assert_eq!(
1553 json["mcpServers"]["lean-ctx"]["command"],
1554 "/new/path/lean-ctx"
1555 );
1556 assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1557 }
1558
1559 #[test]
1560 fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1561 let dir = tempfile::tempdir().unwrap();
1562 let path = dir.path().join("mcp.json");
1563 std::fs::write(
1564 &path,
1565 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1566 )
1567 .unwrap();
1568
1569 let t = target("Cursor", path.clone(), ConfigType::McpJson);
1570 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1571 assert_eq!(res.action, WriteAction::Updated);
1572
1573 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1574 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1575 assert_eq!(
1576 json["mcpServers"]["lean-ctx"]["command"],
1577 "/new/path/lean-ctx"
1578 );
1579 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1580 assert!(
1581 json["mcpServers"]["lean-ctx"]["autoApprove"]
1582 .as_array()
1583 .unwrap()
1584 .len()
1585 > 5
1586 );
1587 }
1588
1589 #[test]
1590 fn crush_config_writes_mcp_root() {
1591 let dir = tempfile::tempdir().unwrap();
1592 let path = dir.path().join("crush.json");
1593 std::fs::write(
1594 &path,
1595 r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1596 )
1597 .unwrap();
1598
1599 let t = target("test", path.clone(), ConfigType::Crush);
1600 let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1601 assert_eq!(res.action, WriteAction::Updated);
1602
1603 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1604 assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1605 assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1606 }
1607
1608 #[test]
1609 fn codex_toml_upserts_existing_section() {
1610 let dir = tempfile::tempdir().unwrap();
1611 let path = dir.path().join("config.toml");
1612 std::fs::write(
1613 &path,
1614 r#"[mcp_servers.lean-ctx]
1615command = "old"
1616args = ["x"]
1617"#,
1618 )
1619 .unwrap();
1620
1621 let t = target("test", path.clone(), ConfigType::Codex);
1622 let res = write_codex_config(&t, "new").unwrap();
1623 assert_eq!(res.action, WriteAction::Updated);
1624
1625 let content = std::fs::read_to_string(&path).unwrap();
1626 assert!(content.contains(r#"command = "new""#));
1627 assert!(content.contains("args = []"));
1628 }
1629
1630 #[test]
1631 fn upsert_codex_toml_inserts_new_section_when_missing() {
1632 let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1633 assert!(updated.contains("[mcp_servers.lean-ctx]"));
1634 assert!(updated.contains("command = \"lean-ctx\""));
1635 assert!(updated.contains("args = []"));
1636 }
1637
1638 #[test]
1639 fn codex_toml_uses_single_quotes_for_backslash_paths() {
1640 let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1641 let updated = upsert_codex_toml("", win_path);
1642 assert!(
1643 updated.contains(&format!("command = '{win_path}'")),
1644 "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1645 );
1646 }
1647
1648 #[test]
1649 fn codex_toml_uses_double_quotes_for_unix_paths() {
1650 let unix_path = "/usr/local/bin/lean-ctx";
1651 let updated = upsert_codex_toml("", unix_path);
1652 assert!(
1653 updated.contains(&format!("command = \"{unix_path}\"")),
1654 "Unix paths should use double quotes: {updated}"
1655 );
1656 }
1657
1658 #[test]
1659 fn upsert_codex_toml_inserts_parent_before_orphaned_tool_subtables() {
1660 let input = "\
1661[mcp_servers.lean-ctx.tools.ctx_multi_read]
1662approval_mode = \"approve\"
1663
1664[mcp_servers.lean-ctx.tools.ctx_read]
1665approval_mode = \"approve\"
1666";
1667 let updated = upsert_codex_toml(input, "lean-ctx");
1668 let parent_pos = updated
1669 .find("[mcp_servers.lean-ctx]\n")
1670 .expect("parent section must be inserted");
1671 let tools_pos = updated
1672 .find("[mcp_servers.lean-ctx.tools.")
1673 .expect("tool sub-tables must be preserved");
1674 assert!(
1675 parent_pos < tools_pos,
1676 "parent must come before tool sub-tables:\n{updated}"
1677 );
1678 assert!(updated.contains("command = \"lean-ctx\""));
1679 assert!(updated.contains("args = []"));
1680 assert!(updated.contains("approval_mode = \"approve\""));
1681 }
1682
1683 #[test]
1684 fn upsert_codex_toml_handles_issue_191_windows_scenario() {
1685 let input = "\
1686[mcp_servers.lean-ctx.tools.ctx_multi_read]
1687approval_mode = \"approve\"
1688
1689[mcp_servers.lean-ctx.tools.ctx_read]
1690approval_mode = \"approve\"
1691
1692[mcp_servers.lean-ctx.tools.ctx_search]
1693approval_mode = \"approve\"
1694
1695[mcp_servers.lean-ctx.tools.ctx_tree]
1696approval_mode = \"approve\"
1697";
1698 let win_path = r"C:\Users\wudon\AppData\Roaming\npm\lean-ctx.cmd";
1699 let updated = upsert_codex_toml(input, win_path);
1700 assert!(
1701 updated.contains(&format!("command = '{win_path}'")),
1702 "Windows path must use single quotes: {updated}"
1703 );
1704 let parent_pos = updated.find("[mcp_servers.lean-ctx]\n").unwrap();
1705 let first_tool = updated.find("[mcp_servers.lean-ctx.tools.").unwrap();
1706 assert!(parent_pos < first_tool);
1707 assert_eq!(
1708 updated.matches("[mcp_servers.lean-ctx]\n").count(),
1709 1,
1710 "parent section must appear exactly once"
1711 );
1712 }
1713
1714 #[test]
1715 fn upsert_codex_toml_does_not_duplicate_parent_when_present() {
1716 let input = "\
1717[mcp_servers.lean-ctx]
1718command = \"old\"
1719args = [\"x\"]
1720
1721[mcp_servers.lean-ctx.tools.ctx_read]
1722approval_mode = \"approve\"
1723";
1724 let updated = upsert_codex_toml(input, "new");
1725 assert_eq!(
1726 updated.matches("[mcp_servers.lean-ctx]").count(),
1727 1,
1728 "must not duplicate parent section"
1729 );
1730 assert!(updated.contains("command = \"new\""));
1731 assert!(updated.contains("args = []"));
1732 assert!(updated.contains("approval_mode = \"approve\""));
1733 }
1734
1735 #[test]
1736 fn auto_approve_contains_core_tools() {
1737 let tools = auto_approve_tools();
1738 assert!(tools.contains(&"ctx_read"));
1739 assert!(tools.contains(&"ctx_shell"));
1740 assert!(tools.contains(&"ctx_search"));
1741 assert!(tools.contains(&"ctx_workflow"));
1742 assert!(tools.contains(&"ctx_cost"));
1743 }
1744
1745 #[test]
1746 fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
1747 let dir = tempfile::tempdir().unwrap();
1748 let path = dir.path().join("mcp.json");
1749 std::fs::write(
1750 &path,
1751 r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
1752 )
1753 .unwrap();
1754
1755 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1756 let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1757 assert_eq!(res.action, WriteAction::Updated);
1758
1759 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1760 assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
1761 assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1762 assert_eq!(
1763 json["mcpServers"]["lean-ctx"]["args"],
1764 serde_json::json!([])
1765 );
1766 assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
1767 .as_str()
1768 .is_some_and(|s| !s.trim().is_empty()));
1769 assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
1770 assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
1771 assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
1772 }
1773
1774 #[test]
1775 fn qoder_mcp_config_is_idempotent() {
1776 let dir = tempfile::tempdir().unwrap();
1777 let path = dir.path().join("mcp.json");
1778 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1779
1780 let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1781 let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1782
1783 assert_eq!(first.action, WriteAction::Created);
1784 assert_eq!(second.action, WriteAction::Already);
1785 }
1786
1787 #[test]
1788 fn qoder_mcp_config_creates_missing_parent_directories() {
1789 let dir = tempfile::tempdir().unwrap();
1790 let path = dir
1791 .path()
1792 .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
1793 let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1794
1795 let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
1796
1797 assert_eq!(res.action, WriteAction::Created);
1798 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1799 assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1800 }
1801
1802 #[test]
1803 fn antigravity_config_omits_auto_approve() {
1804 let dir = tempfile::tempdir().unwrap();
1805 let path = dir.path().join("mcp_config.json");
1806
1807 let t = EditorTarget {
1808 name: "Antigravity",
1809 agent_key: "gemini".to_string(),
1810 config_path: path.clone(),
1811 detect_path: PathBuf::from("/nonexistent"),
1812 config_type: ConfigType::McpJson,
1813 };
1814 let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1815 assert_eq!(res.action, WriteAction::Created);
1816
1817 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1818 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1819 assert_eq!(
1820 json["mcpServers"]["lean-ctx"]["command"],
1821 "/usr/local/bin/lean-ctx"
1822 );
1823 }
1824
1825 #[test]
1826 fn hermes_yaml_inserts_into_existing_mcp_servers() {
1827 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";
1828 let block = " lean-ctx:\n command: \"lean-ctx\"\n env:\n LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1829 let result = upsert_hermes_yaml_mcp(existing, block);
1830 assert!(result.contains("lean-ctx"));
1831 assert!(result.contains("model: anthropic/claude-sonnet-4"));
1832 assert!(result.contains("tool_allowlist:"));
1833 assert!(result.contains("github:"));
1834 }
1835
1836 #[test]
1837 fn hermes_yaml_creates_mcp_servers_section() {
1838 let existing = "model: openai/gpt-4o\n";
1839 let block = " lean-ctx:\n command: \"lean-ctx\"";
1840 let result = upsert_hermes_yaml_mcp(existing, block);
1841 assert!(result.contains("mcp_servers:"));
1842 assert!(result.contains("lean-ctx"));
1843 assert!(result.contains("model: openai/gpt-4o"));
1844 }
1845
1846 #[test]
1847 fn hermes_yaml_skips_if_already_present() {
1848 let dir = tempfile::tempdir().unwrap();
1849 let path = dir.path().join("config.yaml");
1850 std::fs::write(
1851 &path,
1852 "mcp_servers:\n lean-ctx:\n command: \"lean-ctx\"\n",
1853 )
1854 .unwrap();
1855 let t = target("test", path.clone(), ConfigType::HermesYaml);
1856 let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1857 assert_eq!(res.action, WriteAction::Already);
1858 }
1859
1860 #[test]
1861 fn remove_codex_section_also_removes_env_subtable() {
1862 let input = "\
1863[other]
1864x = 1
1865
1866[mcp_servers.lean-ctx]
1867args = []
1868command = \"/usr/local/bin/lean-ctx\"
1869
1870[mcp_servers.lean-ctx.env]
1871LEAN_CTX_DATA_DIR = \"/home/user/.lean-ctx\"
1872
1873[features]
1874codex_hooks = true
1875";
1876 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1877 assert!(
1878 !result.contains("[mcp_servers.lean-ctx]"),
1879 "parent section must be removed"
1880 );
1881 assert!(
1882 !result.contains("LEAN_CTX_DATA_DIR"),
1883 "env sub-table must be removed too"
1884 );
1885 assert!(result.contains("[other]"), "unrelated sections preserved");
1886 assert!(
1887 result.contains("[features]"),
1888 "sections after must be preserved"
1889 );
1890 }
1891
1892 #[test]
1893 fn remove_codex_section_preserves_other_mcp_servers() {
1894 let input = "\
1895[mcp_servers.lean-ctx]
1896command = \"lean-ctx\"
1897
1898[mcp_servers.lean-ctx.env]
1899X = \"1\"
1900
1901[mcp_servers.other]
1902command = \"other\"
1903";
1904 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1905 assert!(!result.contains("[mcp_servers.lean-ctx]"));
1906 assert!(
1907 result.contains("[mcp_servers.other]"),
1908 "other MCP servers must be preserved"
1909 );
1910 assert!(result.contains("command = \"other\""));
1911 }
1912
1913 #[test]
1914 fn remove_codex_section_does_not_remove_similarly_named_server() {
1915 let input = "\
1916[mcp_servers.lean-ctx]
1917command = \"lean-ctx\"
1918
1919[mcp_servers.lean-ctx-probe]
1920command = \"probe\"
1921";
1922 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1923 assert!(
1924 !result.contains("[mcp_servers.lean-ctx]\n"),
1925 "target section must be removed"
1926 );
1927 assert!(
1928 result.contains("[mcp_servers.lean-ctx-probe]"),
1929 "similarly-named server must NOT be removed"
1930 );
1931 assert!(result.contains("command = \"probe\""));
1932 }
1933
1934 #[test]
1935 fn remove_codex_section_handles_no_match() {
1936 let input = "[other]\nx = 1\n";
1937 let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1938 assert_eq!(result, "[other]\nx = 1\n");
1939 }
1940}