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