Skip to main content

lean_ctx/setup/
with_options.rs

1use chrono::Utc;
2
3use crate::core::editor_registry::{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};
7
8use super::helpers::shorten_path;
9use super::index_build::{may_autoindex_cwd, spawn_index_build_background};
10use super::mcp::configure_agent_mcp;
11use super::options::SetupOptions;
12
13pub fn run_setup_with_options(opts: SetupOptions) -> Result<SetupReport, String> {
14    let _quiet_guard = opts.json.then(crate::core::runtime_flags::scoped_quiet);
15    let started_at = Utc::now();
16    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
17    let binary = resolve_portable_binary();
18    let home_str = home.to_string_lossy().to_string();
19
20    // Commit to the XDG layout (and drain any residual ~/.lean-ctx) so a stray
21    // marker can never re-collapse config/data/state/cache later (GL #623).
22    crate::core::layout_pin::heal();
23
24    let targets = crate::core::editor_registry::build_targets(&home);
25    let setup_cfg = crate::core::config::Config::load().setup;
26    let update_mcp = setup_cfg.should_update_mcp();
27    let should_inject = should_inject_rules(opts, setup_cfg.should_inject_rules());
28    let should_install_skills = should_inject_skills(opts, setup_cfg.should_inject_skills());
29
30    let mut steps = vec![
31        build_shell_hook_step(opts, &binary),
32        build_daemon_step(),
33        build_editor_step(opts, &home_str, &binary, &targets, update_mcp),
34        build_rules_step(opts, &home, should_inject),
35    ];
36
37    if let Some(step) = build_rules_dedup_step(&home, should_inject) {
38        steps.push(step);
39    }
40    if let Some(step) = build_skill_step(&home, should_install_skills) {
41        steps.push(step);
42    }
43    if let Some(step) = build_agent_hooks_step(&targets, update_mcp) {
44        steps.push(step);
45    }
46
47    steps.extend([
48        build_tool_profile_step(),
49        build_proxy_step(opts, &home),
50        build_doctor_compact_step(),
51    ]);
52
53    maybe_add_project_root_warning(&mut steps);
54    maybe_spawn_background_index();
55    maybe_enable_ide_config_access(opts);
56
57    let report = build_setup_report(started_at, steps);
58    persist_setup_report(&report)?;
59
60    Ok(report)
61}
62
63fn setup_step(name: &str) -> SetupStepReport {
64    SetupStepReport {
65        name: name.to_string(),
66        ok: true,
67        items: Vec::new(),
68        warnings: Vec::new(),
69        errors: Vec::new(),
70    }
71}
72
73fn build_shell_hook_step(opts: SetupOptions, _binary: &str) -> SetupStepReport {
74    let mut shell_step = setup_step("shell_hook");
75    if !opts.non_interactive || opts.yes {
76        if opts.json {
77            crate::cli::cmd_init_quiet(&["--global".to_string()]);
78        } else {
79            crate::cli::cmd_init(&["--global".to_string()]);
80        }
81        crate::shell_hook::install_all(opts.json);
82        #[cfg(not(windows))]
83        {
84            let binary = _binary;
85            let hook_content = crate::cli::generate_hook_posix(binary);
86            if crate::shell::is_container() {
87                crate::cli::write_env_sh_for_containers(&hook_content);
88                shell_step.items.push(SetupItem {
89                    name: "env_sh".to_string(),
90                    status: "created".to_string(),
91                    path: Some(crate::core::paths::config_dir().map_or_else(
92                        |_| "~/.config/lean-ctx/env.sh".to_string(),
93                        |d| d.join("env.sh").to_string_lossy().to_string(),
94                    )),
95                    note: Some("Docker/CI helper (BASH_ENV / CLAUDE_ENV_FILE)".to_string()),
96                });
97            } else {
98                shell_step.items.push(SetupItem {
99                    name: "env_sh".to_string(),
100                    status: "skipped".to_string(),
101                    path: None,
102                    note: Some("not a container environment".to_string()),
103                });
104            }
105        }
106        shell_step.items.push(SetupItem {
107            name: "init --global".to_string(),
108            status: "ran".to_string(),
109            path: None,
110            note: None,
111        });
112        shell_step.items.push(SetupItem {
113            name: "universal_shell_hook".to_string(),
114            status: "installed".to_string(),
115            path: None,
116            note: Some("~/.zshenv, ~/.bashenv, agent aliases".to_string()),
117        });
118    } else {
119        shell_step
120            .warnings
121            .push("non_interactive_without_yes: shell hook not installed (use --yes)".to_string());
122        shell_step.ok = false;
123        shell_step.items.push(SetupItem {
124            name: "init --global".to_string(),
125            status: "skipped".to_string(),
126            path: None,
127            note: Some("requires --yes in --non-interactive mode".to_string()),
128        });
129    }
130    shell_step
131}
132
133fn build_daemon_step() -> SetupStepReport {
134    let mut daemon_step = setup_step("daemon");
135    let was_running = crate::daemon::is_daemon_running();
136    if was_running {
137        let _ = crate::daemon::stop_daemon();
138        std::thread::sleep(std::time::Duration::from_millis(500));
139    }
140    match crate::daemon::start_daemon(&[]) {
141        Ok(()) => {
142            let action = if was_running { "restarted" } else { "started" };
143            daemon_step.items.push(SetupItem {
144                name: "serve --daemon".to_string(),
145                status: action.to_string(),
146                path: Some(crate::daemon::daemon_addr().display()),
147                note: Some("CLI commands can route via IPC when running".to_string()),
148            });
149        }
150        Err(e) => {
151            daemon_step
152                .warnings
153                .push(format!("daemon start failed (non-fatal): {e}"));
154            daemon_step.items.push(SetupItem {
155                name: "serve --daemon".to_string(),
156                status: "skipped".to_string(),
157                path: None,
158                note: Some(format!("optional — {e}")),
159            });
160        }
161    }
162    daemon_step
163}
164
165fn build_editor_step(
166    opts: SetupOptions,
167    home_str: &str,
168    binary: &str,
169    targets: &[EditorTarget],
170    update_mcp: bool,
171) -> SetupStepReport {
172    let mut editor_step = setup_step("editors");
173    for target in targets {
174        let short_path = shorten_path(&target.config_path.to_string_lossy(), home_str);
175        if !target.detect_path.exists() {
176            editor_step.items.push(SetupItem {
177                name: target.name.to_string(),
178                status: "not_detected".to_string(),
179                path: Some(short_path),
180                note: None,
181            });
182            continue;
183        }
184
185        let mode = if target.agent_key.is_empty() {
186            HookMode::Mcp
187        } else {
188            recommend_hook_mode(&target.agent_key)
189        };
190
191        if !update_mcp {
192            editor_step.items.push(SetupItem {
193                name: target.name.to_string(),
194                status: "skipped".to_string(),
195                path: Some(short_path),
196                note: Some(format!(
197                    "mode={mode}; MCP registration skipped (auto_update_mcp=false)"
198                )),
199            });
200            continue;
201        }
202
203        let res = crate::core::editor_registry::write_config_with_options(
204            target,
205            binary,
206            WriteOptions {
207                overwrite_invalid: opts.fix,
208            },
209        );
210        match res {
211            Ok(w) => {
212                let note_parts: Vec<String> = [Some(format!("mode={mode}")), w.note]
213                    .into_iter()
214                    .flatten()
215                    .collect();
216                editor_step.items.push(SetupItem {
217                    name: target.name.to_string(),
218                    status: match w.action {
219                        WriteAction::Created => "created".to_string(),
220                        WriteAction::Updated => "updated".to_string(),
221                        WriteAction::Already => "already".to_string(),
222                    },
223                    path: Some(short_path),
224                    note: Some(note_parts.join("; ")),
225                });
226            }
227            Err(e) => {
228                editor_step.ok = false;
229                editor_step.items.push(SetupItem {
230                    name: target.name.to_string(),
231                    status: "error".to_string(),
232                    path: Some(short_path),
233                    note: Some(e),
234                });
235            }
236        }
237    }
238    editor_step
239}
240
241fn should_inject_rules(opts: SetupOptions, config_value: bool) -> bool {
242    if opts.skip_rules {
243        false
244    } else if opts.force_inject_rules {
245        true
246    } else if opts.yes && opts.non_interactive {
247        config_value
248    } else {
249        true
250    }
251}
252
253fn should_inject_skills(opts: SetupOptions, config_value: bool) -> bool {
254    should_inject_rules(opts, config_value)
255}
256
257fn build_rules_step(
258    opts: SetupOptions,
259    home: &std::path::Path,
260    should_inject: bool,
261) -> SetupStepReport {
262    let mut rules_step = setup_step("agent_rules");
263    if should_inject {
264        let rules_result = crate::rules_inject::inject_all_rules(home);
265        for n in rules_result.injected {
266            rules_step.items.push(SetupItem {
267                name: n,
268                status: "injected".to_string(),
269                path: None,
270                note: None,
271            });
272        }
273        for n in rules_result.updated {
274            rules_step.items.push(SetupItem {
275                name: n,
276                status: "updated".to_string(),
277                path: None,
278                note: None,
279            });
280        }
281        for n in rules_result.already {
282            rules_step.items.push(SetupItem {
283                name: n,
284                status: "already".to_string(),
285                path: None,
286                note: None,
287            });
288        }
289        if !rules_result.backed_up.is_empty() {
290            for bak in &rules_result.backed_up {
291                rules_step.items.push(SetupItem {
292                    name: "backup".to_string(),
293                    status: "created".to_string(),
294                    path: Some(bak.clone()),
295                    note: Some("previous version backed up".to_string()),
296                });
297            }
298        }
299        for e in rules_result.errors {
300            rules_step.ok = false;
301            rules_step.errors.push(e);
302        }
303    } else {
304        let reason = if opts.skip_rules {
305            "--skip-rules flag set"
306        } else {
307            "auto_inject_rules not enabled (run `lean-ctx setup` or set auto_inject_rules = true)"
308        };
309        rules_step.items.push(SetupItem {
310            name: "agent_rules".to_string(),
311            status: "skipped".to_string(),
312            path: None,
313            note: Some(reason.to_string()),
314        });
315    }
316    rules_step
317}
318
319fn build_rules_dedup_step(home: &std::path::Path, should_inject: bool) -> Option<SetupStepReport> {
320    if !should_inject {
321        return None;
322    }
323    let mut dedup_step = setup_step("rules_dedup");
324    let project = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
325    for line in crate::cli::rules_dedup::auto_apply(home, &project) {
326        let failed = line.starts_with("FAILED");
327        if failed {
328            dedup_step.warnings.push(line.clone());
329        }
330        dedup_step.items.push(SetupItem {
331            name: "dedup".to_string(),
332            status: if failed { "failed" } else { "applied" }.to_string(),
333            path: None,
334            note: Some(line),
335        });
336    }
337    Some(dedup_step)
338}
339
340fn build_skill_step(
341    home: &std::path::Path,
342    should_install_skills: bool,
343) -> Option<SetupStepReport> {
344    let mut skill_step = setup_step("skill_files");
345    if should_install_skills {
346        let skill_results = crate::rules_inject::install_all_skills(home);
347        for (name, is_new) in &skill_results {
348            skill_step.items.push(SetupItem {
349                name: name.clone(),
350                status: if *is_new { "installed" } else { "already" }.to_string(),
351                path: None,
352                note: Some("SKILL.md".to_string()),
353            });
354        }
355    } else {
356        skill_step.items.push(SetupItem {
357            name: "skill_files".to_string(),
358            status: "skipped".to_string(),
359            path: None,
360            note: Some("auto_inject_skills not enabled".to_string()),
361        });
362    }
363    (!skill_step.items.is_empty()).then_some(skill_step)
364}
365
366fn build_agent_hooks_step(targets: &[EditorTarget], update_mcp: bool) -> Option<SetupStepReport> {
367    let mut hooks_step = setup_step("agent_hooks");
368    for target in targets {
369        if !target.detect_path.exists() || target.agent_key.is_empty() {
370            continue;
371        }
372        let mode = recommend_hook_mode(&target.agent_key);
373        crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
374        let mcp_note = if update_mcp {
375            match configure_agent_mcp(&target.agent_key) {
376                Ok(()) => "; MCP config updated".to_string(),
377                Err(e) => format!("; MCP config skipped: {e}"),
378            }
379        } else {
380            "; MCP registration skipped (auto_update_mcp=false)".to_string()
381        };
382        hooks_step.items.push(SetupItem {
383            name: format!("{} hooks", target.name),
384            status: "installed".to_string(),
385            path: Some(target.detect_path.to_string_lossy().to_string()),
386            note: Some(format!(
387                "mode={mode}; merge-based install/repair (preserves other hooks/plugins){mcp_note}"
388            )),
389        });
390    }
391    (!hooks_step.items.is_empty()).then_some(hooks_step)
392}
393
394fn build_tool_profile_step() -> SetupStepReport {
395    let mut tool_profile_step = setup_step("tool_profile");
396    let cfg = crate::core::config::Config::load();
397    if cfg.tool_profile.is_none() && std::env::var("LEAN_CTX_TOOL_PROFILE").is_err() {
398        let lazy_count = crate::tool_defs::core_tool_names().len();
399        tool_profile_step.items.push(SetupItem {
400            name: "tool_profile".to_string(),
401            status: "lean default".to_string(),
402            path: None,
403            note: Some(format!(
404                "{lazy_count} tools advertised, all reachable via ctx_call \
405                 (pin more with: lean-ctx tools standard|power)"
406            )),
407        });
408    } else {
409        let profile = cfg.tool_profile_effective();
410        let overhead_hint = match profile {
411            crate::core::tool_profiles::ToolProfile::Power => {
412                "; advertises ALL tool schemas — `lean-ctx tools lean` cuts this to the lazy core"
413            }
414            _ => "",
415        };
416        tool_profile_step.items.push(SetupItem {
417            name: "tool_profile".to_string(),
418            status: "already".to_string(),
419            path: None,
420            note: Some(format!("profile={}{overhead_hint}", profile.as_str())),
421        });
422    }
423    tool_profile_step
424}
425
426fn build_proxy_step(opts: SetupOptions, home: &std::path::Path) -> SetupStepReport {
427    let mut proxy_step = setup_step("proxy");
428    if opts.skip_proxy {
429        proxy_step.items.push(SetupItem {
430            name: "proxy".to_string(),
431            status: "skipped".to_string(),
432            path: None,
433            note: Some("Proxy not enabled (run `lean-ctx proxy enable`)".to_string()),
434        });
435    } else {
436        let proxy_cfg = crate::core::config::Config::load();
437        if proxy_cfg.proxy_enabled == Some(true) {
438            let proxy_port = crate::proxy_setup::default_port();
439            crate::proxy_autostart::install(proxy_port, true);
440            std::thread::sleep(std::time::Duration::from_millis(500));
441            crate::proxy_setup::install_proxy_env(home, proxy_port, opts.json);
442            proxy_step.items.push(SetupItem {
443                name: "proxy_autostart".to_string(),
444                status: "installed".to_string(),
445                path: None,
446                note: Some("LaunchAgent/systemd auto-start on login".to_string()),
447            });
448            proxy_step.items.push(SetupItem {
449                name: "proxy_env".to_string(),
450                status: "configured".to_string(),
451                path: None,
452                note: Some("ANTHROPIC_BASE_URL, OPENAI_BASE_URL, GEMINI_API_BASE_URL".to_string()),
453            });
454        } else {
455            proxy_step.items.push(SetupItem {
456                name: "proxy".to_string(),
457                status: "skipped".to_string(),
458                path: None,
459                note: Some(
460                    "Proxy not opted-in (run `lean-ctx proxy enable` to activate)".to_string(),
461                ),
462            });
463        }
464    }
465    proxy_step
466}
467
468fn build_doctor_compact_step() -> SetupStepReport {
469    let mut env_step = setup_step("doctor_compact");
470    let (passed, total) = crate::doctor::compact_score();
471    env_step.items.push(SetupItem {
472        name: "doctor".to_string(),
473        status: format!("{passed}/{total}"),
474        path: None,
475        note: None,
476    });
477    if passed != total {
478        env_step.warnings.push(format!(
479            "doctor compact not fully passing: {passed}/{total}"
480        ));
481    }
482    env_step
483}
484
485fn maybe_add_project_root_warning(steps: &mut Vec<SetupStepReport>) {
486    let has_env_root = std::env::var("LEAN_CTX_PROJECT_ROOT").is_ok_and(|v| !v.is_empty());
487    let cfg = crate::core::config::Config::load();
488    let has_cfg_root = cfg.project_root.as_ref().is_some_and(|v| !v.is_empty());
489
490    if has_env_root || has_cfg_root {
491        return;
492    }
493
494    let Ok(cwd) = std::env::current_dir() else {
495        return;
496    };
497    let is_home = dirs::home_dir().is_some_and(|h| cwd == h);
498    if !is_home {
499        return;
500    }
501
502    let mut root_step = SetupStepReport {
503        name: "project_root".to_string(),
504        ok: true,
505        items: Vec::new(),
506        warnings: vec![
507            "No project_root configured. Running from $HOME can cause excessive scanning. \
508             Set via: lean-ctx config set project_root /path/to/project"
509                .to_string(),
510        ],
511        errors: Vec::new(),
512    };
513    root_step.items.push(SetupItem {
514        name: "project_root".to_string(),
515        status: "unconfigured".to_string(),
516        path: None,
517        note: Some("Set LEAN_CTX_PROJECT_ROOT or add project_root to config.toml".to_string()),
518    });
519    steps.push(root_step);
520}
521
522fn maybe_spawn_background_index() {
523    if let Ok(cwd) = std::env::current_dir()
524        && may_autoindex_cwd(&cwd)
525        && crate::core::pathutil::has_project_marker(&cwd)
526    {
527        spawn_index_build_background(&cwd);
528    }
529}
530
531fn maybe_enable_ide_config_access(opts: SetupOptions) {
532    if !opts.yes
533        || opts.fix
534        || crate::core::config::Config::load()
535            .allow_ide_config_dirs
536            .is_some()
537    {
538        return;
539    }
540
541    match crate::core::config::Config::update_global(|c| {
542        c.allow_ide_config_dirs = Some(true);
543    }) {
544        Ok(_) => {
545            if !opts.json {
546                println!(
547                    "  Enabled IDE config access (allow_ide_config_dirs) — \
548                     disable: lean-ctx config set allow_ide_config_dirs false"
549                );
550            }
551        }
552        Err(e) => tracing::warn!("could not enable IDE config access: {e}"),
553    }
554}
555
556fn build_setup_report(
557    started_at: chrono::DateTime<Utc>,
558    steps: Vec<SetupStepReport>,
559) -> SetupReport {
560    let finished_at = Utc::now();
561    let success = steps.iter().all(|s| s.ok);
562    SetupReport {
563        schema_version: 1,
564        started_at,
565        finished_at,
566        success,
567        platform: PlatformInfo {
568            os: std::env::consts::OS.to_string(),
569            arch: std::env::consts::ARCH.to_string(),
570        },
571        steps,
572        warnings: Vec::new(),
573        errors: Vec::new(),
574    }
575}
576
577fn persist_setup_report(report: &SetupReport) -> Result<(), String> {
578    let path = SetupReport::default_path()?;
579    let mut content =
580        serde_json::to_string_pretty(report).map_err(|e| format!("serialize report: {e}"))?;
581    content.push('\n');
582    crate::config_io::write_atomic(&path, &content)
583}