Skip to main content

lean_ctx/setup/
interactive.rs

1use crate::core::editor_registry::{WriteAction, WriteOptions};
2use crate::core::portable_binary::resolve_portable_binary;
3use crate::hooks::{HookMode, recommend_hook_mode};
4
5use super::first_run::{first_run_setup_level, persist_setup_choice};
6use super::helpers::{
7    configure_plan_mode_settings, configure_premium_features, configure_tool_profile,
8    install_skill_files, shorten_path,
9};
10use super::index_build::spawn_index_build_background;
11use super::options::SetupOptions;
12use super::with_options::run_setup_with_options;
13
14pub fn run_setup() {
15    use crate::terminal_ui;
16
17    if crate::shell::is_non_interactive() {
18        eprintln!("Non-interactive terminal detected (no TTY on stdin).");
19        eprintln!(
20            "Running in non-interactive mode (equivalent to: lean-ctx setup --non-interactive --yes)"
21        );
22        eprintln!();
23        let opts = SetupOptions {
24            non_interactive: true,
25            yes: true,
26            ..Default::default()
27        };
28        match run_setup_with_options(opts) {
29            Ok(report) => {
30                for w in &report.warnings {
31                    tracing::warn!("{w}");
32                }
33            }
34            Err(e) => tracing::error!("Setup error: {e}"),
35        }
36        return;
37    }
38
39    let Some(home) = dirs::home_dir() else {
40        tracing::error!("Cannot determine home directory");
41        std::process::exit(1);
42    };
43
44    let binary = resolve_portable_binary();
45
46    let home_str = home.to_string_lossy().to_string();
47
48    terminal_ui::print_setup_header();
49
50    let (inject_rules, inject_skills) = first_run_setup_level();
51    persist_setup_choice(inject_rules, inject_skills);
52
53    terminal_ui::print_step_header(1, 13, "Shell Hook");
54    crate::cli::cmd_init(&["--global".to_string()]);
55    crate::shell_hook::install_all(false);
56
57    terminal_ui::print_step_header(2, 13, "Daemon");
58    if crate::daemon::is_daemon_running() {
59        terminal_ui::print_status_ok("Daemon running — restarting with current binary…");
60        let _ = crate::daemon::stop_daemon();
61        std::thread::sleep(std::time::Duration::from_millis(500));
62        if let Err(e) = crate::daemon::start_daemon(&[]) {
63            terminal_ui::print_status_warn(&format!("Daemon restart failed: {e}"));
64        }
65    } else if let Err(e) = crate::daemon::start_daemon(&[]) {
66        terminal_ui::print_status_warn(&format!("Daemon start failed: {e}"));
67    }
68
69    terminal_ui::print_step_header(3, 13, "AI Tool Detection");
70
71    let targets = crate::core::editor_registry::build_targets(&home);
72    // #281: in MCP-disabled environments (`auto_update_mcp = false`) editors are
73    // still detected and hooks/rules still install, but the MCP server is never
74    // written into their configs.
75    let update_mcp = crate::core::config::Config::load()
76        .setup
77        .should_update_mcp();
78    let mut newly_configured: Vec<&str> = Vec::new();
79    let mut already_configured: Vec<&str> = Vec::new();
80    let mut not_installed: Vec<&str> = Vec::new();
81    let mut mcp_skipped: Vec<&str> = Vec::new();
82    let mut errors: Vec<&str> = Vec::new();
83
84    for target in &targets {
85        let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
86
87        if !target.detect_path.exists() {
88            not_installed.push(target.name);
89            continue;
90        }
91
92        if !update_mcp {
93            terminal_ui::print_status_ok(&format!(
94                "{:<20} \x1b[2mMCP registration skipped (auto_update_mcp=false)\x1b[0m",
95                target.name
96            ));
97            mcp_skipped.push(target.name);
98            continue;
99        }
100
101        let mode = if target.agent_key.is_empty() {
102            HookMode::Mcp
103        } else {
104            recommend_hook_mode(&target.agent_key)
105        };
106
107        match crate::core::editor_registry::write_config_with_options(
108            target,
109            &binary,
110            WriteOptions {
111                overwrite_invalid: false,
112            },
113        ) {
114            Ok(res) if res.action == WriteAction::Already => {
115                terminal_ui::print_status_ok(&format!(
116                    "{:<20} \x1b[36m{mode}\x1b[0m  \x1b[2m{short_path}\x1b[0m",
117                    target.name
118                ));
119                already_configured.push(target.name);
120            }
121            Ok(_) => {
122                terminal_ui::print_status_new(&format!(
123                    "{:<20} \x1b[36m{mode}\x1b[0m  \x1b[2m{short_path}\x1b[0m",
124                    target.name
125                ));
126                newly_configured.push(target.name);
127            }
128            Err(e) => {
129                terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
130                errors.push(target.name);
131            }
132        }
133    }
134
135    let total_ok = newly_configured.len() + already_configured.len();
136    if total_ok == 0 && errors.is_empty() && mcp_skipped.is_empty() {
137        terminal_ui::print_status_warn(
138            "No AI tools detected. Install one and re-run: lean-ctx setup",
139        );
140    }
141
142    if !not_installed.is_empty() {
143        println!(
144            "  \x1b[2m○ {} not detected: {}\x1b[0m",
145            not_installed.len(),
146            not_installed.join(", ")
147        );
148    }
149
150    configure_plan_mode_settings(&newly_configured, &already_configured);
151
152    terminal_ui::print_step_header(4, 13, "Agent Rules");
153    let rules_result = if inject_rules {
154        let r = crate::rules_inject::inject_all_rules(&home);
155        for name in &r.injected {
156            terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules injected\x1b[0m"));
157        }
158        for name in &r.updated {
159            terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules updated\x1b[0m"));
160        }
161        for name in &r.already {
162            terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mrules up-to-date\x1b[0m"));
163        }
164        for err in &r.errors {
165            terminal_ui::print_status_warn(err);
166        }
167        if !r.backed_up.is_empty() {
168            for bak in &r.backed_up {
169                println!("  \x1b[2m  ↳ backup: {bak}\x1b[0m");
170            }
171        }
172        if r.injected.is_empty()
173            && r.updated.is_empty()
174            && r.already.is_empty()
175            && r.errors.is_empty()
176        {
177            terminal_ui::print_status_skip("No agent rules needed");
178        }
179        r
180    } else {
181        terminal_ui::print_status_skip(
182            "Skipped (run `lean-ctx setup` or set auto_inject_rules = true in config)",
183        );
184        crate::rules_inject::InjectResult::default()
185    };
186
187    for target in &targets {
188        if !target.detect_path.exists() || target.agent_key.is_empty() {
189            continue;
190        }
191        let mode = recommend_hook_mode(&target.agent_key);
192        crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
193    }
194
195    terminal_ui::print_step_header(5, 13, "API Proxy (optional)");
196    {
197        let cfg = crate::core::config::Config::load();
198        let proxy_port = crate::proxy_setup::default_port();
199
200        match cfg.proxy_enabled {
201            Some(true) => {
202                crate::proxy_autostart::install(proxy_port, false);
203                std::thread::sleep(std::time::Duration::from_millis(500));
204                crate::proxy_setup::install_proxy_env(&home, proxy_port, false);
205                terminal_ui::print_status_ok("Proxy active (opted in)");
206            }
207            Some(false) => {
208                terminal_ui::print_status_skip(
209                    "Proxy disabled (run `lean-ctx proxy enable` to change)",
210                );
211            }
212            None => {
213                println!(
214                    "  \x1b[2mThe API proxy routes LLM requests through lean-ctx for additional\x1b[0m"
215                );
216                println!(
217                    "  \x1b[2mtool-result compression and precise token analytics in the dashboard.\x1b[0m"
218                );
219                println!();
220                println!(
221                    "  \x1b[2mWithout it: MCP tools, shell hooks, gain tracking, and memory\x1b[0m"
222                );
223                println!(
224                    "  \x1b[2mall work normally. The proxy adds ~5-15% extra savings on top.\x1b[0m"
225                );
226                println!();
227                print!("  Enable the API proxy? [y/N] ");
228                let _ = std::io::Write::flush(&mut std::io::stdout());
229                let mut input = String::new();
230                let _ = std::io::stdin().read_line(&mut input);
231                let answer = matches!(input.trim().to_lowercase().as_str(), "y" | "yes");
232                if let Err(e) =
233                    crate::core::config::Config::update_global(|c| c.proxy_enabled = Some(answer))
234                {
235                    tracing::warn!("could not persist proxy choice: {e}");
236                }
237                if answer {
238                    crate::proxy_autostart::install(proxy_port, false);
239                    std::thread::sleep(std::time::Duration::from_millis(500));
240                    crate::proxy_setup::install_proxy_env(&home, proxy_port, false);
241                    terminal_ui::print_status_new("Proxy enabled");
242                } else {
243                    terminal_ui::print_status_skip(
244                        "Proxy skipped (run `lean-ctx proxy enable` anytime)",
245                    );
246                }
247            }
248        }
249    }
250
251    terminal_ui::print_step_header(6, 13, "IDE Config Access (optional)");
252    {
253        let cfg = crate::core::config::Config::load();
254        match cfg.allow_ide_config_dirs {
255            Some(true) => {
256                terminal_ui::print_status_ok(
257                    "Enabled — the agent can read your editors' config dirs",
258                );
259            }
260            Some(false) => {
261                terminal_ui::print_status_skip(
262                    "Off (enable: lean-ctx config set allow_ide_config_dirs true)",
263                );
264            }
265            None => {
266                println!(
267                    "  \x1b[2mlean-ctx tools are jailed to the current project. Enabling this lets\x1b[0m"
268                );
269                println!(
270                    "  \x1b[2mthe agent read every supported editor's config dir (~/.cursor, VS Code,\x1b[0m"
271                );
272                println!(
273                    "  \x1b[2mCline/Roo, JetBrains, …) to manage MCP setup across editors.\x1b[0m"
274                );
275                println!();
276                println!(
277                    "  \x1b[33mTrade-off:\x1b[0m \x1b[2mthose dirs can hold other agents' sessions & credentials.\x1b[0m"
278                );
279                println!();
280                print!("  Allow the agent to read IDE config dirs? [y/N] ");
281                let _ = std::io::Write::flush(&mut std::io::stdout());
282                let mut input = String::new();
283                let _ = std::io::stdin().read_line(&mut input);
284                let answer = matches!(input.trim().to_lowercase().as_str(), "y" | "yes");
285                if let Err(e) = crate::core::config::Config::update_global(|c| {
286                    c.allow_ide_config_dirs = Some(answer);
287                }) {
288                    tracing::warn!("could not persist IDE-config-access choice: {e}");
289                }
290                if answer {
291                    terminal_ui::print_status_new("IDE config access enabled");
292                } else {
293                    terminal_ui::print_status_skip(
294                        "Skipped (enable later: lean-ctx config set allow_ide_config_dirs true)",
295                    );
296                }
297            }
298        }
299    }
300
301    terminal_ui::print_step_header(7, 13, "Skill Files");
302    if inject_skills {
303        let skill_result = install_skill_files(&home);
304        for (name, installed) in &skill_result {
305            if *installed {
306                terminal_ui::print_status_new(&format!(
307                    "{name:<20} \x1b[2mSKILL.md installed\x1b[0m"
308                ));
309            } else {
310                terminal_ui::print_status_ok(&format!(
311                    "{name:<20} \x1b[2mSKILL.md up-to-date\x1b[0m"
312                ));
313            }
314        }
315        if skill_result.is_empty() {
316            terminal_ui::print_status_skip("No skill directories to install");
317        }
318    } else {
319        terminal_ui::print_status_skip(
320            "Skipped (skill files install with the rules opt-in; choose Standard/Full in `lean-ctx setup`)",
321        );
322    }
323
324    terminal_ui::print_step_header(8, 13, "Environment Check");
325    let lean_dir = crate::core::data_dir::lean_ctx_data_dir()
326        .unwrap_or_else(|_| home.join(".config/lean-ctx"));
327    if lean_dir.exists() {
328        terminal_ui::print_status_ok(&format!("{} ready", lean_dir.display()));
329    } else {
330        let _ = std::fs::create_dir_all(&lean_dir);
331        terminal_ui::print_status_new(&format!("Created {}", lean_dir.display()));
332    }
333    if let Some(report) = crate::core::data_consolidate::consolidate()
334        && report.files_moved > 0
335    {
336        terminal_ui::print_status_new(&format!(
337            "Consolidated {} file(s) from a split data dir into {}",
338            report.files_moved,
339            report.canonical.display()
340        ));
341    }
342    // #594: relocate a `config.toml` that an old MCP env (LEAN_CTX_DATA_DIR)
343    // stranded in the data dir, so CLI and MCP read the same config from now on.
344    if let Some(report) = crate::core::config_heal::heal() {
345        match report.action {
346            crate::core::config_heal::HealAction::Adopted => {
347                terminal_ui::print_status_new(&format!(
348                    "Recovered your config into {}",
349                    report.to.display()
350                ));
351            }
352            crate::core::config_heal::HealAction::Superseded => {
353                terminal_ui::print_status_ok("Unified config (archived a stale data-dir copy)");
354            }
355        }
356    }
357    crate::doctor::run_compact();
358
359    // Commit to the XDG layout (and drain any residual ~/.lean-ctx) so a stray
360    // marker can never re-collapse config/data/state/cache later (GL #623).
361    crate::core::layout_pin::heal();
362
363    terminal_ui::print_step_header(9, 13, "Help Improve lean-ctx");
364    println!("  Share anonymous telemetry to make lean-ctx better:");
365    println!("    • Version, OS, architecture, random install ID");
366    println!("    • Compression patterns: file-type, size bucket, mode, ratio");
367    println!("  No code, no file names, no personal data — ever.");
368    println!("  Inspect anytime: lean-ctx telemetry show");
369    println!();
370    print!("  Enable anonymous telemetry? [y/N] ");
371    use std::io::Write;
372    std::io::stdout().flush().ok();
373
374    let mut input = String::new();
375    let contribute = if std::io::stdin().read_line(&mut input).is_ok() {
376        let answer = input.trim().to_lowercase();
377        answer == "y" || answer == "yes"
378    } else {
379        false
380    };
381
382    if contribute {
383        let config_path = crate::core::config::Config::path()
384            .unwrap_or_else(|| home.join(".config/lean-ctx").join("config.toml"));
385        if let Some(dir) = config_path.parent() {
386            let _ = std::fs::create_dir_all(dir);
387        }
388        let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
389        if !config_content.contains("[telemetry]") {
390            if !config_content.ends_with('\n') {
391                config_content.push('\n');
392            }
393            config_content.push_str("\n[telemetry]\nenabled = true\n");
394        }
395        let _ = crate::config_io::write_atomic_with_backup(&config_path, &config_content);
396        terminal_ui::print_status_ok("Enabled — thank you!");
397    } else {
398        terminal_ui::print_status_skip("Skipped — enable later with: lean-ctx telemetry on");
399    }
400
401    terminal_ui::print_step_header(10, 13, "Auto-Updates");
402    println!("  Keep lean-ctx up to date automatically.");
403    println!("  \x1b[1mChecks GitHub every 6h, installs only when a new release exists.\x1b[0m");
404    println!(
405        "  \x1b[2mNo restarts mid-session. Change anytime: lean-ctx update --schedule off\x1b[0m"
406    );
407    println!();
408    print!("  Enable automatic updates? \x1b[1m[y/N]\x1b[0m ");
409    std::io::stdout().flush().ok();
410
411    let mut auto_input = String::new();
412    let auto_update = if std::io::stdin().read_line(&mut auto_input).is_ok() {
413        let answer = auto_input.trim().to_lowercase();
414        answer == "y" || answer == "yes"
415    } else {
416        false
417    };
418
419    if auto_update {
420        let cfg = crate::core::config::Config::load();
421        let hours = cfg.updates.check_interval_hours;
422        match crate::core::update_scheduler::install_schedule(hours) {
423            Ok(info) => {
424                crate::core::update_scheduler::set_auto_update(true, false, hours);
425                terminal_ui::print_status_ok(&format!("Enabled — {info}"));
426            }
427            Err(e) => {
428                terminal_ui::print_status_warn(&format!("Scheduler setup failed: {e}"));
429                terminal_ui::print_status_skip("Enable later: lean-ctx update --schedule");
430            }
431        }
432    } else {
433        crate::core::update_scheduler::set_auto_update(false, false, 6);
434        terminal_ui::print_status_skip("Skipped — enable later: lean-ctx update --schedule");
435    }
436
437    terminal_ui::print_step_header(11, 13, "Tool Profile");
438    configure_tool_profile();
439
440    terminal_ui::print_step_header(12, 13, "Advanced Tuning (optional)");
441    configure_premium_features(&home);
442
443    terminal_ui::print_step_header(13, 13, "Code Intelligence");
444    let cwd = std::env::current_dir().ok();
445    let cwd_is_home = cwd
446        .as_ref()
447        .is_some_and(|d| dirs::home_dir().is_some_and(|h| d.as_path() == h.as_path()));
448    if cwd_is_home {
449        terminal_ui::print_status_warn(
450            "Running from $HOME — graph build skipped to avoid scanning your entire home directory.",
451        );
452        println!();
453        println!("  \x1b[1mSet a default project root to avoid this:\x1b[0m");
454        println!("  \x1b[2mEnter your main project path (or press Enter to skip):\x1b[0m");
455        print!("  \x1b[1m>\x1b[0m ");
456        use std::io::Write;
457        std::io::stdout().flush().ok();
458        let mut root_input = String::new();
459        if std::io::stdin().read_line(&mut root_input).is_ok() {
460            let root_trimmed = root_input.trim();
461            if root_trimmed.is_empty() {
462                terminal_ui::print_status_skip(
463                    "No project root set. Set later: lean-ctx config set project_root /path/to/project",
464                );
465            } else {
466                let root_path = std::path::Path::new(root_trimmed);
467                if root_path.exists() && root_path.is_dir() {
468                    let config_path = crate::core::config::Config::path()
469                        .unwrap_or_else(|| home.join(".config/lean-ctx").join("config.toml"));
470                    let mut content = std::fs::read_to_string(&config_path).unwrap_or_default();
471                    if content.contains("project_root") {
472                        if let Ok(re) = regex::Regex::new(r#"(?m)^project_root\s*=\s*"[^"]*""#) {
473                            content = re
474                                .replace(&content, &format!("project_root = \"{root_trimmed}\""))
475                                .to_string();
476                        }
477                    } else {
478                        if !content.is_empty() && !content.ends_with('\n') {
479                            content.push('\n');
480                        }
481                        content.push_str(&format!("project_root = \"{root_trimmed}\"\n"));
482                    }
483                    let _ = crate::config_io::write_atomic_with_backup(&config_path, &content);
484                    terminal_ui::print_status_ok(&format!("Project root set: {root_trimmed}"));
485                    if crate::core::pathutil::has_project_marker(root_path) {
486                        spawn_index_build_background(root_path);
487                        terminal_ui::print_status_ok("Graph build started (background)");
488                    }
489                } else {
490                    terminal_ui::print_status_warn(&format!(
491                        "Path not found: {root_trimmed} — skipped"
492                    ));
493                }
494            }
495        }
496    } else {
497        let is_project = cwd
498            .as_ref()
499            .is_some_and(|d| crate::core::pathutil::has_project_marker(d));
500        if is_project {
501            println!("  \x1b[2mBuilding code graph for graph-aware reads, impact analysis,\x1b[0m");
502            println!("  \x1b[2mand smart search fusion in the background...\x1b[0m");
503            if let Some(ref root) = cwd {
504                spawn_index_build_background(root);
505            }
506            terminal_ui::print_status_ok("Graph build started (background)");
507        } else {
508            println!("  \x1b[2mRun `lean-ctx graph build` inside any git project to enable\x1b[0m");
509            println!(
510                "  \x1b[2mgraph-aware reads, impact analysis, and smart search fusion.\x1b[0m"
511            );
512        }
513    }
514    println!();
515
516    {
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!();
531    println!(
532        "  \x1b[1;32m✓ Setup complete!\x1b[0m  \x1b[1m{}\x1b[0m configured, \x1b[2m{} already set, {} skipped\x1b[0m",
533        newly_configured.len(),
534        already_configured.len(),
535        not_installed.len()
536    );
537
538    if !errors.is_empty() {
539        println!(
540            "  \x1b[33m⚠ {} error{}: {}\x1b[0m",
541            errors.len(),
542            if errors.len() == 1 { "" } else { "s" },
543            errors.join(", ")
544        );
545    }
546
547    let source_cmd = crate::shell_hook::shell_source_command().unwrap_or("Restart your shell");
548
549    let dim = "\x1b[2m";
550    let bold = "\x1b[1m";
551    let cyan = "\x1b[36m";
552    let yellow = "\x1b[33m";
553    let rst = "\x1b[0m";
554
555    println!();
556    println!("  {bold}Next steps:{rst}");
557    println!();
558    println!("  {cyan}1.{rst} Reload your shell:");
559    println!("     {bold}{source_cmd}{rst}");
560    println!();
561
562    let mut tools_to_restart: Vec<String> = newly_configured
563        .iter()
564        .map(std::string::ToString::to_string)
565        .collect();
566    for name in rules_result
567        .injected
568        .iter()
569        .chain(rules_result.updated.iter())
570    {
571        if !tools_to_restart.contains(name) {
572            tools_to_restart.push(name.clone());
573        }
574    }
575
576    if !tools_to_restart.is_empty() {
577        println!("  {cyan}2.{rst} {yellow}{bold}Restart your IDE / AI tool:{rst}");
578        println!("     {bold}{}{rst}", tools_to_restart.join(", "));
579        println!(
580            "     {dim}Changes take effect after a full restart (MCP may be enabled or disabled depending on mode).{rst}"
581        );
582        println!("     {dim}Close and re-open the application completely.{rst}");
583    } else if !already_configured.is_empty() {
584        println!(
585            "  {cyan}2.{rst} {dim}Your tools are already configured — no restart needed.{rst}"
586        );
587    }
588
589    println!();
590    println!(
591        "  {dim}After restart, lean-ctx will automatically optimize every AI interaction.{rst}"
592    );
593    println!("  {dim}Verify with:{rst} {bold}lean-ctx gain{rst}");
594
595    println!();
596    terminal_ui::print_logo_animated();
597    terminal_ui::print_command_box();
598
599    crate::cli::show_first_run_wow();
600}