1use std::path::PathBuf;
2
3use crate::core::editor_registry::{ConfigType, EditorTarget, WriteAction, WriteOptions};
4use crate::core::portable_binary::resolve_portable_binary;
5use crate::core::setup_report::{PlatformInfo, SetupItem, SetupReport, SetupStepReport};
6use crate::hooks::{recommend_hook_mode, HookMode};
7use chrono::Utc;
8use std::ffi::OsString;
9
10pub fn claude_config_json_path(home: &std::path::Path) -> PathBuf {
11 crate::core::editor_registry::claude_mcp_json_path(home)
12}
13
14pub fn claude_config_dir(home: &std::path::Path) -> PathBuf {
15 crate::core::editor_registry::claude_state_dir(home)
16}
17
18pub(crate) struct EnvVarGuard {
19 key: &'static str,
20 previous: Option<OsString>,
21}
22
23impl EnvVarGuard {
24 pub(crate) fn set(key: &'static str, value: &str) -> Self {
25 let previous = std::env::var_os(key);
26 std::env::set_var(key, value);
27 Self { key, previous }
28 }
29}
30
31impl Drop for EnvVarGuard {
32 fn drop(&mut self) {
33 if let Some(previous) = &self.previous {
34 std::env::set_var(self.key, previous);
35 } else {
36 std::env::remove_var(self.key);
37 }
38 }
39}
40
41pub fn run_setup() {
42 use crate::terminal_ui;
43
44 if crate::shell::is_non_interactive() {
45 eprintln!("Non-interactive terminal detected (no TTY on stdin).");
46 eprintln!("Running in non-interactive mode (equivalent to: lean-ctx setup --non-interactive --yes)");
47 eprintln!();
48 let opts = SetupOptions {
49 non_interactive: true,
50 yes: true,
51 fix: false,
52 json: false,
53 };
54 match run_setup_with_options(opts) {
55 Ok(report) => {
56 if !report.warnings.is_empty() {
57 for w in &report.warnings {
58 tracing::warn!("{w}");
59 }
60 }
61 }
62 Err(e) => tracing::error!("Setup error: {e}"),
63 }
64 return;
65 }
66
67 let Some(home) = dirs::home_dir() else {
68 tracing::error!("Cannot determine home directory");
69 std::process::exit(1);
70 };
71
72 let binary = resolve_portable_binary();
73
74 let home_str = home.to_string_lossy().to_string();
75
76 terminal_ui::print_setup_header();
77
78 terminal_ui::print_step_header(1, 10, "Shell Hook");
80 crate::cli::cmd_init(&["--global".to_string()]);
81 crate::shell_hook::install_all(false);
82
83 terminal_ui::print_step_header(2, 10, "Daemon");
85 #[cfg(unix)]
86 {
87 if crate::daemon::is_daemon_running() {
88 terminal_ui::print_status_ok("Daemon running — restarting with current binary…");
89 let _ = crate::daemon::stop_daemon();
90 std::thread::sleep(std::time::Duration::from_millis(500));
91 if let Err(e) = crate::daemon::start_daemon(&[]) {
92 terminal_ui::print_status_warn(&format!("Daemon restart failed: {e}"));
93 }
94 } else if let Err(e) = crate::daemon::start_daemon(&[]) {
95 terminal_ui::print_status_warn(&format!("Daemon start failed: {e}"));
96 }
97 }
98 #[cfg(not(unix))]
99 {
100 terminal_ui::print_status_skip("Daemon supported on Unix only");
101 }
102
103 terminal_ui::print_step_header(3, 10, "AI Tool Detection");
105
106 let targets = crate::core::editor_registry::build_targets(&home);
107 let mut newly_configured: Vec<&str> = Vec::new();
108 let mut already_configured: Vec<&str> = Vec::new();
109 let mut not_installed: Vec<&str> = Vec::new();
110 let mut errors: Vec<&str> = Vec::new();
111
112 for target in &targets {
113 let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
114
115 if !target.detect_path.exists() {
116 not_installed.push(target.name);
117 continue;
118 }
119
120 let mode = if target.agent_key.is_empty() {
121 HookMode::Mcp
122 } else {
123 recommend_hook_mode(&target.agent_key)
124 };
125
126 if mode == HookMode::CliRedirect {
127 match crate::core::editor_registry::remove_lean_ctx_server(
128 target,
129 WriteOptions {
130 overwrite_invalid: false,
131 },
132 ) {
133 Ok(res) => {
134 let status_msg = format!(
135 "{:<20} \x1b[36m{mode}\x1b[0m \x1b[2m{short_path} (mcp=disabled)\x1b[0m",
136 target.name
137 );
138 if res.action == WriteAction::Already {
139 terminal_ui::print_status_ok(&status_msg);
140 already_configured.push(target.name);
141 } else {
142 terminal_ui::print_status_new(&status_msg);
143 newly_configured.push(target.name);
144 }
145 }
146 Err(e) => {
147 terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
148 errors.push(target.name);
149 }
150 }
151 continue;
152 }
153
154 match crate::core::editor_registry::write_config_with_options(
155 target,
156 &binary,
157 WriteOptions {
158 overwrite_invalid: false,
159 },
160 ) {
161 Ok(res) if res.action == WriteAction::Already => {
162 terminal_ui::print_status_ok(&format!(
163 "{:<20} \x1b[36m{mode}\x1b[0m \x1b[2m{short_path}\x1b[0m",
164 target.name
165 ));
166 already_configured.push(target.name);
167 }
168 Ok(_) => {
169 terminal_ui::print_status_new(&format!(
170 "{:<20} \x1b[36m{mode}\x1b[0m \x1b[2m{short_path}\x1b[0m",
171 target.name
172 ));
173 newly_configured.push(target.name);
174 }
175 Err(e) => {
176 terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
177 errors.push(target.name);
178 }
179 }
180 }
181
182 let total_ok = newly_configured.len() + already_configured.len();
183 if total_ok == 0 && errors.is_empty() {
184 terminal_ui::print_status_warn(
185 "No AI tools detected. Install one and re-run: lean-ctx setup",
186 );
187 }
188
189 if !not_installed.is_empty() {
190 println!(
191 " \x1b[2m○ {} not detected: {}\x1b[0m",
192 not_installed.len(),
193 not_installed.join(", ")
194 );
195 }
196
197 terminal_ui::print_step_header(4, 10, "Agent Rules");
199 let rules_result = crate::rules_inject::inject_all_rules(&home);
200 for name in &rules_result.injected {
201 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules injected\x1b[0m"));
202 }
203 for name in &rules_result.updated {
204 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules updated\x1b[0m"));
205 }
206 for name in &rules_result.already {
207 terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mrules up-to-date\x1b[0m"));
208 }
209 for err in &rules_result.errors {
210 terminal_ui::print_status_warn(err);
211 }
212 if rules_result.injected.is_empty()
213 && rules_result.updated.is_empty()
214 && rules_result.already.is_empty()
215 && rules_result.errors.is_empty()
216 {
217 terminal_ui::print_status_skip("No agent rules needed");
218 }
219
220 for target in &targets {
222 if !target.detect_path.exists() || target.agent_key.is_empty() {
223 continue;
224 }
225 let mode = recommend_hook_mode(&target.agent_key);
226 crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
227 }
228
229 terminal_ui::print_step_header(5, 10, "API Proxy");
231 crate::proxy_setup::install_proxy_env(&home, crate::proxy_setup::default_port(), false);
232 println!();
233 println!(" \x1b[2mStart proxy for maximum token savings:\x1b[0m");
234 println!(" \x1b[1mlean-ctx proxy start\x1b[0m");
235 println!(" \x1b[2mEnable autostart:\x1b[0m");
236 println!(" \x1b[1mlean-ctx proxy start --autostart\x1b[0m");
237
238 terminal_ui::print_step_header(6, 10, "Skill Files");
240 let skill_result = install_skill_files(&home);
241 for (name, installed) in &skill_result {
242 if *installed {
243 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mSKILL.md installed\x1b[0m"));
244 } else {
245 terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mSKILL.md up-to-date\x1b[0m"));
246 }
247 }
248 if skill_result.is_empty() {
249 terminal_ui::print_status_skip("No skill directories to install");
250 }
251
252 terminal_ui::print_step_header(7, 10, "Environment Check");
254 let lean_dir = home.join(".lean-ctx");
255 if lean_dir.exists() {
256 terminal_ui::print_status_ok("~/.lean-ctx/ ready");
257 } else {
258 let _ = std::fs::create_dir_all(&lean_dir);
259 terminal_ui::print_status_new("Created ~/.lean-ctx/");
260 }
261 crate::doctor::run_compact();
262
263 terminal_ui::print_step_header(8, 10, "Help Improve lean-ctx");
265 println!(" Share anonymous compression stats to make lean-ctx better.");
266 println!(" \x1b[1mNo code, no file names, no personal data — ever.\x1b[0m");
267 println!();
268 print!(" Enable anonymous data sharing? \x1b[1m[y/N]\x1b[0m ");
269 use std::io::Write;
270 std::io::stdout().flush().ok();
271
272 let mut input = String::new();
273 let contribute = if std::io::stdin().read_line(&mut input).is_ok() {
274 let answer = input.trim().to_lowercase();
275 answer == "y" || answer == "yes"
276 } else {
277 false
278 };
279
280 if contribute {
281 let config_dir = home.join(".lean-ctx");
282 let _ = std::fs::create_dir_all(&config_dir);
283 let config_path = config_dir.join("config.toml");
284 let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
285 if !config_content.contains("[cloud]") {
286 if !config_content.is_empty() && !config_content.ends_with('\n') {
287 config_content.push('\n');
288 }
289 config_content.push_str("\n[cloud]\ncontribute_enabled = true\n");
290 let _ = std::fs::write(&config_path, config_content);
291 }
292 terminal_ui::print_status_ok("Enabled — thank you!");
293 } else {
294 terminal_ui::print_status_skip("Skipped — enable later with: lean-ctx config");
295 }
296
297 terminal_ui::print_step_header(9, 10, "Premium Features");
299 configure_premium_features(&home);
300
301 terminal_ui::print_step_header(10, 10, "Code Intelligence");
303 let cwd = std::env::current_dir().ok();
304 let is_project = cwd.as_ref().is_some_and(|d| {
305 d.join(".git").exists()
306 || d.join("Cargo.toml").exists()
307 || d.join("package.json").exists()
308 || d.join("go.mod").exists()
309 });
310 if is_project {
311 println!(" \x1b[2mBuilding code graph for graph-aware reads, impact analysis,\x1b[0m");
312 println!(" \x1b[2mand smart search fusion in the background...\x1b[0m");
313 if let Some(ref root) = cwd {
314 spawn_index_build_background(root);
315 }
316 terminal_ui::print_status_ok("Graph build started (background)");
317 } else {
318 println!(" \x1b[2mRun `lean-ctx impact build` inside any git project to enable\x1b[0m");
319 println!(" \x1b[2mgraph-aware reads, impact analysis, and smart search fusion.\x1b[0m");
320 }
321 println!();
322
323 println!();
325 println!(
326 " \x1b[1;32m✓ Setup complete!\x1b[0m \x1b[1m{}\x1b[0m configured, \x1b[2m{} already set, {} skipped\x1b[0m",
327 newly_configured.len(),
328 already_configured.len(),
329 not_installed.len()
330 );
331
332 if !errors.is_empty() {
333 println!(
334 " \x1b[33m⚠ {} error{}: {}\x1b[0m",
335 errors.len(),
336 if errors.len() == 1 { "" } else { "s" },
337 errors.join(", ")
338 );
339 }
340
341 let shell = std::env::var("SHELL").unwrap_or_default();
343 let source_cmd = if shell.contains("zsh") {
344 "source ~/.zshrc"
345 } else if shell.contains("fish") {
346 "source ~/.config/fish/config.fish"
347 } else if shell.contains("bash") {
348 "source ~/.bashrc"
349 } else {
350 "Restart your shell"
351 };
352
353 let dim = "\x1b[2m";
354 let bold = "\x1b[1m";
355 let cyan = "\x1b[36m";
356 let yellow = "\x1b[33m";
357 let rst = "\x1b[0m";
358
359 println!();
360 println!(" {bold}Next steps:{rst}");
361 println!();
362 println!(" {cyan}1.{rst} Reload your shell:");
363 println!(" {bold}{source_cmd}{rst}");
364 println!();
365
366 let mut tools_to_restart: Vec<String> = newly_configured
367 .iter()
368 .map(std::string::ToString::to_string)
369 .collect();
370 for name in rules_result
371 .injected
372 .iter()
373 .chain(rules_result.updated.iter())
374 {
375 if !tools_to_restart.iter().any(|t| t == name) {
376 tools_to_restart.push(name.clone());
377 }
378 }
379
380 if !tools_to_restart.is_empty() {
381 println!(" {cyan}2.{rst} {yellow}{bold}Restart your IDE / AI tool:{rst}");
382 println!(" {bold}{}{rst}", tools_to_restart.join(", "));
383 println!(
384 " {dim}Changes take effect after a full restart (MCP may be enabled or disabled depending on mode).{rst}"
385 );
386 println!(" {dim}Close and re-open the application completely.{rst}");
387 } else if !already_configured.is_empty() {
388 println!(
389 " {cyan}2.{rst} {dim}Your tools are already configured — no restart needed.{rst}"
390 );
391 }
392
393 println!();
394 println!(
395 " {dim}After restart, lean-ctx will automatically optimize every AI interaction.{rst}"
396 );
397 println!(" {dim}Verify with:{rst} {bold}lean-ctx gain{rst}");
398
399 println!();
401 terminal_ui::print_logo_animated();
402 terminal_ui::print_command_box();
403}
404
405#[derive(Debug, Clone, Copy, Default)]
406pub struct SetupOptions {
407 pub non_interactive: bool,
408 pub yes: bool,
409 pub fix: bool,
410 pub json: bool,
411}
412
413pub fn run_setup_with_options(opts: SetupOptions) -> Result<SetupReport, String> {
414 let _quiet_guard = opts.json.then(|| EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
415 let started_at = Utc::now();
416 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
417 let binary = resolve_portable_binary();
418 let home_str = home.to_string_lossy().to_string();
419
420 let mut steps: Vec<SetupStepReport> = Vec::new();
421
422 let mut shell_step = SetupStepReport {
424 name: "shell_hook".to_string(),
425 ok: true,
426 items: Vec::new(),
427 warnings: Vec::new(),
428 errors: Vec::new(),
429 };
430 if !opts.non_interactive || opts.yes {
431 if opts.json {
432 crate::cli::cmd_init_quiet(&["--global".to_string()]);
433 } else {
434 crate::cli::cmd_init(&["--global".to_string()]);
435 }
436 crate::shell_hook::install_all(opts.json);
437 #[cfg(not(windows))]
438 {
439 let hook_content = crate::cli::generate_hook_posix(&binary);
441 crate::cli::write_env_sh_for_containers(&hook_content);
442 shell_step.items.push(SetupItem {
443 name: "env_sh".to_string(),
444 status: "created".to_string(),
445 path: Some("~/.lean-ctx/env.sh".to_string()),
446 note: Some("Docker/CI helper (BASH_ENV / CLAUDE_ENV_FILE)".to_string()),
447 });
448 }
449 shell_step.items.push(SetupItem {
450 name: "init --global".to_string(),
451 status: "ran".to_string(),
452 path: None,
453 note: None,
454 });
455 shell_step.items.push(SetupItem {
456 name: "universal_shell_hook".to_string(),
457 status: "installed".to_string(),
458 path: None,
459 note: Some("~/.zshenv, ~/.bashenv, agent aliases".to_string()),
460 });
461 } else {
462 shell_step
463 .warnings
464 .push("non_interactive_without_yes: shell hook not installed (use --yes)".to_string());
465 shell_step.ok = false;
466 shell_step.items.push(SetupItem {
467 name: "init --global".to_string(),
468 status: "skipped".to_string(),
469 path: None,
470 note: Some("requires --yes in --non-interactive mode".to_string()),
471 });
472 }
473 steps.push(shell_step);
474
475 let mut daemon_step = SetupStepReport {
477 name: "daemon".to_string(),
478 ok: true,
479 items: Vec::new(),
480 warnings: Vec::new(),
481 errors: Vec::new(),
482 };
483 #[cfg(unix)]
484 {
485 let was_running = crate::daemon::is_daemon_running();
486 if was_running {
487 let _ = crate::daemon::stop_daemon();
488 std::thread::sleep(std::time::Duration::from_millis(500));
489 }
490 match crate::daemon::start_daemon(&[]) {
491 Ok(()) => {
492 let action = if was_running { "restarted" } else { "started" };
493 daemon_step.items.push(SetupItem {
494 name: "serve --daemon".to_string(),
495 status: action.to_string(),
496 path: Some(
497 crate::daemon::daemon_socket_path()
498 .to_string_lossy()
499 .to_string(),
500 ),
501 note: Some("CLI commands can route via UDS when running".to_string()),
502 });
503 }
504 Err(e) => {
505 daemon_step.ok = false;
506 daemon_step
507 .warnings
508 .push(format!("daemon start failed: {e}"));
509 daemon_step.items.push(SetupItem {
510 name: "serve --daemon".to_string(),
511 status: "error".to_string(),
512 path: Some(
513 crate::daemon::daemon_socket_path()
514 .to_string_lossy()
515 .to_string(),
516 ),
517 note: Some(e.to_string()),
518 });
519 }
520 }
521 }
522 #[cfg(not(unix))]
523 {
524 daemon_step.items.push(SetupItem {
525 name: "serve --daemon".to_string(),
526 status: "skipped".to_string(),
527 path: None,
528 note: Some("daemon supported on Unix only".to_string()),
529 });
530 }
531 steps.push(daemon_step);
532
533 let mut editor_step = SetupStepReport {
535 name: "editors".to_string(),
536 ok: true,
537 items: Vec::new(),
538 warnings: Vec::new(),
539 errors: Vec::new(),
540 };
541
542 let targets = crate::core::editor_registry::build_targets(&home);
543 for target in &targets {
544 let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
545 if !target.detect_path.exists() {
546 editor_step.items.push(SetupItem {
547 name: target.name.to_string(),
548 status: "not_detected".to_string(),
549 path: Some(short_path),
550 note: None,
551 });
552 continue;
553 }
554
555 let mode = if target.agent_key.is_empty() {
556 HookMode::Mcp
557 } else {
558 recommend_hook_mode(&target.agent_key)
559 };
560
561 if mode == HookMode::CliRedirect {
564 let res = crate::core::editor_registry::remove_lean_ctx_server(
565 target,
566 WriteOptions {
567 overwrite_invalid: opts.fix,
568 },
569 );
570 match res {
571 Ok(w) => {
572 let note_parts: Vec<String> = [
573 Some(format!("mode={mode}")),
574 Some("mcp=disabled".to_string()),
575 w.note,
576 ]
577 .into_iter()
578 .flatten()
579 .collect();
580 editor_step.items.push(SetupItem {
581 name: target.name.to_string(),
582 status: match w.action {
583 WriteAction::Created => "created".to_string(),
584 WriteAction::Updated => "updated".to_string(),
585 WriteAction::Already => "already".to_string(),
586 },
587 path: Some(short_path),
588 note: Some(note_parts.join("; ")),
589 });
590 }
591 Err(e) => {
592 editor_step.ok = false;
593 editor_step.items.push(SetupItem {
594 name: target.name.to_string(),
595 status: "error".to_string(),
596 path: Some(short_path),
597 note: Some(format!("mode={mode}; mcp=disable_failed; {e}")),
598 });
599 }
600 }
601 continue;
602 }
603
604 let res = crate::core::editor_registry::write_config_with_options(
605 target,
606 &binary,
607 WriteOptions {
608 overwrite_invalid: opts.fix,
609 },
610 );
611 match res {
612 Ok(w) => {
613 let note_parts: Vec<String> = [Some(format!("mode={mode}")), w.note]
614 .into_iter()
615 .flatten()
616 .collect();
617 editor_step.items.push(SetupItem {
618 name: target.name.to_string(),
619 status: match w.action {
620 WriteAction::Created => "created".to_string(),
621 WriteAction::Updated => "updated".to_string(),
622 WriteAction::Already => "already".to_string(),
623 },
624 path: Some(short_path),
625 note: Some(note_parts.join("; ")),
626 });
627 }
628 Err(e) => {
629 editor_step.ok = false;
630 editor_step.items.push(SetupItem {
631 name: target.name.to_string(),
632 status: "error".to_string(),
633 path: Some(short_path),
634 note: Some(e),
635 });
636 }
637 }
638 }
639 steps.push(editor_step);
640
641 let mut rules_step = SetupStepReport {
643 name: "agent_rules".to_string(),
644 ok: true,
645 items: Vec::new(),
646 warnings: Vec::new(),
647 errors: Vec::new(),
648 };
649 let rules_result = crate::rules_inject::inject_all_rules(&home);
650 for n in rules_result.injected {
651 rules_step.items.push(SetupItem {
652 name: n,
653 status: "injected".to_string(),
654 path: None,
655 note: None,
656 });
657 }
658 for n in rules_result.updated {
659 rules_step.items.push(SetupItem {
660 name: n,
661 status: "updated".to_string(),
662 path: None,
663 note: None,
664 });
665 }
666 for n in rules_result.already {
667 rules_step.items.push(SetupItem {
668 name: n,
669 status: "already".to_string(),
670 path: None,
671 note: None,
672 });
673 }
674 for e in rules_result.errors {
675 rules_step.ok = false;
676 rules_step.errors.push(e);
677 }
678 steps.push(rules_step);
679
680 let mut skill_step = SetupStepReport {
682 name: "skill_files".to_string(),
683 ok: true,
684 items: Vec::new(),
685 warnings: Vec::new(),
686 errors: Vec::new(),
687 };
688 let skill_results = crate::rules_inject::install_all_skills(&home);
689 for (name, is_new) in &skill_results {
690 skill_step.items.push(SetupItem {
691 name: name.clone(),
692 status: if *is_new { "installed" } else { "already" }.to_string(),
693 path: None,
694 note: Some("SKILL.md".to_string()),
695 });
696 }
697 if !skill_step.items.is_empty() {
698 steps.push(skill_step);
699 }
700
701 let mut hooks_step = SetupStepReport {
703 name: "agent_hooks".to_string(),
704 ok: true,
705 items: Vec::new(),
706 warnings: Vec::new(),
707 errors: Vec::new(),
708 };
709 for target in &targets {
710 if !target.detect_path.exists() || target.agent_key.is_empty() {
711 continue;
712 }
713 let mode = recommend_hook_mode(&target.agent_key);
714 crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
715 hooks_step.items.push(SetupItem {
716 name: format!("{} hooks", target.name),
717 status: "installed".to_string(),
718 path: Some(target.detect_path.to_string_lossy().to_string()),
719 note: Some(format!(
720 "mode={mode}; merge-based install/repair (preserves other hooks/plugins)"
721 )),
722 });
723 }
724 if !hooks_step.items.is_empty() {
725 steps.push(hooks_step);
726 }
727
728 let mut proxy_step = SetupStepReport {
730 name: "proxy_env".to_string(),
731 ok: true,
732 items: Vec::new(),
733 warnings: Vec::new(),
734 errors: Vec::new(),
735 };
736 crate::proxy_setup::install_proxy_env(&home, crate::proxy_setup::default_port(), opts.json);
737 proxy_step.items.push(SetupItem {
738 name: "proxy_env".to_string(),
739 status: "configured".to_string(),
740 path: None,
741 note: Some("ANTHROPIC_BASE_URL, OPENAI_BASE_URL, GEMINI_API_BASE_URL".to_string()),
742 });
743 steps.push(proxy_step);
744
745 let mut env_step = SetupStepReport {
747 name: "doctor_compact".to_string(),
748 ok: true,
749 items: Vec::new(),
750 warnings: Vec::new(),
751 errors: Vec::new(),
752 };
753 let (passed, total) = crate::doctor::compact_score();
754 env_step.items.push(SetupItem {
755 name: "doctor".to_string(),
756 status: format!("{passed}/{total}"),
757 path: None,
758 note: None,
759 });
760 if passed != total {
761 env_step.warnings.push(format!(
762 "doctor compact not fully passing: {passed}/{total}"
763 ));
764 }
765 steps.push(env_step);
766
767 if let Ok(cwd) = std::env::current_dir() {
769 let is_project = cwd.join(".git").exists()
770 || cwd.join("Cargo.toml").exists()
771 || cwd.join("package.json").exists()
772 || cwd.join("go.mod").exists();
773 if is_project {
774 spawn_index_build_background(&cwd);
775 }
776 }
777
778 let finished_at = Utc::now();
779 let success = steps.iter().all(|s| s.ok);
780 let report = SetupReport {
781 schema_version: 1,
782 started_at,
783 finished_at,
784 success,
785 platform: PlatformInfo {
786 os: std::env::consts::OS.to_string(),
787 arch: std::env::consts::ARCH.to_string(),
788 },
789 steps,
790 warnings: Vec::new(),
791 errors: Vec::new(),
792 };
793
794 let path = SetupReport::default_path()?;
795 let mut content =
796 serde_json::to_string_pretty(&report).map_err(|e| format!("serialize report: {e}"))?;
797 content.push('\n');
798 crate::config_io::write_atomic(&path, &content)?;
799
800 Ok(report)
801}
802
803fn spawn_index_build_background(root: &std::path::Path) {
804 let binary = std::env::current_exe().map_or_else(
805 |_| resolve_portable_binary(),
806 |p| p.to_string_lossy().to_string(),
807 );
808 let _ = std::process::Command::new(&binary)
809 .args(["index", "build-graph", "--root"])
810 .arg(root)
811 .stdout(std::process::Stdio::null())
812 .stderr(std::process::Stdio::null())
813 .stdin(std::process::Stdio::null())
814 .spawn();
815}
816
817pub fn configure_agent_mcp(agent: &str) -> Result<(), String> {
818 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
819 let binary = resolve_portable_binary();
820
821 let targets = agent_mcp_targets(agent, &home)?;
822
823 for t in &targets {
824 crate::core::editor_registry::write_config_with_options(
825 t,
826 &binary,
827 WriteOptions {
828 overwrite_invalid: true,
829 },
830 )?;
831 }
832
833 if agent == "kiro" {
834 install_kiro_steering(&home);
835 }
836
837 Ok(())
838}
839
840fn agent_mcp_targets(agent: &str, home: &std::path::Path) -> Result<Vec<EditorTarget>, String> {
841 let mut targets = Vec::<EditorTarget>::new();
842
843 let push = |targets: &mut Vec<EditorTarget>,
844 name: &'static str,
845 config_path: PathBuf,
846 config_type: ConfigType| {
847 targets.push(EditorTarget {
848 name,
849 agent_key: agent.to_string(),
850 detect_path: PathBuf::from("/nonexistent"), config_path,
852 config_type,
853 });
854 };
855
856 let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
857
858 match agent {
859 "cursor" => push(
860 &mut targets,
861 "Cursor",
862 home.join(".cursor/mcp.json"),
863 ConfigType::McpJson,
864 ),
865 "claude" | "claude-code" => push(
866 &mut targets,
867 "Claude Code",
868 crate::core::editor_registry::claude_mcp_json_path(home),
869 ConfigType::McpJson,
870 ),
871 "windsurf" => push(
872 &mut targets,
873 "Windsurf",
874 home.join(".codeium/windsurf/mcp_config.json"),
875 ConfigType::McpJson,
876 ),
877 "codex" => push(
878 &mut targets,
879 "Codex CLI",
880 home.join(".codex/config.toml"),
881 ConfigType::Codex,
882 ),
883 "gemini" => {
884 push(
885 &mut targets,
886 "Gemini CLI",
887 home.join(".gemini/settings.json"),
888 ConfigType::GeminiSettings,
889 );
890 push(
891 &mut targets,
892 "Antigravity",
893 home.join(".gemini/antigravity/mcp_config.json"),
894 ConfigType::McpJson,
895 );
896 }
897 "antigravity" => push(
898 &mut targets,
899 "Antigravity",
900 home.join(".gemini/antigravity/mcp_config.json"),
901 ConfigType::McpJson,
902 ),
903 "copilot" => push(
904 &mut targets,
905 "VS Code / Copilot",
906 crate::core::editor_registry::vscode_mcp_path(),
907 ConfigType::VsCodeMcp,
908 ),
909 "crush" => push(
910 &mut targets,
911 "Crush",
912 home.join(".config/crush/crush.json"),
913 ConfigType::Crush,
914 ),
915 "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
916 "qoder" => {
917 for path in crate::core::editor_registry::qoder_all_mcp_paths(home) {
918 push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
919 }
920 }
921 "qoderwork" => push(
922 &mut targets,
923 "QoderWork",
924 crate::core::editor_registry::qoderwork_mcp_path(home),
925 ConfigType::McpJson,
926 ),
927 "cline" => push(
928 &mut targets,
929 "Cline",
930 crate::core::editor_registry::cline_mcp_path(),
931 ConfigType::McpJson,
932 ),
933 "roo" => push(
934 &mut targets,
935 "Roo Code",
936 crate::core::editor_registry::roo_mcp_path(),
937 ConfigType::McpJson,
938 ),
939 "kiro" => push(
940 &mut targets,
941 "AWS Kiro",
942 home.join(".kiro/settings/mcp.json"),
943 ConfigType::McpJson,
944 ),
945 "verdent" => push(
946 &mut targets,
947 "Verdent",
948 home.join(".verdent/mcp.json"),
949 ConfigType::McpJson,
950 ),
951 "jetbrains" | "amp" => {
952 }
954 "qwen" => push(
955 &mut targets,
956 "Qwen Code",
957 home.join(".qwen/settings.json"),
958 ConfigType::McpJson,
959 ),
960 "trae" => push(
961 &mut targets,
962 "Trae",
963 home.join(".trae/mcp.json"),
964 ConfigType::McpJson,
965 ),
966 "amazonq" => push(
967 &mut targets,
968 "Amazon Q Developer",
969 home.join(".aws/amazonq/default.json"),
970 ConfigType::McpJson,
971 ),
972 "opencode" => {
973 #[cfg(windows)]
974 let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
975 std::path::PathBuf::from(appdata)
976 .join("opencode")
977 .join("opencode.json")
978 } else {
979 home.join(".config/opencode/opencode.json")
980 };
981 #[cfg(not(windows))]
982 let opencode_path = home.join(".config/opencode/opencode.json");
983 push(
984 &mut targets,
985 "OpenCode",
986 opencode_path,
987 ConfigType::OpenCode,
988 );
989 }
990 "hermes" => push(
991 &mut targets,
992 "Hermes Agent",
993 home.join(".hermes/config.yaml"),
994 ConfigType::HermesYaml,
995 ),
996 "vscode" => push(
997 &mut targets,
998 "VS Code",
999 crate::core::editor_registry::vscode_mcp_path(),
1000 ConfigType::VsCodeMcp,
1001 ),
1002 "zed" => push(
1003 &mut targets,
1004 "Zed",
1005 crate::core::editor_registry::zed_settings_path(home),
1006 ConfigType::Zed,
1007 ),
1008 "aider" => push(
1009 &mut targets,
1010 "Aider",
1011 home.join(".aider/mcp.json"),
1012 ConfigType::McpJson,
1013 ),
1014 "continue" => push(
1015 &mut targets,
1016 "Continue",
1017 home.join(".continue/mcp.json"),
1018 ConfigType::McpJson,
1019 ),
1020 "neovim" => push(
1021 &mut targets,
1022 "Neovim (mcphub.nvim)",
1023 home.join(".config/mcphub/servers.json"),
1024 ConfigType::McpJson,
1025 ),
1026 "emacs" => push(
1027 &mut targets,
1028 "Emacs (mcp.el)",
1029 home.join(".emacs.d/mcp.json"),
1030 ConfigType::McpJson,
1031 ),
1032 "sublime" => push(
1033 &mut targets,
1034 "Sublime Text",
1035 home.join(".config/sublime-text/mcp.json"),
1036 ConfigType::McpJson,
1037 ),
1038 _ => {
1039 return Err(format!("Unknown agent '{agent}'"));
1040 }
1041 }
1042
1043 Ok(targets)
1044}
1045
1046pub fn disable_agent_mcp(agent: &str, overwrite_invalid: bool) -> Result<(), String> {
1047 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
1048
1049 let mut targets = Vec::<EditorTarget>::new();
1050
1051 let push = |targets: &mut Vec<EditorTarget>,
1052 name: &'static str,
1053 config_path: PathBuf,
1054 config_type: ConfigType| {
1055 targets.push(EditorTarget {
1056 name,
1057 agent_key: agent.to_string(),
1058 detect_path: PathBuf::from("/nonexistent"),
1059 config_path,
1060 config_type,
1061 });
1062 };
1063
1064 let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
1065
1066 match agent {
1067 "cursor" => push(
1068 &mut targets,
1069 "Cursor",
1070 home.join(".cursor/mcp.json"),
1071 ConfigType::McpJson,
1072 ),
1073 "claude" | "claude-code" => push(
1074 &mut targets,
1075 "Claude Code",
1076 crate::core::editor_registry::claude_mcp_json_path(&home),
1077 ConfigType::McpJson,
1078 ),
1079 "windsurf" => push(
1080 &mut targets,
1081 "Windsurf",
1082 home.join(".codeium/windsurf/mcp_config.json"),
1083 ConfigType::McpJson,
1084 ),
1085 "codex" => push(
1086 &mut targets,
1087 "Codex CLI",
1088 home.join(".codex/config.toml"),
1089 ConfigType::Codex,
1090 ),
1091 "gemini" => {
1092 push(
1093 &mut targets,
1094 "Gemini CLI",
1095 home.join(".gemini/settings.json"),
1096 ConfigType::GeminiSettings,
1097 );
1098 push(
1099 &mut targets,
1100 "Antigravity",
1101 home.join(".gemini/antigravity/mcp_config.json"),
1102 ConfigType::McpJson,
1103 );
1104 }
1105 "antigravity" => push(
1106 &mut targets,
1107 "Antigravity",
1108 home.join(".gemini/antigravity/mcp_config.json"),
1109 ConfigType::McpJson,
1110 ),
1111 "copilot" => push(
1112 &mut targets,
1113 "VS Code / Copilot",
1114 crate::core::editor_registry::vscode_mcp_path(),
1115 ConfigType::VsCodeMcp,
1116 ),
1117 "crush" => push(
1118 &mut targets,
1119 "Crush",
1120 home.join(".config/crush/crush.json"),
1121 ConfigType::Crush,
1122 ),
1123 "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
1124 "qoder" => {
1125 for path in crate::core::editor_registry::qoder_all_mcp_paths(&home) {
1126 push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
1127 }
1128 }
1129 "qoderwork" => push(
1130 &mut targets,
1131 "QoderWork",
1132 crate::core::editor_registry::qoderwork_mcp_path(&home),
1133 ConfigType::McpJson,
1134 ),
1135 "cline" => push(
1136 &mut targets,
1137 "Cline",
1138 crate::core::editor_registry::cline_mcp_path(),
1139 ConfigType::McpJson,
1140 ),
1141 "roo" => push(
1142 &mut targets,
1143 "Roo Code",
1144 crate::core::editor_registry::roo_mcp_path(),
1145 ConfigType::McpJson,
1146 ),
1147 "kiro" => push(
1148 &mut targets,
1149 "AWS Kiro",
1150 home.join(".kiro/settings/mcp.json"),
1151 ConfigType::McpJson,
1152 ),
1153 "verdent" => push(
1154 &mut targets,
1155 "Verdent",
1156 home.join(".verdent/mcp.json"),
1157 ConfigType::McpJson,
1158 ),
1159 "jetbrains" | "amp" => {
1160 }
1162 "qwen" => push(
1163 &mut targets,
1164 "Qwen Code",
1165 home.join(".qwen/settings.json"),
1166 ConfigType::McpJson,
1167 ),
1168 "trae" => push(
1169 &mut targets,
1170 "Trae",
1171 home.join(".trae/mcp.json"),
1172 ConfigType::McpJson,
1173 ),
1174 "amazonq" => push(
1175 &mut targets,
1176 "Amazon Q Developer",
1177 home.join(".aws/amazonq/default.json"),
1178 ConfigType::McpJson,
1179 ),
1180 "opencode" => {
1181 #[cfg(windows)]
1182 let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
1183 std::path::PathBuf::from(appdata)
1184 .join("opencode")
1185 .join("opencode.json")
1186 } else {
1187 home.join(".config/opencode/opencode.json")
1188 };
1189 #[cfg(not(windows))]
1190 let opencode_path = home.join(".config/opencode/opencode.json");
1191 push(
1192 &mut targets,
1193 "OpenCode",
1194 opencode_path,
1195 ConfigType::OpenCode,
1196 );
1197 }
1198 "hermes" => push(
1199 &mut targets,
1200 "Hermes Agent",
1201 home.join(".hermes/config.yaml"),
1202 ConfigType::HermesYaml,
1203 ),
1204 "vscode" => push(
1205 &mut targets,
1206 "VS Code",
1207 crate::core::editor_registry::vscode_mcp_path(),
1208 ConfigType::VsCodeMcp,
1209 ),
1210 "zed" => push(
1211 &mut targets,
1212 "Zed",
1213 crate::core::editor_registry::zed_settings_path(&home),
1214 ConfigType::Zed,
1215 ),
1216 "aider" => push(
1217 &mut targets,
1218 "Aider",
1219 home.join(".aider/mcp.json"),
1220 ConfigType::McpJson,
1221 ),
1222 "continue" => push(
1223 &mut targets,
1224 "Continue",
1225 home.join(".continue/mcp.json"),
1226 ConfigType::McpJson,
1227 ),
1228 "neovim" => push(
1229 &mut targets,
1230 "Neovim (mcphub.nvim)",
1231 home.join(".config/mcphub/servers.json"),
1232 ConfigType::McpJson,
1233 ),
1234 "emacs" => push(
1235 &mut targets,
1236 "Emacs (mcp.el)",
1237 home.join(".emacs.d/mcp.json"),
1238 ConfigType::McpJson,
1239 ),
1240 "sublime" => push(
1241 &mut targets,
1242 "Sublime Text",
1243 home.join(".config/sublime-text/mcp.json"),
1244 ConfigType::McpJson,
1245 ),
1246 _ => {
1247 return Err(format!("Unknown agent '{agent}'"));
1248 }
1249 }
1250
1251 for t in &targets {
1252 crate::core::editor_registry::remove_lean_ctx_server(
1253 t,
1254 WriteOptions { overwrite_invalid },
1255 )?;
1256 }
1257
1258 Ok(())
1259}
1260
1261pub fn install_skill_files(home: &std::path::Path) -> Vec<(String, bool)> {
1262 crate::rules_inject::install_all_skills(home)
1263}
1264
1265fn install_kiro_steering(home: &std::path::Path) {
1266 let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
1267 let steering_dir = cwd.join(".kiro").join("steering");
1268 let steering_file = steering_dir.join("lean-ctx.md");
1269
1270 if steering_file.exists()
1271 && std::fs::read_to_string(&steering_file)
1272 .unwrap_or_default()
1273 .contains("lean-ctx")
1274 {
1275 println!(" Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1276 return;
1277 }
1278
1279 let _ = std::fs::create_dir_all(&steering_dir);
1280 let _ = std::fs::write(&steering_file, crate::hooks::KIRO_STEERING_TEMPLATE);
1281 println!(" \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1282}
1283
1284fn shorten_path(path: &str, home: &str) -> String {
1285 if let Some(stripped) = path.strip_prefix(home) {
1286 format!("~{stripped}")
1287 } else {
1288 path.to_string()
1289 }
1290}
1291
1292fn upsert_toml_key(content: &mut String, key: &str, value: &str) {
1293 let pattern = format!("{key} = ");
1294 if let Some(start) = content.find(&pattern) {
1295 let line_end = content[start..]
1296 .find('\n')
1297 .map_or(content.len(), |p| start + p);
1298 content.replace_range(start..line_end, &format!("{key} = \"{value}\""));
1299 } else {
1300 if !content.is_empty() && !content.ends_with('\n') {
1301 content.push('\n');
1302 }
1303 content.push_str(&format!("{key} = \"{value}\"\n"));
1304 }
1305}
1306
1307fn configure_premium_features(home: &std::path::Path) {
1308 use crate::terminal_ui;
1309 use std::io::Write;
1310
1311 let config_dir = home.join(".lean-ctx");
1312 let _ = std::fs::create_dir_all(&config_dir);
1313 let config_path = config_dir.join("config.toml");
1314 let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
1315
1316 let dim = "\x1b[2m";
1317 let bold = "\x1b[1m";
1318 let rst = "\x1b[0m";
1319
1320 println!(
1322 "\n {bold}Agent Output Optimization{rst} {dim}(reduces output tokens by 40-70%){rst}"
1323 );
1324 println!(
1325 " {dim}Levels: lite (concise), full (max density), ultra (expert pair-programmer){rst}"
1326 );
1327 print!(" Terse agent mode? {bold}[off/lite/full/ultra]{rst} {dim}(default: off){rst} ");
1328 std::io::stdout().flush().ok();
1329
1330 let mut terse_input = String::new();
1331 let terse_level = if std::io::stdin().read_line(&mut terse_input).is_ok() {
1332 match terse_input.trim().to_lowercase().as_str() {
1333 "lite" => "lite",
1334 "full" => "full",
1335 "ultra" => "ultra",
1336 _ => "off",
1337 }
1338 } else {
1339 "off"
1340 };
1341
1342 if terse_level != "off" {
1343 upsert_toml_key(&mut config_content, "terse_agent", terse_level);
1344 terminal_ui::print_status_ok(&format!("Terse agent: {terse_level}"));
1345 } else if config_content.contains("terse_agent") {
1346 upsert_toml_key(&mut config_content, "terse_agent", "off");
1347 terminal_ui::print_status_ok("Terse agent: off");
1348 } else {
1349 terminal_ui::print_status_skip(
1350 "Terse agent: off (change later with: lean-ctx terse <level>)",
1351 );
1352 }
1353
1354 println!(
1356 "\n {bold}Tool Result Archive{rst} {dim}(zero-loss: large outputs archived, retrievable via ctx_expand){rst}"
1357 );
1358 print!(" Enable auto-archive? {bold}[Y/n]{rst} ");
1359 std::io::stdout().flush().ok();
1360
1361 let mut archive_input = String::new();
1362 let archive_on = if std::io::stdin().read_line(&mut archive_input).is_ok() {
1363 let a = archive_input.trim().to_lowercase();
1364 a.is_empty() || a == "y" || a == "yes"
1365 } else {
1366 true
1367 };
1368
1369 if archive_on && !config_content.contains("[archive]") {
1370 if !config_content.is_empty() && !config_content.ends_with('\n') {
1371 config_content.push('\n');
1372 }
1373 config_content.push_str("\n[archive]\nenabled = true\n");
1374 terminal_ui::print_status_ok("Tool Result Archive: enabled");
1375 } else if !archive_on {
1376 terminal_ui::print_status_skip("Archive: off (enable later in config.toml)");
1377 }
1378
1379 println!(
1381 "\n {bold}Output Density{rst} {dim}(compresses tool output: normal, terse, ultra){rst}"
1382 );
1383 print!(" Output density? {bold}[normal/terse/ultra]{rst} {dim}(default: normal){rst} ");
1384 std::io::stdout().flush().ok();
1385
1386 let mut density_input = String::new();
1387 let density = if std::io::stdin().read_line(&mut density_input).is_ok() {
1388 match density_input.trim().to_lowercase().as_str() {
1389 "terse" => "terse",
1390 "ultra" => "ultra",
1391 _ => "normal",
1392 }
1393 } else {
1394 "normal"
1395 };
1396
1397 if density != "normal" {
1398 upsert_toml_key(&mut config_content, "output_density", density);
1399 terminal_ui::print_status_ok(&format!("Output density: {density}"));
1400 } else if config_content.contains("output_density") {
1401 upsert_toml_key(&mut config_content, "output_density", "normal");
1402 terminal_ui::print_status_ok("Output density: normal");
1403 } else {
1404 terminal_ui::print_status_skip("Output density: normal (change later in config.toml)");
1405 }
1406
1407 let _ = std::fs::write(&config_path, config_content);
1408}
1409
1410#[cfg(all(test, target_os = "macos"))]
1411mod tests {
1412 use super::*;
1413
1414 #[test]
1415 #[cfg(target_os = "macos")]
1416 fn qoder_agent_targets_include_all_macos_mcp_locations() {
1417 let home = std::path::Path::new("/Users/tester");
1418 let targets = agent_mcp_targets("qoder", home).unwrap();
1419 let paths: Vec<_> = targets.iter().map(|t| t.config_path.as_path()).collect();
1420
1421 assert_eq!(
1422 paths,
1423 vec![
1424 home.join(".qoder/mcp.json").as_path(),
1425 home.join("Library/Application Support/Qoder/User/mcp.json")
1426 .as_path(),
1427 home.join("Library/Application Support/Qoder/SharedClientCache/mcp.json")
1428 .as_path(),
1429 ]
1430 );
1431 assert!(targets
1432 .iter()
1433 .all(|t| t.config_type == ConfigType::QoderSettings));
1434 }
1435}