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