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