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
57    terminal_ui::print_step_header(1, 5, "Shell Hook");
58    crate::cli::cmd_init(&["--global".to_string()]);
59
60    // Step 2: Editor auto-detection + configuration
61    terminal_ui::print_step_header(2, 5, "AI Tool Detection");
62
63    let targets = crate::core::editor_registry::build_targets(&home);
64    let mut newly_configured: Vec<&str> = Vec::new();
65    let mut already_configured: Vec<&str> = Vec::new();
66    let mut not_installed: Vec<&str> = Vec::new();
67    let mut errors: Vec<&str> = Vec::new();
68
69    for target in &targets {
70        let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
71
72        if !target.detect_path.exists() {
73            not_installed.push(target.name);
74            continue;
75        }
76
77        match crate::core::editor_registry::write_config_with_options(
78            target,
79            &binary,
80            WriteOptions {
81                overwrite_invalid: false,
82            },
83        ) {
84            Ok(res) if res.action == WriteAction::Already => {
85                terminal_ui::print_status_ok(&format!(
86                    "{:<20} \x1b[2m{short_path}\x1b[0m",
87                    target.name
88                ));
89                already_configured.push(target.name);
90            }
91            Ok(_) => {
92                terminal_ui::print_status_new(&format!(
93                    "{:<20} \x1b[2m{short_path}\x1b[0m",
94                    target.name
95                ));
96                newly_configured.push(target.name);
97            }
98            Err(e) => {
99                terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
100                errors.push(target.name);
101            }
102        }
103    }
104
105    let total_ok = newly_configured.len() + already_configured.len();
106    if total_ok == 0 && errors.is_empty() {
107        terminal_ui::print_status_warn(
108            "No AI tools detected. Install one and re-run: lean-ctx setup",
109        );
110    }
111
112    if !not_installed.is_empty() {
113        println!(
114            "  \x1b[2m○ {} not detected: {}\x1b[0m",
115            not_installed.len(),
116            not_installed.join(", ")
117        );
118    }
119
120    // Step 3: Agent rules injection
121    terminal_ui::print_step_header(3, 5, "Agent Rules");
122    let rules_result = crate::rules_inject::inject_all_rules(&home);
123    for name in &rules_result.injected {
124        terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules injected\x1b[0m"));
125    }
126    for name in &rules_result.updated {
127        terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules updated\x1b[0m"));
128    }
129    for name in &rules_result.already {
130        terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mrules up-to-date\x1b[0m"));
131    }
132    for err in &rules_result.errors {
133        terminal_ui::print_status_warn(err);
134    }
135    if rules_result.injected.is_empty()
136        && rules_result.updated.is_empty()
137        && rules_result.already.is_empty()
138        && rules_result.errors.is_empty()
139    {
140        terminal_ui::print_status_skip("No agent rules needed");
141    }
142
143    // Legacy agent hooks
144    for target in &targets {
145        if !target.detect_path.exists() || target.agent_key.is_empty() {
146            continue;
147        }
148        crate::hooks::install_agent_hook(&target.agent_key, true);
149    }
150
151    // Step 4: Data directory + diagnostics
152    terminal_ui::print_step_header(4, 5, "Environment Check");
153    let lean_dir = home.join(".lean-ctx");
154    if !lean_dir.exists() {
155        let _ = std::fs::create_dir_all(&lean_dir);
156        terminal_ui::print_status_new("Created ~/.lean-ctx/");
157    } else {
158        terminal_ui::print_status_ok("~/.lean-ctx/ ready");
159    }
160    crate::doctor::run_compact();
161
162    // Step 5: Data sharing
163    terminal_ui::print_step_header(5, 5, "Help Improve lean-ctx");
164    println!("  Share anonymous compression stats to make lean-ctx better.");
165    println!("  \x1b[1mNo code, no file names, no personal data — ever.\x1b[0m");
166    println!();
167    print!("  Enable anonymous data sharing? \x1b[1m[Y/n]\x1b[0m ");
168    use std::io::Write;
169    std::io::stdout().flush().ok();
170
171    let mut input = String::new();
172    let contribute = if std::io::stdin().read_line(&mut input).is_ok() {
173        let answer = input.trim().to_lowercase();
174        answer.is_empty() || answer == "y" || answer == "yes"
175    } else {
176        false
177    };
178
179    if contribute {
180        let config_dir = home.join(".lean-ctx");
181        let _ = std::fs::create_dir_all(&config_dir);
182        let config_path = config_dir.join("config.toml");
183        let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
184        if !config_content.contains("[cloud]") {
185            if !config_content.is_empty() && !config_content.ends_with('\n') {
186                config_content.push('\n');
187            }
188            config_content.push_str("\n[cloud]\ncontribute_enabled = true\n");
189            let _ = std::fs::write(&config_path, config_content);
190        }
191        terminal_ui::print_status_ok("Enabled — thank you!");
192    } else {
193        terminal_ui::print_status_skip("Skipped — enable later with: lean-ctx config");
194    }
195
196    // Summary
197    println!();
198    println!(
199        "  \x1b[1;32m✓ Setup complete!\x1b[0m  \x1b[1m{}\x1b[0m configured, \x1b[2m{} already set, {} skipped\x1b[0m",
200        newly_configured.len(),
201        already_configured.len(),
202        not_installed.len()
203    );
204
205    if !errors.is_empty() {
206        println!(
207            "  \x1b[33m⚠ {} error{}: {}\x1b[0m",
208            errors.len(),
209            if errors.len() != 1 { "s" } else { "" },
210            errors.join(", ")
211        );
212    }
213
214    // Next steps
215    let shell = std::env::var("SHELL").unwrap_or_default();
216    let source_cmd = if shell.contains("zsh") {
217        "source ~/.zshrc"
218    } else if shell.contains("fish") {
219        "source ~/.config/fish/config.fish"
220    } else if shell.contains("bash") {
221        "source ~/.bashrc"
222    } else {
223        "Restart your shell"
224    };
225
226    let dim = "\x1b[2m";
227    let bold = "\x1b[1m";
228    let cyan = "\x1b[36m";
229    let yellow = "\x1b[33m";
230    let rst = "\x1b[0m";
231
232    println!();
233    println!("  {bold}Next steps:{rst}");
234    println!();
235    println!("  {cyan}1.{rst} Reload your shell:");
236    println!("     {bold}{source_cmd}{rst}");
237    println!();
238
239    let mut tools_to_restart: Vec<String> =
240        newly_configured.iter().map(|s| s.to_string()).collect();
241    for name in rules_result
242        .injected
243        .iter()
244        .chain(rules_result.updated.iter())
245    {
246        if !tools_to_restart.iter().any(|t| t == name) {
247            tools_to_restart.push(name.clone());
248        }
249    }
250
251    if !tools_to_restart.is_empty() {
252        println!("  {cyan}2.{rst} {yellow}{bold}Restart your IDE / AI tool:{rst}");
253        println!("     {bold}{}{rst}", tools_to_restart.join(", "));
254        println!(
255            "     {dim}The MCP connection must be re-established for changes to take effect.{rst}"
256        );
257        println!("     {dim}Close and re-open the application completely.{rst}");
258    } else if !already_configured.is_empty() {
259        println!(
260            "  {cyan}2.{rst} {dim}Your tools are already configured — no restart needed.{rst}"
261        );
262    }
263
264    println!();
265    println!(
266        "  {dim}After restart, lean-ctx will automatically optimize every AI interaction.{rst}"
267    );
268    println!("  {dim}Verify with:{rst} {bold}lean-ctx gain{rst}");
269
270    // Logo + commands
271    println!();
272    terminal_ui::print_logo_animated();
273    terminal_ui::print_command_box();
274}
275
276#[derive(Debug, Clone, Copy, Default)]
277pub struct SetupOptions {
278    pub non_interactive: bool,
279    pub yes: bool,
280    pub fix: bool,
281    pub json: bool,
282}
283
284pub fn run_setup_with_options(opts: SetupOptions) -> Result<SetupReport, String> {
285    let started_at = Utc::now();
286    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
287    let binary = resolve_portable_binary();
288    let home_str = home.to_string_lossy().to_string();
289
290    let mut steps: Vec<SetupStepReport> = Vec::new();
291
292    // Step: Shell Hook
293    let mut shell_step = SetupStepReport {
294        name: "shell_hook".to_string(),
295        ok: true,
296        items: Vec::new(),
297        warnings: Vec::new(),
298        errors: Vec::new(),
299    };
300    if !opts.non_interactive || opts.yes {
301        if opts.json {
302            crate::cli::cmd_init_quiet(&["--global".to_string()]);
303        } else {
304            crate::cli::cmd_init(&["--global".to_string()]);
305        }
306        shell_step.items.push(SetupItem {
307            name: "init --global".to_string(),
308            status: "ran".to_string(),
309            path: None,
310            note: None,
311        });
312    } else {
313        shell_step
314            .warnings
315            .push("non_interactive_without_yes: shell hook not installed (use --yes)".to_string());
316        shell_step.ok = false;
317        shell_step.items.push(SetupItem {
318            name: "init --global".to_string(),
319            status: "skipped".to_string(),
320            path: None,
321            note: Some("requires --yes in --non-interactive mode".to_string()),
322        });
323    }
324    steps.push(shell_step);
325
326    // Step: Editor MCP config
327    let mut editor_step = SetupStepReport {
328        name: "editors".to_string(),
329        ok: true,
330        items: Vec::new(),
331        warnings: Vec::new(),
332        errors: Vec::new(),
333    };
334
335    let targets = crate::core::editor_registry::build_targets(&home);
336    for target in &targets {
337        let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
338        if !target.detect_path.exists() {
339            editor_step.items.push(SetupItem {
340                name: target.name.to_string(),
341                status: "not_detected".to_string(),
342                path: Some(short_path),
343                note: None,
344            });
345            continue;
346        }
347
348        let res = crate::core::editor_registry::write_config_with_options(
349            target,
350            &binary,
351            WriteOptions {
352                overwrite_invalid: opts.fix,
353            },
354        );
355        match res {
356            Ok(w) => {
357                editor_step.items.push(SetupItem {
358                    name: target.name.to_string(),
359                    status: match w.action {
360                        WriteAction::Created => "created".to_string(),
361                        WriteAction::Updated => "updated".to_string(),
362                        WriteAction::Already => "already".to_string(),
363                    },
364                    path: Some(short_path),
365                    note: w.note,
366                });
367            }
368            Err(e) => {
369                editor_step.ok = false;
370                editor_step.items.push(SetupItem {
371                    name: target.name.to_string(),
372                    status: "error".to_string(),
373                    path: Some(short_path),
374                    note: Some(e),
375                });
376            }
377        }
378    }
379    steps.push(editor_step);
380
381    // Step: Agent rules
382    let mut rules_step = SetupStepReport {
383        name: "agent_rules".to_string(),
384        ok: true,
385        items: Vec::new(),
386        warnings: Vec::new(),
387        errors: Vec::new(),
388    };
389    let rules_result = crate::rules_inject::inject_all_rules(&home);
390    for n in rules_result.injected {
391        rules_step.items.push(SetupItem {
392            name: n,
393            status: "injected".to_string(),
394            path: None,
395            note: None,
396        });
397    }
398    for n in rules_result.updated {
399        rules_step.items.push(SetupItem {
400            name: n,
401            status: "updated".to_string(),
402            path: None,
403            note: None,
404        });
405    }
406    for n in rules_result.already {
407        rules_step.items.push(SetupItem {
408            name: n,
409            status: "already".to_string(),
410            path: None,
411            note: None,
412        });
413    }
414    for e in rules_result.errors {
415        rules_step.ok = false;
416        rules_step.errors.push(e);
417    }
418    steps.push(rules_step);
419
420    // Step: Environment / doctor (compact)
421    let mut env_step = SetupStepReport {
422        name: "doctor_compact".to_string(),
423        ok: true,
424        items: Vec::new(),
425        warnings: Vec::new(),
426        errors: Vec::new(),
427    };
428    let (passed, total) = crate::doctor::compact_score();
429    env_step.items.push(SetupItem {
430        name: "doctor".to_string(),
431        status: format!("{passed}/{total}"),
432        path: None,
433        note: None,
434    });
435    if passed != total {
436        env_step.warnings.push(format!(
437            "doctor compact not fully passing: {passed}/{total}"
438        ));
439    }
440    steps.push(env_step);
441
442    let finished_at = Utc::now();
443    let success = steps.iter().all(|s| s.ok);
444    let report = SetupReport {
445        schema_version: 1,
446        started_at,
447        finished_at,
448        success,
449        platform: PlatformInfo {
450            os: std::env::consts::OS.to_string(),
451            arch: std::env::consts::ARCH.to_string(),
452        },
453        steps,
454        warnings: Vec::new(),
455        errors: Vec::new(),
456    };
457
458    let path = SetupReport::default_path()?;
459    let mut content =
460        serde_json::to_string_pretty(&report).map_err(|e| format!("serialize report: {e}"))?;
461    content.push('\n');
462    crate::config_io::write_atomic(&path, &content)?;
463
464    Ok(report)
465}
466
467pub fn configure_agent_mcp(agent: &str) -> Result<(), String> {
468    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
469    let binary = resolve_portable_binary();
470
471    let mut targets = Vec::<EditorTarget>::new();
472
473    let push = |targets: &mut Vec<EditorTarget>,
474                name: &'static str,
475                config_path: PathBuf,
476                config_type: ConfigType| {
477        targets.push(EditorTarget {
478            name,
479            agent_key: agent.to_string(),
480            detect_path: PathBuf::from("/nonexistent"), // not used in direct agent config
481            config_path,
482            config_type,
483        });
484    };
485
486    match agent {
487        "cursor" => push(
488            &mut targets,
489            "Cursor",
490            home.join(".cursor/mcp.json"),
491            ConfigType::McpJson,
492        ),
493        "claude" | "claude-code" => push(
494            &mut targets,
495            "Claude Code",
496            crate::core::editor_registry::claude_mcp_json_path(&home),
497            ConfigType::McpJson,
498        ),
499        "windsurf" => push(
500            &mut targets,
501            "Windsurf",
502            home.join(".codeium/windsurf/mcp_config.json"),
503            ConfigType::McpJson,
504        ),
505        "codex" => push(
506            &mut targets,
507            "Codex CLI",
508            home.join(".codex/config.toml"),
509            ConfigType::Codex,
510        ),
511        "gemini" => {
512            push(
513                &mut targets,
514                "Gemini CLI",
515                home.join(".gemini/settings/mcp.json"),
516                ConfigType::McpJson,
517            );
518            push(
519                &mut targets,
520                "Antigravity",
521                home.join(".gemini/antigravity/mcp_config.json"),
522                ConfigType::McpJson,
523            );
524        }
525        "antigravity" => push(
526            &mut targets,
527            "Antigravity",
528            home.join(".gemini/antigravity/mcp_config.json"),
529            ConfigType::McpJson,
530        ),
531        "copilot" => push(
532            &mut targets,
533            "VS Code / Copilot",
534            crate::core::editor_registry::vscode_mcp_path(),
535            ConfigType::VsCodeMcp,
536        ),
537        "crush" => push(
538            &mut targets,
539            "Crush",
540            home.join(".config/crush/crush.json"),
541            ConfigType::Crush,
542        ),
543        "pi" => push(
544            &mut targets,
545            "Pi Coding Agent",
546            home.join(".pi/agent/mcp.json"),
547            ConfigType::McpJson,
548        ),
549        "cline" => push(
550            &mut targets,
551            "Cline",
552            crate::core::editor_registry::cline_mcp_path(),
553            ConfigType::McpJson,
554        ),
555        "roo" => push(
556            &mut targets,
557            "Roo Code",
558            crate::core::editor_registry::roo_mcp_path(),
559            ConfigType::McpJson,
560        ),
561        "kiro" => push(
562            &mut targets,
563            "AWS Kiro",
564            home.join(".kiro/settings/mcp.json"),
565            ConfigType::McpJson,
566        ),
567        "verdent" => push(
568            &mut targets,
569            "Verdent",
570            home.join(".verdent/mcp.json"),
571            ConfigType::McpJson,
572        ),
573        "jetbrains" => push(
574            &mut targets,
575            "JetBrains IDEs",
576            home.join(".jb-mcp.json"),
577            ConfigType::McpJson,
578        ),
579        _ => {
580            return Err(format!("Unknown agent '{agent}'"));
581        }
582    }
583
584    for t in &targets {
585        crate::core::editor_registry::write_config_with_options(
586            t,
587            &binary,
588            WriteOptions {
589                overwrite_invalid: true,
590            },
591        )?;
592    }
593
594    if agent == "kiro" {
595        install_kiro_steering(&home);
596    }
597
598    Ok(())
599}
600
601fn install_kiro_steering(home: &std::path::Path) {
602    let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
603    let steering_dir = cwd.join(".kiro").join("steering");
604    let steering_file = steering_dir.join("lean-ctx.md");
605
606    if steering_file.exists()
607        && std::fs::read_to_string(&steering_file)
608            .unwrap_or_default()
609            .contains("lean-ctx")
610    {
611        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
612        return;
613    }
614
615    let _ = std::fs::create_dir_all(&steering_dir);
616    let _ = std::fs::write(&steering_file, crate::hooks::KIRO_STEERING_TEMPLATE);
617    println!("  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
618}
619
620fn shorten_path(path: &str, home: &str) -> String {
621    if let Some(stripped) = path.strip_prefix(home) {
622        format!("~{stripped}")
623    } else {
624        path.to_string()
625    }
626}