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 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}