Skip to main content

lean_ctx/setup/
helpers.rs

1//! Setup helper routines (skill install, TOML key upserts, profile + premium
2//! feature configuration). Split out of `setup/mod.rs` for focus.
3
4#[allow(clippy::wildcard_imports)]
5use super::*;
6
7pub fn install_skill_files(home: &std::path::Path) -> Vec<(String, bool)> {
8    crate::rules_inject::install_all_skills(home)
9}
10
11pub(crate) fn install_kiro_steering(home: &std::path::Path) {
12    let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
13    let steering_dir = cwd.join(".kiro").join("steering");
14    let steering_file = steering_dir.join("lean-ctx.md");
15
16    if steering_file.exists()
17        && std::fs::read_to_string(&steering_file)
18            .unwrap_or_default()
19            .contains("lean-ctx")
20    {
21        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
22        return;
23    }
24
25    let _ = std::fs::create_dir_all(&steering_dir);
26    let _ = std::fs::write(&steering_file, crate::hooks::kiro_steering_content());
27    println!(
28        "  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)"
29    );
30}
31
32pub(crate) fn configure_plan_mode_settings(newly_configured: &[&str], already_configured: &[&str]) {
33    use crate::terminal_ui;
34
35    let all_configured: Vec<&str> = newly_configured
36        .iter()
37        .chain(already_configured.iter())
38        .copied()
39        .collect();
40
41    let has_vscode = all_configured.contains(&"VS Code");
42    let has_claude = all_configured.contains(&"Claude Code");
43    let has_codebuddy = all_configured.contains(&"CodeBuddy");
44
45    if !has_vscode && !has_claude && !has_codebuddy {
46        return;
47    }
48
49    if has_vscode {
50        match crate::core::editor_registry::plan_mode::write_vscode_plan_settings() {
51            Ok(r) if r.action == WriteAction::Already => {
52                terminal_ui::print_status_ok(
53                    "VS Code            \x1b[2mplan mode already configured\x1b[0m",
54                );
55            }
56            Ok(_) => {
57                terminal_ui::print_status_new(
58                    "VS Code            \x1b[2mplan mode tools configured\x1b[0m",
59                );
60            }
61            Err(e) => {
62                terminal_ui::print_status_warn(&format!("VS Code plan mode: {e}"));
63            }
64        }
65    }
66
67    if has_claude {
68        match crate::core::editor_registry::plan_mode::write_claude_code_plan_permissions() {
69            Ok(r) if r.action == WriteAction::Already => {
70                terminal_ui::print_status_ok(
71                    "Claude Code        \x1b[2mplan mode permissions present\x1b[0m",
72                );
73            }
74            Ok(_) => {
75                terminal_ui::print_status_new(
76                    "Claude Code        \x1b[2mplan mode permissions added\x1b[0m",
77                );
78            }
79            Err(e) => {
80                terminal_ui::print_status_warn(&format!("Claude Code plan mode: {e}"));
81            }
82        }
83    }
84
85    if has_codebuddy {
86        match crate::core::editor_registry::plan_mode::write_claude_code_plan_permissions() {
87            Ok(r) if r.action == WriteAction::Already => {
88                terminal_ui::print_status_ok(
89                    "CodeBuddy          \x1b[2mplan mode permissions present\x1b[0m",
90                );
91            }
92            Ok(_) => {
93                terminal_ui::print_status_new(
94                    "CodeBuddy          \x1b[2mplan mode permissions added\x1b[0m",
95                );
96            }
97            Err(e) => {
98                terminal_ui::print_status_warn(&format!("CodeBuddy plan mode: {e}"));
99            }
100        }
101    }
102}
103
104pub(crate) fn shorten_path(path: &str, home: &str) -> String {
105    if let Some(stripped) = path.strip_prefix(home) {
106        format!("~{stripped}")
107    } else {
108        path.to_string()
109    }
110}
111
112fn upsert_toml_key(content: &mut String, key: &str, value: &str) {
113    let pattern = format!("{key} = ");
114    if let Some(start) = content.find(&pattern) {
115        let line_end = content[start..]
116            .find('\n')
117            .map_or(content.len(), |p| start + p);
118        content.replace_range(start..line_end, &format!("{key} = \"{value}\""));
119    } else {
120        if !content.is_empty() && !content.ends_with('\n') {
121            content.push('\n');
122        }
123        content.push_str(&format!("{key} = \"{value}\"\n"));
124    }
125}
126
127fn remove_toml_key(content: &mut String, key: &str) {
128    let pattern = format!("{key} = ");
129    if let Some(start) = content.find(&pattern) {
130        let line_end = content[start..]
131            .find('\n')
132            .map_or(content.len(), |p| start + p + 1);
133        content.replace_range(start..line_end, "");
134    }
135}
136
137pub(crate) fn configure_tool_profile() {
138    use crate::terminal_ui;
139    use std::io::Write;
140
141    let cfg = crate::core::config::Config::load();
142    let current = cfg.tool_profile_effective();
143    let pinned = cfg.tool_profile.is_some() || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok();
144
145    // An explicitly pinned non-power profile is a deliberate, bounded choice —
146    // don't re-nag. Power (pinned or legacy fallback) re-prompts because it
147    // advertises every tool schema, the single largest fixed cost (#575).
148    if pinned && !matches!(current, crate::core::tool_profiles::ToolProfile::Power) {
149        terminal_ui::print_status_ok(&format!(
150            "Tool profile: {} ({} tools)",
151            current.as_str(),
152            current.tool_count()
153        ));
154        return;
155    }
156
157    let dim = "\x1b[2m";
158    let bold = "\x1b[1m";
159    let cyan = "\x1b[36m";
160    let rst = "\x1b[0m";
161
162    let registry_count = crate::server::registry::tool_count();
163    let lazy_count = crate::tool_defs::core_tool_names().len();
164
165    println!("  {dim}Control how many MCP tool schemas your AI agent sees.{rst}");
166    println!("  {dim}Fewer advertised tools = less context overhead. Every tool stays{rst}");
167    println!("  {dim}callable through ctx_call, even when its schema is not advertised.{rst}");
168    println!();
169    println!(
170        "  {cyan}lean{rst}      — {lazy_count} tools  {dim}(lazy core, recommended — lowest token overhead){rst}"
171    );
172    println!(
173        "  {cyan}minimal{rst}   — 5 tools  {dim}(ctx_read, ctx_shell, ctx_search, ctx_glob, ctx_tree){rst}"
174    );
175    println!("  {cyan}standard{rst}  — 16 tools  {dim}(balanced set for most workflows){rst}");
176    println!(
177        "  {cyan}power{rst}     — {registry_count} tools  {dim}(everything advertised, costs the most context){rst}"
178    );
179    println!();
180    print!("  Tool profile? {bold}[lean/minimal/standard/power]{rst} {dim}(default: lean){rst} ");
181    std::io::stdout().flush().ok();
182
183    let mut profile_input = String::new();
184    let profile_name = if std::io::stdin().read_line(&mut profile_input).is_ok() {
185        let trimmed = profile_input.trim().to_lowercase();
186        match trimmed.as_str() {
187            "minimal" | "min" => "minimal",
188            "standard" | "std" => "standard",
189            "power" | "full" | "all" => "power",
190            _ => "lean",
191        }
192    } else {
193        "lean"
194    };
195
196    if profile_name == "lean" {
197        match crate::core::tool_profiles::clear_profile_in_config() {
198            Ok(()) => terminal_ui::print_status_ok(&format!(
199                "Tool profile: lean ({lazy_count} tools advertised, all reachable via ctx_call)"
200            )),
201            Err(e) => terminal_ui::print_status_warn(&format!("Could not save tool profile: {e}")),
202        }
203        return;
204    }
205
206    match crate::core::tool_profiles::set_profile_in_config(profile_name) {
207        Ok(()) => {
208            let profile = crate::core::tool_profiles::ToolProfile::parse(profile_name)
209                .unwrap_or(crate::core::tool_profiles::ToolProfile::Standard);
210            let count = match &profile {
211                crate::core::tool_profiles::ToolProfile::Power => registry_count,
212                other => other.tool_count(),
213            };
214            terminal_ui::print_status_ok(&format!("Tool profile: {profile_name} ({count} tools)"));
215        }
216        Err(e) => {
217            terminal_ui::print_status_warn(&format!("Could not save tool profile: {e}"));
218        }
219    }
220}
221
222pub(crate) fn configure_premium_features(home: &std::path::Path) {
223    use crate::terminal_ui;
224    use std::io::Write;
225
226    let config_path = crate::core::config::Config::path()
227        .unwrap_or_else(|| home.join(".config/lean-ctx").join("config.toml"));
228    if let Some(dir) = config_path.parent() {
229        let _ = std::fs::create_dir_all(dir);
230    }
231    let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
232
233    let dim = "\x1b[2m";
234    let bold = "\x1b[1m";
235    let cyan = "\x1b[36m";
236    let rst = "\x1b[0m";
237
238    // Unified Compression Level (replaces terse_agent + output_density)
239    println!("\n  {bold}Compression Level{rst} {dim}(controls all token optimization layers){rst}");
240    println!("  {dim}Applies to tool output, agent prompts, and protocol mode.{rst}");
241    println!();
242    println!("  {cyan}off{rst}      — No compression (full verbose output)");
243    println!(
244        "  {cyan}lite{rst}     — Light: concise output, basic terse filtering {dim}(~25% savings){rst}"
245    );
246    println!(
247        "  {cyan}standard{rst} — Dense output + compact protocol + pattern-aware {dim}(~45% savings){rst}"
248    );
249    println!(
250        "  {cyan}max{rst}      — Expert mode: TDD protocol, all layers active {dim}(~65% savings){rst}"
251    );
252    println!();
253    print!("  Compression level? {bold}[off/lite/standard/max]{rst} {dim}(default: off){rst} ");
254    std::io::stdout().flush().ok();
255
256    let mut level_input = String::new();
257    let level = if std::io::stdin().read_line(&mut level_input).is_ok() {
258        match level_input.trim().to_lowercase().as_str() {
259            "lite" => "lite",
260            "standard" | "std" => "standard",
261            "max" => "max",
262            _ => "off",
263        }
264    } else {
265        "off"
266    };
267
268    // Stage the compression change in the config text; the success line is only
269    // emitted after the write below actually persists (#415).
270    let (effective_level, compression_status) = if level != "off" {
271        upsert_toml_key(&mut config_content, "compression_level", level);
272        remove_toml_key(&mut config_content, "terse_agent");
273        remove_toml_key(&mut config_content, "output_density");
274        (
275            crate::core::config::CompressionLevel::from_str_label(level),
276            StatusLine::ok(format!("Compression: {level}")),
277        )
278    } else if config_content.contains("compression_level") {
279        upsert_toml_key(&mut config_content, "compression_level", "off");
280        (
281            Some(crate::core::config::CompressionLevel::Off),
282            StatusLine::ok("Compression: off".to_string()),
283        )
284    } else {
285        (
286            Some(crate::core::config::CompressionLevel::Off),
287            StatusLine::skip(
288                "Compression: off (change later with: lean-ctx compression <level>)".to_string(),
289            ),
290        )
291    };
292
293    // Tool Result Archive
294    println!(
295        "\n  {bold}Tool Result Archive{rst} {dim}(zero-loss: large outputs archived, retrievable via ctx_expand){rst}"
296    );
297    print!("  Enable auto-archive? {bold}[Y/n]{rst} ");
298    std::io::stdout().flush().ok();
299
300    let mut archive_input = String::new();
301    let archive_on = if std::io::stdin().read_line(&mut archive_input).is_ok() {
302        let a = archive_input.trim().to_lowercase();
303        a.is_empty() || a == "y" || a == "yes"
304    } else {
305        true
306    };
307
308    let archive_status = if archive_on && !config_content.contains("[archive]") {
309        if !config_content.is_empty() && !config_content.ends_with('\n') {
310            config_content.push('\n');
311        }
312        config_content.push_str("\n[archive]\nenabled = true\n");
313        Some(StatusLine::ok("Tool Result Archive: enabled".to_string()))
314    } else if !archive_on {
315        Some(StatusLine::skip(
316            "Archive: off (enable later in config.toml)".to_string(),
317        ))
318    } else {
319        None
320    };
321
322    // Single atomic write. Only claim success — and only inject the rules prompt —
323    // once the config has genuinely been persisted; a swallowed write error here
324    // is exactly what made setup report settings it never applied (#415).
325    match crate::config_io::write_atomic_with_backup(&config_path, &config_content) {
326        Ok(()) => {
327            compression_status.emit();
328            if effective_level.is_some() {
329                let home = dirs::home_dir().unwrap_or_default();
330                let result = crate::rules_inject::inject_all_rules(&home);
331                if !result.updated.is_empty() {
332                    terminal_ui::print_status_ok(&format!(
333                        "Updated {} rules file(s) with compression prompt",
334                        result.updated.len()
335                    ));
336                }
337            }
338            if let Some(status) = archive_status {
339                status.emit();
340            }
341        }
342        Err(e) => {
343            terminal_ui::print_status_warn(&format!(
344                "Could not save settings to {}: {e}",
345                config_path.display()
346            ));
347            terminal_ui::print_status_warn(
348                "Premium features were not applied — re-run `lean-ctx setup` or edit config.toml manually",
349            );
350        }
351    }
352}
353
354/// A setup status line whose emission is deferred until the underlying config
355/// write succeeds, so the wizard never reports a setting it failed to persist.
356struct StatusLine {
357    skip: bool,
358    msg: String,
359}
360
361impl StatusLine {
362    fn ok(msg: String) -> Self {
363        Self { skip: false, msg }
364    }
365    fn skip(msg: String) -> Self {
366        Self { skip: true, msg }
367    }
368    fn emit(&self) {
369        if self.skip {
370            crate::terminal_ui::print_status_skip(&self.msg);
371        } else {
372            crate::terminal_ui::print_status_ok(&self.msg);
373        }
374    }
375}