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 chrono::Utc;
7
8pub fn claude_config_json_path(home: &std::path::Path) -> PathBuf {
9 crate::core::editor_registry::claude_mcp_json_path(home)
10}
11
12pub fn claude_config_dir(home: &std::path::Path) -> PathBuf {
13 crate::core::editor_registry::claude_state_dir(home)
14}
15
16pub fn run_setup() {
17 use crate::terminal_ui;
18
19 if crate::shell::is_non_interactive() {
20 eprintln!("Non-interactive terminal detected (no TTY on stdin).");
21 eprintln!("Running in non-interactive mode (equivalent to: lean-ctx setup --non-interactive --yes)");
22 eprintln!();
23 let opts = SetupOptions {
24 non_interactive: true,
25 yes: true,
26 fix: false,
27 json: false,
28 };
29 match run_setup_with_options(opts) {
30 Ok(report) => {
31 if !report.warnings.is_empty() {
32 for w in &report.warnings {
33 eprintln!(" warning: {w}");
34 }
35 }
36 }
37 Err(e) => eprintln!("Setup error: {e}"),
38 }
39 return;
40 }
41
42 let home = match dirs::home_dir() {
43 Some(h) => h,
44 None => {
45 eprintln!("Cannot determine home directory");
46 std::process::exit(1);
47 }
48 };
49
50 let binary = resolve_portable_binary();
51
52 let home_str = home.to_string_lossy().to_string();
53
54 terminal_ui::print_setup_header();
55
56 terminal_ui::print_step_header(1, 6, "Shell Hook");
58 crate::cli::cmd_init(&["--global".to_string()]);
59 crate::shell_hook::install_all(false);
60
61 terminal_ui::print_step_header(2, 6, "AI Tool Detection");
63
64 let targets = crate::core::editor_registry::build_targets(&home);
65 let mut newly_configured: Vec<&str> = Vec::new();
66 let mut already_configured: Vec<&str> = Vec::new();
67 let mut not_installed: Vec<&str> = Vec::new();
68 let mut errors: Vec<&str> = Vec::new();
69
70 for target in &targets {
71 let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
72
73 if !target.detect_path.exists() {
74 not_installed.push(target.name);
75 continue;
76 }
77
78 match crate::core::editor_registry::write_config_with_options(
79 target,
80 &binary,
81 WriteOptions {
82 overwrite_invalid: false,
83 },
84 ) {
85 Ok(res) if res.action == WriteAction::Already => {
86 terminal_ui::print_status_ok(&format!(
87 "{:<20} \x1b[2m{short_path}\x1b[0m",
88 target.name
89 ));
90 already_configured.push(target.name);
91 }
92 Ok(_) => {
93 terminal_ui::print_status_new(&format!(
94 "{:<20} \x1b[2m{short_path}\x1b[0m",
95 target.name
96 ));
97 newly_configured.push(target.name);
98 }
99 Err(e) => {
100 terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
101 errors.push(target.name);
102 }
103 }
104 }
105
106 let total_ok = newly_configured.len() + already_configured.len();
107 if total_ok == 0 && errors.is_empty() {
108 terminal_ui::print_status_warn(
109 "No AI tools detected. Install one and re-run: lean-ctx setup",
110 );
111 }
112
113 if !not_installed.is_empty() {
114 println!(
115 " \x1b[2m○ {} not detected: {}\x1b[0m",
116 not_installed.len(),
117 not_installed.join(", ")
118 );
119 }
120
121 terminal_ui::print_step_header(3, 6, "Agent Rules");
123 let rules_result = crate::rules_inject::inject_all_rules(&home);
124 for name in &rules_result.injected {
125 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules injected\x1b[0m"));
126 }
127 for name in &rules_result.updated {
128 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules updated\x1b[0m"));
129 }
130 for name in &rules_result.already {
131 terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mrules up-to-date\x1b[0m"));
132 }
133 for err in &rules_result.errors {
134 terminal_ui::print_status_warn(err);
135 }
136 if rules_result.injected.is_empty()
137 && rules_result.updated.is_empty()
138 && rules_result.already.is_empty()
139 && rules_result.errors.is_empty()
140 {
141 terminal_ui::print_status_skip("No agent rules needed");
142 }
143
144 for target in &targets {
146 if !target.detect_path.exists() || target.agent_key.is_empty() {
147 continue;
148 }
149 crate::hooks::install_agent_hook(&target.agent_key, true);
150 }
151
152 terminal_ui::print_step_header(4, 6, "API Proxy");
154 crate::proxy_setup::install_proxy_env(&home, crate::proxy_setup::default_port(), false);
155 println!();
156 println!(" \x1b[2mStart proxy for maximum token savings:\x1b[0m");
157 println!(" \x1b[1mlean-ctx proxy start\x1b[0m");
158 println!(" \x1b[2mEnable autostart:\x1b[0m");
159 println!(" \x1b[1mlean-ctx proxy start --autostart\x1b[0m");
160
161 terminal_ui::print_step_header(5, 6, "Environment Check");
163 let lean_dir = home.join(".lean-ctx");
164 if !lean_dir.exists() {
165 let _ = std::fs::create_dir_all(&lean_dir);
166 terminal_ui::print_status_new("Created ~/.lean-ctx/");
167 } else {
168 terminal_ui::print_status_ok("~/.lean-ctx/ ready");
169 }
170 crate::doctor::run_compact();
171
172 terminal_ui::print_step_header(6, 6, "Help Improve lean-ctx");
174 println!(" Share anonymous compression stats to make lean-ctx better.");
175 println!(" \x1b[1mNo code, no file names, no personal data — ever.\x1b[0m");
176 println!();
177 print!(" Enable anonymous data sharing? \x1b[1m[Y/n]\x1b[0m ");
178 use std::io::Write;
179 std::io::stdout().flush().ok();
180
181 let mut input = String::new();
182 let contribute = if std::io::stdin().read_line(&mut input).is_ok() {
183 let answer = input.trim().to_lowercase();
184 answer.is_empty() || answer == "y" || answer == "yes"
185 } else {
186 false
187 };
188
189 if contribute {
190 let config_dir = home.join(".lean-ctx");
191 let _ = std::fs::create_dir_all(&config_dir);
192 let config_path = config_dir.join("config.toml");
193 let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
194 if !config_content.contains("[cloud]") {
195 if !config_content.is_empty() && !config_content.ends_with('\n') {
196 config_content.push('\n');
197 }
198 config_content.push_str("\n[cloud]\ncontribute_enabled = true\n");
199 let _ = std::fs::write(&config_path, config_content);
200 }
201 terminal_ui::print_status_ok("Enabled — thank you!");
202 } else {
203 terminal_ui::print_status_skip("Skipped — enable later with: lean-ctx config");
204 }
205
206 println!();
208 println!(
209 " \x1b[1;32m✓ Setup complete!\x1b[0m \x1b[1m{}\x1b[0m configured, \x1b[2m{} already set, {} skipped\x1b[0m",
210 newly_configured.len(),
211 already_configured.len(),
212 not_installed.len()
213 );
214
215 if !errors.is_empty() {
216 println!(
217 " \x1b[33m⚠ {} error{}: {}\x1b[0m",
218 errors.len(),
219 if errors.len() != 1 { "s" } else { "" },
220 errors.join(", ")
221 );
222 }
223
224 let shell = std::env::var("SHELL").unwrap_or_default();
226 let source_cmd = if shell.contains("zsh") {
227 "source ~/.zshrc"
228 } else if shell.contains("fish") {
229 "source ~/.config/fish/config.fish"
230 } else if shell.contains("bash") {
231 "source ~/.bashrc"
232 } else {
233 "Restart your shell"
234 };
235
236 let dim = "\x1b[2m";
237 let bold = "\x1b[1m";
238 let cyan = "\x1b[36m";
239 let yellow = "\x1b[33m";
240 let rst = "\x1b[0m";
241
242 println!();
243 println!(" {bold}Next steps:{rst}");
244 println!();
245 println!(" {cyan}1.{rst} Reload your shell:");
246 println!(" {bold}{source_cmd}{rst}");
247 println!();
248
249 let mut tools_to_restart: Vec<String> =
250 newly_configured.iter().map(|s| s.to_string()).collect();
251 for name in rules_result
252 .injected
253 .iter()
254 .chain(rules_result.updated.iter())
255 {
256 if !tools_to_restart.iter().any(|t| t == name) {
257 tools_to_restart.push(name.clone());
258 }
259 }
260
261 if !tools_to_restart.is_empty() {
262 println!(" {cyan}2.{rst} {yellow}{bold}Restart your IDE / AI tool:{rst}");
263 println!(" {bold}{}{rst}", tools_to_restart.join(", "));
264 println!(
265 " {dim}The MCP connection must be re-established for changes to take effect.{rst}"
266 );
267 println!(" {dim}Close and re-open the application completely.{rst}");
268 } else if !already_configured.is_empty() {
269 println!(
270 " {cyan}2.{rst} {dim}Your tools are already configured — no restart needed.{rst}"
271 );
272 }
273
274 println!();
275 println!(
276 " {dim}After restart, lean-ctx will automatically optimize every AI interaction.{rst}"
277 );
278 println!(" {dim}Verify with:{rst} {bold}lean-ctx gain{rst}");
279
280 println!();
282 terminal_ui::print_logo_animated();
283 terminal_ui::print_command_box();
284}
285
286#[derive(Debug, Clone, Copy, Default)]
287pub struct SetupOptions {
288 pub non_interactive: bool,
289 pub yes: bool,
290 pub fix: bool,
291 pub json: bool,
292}
293
294pub fn run_setup_with_options(opts: SetupOptions) -> Result<SetupReport, String> {
295 let started_at = Utc::now();
296 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
297 let binary = resolve_portable_binary();
298 let home_str = home.to_string_lossy().to_string();
299
300 let mut steps: Vec<SetupStepReport> = Vec::new();
301
302 let mut shell_step = SetupStepReport {
304 name: "shell_hook".to_string(),
305 ok: true,
306 items: Vec::new(),
307 warnings: Vec::new(),
308 errors: Vec::new(),
309 };
310 if !opts.non_interactive || opts.yes {
311 if opts.json {
312 crate::cli::cmd_init_quiet(&["--global".to_string()]);
313 } else {
314 crate::cli::cmd_init(&["--global".to_string()]);
315 }
316 crate::shell_hook::install_all(opts.json);
317 shell_step.items.push(SetupItem {
318 name: "init --global".to_string(),
319 status: "ran".to_string(),
320 path: None,
321 note: None,
322 });
323 shell_step.items.push(SetupItem {
324 name: "universal_shell_hook".to_string(),
325 status: "installed".to_string(),
326 path: None,
327 note: Some("~/.zshenv, ~/.bashenv, agent aliases".to_string()),
328 });
329 } else {
330 shell_step
331 .warnings
332 .push("non_interactive_without_yes: shell hook not installed (use --yes)".to_string());
333 shell_step.ok = false;
334 shell_step.items.push(SetupItem {
335 name: "init --global".to_string(),
336 status: "skipped".to_string(),
337 path: None,
338 note: Some("requires --yes in --non-interactive mode".to_string()),
339 });
340 }
341 steps.push(shell_step);
342
343 let mut editor_step = SetupStepReport {
345 name: "editors".to_string(),
346 ok: true,
347 items: Vec::new(),
348 warnings: Vec::new(),
349 errors: Vec::new(),
350 };
351
352 let targets = crate::core::editor_registry::build_targets(&home);
353 for target in &targets {
354 let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
355 if !target.detect_path.exists() {
356 editor_step.items.push(SetupItem {
357 name: target.name.to_string(),
358 status: "not_detected".to_string(),
359 path: Some(short_path),
360 note: None,
361 });
362 continue;
363 }
364
365 let res = crate::core::editor_registry::write_config_with_options(
366 target,
367 &binary,
368 WriteOptions {
369 overwrite_invalid: opts.fix,
370 },
371 );
372 match res {
373 Ok(w) => {
374 editor_step.items.push(SetupItem {
375 name: target.name.to_string(),
376 status: match w.action {
377 WriteAction::Created => "created".to_string(),
378 WriteAction::Updated => "updated".to_string(),
379 WriteAction::Already => "already".to_string(),
380 },
381 path: Some(short_path),
382 note: w.note,
383 });
384 }
385 Err(e) => {
386 editor_step.ok = false;
387 editor_step.items.push(SetupItem {
388 name: target.name.to_string(),
389 status: "error".to_string(),
390 path: Some(short_path),
391 note: Some(e),
392 });
393 }
394 }
395 }
396 steps.push(editor_step);
397
398 let mut rules_step = SetupStepReport {
400 name: "agent_rules".to_string(),
401 ok: true,
402 items: Vec::new(),
403 warnings: Vec::new(),
404 errors: Vec::new(),
405 };
406 let rules_result = crate::rules_inject::inject_all_rules(&home);
407 for n in rules_result.injected {
408 rules_step.items.push(SetupItem {
409 name: n,
410 status: "injected".to_string(),
411 path: None,
412 note: None,
413 });
414 }
415 for n in rules_result.updated {
416 rules_step.items.push(SetupItem {
417 name: n,
418 status: "updated".to_string(),
419 path: None,
420 note: None,
421 });
422 }
423 for n in rules_result.already {
424 rules_step.items.push(SetupItem {
425 name: n,
426 status: "already".to_string(),
427 path: None,
428 note: None,
429 });
430 }
431 for e in rules_result.errors {
432 rules_step.ok = false;
433 rules_step.errors.push(e);
434 }
435 steps.push(rules_step);
436
437 let mut hooks_step = SetupStepReport {
439 name: "agent_hooks".to_string(),
440 ok: true,
441 items: Vec::new(),
442 warnings: Vec::new(),
443 errors: Vec::new(),
444 };
445 for target in &targets {
446 if !target.detect_path.exists() {
447 continue;
448 }
449 match target.agent_key.as_str() {
450 "codex" => {
451 crate::hooks::agents::install_codex_hook();
452 hooks_step.items.push(SetupItem {
453 name: "Codex hooks".to_string(),
454 status: "installed".to_string(),
455 path: Some("~/.codex/hooks/".to_string()),
456 note: None,
457 });
458 }
459 "cursor" => {
460 let hooks_path = home.join(".cursor/hooks.json");
461 if !hooks_path.exists() {
462 crate::hooks::agents::install_cursor_hook(true);
463 hooks_step.items.push(SetupItem {
464 name: "Cursor hooks".to_string(),
465 status: "installed".to_string(),
466 path: Some("~/.cursor/hooks.json".to_string()),
467 note: None,
468 });
469 }
470 }
471 _ => {}
472 }
473 }
474 if !hooks_step.items.is_empty() {
475 steps.push(hooks_step);
476 }
477
478 let mut proxy_step = SetupStepReport {
480 name: "proxy_env".to_string(),
481 ok: true,
482 items: Vec::new(),
483 warnings: Vec::new(),
484 errors: Vec::new(),
485 };
486 crate::proxy_setup::install_proxy_env(&home, crate::proxy_setup::default_port(), opts.json);
487 proxy_step.items.push(SetupItem {
488 name: "proxy_env".to_string(),
489 status: "configured".to_string(),
490 path: None,
491 note: Some("ANTHROPIC_BASE_URL, OPENAI_BASE_URL, GEMINI_API_BASE_URL".to_string()),
492 });
493 steps.push(proxy_step);
494
495 let mut env_step = SetupStepReport {
497 name: "doctor_compact".to_string(),
498 ok: true,
499 items: Vec::new(),
500 warnings: Vec::new(),
501 errors: Vec::new(),
502 };
503 let (passed, total) = crate::doctor::compact_score();
504 env_step.items.push(SetupItem {
505 name: "doctor".to_string(),
506 status: format!("{passed}/{total}"),
507 path: None,
508 note: None,
509 });
510 if passed != total {
511 env_step.warnings.push(format!(
512 "doctor compact not fully passing: {passed}/{total}"
513 ));
514 }
515 steps.push(env_step);
516
517 let finished_at = Utc::now();
518 let success = steps.iter().all(|s| s.ok);
519 let report = SetupReport {
520 schema_version: 1,
521 started_at,
522 finished_at,
523 success,
524 platform: PlatformInfo {
525 os: std::env::consts::OS.to_string(),
526 arch: std::env::consts::ARCH.to_string(),
527 },
528 steps,
529 warnings: Vec::new(),
530 errors: Vec::new(),
531 };
532
533 let path = SetupReport::default_path()?;
534 let mut content =
535 serde_json::to_string_pretty(&report).map_err(|e| format!("serialize report: {e}"))?;
536 content.push('\n');
537 crate::config_io::write_atomic(&path, &content)?;
538
539 Ok(report)
540}
541
542pub fn configure_agent_mcp(agent: &str) -> Result<(), String> {
543 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
544 let binary = resolve_portable_binary();
545
546 let mut targets = Vec::<EditorTarget>::new();
547
548 let push = |targets: &mut Vec<EditorTarget>,
549 name: &'static str,
550 config_path: PathBuf,
551 config_type: ConfigType| {
552 targets.push(EditorTarget {
553 name,
554 agent_key: agent.to_string(),
555 detect_path: PathBuf::from("/nonexistent"), config_path,
557 config_type,
558 });
559 };
560
561 match agent {
562 "cursor" => push(
563 &mut targets,
564 "Cursor",
565 home.join(".cursor/mcp.json"),
566 ConfigType::McpJson,
567 ),
568 "claude" | "claude-code" => push(
569 &mut targets,
570 "Claude Code",
571 crate::core::editor_registry::claude_mcp_json_path(&home),
572 ConfigType::McpJson,
573 ),
574 "windsurf" => push(
575 &mut targets,
576 "Windsurf",
577 home.join(".codeium/windsurf/mcp_config.json"),
578 ConfigType::McpJson,
579 ),
580 "codex" => push(
581 &mut targets,
582 "Codex CLI",
583 home.join(".codex/config.toml"),
584 ConfigType::Codex,
585 ),
586 "gemini" => {
587 push(
588 &mut targets,
589 "Gemini CLI",
590 home.join(".gemini/settings.json"),
591 ConfigType::GeminiSettings,
592 );
593 push(
594 &mut targets,
595 "Antigravity",
596 home.join(".gemini/antigravity/mcp_config.json"),
597 ConfigType::McpJson,
598 );
599 }
600 "antigravity" => push(
601 &mut targets,
602 "Antigravity",
603 home.join(".gemini/antigravity/mcp_config.json"),
604 ConfigType::McpJson,
605 ),
606 "copilot" => push(
607 &mut targets,
608 "VS Code / Copilot",
609 crate::core::editor_registry::vscode_mcp_path(),
610 ConfigType::VsCodeMcp,
611 ),
612 "crush" => push(
613 &mut targets,
614 "Crush",
615 home.join(".config/crush/crush.json"),
616 ConfigType::Crush,
617 ),
618 "pi" => push(
619 &mut targets,
620 "Pi Coding Agent",
621 home.join(".pi/agent/mcp.json"),
622 ConfigType::McpJson,
623 ),
624 "cline" => push(
625 &mut targets,
626 "Cline",
627 crate::core::editor_registry::cline_mcp_path(),
628 ConfigType::McpJson,
629 ),
630 "roo" => push(
631 &mut targets,
632 "Roo Code",
633 crate::core::editor_registry::roo_mcp_path(),
634 ConfigType::McpJson,
635 ),
636 "kiro" => push(
637 &mut targets,
638 "AWS Kiro",
639 home.join(".kiro/settings/mcp.json"),
640 ConfigType::McpJson,
641 ),
642 "verdent" => push(
643 &mut targets,
644 "Verdent",
645 home.join(".verdent/mcp.json"),
646 ConfigType::McpJson,
647 ),
648 "jetbrains" => {
649 }
651 "qwen" => push(
652 &mut targets,
653 "Qwen Code",
654 home.join(".qwen/mcp.json"),
655 ConfigType::McpJson,
656 ),
657 "trae" => push(
658 &mut targets,
659 "Trae",
660 home.join(".trae/mcp.json"),
661 ConfigType::McpJson,
662 ),
663 "amazonq" => push(
664 &mut targets,
665 "Amazon Q Developer",
666 home.join(".aws/amazonq/mcp.json"),
667 ConfigType::McpJson,
668 ),
669 "opencode" => {
670 #[cfg(windows)]
671 let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
672 std::path::PathBuf::from(appdata)
673 .join("opencode")
674 .join("opencode.json")
675 } else {
676 home.join(".config/opencode/opencode.json")
677 };
678 #[cfg(not(windows))]
679 let opencode_path = home.join(".config/opencode/opencode.json");
680 push(
681 &mut targets,
682 "OpenCode",
683 opencode_path,
684 ConfigType::OpenCode,
685 );
686 }
687 "aider" => push(
688 &mut targets,
689 "Aider",
690 home.join(".aider/mcp.json"),
691 ConfigType::McpJson,
692 ),
693 "amp" => {
694 }
696 "hermes" => push(
697 &mut targets,
698 "Hermes Agent",
699 home.join(".hermes/config.yaml"),
700 ConfigType::HermesYaml,
701 ),
702 _ => {
703 return Err(format!("Unknown agent '{agent}'"));
704 }
705 }
706
707 for t in &targets {
708 crate::core::editor_registry::write_config_with_options(
709 t,
710 &binary,
711 WriteOptions {
712 overwrite_invalid: true,
713 },
714 )?;
715 }
716
717 if agent == "kiro" {
718 install_kiro_steering(&home);
719 }
720
721 Ok(())
722}
723
724fn install_kiro_steering(home: &std::path::Path) {
725 let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
726 let steering_dir = cwd.join(".kiro").join("steering");
727 let steering_file = steering_dir.join("lean-ctx.md");
728
729 if steering_file.exists()
730 && std::fs::read_to_string(&steering_file)
731 .unwrap_or_default()
732 .contains("lean-ctx")
733 {
734 println!(" Kiro steering file already exists at .kiro/steering/lean-ctx.md");
735 return;
736 }
737
738 let _ = std::fs::create_dir_all(&steering_dir);
739 let _ = std::fs::write(&steering_file, crate::hooks::KIRO_STEERING_TEMPLATE);
740 println!(" \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
741}
742
743fn shorten_path(path: &str, home: &str) -> String {
744 if let Some(stripped) = path.strip_prefix(home) {
745 format!("~{stripped}")
746 } else {
747 path.to_string()
748 }
749}