1use std::net::TcpListener;
4use std::path::PathBuf;
5
6const GREEN: &str = "\x1b[32m";
7const RED: &str = "\x1b[31m";
8const BOLD: &str = "\x1b[1m";
9const RST: &str = "\x1b[0m";
10const DIM: &str = "\x1b[2m";
11const WHITE: &str = "\x1b[97m";
12const YELLOW: &str = "\x1b[33m";
13
14const VERSION: &str = env!("CARGO_PKG_VERSION");
15
16struct Outcome {
17 ok: bool,
18 line: String,
19}
20
21fn print_check(outcome: &Outcome) {
22 let mark = if outcome.ok {
23 format!("{GREEN}✓{RST}")
24 } else {
25 format!("{RED}✗{RST}")
26 };
27 println!(" {mark} {}", outcome.line);
28}
29
30fn path_in_path_env() -> bool {
31 if let Ok(path) = std::env::var("PATH") {
32 for dir in std::env::split_paths(&path) {
33 if dir.join("lean-ctx").is_file() {
34 return true;
35 }
36 if cfg!(windows)
37 && (dir.join("lean-ctx.exe").is_file() || dir.join("lean-ctx.cmd").is_file())
38 {
39 return true;
40 }
41 }
42 }
43 false
44}
45
46fn resolve_lean_ctx_binary() -> Option<PathBuf> {
47 #[cfg(unix)]
48 {
49 let output = std::process::Command::new("/bin/sh")
50 .arg("-c")
51 .arg("command -v lean-ctx")
52 .env("LEAN_CTX_ACTIVE", "1")
53 .output()
54 .ok()?;
55 if !output.status.success() {
56 return None;
57 }
58 let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
59 if s.is_empty() {
60 None
61 } else {
62 Some(PathBuf::from(s))
63 }
64 }
65
66 #[cfg(windows)]
67 {
68 let output = std::process::Command::new("where.exe")
69 .arg("lean-ctx")
70 .env("LEAN_CTX_ACTIVE", "1")
71 .output()
72 .ok()?;
73 if !output.status.success() {
74 return None;
75 }
76 let stdout = String::from_utf8_lossy(&output.stdout);
77 let lines: Vec<&str> = stdout
78 .lines()
79 .map(|l| l.trim())
80 .filter(|l| !l.is_empty())
81 .collect();
82 let exe_line = lines.iter().find(|l| l.ends_with(".exe"));
83 let best = exe_line.or(lines.first()).map(|s| s.to_string());
84 best.map(PathBuf::from)
85 }
86}
87
88fn lean_ctx_version_from_path() -> Outcome {
89 let resolved = resolve_lean_ctx_binary();
90 let bin = resolved
91 .clone()
92 .unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| "lean-ctx".into()));
93
94 let try_run = |cmd: &std::path::Path| -> Result<String, String> {
95 let output = std::process::Command::new(cmd)
96 .args(["--version"])
97 .env("LEAN_CTX_ACTIVE", "1")
98 .output()
99 .map_err(|e| e.to_string())?;
100 if !output.status.success() {
101 return Err(format!(
102 "exited with {}",
103 output.status.code().unwrap_or(-1)
104 ));
105 }
106 let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
107 if text.is_empty() {
108 return Err("empty output".to_string());
109 }
110 Ok(text)
111 };
112
113 match try_run(&bin) {
114 Ok(text) => Outcome {
115 ok: true,
116 line: format!("{BOLD}lean-ctx version{RST} {WHITE}{text}{RST}"),
117 },
118 Err(_first_err) => {
119 #[cfg(windows)]
120 {
121 let candidates = [
122 bin.with_extension("exe"),
123 bin.parent()
124 .unwrap_or(std::path::Path::new("."))
125 .join("node_modules")
126 .join("lean-ctx-bin")
127 .join("bin")
128 .join("lean-ctx.exe"),
129 ];
130 for candidate in &candidates {
131 if candidate.is_file() {
132 if let Ok(text) = try_run(candidate) {
133 return Outcome {
134 ok: true,
135 line: format!(
136 "{BOLD}lean-ctx version{RST} {WHITE}{text}{RST} {DIM}(via {}){RST}",
137 candidate.display()
138 ),
139 };
140 }
141 }
142 }
143 }
144
145 let current_exe_result = std::env::current_exe();
146 if let Ok(ref exe) = current_exe_result {
147 if exe != &bin {
148 if let Ok(text) = try_run(exe) {
149 return Outcome {
150 ok: true,
151 line: format!("{BOLD}lean-ctx version{RST} {WHITE}{text}{RST} {DIM}(this binary){RST}"),
152 };
153 }
154 }
155 }
156
157 Outcome {
158 ok: false,
159 line: format!(
160 "{BOLD}lean-ctx version{RST} {RED}failed to run `lean-ctx --version`: {_first_err}{RST} {DIM}(resolved: {}){RST}",
161 bin.display()
162 ),
163 }
164 }
165 }
166}
167
168fn rc_contains_lean_ctx(path: &PathBuf) -> bool {
169 match std::fs::read_to_string(path) {
170 Ok(s) => s.contains("lean-ctx"),
171 Err(_) => false,
172 }
173}
174
175fn shell_aliases_outcome() -> Outcome {
176 let home = match dirs::home_dir() {
177 Some(h) => h,
178 None => {
179 return Outcome {
180 ok: false,
181 line: format!(
182 "{BOLD}Shell aliases{RST} {RED}could not resolve home directory{RST}"
183 ),
184 };
185 }
186 };
187
188 let mut parts = Vec::new();
189
190 let zsh = home.join(".zshrc");
191 if rc_contains_lean_ctx(&zsh) {
192 parts.push(format!("{DIM}~/.zshrc{RST}"));
193 }
194 let bash = home.join(".bashrc");
195 if rc_contains_lean_ctx(&bash) {
196 parts.push(format!("{DIM}~/.bashrc{RST}"));
197 }
198
199 #[cfg(windows)]
200 {
201 let ps_profile = home
202 .join("Documents")
203 .join("PowerShell")
204 .join("Microsoft.PowerShell_profile.ps1");
205 let ps_profile_legacy = home
206 .join("Documents")
207 .join("WindowsPowerShell")
208 .join("Microsoft.PowerShell_profile.ps1");
209 if rc_contains_lean_ctx(&ps_profile) {
210 parts.push(format!("{DIM}PowerShell profile{RST}"));
211 } else if rc_contains_lean_ctx(&ps_profile_legacy) {
212 parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
213 }
214 }
215
216 if parts.is_empty() {
217 let hint = if cfg!(windows) {
218 "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
219 } else {
220 "no \"lean-ctx\" in ~/.zshrc or ~/.bashrc"
221 };
222 Outcome {
223 ok: false,
224 line: format!("{BOLD}Shell aliases{RST} {RED}{hint}{RST}"),
225 }
226 } else {
227 Outcome {
228 ok: true,
229 line: format!(
230 "{BOLD}Shell aliases{RST} {GREEN}lean-ctx referenced in {}{RST}",
231 parts.join(", ")
232 ),
233 }
234 }
235}
236
237struct McpLocation {
238 name: &'static str,
239 display: &'static str,
240 path: PathBuf,
241}
242
243fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
244 let mut locations = vec![
245 McpLocation {
246 name: "Cursor",
247 display: "~/.cursor/mcp.json",
248 path: home.join(".cursor").join("mcp.json"),
249 },
250 McpLocation {
251 name: "Claude Code",
252 display: "~/.claude.json",
253 path: home.join(".claude.json"),
254 },
255 McpLocation {
256 name: "Windsurf",
257 display: "~/.codeium/windsurf/mcp_config.json",
258 path: home
259 .join(".codeium")
260 .join("windsurf")
261 .join("mcp_config.json"),
262 },
263 McpLocation {
264 name: "Codex",
265 display: "~/.codex/config.toml",
266 path: home.join(".codex").join("config.toml"),
267 },
268 McpLocation {
269 name: "Gemini CLI",
270 display: "~/.gemini/settings/mcp.json",
271 path: home.join(".gemini").join("settings").join("mcp.json"),
272 },
273 McpLocation {
274 name: "Antigravity",
275 display: "~/.gemini/antigravity/mcp_config.json",
276 path: home
277 .join(".gemini")
278 .join("antigravity")
279 .join("mcp_config.json"),
280 },
281 ];
282
283 #[cfg(unix)]
284 {
285 let zed_cfg = home.join(".config").join("zed").join("settings.json");
286 locations.push(McpLocation {
287 name: "Zed",
288 display: "~/.config/zed/settings.json",
289 path: zed_cfg,
290 });
291 }
292
293 locations.push(McpLocation {
294 name: "Qwen Code",
295 display: "~/.qwen/mcp.json",
296 path: home.join(".qwen").join("mcp.json"),
297 });
298 locations.push(McpLocation {
299 name: "Trae",
300 display: "~/.trae/mcp.json",
301 path: home.join(".trae").join("mcp.json"),
302 });
303 locations.push(McpLocation {
304 name: "Amazon Q",
305 display: "~/.aws/amazonq/mcp.json",
306 path: home.join(".aws").join("amazonq").join("mcp.json"),
307 });
308 locations.push(McpLocation {
309 name: "JetBrains",
310 display: "~/.jb-mcp.json",
311 path: home.join(".jb-mcp.json"),
312 });
313
314 {
315 #[cfg(unix)]
316 let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
317 #[cfg(unix)]
318 let opencode_display = "~/.config/opencode/opencode.json";
319
320 #[cfg(windows)]
321 let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
322 std::path::PathBuf::from(appdata)
323 .join("opencode")
324 .join("opencode.json")
325 } else {
326 home.join(".config").join("opencode").join("opencode.json")
327 };
328 #[cfg(windows)]
329 let opencode_display = "%APPDATA%/opencode/opencode.json";
330
331 locations.push(McpLocation {
332 name: "OpenCode",
333 display: opencode_display,
334 path: opencode_cfg,
335 });
336 }
337
338 #[cfg(target_os = "macos")]
339 {
340 let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
341 locations.push(McpLocation {
342 name: "VS Code / Copilot",
343 display: "~/Library/Application Support/Code/User/mcp.json",
344 path: vscode_mcp,
345 });
346 }
347 #[cfg(target_os = "linux")]
348 {
349 let vscode_mcp = home.join(".config/Code/User/mcp.json");
350 locations.push(McpLocation {
351 name: "VS Code / Copilot",
352 display: "~/.config/Code/User/mcp.json",
353 path: vscode_mcp,
354 });
355 }
356 #[cfg(target_os = "windows")]
357 {
358 if let Ok(appdata) = std::env::var("APPDATA") {
359 let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
360 locations.push(McpLocation {
361 name: "VS Code / Copilot",
362 display: "%APPDATA%/Code/User/mcp.json",
363 path: vscode_mcp,
364 });
365 }
366 }
367
368 locations
369}
370
371fn mcp_config_outcome() -> Outcome {
372 let home = match dirs::home_dir() {
373 Some(h) => h,
374 None => {
375 return Outcome {
376 ok: false,
377 line: format!("{BOLD}MCP config{RST} {RED}could not resolve home directory{RST}"),
378 };
379 }
380 };
381
382 let locations = mcp_config_locations(&home);
383 let mut found: Vec<String> = Vec::new();
384 let mut exists_no_ref: Vec<String> = Vec::new();
385
386 for loc in &locations {
387 match std::fs::read_to_string(&loc.path) {
388 Ok(content) if content.contains("lean-ctx") => {
389 found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
390 }
391 Ok(_) => {
392 exists_no_ref.push(loc.name.to_string());
393 }
394 Err(_) => {}
395 }
396 }
397
398 if !found.is_empty() {
399 Outcome {
400 ok: true,
401 line: format!(
402 "{BOLD}MCP config{RST} {GREEN}lean-ctx found in: {}{RST}",
403 found.join(", ")
404 ),
405 }
406 } else if !exists_no_ref.is_empty() {
407 Outcome {
408 ok: false,
409 line: format!(
410 "{BOLD}MCP config{RST} {YELLOW}config exists for {} but does not reference lean-ctx{RST} {DIM}(run: lean-ctx init --agent <editor>){RST}",
411 exists_no_ref.join(", ")
412 ),
413 }
414 } else {
415 Outcome {
416 ok: false,
417 line: format!(
418 "{BOLD}MCP config{RST} {YELLOW}no MCP config found{RST} {DIM}(checked: Cursor, Claude, Windsurf, Codex, Gemini, Antigravity, Zed){RST}"
419 ),
420 }
421 }
422}
423
424fn port_3333_outcome() -> Outcome {
425 match TcpListener::bind("127.0.0.1:3333") {
426 Ok(_listener) => Outcome {
427 ok: true,
428 line: format!("{BOLD}Dashboard port 3333{RST} {GREEN}available on 127.0.0.1{RST}"),
429 },
430 Err(e) => Outcome {
431 ok: false,
432 line: format!("{BOLD}Dashboard port 3333{RST} {RED}not available: {e}{RST}"),
433 },
434 }
435}
436
437fn pi_outcome() -> Option<Outcome> {
438 let pi_result = std::process::Command::new("pi").arg("--version").output();
439
440 match pi_result {
441 Ok(output) if output.status.success() => {
442 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
443 let has_plugin = std::process::Command::new("pi")
444 .args(["list"])
445 .output()
446 .map(|o| String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx"))
447 .unwrap_or(false);
448
449 if has_plugin {
450 Some(Outcome {
451 ok: true,
452 line: format!(
453 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx installed{RST}"
454 ),
455 })
456 } else {
457 Some(Outcome {
458 ok: false,
459 line: format!(
460 "{BOLD}Pi Coding Agent{RST} {YELLOW}{version}, but pi-lean-ctx not installed{RST} {DIM}(run: pi install npm:pi-lean-ctx){RST}"
461 ),
462 })
463 }
464 }
465 _ => None,
466 }
467}
468
469pub fn run() {
471 let mut passed = 0u32;
472 let total = 8u32;
473
474 println!("{BOLD}{WHITE}lean-ctx doctor{RST} {DIM}diagnostics{RST}\n");
475
476 let path_bin = resolve_lean_ctx_binary();
478 let also_in_path_dirs = path_in_path_env();
479 let bin_ok = path_bin.is_some() || also_in_path_dirs;
480 if bin_ok {
481 passed += 1;
482 }
483 let bin_line = if let Some(p) = path_bin {
484 format!("{BOLD}lean-ctx in PATH{RST} {WHITE}{}{RST}", p.display())
485 } else if also_in_path_dirs {
486 format!(
487 "{BOLD}lean-ctx in PATH{RST} {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
488 )
489 } else {
490 format!("{BOLD}lean-ctx in PATH{RST} {RED}not found{RST}")
491 };
492 print_check(&Outcome {
493 ok: bin_ok,
494 line: bin_line,
495 });
496
497 let ver = if bin_ok {
499 lean_ctx_version_from_path()
500 } else {
501 Outcome {
502 ok: false,
503 line: format!("{BOLD}lean-ctx version{RST} {RED}skipped (binary not in PATH){RST}"),
504 }
505 };
506 if ver.ok {
507 passed += 1;
508 }
509 print_check(&ver);
510
511 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
513 let dir_outcome = match &lean_dir {
514 Some(p) if p.is_dir() => {
515 passed += 1;
516 Outcome {
517 ok: true,
518 line: format!(
519 "{BOLD}~/.lean-ctx/{RST} {GREEN}exists{RST} {DIM}{}{RST}",
520 p.display()
521 ),
522 }
523 }
524 Some(p) => Outcome {
525 ok: false,
526 line: format!(
527 "{BOLD}~/.lean-ctx/{RST} {RED}missing or not a directory{RST} {DIM}{}{RST}",
528 p.display()
529 ),
530 },
531 None => Outcome {
532 ok: false,
533 line: format!("{BOLD}~/.lean-ctx/{RST} {RED}could not resolve home directory{RST}"),
534 },
535 };
536 print_check(&dir_outcome);
537
538 let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
540 let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
541 Some(m) if m.is_file() => {
542 passed += 1;
543 let size = m.len();
544 Outcome {
545 ok: true,
546 line: format!(
547 "{BOLD}stats.json{RST} {GREEN}exists{RST} {WHITE}{size} bytes{RST} {DIM}{}{RST}",
548 stats_path.as_ref().unwrap().display()
549 ),
550 }
551 }
552 Some(_m) => Outcome {
553 ok: false,
554 line: format!(
555 "{BOLD}stats.json{RST} {RED}not a file{RST} {DIM}{}{RST}",
556 stats_path.as_ref().unwrap().display()
557 ),
558 },
559 None => {
560 passed += 1;
561 Outcome {
562 ok: true,
563 line: match &stats_path {
564 Some(p) => format!(
565 "{BOLD}stats.json{RST} {YELLOW}not yet created{RST} {DIM}(will appear after first use) {}{RST}",
566 p.display()
567 ),
568 None => format!("{BOLD}stats.json{RST} {RED}could not resolve path{RST}"),
569 },
570 }
571 }
572 };
573 print_check(&stats_outcome);
574
575 let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
577 let config_outcome = match &config_path {
578 Some(p) => match std::fs::metadata(p) {
579 Ok(m) if m.is_file() => {
580 passed += 1;
581 Outcome {
582 ok: true,
583 line: format!(
584 "{BOLD}config.toml{RST} {GREEN}exists{RST} {DIM}{}{RST}",
585 p.display()
586 ),
587 }
588 }
589 Ok(_) => Outcome {
590 ok: false,
591 line: format!(
592 "{BOLD}config.toml{RST} {RED}exists but is not a regular file{RST} {DIM}{}{RST}",
593 p.display()
594 ),
595 },
596 Err(_) => {
597 passed += 1;
598 Outcome {
599 ok: true,
600 line: format!(
601 "{BOLD}config.toml{RST} {YELLOW}not found, using defaults{RST} {DIM}(expected at {}){RST}",
602 p.display()
603 ),
604 }
605 }
606 },
607 None => Outcome {
608 ok: false,
609 line: format!("{BOLD}config.toml{RST} {RED}could not resolve path{RST}"),
610 },
611 };
612 print_check(&config_outcome);
613
614 let aliases = shell_aliases_outcome();
616 if aliases.ok {
617 passed += 1;
618 }
619 print_check(&aliases);
620
621 let mcp = mcp_config_outcome();
623 if mcp.ok {
624 passed += 1;
625 }
626 print_check(&mcp);
627
628 let port = port_3333_outcome();
630 if port.ok {
631 passed += 1;
632 }
633 print_check(&port);
634
635 let pi = pi_outcome();
637 if let Some(ref pi_check) = pi {
638 if pi_check.ok {
639 passed += 1;
640 }
641 print_check(pi_check);
642 }
643
644 let effective_total = if pi.is_some() { total + 1 } else { total };
645 println!();
646 println!(" {BOLD}{WHITE}Summary:{RST} {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
647 println!(" {DIM}This binary: lean-ctx {VERSION} (Cargo package version){RST}");
648}