diff --git a/rust/src/setup/with_options.rs b/rust/src/setup/with_options.rs index 65a3e075b5..de1428b97b 100644 --- a/rust/src/setup/with_options.rs +++ b/rust/src/setup/with_options.rs @@ -1,6 +1,6 @@ use chrono::Utc; -use crate::core::editor_registry::{WriteAction, WriteOptions}; +use crate::core::editor_registry::{EditorTarget, WriteAction, WriteOptions}; use crate::core::portable_binary::resolve_portable_binary; use crate::core::setup_report::{PlatformInfo, SetupItem, SetupReport, SetupStepReport}; use crate::hooks::{HookMode, recommend_hook_mode}; @@ -21,16 +21,57 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result // marker can never re-collapse config/data/state/cache later (GL #623). crate::core::layout_pin::heal(); - let mut steps: Vec = Vec::new(); + let targets = crate::core::editor_registry::build_targets(&home); + let setup_cfg = crate::core::config::Config::load().setup; + let update_mcp = setup_cfg.should_update_mcp(); + let should_inject = should_inject_rules(opts, setup_cfg.should_inject_rules()); + let should_install_skills = should_inject_skills(opts, setup_cfg.should_inject_skills()); + + let mut steps = vec![ + build_shell_hook_step(opts, &binary), + build_daemon_step(), + build_editor_step(opts, &home_str, &binary, &targets, update_mcp), + build_rules_step(opts, &home, should_inject), + ]; + + if let Some(step) = build_rules_dedup_step(&home, should_inject) { + steps.push(step); + } + if let Some(step) = build_skill_step(&home, should_install_skills) { + steps.push(step); + } + if let Some(step) = build_agent_hooks_step(&targets, update_mcp) { + steps.push(step); + } + + steps.extend([ + build_tool_profile_step(), + build_proxy_step(opts, &home), + build_doctor_compact_step(), + ]); + + maybe_add_project_root_warning(&mut steps); + maybe_spawn_background_index(); + maybe_enable_ide_config_access(opts); - // Step: Shell Hook - let mut shell_step = SetupStepReport { - name: "shell_hook".to_string(), + let report = build_setup_report(started_at, steps); + persist_setup_report(&report)?; + + Ok(report) +} + +fn setup_step(name: &str) -> SetupStepReport { + SetupStepReport { + name: name.to_string(), ok: true, items: Vec::new(), warnings: Vec::new(), errors: Vec::new(), - }; + } +} + +fn build_shell_hook_step(opts: SetupOptions, _binary: &str) -> SetupStepReport { + let mut shell_step = setup_step("shell_hook"); if !opts.non_interactive || opts.yes { if opts.json { crate::cli::cmd_init_quiet(&["--global".to_string()]); @@ -40,7 +81,8 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result crate::shell_hook::install_all(opts.json); #[cfg(not(windows))] { - let hook_content = crate::cli::generate_hook_posix(&binary); + let binary = _binary; + let hook_content = crate::cli::generate_hook_posix(binary); if crate::shell::is_container() { crate::cli::write_env_sh_for_containers(&hook_content); shell_step.items.push(SetupItem { @@ -85,64 +127,51 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result note: Some("requires --yes in --non-interactive mode".to_string()), }); } - steps.push(shell_step); + shell_step +} - // Step: Daemon (optional acceleration for CLI routing) - let mut daemon_step = SetupStepReport { - name: "daemon".to_string(), - ok: true, - items: Vec::new(), - warnings: Vec::new(), - errors: Vec::new(), - }; - { - let was_running = crate::daemon::is_daemon_running(); - if was_running { - let _ = crate::daemon::stop_daemon(); - std::thread::sleep(std::time::Duration::from_millis(500)); +fn build_daemon_step() -> SetupStepReport { + let mut daemon_step = setup_step("daemon"); + let was_running = crate::daemon::is_daemon_running(); + if was_running { + let _ = crate::daemon::stop_daemon(); + std::thread::sleep(std::time::Duration::from_millis(500)); + } + match crate::daemon::start_daemon(&[]) { + Ok(()) => { + let action = if was_running { "restarted" } else { "started" }; + daemon_step.items.push(SetupItem { + name: "serve --daemon".to_string(), + status: action.to_string(), + path: Some(crate::daemon::daemon_addr().display()), + note: Some("CLI commands can route via IPC when running".to_string()), + }); } - match crate::daemon::start_daemon(&[]) { - Ok(()) => { - let action = if was_running { "restarted" } else { "started" }; - daemon_step.items.push(SetupItem { - name: "serve --daemon".to_string(), - status: action.to_string(), - path: Some(crate::daemon::daemon_addr().display()), - note: Some("CLI commands can route via IPC when running".to_string()), - }); - } - Err(e) => { - daemon_step - .warnings - .push(format!("daemon start failed (non-fatal): {e}")); - daemon_step.items.push(SetupItem { - name: "serve --daemon".to_string(), - status: "skipped".to_string(), - path: None, - note: Some(format!("optional — {e}")), - }); - } + Err(e) => { + daemon_step + .warnings + .push(format!("daemon start failed (non-fatal): {e}")); + daemon_step.items.push(SetupItem { + name: "serve --daemon".to_string(), + status: "skipped".to_string(), + path: None, + note: Some(format!("optional — {e}")), + }); } } - steps.push(daemon_step); - - // Step: Editor MCP config - let mut editor_step = SetupStepReport { - name: "editors".to_string(), - ok: true, - items: Vec::new(), - warnings: Vec::new(), - errors: Vec::new(), - }; + daemon_step +} - let targets = crate::core::editor_registry::build_targets(&home); - // #281: honor `auto_update_mcp = false` — editors are still detected and - // reported, but the MCP server is never registered in their configs. - let update_mcp = crate::core::config::Config::load() - .setup - .should_update_mcp(); - for target in &targets { - let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str); +fn build_editor_step( + opts: SetupOptions, + home_str: &str, + binary: &str, + targets: &[EditorTarget], + update_mcp: bool, +) -> SetupStepReport { + let mut editor_step = setup_step("editors"); + for target in targets { + let short_path = shorten_path(&target.config_path.to_string_lossy(), home_str); if !target.detect_path.exists() { editor_step.items.push(SetupItem { name: target.name.to_string(), @@ -173,7 +202,7 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result let res = crate::core::editor_registry::write_config_with_options( target, - &binary, + binary, WriteOptions { overwrite_invalid: opts.fix, }, @@ -206,29 +235,33 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result } } } - steps.push(editor_step); + editor_step +} - // Step: Agent rules — respect config unless explicitly forced or skipped - let mut rules_step = SetupStepReport { - name: "agent_rules".to_string(), - ok: true, - items: Vec::new(), - warnings: Vec::new(), - errors: Vec::new(), - }; - let setup_cfg = crate::core::config::Config::load().setup; - let should_inject = if opts.skip_rules { +fn should_inject_rules(opts: SetupOptions, config_value: bool) -> bool { + if opts.skip_rules { false } else if opts.force_inject_rules { true } else if opts.yes && opts.non_interactive { - setup_cfg.should_inject_rules() + config_value } else { true - }; + } +} +fn should_inject_skills(opts: SetupOptions, config_value: bool) -> bool { + should_inject_rules(opts, config_value) +} + +fn build_rules_step( + opts: SetupOptions, + home: &std::path::Path, + should_inject: bool, +) -> SetupStepReport { + let mut rules_step = setup_step("agent_rules"); if should_inject { - let rules_result = crate::rules_inject::inject_all_rules(&home); + let rules_result = crate::rules_inject::inject_all_rules(home); for n in rules_result.injected { rules_step.items.push(SetupItem { name: n, @@ -280,55 +313,37 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result note: Some(reason.to_string()), }); } - steps.push(rules_step); + rules_step +} - // Step: rules dedup (#578) — auto-heal duplicated lean-ctx guidance so a - // client never pays for the rules block twice per session. Only - // lean-ctx-owned files / marked blocks are touched (backups for edits); - // user-maintained content is left alone by `auto_apply`. - if should_inject { - let mut dedup_step = SetupStepReport { - name: "rules_dedup".to_string(), - ok: true, - items: Vec::new(), - warnings: Vec::new(), - errors: Vec::new(), - }; - let project = std::env::current_dir().unwrap_or_else(|_| home.clone()); - for line in crate::cli::rules_dedup::auto_apply(&home, &project) { - let failed = line.starts_with("FAILED"); - if failed { - dedup_step.warnings.push(line.clone()); - } - dedup_step.items.push(SetupItem { - name: "dedup".to_string(), - status: if failed { "failed" } else { "applied" }.to_string(), - path: None, - note: Some(line), - }); +fn build_rules_dedup_step(home: &std::path::Path, should_inject: bool) -> Option { + if !should_inject { + return None; + } + let mut dedup_step = setup_step("rules_dedup"); + let project = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf()); + for line in crate::cli::rules_dedup::auto_apply(home, &project) { + let failed = line.starts_with("FAILED"); + if failed { + dedup_step.warnings.push(line.clone()); } - steps.push(dedup_step); + dedup_step.items.push(SetupItem { + name: "dedup".to_string(), + status: if failed { "failed" } else { "applied" }.to_string(), + path: None, + note: Some(line), + }); } + Some(dedup_step) +} - // Step: Skill files — respect config - let mut skill_step = SetupStepReport { - name: "skill_files".to_string(), - ok: true, - items: Vec::new(), - warnings: Vec::new(), - errors: Vec::new(), - }; - let should_install_skills = if opts.skip_rules { - false - } else if opts.force_inject_rules { - true - } else if opts.yes && opts.non_interactive { - setup_cfg.should_inject_skills() - } else { - true - }; +fn build_skill_step( + home: &std::path::Path, + should_install_skills: bool, +) -> Option { + let mut skill_step = setup_step("skill_files"); if should_install_skills { - let skill_results = crate::rules_inject::install_all_skills(&home); + let skill_results = crate::rules_inject::install_all_skills(home); for (name, is_new) in &skill_results { skill_step.items.push(SetupItem { name: name.clone(), @@ -345,27 +360,18 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result note: Some("auto_inject_skills not enabled".to_string()), }); } - if !skill_step.items.is_empty() { - steps.push(skill_step); - } + (!skill_step.items.is_empty()).then_some(skill_step) +} - // Step: Agent-specific hooks (all detected agents) - let mut hooks_step = SetupStepReport { - name: "agent_hooks".to_string(), - ok: true, - items: Vec::new(), - warnings: Vec::new(), - errors: Vec::new(), - }; - for target in &targets { +fn build_agent_hooks_step(targets: &[EditorTarget], update_mcp: bool) -> Option { + let mut hooks_step = setup_step("agent_hooks"); + for target in targets { if !target.detect_path.exists() || target.agent_key.is_empty() { continue; } let mode = recommend_hook_mode(&target.agent_key); crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode); - // #281: honor `[setup] auto_update_mcp = false` — register MCP only when - // enabled; hooks above always install. - let mcp_note = if setup_cfg.should_update_mcp() { + let mcp_note = if update_mcp { match configure_agent_mcp(&target.agent_key) { Ok(()) => "; MCP config updated".to_string(), Err(e) => format!("; MCP config skipped: {e}"), @@ -382,61 +388,43 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result )), }); } - if !hooks_step.items.is_empty() { - steps.push(hooks_step); - } + (!hooks_step.items.is_empty()).then_some(hooks_step) +} - // Step: Tool profile. Deliberately does NOT write a default profile: - // writing `tool_profile = "standard"` made every install "explicit", which - // disables the lazy-core advertisement (the lazy core) and ships the full - // profile schema set (~5-15k tokens) to every session (#575). The lean - // default needs no config key — all tools stay reachable via ctx_call. - let mut tool_profile_step = SetupStepReport { - name: "tool_profile".to_string(), - ok: true, - items: Vec::new(), - warnings: Vec::new(), - errors: Vec::new(), - }; - { - let cfg = crate::core::config::Config::load(); - if cfg.tool_profile.is_none() && std::env::var("LEAN_CTX_TOOL_PROFILE").is_err() { - let lazy_count = crate::tool_defs::core_tool_names().len(); - tool_profile_step.items.push(SetupItem { - name: "tool_profile".to_string(), - status: "lean default".to_string(), - path: None, - note: Some(format!( - "{lazy_count} tools advertised, all reachable via ctx_call \ - (pin more with: lean-ctx tools standard|power)" - )), - }); - } else { - let profile = cfg.tool_profile_effective(); - let overhead_hint = match profile { - crate::core::tool_profiles::ToolProfile::Power => { - "; advertises ALL tool schemas — `lean-ctx tools lean` cuts this to the lazy core" - } - _ => "", - }; - tool_profile_step.items.push(SetupItem { - name: "tool_profile".to_string(), - status: "already".to_string(), - path: None, - note: Some(format!("profile={}{overhead_hint}", profile.as_str())), - }); - } +fn build_tool_profile_step() -> SetupStepReport { + let mut tool_profile_step = setup_step("tool_profile"); + let cfg = crate::core::config::Config::load(); + if cfg.tool_profile.is_none() && std::env::var("LEAN_CTX_TOOL_PROFILE").is_err() { + let lazy_count = crate::tool_defs::core_tool_names().len(); + tool_profile_step.items.push(SetupItem { + name: "tool_profile".to_string(), + status: "lean default".to_string(), + path: None, + note: Some(format!( + "{lazy_count} tools advertised, all reachable via ctx_call \ + (pin more with: lean-ctx tools standard|power)" + )), + }); + } else { + let profile = cfg.tool_profile_effective(); + let overhead_hint = match profile { + crate::core::tool_profiles::ToolProfile::Power => { + "; advertises ALL tool schemas — `lean-ctx tools lean` cuts this to the lazy core" + } + _ => "", + }; + tool_profile_step.items.push(SetupItem { + name: "tool_profile".to_string(), + status: "already".to_string(), + path: None, + note: Some(format!("profile={}{overhead_hint}", profile.as_str())), + }); } - steps.push(tool_profile_step); + tool_profile_step +} - // Step: Proxy autostart + env vars (respects opt-in) - let mut proxy_step = SetupStepReport { - name: "proxy".to_string(), - ok: true, - items: Vec::new(), - warnings: Vec::new(), - errors: Vec::new(), - }; +fn build_proxy_step(opts: SetupOptions, home: &std::path::Path) -> SetupStepReport { + let mut proxy_step = setup_step("proxy"); if opts.skip_proxy { proxy_step.items.push(SetupItem { name: "proxy".to_string(), @@ -450,7 +438,7 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result let proxy_port = crate::proxy_setup::default_port(); crate::proxy_autostart::install(proxy_port, true); std::thread::sleep(std::time::Duration::from_millis(500)); - crate::proxy_setup::install_proxy_env(&home, proxy_port, opts.json); + crate::proxy_setup::install_proxy_env(home, proxy_port, opts.json); proxy_step.items.push(SetupItem { name: "proxy_autostart".to_string(), status: "installed".to_string(), @@ -474,16 +462,11 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result }); } } - steps.push(proxy_step); + proxy_step +} - // Step: Environment / doctor (compact) - let mut env_step = SetupStepReport { - name: "doctor_compact".to_string(), - ok: true, - items: Vec::new(), - warnings: Vec::new(), - errors: Vec::new(), - }; +fn build_doctor_compact_step() -> SetupStepReport { + let mut env_step = setup_step("doctor_compact"); let (passed, total) = crate::doctor::compact_score(); env_step.items.push(SetupItem { name: "doctor".to_string(), @@ -496,57 +479,53 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result "doctor compact not fully passing: {passed}/{total}" )); } - steps.push(env_step); + env_step +} - // Project root validation: warn if no root is configured and cwd is broad +fn maybe_add_project_root_warning(steps: &mut Vec) { + let has_env_root = std::env::var("LEAN_CTX_PROJECT_ROOT").is_ok_and(|v| !v.is_empty()); + let cfg = crate::core::config::Config::load(); + let has_cfg_root = cfg.project_root.as_ref().is_some_and(|v| !v.is_empty()); + if !has_env_root + && !has_cfg_root + && let Ok(cwd) = std::env::current_dir() { - let has_env_root = std::env::var("LEAN_CTX_PROJECT_ROOT").is_ok_and(|v| !v.is_empty()); - let cfg = crate::core::config::Config::load(); - let has_cfg_root = cfg.project_root.as_ref().is_some_and(|v| !v.is_empty()); - if !has_env_root - && !has_cfg_root - && let Ok(cwd) = std::env::current_dir() - { - let is_home = dirs::home_dir().is_some_and(|h| cwd == h); - if is_home { - let mut root_step = SetupStepReport { - name: "project_root".to_string(), - ok: true, - items: Vec::new(), - warnings: vec![ - "No project_root configured. Running from $HOME can cause excessive scanning. \ - Set via: lean-ctx config set project_root /path/to/project".to_string() - ], - errors: Vec::new(), - }; - root_step.items.push(SetupItem { - name: "project_root".to_string(), - status: "unconfigured".to_string(), - path: None, - note: Some( - "Set LEAN_CTX_PROJECT_ROOT or add project_root to config.toml".to_string(), - ), - }); - steps.push(root_step); - } + let is_home = dirs::home_dir().is_some_and(|h| cwd == h); + if is_home { + let mut root_step = SetupStepReport { + name: "project_root".to_string(), + ok: true, + items: Vec::new(), + warnings: vec![ + "No project_root configured. Running from $HOME can cause excessive scanning. \ + Set via: lean-ctx config set project_root /path/to/project" + .to_string(), + ], + errors: Vec::new(), + }; + root_step.items.push(SetupItem { + name: "project_root".to_string(), + status: "unconfigured".to_string(), + path: None, + note: Some( + "Set LEAN_CTX_PROJECT_ROOT or add project_root to config.toml".to_string(), + ), + }); + steps.push(root_step); } } +} - // Auto-build property graph if inside any recognized project. The marker - // probe is TCC-guarded (#356): a launchd-standalone setup run never stats - // markers under ~/Documents — and `may_autoindex_cwd` additionally skips the - // probe for a non-standalone CLI refresh whose cwd is in a protected dir. +fn maybe_spawn_background_index() { if let Ok(cwd) = std::env::current_dir() && may_autoindex_cwd(&cwd) && crate::core::pathutil::has_project_marker(&cwd) { spawn_index_build_background(&cwd); } +} - // IDE config access: the interactive `setup` prompts for informed consent - // (see run_setup). An explicit `--yes` is itself consent, so enable the - // registry-derived opt-in if the user has never decided. `--fix` repair runs - // must never silently widen the jail, so they are left untouched. +fn maybe_enable_ide_config_access(opts: SetupOptions) { if opts.yes && !opts.fix && crate::core::config::Config::load() @@ -556,8 +535,6 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result match crate::core::config::Config::update_global(|c| { c.allow_ide_config_dirs = Some(true); }) { - // --yes is consent, but say so out loud: the user should know the - // path jail now includes IDE config dirs, and how to revert it. Ok(_) => { if !opts.json { println!( @@ -569,10 +546,15 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result Err(e) => tracing::warn!("could not enable IDE config access: {e}"), } } +} +fn build_setup_report( + started_at: chrono::DateTime, + steps: Vec, +) -> SetupReport { let finished_at = Utc::now(); let success = steps.iter().all(|s| s.ok); - let report = SetupReport { + SetupReport { schema_version: 1, started_at, finished_at, @@ -584,13 +566,13 @@ pub fn run_setup_with_options(opts: SetupOptions) -> Result steps, warnings: Vec::new(), errors: Vec::new(), - }; + } +} +fn persist_setup_report(report: &SetupReport) -> Result<(), String> { let path = SetupReport::default_path()?; let mut content = - serde_json::to_string_pretty(&report).map_err(|e| format!("serialize report: {e}"))?; + serde_json::to_string_pretty(report).map_err(|e| format!("serialize report: {e}"))?; content.push('\n'); - crate::config_io::write_atomic(&path, &content)?; - - Ok(report) + crate::config_io::write_atomic(&path, &content) }