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" => push(
893            &mut targets,
894            "Codex CLI",
895            home.join(".codex/config.toml"),
896            ConfigType::Codex,
897        ),
898        "gemini" => {
899            push(
900                &mut targets,
901                "Gemini CLI",
902                home.join(".gemini/settings.json"),
903                ConfigType::GeminiSettings,
904            );
905            push(
906                &mut targets,
907                "Antigravity",
908                home.join(".gemini/antigravity/mcp_config.json"),
909                ConfigType::McpJson,
910            );
911        }
912        "antigravity" => push(
913            &mut targets,
914            "Antigravity",
915            home.join(".gemini/antigravity/mcp_config.json"),
916            ConfigType::McpJson,
917        ),
918        "copilot" => push(
919            &mut targets,
920            "VS Code / Copilot",
921            crate::core::editor_registry::vscode_mcp_path(),
922            ConfigType::VsCodeMcp,
923        ),
924        "crush" => push(
925            &mut targets,
926            "Crush",
927            home.join(".config/crush/crush.json"),
928            ConfigType::Crush,
929        ),
930        "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
931        "qoder" => {
932            for path in crate::core::editor_registry::qoder_all_mcp_paths(home) {
933                push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
934            }
935        }
936        "qoderwork" => push(
937            &mut targets,
938            "QoderWork",
939            crate::core::editor_registry::qoderwork_mcp_path(home),
940            ConfigType::McpJson,
941        ),
942        "cline" => push(
943            &mut targets,
944            "Cline",
945            crate::core::editor_registry::cline_mcp_path(),
946            ConfigType::McpJson,
947        ),
948        "roo" => push(
949            &mut targets,
950            "Roo Code",
951            crate::core::editor_registry::roo_mcp_path(),
952            ConfigType::McpJson,
953        ),
954        "kiro" => push(
955            &mut targets,
956            "AWS Kiro",
957            home.join(".kiro/settings/mcp.json"),
958            ConfigType::McpJson,
959        ),
960        "verdent" => push(
961            &mut targets,
962            "Verdent",
963            home.join(".verdent/mcp.json"),
964            ConfigType::McpJson,
965        ),
966        "jetbrains" | "amp" => {
967            // Handled by dedicated install hooks (servers[] array / amp.mcpServers)
968        }
969        "qwen" => push(
970            &mut targets,
971            "Qwen Code",
972            home.join(".qwen/settings.json"),
973            ConfigType::McpJson,
974        ),
975        "trae" => push(
976            &mut targets,
977            "Trae",
978            home.join(".trae/mcp.json"),
979            ConfigType::McpJson,
980        ),
981        "amazonq" => push(
982            &mut targets,
983            "Amazon Q Developer",
984            home.join(".aws/amazonq/default.json"),
985            ConfigType::McpJson,
986        ),
987        "opencode" => {
988            #[cfg(windows)]
989            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
990                std::path::PathBuf::from(appdata)
991                    .join("opencode")
992                    .join("opencode.json")
993            } else {
994                home.join(".config/opencode/opencode.json")
995            };
996            #[cfg(not(windows))]
997            let opencode_path = home.join(".config/opencode/opencode.json");
998            push(
999                &mut targets,
1000                "OpenCode",
1001                opencode_path,
1002                ConfigType::OpenCode,
1003            );
1004        }
1005        "hermes" => push(
1006            &mut targets,
1007            "Hermes Agent",
1008            home.join(".hermes/config.yaml"),
1009            ConfigType::HermesYaml,
1010        ),
1011        "vscode" => push(
1012            &mut targets,
1013            "VS Code",
1014            crate::core::editor_registry::vscode_mcp_path(),
1015            ConfigType::VsCodeMcp,
1016        ),
1017        "zed" => push(
1018            &mut targets,
1019            "Zed",
1020            crate::core::editor_registry::zed_settings_path(home),
1021            ConfigType::Zed,
1022        ),
1023        "aider" => push(
1024            &mut targets,
1025            "Aider",
1026            home.join(".aider/mcp.json"),
1027            ConfigType::McpJson,
1028        ),
1029        "continue" => push(
1030            &mut targets,
1031            "Continue",
1032            home.join(".continue/mcp.json"),
1033            ConfigType::McpJson,
1034        ),
1035        "neovim" => push(
1036            &mut targets,
1037            "Neovim (mcphub.nvim)",
1038            home.join(".config/mcphub/servers.json"),
1039            ConfigType::McpJson,
1040        ),
1041        "emacs" => push(
1042            &mut targets,
1043            "Emacs (mcp.el)",
1044            home.join(".emacs.d/mcp.json"),
1045            ConfigType::McpJson,
1046        ),
1047        "sublime" => push(
1048            &mut targets,
1049            "Sublime Text",
1050            home.join(".config/sublime-text/mcp.json"),
1051            ConfigType::McpJson,
1052        ),
1053        _ => {
1054            return Err(format!("Unknown agent '{agent}'"));
1055        }
1056    }
1057
1058    Ok(targets)
1059}
1060
1061pub fn disable_agent_mcp(agent: &str, overwrite_invalid: bool) -> Result<(), String> {
1062    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
1063
1064    let mut targets = Vec::<EditorTarget>::new();
1065
1066    let push = |targets: &mut Vec<EditorTarget>,
1067                name: &'static str,
1068                config_path: PathBuf,
1069                config_type: ConfigType| {
1070        targets.push(EditorTarget {
1071            name,
1072            agent_key: agent.to_string(),
1073            detect_path: PathBuf::from("/nonexistent"),
1074            config_path,
1075            config_type,
1076        });
1077    };
1078
1079    let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
1080
1081    match agent {
1082        "cursor" => push(
1083            &mut targets,
1084            "Cursor",
1085            home.join(".cursor/mcp.json"),
1086            ConfigType::McpJson,
1087        ),
1088        "claude" | "claude-code" => push(
1089            &mut targets,
1090            "Claude Code",
1091            crate::core::editor_registry::claude_mcp_json_path(&home),
1092            ConfigType::McpJson,
1093        ),
1094        "windsurf" => push(
1095            &mut targets,
1096            "Windsurf",
1097            home.join(".codeium/windsurf/mcp_config.json"),
1098            ConfigType::McpJson,
1099        ),
1100        "codex" => push(
1101            &mut targets,
1102            "Codex CLI",
1103            home.join(".codex/config.toml"),
1104            ConfigType::Codex,
1105        ),
1106        "gemini" => {
1107            push(
1108                &mut targets,
1109                "Gemini CLI",
1110                home.join(".gemini/settings.json"),
1111                ConfigType::GeminiSettings,
1112            );
1113            push(
1114                &mut targets,
1115                "Antigravity",
1116                home.join(".gemini/antigravity/mcp_config.json"),
1117                ConfigType::McpJson,
1118            );
1119        }
1120        "antigravity" => push(
1121            &mut targets,
1122            "Antigravity",
1123            home.join(".gemini/antigravity/mcp_config.json"),
1124            ConfigType::McpJson,
1125        ),
1126        "copilot" => push(
1127            &mut targets,
1128            "VS Code / Copilot",
1129            crate::core::editor_registry::vscode_mcp_path(),
1130            ConfigType::VsCodeMcp,
1131        ),
1132        "crush" => push(
1133            &mut targets,
1134            "Crush",
1135            home.join(".config/crush/crush.json"),
1136            ConfigType::Crush,
1137        ),
1138        "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
1139        "qoder" => {
1140            for path in crate::core::editor_registry::qoder_all_mcp_paths(&home) {
1141                push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
1142            }
1143        }
1144        "qoderwork" => push(
1145            &mut targets,
1146            "QoderWork",
1147            crate::core::editor_registry::qoderwork_mcp_path(&home),
1148            ConfigType::McpJson,
1149        ),
1150        "cline" => push(
1151            &mut targets,
1152            "Cline",
1153            crate::core::editor_registry::cline_mcp_path(),
1154            ConfigType::McpJson,
1155        ),
1156        "roo" => push(
1157            &mut targets,
1158            "Roo Code",
1159            crate::core::editor_registry::roo_mcp_path(),
1160            ConfigType::McpJson,
1161        ),
1162        "kiro" => push(
1163            &mut targets,
1164            "AWS Kiro",
1165            home.join(".kiro/settings/mcp.json"),
1166            ConfigType::McpJson,
1167        ),
1168        "verdent" => push(
1169            &mut targets,
1170            "Verdent",
1171            home.join(".verdent/mcp.json"),
1172            ConfigType::McpJson,
1173        ),
1174        "jetbrains" | "amp" => {
1175            // Not supported for disable via this helper.
1176        }
1177        "qwen" => push(
1178            &mut targets,
1179            "Qwen Code",
1180            home.join(".qwen/settings.json"),
1181            ConfigType::McpJson,
1182        ),
1183        "trae" => push(
1184            &mut targets,
1185            "Trae",
1186            home.join(".trae/mcp.json"),
1187            ConfigType::McpJson,
1188        ),
1189        "amazonq" => push(
1190            &mut targets,
1191            "Amazon Q Developer",
1192            home.join(".aws/amazonq/default.json"),
1193            ConfigType::McpJson,
1194        ),
1195        "opencode" => {
1196            #[cfg(windows)]
1197            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
1198                std::path::PathBuf::from(appdata)
1199                    .join("opencode")
1200                    .join("opencode.json")
1201            } else {
1202                home.join(".config/opencode/opencode.json")
1203            };
1204            #[cfg(not(windows))]
1205            let opencode_path = home.join(".config/opencode/opencode.json");
1206            push(
1207                &mut targets,
1208                "OpenCode",
1209                opencode_path,
1210                ConfigType::OpenCode,
1211            );
1212        }
1213        "hermes" => push(
1214            &mut targets,
1215            "Hermes Agent",
1216            home.join(".hermes/config.yaml"),
1217            ConfigType::HermesYaml,
1218        ),
1219        "vscode" => push(
1220            &mut targets,
1221            "VS Code",
1222            crate::core::editor_registry::vscode_mcp_path(),
1223            ConfigType::VsCodeMcp,
1224        ),
1225        "zed" => push(
1226            &mut targets,
1227            "Zed",
1228            crate::core::editor_registry::zed_settings_path(&home),
1229            ConfigType::Zed,
1230        ),
1231        "aider" => push(
1232            &mut targets,
1233            "Aider",
1234            home.join(".aider/mcp.json"),
1235            ConfigType::McpJson,
1236        ),
1237        "continue" => push(
1238            &mut targets,
1239            "Continue",
1240            home.join(".continue/mcp.json"),
1241            ConfigType::McpJson,
1242        ),
1243        "neovim" => push(
1244            &mut targets,
1245            "Neovim (mcphub.nvim)",
1246            home.join(".config/mcphub/servers.json"),
1247            ConfigType::McpJson,
1248        ),
1249        "emacs" => push(
1250            &mut targets,
1251            "Emacs (mcp.el)",
1252            home.join(".emacs.d/mcp.json"),
1253            ConfigType::McpJson,
1254        ),
1255        "sublime" => push(
1256            &mut targets,
1257            "Sublime Text",
1258            home.join(".config/sublime-text/mcp.json"),
1259            ConfigType::McpJson,
1260        ),
1261        _ => {
1262            return Err(format!("Unknown agent '{agent}'"));
1263        }
1264    }
1265
1266    for t in &targets {
1267        crate::core::editor_registry::remove_lean_ctx_server(
1268            t,
1269            WriteOptions { overwrite_invalid },
1270        )?;
1271    }
1272
1273    Ok(())
1274}
1275
1276pub fn install_skill_files(home: &std::path::Path) -> Vec<(String, bool)> {
1277    crate::rules_inject::install_all_skills(home)
1278}
1279
1280fn install_kiro_steering(home: &std::path::Path) {
1281    let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
1282    let steering_dir = cwd.join(".kiro").join("steering");
1283    let steering_file = steering_dir.join("lean-ctx.md");
1284
1285    if steering_file.exists()
1286        && std::fs::read_to_string(&steering_file)
1287            .unwrap_or_default()
1288            .contains("lean-ctx")
1289    {
1290        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1291        return;
1292    }
1293
1294    let _ = std::fs::create_dir_all(&steering_dir);
1295    let _ = std::fs::write(&steering_file, crate::hooks::KIRO_STEERING_TEMPLATE);
1296    println!("  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1297}
1298
1299fn shorten_path(path: &str, home: &str) -> String {
1300    if let Some(stripped) = path.strip_prefix(home) {
1301        format!("~{stripped}")
1302    } else {
1303        path.to_string()
1304    }
1305}
1306
1307fn upsert_toml_key(content: &mut String, key: &str, value: &str) {
1308    let pattern = format!("{key} = ");
1309    if let Some(start) = content.find(&pattern) {
1310        let line_end = content[start..]
1311            .find('\n')
1312            .map_or(content.len(), |p| start + p);
1313        content.replace_range(start..line_end, &format!("{key} = \"{value}\""));
1314    } else {
1315        if !content.is_empty() && !content.ends_with('\n') {
1316            content.push('\n');
1317        }
1318        content.push_str(&format!("{key} = \"{value}\"\n"));
1319    }
1320}
1321
1322fn remove_toml_key(content: &mut String, key: &str) {
1323    let pattern = format!("{key} = ");
1324    if let Some(start) = content.find(&pattern) {
1325        let line_end = content[start..]
1326            .find('\n')
1327            .map_or(content.len(), |p| start + p + 1);
1328        content.replace_range(start..line_end, "");
1329    }
1330}
1331
1332fn configure_premium_features(home: &std::path::Path) {
1333    use crate::terminal_ui;
1334    use std::io::Write;
1335
1336    let config_dir = home.join(".lean-ctx");
1337    let _ = std::fs::create_dir_all(&config_dir);
1338    let config_path = config_dir.join("config.toml");
1339    let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
1340
1341    let dim = "\x1b[2m";
1342    let bold = "\x1b[1m";
1343    let cyan = "\x1b[36m";
1344    let rst = "\x1b[0m";
1345
1346    // Unified Compression Level (replaces terse_agent + output_density)
1347    println!("\n  {bold}Compression Level{rst} {dim}(controls all token optimization layers){rst}");
1348    println!("  {dim}Applies to tool output, agent prompts, and protocol mode.{rst}");
1349    println!();
1350    println!("  {cyan}off{rst}      — No compression (full verbose output)");
1351    println!("  {cyan}lite{rst}     — Light: concise output, basic terse filtering {dim}(~25% savings){rst}");
1352    println!("  {cyan}standard{rst} — Dense output + compact protocol + pattern-aware {dim}(~45% savings){rst}");
1353    println!("  {cyan}max{rst}      — Expert mode: TDD protocol, all layers active {dim}(~65% savings){rst}");
1354    println!();
1355    print!("  Compression level? {bold}[off/lite/standard/max]{rst} {dim}(default: off){rst} ");
1356    std::io::stdout().flush().ok();
1357
1358    let mut level_input = String::new();
1359    let level = if std::io::stdin().read_line(&mut level_input).is_ok() {
1360        match level_input.trim().to_lowercase().as_str() {
1361            "lite" => "lite",
1362            "standard" | "std" => "standard",
1363            "max" => "max",
1364            _ => "off",
1365        }
1366    } else {
1367        "off"
1368    };
1369
1370    let effective_level = if level != "off" {
1371        upsert_toml_key(&mut config_content, "compression_level", level);
1372        remove_toml_key(&mut config_content, "terse_agent");
1373        remove_toml_key(&mut config_content, "output_density");
1374        terminal_ui::print_status_ok(&format!("Compression: {level}"));
1375        crate::core::config::CompressionLevel::from_str_label(level)
1376    } else if config_content.contains("compression_level") {
1377        upsert_toml_key(&mut config_content, "compression_level", "off");
1378        terminal_ui::print_status_ok("Compression: off");
1379        Some(crate::core::config::CompressionLevel::Off)
1380    } else {
1381        terminal_ui::print_status_skip(
1382            "Compression: off (change later with: lean-ctx compression <level>)",
1383        );
1384        Some(crate::core::config::CompressionLevel::Off)
1385    };
1386
1387    if let Some(lvl) = effective_level {
1388        let n = crate::core::terse::rules_inject::inject(&lvl);
1389        if n > 0 {
1390            terminal_ui::print_status_ok(&format!(
1391                "Updated {n} rules file(s) with compression prompt"
1392            ));
1393        }
1394    }
1395
1396    // Tool Result Archive (unchanged)
1397    println!(
1398        "\n  {bold}Tool Result Archive{rst} {dim}(zero-loss: large outputs archived, retrievable via ctx_expand){rst}"
1399    );
1400    print!("  Enable auto-archive? {bold}[Y/n]{rst} ");
1401    std::io::stdout().flush().ok();
1402
1403    let mut archive_input = String::new();
1404    let archive_on = if std::io::stdin().read_line(&mut archive_input).is_ok() {
1405        let a = archive_input.trim().to_lowercase();
1406        a.is_empty() || a == "y" || a == "yes"
1407    } else {
1408        true
1409    };
1410
1411    if archive_on && !config_content.contains("[archive]") {
1412        if !config_content.is_empty() && !config_content.ends_with('\n') {
1413            config_content.push('\n');
1414        }
1415        config_content.push_str("\n[archive]\nenabled = true\n");
1416        terminal_ui::print_status_ok("Tool Result Archive: enabled");
1417    } else if !archive_on {
1418        terminal_ui::print_status_skip("Archive: off (enable later in config.toml)");
1419    }
1420
1421    let _ = std::fs::write(&config_path, config_content);
1422}
1423
1424#[cfg(all(test, target_os = "macos"))]
1425mod tests {
1426    use super::*;
1427
1428    #[test]
1429    #[cfg(target_os = "macos")]
1430    fn qoder_agent_targets_include_all_macos_mcp_locations() {
1431        let home = std::path::Path::new("/Users/tester");
1432        let targets = agent_mcp_targets("qoder", home).unwrap();
1433        let paths: Vec<_> = targets.iter().map(|t| t.config_path.as_path()).collect();
1434
1435        assert_eq!(
1436            paths,
1437            vec![
1438                home.join(".qoder/mcp.json").as_path(),
1439                home.join("Library/Application Support/Qoder/User/mcp.json")
1440                    .as_path(),
1441                home.join("Library/Application Support/Qoder/SharedClientCache/mcp.json")
1442                    .as_path(),
1443            ]
1444        );
1445        assert!(targets
1446            .iter()
1447            .all(|t| t.config_type == ConfigType::QoderSettings));
1448    }
1449}