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
42    if !has_vscode && !has_claude {
43        return;
44    }
45
46    if has_vscode {
47        match crate::core::editor_registry::plan_mode::write_vscode_plan_settings() {
48            Ok(r) if r.action == WriteAction::Already => {
49                terminal_ui::print_status_ok(
50                    "VS Code            \x1b[2mplan mode already configured\x1b[0m",
51                );
52            }
53            Ok(_) => {
54                terminal_ui::print_status_new(
55                    "VS Code            \x1b[2mplan mode tools configured\x1b[0m",
56                );
57            }
58            Err(e) => {
59                terminal_ui::print_status_warn(&format!("VS Code plan mode: {e}"));
60            }
61        }
62    }
63
64    if has_claude {
65        match crate::core::editor_registry::plan_mode::write_claude_code_plan_permissions() {
66            Ok(r) if r.action == WriteAction::Already => {
67                terminal_ui::print_status_ok(
68                    "Claude Code        \x1b[2mplan mode permissions present\x1b[0m",
69                );
70            }
71            Ok(_) => {
72                terminal_ui::print_status_new(
73                    "Claude Code        \x1b[2mplan mode permissions added\x1b[0m",
74                );
75            }
76            Err(e) => {
77                terminal_ui::print_status_warn(&format!("Claude Code plan mode: {e}"));
78            }
79        }
80    }
81}
82
83pub(crate) fn shorten_path(path: &str, home: &str) -> String {
84    if let Some(stripped) = path.strip_prefix(home) {
85        format!("~{stripped}")
86    } else {
87        path.to_string()
88    }
89}
90
91fn upsert_toml_key(content: &mut String, key: &str, value: &str) {
92    let pattern = format!("{key} = ");
93    if let Some(start) = content.find(&pattern) {
94        let line_end = content[start..]
95            .find('\n')
96            .map_or(content.len(), |p| start + p);
97        content.replace_range(start..line_end, &format!("{key} = \"{value}\""));
98    } else {
99        if !content.is_empty() && !content.ends_with('\n') {
100            content.push('\n');
101        }
102        content.push_str(&format!("{key} = \"{value}\"\n"));
103    }
104}
105
106fn remove_toml_key(content: &mut String, key: &str) {
107    let pattern = format!("{key} = ");
108    if let Some(start) = content.find(&pattern) {
109        let line_end = content[start..]
110            .find('\n')
111            .map_or(content.len(), |p| start + p + 1);
112        content.replace_range(start..line_end, "");
113    }
114}
115
116pub(crate) fn configure_tool_profile() {
117    use crate::terminal_ui;
118    use std::io::Write;
119
120    let cfg = crate::core::config::Config::load();
121    let current = cfg.tool_profile_effective();
122    let pinned = cfg.tool_profile.is_some() || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok();
123
124    // An explicitly pinned non-power profile is a deliberate, bounded choice —
125    // don't re-nag. Power (pinned or legacy fallback) re-prompts because it
126    // advertises every tool schema, the single largest fixed cost (#575).
127    if pinned && !matches!(current, crate::core::tool_profiles::ToolProfile::Power) {
128        terminal_ui::print_status_ok(&format!(
129            "Tool profile: {} ({} tools)",
130            current.as_str(),
131            current.tool_count()
132        ));
133        return;
134    }
135
136    let dim = "\x1b[2m";
137    let bold = "\x1b[1m";
138    let cyan = "\x1b[36m";
139    let rst = "\x1b[0m";
140
141    let registry_count = crate::server::registry::tool_count();
142    let lazy_count = crate::tool_defs::core_tool_names().len();
143
144    println!("  {dim}Control how many MCP tool schemas your AI agent sees.{rst}");
145    println!("  {dim}Fewer advertised tools = less context overhead. Every tool stays{rst}");
146    println!("  {dim}callable through ctx_call, even when its schema is not advertised.{rst}");
147    println!();
148    println!(
149        "  {cyan}lean{rst}      — {lazy_count} tools  {dim}(lazy core, recommended — lowest token overhead){rst}"
150    );
151    println!(
152        "  {cyan}minimal{rst}   — 6 tools   {dim}(ctx_read, ctx_shell, shell, ctx_search, ctx_tree, ctx_session){rst}"
153    );
154    println!("  {cyan}standard{rst}  — 22 tools  {dim}(balanced set for most workflows){rst}");
155    println!(
156        "  {cyan}power{rst}     — {registry_count} tools  {dim}(everything advertised, costs the most context){rst}"
157    );
158    println!();
159    print!("  Tool profile? {bold}[lean/minimal/standard/power]{rst} {dim}(default: lean){rst} ");
160    std::io::stdout().flush().ok();
161
162    let mut profile_input = String::new();
163    let profile_name = if std::io::stdin().read_line(&mut profile_input).is_ok() {
164        let trimmed = profile_input.trim().to_lowercase();
165        match trimmed.as_str() {
166            "minimal" | "min" => "minimal",
167            "standard" | "std" => "standard",
168            "power" | "full" | "all" => "power",
169            _ => "lean",
170        }
171    } else {
172        "lean"
173    };
174
175    if profile_name == "lean" {
176        match crate::core::tool_profiles::clear_profile_in_config() {
177            Ok(()) => terminal_ui::print_status_ok(&format!(
178                "Tool profile: lean ({lazy_count} tools advertised, all reachable via ctx_call)"
179            )),
180            Err(e) => terminal_ui::print_status_warn(&format!("Could not save tool profile: {e}")),
181        }
182        return;
183    }
184
185    match crate::core::tool_profiles::set_profile_in_config(profile_name) {
186        Ok(()) => {
187            let profile = crate::core::tool_profiles::ToolProfile::parse(profile_name)
188                .unwrap_or(crate::core::tool_profiles::ToolProfile::Standard);
189            let count = match &profile {
190                crate::core::tool_profiles::ToolProfile::Power => registry_count,
191                other => other.tool_count(),
192            };
193            terminal_ui::print_status_ok(&format!("Tool profile: {profile_name} ({count} tools)"));
194        }
195        Err(e) => {
196            terminal_ui::print_status_warn(&format!("Could not save tool profile: {e}"));
197        }
198    }
199}
200
201pub(crate) fn configure_premium_features(home: &std::path::Path) {
202    use crate::terminal_ui;
203    use std::io::Write;
204
205    let config_dir = crate::core::data_dir::lean_ctx_data_dir()
206        .unwrap_or_else(|_| home.join(".config/lean-ctx"));
207    let _ = std::fs::create_dir_all(&config_dir);
208    let config_path = config_dir.join("config.toml");
209    let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
210
211    let dim = "\x1b[2m";
212    let bold = "\x1b[1m";
213    let cyan = "\x1b[36m";
214    let rst = "\x1b[0m";
215
216    // Unified Compression Level (replaces terse_agent + output_density)
217    println!("\n  {bold}Compression Level{rst} {dim}(controls all token optimization layers){rst}");
218    println!("  {dim}Applies to tool output, agent prompts, and protocol mode.{rst}");
219    println!();
220    println!("  {cyan}off{rst}      — No compression (full verbose output)");
221    println!("  {cyan}lite{rst}     — Light: concise output, basic terse filtering {dim}(~25% savings){rst}");
222    println!("  {cyan}standard{rst} — Dense output + compact protocol + pattern-aware {dim}(~45% savings){rst}");
223    println!("  {cyan}max{rst}      — Expert mode: TDD protocol, all layers active {dim}(~65% savings){rst}");
224    println!();
225    print!("  Compression level? {bold}[off/lite/standard/max]{rst} {dim}(default: off){rst} ");
226    std::io::stdout().flush().ok();
227
228    let mut level_input = String::new();
229    let level = if std::io::stdin().read_line(&mut level_input).is_ok() {
230        match level_input.trim().to_lowercase().as_str() {
231            "lite" => "lite",
232            "standard" | "std" => "standard",
233            "max" => "max",
234            _ => "off",
235        }
236    } else {
237        "off"
238    };
239
240    let effective_level = if level != "off" {
241        upsert_toml_key(&mut config_content, "compression_level", level);
242        remove_toml_key(&mut config_content, "terse_agent");
243        remove_toml_key(&mut config_content, "output_density");
244        terminal_ui::print_status_ok(&format!("Compression: {level}"));
245        crate::core::config::CompressionLevel::from_str_label(level)
246    } else if config_content.contains("compression_level") {
247        upsert_toml_key(&mut config_content, "compression_level", "off");
248        terminal_ui::print_status_ok("Compression: off");
249        Some(crate::core::config::CompressionLevel::Off)
250    } else {
251        terminal_ui::print_status_skip(
252            "Compression: off (change later with: lean-ctx compression <level>)",
253        );
254        Some(crate::core::config::CompressionLevel::Off)
255    };
256
257    if let Some(lvl) = effective_level {
258        let n = crate::core::terse::rules_inject::inject(&lvl);
259        if n > 0 {
260            terminal_ui::print_status_ok(&format!(
261                "Updated {n} rules file(s) with compression prompt"
262            ));
263        }
264    }
265
266    // Tool Result Archive (unchanged)
267    println!(
268        "\n  {bold}Tool Result Archive{rst} {dim}(zero-loss: large outputs archived, retrievable via ctx_expand){rst}"
269    );
270    print!("  Enable auto-archive? {bold}[Y/n]{rst} ");
271    std::io::stdout().flush().ok();
272
273    let mut archive_input = String::new();
274    let archive_on = if std::io::stdin().read_line(&mut archive_input).is_ok() {
275        let a = archive_input.trim().to_lowercase();
276        a.is_empty() || a == "y" || a == "yes"
277    } else {
278        true
279    };
280
281    if archive_on && !config_content.contains("[archive]") {
282        if !config_content.is_empty() && !config_content.ends_with('\n') {
283            config_content.push('\n');
284        }
285        config_content.push_str("\n[archive]\nenabled = true\n");
286        terminal_ui::print_status_ok("Tool Result Archive: enabled");
287    } else if !archive_on {
288        terminal_ui::print_status_skip("Archive: off (enable later in config.toml)");
289    }
290
291    let _ = crate::config_io::write_atomic_with_backup(&config_path, &config_content);
292}