Skip to main content

lean_ctx/
setup.rs

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::{recommend_hook_mode, HookMode};
7use chrono::Utc;
8use std::ffi::OsString;
9
10pub fn claude_config_json_path(home: &std::path::Path) -> PathBuf {
11    crate::core::editor_registry::claude_mcp_json_path(home)
12}
13
14pub fn claude_config_dir(home: &std::path::Path) -> PathBuf {
15    crate::core::editor_registry::claude_state_dir(home)
16}
17
18pub(crate) struct EnvVarGuard {
19    key: &'static str,
20    previous: Option<OsString>,
21}
22
23impl EnvVarGuard {
24    pub(crate) fn set(key: &'static str, value: &str) -> Self {
25        let previous = std::env::var_os(key);
26        std::env::set_var(key, value);
27        Self { key, previous }
28    }
29}
30
31impl Drop for EnvVarGuard {
32    fn drop(&mut self) {
33        if let Some(previous) = &self.previous {
34            std::env::set_var(self.key, previous);
35        } else {
36            std::env::remove_var(self.key);
37        }
38    }
39}
40
41pub fn run_setup() {
42    use crate::terminal_ui;
43
44    if crate::shell::is_non_interactive() {
45        eprintln!("Non-interactive terminal detected (no TTY on stdin).");
46        eprintln!("Running in non-interactive mode (equivalent to: lean-ctx setup --non-interactive --yes)");
47        eprintln!();
48        let opts = SetupOptions {
49            non_interactive: true,
50            yes: true,
51            ..Default::default()
52        };
53        match run_setup_with_options(opts) {
54            Ok(report) => {
55                if !report.warnings.is_empty() {
56                    for w in &report.warnings {
57                        tracing::warn!("{w}");
58                    }
59                }
60            }
61            Err(e) => tracing::error!("Setup error: {e}"),
62        }
63        return;
64    }
65
66    let Some(home) = dirs::home_dir() else {
67        tracing::error!("Cannot determine home directory");
68        std::process::exit(1);
69    };
70
71    let binary = resolve_portable_binary();
72
73    let home_str = home.to_string_lossy().to_string();
74
75    terminal_ui::print_setup_header();
76
77    // Step 1: Shell hook (legacy aliases + universal shell hook)
78    terminal_ui::print_step_header(1, 10, "Shell Hook");
79    crate::cli::cmd_init(&["--global".to_string()]);
80    crate::shell_hook::install_all(false);
81
82    // Step 2: Daemon (optional acceleration for CLI routing)
83    terminal_ui::print_step_header(2, 10, "Daemon");
84    #[cfg(unix)]
85    {
86        if crate::daemon::is_daemon_running() {
87            terminal_ui::print_status_ok("Daemon running — restarting with current binary…");
88            let _ = crate::daemon::stop_daemon();
89            std::thread::sleep(std::time::Duration::from_millis(500));
90            if let Err(e) = crate::daemon::start_daemon(&[]) {
91                terminal_ui::print_status_warn(&format!("Daemon restart failed: {e}"));
92            }
93        } else if let Err(e) = crate::daemon::start_daemon(&[]) {
94            terminal_ui::print_status_warn(&format!("Daemon start failed: {e}"));
95        }
96    }
97    #[cfg(not(unix))]
98    {
99        terminal_ui::print_status_skip("Daemon supported on Unix only");
100    }
101
102    // Step 3: Editor auto-detection + configuration
103    terminal_ui::print_step_header(3, 10, "AI Tool Detection");
104
105    let targets = crate::core::editor_registry::build_targets(&home);
106    let mut newly_configured: Vec<&str> = Vec::new();
107    let mut already_configured: Vec<&str> = Vec::new();
108    let mut not_installed: Vec<&str> = Vec::new();
109    let mut errors: Vec<&str> = Vec::new();
110
111    for target in &targets {
112        let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
113
114        if !target.detect_path.exists() {
115            not_installed.push(target.name);
116            continue;
117        }
118
119        let mode = if target.agent_key.is_empty() {
120            HookMode::Mcp
121        } else {
122            recommend_hook_mode(&target.agent_key)
123        };
124
125        if mode == HookMode::CliRedirect {
126            match crate::core::editor_registry::remove_lean_ctx_server(
127                target,
128                WriteOptions {
129                    overwrite_invalid: false,
130                },
131            ) {
132                Ok(res) => {
133                    let status_msg = format!(
134                        "{:<20} \x1b[36m{mode}\x1b[0m  \x1b[2m{short_path} (mcp=disabled)\x1b[0m",
135                        target.name
136                    );
137                    if res.action == WriteAction::Already {
138                        terminal_ui::print_status_ok(&status_msg);
139                        already_configured.push(target.name);
140                    } else {
141                        terminal_ui::print_status_new(&status_msg);
142                        newly_configured.push(target.name);
143                    }
144                }
145                Err(e) => {
146                    terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
147                    errors.push(target.name);
148                }
149            }
150            continue;
151        }
152
153        match crate::core::editor_registry::write_config_with_options(
154            target,
155            &binary,
156            WriteOptions {
157                overwrite_invalid: false,
158            },
159        ) {
160            Ok(res) if res.action == WriteAction::Already => {
161                terminal_ui::print_status_ok(&format!(
162                    "{:<20} \x1b[36m{mode}\x1b[0m  \x1b[2m{short_path}\x1b[0m",
163                    target.name
164                ));
165                already_configured.push(target.name);
166            }
167            Ok(_) => {
168                terminal_ui::print_status_new(&format!(
169                    "{:<20} \x1b[36m{mode}\x1b[0m  \x1b[2m{short_path}\x1b[0m",
170                    target.name
171                ));
172                newly_configured.push(target.name);
173            }
174            Err(e) => {
175                terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
176                errors.push(target.name);
177            }
178        }
179    }
180
181    let total_ok = newly_configured.len() + already_configured.len();
182    if total_ok == 0 && errors.is_empty() {
183        terminal_ui::print_status_warn(
184            "No AI tools detected. Install one and re-run: lean-ctx setup",
185        );
186    }
187
188    if !not_installed.is_empty() {
189        println!(
190            "  \x1b[2m○ {} not detected: {}\x1b[0m",
191            not_installed.len(),
192            not_installed.join(", ")
193        );
194    }
195
196    // Step 4: Agent rules injection
197    terminal_ui::print_step_header(4, 10, "Agent Rules");
198    let rules_result = crate::rules_inject::inject_all_rules(&home);
199    for name in &rules_result.injected {
200        terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules injected\x1b[0m"));
201    }
202    for name in &rules_result.updated {
203        terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules updated\x1b[0m"));
204    }
205    for name in &rules_result.already {
206        terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mrules up-to-date\x1b[0m"));
207    }
208    for err in &rules_result.errors {
209        terminal_ui::print_status_warn(err);
210    }
211    if rules_result.injected.is_empty()
212        && rules_result.updated.is_empty()
213        && rules_result.already.is_empty()
214        && rules_result.errors.is_empty()
215    {
216        terminal_ui::print_status_skip("No agent rules needed");
217    }
218
219    // Agent hooks (mode-aware)
220    for target in &targets {
221        if !target.detect_path.exists() || target.agent_key.is_empty() {
222            continue;
223        }
224        let mode = recommend_hook_mode(&target.agent_key);
225        crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
226    }
227
228    // Step 5: API Proxy configuration
229    terminal_ui::print_step_header(5, 10, "API Proxy");
230    crate::proxy_setup::install_proxy_env(&home, crate::proxy_setup::default_port(), false);
231    println!();
232    println!("  \x1b[2mStart proxy for maximum token savings:\x1b[0m");
233    println!("    \x1b[1mlean-ctx proxy start\x1b[0m");
234    println!("  \x1b[2mEnable autostart:\x1b[0m");
235    println!("    \x1b[1mlean-ctx proxy start --autostart\x1b[0m");
236
237    // Step 6: SKILL.md installation
238    terminal_ui::print_step_header(6, 10, "Skill Files");
239    let skill_result = install_skill_files(&home);
240    for (name, installed) in &skill_result {
241        if *installed {
242            terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mSKILL.md installed\x1b[0m"));
243        } else {
244            terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mSKILL.md up-to-date\x1b[0m"));
245        }
246    }
247    if skill_result.is_empty() {
248        terminal_ui::print_status_skip("No skill directories to install");
249    }
250
251    // Step 7: Data directory + diagnostics
252    terminal_ui::print_step_header(7, 10, "Environment Check");
253    let lean_dir = home.join(".lean-ctx");
254    if lean_dir.exists() {
255        terminal_ui::print_status_ok("~/.lean-ctx/ ready");
256    } else {
257        let _ = std::fs::create_dir_all(&lean_dir);
258        terminal_ui::print_status_new("Created ~/.lean-ctx/");
259    }
260    crate::doctor::run_compact();
261
262    // Step 8: Data sharing
263    terminal_ui::print_step_header(8, 10, "Help Improve lean-ctx");
264    println!("  Share anonymous compression stats to make lean-ctx better.");
265    println!("  \x1b[1mNo code, no file names, no personal data — ever.\x1b[0m");
266    println!();
267    print!("  Enable anonymous data sharing? \x1b[1m[y/N]\x1b[0m ");
268    use std::io::Write;
269    std::io::stdout().flush().ok();
270
271    let mut input = String::new();
272    let contribute = if std::io::stdin().read_line(&mut input).is_ok() {
273        let answer = input.trim().to_lowercase();
274        answer == "y" || answer == "yes"
275    } else {
276        false
277    };
278
279    if contribute {
280        let config_dir = home.join(".lean-ctx");
281        let _ = std::fs::create_dir_all(&config_dir);
282        let config_path = config_dir.join("config.toml");
283        let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
284        if !config_content.contains("[cloud]") {
285            if !config_content.is_empty() && !config_content.ends_with('\n') {
286                config_content.push('\n');
287            }
288            config_content.push_str("\n[cloud]\ncontribute_enabled = true\n");
289            let _ = std::fs::write(&config_path, config_content);
290        }
291        terminal_ui::print_status_ok("Enabled — thank you!");
292    } else {
293        terminal_ui::print_status_skip("Skipped — enable later with: lean-ctx config");
294    }
295
296    // Step 9: Premium Features Configuration
297    terminal_ui::print_step_header(9, 10, "Premium Features");
298    configure_premium_features(&home);
299
300    // Step 10: Code Intelligence — build graph in background
301    terminal_ui::print_step_header(10, 10, "Code Intelligence");
302    let cwd = std::env::current_dir().ok();
303    let is_project = cwd.as_ref().is_some_and(|d| {
304        d.join(".git").exists()
305            || d.join("Cargo.toml").exists()
306            || d.join("package.json").exists()
307            || d.join("go.mod").exists()
308    });
309    if is_project {
310        println!("  \x1b[2mBuilding code graph for graph-aware reads, impact analysis,\x1b[0m");
311        println!("  \x1b[2mand smart search fusion in the background...\x1b[0m");
312        if let Some(ref root) = cwd {
313            spawn_index_build_background(root);
314        }
315        terminal_ui::print_status_ok("Graph build started (background)");
316    } else {
317        println!("  \x1b[2mRun `lean-ctx impact build` inside any git project to enable\x1b[0m");
318        println!("  \x1b[2mgraph-aware reads, impact analysis, and smart search fusion.\x1b[0m");
319    }
320    println!();
321
322    // Auto-approve transparency banner
323    {
324        let tools = crate::core::editor_registry::writers::auto_approve_tools();
325        println!();
326        println!(
327            "  \x1b[33m⚡ Auto-approved tools ({} total):\x1b[0m",
328            tools.len()
329        );
330        for chunk in tools.chunks(6) {
331            let names: Vec<_> = chunk.iter().map(|t| format!("\x1b[2m{t}\x1b[0m")).collect();
332            println!("    {}", names.join(", "));
333        }
334        println!("  \x1b[2mDisable with: lean-ctx setup --no-auto-approve\x1b[0m");
335    }
336
337    // Summary
338    println!();
339    println!(
340        "  \x1b[1;32m✓ Setup complete!\x1b[0m  \x1b[1m{}\x1b[0m configured, \x1b[2m{} already set, {} skipped\x1b[0m",
341        newly_configured.len(),
342        already_configured.len(),
343        not_installed.len()
344    );
345
346    if !errors.is_empty() {
347        println!(
348            "  \x1b[33m⚠ {} error{}: {}\x1b[0m",
349            errors.len(),
350            if errors.len() == 1 { "" } else { "s" },
351            errors.join(", ")
352        );
353    }
354
355    // Next steps
356    let shell = std::env::var("SHELL").unwrap_or_default();
357    let source_cmd = if shell.contains("zsh") {
358        "source ~/.zshrc"
359    } else if shell.contains("fish") {
360        "source ~/.config/fish/config.fish"
361    } else if shell.contains("bash") {
362        "source ~/.bashrc"
363    } else {
364        "Restart your shell"
365    };
366
367    let dim = "\x1b[2m";
368    let bold = "\x1b[1m";
369    let cyan = "\x1b[36m";
370    let yellow = "\x1b[33m";
371    let rst = "\x1b[0m";
372
373    println!();
374    println!("  {bold}Next steps:{rst}");
375    println!();
376    println!("  {cyan}1.{rst} Reload your shell:");
377    println!("     {bold}{source_cmd}{rst}");
378    println!();
379
380    let mut tools_to_restart: Vec<String> = newly_configured
381        .iter()
382        .map(std::string::ToString::to_string)
383        .collect();
384    for name in rules_result
385        .injected
386        .iter()
387        .chain(rules_result.updated.iter())
388    {
389        if !tools_to_restart.iter().any(|t| t == name) {
390            tools_to_restart.push(name.clone());
391        }
392    }
393
394    if !tools_to_restart.is_empty() {
395        println!("  {cyan}2.{rst} {yellow}{bold}Restart your IDE / AI tool:{rst}");
396        println!("     {bold}{}{rst}", tools_to_restart.join(", "));
397        println!(
398            "     {dim}Changes take effect after a full restart (MCP may be enabled or disabled depending on mode).{rst}"
399        );
400        println!("     {dim}Close and re-open the application completely.{rst}");
401    } else if !already_configured.is_empty() {
402        println!(
403            "  {cyan}2.{rst} {dim}Your tools are already configured — no restart needed.{rst}"
404        );
405    }
406
407    println!();
408    println!(
409        "  {dim}After restart, lean-ctx will automatically optimize every AI interaction.{rst}"
410    );
411    println!("  {dim}Verify with:{rst} {bold}lean-ctx gain{rst}");
412
413    // Logo + commands
414    println!();
415    terminal_ui::print_logo_animated();
416    terminal_ui::print_command_box();
417}
418
419#[derive(Debug, Clone, Copy, Default)]
420pub struct SetupOptions {
421    pub non_interactive: bool,
422    pub yes: bool,
423    pub fix: bool,
424    pub json: bool,
425    pub no_auto_approve: bool,
426}
427
428pub fn run_setup_with_options(opts: SetupOptions) -> Result<SetupReport, String> {
429    let _quiet_guard = opts.json.then(|| EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
430    let started_at = Utc::now();
431    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
432    let binary = resolve_portable_binary();
433    let home_str = home.to_string_lossy().to_string();
434
435    let mut steps: Vec<SetupStepReport> = Vec::new();
436
437    // Step: Shell Hook
438    let mut shell_step = SetupStepReport {
439        name: "shell_hook".to_string(),
440        ok: true,
441        items: Vec::new(),
442        warnings: Vec::new(),
443        errors: Vec::new(),
444    };
445    if !opts.non_interactive || opts.yes {
446        if opts.json {
447            crate::cli::cmd_init_quiet(&["--global".to_string()]);
448        } else {
449            crate::cli::cmd_init(&["--global".to_string()]);
450        }
451        crate::shell_hook::install_all(opts.json);
452        #[cfg(not(windows))]
453        {
454            // Ensure Docker/CI shells can source lean-ctx hooks via BASH_ENV / CLAUDE_ENV_FILE.
455            let hook_content = crate::cli::generate_hook_posix(&binary);
456            crate::cli::write_env_sh_for_containers(&hook_content);
457            shell_step.items.push(SetupItem {
458                name: "env_sh".to_string(),
459                status: "created".to_string(),
460                path: Some("~/.lean-ctx/env.sh".to_string()),
461                note: Some("Docker/CI helper (BASH_ENV / CLAUDE_ENV_FILE)".to_string()),
462            });
463        }
464        shell_step.items.push(SetupItem {
465            name: "init --global".to_string(),
466            status: "ran".to_string(),
467            path: None,
468            note: None,
469        });
470        shell_step.items.push(SetupItem {
471            name: "universal_shell_hook".to_string(),
472            status: "installed".to_string(),
473            path: None,
474            note: Some("~/.zshenv, ~/.bashenv, agent aliases".to_string()),
475        });
476    } else {
477        shell_step
478            .warnings
479            .push("non_interactive_without_yes: shell hook not installed (use --yes)".to_string());
480        shell_step.ok = false;
481        shell_step.items.push(SetupItem {
482            name: "init --global".to_string(),
483            status: "skipped".to_string(),
484            path: None,
485            note: Some("requires --yes in --non-interactive mode".to_string()),
486        });
487    }
488    steps.push(shell_step);
489
490    // Step: Daemon (optional acceleration for CLI routing)
491    let mut daemon_step = SetupStepReport {
492        name: "daemon".to_string(),
493        ok: true,
494        items: Vec::new(),
495        warnings: Vec::new(),
496        errors: Vec::new(),
497    };
498    #[cfg(unix)]
499    {
500        let was_running = crate::daemon::is_daemon_running();
501        if was_running {
502            let _ = crate::daemon::stop_daemon();
503            std::thread::sleep(std::time::Duration::from_millis(500));
504        }
505        match crate::daemon::start_daemon(&[]) {
506            Ok(()) => {
507                let action = if was_running { "restarted" } else { "started" };
508                daemon_step.items.push(SetupItem {
509                    name: "serve --daemon".to_string(),
510                    status: action.to_string(),
511                    path: Some(
512                        crate::daemon::daemon_socket_path()
513                            .to_string_lossy()
514                            .to_string(),
515                    ),
516                    note: Some("CLI commands can route via UDS when running".to_string()),
517                });
518            }
519            Err(e) => {
520                daemon_step.ok = false;
521                daemon_step
522                    .warnings
523                    .push(format!("daemon start failed: {e}"));
524                daemon_step.items.push(SetupItem {
525                    name: "serve --daemon".to_string(),
526                    status: "error".to_string(),
527                    path: Some(
528                        crate::daemon::daemon_socket_path()
529                            .to_string_lossy()
530                            .to_string(),
531                    ),
532                    note: Some(e.to_string()),
533                });
534            }
535        }
536    }
537    #[cfg(not(unix))]
538    {
539        daemon_step.items.push(SetupItem {
540            name: "serve --daemon".to_string(),
541            status: "skipped".to_string(),
542            path: None,
543            note: Some("daemon supported on Unix only".to_string()),
544        });
545    }
546    steps.push(daemon_step);
547
548    // Step: Editor MCP config
549    let mut editor_step = SetupStepReport {
550        name: "editors".to_string(),
551        ok: true,
552        items: Vec::new(),
553        warnings: Vec::new(),
554        errors: Vec::new(),
555    };
556
557    let targets = crate::core::editor_registry::build_targets(&home);
558    for target in &targets {
559        let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
560        if !target.detect_path.exists() {
561            editor_step.items.push(SetupItem {
562                name: target.name.to_string(),
563                status: "not_detected".to_string(),
564                path: Some(short_path),
565                note: None,
566            });
567            continue;
568        }
569
570        let mode = if target.agent_key.is_empty() {
571            HookMode::Mcp
572        } else {
573            recommend_hook_mode(&target.agent_key)
574        };
575
576        // CLI-redirect means: do NOT configure MCP (avoid tool schema overhead).
577        // If lean-ctx was previously configured, proactively remove it from the editor config.
578        if mode == HookMode::CliRedirect {
579            let res = crate::core::editor_registry::remove_lean_ctx_server(
580                target,
581                WriteOptions {
582                    overwrite_invalid: opts.fix,
583                },
584            );
585            match res {
586                Ok(w) => {
587                    let note_parts: Vec<String> = [
588                        Some(format!("mode={mode}")),
589                        Some("mcp=disabled".to_string()),
590                        w.note,
591                    ]
592                    .into_iter()
593                    .flatten()
594                    .collect();
595                    editor_step.items.push(SetupItem {
596                        name: target.name.to_string(),
597                        status: match w.action {
598                            WriteAction::Created => "created".to_string(),
599                            WriteAction::Updated => "updated".to_string(),
600                            WriteAction::Already => "already".to_string(),
601                        },
602                        path: Some(short_path),
603                        note: Some(note_parts.join("; ")),
604                    });
605                }
606                Err(e) => {
607                    editor_step.ok = false;
608                    editor_step.items.push(SetupItem {
609                        name: target.name.to_string(),
610                        status: "error".to_string(),
611                        path: Some(short_path),
612                        note: Some(format!("mode={mode}; mcp=disable_failed; {e}")),
613                    });
614                }
615            }
616            continue;
617        }
618
619        let res = crate::core::editor_registry::write_config_with_options(
620            target,
621            &binary,
622            WriteOptions {
623                overwrite_invalid: opts.fix,
624            },
625        );
626        match res {
627            Ok(w) => {
628                let note_parts: Vec<String> = [Some(format!("mode={mode}")), w.note]
629                    .into_iter()
630                    .flatten()
631                    .collect();
632                editor_step.items.push(SetupItem {
633                    name: target.name.to_string(),
634                    status: match w.action {
635                        WriteAction::Created => "created".to_string(),
636                        WriteAction::Updated => "updated".to_string(),
637                        WriteAction::Already => "already".to_string(),
638                    },
639                    path: Some(short_path),
640                    note: Some(note_parts.join("; ")),
641                });
642            }
643            Err(e) => {
644                editor_step.ok = false;
645                editor_step.items.push(SetupItem {
646                    name: target.name.to_string(),
647                    status: "error".to_string(),
648                    path: Some(short_path),
649                    note: Some(e),
650                });
651            }
652        }
653    }
654    steps.push(editor_step);
655
656    // Step: Agent rules
657    let mut rules_step = SetupStepReport {
658        name: "agent_rules".to_string(),
659        ok: true,
660        items: Vec::new(),
661        warnings: Vec::new(),
662        errors: Vec::new(),
663    };
664    let rules_result = crate::rules_inject::inject_all_rules(&home);
665    for n in rules_result.injected {
666        rules_step.items.push(SetupItem {
667            name: n,
668            status: "injected".to_string(),
669            path: None,
670            note: None,
671        });
672    }
673    for n in rules_result.updated {
674        rules_step.items.push(SetupItem {
675            name: n,
676            status: "updated".to_string(),
677            path: None,
678            note: None,
679        });
680    }
681    for n in rules_result.already {
682        rules_step.items.push(SetupItem {
683            name: n,
684            status: "already".to_string(),
685            path: None,
686            note: None,
687        });
688    }
689    for e in rules_result.errors {
690        rules_step.ok = false;
691        rules_step.errors.push(e);
692    }
693    steps.push(rules_step);
694
695    // Step: Skill files
696    let mut skill_step = SetupStepReport {
697        name: "skill_files".to_string(),
698        ok: true,
699        items: Vec::new(),
700        warnings: Vec::new(),
701        errors: Vec::new(),
702    };
703    let skill_results = crate::rules_inject::install_all_skills(&home);
704    for (name, is_new) in &skill_results {
705        skill_step.items.push(SetupItem {
706            name: name.clone(),
707            status: if *is_new { "installed" } else { "already" }.to_string(),
708            path: None,
709            note: Some("SKILL.md".to_string()),
710        });
711    }
712    if !skill_step.items.is_empty() {
713        steps.push(skill_step);
714    }
715
716    // Step: Agent-specific hooks (all detected agents)
717    let mut hooks_step = SetupStepReport {
718        name: "agent_hooks".to_string(),
719        ok: true,
720        items: Vec::new(),
721        warnings: Vec::new(),
722        errors: Vec::new(),
723    };
724    for target in &targets {
725        if !target.detect_path.exists() || target.agent_key.is_empty() {
726            continue;
727        }
728        let mode = recommend_hook_mode(&target.agent_key);
729        crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
730        hooks_step.items.push(SetupItem {
731            name: format!("{} hooks", target.name),
732            status: "installed".to_string(),
733            path: Some(target.detect_path.to_string_lossy().to_string()),
734            note: Some(format!(
735                "mode={mode}; merge-based install/repair (preserves other hooks/plugins)"
736            )),
737        });
738    }
739    if !hooks_step.items.is_empty() {
740        steps.push(hooks_step);
741    }
742
743    // Step: Proxy env vars
744    let mut proxy_step = SetupStepReport {
745        name: "proxy_env".to_string(),
746        ok: true,
747        items: Vec::new(),
748        warnings: Vec::new(),
749        errors: Vec::new(),
750    };
751    crate::proxy_setup::install_proxy_env(&home, crate::proxy_setup::default_port(), opts.json);
752    proxy_step.items.push(SetupItem {
753        name: "proxy_env".to_string(),
754        status: "configured".to_string(),
755        path: None,
756        note: Some("ANTHROPIC_BASE_URL, OPENAI_BASE_URL, GEMINI_API_BASE_URL".to_string()),
757    });
758    steps.push(proxy_step);
759
760    // Step: Environment / doctor (compact)
761    let mut env_step = SetupStepReport {
762        name: "doctor_compact".to_string(),
763        ok: true,
764        items: Vec::new(),
765        warnings: Vec::new(),
766        errors: Vec::new(),
767    };
768    let (passed, total) = crate::doctor::compact_score();
769    env_step.items.push(SetupItem {
770        name: "doctor".to_string(),
771        status: format!("{passed}/{total}"),
772        path: None,
773        note: None,
774    });
775    if passed != total {
776        env_step.warnings.push(format!(
777            "doctor compact not fully passing: {passed}/{total}"
778        ));
779    }
780    steps.push(env_step);
781
782    // Auto-build property graph if inside any recognized project
783    if let Ok(cwd) = std::env::current_dir() {
784        let is_project = cwd.join(".git").exists()
785            || cwd.join("Cargo.toml").exists()
786            || cwd.join("package.json").exists()
787            || cwd.join("go.mod").exists();
788        if is_project {
789            spawn_index_build_background(&cwd);
790        }
791    }
792
793    let finished_at = Utc::now();
794    let success = steps.iter().all(|s| s.ok);
795    let report = SetupReport {
796        schema_version: 1,
797        started_at,
798        finished_at,
799        success,
800        platform: PlatformInfo {
801            os: std::env::consts::OS.to_string(),
802            arch: std::env::consts::ARCH.to_string(),
803        },
804        steps,
805        warnings: Vec::new(),
806        errors: Vec::new(),
807    };
808
809    let path = SetupReport::default_path()?;
810    let mut content =
811        serde_json::to_string_pretty(&report).map_err(|e| format!("serialize report: {e}"))?;
812    content.push('\n');
813    crate::config_io::write_atomic(&path, &content)?;
814
815    Ok(report)
816}
817
818fn spawn_index_build_background(root: &std::path::Path) {
819    let binary = std::env::current_exe().map_or_else(
820        |_| resolve_portable_binary(),
821        |p| p.to_string_lossy().to_string(),
822    );
823    let _ = std::process::Command::new(&binary)
824        .args(["index", "build-graph", "--root"])
825        .arg(root)
826        .stdout(std::process::Stdio::null())
827        .stderr(std::process::Stdio::null())
828        .stdin(std::process::Stdio::null())
829        .spawn();
830}
831
832pub fn configure_agent_mcp(agent: &str) -> Result<(), String> {
833    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
834    let binary = resolve_portable_binary();
835
836    let targets = agent_mcp_targets(agent, &home)?;
837
838    for t in &targets {
839        crate::core::editor_registry::write_config_with_options(
840            t,
841            &binary,
842            WriteOptions {
843                overwrite_invalid: true,
844            },
845        )?;
846    }
847
848    if agent == "kiro" {
849        install_kiro_steering(&home);
850    }
851
852    Ok(())
853}
854
855fn agent_mcp_targets(agent: &str, home: &std::path::Path) -> Result<Vec<EditorTarget>, String> {
856    let mut targets = Vec::<EditorTarget>::new();
857
858    let push = |targets: &mut Vec<EditorTarget>,
859                name: &'static str,
860                config_path: PathBuf,
861                config_type: ConfigType| {
862        targets.push(EditorTarget {
863            name,
864            agent_key: agent.to_string(),
865            detect_path: PathBuf::from("/nonexistent"), // not used in direct agent config
866            config_path,
867            config_type,
868        });
869    };
870
871    let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
872
873    match agent {
874        "cursor" => push(
875            &mut targets,
876            "Cursor",
877            home.join(".cursor/mcp.json"),
878            ConfigType::McpJson,
879        ),
880        "claude" | "claude-code" => push(
881            &mut targets,
882            "Claude Code",
883            crate::core::editor_registry::claude_mcp_json_path(home),
884            ConfigType::McpJson,
885        ),
886        "windsurf" => push(
887            &mut targets,
888            "Windsurf",
889            home.join(".codeium/windsurf/mcp_config.json"),
890            ConfigType::McpJson,
891        ),
892        "codex" => {
893            let codex_dir =
894                crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
895            push(
896                &mut targets,
897                "Codex CLI",
898                codex_dir.join("config.toml"),
899                ConfigType::Codex,
900            );
901        }
902        "gemini" => {
903            push(
904                &mut targets,
905                "Gemini CLI",
906                home.join(".gemini/settings.json"),
907                ConfigType::GeminiSettings,
908            );
909            push(
910                &mut targets,
911                "Antigravity",
912                home.join(".gemini/antigravity/mcp_config.json"),
913                ConfigType::McpJson,
914            );
915        }
916        "antigravity" => push(
917            &mut targets,
918            "Antigravity",
919            home.join(".gemini/antigravity/mcp_config.json"),
920            ConfigType::McpJson,
921        ),
922        "copilot" => push(
923            &mut targets,
924            "VS Code / Copilot",
925            crate::core::editor_registry::vscode_mcp_path(),
926            ConfigType::VsCodeMcp,
927        ),
928        "crush" => push(
929            &mut targets,
930            "Crush",
931            home.join(".config/crush/crush.json"),
932            ConfigType::Crush,
933        ),
934        "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
935        "qoder" => {
936            for path in crate::core::editor_registry::qoder_all_mcp_paths(home) {
937                push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
938            }
939        }
940        "qoderwork" => push(
941            &mut targets,
942            "QoderWork",
943            crate::core::editor_registry::qoderwork_mcp_path(home),
944            ConfigType::McpJson,
945        ),
946        "cline" => push(
947            &mut targets,
948            "Cline",
949            crate::core::editor_registry::cline_mcp_path(),
950            ConfigType::McpJson,
951        ),
952        "roo" => push(
953            &mut targets,
954            "Roo Code",
955            crate::core::editor_registry::roo_mcp_path(),
956            ConfigType::McpJson,
957        ),
958        "kiro" => push(
959            &mut targets,
960            "AWS Kiro",
961            home.join(".kiro/settings/mcp.json"),
962            ConfigType::McpJson,
963        ),
964        "verdent" => push(
965            &mut targets,
966            "Verdent",
967            home.join(".verdent/mcp.json"),
968            ConfigType::McpJson,
969        ),
970        "jetbrains" | "amp" => {
971            // Handled by dedicated install hooks (servers[] array / amp.mcpServers)
972        }
973        "qwen" => push(
974            &mut targets,
975            "Qwen Code",
976            home.join(".qwen/settings.json"),
977            ConfigType::McpJson,
978        ),
979        "trae" => push(
980            &mut targets,
981            "Trae",
982            home.join(".trae/mcp.json"),
983            ConfigType::McpJson,
984        ),
985        "amazonq" => push(
986            &mut targets,
987            "Amazon Q Developer",
988            home.join(".aws/amazonq/default.json"),
989            ConfigType::McpJson,
990        ),
991        "opencode" => {
992            #[cfg(windows)]
993            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
994                std::path::PathBuf::from(appdata)
995                    .join("opencode")
996                    .join("opencode.json")
997            } else {
998                home.join(".config/opencode/opencode.json")
999            };
1000            #[cfg(not(windows))]
1001            let opencode_path = home.join(".config/opencode/opencode.json");
1002            push(
1003                &mut targets,
1004                "OpenCode",
1005                opencode_path,
1006                ConfigType::OpenCode,
1007            );
1008        }
1009        "hermes" => push(
1010            &mut targets,
1011            "Hermes Agent",
1012            home.join(".hermes/config.yaml"),
1013            ConfigType::HermesYaml,
1014        ),
1015        "vscode" => push(
1016            &mut targets,
1017            "VS Code",
1018            crate::core::editor_registry::vscode_mcp_path(),
1019            ConfigType::VsCodeMcp,
1020        ),
1021        "zed" => push(
1022            &mut targets,
1023            "Zed",
1024            crate::core::editor_registry::zed_settings_path(home),
1025            ConfigType::Zed,
1026        ),
1027        "aider" => push(
1028            &mut targets,
1029            "Aider",
1030            home.join(".aider/mcp.json"),
1031            ConfigType::McpJson,
1032        ),
1033        "continue" => push(
1034            &mut targets,
1035            "Continue",
1036            home.join(".continue/mcp.json"),
1037            ConfigType::McpJson,
1038        ),
1039        "neovim" => push(
1040            &mut targets,
1041            "Neovim (mcphub.nvim)",
1042            home.join(".config/mcphub/servers.json"),
1043            ConfigType::McpJson,
1044        ),
1045        "emacs" => push(
1046            &mut targets,
1047            "Emacs (mcp.el)",
1048            home.join(".emacs.d/mcp.json"),
1049            ConfigType::McpJson,
1050        ),
1051        "sublime" => push(
1052            &mut targets,
1053            "Sublime Text",
1054            home.join(".config/sublime-text/mcp.json"),
1055            ConfigType::McpJson,
1056        ),
1057        _ => {
1058            return Err(format!("Unknown agent '{agent}'"));
1059        }
1060    }
1061
1062    Ok(targets)
1063}
1064
1065pub fn disable_agent_mcp(agent: &str, overwrite_invalid: bool) -> Result<(), String> {
1066    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
1067
1068    let mut targets = Vec::<EditorTarget>::new();
1069
1070    let push = |targets: &mut Vec<EditorTarget>,
1071                name: &'static str,
1072                config_path: PathBuf,
1073                config_type: ConfigType| {
1074        targets.push(EditorTarget {
1075            name,
1076            agent_key: agent.to_string(),
1077            detect_path: PathBuf::from("/nonexistent"),
1078            config_path,
1079            config_type,
1080        });
1081    };
1082
1083    let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
1084
1085    match agent {
1086        "cursor" => push(
1087            &mut targets,
1088            "Cursor",
1089            home.join(".cursor/mcp.json"),
1090            ConfigType::McpJson,
1091        ),
1092        "claude" | "claude-code" => push(
1093            &mut targets,
1094            "Claude Code",
1095            crate::core::editor_registry::claude_mcp_json_path(&home),
1096            ConfigType::McpJson,
1097        ),
1098        "windsurf" => push(
1099            &mut targets,
1100            "Windsurf",
1101            home.join(".codeium/windsurf/mcp_config.json"),
1102            ConfigType::McpJson,
1103        ),
1104        "codex" => {
1105            let codex_dir =
1106                crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
1107            push(
1108                &mut targets,
1109                "Codex CLI",
1110                codex_dir.join("config.toml"),
1111                ConfigType::Codex,
1112            );
1113        }
1114        "gemini" => {
1115            push(
1116                &mut targets,
1117                "Gemini CLI",
1118                home.join(".gemini/settings.json"),
1119                ConfigType::GeminiSettings,
1120            );
1121            push(
1122                &mut targets,
1123                "Antigravity",
1124                home.join(".gemini/antigravity/mcp_config.json"),
1125                ConfigType::McpJson,
1126            );
1127        }
1128        "antigravity" => push(
1129            &mut targets,
1130            "Antigravity",
1131            home.join(".gemini/antigravity/mcp_config.json"),
1132            ConfigType::McpJson,
1133        ),
1134        "copilot" => push(
1135            &mut targets,
1136            "VS Code / Copilot",
1137            crate::core::editor_registry::vscode_mcp_path(),
1138            ConfigType::VsCodeMcp,
1139        ),
1140        "crush" => push(
1141            &mut targets,
1142            "Crush",
1143            home.join(".config/crush/crush.json"),
1144            ConfigType::Crush,
1145        ),
1146        "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
1147        "qoder" => {
1148            for path in crate::core::editor_registry::qoder_all_mcp_paths(&home) {
1149                push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
1150            }
1151        }
1152        "qoderwork" => push(
1153            &mut targets,
1154            "QoderWork",
1155            crate::core::editor_registry::qoderwork_mcp_path(&home),
1156            ConfigType::McpJson,
1157        ),
1158        "cline" => push(
1159            &mut targets,
1160            "Cline",
1161            crate::core::editor_registry::cline_mcp_path(),
1162            ConfigType::McpJson,
1163        ),
1164        "roo" => push(
1165            &mut targets,
1166            "Roo Code",
1167            crate::core::editor_registry::roo_mcp_path(),
1168            ConfigType::McpJson,
1169        ),
1170        "kiro" => push(
1171            &mut targets,
1172            "AWS Kiro",
1173            home.join(".kiro/settings/mcp.json"),
1174            ConfigType::McpJson,
1175        ),
1176        "verdent" => push(
1177            &mut targets,
1178            "Verdent",
1179            home.join(".verdent/mcp.json"),
1180            ConfigType::McpJson,
1181        ),
1182        "jetbrains" | "amp" => {
1183            // Not supported for disable via this helper.
1184        }
1185        "qwen" => push(
1186            &mut targets,
1187            "Qwen Code",
1188            home.join(".qwen/settings.json"),
1189            ConfigType::McpJson,
1190        ),
1191        "trae" => push(
1192            &mut targets,
1193            "Trae",
1194            home.join(".trae/mcp.json"),
1195            ConfigType::McpJson,
1196        ),
1197        "amazonq" => push(
1198            &mut targets,
1199            "Amazon Q Developer",
1200            home.join(".aws/amazonq/default.json"),
1201            ConfigType::McpJson,
1202        ),
1203        "opencode" => {
1204            #[cfg(windows)]
1205            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
1206                std::path::PathBuf::from(appdata)
1207                    .join("opencode")
1208                    .join("opencode.json")
1209            } else {
1210                home.join(".config/opencode/opencode.json")
1211            };
1212            #[cfg(not(windows))]
1213            let opencode_path = home.join(".config/opencode/opencode.json");
1214            push(
1215                &mut targets,
1216                "OpenCode",
1217                opencode_path,
1218                ConfigType::OpenCode,
1219            );
1220        }
1221        "hermes" => push(
1222            &mut targets,
1223            "Hermes Agent",
1224            home.join(".hermes/config.yaml"),
1225            ConfigType::HermesYaml,
1226        ),
1227        "vscode" => push(
1228            &mut targets,
1229            "VS Code",
1230            crate::core::editor_registry::vscode_mcp_path(),
1231            ConfigType::VsCodeMcp,
1232        ),
1233        "zed" => push(
1234            &mut targets,
1235            "Zed",
1236            crate::core::editor_registry::zed_settings_path(&home),
1237            ConfigType::Zed,
1238        ),
1239        "aider" => push(
1240            &mut targets,
1241            "Aider",
1242            home.join(".aider/mcp.json"),
1243            ConfigType::McpJson,
1244        ),
1245        "continue" => push(
1246            &mut targets,
1247            "Continue",
1248            home.join(".continue/mcp.json"),
1249            ConfigType::McpJson,
1250        ),
1251        "neovim" => push(
1252            &mut targets,
1253            "Neovim (mcphub.nvim)",
1254            home.join(".config/mcphub/servers.json"),
1255            ConfigType::McpJson,
1256        ),
1257        "emacs" => push(
1258            &mut targets,
1259            "Emacs (mcp.el)",
1260            home.join(".emacs.d/mcp.json"),
1261            ConfigType::McpJson,
1262        ),
1263        "sublime" => push(
1264            &mut targets,
1265            "Sublime Text",
1266            home.join(".config/sublime-text/mcp.json"),
1267            ConfigType::McpJson,
1268        ),
1269        _ => {
1270            return Err(format!("Unknown agent '{agent}'"));
1271        }
1272    }
1273
1274    for t in &targets {
1275        crate::core::editor_registry::remove_lean_ctx_server(
1276            t,
1277            WriteOptions { overwrite_invalid },
1278        )?;
1279    }
1280
1281    Ok(())
1282}
1283
1284pub fn install_skill_files(home: &std::path::Path) -> Vec<(String, bool)> {
1285    crate::rules_inject::install_all_skills(home)
1286}
1287
1288fn install_kiro_steering(home: &std::path::Path) {
1289    let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
1290    let steering_dir = cwd.join(".kiro").join("steering");
1291    let steering_file = steering_dir.join("lean-ctx.md");
1292
1293    if steering_file.exists()
1294        && std::fs::read_to_string(&steering_file)
1295            .unwrap_or_default()
1296            .contains("lean-ctx")
1297    {
1298        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1299        return;
1300    }
1301
1302    let _ = std::fs::create_dir_all(&steering_dir);
1303    let _ = std::fs::write(&steering_file, crate::hooks::KIRO_STEERING_TEMPLATE);
1304    println!("  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1305}
1306
1307fn shorten_path(path: &str, home: &str) -> String {
1308    if let Some(stripped) = path.strip_prefix(home) {
1309        format!("~{stripped}")
1310    } else {
1311        path.to_string()
1312    }
1313}
1314
1315fn upsert_toml_key(content: &mut String, key: &str, value: &str) {
1316    let pattern = format!("{key} = ");
1317    if let Some(start) = content.find(&pattern) {
1318        let line_end = content[start..]
1319            .find('\n')
1320            .map_or(content.len(), |p| start + p);
1321        content.replace_range(start..line_end, &format!("{key} = \"{value}\""));
1322    } else {
1323        if !content.is_empty() && !content.ends_with('\n') {
1324            content.push('\n');
1325        }
1326        content.push_str(&format!("{key} = \"{value}\"\n"));
1327    }
1328}
1329
1330fn remove_toml_key(content: &mut String, key: &str) {
1331    let pattern = format!("{key} = ");
1332    if let Some(start) = content.find(&pattern) {
1333        let line_end = content[start..]
1334            .find('\n')
1335            .map_or(content.len(), |p| start + p + 1);
1336        content.replace_range(start..line_end, "");
1337    }
1338}
1339
1340fn configure_premium_features(home: &std::path::Path) {
1341    use crate::terminal_ui;
1342    use std::io::Write;
1343
1344    let config_dir = home.join(".lean-ctx");
1345    let _ = std::fs::create_dir_all(&config_dir);
1346    let config_path = config_dir.join("config.toml");
1347    let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
1348
1349    let dim = "\x1b[2m";
1350    let bold = "\x1b[1m";
1351    let cyan = "\x1b[36m";
1352    let rst = "\x1b[0m";
1353
1354    // Unified Compression Level (replaces terse_agent + output_density)
1355    println!("\n  {bold}Compression Level{rst} {dim}(controls all token optimization layers){rst}");
1356    println!("  {dim}Applies to tool output, agent prompts, and protocol mode.{rst}");
1357    println!();
1358    println!("  {cyan}off{rst}      — No compression (full verbose output)");
1359    println!("  {cyan}lite{rst}     — Light: concise output, basic terse filtering {dim}(~25% savings){rst}");
1360    println!("  {cyan}standard{rst} — Dense output + compact protocol + pattern-aware {dim}(~45% savings){rst}");
1361    println!("  {cyan}max{rst}      — Expert mode: TDD protocol, all layers active {dim}(~65% savings){rst}");
1362    println!();
1363    print!("  Compression level? {bold}[off/lite/standard/max]{rst} {dim}(default: off){rst} ");
1364    std::io::stdout().flush().ok();
1365
1366    let mut level_input = String::new();
1367    let level = if std::io::stdin().read_line(&mut level_input).is_ok() {
1368        match level_input.trim().to_lowercase().as_str() {
1369            "lite" => "lite",
1370            "standard" | "std" => "standard",
1371            "max" => "max",
1372            _ => "off",
1373        }
1374    } else {
1375        "off"
1376    };
1377
1378    let effective_level = if level != "off" {
1379        upsert_toml_key(&mut config_content, "compression_level", level);
1380        remove_toml_key(&mut config_content, "terse_agent");
1381        remove_toml_key(&mut config_content, "output_density");
1382        terminal_ui::print_status_ok(&format!("Compression: {level}"));
1383        crate::core::config::CompressionLevel::from_str_label(level)
1384    } else if config_content.contains("compression_level") {
1385        upsert_toml_key(&mut config_content, "compression_level", "off");
1386        terminal_ui::print_status_ok("Compression: off");
1387        Some(crate::core::config::CompressionLevel::Off)
1388    } else {
1389        terminal_ui::print_status_skip(
1390            "Compression: off (change later with: lean-ctx compression <level>)",
1391        );
1392        Some(crate::core::config::CompressionLevel::Off)
1393    };
1394
1395    if let Some(lvl) = effective_level {
1396        let n = crate::core::terse::rules_inject::inject(&lvl);
1397        if n > 0 {
1398            terminal_ui::print_status_ok(&format!(
1399                "Updated {n} rules file(s) with compression prompt"
1400            ));
1401        }
1402    }
1403
1404    // Tool Result Archive (unchanged)
1405    println!(
1406        "\n  {bold}Tool Result Archive{rst} {dim}(zero-loss: large outputs archived, retrievable via ctx_expand){rst}"
1407    );
1408    print!("  Enable auto-archive? {bold}[Y/n]{rst} ");
1409    std::io::stdout().flush().ok();
1410
1411    let mut archive_input = String::new();
1412    let archive_on = if std::io::stdin().read_line(&mut archive_input).is_ok() {
1413        let a = archive_input.trim().to_lowercase();
1414        a.is_empty() || a == "y" || a == "yes"
1415    } else {
1416        true
1417    };
1418
1419    if archive_on && !config_content.contains("[archive]") {
1420        if !config_content.is_empty() && !config_content.ends_with('\n') {
1421            config_content.push('\n');
1422        }
1423        config_content.push_str("\n[archive]\nenabled = true\n");
1424        terminal_ui::print_status_ok("Tool Result Archive: enabled");
1425    } else if !archive_on {
1426        terminal_ui::print_status_skip("Archive: off (enable later in config.toml)");
1427    }
1428
1429    let _ = std::fs::write(&config_path, config_content);
1430}
1431
1432#[cfg(all(test, target_os = "macos"))]
1433mod tests {
1434    use super::*;
1435
1436    #[test]
1437    #[cfg(target_os = "macos")]
1438    fn qoder_agent_targets_include_all_macos_mcp_locations() {
1439        let home = std::path::Path::new("/Users/tester");
1440        let targets = agent_mcp_targets("qoder", home).unwrap();
1441        let paths: Vec<_> = targets.iter().map(|t| t.config_path.as_path()).collect();
1442
1443        assert_eq!(
1444            paths,
1445            vec![
1446                home.join(".qoder/mcp.json").as_path(),
1447                home.join("Library/Application Support/Qoder/User/mcp.json")
1448                    .as_path(),
1449                home.join("Library/Application Support/Qoder/SharedClientCache/mcp.json")
1450                    .as_path(),
1451            ]
1452        );
1453        assert!(targets
1454            .iter()
1455            .all(|t| t.config_type == ConfigType::QoderSettings));
1456    }
1457}