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 chrono::Utc;
7
8pub fn claude_config_json_path(home: &std::path::Path) -> PathBuf {
9    crate::core::editor_registry::claude_mcp_json_path(home)
10}
11
12pub fn claude_config_dir(home: &std::path::Path) -> PathBuf {
13    crate::core::editor_registry::claude_state_dir(home)
14}
15
16pub fn run_setup() {
17    use crate::terminal_ui;
18
19    if crate::shell::is_non_interactive() {
20        eprintln!("Non-interactive terminal detected (no TTY on stdin).");
21        eprintln!("Running in non-interactive mode (equivalent to: lean-ctx setup --non-interactive --yes)");
22        eprintln!();
23        let opts = SetupOptions {
24            non_interactive: true,
25            yes: true,
26            fix: false,
27            json: false,
28        };
29        match run_setup_with_options(opts) {
30            Ok(report) => {
31                if !report.warnings.is_empty() {
32                    for w in &report.warnings {
33                        eprintln!("  warning: {w}");
34                    }
35                }
36            }
37            Err(e) => eprintln!("Setup error: {e}"),
38        }
39        return;
40    }
41
42    let home = match dirs::home_dir() {
43        Some(h) => h,
44        None => {
45            eprintln!("Cannot determine home directory");
46            std::process::exit(1);
47        }
48    };
49
50    let binary = resolve_portable_binary();
51
52    let home_str = home.to_string_lossy().to_string();
53
54    terminal_ui::print_setup_header();
55
56    // Step 1: Shell hook (legacy aliases + universal shell hook)
57    terminal_ui::print_step_header(1, 6, "Shell Hook");
58    crate::cli::cmd_init(&["--global".to_string()]);
59    crate::shell_hook::install_all(false);
60
61    // Step 2: Editor auto-detection + configuration
62    terminal_ui::print_step_header(2, 6, "AI Tool Detection");
63
64    let targets = crate::core::editor_registry::build_targets(&home);
65    let mut newly_configured: Vec<&str> = Vec::new();
66    let mut already_configured: Vec<&str> = Vec::new();
67    let mut not_installed: Vec<&str> = Vec::new();
68    let mut errors: Vec<&str> = Vec::new();
69
70    for target in &targets {
71        let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
72
73        if !target.detect_path.exists() {
74            not_installed.push(target.name);
75            continue;
76        }
77
78        match crate::core::editor_registry::write_config_with_options(
79            target,
80            &binary,
81            WriteOptions {
82                overwrite_invalid: false,
83            },
84        ) {
85            Ok(res) if res.action == WriteAction::Already => {
86                terminal_ui::print_status_ok(&format!(
87                    "{:<20} \x1b[2m{short_path}\x1b[0m",
88                    target.name
89                ));
90                already_configured.push(target.name);
91            }
92            Ok(_) => {
93                terminal_ui::print_status_new(&format!(
94                    "{:<20} \x1b[2m{short_path}\x1b[0m",
95                    target.name
96                ));
97                newly_configured.push(target.name);
98            }
99            Err(e) => {
100                terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
101                errors.push(target.name);
102            }
103        }
104    }
105
106    let total_ok = newly_configured.len() + already_configured.len();
107    if total_ok == 0 && errors.is_empty() {
108        terminal_ui::print_status_warn(
109            "No AI tools detected. Install one and re-run: lean-ctx setup",
110        );
111    }
112
113    if !not_installed.is_empty() {
114        println!(
115            "  \x1b[2m○ {} not detected: {}\x1b[0m",
116            not_installed.len(),
117            not_installed.join(", ")
118        );
119    }
120
121    // Step 3: Agent rules injection
122    terminal_ui::print_step_header(3, 6, "Agent Rules");
123    let rules_result = crate::rules_inject::inject_all_rules(&home);
124    for name in &rules_result.injected {
125        terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules injected\x1b[0m"));
126    }
127    for name in &rules_result.updated {
128        terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules updated\x1b[0m"));
129    }
130    for name in &rules_result.already {
131        terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mrules up-to-date\x1b[0m"));
132    }
133    for err in &rules_result.errors {
134        terminal_ui::print_status_warn(err);
135    }
136    if rules_result.injected.is_empty()
137        && rules_result.updated.is_empty()
138        && rules_result.already.is_empty()
139        && rules_result.errors.is_empty()
140    {
141        terminal_ui::print_status_skip("No agent rules needed");
142    }
143
144    // Legacy agent hooks
145    for target in &targets {
146        if !target.detect_path.exists() || target.agent_key.is_empty() {
147            continue;
148        }
149        crate::hooks::install_agent_hook(&target.agent_key, true);
150    }
151
152    // Step 4: API Proxy configuration
153    terminal_ui::print_step_header(4, 6, "API Proxy");
154    crate::proxy_setup::install_proxy_env(&home, crate::proxy_setup::default_port(), false);
155    println!();
156    println!("  \x1b[2mStart proxy for maximum token savings:\x1b[0m");
157    println!("    \x1b[1mlean-ctx proxy start\x1b[0m");
158    println!("  \x1b[2mEnable autostart:\x1b[0m");
159    println!("    \x1b[1mlean-ctx proxy start --autostart\x1b[0m");
160
161    // Step 5: Data directory + diagnostics
162    terminal_ui::print_step_header(5, 6, "Environment Check");
163    let lean_dir = home.join(".lean-ctx");
164    if !lean_dir.exists() {
165        let _ = std::fs::create_dir_all(&lean_dir);
166        terminal_ui::print_status_new("Created ~/.lean-ctx/");
167    } else {
168        terminal_ui::print_status_ok("~/.lean-ctx/ ready");
169    }
170    crate::doctor::run_compact();
171
172    // Step 6: Data sharing
173    terminal_ui::print_step_header(6, 6, "Help Improve lean-ctx");
174    println!("  Share anonymous compression stats to make lean-ctx better.");
175    println!("  \x1b[1mNo code, no file names, no personal data — ever.\x1b[0m");
176    println!();
177    print!("  Enable anonymous data sharing? \x1b[1m[Y/n]\x1b[0m ");
178    use std::io::Write;
179    std::io::stdout().flush().ok();
180
181    let mut input = String::new();
182    let contribute = if std::io::stdin().read_line(&mut input).is_ok() {
183        let answer = input.trim().to_lowercase();
184        answer.is_empty() || answer == "y" || answer == "yes"
185    } else {
186        false
187    };
188
189    if contribute {
190        let config_dir = home.join(".lean-ctx");
191        let _ = std::fs::create_dir_all(&config_dir);
192        let config_path = config_dir.join("config.toml");
193        let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
194        if !config_content.contains("[cloud]") {
195            if !config_content.is_empty() && !config_content.ends_with('\n') {
196                config_content.push('\n');
197            }
198            config_content.push_str("\n[cloud]\ncontribute_enabled = true\n");
199            let _ = std::fs::write(&config_path, config_content);
200        }
201        terminal_ui::print_status_ok("Enabled — thank you!");
202    } else {
203        terminal_ui::print_status_skip("Skipped — enable later with: lean-ctx config");
204    }
205
206    // Summary
207    println!();
208    println!(
209        "  \x1b[1;32m✓ Setup complete!\x1b[0m  \x1b[1m{}\x1b[0m configured, \x1b[2m{} already set, {} skipped\x1b[0m",
210        newly_configured.len(),
211        already_configured.len(),
212        not_installed.len()
213    );
214
215    if !errors.is_empty() {
216        println!(
217            "  \x1b[33m⚠ {} error{}: {}\x1b[0m",
218            errors.len(),
219            if errors.len() != 1 { "s" } else { "" },
220            errors.join(", ")
221        );
222    }
223
224    // Next steps
225    let shell = std::env::var("SHELL").unwrap_or_default();
226    let source_cmd = if shell.contains("zsh") {
227        "source ~/.zshrc"
228    } else if shell.contains("fish") {
229        "source ~/.config/fish/config.fish"
230    } else if shell.contains("bash") {
231        "source ~/.bashrc"
232    } else {
233        "Restart your shell"
234    };
235
236    let dim = "\x1b[2m";
237    let bold = "\x1b[1m";
238    let cyan = "\x1b[36m";
239    let yellow = "\x1b[33m";
240    let rst = "\x1b[0m";
241
242    println!();
243    println!("  {bold}Next steps:{rst}");
244    println!();
245    println!("  {cyan}1.{rst} Reload your shell:");
246    println!("     {bold}{source_cmd}{rst}");
247    println!();
248
249    let mut tools_to_restart: Vec<String> =
250        newly_configured.iter().map(|s| s.to_string()).collect();
251    for name in rules_result
252        .injected
253        .iter()
254        .chain(rules_result.updated.iter())
255    {
256        if !tools_to_restart.iter().any(|t| t == name) {
257            tools_to_restart.push(name.clone());
258        }
259    }
260
261    if !tools_to_restart.is_empty() {
262        println!("  {cyan}2.{rst} {yellow}{bold}Restart your IDE / AI tool:{rst}");
263        println!("     {bold}{}{rst}", tools_to_restart.join(", "));
264        println!(
265            "     {dim}The MCP connection must be re-established for changes to take effect.{rst}"
266        );
267        println!("     {dim}Close and re-open the application completely.{rst}");
268    } else if !already_configured.is_empty() {
269        println!(
270            "  {cyan}2.{rst} {dim}Your tools are already configured — no restart needed.{rst}"
271        );
272    }
273
274    println!();
275    println!(
276        "  {dim}After restart, lean-ctx will automatically optimize every AI interaction.{rst}"
277    );
278    println!("  {dim}Verify with:{rst} {bold}lean-ctx gain{rst}");
279
280    // Logo + commands
281    println!();
282    terminal_ui::print_logo_animated();
283    terminal_ui::print_command_box();
284}
285
286#[derive(Debug, Clone, Copy, Default)]
287pub struct SetupOptions {
288    pub non_interactive: bool,
289    pub yes: bool,
290    pub fix: bool,
291    pub json: bool,
292}
293
294pub fn run_setup_with_options(opts: SetupOptions) -> Result<SetupReport, String> {
295    let started_at = Utc::now();
296    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
297    let binary = resolve_portable_binary();
298    let home_str = home.to_string_lossy().to_string();
299
300    let mut steps: Vec<SetupStepReport> = Vec::new();
301
302    // Step: Shell Hook
303    let mut shell_step = SetupStepReport {
304        name: "shell_hook".to_string(),
305        ok: true,
306        items: Vec::new(),
307        warnings: Vec::new(),
308        errors: Vec::new(),
309    };
310    if !opts.non_interactive || opts.yes {
311        if opts.json {
312            crate::cli::cmd_init_quiet(&["--global".to_string()]);
313        } else {
314            crate::cli::cmd_init(&["--global".to_string()]);
315        }
316        crate::shell_hook::install_all(opts.json);
317        shell_step.items.push(SetupItem {
318            name: "init --global".to_string(),
319            status: "ran".to_string(),
320            path: None,
321            note: None,
322        });
323        shell_step.items.push(SetupItem {
324            name: "universal_shell_hook".to_string(),
325            status: "installed".to_string(),
326            path: None,
327            note: Some("~/.zshenv, ~/.bashenv, agent aliases".to_string()),
328        });
329    } else {
330        shell_step
331            .warnings
332            .push("non_interactive_without_yes: shell hook not installed (use --yes)".to_string());
333        shell_step.ok = false;
334        shell_step.items.push(SetupItem {
335            name: "init --global".to_string(),
336            status: "skipped".to_string(),
337            path: None,
338            note: Some("requires --yes in --non-interactive mode".to_string()),
339        });
340    }
341    steps.push(shell_step);
342
343    // Step: Editor MCP config
344    let mut editor_step = SetupStepReport {
345        name: "editors".to_string(),
346        ok: true,
347        items: Vec::new(),
348        warnings: Vec::new(),
349        errors: Vec::new(),
350    };
351
352    let targets = crate::core::editor_registry::build_targets(&home);
353    for target in &targets {
354        let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
355        if !target.detect_path.exists() {
356            editor_step.items.push(SetupItem {
357                name: target.name.to_string(),
358                status: "not_detected".to_string(),
359                path: Some(short_path),
360                note: None,
361            });
362            continue;
363        }
364
365        let res = crate::core::editor_registry::write_config_with_options(
366            target,
367            &binary,
368            WriteOptions {
369                overwrite_invalid: opts.fix,
370            },
371        );
372        match res {
373            Ok(w) => {
374                editor_step.items.push(SetupItem {
375                    name: target.name.to_string(),
376                    status: match w.action {
377                        WriteAction::Created => "created".to_string(),
378                        WriteAction::Updated => "updated".to_string(),
379                        WriteAction::Already => "already".to_string(),
380                    },
381                    path: Some(short_path),
382                    note: w.note,
383                });
384            }
385            Err(e) => {
386                editor_step.ok = false;
387                editor_step.items.push(SetupItem {
388                    name: target.name.to_string(),
389                    status: "error".to_string(),
390                    path: Some(short_path),
391                    note: Some(e),
392                });
393            }
394        }
395    }
396    steps.push(editor_step);
397
398    // Step: Agent rules
399    let mut rules_step = SetupStepReport {
400        name: "agent_rules".to_string(),
401        ok: true,
402        items: Vec::new(),
403        warnings: Vec::new(),
404        errors: Vec::new(),
405    };
406    let rules_result = crate::rules_inject::inject_all_rules(&home);
407    for n in rules_result.injected {
408        rules_step.items.push(SetupItem {
409            name: n,
410            status: "injected".to_string(),
411            path: None,
412            note: None,
413        });
414    }
415    for n in rules_result.updated {
416        rules_step.items.push(SetupItem {
417            name: n,
418            status: "updated".to_string(),
419            path: None,
420            note: None,
421        });
422    }
423    for n in rules_result.already {
424        rules_step.items.push(SetupItem {
425            name: n,
426            status: "already".to_string(),
427            path: None,
428            note: None,
429        });
430    }
431    for e in rules_result.errors {
432        rules_step.ok = false;
433        rules_step.errors.push(e);
434    }
435    steps.push(rules_step);
436
437    // Step: Agent-specific hooks (Codex, Cursor)
438    let mut hooks_step = SetupStepReport {
439        name: "agent_hooks".to_string(),
440        ok: true,
441        items: Vec::new(),
442        warnings: Vec::new(),
443        errors: Vec::new(),
444    };
445    for target in &targets {
446        if !target.detect_path.exists() {
447            continue;
448        }
449        match target.agent_key.as_str() {
450            "codex" => {
451                crate::hooks::agents::install_codex_hook();
452                hooks_step.items.push(SetupItem {
453                    name: "Codex hooks".to_string(),
454                    status: "installed".to_string(),
455                    path: Some("~/.codex/hooks/".to_string()),
456                    note: None,
457                });
458            }
459            "cursor" => {
460                let hooks_path = home.join(".cursor/hooks.json");
461                if !hooks_path.exists() {
462                    crate::hooks::agents::install_cursor_hook(true);
463                    hooks_step.items.push(SetupItem {
464                        name: "Cursor hooks".to_string(),
465                        status: "installed".to_string(),
466                        path: Some("~/.cursor/hooks.json".to_string()),
467                        note: None,
468                    });
469                }
470            }
471            _ => {}
472        }
473    }
474    if !hooks_step.items.is_empty() {
475        steps.push(hooks_step);
476    }
477
478    // Step: Proxy env vars
479    let mut proxy_step = SetupStepReport {
480        name: "proxy_env".to_string(),
481        ok: true,
482        items: Vec::new(),
483        warnings: Vec::new(),
484        errors: Vec::new(),
485    };
486    crate::proxy_setup::install_proxy_env(&home, crate::proxy_setup::default_port(), opts.json);
487    proxy_step.items.push(SetupItem {
488        name: "proxy_env".to_string(),
489        status: "configured".to_string(),
490        path: None,
491        note: Some("ANTHROPIC_BASE_URL, OPENAI_BASE_URL, GEMINI_API_BASE_URL".to_string()),
492    });
493    steps.push(proxy_step);
494
495    // Step: Environment / doctor (compact)
496    let mut env_step = SetupStepReport {
497        name: "doctor_compact".to_string(),
498        ok: true,
499        items: Vec::new(),
500        warnings: Vec::new(),
501        errors: Vec::new(),
502    };
503    let (passed, total) = crate::doctor::compact_score();
504    env_step.items.push(SetupItem {
505        name: "doctor".to_string(),
506        status: format!("{passed}/{total}"),
507        path: None,
508        note: None,
509    });
510    if passed != total {
511        env_step.warnings.push(format!(
512            "doctor compact not fully passing: {passed}/{total}"
513        ));
514    }
515    steps.push(env_step);
516
517    let finished_at = Utc::now();
518    let success = steps.iter().all(|s| s.ok);
519    let report = SetupReport {
520        schema_version: 1,
521        started_at,
522        finished_at,
523        success,
524        platform: PlatformInfo {
525            os: std::env::consts::OS.to_string(),
526            arch: std::env::consts::ARCH.to_string(),
527        },
528        steps,
529        warnings: Vec::new(),
530        errors: Vec::new(),
531    };
532
533    let path = SetupReport::default_path()?;
534    let mut content =
535        serde_json::to_string_pretty(&report).map_err(|e| format!("serialize report: {e}"))?;
536    content.push('\n');
537    crate::config_io::write_atomic(&path, &content)?;
538
539    Ok(report)
540}
541
542pub fn configure_agent_mcp(agent: &str) -> Result<(), String> {
543    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
544    let binary = resolve_portable_binary();
545
546    let mut targets = Vec::<EditorTarget>::new();
547
548    let push = |targets: &mut Vec<EditorTarget>,
549                name: &'static str,
550                config_path: PathBuf,
551                config_type: ConfigType| {
552        targets.push(EditorTarget {
553            name,
554            agent_key: agent.to_string(),
555            detect_path: PathBuf::from("/nonexistent"), // not used in direct agent config
556            config_path,
557            config_type,
558        });
559    };
560
561    match agent {
562        "cursor" => push(
563            &mut targets,
564            "Cursor",
565            home.join(".cursor/mcp.json"),
566            ConfigType::McpJson,
567        ),
568        "claude" | "claude-code" => push(
569            &mut targets,
570            "Claude Code",
571            crate::core::editor_registry::claude_mcp_json_path(&home),
572            ConfigType::McpJson,
573        ),
574        "windsurf" => push(
575            &mut targets,
576            "Windsurf",
577            home.join(".codeium/windsurf/mcp_config.json"),
578            ConfigType::McpJson,
579        ),
580        "codex" => push(
581            &mut targets,
582            "Codex CLI",
583            home.join(".codex/config.toml"),
584            ConfigType::Codex,
585        ),
586        "gemini" => {
587            push(
588                &mut targets,
589                "Gemini CLI",
590                home.join(".gemini/settings.json"),
591                ConfigType::GeminiSettings,
592            );
593            push(
594                &mut targets,
595                "Antigravity",
596                home.join(".gemini/antigravity/mcp_config.json"),
597                ConfigType::McpJson,
598            );
599        }
600        "antigravity" => push(
601            &mut targets,
602            "Antigravity",
603            home.join(".gemini/antigravity/mcp_config.json"),
604            ConfigType::McpJson,
605        ),
606        "copilot" => push(
607            &mut targets,
608            "VS Code / Copilot",
609            crate::core::editor_registry::vscode_mcp_path(),
610            ConfigType::VsCodeMcp,
611        ),
612        "crush" => push(
613            &mut targets,
614            "Crush",
615            home.join(".config/crush/crush.json"),
616            ConfigType::Crush,
617        ),
618        "pi" => push(
619            &mut targets,
620            "Pi Coding Agent",
621            home.join(".pi/agent/mcp.json"),
622            ConfigType::McpJson,
623        ),
624        "cline" => push(
625            &mut targets,
626            "Cline",
627            crate::core::editor_registry::cline_mcp_path(),
628            ConfigType::McpJson,
629        ),
630        "roo" => push(
631            &mut targets,
632            "Roo Code",
633            crate::core::editor_registry::roo_mcp_path(),
634            ConfigType::McpJson,
635        ),
636        "kiro" => push(
637            &mut targets,
638            "AWS Kiro",
639            home.join(".kiro/settings/mcp.json"),
640            ConfigType::McpJson,
641        ),
642        "verdent" => push(
643            &mut targets,
644            "Verdent",
645            home.join(".verdent/mcp.json"),
646            ConfigType::McpJson,
647        ),
648        "jetbrains" => {
649            // JetBrains uses servers[] array format, handled by install_jetbrains_hook
650        }
651        "qwen" => push(
652            &mut targets,
653            "Qwen Code",
654            home.join(".qwen/mcp.json"),
655            ConfigType::McpJson,
656        ),
657        "trae" => push(
658            &mut targets,
659            "Trae",
660            home.join(".trae/mcp.json"),
661            ConfigType::McpJson,
662        ),
663        "amazonq" => push(
664            &mut targets,
665            "Amazon Q Developer",
666            home.join(".aws/amazonq/mcp.json"),
667            ConfigType::McpJson,
668        ),
669        "opencode" => {
670            #[cfg(windows)]
671            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
672                std::path::PathBuf::from(appdata)
673                    .join("opencode")
674                    .join("opencode.json")
675            } else {
676                home.join(".config/opencode/opencode.json")
677            };
678            #[cfg(not(windows))]
679            let opencode_path = home.join(".config/opencode/opencode.json");
680            push(
681                &mut targets,
682                "OpenCode",
683                opencode_path,
684                ConfigType::OpenCode,
685            );
686        }
687        "aider" => push(
688            &mut targets,
689            "Aider",
690            home.join(".aider/mcp.json"),
691            ConfigType::McpJson,
692        ),
693        "amp" => {
694            // Amp uses amp.mcpServers in ~/.config/amp/settings.json, handled by install_amp_hook
695        }
696        "hermes" => push(
697            &mut targets,
698            "Hermes Agent",
699            home.join(".hermes/config.yaml"),
700            ConfigType::HermesYaml,
701        ),
702        _ => {
703            return Err(format!("Unknown agent '{agent}'"));
704        }
705    }
706
707    for t in &targets {
708        crate::core::editor_registry::write_config_with_options(
709            t,
710            &binary,
711            WriteOptions {
712                overwrite_invalid: true,
713            },
714        )?;
715    }
716
717    if agent == "kiro" {
718        install_kiro_steering(&home);
719    }
720
721    Ok(())
722}
723
724fn install_kiro_steering(home: &std::path::Path) {
725    let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
726    let steering_dir = cwd.join(".kiro").join("steering");
727    let steering_file = steering_dir.join("lean-ctx.md");
728
729    if steering_file.exists()
730        && std::fs::read_to_string(&steering_file)
731            .unwrap_or_default()
732            .contains("lean-ctx")
733    {
734        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
735        return;
736    }
737
738    let _ = std::fs::create_dir_all(&steering_dir);
739    let _ = std::fs::write(&steering_file, crate::hooks::KIRO_STEERING_TEMPLATE);
740    println!("  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
741}
742
743fn shorten_path(path: &str, home: &str) -> String {
744    if let Some(stripped) = path.strip_prefix(home) {
745        format!("~{stripped}")
746    } else {
747        path.to_string()
748    }
749}