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::{HookMode, recommend_hook_mode};
7use chrono::Utc;
8use std::ffi::OsString;
9mod mcp;
10pub use mcp::*;
11mod helpers;
12pub use helpers::*;
13
14pub fn claude_config_json_path(home: &std::path::Path) -> PathBuf {
15 crate::core::editor_registry::claude_mcp_json_path(home)
16}
17
18pub fn claude_config_dir(home: &std::path::Path) -> PathBuf {
19 crate::core::editor_registry::claude_state_dir(home)
20}
21
22pub(crate) struct EnvVarGuard {
23 key: &'static str,
24 previous: Option<OsString>,
25}
26
27impl EnvVarGuard {
28 pub(crate) fn set(key: &'static str, value: &str) -> Self {
29 let previous = std::env::var_os(key);
30 unsafe { std::env::set_var(key, value) };
34 Self { key, previous }
35 }
36}
37
38impl Drop for EnvVarGuard {
39 fn drop(&mut self) {
40 if let Some(previous) = &self.previous {
41 unsafe { std::env::set_var(self.key, previous) };
44 } else {
45 unsafe { std::env::remove_var(self.key) };
48 }
49 }
50}
51
52fn first_run_setup_level() -> (bool, bool) {
55 use std::io::Write;
56
57 let cfg = crate::core::config::Config::load();
58 if cfg.setup.auto_inject_rules.is_some() {
59 return (
60 cfg.setup.should_inject_rules(),
61 cfg.setup.should_inject_skills(),
62 );
63 }
64
65 println!();
66 println!(" \x1b[1mWelcome to lean-ctx!\x1b[0m");
67 println!();
68 println!(" lean-ctx compresses AI context by 60-99%, saving tokens and money.");
69 println!();
70 println!(" Choose your setup level:");
71 println!(
72 " \x1b[36m[1]\x1b[0m Minimal \x1b[2m— Just MCP tools, no config file changes (recommended)\x1b[0m"
73 );
74 println!(
75 " \x1b[36m[2]\x1b[0m Standard \x1b[2m— MCP tools + agent instructions for optimal mode selection\x1b[0m"
76 );
77 println!(
78 " \x1b[36m[3]\x1b[0m Full \x1b[2m— Everything (tools + rules + skills + shell hooks)\x1b[0m"
79 );
80 println!();
81 print!(" Your choice \x1b[1m[1]\x1b[0m: ");
82 std::io::stdout().flush().ok();
83
84 let mut input = String::new();
85 let choice = if std::io::stdin().read_line(&mut input).is_ok() {
86 input.trim().parse::<u8>().unwrap_or(1)
87 } else {
88 1
89 };
90
91 match choice {
92 3 => (true, true),
93 2 => (true, false),
94 _ => (false, false),
95 }
96}
97
98fn persist_setup_choice(inject_rules: bool, inject_skills: bool) {
100 let mut cfg = crate::core::config::Config::load();
101 cfg.setup.auto_inject_rules = Some(inject_rules);
102 cfg.setup.auto_inject_skills = Some(inject_skills);
103 let _ = cfg.save();
104}
105
106pub fn run_setup() {
107 use crate::terminal_ui;
108
109 if crate::shell::is_non_interactive() {
110 eprintln!("Non-interactive terminal detected (no TTY on stdin).");
111 eprintln!(
112 "Running in non-interactive mode (equivalent to: lean-ctx setup --non-interactive --yes)"
113 );
114 eprintln!();
115 let opts = SetupOptions {
116 non_interactive: true,
117 yes: true,
118 ..Default::default()
119 };
120 match run_setup_with_options(opts) {
121 Ok(report) => {
122 if !report.warnings.is_empty() {
123 for w in &report.warnings {
124 tracing::warn!("{w}");
125 }
126 }
127 }
128 Err(e) => tracing::error!("Setup error: {e}"),
129 }
130 return;
131 }
132
133 let Some(home) = dirs::home_dir() else {
134 tracing::error!("Cannot determine home directory");
135 std::process::exit(1);
136 };
137
138 let binary = resolve_portable_binary();
139
140 let home_str = home.to_string_lossy().to_string();
141
142 terminal_ui::print_setup_header();
143
144 let (inject_rules, inject_skills) = first_run_setup_level();
145 persist_setup_choice(inject_rules, inject_skills);
146
147 terminal_ui::print_step_header(1, 12, "Shell Hook");
149 crate::cli::cmd_init(&["--global".to_string()]);
150 crate::shell_hook::install_all(false);
151
152 terminal_ui::print_step_header(2, 12, "Daemon");
154 if crate::daemon::is_daemon_running() {
155 terminal_ui::print_status_ok("Daemon running — restarting with current binary…");
156 let _ = crate::daemon::stop_daemon();
157 std::thread::sleep(std::time::Duration::from_millis(500));
158 if let Err(e) = crate::daemon::start_daemon(&[]) {
159 terminal_ui::print_status_warn(&format!("Daemon restart failed: {e}"));
160 }
161 } else if let Err(e) = crate::daemon::start_daemon(&[]) {
162 terminal_ui::print_status_warn(&format!("Daemon start failed: {e}"));
163 }
164
165 terminal_ui::print_step_header(3, 12, "AI Tool Detection");
167
168 let targets = crate::core::editor_registry::build_targets(&home);
169 let update_mcp = crate::core::config::Config::load()
173 .setup
174 .should_update_mcp();
175 let mut newly_configured: Vec<&str> = Vec::new();
176 let mut already_configured: Vec<&str> = Vec::new();
177 let mut not_installed: Vec<&str> = Vec::new();
178 let mut mcp_skipped: Vec<&str> = Vec::new();
179 let mut errors: Vec<&str> = Vec::new();
180
181 for target in &targets {
182 let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
183
184 if !target.detect_path.exists() {
185 not_installed.push(target.name);
186 continue;
187 }
188
189 if !update_mcp {
190 terminal_ui::print_status_ok(&format!(
191 "{:<20} \x1b[2mMCP registration skipped (auto_update_mcp=false)\x1b[0m",
192 target.name
193 ));
194 mcp_skipped.push(target.name);
195 continue;
196 }
197
198 let mode = if target.agent_key.is_empty() {
199 HookMode::Mcp
200 } else {
201 recommend_hook_mode(&target.agent_key)
202 };
203
204 match crate::core::editor_registry::write_config_with_options(
205 target,
206 &binary,
207 WriteOptions {
208 overwrite_invalid: false,
209 },
210 ) {
211 Ok(res) if res.action == WriteAction::Already => {
212 terminal_ui::print_status_ok(&format!(
213 "{:<20} \x1b[36m{mode}\x1b[0m \x1b[2m{short_path}\x1b[0m",
214 target.name
215 ));
216 already_configured.push(target.name);
217 }
218 Ok(_) => {
219 terminal_ui::print_status_new(&format!(
220 "{:<20} \x1b[36m{mode}\x1b[0m \x1b[2m{short_path}\x1b[0m",
221 target.name
222 ));
223 newly_configured.push(target.name);
224 }
225 Err(e) => {
226 terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
227 errors.push(target.name);
228 }
229 }
230 }
231
232 let total_ok = newly_configured.len() + already_configured.len();
233 if total_ok == 0 && errors.is_empty() && mcp_skipped.is_empty() {
234 terminal_ui::print_status_warn(
235 "No AI tools detected. Install one and re-run: lean-ctx setup",
236 );
237 }
238
239 if !not_installed.is_empty() {
240 println!(
241 " \x1b[2m○ {} not detected: {}\x1b[0m",
242 not_installed.len(),
243 not_installed.join(", ")
244 );
245 }
246
247 configure_plan_mode_settings(&newly_configured, &already_configured);
248
249 terminal_ui::print_step_header(4, 12, "Agent Rules");
251 let rules_result = if inject_rules {
252 let r = crate::rules_inject::inject_all_rules(&home);
253 for name in &r.injected {
254 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules injected\x1b[0m"));
255 }
256 for name in &r.updated {
257 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules updated\x1b[0m"));
258 }
259 for name in &r.already {
260 terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mrules up-to-date\x1b[0m"));
261 }
262 for err in &r.errors {
263 terminal_ui::print_status_warn(err);
264 }
265 if !r.backed_up.is_empty() {
266 for bak in &r.backed_up {
267 println!(" \x1b[2m ↳ backup: {bak}\x1b[0m");
268 }
269 }
270 if r.injected.is_empty()
271 && r.updated.is_empty()
272 && r.already.is_empty()
273 && r.errors.is_empty()
274 {
275 terminal_ui::print_status_skip("No agent rules needed");
276 }
277 r
278 } else {
279 terminal_ui::print_status_skip("Skipped (run `lean-ctx setup --inject-rules` to enable)");
280 crate::rules_inject::InjectResult::default()
281 };
282
283 for target in &targets {
285 if !target.detect_path.exists() || target.agent_key.is_empty() {
286 continue;
287 }
288 let mode = recommend_hook_mode(&target.agent_key);
289 crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
290 }
291
292 terminal_ui::print_step_header(5, 12, "API Proxy (optional)");
294 {
295 let mut cfg = crate::core::config::Config::load();
296 let proxy_port = crate::proxy_setup::default_port();
297
298 match cfg.proxy_enabled {
299 Some(true) => {
300 crate::proxy_autostart::install(proxy_port, false);
301 std::thread::sleep(std::time::Duration::from_millis(500));
302 crate::proxy_setup::install_proxy_env(&home, proxy_port, false);
303 terminal_ui::print_status_ok("Proxy active (opted in)");
304 }
305 Some(false) => {
306 terminal_ui::print_status_skip(
307 "Proxy disabled (run `lean-ctx proxy enable` to change)",
308 );
309 }
310 None => {
311 println!(
312 " \x1b[2mThe API proxy routes LLM requests through lean-ctx for additional\x1b[0m"
313 );
314 println!(
315 " \x1b[2mtool-result compression and precise token analytics in the dashboard.\x1b[0m"
316 );
317 println!();
318 println!(
319 " \x1b[2mWithout it: MCP tools, shell hooks, gain tracking, and memory\x1b[0m"
320 );
321 println!(
322 " \x1b[2mall work normally. The proxy adds ~5-15% extra savings on top.\x1b[0m"
323 );
324 println!();
325 print!(" Enable the API proxy? [y/N] ");
326 let _ = std::io::Write::flush(&mut std::io::stdout());
327 let mut input = String::new();
328 let _ = std::io::stdin().read_line(&mut input);
329 let answer = matches!(input.trim().to_lowercase().as_str(), "y" | "yes");
330 cfg.proxy_enabled = Some(answer);
331 let _ = cfg.save();
332 if answer {
333 crate::proxy_autostart::install(proxy_port, false);
334 std::thread::sleep(std::time::Duration::from_millis(500));
335 crate::proxy_setup::install_proxy_env(&home, proxy_port, false);
336 terminal_ui::print_status_new("Proxy enabled");
337 } else {
338 terminal_ui::print_status_skip(
339 "Proxy skipped (run `lean-ctx proxy enable` anytime)",
340 );
341 }
342 }
343 }
344 }
345
346 terminal_ui::print_step_header(6, 12, "Skill Files");
348 if inject_skills {
349 let skill_result = install_skill_files(&home);
350 for (name, installed) in &skill_result {
351 if *installed {
352 terminal_ui::print_status_new(&format!(
353 "{name:<20} \x1b[2mSKILL.md installed\x1b[0m"
354 ));
355 } else {
356 terminal_ui::print_status_ok(&format!(
357 "{name:<20} \x1b[2mSKILL.md up-to-date\x1b[0m"
358 ));
359 }
360 }
361 if skill_result.is_empty() {
362 terminal_ui::print_status_skip("No skill directories to install");
363 }
364 } else {
365 terminal_ui::print_status_skip(
366 "Skipped (skill files install with the rules opt-in; choose Standard/Full in `lean-ctx setup`)",
367 );
368 }
369
370 terminal_ui::print_step_header(7, 12, "Environment Check");
372 let lean_dir = crate::core::data_dir::lean_ctx_data_dir()
373 .unwrap_or_else(|_| home.join(".config/lean-ctx"));
374 if lean_dir.exists() {
375 terminal_ui::print_status_ok(&format!("{} ready", lean_dir.display()));
376 } else {
377 let _ = std::fs::create_dir_all(&lean_dir);
378 terminal_ui::print_status_new(&format!("Created {}", lean_dir.display()));
379 }
380 if let Some(report) = crate::core::data_consolidate::consolidate()
381 && report.files_moved > 0
382 {
383 terminal_ui::print_status_new(&format!(
384 "Consolidated {} file(s) from a split data dir into {}",
385 report.files_moved,
386 report.canonical.display()
387 ));
388 }
389 crate::doctor::run_compact();
390
391 terminal_ui::print_step_header(8, 12, "Help Improve lean-ctx");
393 println!(" Share anonymous compression stats to make lean-ctx better.");
394 println!(" \x1b[1mNo code, no file names, no personal data — ever.\x1b[0m");
395 println!();
396 print!(" Enable anonymous data sharing? \x1b[1m[y/N]\x1b[0m ");
397 use std::io::Write;
398 std::io::stdout().flush().ok();
399
400 let mut input = String::new();
401 let contribute = if std::io::stdin().read_line(&mut input).is_ok() {
402 let answer = input.trim().to_lowercase();
403 answer == "y" || answer == "yes"
404 } else {
405 false
406 };
407
408 if contribute {
409 let config_path = crate::core::config::Config::path()
410 .unwrap_or_else(|| home.join(".config/lean-ctx").join("config.toml"));
411 if let Some(dir) = config_path.parent() {
412 let _ = std::fs::create_dir_all(dir);
413 }
414 let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
415 if !config_content.contains("[cloud]") {
416 if !config_content.is_empty() && !config_content.ends_with('\n') {
417 config_content.push('\n');
418 }
419 config_content.push_str("\n[cloud]\ncontribute_enabled = true\n");
420 let _ = crate::config_io::write_atomic_with_backup(&config_path, &config_content);
421 }
422 terminal_ui::print_status_ok("Enabled — thank you!");
423 } else {
424 terminal_ui::print_status_skip("Skipped — enable later with: lean-ctx config");
425 }
426
427 terminal_ui::print_step_header(9, 12, "Auto-Updates");
429 println!(" Keep lean-ctx up to date automatically.");
430 println!(" \x1b[1mChecks GitHub every 6h, installs only when a new release exists.\x1b[0m");
431 println!(
432 " \x1b[2mNo restarts mid-session. Change anytime: lean-ctx update --schedule off\x1b[0m"
433 );
434 println!();
435 print!(" Enable automatic updates? \x1b[1m[y/N]\x1b[0m ");
436 std::io::stdout().flush().ok();
437
438 let mut auto_input = String::new();
439 let auto_update = if std::io::stdin().read_line(&mut auto_input).is_ok() {
440 let answer = auto_input.trim().to_lowercase();
441 answer == "y" || answer == "yes"
442 } else {
443 false
444 };
445
446 if auto_update {
447 let cfg = crate::core::config::Config::load();
448 let hours = cfg.updates.check_interval_hours;
449 match crate::core::update_scheduler::install_schedule(hours) {
450 Ok(info) => {
451 crate::core::update_scheduler::set_auto_update(true, false, hours);
452 terminal_ui::print_status_ok(&format!("Enabled — {info}"));
453 }
454 Err(e) => {
455 terminal_ui::print_status_warn(&format!("Scheduler setup failed: {e}"));
456 terminal_ui::print_status_skip("Enable later: lean-ctx update --schedule");
457 }
458 }
459 } else {
460 crate::core::update_scheduler::set_auto_update(false, false, 6);
461 terminal_ui::print_status_skip("Skipped — enable later: lean-ctx update --schedule");
462 }
463
464 terminal_ui::print_step_header(10, 12, "Tool Profile");
466 configure_tool_profile();
467
468 terminal_ui::print_step_header(11, 12, "Advanced Tuning (optional)");
470 configure_premium_features(&home);
471
472 terminal_ui::print_step_header(12, 12, "Code Intelligence");
474 let cwd = std::env::current_dir().ok();
475 let cwd_is_home = cwd
476 .as_ref()
477 .is_some_and(|d| dirs::home_dir().is_some_and(|h| d.as_path() == h.as_path()));
478 if cwd_is_home {
479 terminal_ui::print_status_warn(
480 "Running from $HOME — graph build skipped to avoid scanning your entire home directory.",
481 );
482 println!();
483 println!(" \x1b[1mSet a default project root to avoid this:\x1b[0m");
484 println!(" \x1b[2mEnter your main project path (or press Enter to skip):\x1b[0m");
485 print!(" \x1b[1m>\x1b[0m ");
486 use std::io::Write;
487 std::io::stdout().flush().ok();
488 let mut root_input = String::new();
489 if std::io::stdin().read_line(&mut root_input).is_ok() {
490 let root_trimmed = root_input.trim();
491 if root_trimmed.is_empty() {
492 terminal_ui::print_status_skip(
493 "No project root set. Set later: lean-ctx config set project_root /path/to/project",
494 );
495 } else {
496 let root_path = std::path::Path::new(root_trimmed);
497 if root_path.exists() && root_path.is_dir() {
498 let config_path = crate::core::config::Config::path()
499 .unwrap_or_else(|| home.join(".config/lean-ctx").join("config.toml"));
500 let mut content = std::fs::read_to_string(&config_path).unwrap_or_default();
501 if content.contains("project_root") {
502 if let Ok(re) = regex::Regex::new(r#"(?m)^project_root\s*=\s*"[^"]*""#) {
503 content = re
504 .replace(&content, &format!("project_root = \"{root_trimmed}\""))
505 .to_string();
506 }
507 } else {
508 if !content.is_empty() && !content.ends_with('\n') {
509 content.push('\n');
510 }
511 content.push_str(&format!("project_root = \"{root_trimmed}\"\n"));
512 }
513 let _ = crate::config_io::write_atomic_with_backup(&config_path, &content);
514 terminal_ui::print_status_ok(&format!("Project root set: {root_trimmed}"));
515 if crate::core::pathutil::has_project_marker(root_path) {
516 spawn_index_build_background(root_path);
517 terminal_ui::print_status_ok("Graph build started (background)");
518 }
519 } else {
520 terminal_ui::print_status_warn(&format!(
521 "Path not found: {root_trimmed} — skipped"
522 ));
523 }
524 }
525 }
526 } else {
527 let is_project = cwd
528 .as_ref()
529 .is_some_and(|d| crate::core::pathutil::has_project_marker(d));
530 if is_project {
531 println!(" \x1b[2mBuilding code graph for graph-aware reads, impact analysis,\x1b[0m");
532 println!(" \x1b[2mand smart search fusion in the background...\x1b[0m");
533 if let Some(ref root) = cwd {
534 spawn_index_build_background(root);
535 }
536 terminal_ui::print_status_ok("Graph build started (background)");
537 } else {
538 println!(" \x1b[2mRun `lean-ctx graph build` inside any git project to enable\x1b[0m");
539 println!(
540 " \x1b[2mgraph-aware reads, impact analysis, and smart search fusion.\x1b[0m"
541 );
542 }
543 }
544 println!();
545
546 {
548 let tools = crate::core::editor_registry::writers::auto_approve_tools();
549 println!();
550 println!(
551 " \x1b[33m⚡ Auto-approved tools ({} total):\x1b[0m",
552 tools.len()
553 );
554 for chunk in tools.chunks(6) {
555 let names: Vec<_> = chunk.iter().map(|t| format!("\x1b[2m{t}\x1b[0m")).collect();
556 println!(" {}", names.join(", "));
557 }
558 println!(" \x1b[2mDisable with: lean-ctx setup --no-auto-approve\x1b[0m");
559 }
560
561 println!();
563 println!(
564 " \x1b[1;32m✓ Setup complete!\x1b[0m \x1b[1m{}\x1b[0m configured, \x1b[2m{} already set, {} skipped\x1b[0m",
565 newly_configured.len(),
566 already_configured.len(),
567 not_installed.len()
568 );
569
570 if !errors.is_empty() {
571 println!(
572 " \x1b[33m⚠ {} error{}: {}\x1b[0m",
573 errors.len(),
574 if errors.len() == 1 { "" } else { "s" },
575 errors.join(", ")
576 );
577 }
578
579 let source_cmd = crate::shell_hook::shell_source_command().unwrap_or("Restart your shell");
581
582 let dim = "\x1b[2m";
583 let bold = "\x1b[1m";
584 let cyan = "\x1b[36m";
585 let yellow = "\x1b[33m";
586 let rst = "\x1b[0m";
587
588 println!();
589 println!(" {bold}Next steps:{rst}");
590 println!();
591 println!(" {cyan}1.{rst} Reload your shell:");
592 println!(" {bold}{source_cmd}{rst}");
593 println!();
594
595 let mut tools_to_restart: Vec<String> = newly_configured
596 .iter()
597 .map(std::string::ToString::to_string)
598 .collect();
599 for name in rules_result
600 .injected
601 .iter()
602 .chain(rules_result.updated.iter())
603 {
604 if !tools_to_restart.iter().any(|t| t == name) {
605 tools_to_restart.push(name.clone());
606 }
607 }
608
609 if !tools_to_restart.is_empty() {
610 println!(" {cyan}2.{rst} {yellow}{bold}Restart your IDE / AI tool:{rst}");
611 println!(" {bold}{}{rst}", tools_to_restart.join(", "));
612 println!(
613 " {dim}Changes take effect after a full restart (MCP may be enabled or disabled depending on mode).{rst}"
614 );
615 println!(" {dim}Close and re-open the application completely.{rst}");
616 } else if !already_configured.is_empty() {
617 println!(
618 " {cyan}2.{rst} {dim}Your tools are already configured — no restart needed.{rst}"
619 );
620 }
621
622 println!();
623 println!(
624 " {dim}After restart, lean-ctx will automatically optimize every AI interaction.{rst}"
625 );
626 println!(" {dim}Verify with:{rst} {bold}lean-ctx gain{rst}");
627
628 println!();
630 terminal_ui::print_logo_animated();
631 terminal_ui::print_command_box();
632
633 crate::cli::show_first_run_wow();
635}
636
637pub fn run_onboard() {
645 use crate::terminal_ui;
646
647 let dim = "\x1b[2m";
648 let bold = "\x1b[1m";
649 let cyan = "\x1b[36m";
650 let green = "\x1b[1;32m";
651 let yellow = "\x1b[33m";
652 let rst = "\x1b[0m";
653
654 println!();
655 println!(" {bold}Connecting lean-ctx to your AI tools…{rst}");
656 println!(
657 " {dim}No questions — using recommended defaults. Run `lean-ctx setup` for full control.{rst}"
658 );
659 println!();
660
661 let opts = SetupOptions {
662 non_interactive: true,
663 yes: true,
664 fix: true,
665 ..Default::default()
666 };
667
668 let report = match run_setup_with_options(opts) {
669 Ok(r) => r,
670 Err(e) => {
671 eprintln!(" {yellow}Onboarding could not complete: {e}{rst}");
672 eprintln!(" {dim}Try the guided setup instead: lean-ctx setup{rst}");
673 std::process::exit(1);
674 }
675 };
676
677 let connected: Vec<String> = report
679 .steps
680 .iter()
681 .find(|s| s.name == "editors")
682 .map(|s| {
683 s.items
684 .iter()
685 .filter(|i| matches!(i.status.as_str(), "created" | "updated" | "already"))
686 .map(|i| i.name.clone())
687 .collect()
688 })
689 .unwrap_or_default();
690
691 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
692 .map_or_else(|_| "~/.lean-ctx".to_string(), |p| p.display().to_string());
693
694 println!();
695 if connected.is_empty() {
696 println!(" {yellow}No AI tools detected yet.{rst}");
697 println!(
698 " {dim}Install Cursor, Claude Code, VS Code, etc., then re-run: lean-ctx onboard{rst}"
699 );
700 } else {
701 println!(" {green}✓ lean-ctx is connected.{rst}");
702 println!();
703 println!(" {bold}Connected:{rst} {}", connected.join(", "));
704 }
705 println!(" {dim}Data dir:{rst} {data_dir}");
706
707 let source_cmd = crate::shell_hook::shell_source_command().unwrap_or("Restart your shell");
708 println!();
709 println!(" {bold}One last step:{rst}");
710 println!(" {cyan}1.{rst} Reload your shell: {bold}{source_cmd}{rst}");
711 if !connected.is_empty() {
712 println!(
713 " {cyan}2.{rst} {yellow}Fully restart your AI tool{rst} {dim}(so it reconnects to lean-ctx){rst}"
714 );
715 println!(
716 " {cyan}3.{rst} Ask your AI to read a file — lean-ctx optimizes it automatically."
717 );
718 }
719 println!();
720 println!(
721 " {dim}Check anytime:{rst} {bold}lean-ctx doctor{rst} {dim}·{rst} {bold}lean-ctx gain{rst}"
722 );
723 println!();
724 terminal_ui::print_command_box();
725
726 crate::cli::show_first_run_wow();
728}
729
730#[derive(Debug, Clone, Copy, Default)]
731pub struct SetupOptions {
732 pub non_interactive: bool,
733 pub yes: bool,
734 pub fix: bool,
735 pub json: bool,
736 pub no_auto_approve: bool,
737 pub skip_proxy: bool,
738 pub skip_rules: bool,
739 pub force_inject_rules: bool,
741}
742
743pub fn run_setup_with_options(opts: SetupOptions) -> Result<SetupReport, String> {
744 let _quiet_guard = opts.json.then(|| EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
745 let started_at = Utc::now();
746 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
747 let binary = resolve_portable_binary();
748 let home_str = home.to_string_lossy().to_string();
749
750 let mut steps: Vec<SetupStepReport> = Vec::new();
751
752 let mut shell_step = SetupStepReport {
754 name: "shell_hook".to_string(),
755 ok: true,
756 items: Vec::new(),
757 warnings: Vec::new(),
758 errors: Vec::new(),
759 };
760 if !opts.non_interactive || opts.yes {
761 if opts.json {
762 crate::cli::cmd_init_quiet(&["--global".to_string()]);
763 } else {
764 crate::cli::cmd_init(&["--global".to_string()]);
765 }
766 crate::shell_hook::install_all(opts.json);
767 #[cfg(not(windows))]
768 {
769 let hook_content = crate::cli::generate_hook_posix(&binary);
770 if crate::shell::is_container() {
771 crate::cli::write_env_sh_for_containers(&hook_content);
772 shell_step.items.push(SetupItem {
773 name: "env_sh".to_string(),
774 status: "created".to_string(),
775 path: Some(crate::core::paths::config_dir().map_or_else(
776 |_| "~/.config/lean-ctx/env.sh".to_string(),
777 |d| d.join("env.sh").to_string_lossy().to_string(),
778 )),
779 note: Some("Docker/CI helper (BASH_ENV / CLAUDE_ENV_FILE)".to_string()),
780 });
781 } else {
782 shell_step.items.push(SetupItem {
783 name: "env_sh".to_string(),
784 status: "skipped".to_string(),
785 path: None,
786 note: Some("not a container environment".to_string()),
787 });
788 }
789 }
790 shell_step.items.push(SetupItem {
791 name: "init --global".to_string(),
792 status: "ran".to_string(),
793 path: None,
794 note: None,
795 });
796 shell_step.items.push(SetupItem {
797 name: "universal_shell_hook".to_string(),
798 status: "installed".to_string(),
799 path: None,
800 note: Some("~/.zshenv, ~/.bashenv, agent aliases".to_string()),
801 });
802 } else {
803 shell_step
804 .warnings
805 .push("non_interactive_without_yes: shell hook not installed (use --yes)".to_string());
806 shell_step.ok = false;
807 shell_step.items.push(SetupItem {
808 name: "init --global".to_string(),
809 status: "skipped".to_string(),
810 path: None,
811 note: Some("requires --yes in --non-interactive mode".to_string()),
812 });
813 }
814 steps.push(shell_step);
815
816 let mut daemon_step = SetupStepReport {
818 name: "daemon".to_string(),
819 ok: true,
820 items: Vec::new(),
821 warnings: Vec::new(),
822 errors: Vec::new(),
823 };
824 {
825 let was_running = crate::daemon::is_daemon_running();
826 if was_running {
827 let _ = crate::daemon::stop_daemon();
828 std::thread::sleep(std::time::Duration::from_millis(500));
829 }
830 match crate::daemon::start_daemon(&[]) {
831 Ok(()) => {
832 let action = if was_running { "restarted" } else { "started" };
833 daemon_step.items.push(SetupItem {
834 name: "serve --daemon".to_string(),
835 status: action.to_string(),
836 path: Some(crate::daemon::daemon_addr().display()),
837 note: Some("CLI commands can route via IPC when running".to_string()),
838 });
839 }
840 Err(e) => {
841 daemon_step
842 .warnings
843 .push(format!("daemon start failed (non-fatal): {e}"));
844 daemon_step.items.push(SetupItem {
845 name: "serve --daemon".to_string(),
846 status: "skipped".to_string(),
847 path: None,
848 note: Some(format!("optional — {e}")),
849 });
850 }
851 }
852 }
853 steps.push(daemon_step);
854
855 let mut editor_step = SetupStepReport {
857 name: "editors".to_string(),
858 ok: true,
859 items: Vec::new(),
860 warnings: Vec::new(),
861 errors: Vec::new(),
862 };
863
864 let targets = crate::core::editor_registry::build_targets(&home);
865 let update_mcp = crate::core::config::Config::load()
868 .setup
869 .should_update_mcp();
870 for target in &targets {
871 let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
872 if !target.detect_path.exists() {
873 editor_step.items.push(SetupItem {
874 name: target.name.to_string(),
875 status: "not_detected".to_string(),
876 path: Some(short_path),
877 note: None,
878 });
879 continue;
880 }
881
882 let mode = if target.agent_key.is_empty() {
883 HookMode::Mcp
884 } else {
885 recommend_hook_mode(&target.agent_key)
886 };
887
888 if !update_mcp {
889 editor_step.items.push(SetupItem {
890 name: target.name.to_string(),
891 status: "skipped".to_string(),
892 path: Some(short_path),
893 note: Some(format!(
894 "mode={mode}; MCP registration skipped (auto_update_mcp=false)"
895 )),
896 });
897 continue;
898 }
899
900 let res = crate::core::editor_registry::write_config_with_options(
901 target,
902 &binary,
903 WriteOptions {
904 overwrite_invalid: opts.fix,
905 },
906 );
907 match res {
908 Ok(w) => {
909 let note_parts: Vec<String> = [Some(format!("mode={mode}")), w.note]
910 .into_iter()
911 .flatten()
912 .collect();
913 editor_step.items.push(SetupItem {
914 name: target.name.to_string(),
915 status: match w.action {
916 WriteAction::Created => "created".to_string(),
917 WriteAction::Updated => "updated".to_string(),
918 WriteAction::Already => "already".to_string(),
919 },
920 path: Some(short_path),
921 note: Some(note_parts.join("; ")),
922 });
923 }
924 Err(e) => {
925 editor_step.ok = false;
926 editor_step.items.push(SetupItem {
927 name: target.name.to_string(),
928 status: "error".to_string(),
929 path: Some(short_path),
930 note: Some(e),
931 });
932 }
933 }
934 }
935 steps.push(editor_step);
936
937 let mut rules_step = SetupStepReport {
939 name: "agent_rules".to_string(),
940 ok: true,
941 items: Vec::new(),
942 warnings: Vec::new(),
943 errors: Vec::new(),
944 };
945 let setup_cfg = crate::core::config::Config::load().setup;
946 let should_inject = if opts.skip_rules {
947 false
948 } else if opts.force_inject_rules {
949 true
950 } else if opts.yes && opts.non_interactive {
951 setup_cfg.should_inject_rules()
952 } else {
953 !opts.skip_rules
954 };
955
956 if should_inject {
957 let rules_result = crate::rules_inject::inject_all_rules(&home);
958 for n in rules_result.injected {
959 rules_step.items.push(SetupItem {
960 name: n,
961 status: "injected".to_string(),
962 path: None,
963 note: None,
964 });
965 }
966 for n in rules_result.updated {
967 rules_step.items.push(SetupItem {
968 name: n,
969 status: "updated".to_string(),
970 path: None,
971 note: None,
972 });
973 }
974 for n in rules_result.already {
975 rules_step.items.push(SetupItem {
976 name: n,
977 status: "already".to_string(),
978 path: None,
979 note: None,
980 });
981 }
982 if !rules_result.backed_up.is_empty() {
983 for bak in &rules_result.backed_up {
984 rules_step.items.push(SetupItem {
985 name: "backup".to_string(),
986 status: "created".to_string(),
987 path: Some(bak.clone()),
988 note: Some("previous version backed up".to_string()),
989 });
990 }
991 }
992 for e in rules_result.errors {
993 rules_step.ok = false;
994 rules_step.errors.push(e);
995 }
996 } else {
997 let reason = if opts.skip_rules {
998 "--skip-rules flag set"
999 } else {
1000 "auto_inject_rules not enabled (run `lean-ctx setup --inject-rules`)"
1001 };
1002 rules_step.items.push(SetupItem {
1003 name: "agent_rules".to_string(),
1004 status: "skipped".to_string(),
1005 path: None,
1006 note: Some(reason.to_string()),
1007 });
1008 }
1009 steps.push(rules_step);
1010
1011 let mut skill_step = SetupStepReport {
1013 name: "skill_files".to_string(),
1014 ok: true,
1015 items: Vec::new(),
1016 warnings: Vec::new(),
1017 errors: Vec::new(),
1018 };
1019 let should_install_skills = if opts.skip_rules {
1020 false
1021 } else if opts.force_inject_rules {
1022 true
1023 } else if opts.yes && opts.non_interactive {
1024 setup_cfg.should_inject_skills()
1025 } else {
1026 !opts.skip_rules
1027 };
1028 if should_install_skills {
1029 let skill_results = crate::rules_inject::install_all_skills(&home);
1030 for (name, is_new) in &skill_results {
1031 skill_step.items.push(SetupItem {
1032 name: name.clone(),
1033 status: if *is_new { "installed" } else { "already" }.to_string(),
1034 path: None,
1035 note: Some("SKILL.md".to_string()),
1036 });
1037 }
1038 } else {
1039 skill_step.items.push(SetupItem {
1040 name: "skill_files".to_string(),
1041 status: "skipped".to_string(),
1042 path: None,
1043 note: Some("auto_inject_skills not enabled".to_string()),
1044 });
1045 }
1046 if !skill_step.items.is_empty() {
1047 steps.push(skill_step);
1048 }
1049
1050 let mut hooks_step = SetupStepReport {
1052 name: "agent_hooks".to_string(),
1053 ok: true,
1054 items: Vec::new(),
1055 warnings: Vec::new(),
1056 errors: Vec::new(),
1057 };
1058 for target in &targets {
1059 if !target.detect_path.exists() || target.agent_key.is_empty() {
1060 continue;
1061 }
1062 let mode = recommend_hook_mode(&target.agent_key);
1063 crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
1064 let mcp_note = if setup_cfg.should_update_mcp() {
1067 match configure_agent_mcp(&target.agent_key) {
1068 Ok(()) => "; MCP config updated".to_string(),
1069 Err(e) => format!("; MCP config skipped: {e}"),
1070 }
1071 } else {
1072 "; MCP registration skipped (auto_update_mcp=false)".to_string()
1073 };
1074 hooks_step.items.push(SetupItem {
1075 name: format!("{} hooks", target.name),
1076 status: "installed".to_string(),
1077 path: Some(target.detect_path.to_string_lossy().to_string()),
1078 note: Some(format!(
1079 "mode={mode}; merge-based install/repair (preserves other hooks/plugins){mcp_note}"
1080 )),
1081 });
1082 }
1083 if !hooks_step.items.is_empty() {
1084 steps.push(hooks_step);
1085 }
1086
1087 let mut tool_profile_step = SetupStepReport {
1093 name: "tool_profile".to_string(),
1094 ok: true,
1095 items: Vec::new(),
1096 warnings: Vec::new(),
1097 errors: Vec::new(),
1098 };
1099 {
1100 let cfg = crate::core::config::Config::load();
1101 if cfg.tool_profile.is_none() && std::env::var("LEAN_CTX_TOOL_PROFILE").is_err() {
1102 let lazy_count = crate::tool_defs::core_tool_names().len();
1103 tool_profile_step.items.push(SetupItem {
1104 name: "tool_profile".to_string(),
1105 status: "lean default".to_string(),
1106 path: None,
1107 note: Some(format!(
1108 "{lazy_count} tools advertised, all reachable via ctx_call \
1109 (pin more with: lean-ctx tools standard|power)"
1110 )),
1111 });
1112 } else {
1113 let profile = cfg.tool_profile_effective();
1114 let overhead_hint = match profile {
1115 crate::core::tool_profiles::ToolProfile::Power => {
1116 "; advertises ALL tool schemas — `lean-ctx tools lean` cuts this to the lazy core"
1117 }
1118 _ => "",
1119 };
1120 tool_profile_step.items.push(SetupItem {
1121 name: "tool_profile".to_string(),
1122 status: "already".to_string(),
1123 path: None,
1124 note: Some(format!("profile={}{overhead_hint}", profile.as_str())),
1125 });
1126 }
1127 }
1128 steps.push(tool_profile_step);
1129
1130 let mut proxy_step = SetupStepReport {
1132 name: "proxy".to_string(),
1133 ok: true,
1134 items: Vec::new(),
1135 warnings: Vec::new(),
1136 errors: Vec::new(),
1137 };
1138 if opts.skip_proxy {
1139 proxy_step.items.push(SetupItem {
1140 name: "proxy".to_string(),
1141 status: "skipped".to_string(),
1142 path: None,
1143 note: Some("Proxy not enabled (run `lean-ctx proxy enable`)".to_string()),
1144 });
1145 } else {
1146 let proxy_cfg = crate::core::config::Config::load();
1147 if proxy_cfg.proxy_enabled == Some(true) {
1148 let proxy_port = crate::proxy_setup::default_port();
1149 crate::proxy_autostart::install(proxy_port, true);
1150 std::thread::sleep(std::time::Duration::from_millis(500));
1151 crate::proxy_setup::install_proxy_env(&home, proxy_port, opts.json);
1152 proxy_step.items.push(SetupItem {
1153 name: "proxy_autostart".to_string(),
1154 status: "installed".to_string(),
1155 path: None,
1156 note: Some("LaunchAgent/systemd auto-start on login".to_string()),
1157 });
1158 proxy_step.items.push(SetupItem {
1159 name: "proxy_env".to_string(),
1160 status: "configured".to_string(),
1161 path: None,
1162 note: Some("ANTHROPIC_BASE_URL, OPENAI_BASE_URL, GEMINI_API_BASE_URL".to_string()),
1163 });
1164 } else {
1165 proxy_step.items.push(SetupItem {
1166 name: "proxy".to_string(),
1167 status: "skipped".to_string(),
1168 path: None,
1169 note: Some(
1170 "Proxy not opted-in (run `lean-ctx proxy enable` to activate)".to_string(),
1171 ),
1172 });
1173 }
1174 }
1175 steps.push(proxy_step);
1176
1177 let mut env_step = SetupStepReport {
1179 name: "doctor_compact".to_string(),
1180 ok: true,
1181 items: Vec::new(),
1182 warnings: Vec::new(),
1183 errors: Vec::new(),
1184 };
1185 let (passed, total) = crate::doctor::compact_score();
1186 env_step.items.push(SetupItem {
1187 name: "doctor".to_string(),
1188 status: format!("{passed}/{total}"),
1189 path: None,
1190 note: None,
1191 });
1192 if passed != total {
1193 env_step.warnings.push(format!(
1194 "doctor compact not fully passing: {passed}/{total}"
1195 ));
1196 }
1197 steps.push(env_step);
1198
1199 {
1201 let has_env_root = std::env::var("LEAN_CTX_PROJECT_ROOT")
1202 .ok()
1203 .is_some_and(|v| !v.is_empty());
1204 let cfg = crate::core::config::Config::load();
1205 let has_cfg_root = cfg.project_root.as_ref().is_some_and(|v| !v.is_empty());
1206 if !has_env_root
1207 && !has_cfg_root
1208 && let Ok(cwd) = std::env::current_dir()
1209 {
1210 let is_home = dirs::home_dir().is_some_and(|h| cwd == h);
1211 if is_home {
1212 let mut root_step = SetupStepReport {
1213 name: "project_root".to_string(),
1214 ok: true,
1215 items: Vec::new(),
1216 warnings: vec![
1217 "No project_root configured. Running from $HOME can cause excessive scanning. \
1218 Set via: lean-ctx config set project_root /path/to/project".to_string()
1219 ],
1220 errors: Vec::new(),
1221 };
1222 root_step.items.push(SetupItem {
1223 name: "project_root".to_string(),
1224 status: "unconfigured".to_string(),
1225 path: None,
1226 note: Some(
1227 "Set LEAN_CTX_PROJECT_ROOT or add project_root to config.toml".to_string(),
1228 ),
1229 });
1230 steps.push(root_step);
1231 }
1232 }
1233 }
1234
1235 if let Ok(cwd) = std::env::current_dir()
1239 && crate::core::pathutil::has_project_marker(&cwd)
1240 {
1241 spawn_index_build_background(&cwd);
1242 }
1243
1244 let finished_at = Utc::now();
1245 let success = steps.iter().all(|s| s.ok);
1246 let report = SetupReport {
1247 schema_version: 1,
1248 started_at,
1249 finished_at,
1250 success,
1251 platform: PlatformInfo {
1252 os: std::env::consts::OS.to_string(),
1253 arch: std::env::consts::ARCH.to_string(),
1254 },
1255 steps,
1256 warnings: Vec::new(),
1257 errors: Vec::new(),
1258 };
1259
1260 let path = SetupReport::default_path()?;
1261 let mut content =
1262 serde_json::to_string_pretty(&report).map_err(|e| format!("serialize report: {e}"))?;
1263 content.push('\n');
1264 crate::config_io::write_atomic(&path, &content)?;
1265
1266 Ok(report)
1267}
1268
1269fn spawn_index_build_background(root: &std::path::Path) {
1270 if std::env::var("LEAN_CTX_DISABLED").is_ok()
1271 || matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
1272 {
1273 return;
1274 }
1275 let root_str = crate::core::graph_index::normalize_project_root(&root.to_string_lossy());
1276 if !crate::core::graph_index::is_safe_scan_root_public(&root_str) {
1277 tracing::info!("[setup: skipping background graph build for unsafe root {root_str}]");
1278 return;
1279 }
1280
1281 let binary = resolve_portable_binary();
1282
1283 #[cfg(unix)]
1284 {
1285 let mut cmd = std::process::Command::new("nice");
1286 cmd.args(["-n", "19"]);
1287 if which_ionice_available() {
1288 cmd.arg("ionice").args(["-c", "3"]);
1289 }
1290 cmd.arg(&binary)
1291 .args(["index", "build", "--root"])
1292 .arg(root)
1293 .stdout(std::process::Stdio::null())
1294 .stderr(std::process::Stdio::null())
1295 .stdin(std::process::Stdio::null());
1296 let _ = cmd.spawn();
1297 }
1298
1299 #[cfg(windows)]
1300 {
1301 use std::os::windows::process::CommandExt;
1302 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
1303 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
1304 let _ = std::process::Command::new(&binary)
1305 .args(["index", "build", "--root"])
1306 .arg(root)
1307 .stdout(std::process::Stdio::null())
1308 .stderr(std::process::Stdio::null())
1309 .stdin(std::process::Stdio::null())
1310 .creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW)
1311 .spawn();
1312 }
1313}
1314
1315#[cfg(unix)]
1316fn which_ionice_available() -> bool {
1317 std::process::Command::new("ionice")
1318 .arg("--version")
1319 .stdout(std::process::Stdio::null())
1320 .stderr(std::process::Stdio::null())
1321 .status()
1322 .is_ok()
1323}
1324
1325#[cfg(all(test, target_os = "macos"))]
1326mod tests {
1327 use super::*;
1328
1329 #[test]
1330 #[cfg(target_os = "macos")]
1331 fn qoder_agent_targets_include_all_macos_mcp_locations() {
1332 let home = std::path::Path::new("/Users/tester");
1333 let targets = agent_mcp_targets("qoder", home).unwrap();
1334 let paths: Vec<_> = targets.iter().map(|t| t.config_path.as_path()).collect();
1335
1336 assert_eq!(
1337 paths,
1338 vec![
1339 home.join(".qoder/mcp.json").as_path(),
1340 home.join("Library/Application Support/Qoder/User/mcp.json")
1341 .as_path(),
1342 home.join("Library/Application Support/Qoder/SharedClientCache/mcp.json")
1343 .as_path(),
1344 ]
1345 );
1346 assert!(
1347 targets
1348 .iter()
1349 .all(|t| t.config_type == ConfigType::QoderSettings)
1350 );
1351 }
1352}