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