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.ok = false;
533                daemon_step
534                    .warnings
535                    .push(format!("daemon start failed: {e}"));
536                daemon_step.items.push(SetupItem {
537                    name: "serve --daemon".to_string(),
538                    status: "error".to_string(),
539                    path: Some(crate::daemon::daemon_addr().display()),
540                    note: Some(e.to_string()),
541                });
542            }
543        }
544    }
545    steps.push(daemon_step);
546
547    // Step: Editor MCP config
548    let mut editor_step = SetupStepReport {
549        name: "editors".to_string(),
550        ok: true,
551        items: Vec::new(),
552        warnings: Vec::new(),
553        errors: Vec::new(),
554    };
555
556    let targets = crate::core::editor_registry::build_targets(&home);
557    for target in &targets {
558        let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
559        if !target.detect_path.exists() {
560            editor_step.items.push(SetupItem {
561                name: target.name.to_string(),
562                status: "not_detected".to_string(),
563                path: Some(short_path),
564                note: None,
565            });
566            continue;
567        }
568
569        let mode = if target.agent_key.is_empty() {
570            HookMode::Mcp
571        } else {
572            recommend_hook_mode(&target.agent_key)
573        };
574
575        // CLI-redirect means: do NOT configure MCP (avoid tool schema overhead).
576        // If lean-ctx was previously configured, proactively remove it from the editor config.
577        if mode == HookMode::CliRedirect {
578            let res = crate::core::editor_registry::remove_lean_ctx_server(
579                target,
580                WriteOptions {
581                    overwrite_invalid: opts.fix,
582                },
583            );
584            match res {
585                Ok(w) => {
586                    let note_parts: Vec<String> = [
587                        Some(format!("mode={mode}")),
588                        Some("mcp=disabled".to_string()),
589                        w.note,
590                    ]
591                    .into_iter()
592                    .flatten()
593                    .collect();
594                    editor_step.items.push(SetupItem {
595                        name: target.name.to_string(),
596                        status: match w.action {
597                            WriteAction::Created => "created".to_string(),
598                            WriteAction::Updated => "updated".to_string(),
599                            WriteAction::Already => "already".to_string(),
600                        },
601                        path: Some(short_path),
602                        note: Some(note_parts.join("; ")),
603                    });
604                }
605                Err(e) => {
606                    editor_step.ok = false;
607                    editor_step.items.push(SetupItem {
608                        name: target.name.to_string(),
609                        status: "error".to_string(),
610                        path: Some(short_path),
611                        note: Some(format!("mode={mode}; mcp=disable_failed; {e}")),
612                    });
613                }
614            }
615            continue;
616        }
617
618        let res = crate::core::editor_registry::write_config_with_options(
619            target,
620            &binary,
621            WriteOptions {
622                overwrite_invalid: opts.fix,
623            },
624        );
625        match res {
626            Ok(w) => {
627                let note_parts: Vec<String> = [Some(format!("mode={mode}")), w.note]
628                    .into_iter()
629                    .flatten()
630                    .collect();
631                editor_step.items.push(SetupItem {
632                    name: target.name.to_string(),
633                    status: match w.action {
634                        WriteAction::Created => "created".to_string(),
635                        WriteAction::Updated => "updated".to_string(),
636                        WriteAction::Already => "already".to_string(),
637                    },
638                    path: Some(short_path),
639                    note: Some(note_parts.join("; ")),
640                });
641            }
642            Err(e) => {
643                editor_step.ok = false;
644                editor_step.items.push(SetupItem {
645                    name: target.name.to_string(),
646                    status: "error".to_string(),
647                    path: Some(short_path),
648                    note: Some(e),
649                });
650            }
651        }
652    }
653    steps.push(editor_step);
654
655    // Step: Agent rules
656    let mut rules_step = SetupStepReport {
657        name: "agent_rules".to_string(),
658        ok: true,
659        items: Vec::new(),
660        warnings: Vec::new(),
661        errors: Vec::new(),
662    };
663    let rules_result = crate::rules_inject::inject_all_rules(&home);
664    for n in rules_result.injected {
665        rules_step.items.push(SetupItem {
666            name: n,
667            status: "injected".to_string(),
668            path: None,
669            note: None,
670        });
671    }
672    for n in rules_result.updated {
673        rules_step.items.push(SetupItem {
674            name: n,
675            status: "updated".to_string(),
676            path: None,
677            note: None,
678        });
679    }
680    for n in rules_result.already {
681        rules_step.items.push(SetupItem {
682            name: n,
683            status: "already".to_string(),
684            path: None,
685            note: None,
686        });
687    }
688    for e in rules_result.errors {
689        rules_step.ok = false;
690        rules_step.errors.push(e);
691    }
692    steps.push(rules_step);
693
694    // Step: Skill files
695    let mut skill_step = SetupStepReport {
696        name: "skill_files".to_string(),
697        ok: true,
698        items: Vec::new(),
699        warnings: Vec::new(),
700        errors: Vec::new(),
701    };
702    let skill_results = crate::rules_inject::install_all_skills(&home);
703    for (name, is_new) in &skill_results {
704        skill_step.items.push(SetupItem {
705            name: name.clone(),
706            status: if *is_new { "installed" } else { "already" }.to_string(),
707            path: None,
708            note: Some("SKILL.md".to_string()),
709        });
710    }
711    if !skill_step.items.is_empty() {
712        steps.push(skill_step);
713    }
714
715    // Step: Agent-specific hooks (all detected agents)
716    let mut hooks_step = SetupStepReport {
717        name: "agent_hooks".to_string(),
718        ok: true,
719        items: Vec::new(),
720        warnings: Vec::new(),
721        errors: Vec::new(),
722    };
723    for target in &targets {
724        if !target.detect_path.exists() || target.agent_key.is_empty() {
725            continue;
726        }
727        let mode = recommend_hook_mode(&target.agent_key);
728        crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
729        hooks_step.items.push(SetupItem {
730            name: format!("{} hooks", target.name),
731            status: "installed".to_string(),
732            path: Some(target.detect_path.to_string_lossy().to_string()),
733            note: Some(format!(
734                "mode={mode}; merge-based install/repair (preserves other hooks/plugins)"
735            )),
736        });
737    }
738    if !hooks_step.items.is_empty() {
739        steps.push(hooks_step);
740    }
741
742    // Step: Proxy env vars
743    let mut proxy_step = SetupStepReport {
744        name: "proxy_env".to_string(),
745        ok: true,
746        items: Vec::new(),
747        warnings: Vec::new(),
748        errors: Vec::new(),
749    };
750    crate::proxy_setup::install_proxy_env(&home, crate::proxy_setup::default_port(), opts.json);
751    proxy_step.items.push(SetupItem {
752        name: "proxy_env".to_string(),
753        status: "configured".to_string(),
754        path: None,
755        note: Some("ANTHROPIC_BASE_URL, OPENAI_BASE_URL, GEMINI_API_BASE_URL".to_string()),
756    });
757    steps.push(proxy_step);
758
759    // Step: Environment / doctor (compact)
760    let mut env_step = SetupStepReport {
761        name: "doctor_compact".to_string(),
762        ok: true,
763        items: Vec::new(),
764        warnings: Vec::new(),
765        errors: Vec::new(),
766    };
767    let (passed, total) = crate::doctor::compact_score();
768    env_step.items.push(SetupItem {
769        name: "doctor".to_string(),
770        status: format!("{passed}/{total}"),
771        path: None,
772        note: None,
773    });
774    if passed != total {
775        env_step.warnings.push(format!(
776            "doctor compact not fully passing: {passed}/{total}"
777        ));
778    }
779    steps.push(env_step);
780
781    // Auto-build property graph if inside any recognized project
782    if let Ok(cwd) = std::env::current_dir() {
783        let is_project = cwd.join(".git").exists()
784            || cwd.join("Cargo.toml").exists()
785            || cwd.join("package.json").exists()
786            || cwd.join("go.mod").exists();
787        if is_project {
788            spawn_index_build_background(&cwd);
789        }
790    }
791
792    let finished_at = Utc::now();
793    let success = steps.iter().all(|s| s.ok);
794    let report = SetupReport {
795        schema_version: 1,
796        started_at,
797        finished_at,
798        success,
799        platform: PlatformInfo {
800            os: std::env::consts::OS.to_string(),
801            arch: std::env::consts::ARCH.to_string(),
802        },
803        steps,
804        warnings: Vec::new(),
805        errors: Vec::new(),
806    };
807
808    let path = SetupReport::default_path()?;
809    let mut content =
810        serde_json::to_string_pretty(&report).map_err(|e| format!("serialize report: {e}"))?;
811    content.push('\n');
812    crate::config_io::write_atomic(&path, &content)?;
813
814    Ok(report)
815}
816
817fn spawn_index_build_background(root: &std::path::Path) {
818    let root_str = crate::core::graph_index::normalize_project_root(&root.to_string_lossy());
819    if !crate::core::graph_index::is_safe_scan_root_public(&root_str) {
820        tracing::info!("[setup: skipping background graph build for unsafe root {root_str}]");
821        return;
822    }
823
824    let binary = std::env::current_exe().map_or_else(
825        |_| resolve_portable_binary(),
826        |p| p.to_string_lossy().to_string(),
827    );
828    let _ = std::process::Command::new(&binary)
829        .args(["index", "build-graph", "--root"])
830        .arg(root)
831        .stdout(std::process::Stdio::null())
832        .stderr(std::process::Stdio::null())
833        .stdin(std::process::Stdio::null())
834        .spawn();
835}
836
837pub fn configure_agent_mcp(agent: &str) -> Result<(), String> {
838    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
839    let binary = resolve_portable_binary();
840
841    let targets = agent_mcp_targets(agent, &home)?;
842
843    for t in &targets {
844        crate::core::editor_registry::write_config_with_options(
845            t,
846            &binary,
847            WriteOptions {
848                overwrite_invalid: true,
849            },
850        )?;
851    }
852
853    if agent == "kiro" {
854        install_kiro_steering(&home);
855    }
856
857    Ok(())
858}
859
860fn agent_mcp_targets(agent: &str, home: &std::path::Path) -> Result<Vec<EditorTarget>, String> {
861    let mut targets = Vec::<EditorTarget>::new();
862
863    let push = |targets: &mut Vec<EditorTarget>,
864                name: &'static str,
865                config_path: PathBuf,
866                config_type: ConfigType| {
867        targets.push(EditorTarget {
868            name,
869            agent_key: agent.to_string(),
870            detect_path: PathBuf::from("/nonexistent"), // not used in direct agent config
871            config_path,
872            config_type,
873        });
874    };
875
876    let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
877
878    match agent {
879        "cursor" => push(
880            &mut targets,
881            "Cursor",
882            home.join(".cursor/mcp.json"),
883            ConfigType::McpJson,
884        ),
885        "claude" | "claude-code" => push(
886            &mut targets,
887            "Claude Code",
888            crate::core::editor_registry::claude_mcp_json_path(home),
889            ConfigType::McpJson,
890        ),
891        "windsurf" => push(
892            &mut targets,
893            "Windsurf",
894            home.join(".codeium/windsurf/mcp_config.json"),
895            ConfigType::McpJson,
896        ),
897        "codex" => {
898            let codex_dir =
899                crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
900            push(
901                &mut targets,
902                "Codex CLI",
903                codex_dir.join("config.toml"),
904                ConfigType::Codex,
905            );
906        }
907        "gemini" => {
908            push(
909                &mut targets,
910                "Gemini CLI",
911                home.join(".gemini/settings.json"),
912                ConfigType::GeminiSettings,
913            );
914            push(
915                &mut targets,
916                "Antigravity",
917                home.join(".gemini/antigravity/mcp_config.json"),
918                ConfigType::McpJson,
919            );
920        }
921        "antigravity" => push(
922            &mut targets,
923            "Antigravity",
924            home.join(".gemini/antigravity/mcp_config.json"),
925            ConfigType::McpJson,
926        ),
927        "copilot" => push(
928            &mut targets,
929            "VS Code / Copilot",
930            crate::core::editor_registry::vscode_mcp_path(),
931            ConfigType::VsCodeMcp,
932        ),
933        "crush" => push(
934            &mut targets,
935            "Crush",
936            home.join(".config/crush/crush.json"),
937            ConfigType::Crush,
938        ),
939        "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
940        "qoder" => {
941            for path in crate::core::editor_registry::qoder_all_mcp_paths(home) {
942                push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
943            }
944        }
945        "qoderwork" => push(
946            &mut targets,
947            "QoderWork",
948            crate::core::editor_registry::qoderwork_mcp_path(home),
949            ConfigType::McpJson,
950        ),
951        "cline" => push(
952            &mut targets,
953            "Cline",
954            crate::core::editor_registry::cline_mcp_path(),
955            ConfigType::McpJson,
956        ),
957        "roo" => push(
958            &mut targets,
959            "Roo Code",
960            crate::core::editor_registry::roo_mcp_path(),
961            ConfigType::McpJson,
962        ),
963        "kiro" => push(
964            &mut targets,
965            "AWS Kiro",
966            home.join(".kiro/settings/mcp.json"),
967            ConfigType::McpJson,
968        ),
969        "verdent" => push(
970            &mut targets,
971            "Verdent",
972            home.join(".verdent/mcp.json"),
973            ConfigType::McpJson,
974        ),
975        "jetbrains" | "amp" => {
976            // Handled by dedicated install hooks (servers[] array / amp.mcpServers)
977        }
978        "qwen" => push(
979            &mut targets,
980            "Qwen Code",
981            home.join(".qwen/settings.json"),
982            ConfigType::McpJson,
983        ),
984        "trae" => push(
985            &mut targets,
986            "Trae",
987            home.join(".trae/mcp.json"),
988            ConfigType::McpJson,
989        ),
990        "amazonq" => push(
991            &mut targets,
992            "Amazon Q Developer",
993            home.join(".aws/amazonq/default.json"),
994            ConfigType::McpJson,
995        ),
996        "opencode" => {
997            #[cfg(windows)]
998            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
999                std::path::PathBuf::from(appdata)
1000                    .join("opencode")
1001                    .join("opencode.json")
1002            } else {
1003                home.join(".config/opencode/opencode.json")
1004            };
1005            #[cfg(not(windows))]
1006            let opencode_path = home.join(".config/opencode/opencode.json");
1007            push(
1008                &mut targets,
1009                "OpenCode",
1010                opencode_path,
1011                ConfigType::OpenCode,
1012            );
1013        }
1014        "hermes" => push(
1015            &mut targets,
1016            "Hermes Agent",
1017            home.join(".hermes/config.yaml"),
1018            ConfigType::HermesYaml,
1019        ),
1020        "vscode" => push(
1021            &mut targets,
1022            "VS Code",
1023            crate::core::editor_registry::vscode_mcp_path(),
1024            ConfigType::VsCodeMcp,
1025        ),
1026        "zed" => push(
1027            &mut targets,
1028            "Zed",
1029            crate::core::editor_registry::zed_settings_path(home),
1030            ConfigType::Zed,
1031        ),
1032        "aider" => push(
1033            &mut targets,
1034            "Aider",
1035            home.join(".aider/mcp.json"),
1036            ConfigType::McpJson,
1037        ),
1038        "continue" => push(
1039            &mut targets,
1040            "Continue",
1041            home.join(".continue/mcp.json"),
1042            ConfigType::McpJson,
1043        ),
1044        "neovim" => push(
1045            &mut targets,
1046            "Neovim (mcphub.nvim)",
1047            home.join(".config/mcphub/servers.json"),
1048            ConfigType::McpJson,
1049        ),
1050        "emacs" => push(
1051            &mut targets,
1052            "Emacs (mcp.el)",
1053            home.join(".emacs.d/mcp.json"),
1054            ConfigType::McpJson,
1055        ),
1056        "sublime" => push(
1057            &mut targets,
1058            "Sublime Text",
1059            home.join(".config/sublime-text/mcp.json"),
1060            ConfigType::McpJson,
1061        ),
1062        _ => {
1063            return Err(format!("Unknown agent '{agent}'"));
1064        }
1065    }
1066
1067    Ok(targets)
1068}
1069
1070pub fn disable_agent_mcp(agent: &str, overwrite_invalid: bool) -> Result<(), String> {
1071    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
1072
1073    let mut targets = Vec::<EditorTarget>::new();
1074
1075    let push = |targets: &mut Vec<EditorTarget>,
1076                name: &'static str,
1077                config_path: PathBuf,
1078                config_type: ConfigType| {
1079        targets.push(EditorTarget {
1080            name,
1081            agent_key: agent.to_string(),
1082            detect_path: PathBuf::from("/nonexistent"),
1083            config_path,
1084            config_type,
1085        });
1086    };
1087
1088    let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
1089
1090    match agent {
1091        "cursor" => push(
1092            &mut targets,
1093            "Cursor",
1094            home.join(".cursor/mcp.json"),
1095            ConfigType::McpJson,
1096        ),
1097        "claude" | "claude-code" => push(
1098            &mut targets,
1099            "Claude Code",
1100            crate::core::editor_registry::claude_mcp_json_path(&home),
1101            ConfigType::McpJson,
1102        ),
1103        "windsurf" => push(
1104            &mut targets,
1105            "Windsurf",
1106            home.join(".codeium/windsurf/mcp_config.json"),
1107            ConfigType::McpJson,
1108        ),
1109        "codex" => {
1110            let codex_dir =
1111                crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
1112            push(
1113                &mut targets,
1114                "Codex CLI",
1115                codex_dir.join("config.toml"),
1116                ConfigType::Codex,
1117            );
1118        }
1119        "gemini" => {
1120            push(
1121                &mut targets,
1122                "Gemini CLI",
1123                home.join(".gemini/settings.json"),
1124                ConfigType::GeminiSettings,
1125            );
1126            push(
1127                &mut targets,
1128                "Antigravity",
1129                home.join(".gemini/antigravity/mcp_config.json"),
1130                ConfigType::McpJson,
1131            );
1132        }
1133        "antigravity" => push(
1134            &mut targets,
1135            "Antigravity",
1136            home.join(".gemini/antigravity/mcp_config.json"),
1137            ConfigType::McpJson,
1138        ),
1139        "copilot" => push(
1140            &mut targets,
1141            "VS Code / Copilot",
1142            crate::core::editor_registry::vscode_mcp_path(),
1143            ConfigType::VsCodeMcp,
1144        ),
1145        "crush" => push(
1146            &mut targets,
1147            "Crush",
1148            home.join(".config/crush/crush.json"),
1149            ConfigType::Crush,
1150        ),
1151        "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
1152        "qoder" => {
1153            for path in crate::core::editor_registry::qoder_all_mcp_paths(&home) {
1154                push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
1155            }
1156        }
1157        "qoderwork" => push(
1158            &mut targets,
1159            "QoderWork",
1160            crate::core::editor_registry::qoderwork_mcp_path(&home),
1161            ConfigType::McpJson,
1162        ),
1163        "cline" => push(
1164            &mut targets,
1165            "Cline",
1166            crate::core::editor_registry::cline_mcp_path(),
1167            ConfigType::McpJson,
1168        ),
1169        "roo" => push(
1170            &mut targets,
1171            "Roo Code",
1172            crate::core::editor_registry::roo_mcp_path(),
1173            ConfigType::McpJson,
1174        ),
1175        "kiro" => push(
1176            &mut targets,
1177            "AWS Kiro",
1178            home.join(".kiro/settings/mcp.json"),
1179            ConfigType::McpJson,
1180        ),
1181        "verdent" => push(
1182            &mut targets,
1183            "Verdent",
1184            home.join(".verdent/mcp.json"),
1185            ConfigType::McpJson,
1186        ),
1187        "jetbrains" | "amp" => {
1188            // Not supported for disable via this helper.
1189        }
1190        "qwen" => push(
1191            &mut targets,
1192            "Qwen Code",
1193            home.join(".qwen/settings.json"),
1194            ConfigType::McpJson,
1195        ),
1196        "trae" => push(
1197            &mut targets,
1198            "Trae",
1199            home.join(".trae/mcp.json"),
1200            ConfigType::McpJson,
1201        ),
1202        "amazonq" => push(
1203            &mut targets,
1204            "Amazon Q Developer",
1205            home.join(".aws/amazonq/default.json"),
1206            ConfigType::McpJson,
1207        ),
1208        "opencode" => {
1209            #[cfg(windows)]
1210            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
1211                std::path::PathBuf::from(appdata)
1212                    .join("opencode")
1213                    .join("opencode.json")
1214            } else {
1215                home.join(".config/opencode/opencode.json")
1216            };
1217            #[cfg(not(windows))]
1218            let opencode_path = home.join(".config/opencode/opencode.json");
1219            push(
1220                &mut targets,
1221                "OpenCode",
1222                opencode_path,
1223                ConfigType::OpenCode,
1224            );
1225        }
1226        "hermes" => push(
1227            &mut targets,
1228            "Hermes Agent",
1229            home.join(".hermes/config.yaml"),
1230            ConfigType::HermesYaml,
1231        ),
1232        "vscode" => push(
1233            &mut targets,
1234            "VS Code",
1235            crate::core::editor_registry::vscode_mcp_path(),
1236            ConfigType::VsCodeMcp,
1237        ),
1238        "zed" => push(
1239            &mut targets,
1240            "Zed",
1241            crate::core::editor_registry::zed_settings_path(&home),
1242            ConfigType::Zed,
1243        ),
1244        "aider" => push(
1245            &mut targets,
1246            "Aider",
1247            home.join(".aider/mcp.json"),
1248            ConfigType::McpJson,
1249        ),
1250        "continue" => push(
1251            &mut targets,
1252            "Continue",
1253            home.join(".continue/mcp.json"),
1254            ConfigType::McpJson,
1255        ),
1256        "neovim" => push(
1257            &mut targets,
1258            "Neovim (mcphub.nvim)",
1259            home.join(".config/mcphub/servers.json"),
1260            ConfigType::McpJson,
1261        ),
1262        "emacs" => push(
1263            &mut targets,
1264            "Emacs (mcp.el)",
1265            home.join(".emacs.d/mcp.json"),
1266            ConfigType::McpJson,
1267        ),
1268        "sublime" => push(
1269            &mut targets,
1270            "Sublime Text",
1271            home.join(".config/sublime-text/mcp.json"),
1272            ConfigType::McpJson,
1273        ),
1274        _ => {
1275            return Err(format!("Unknown agent '{agent}'"));
1276        }
1277    }
1278
1279    for t in &targets {
1280        crate::core::editor_registry::remove_lean_ctx_server(
1281            t,
1282            WriteOptions { overwrite_invalid },
1283        )?;
1284    }
1285
1286    Ok(())
1287}
1288
1289pub fn install_skill_files(home: &std::path::Path) -> Vec<(String, bool)> {
1290    crate::rules_inject::install_all_skills(home)
1291}
1292
1293fn install_kiro_steering(home: &std::path::Path) {
1294    let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
1295    let steering_dir = cwd.join(".kiro").join("steering");
1296    let steering_file = steering_dir.join("lean-ctx.md");
1297
1298    if steering_file.exists()
1299        && std::fs::read_to_string(&steering_file)
1300            .unwrap_or_default()
1301            .contains("lean-ctx")
1302    {
1303        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1304        return;
1305    }
1306
1307    let _ = std::fs::create_dir_all(&steering_dir);
1308    let _ = std::fs::write(&steering_file, crate::hooks::KIRO_STEERING_TEMPLATE);
1309    println!("  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1310}
1311
1312fn shorten_path(path: &str, home: &str) -> String {
1313    if let Some(stripped) = path.strip_prefix(home) {
1314        format!("~{stripped}")
1315    } else {
1316        path.to_string()
1317    }
1318}
1319
1320fn upsert_toml_key(content: &mut String, key: &str, value: &str) {
1321    let pattern = format!("{key} = ");
1322    if let Some(start) = content.find(&pattern) {
1323        let line_end = content[start..]
1324            .find('\n')
1325            .map_or(content.len(), |p| start + p);
1326        content.replace_range(start..line_end, &format!("{key} = \"{value}\""));
1327    } else {
1328        if !content.is_empty() && !content.ends_with('\n') {
1329            content.push('\n');
1330        }
1331        content.push_str(&format!("{key} = \"{value}\"\n"));
1332    }
1333}
1334
1335fn remove_toml_key(content: &mut String, key: &str) {
1336    let pattern = format!("{key} = ");
1337    if let Some(start) = content.find(&pattern) {
1338        let line_end = content[start..]
1339            .find('\n')
1340            .map_or(content.len(), |p| start + p + 1);
1341        content.replace_range(start..line_end, "");
1342    }
1343}
1344
1345fn configure_premium_features(home: &std::path::Path) {
1346    use crate::terminal_ui;
1347    use std::io::Write;
1348
1349    let config_dir = home.join(".lean-ctx");
1350    let _ = std::fs::create_dir_all(&config_dir);
1351    let config_path = config_dir.join("config.toml");
1352    let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
1353
1354    let dim = "\x1b[2m";
1355    let bold = "\x1b[1m";
1356    let cyan = "\x1b[36m";
1357    let rst = "\x1b[0m";
1358
1359    // Unified Compression Level (replaces terse_agent + output_density)
1360    println!("\n  {bold}Compression Level{rst} {dim}(controls all token optimization layers){rst}");
1361    println!("  {dim}Applies to tool output, agent prompts, and protocol mode.{rst}");
1362    println!();
1363    println!("  {cyan}off{rst}      — No compression (full verbose output)");
1364    println!("  {cyan}lite{rst}     — Light: concise output, basic terse filtering {dim}(~25% savings){rst}");
1365    println!("  {cyan}standard{rst} — Dense output + compact protocol + pattern-aware {dim}(~45% savings){rst}");
1366    println!("  {cyan}max{rst}      — Expert mode: TDD protocol, all layers active {dim}(~65% savings){rst}");
1367    println!();
1368    print!("  Compression level? {bold}[off/lite/standard/max]{rst} {dim}(default: off){rst} ");
1369    std::io::stdout().flush().ok();
1370
1371    let mut level_input = String::new();
1372    let level = if std::io::stdin().read_line(&mut level_input).is_ok() {
1373        match level_input.trim().to_lowercase().as_str() {
1374            "lite" => "lite",
1375            "standard" | "std" => "standard",
1376            "max" => "max",
1377            _ => "off",
1378        }
1379    } else {
1380        "off"
1381    };
1382
1383    let effective_level = if level != "off" {
1384        upsert_toml_key(&mut config_content, "compression_level", level);
1385        remove_toml_key(&mut config_content, "terse_agent");
1386        remove_toml_key(&mut config_content, "output_density");
1387        terminal_ui::print_status_ok(&format!("Compression: {level}"));
1388        crate::core::config::CompressionLevel::from_str_label(level)
1389    } else if config_content.contains("compression_level") {
1390        upsert_toml_key(&mut config_content, "compression_level", "off");
1391        terminal_ui::print_status_ok("Compression: off");
1392        Some(crate::core::config::CompressionLevel::Off)
1393    } else {
1394        terminal_ui::print_status_skip(
1395            "Compression: off (change later with: lean-ctx compression <level>)",
1396        );
1397        Some(crate::core::config::CompressionLevel::Off)
1398    };
1399
1400    if let Some(lvl) = effective_level {
1401        let n = crate::core::terse::rules_inject::inject(&lvl);
1402        if n > 0 {
1403            terminal_ui::print_status_ok(&format!(
1404                "Updated {n} rules file(s) with compression prompt"
1405            ));
1406        }
1407    }
1408
1409    // Tool Result Archive (unchanged)
1410    println!(
1411        "\n  {bold}Tool Result Archive{rst} {dim}(zero-loss: large outputs archived, retrievable via ctx_expand){rst}"
1412    );
1413    print!("  Enable auto-archive? {bold}[Y/n]{rst} ");
1414    std::io::stdout().flush().ok();
1415
1416    let mut archive_input = String::new();
1417    let archive_on = if std::io::stdin().read_line(&mut archive_input).is_ok() {
1418        let a = archive_input.trim().to_lowercase();
1419        a.is_empty() || a == "y" || a == "yes"
1420    } else {
1421        true
1422    };
1423
1424    if archive_on && !config_content.contains("[archive]") {
1425        if !config_content.is_empty() && !config_content.ends_with('\n') {
1426            config_content.push('\n');
1427        }
1428        config_content.push_str("\n[archive]\nenabled = true\n");
1429        terminal_ui::print_status_ok("Tool Result Archive: enabled");
1430    } else if !archive_on {
1431        terminal_ui::print_status_skip("Archive: off (enable later in config.toml)");
1432    }
1433
1434    let _ = std::fs::write(&config_path, config_content);
1435}
1436
1437#[cfg(all(test, target_os = "macos"))]
1438mod tests {
1439    use super::*;
1440
1441    #[test]
1442    #[cfg(target_os = "macos")]
1443    fn qoder_agent_targets_include_all_macos_mcp_locations() {
1444        let home = std::path::Path::new("/Users/tester");
1445        let targets = agent_mcp_targets("qoder", home).unwrap();
1446        let paths: Vec<_> = targets.iter().map(|t| t.config_path.as_path()).collect();
1447
1448        assert_eq!(
1449            paths,
1450            vec![
1451                home.join(".qoder/mcp.json").as_path(),
1452                home.join("Library/Application Support/Qoder/User/mcp.json")
1453                    .as_path(),
1454                home.join("Library/Application Support/Qoder/SharedClientCache/mcp.json")
1455                    .as_path(),
1456            ]
1457        );
1458        assert!(targets
1459            .iter()
1460            .all(|t| t.config_type == ConfigType::QoderSettings));
1461    }
1462}