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