Skip to main content

lean_ctx/cli/
profile_cmd.rs

1use crate::core::profiles;
2use crate::core::tool_profiles::{self, ToolProfile};
3
4pub fn cmd_profile(args: &[String]) {
5    let action = args.first().map_or("list", String::as_str);
6
7    match action {
8        // Tool profile subcommands
9        "tools" => cmd_tool_profile(&args[1..]),
10        "minimal" | "min" | "standard" | "std" | "power" | "full" | "all" | "lean" | "lazy"
11        | "reset" => {
12            cmd_tool_profile_switch(action);
13            println!("  \x1b[2mTip: the canonical command is `lean-ctx tools {action}`.\x1b[0m");
14        }
15
16        // Existing compression profile subcommands
17        "list" | "ls" => cmd_profile_list(),
18        "show" => {
19            let name = args
20                .get(1)
21                .map_or_else(profiles::active_profile_name, Clone::clone);
22            cmd_profile_show(&name);
23        }
24        "active" | "current" => cmd_profile_active(),
25        "diff" => {
26            if args.len() < 3 {
27                eprintln!("Usage: lean-ctx profile diff <profile-a> <profile-b>");
28                std::process::exit(1);
29            }
30            cmd_profile_diff(&args[1], &args[2]);
31        }
32        "create" => {
33            if args.len() < 2 {
34                eprintln!("Usage: lean-ctx profile create <name> [--from <base>] [--global]");
35                std::process::exit(1);
36            }
37            let name = &args[1];
38            let base = args
39                .iter()
40                .position(|a| a == "--from")
41                .and_then(|i| args.get(i + 1))
42                .map(String::as_str);
43            let global = args.iter().any(|a| a == "--global");
44            cmd_profile_create(name, base, global);
45        }
46        "set" => {
47            if args.len() < 2 {
48                eprintln!("Usage: lean-ctx profile set <name>");
49                eprintln!("  Sets LEAN_CTX_PROFILE for the current shell.");
50                std::process::exit(1);
51            }
52            cmd_profile_set(&args[1]);
53        }
54        _ => {
55            if profiles::load_profile(action).is_some() {
56                cmd_profile_show(action);
57            } else {
58                print_profile_help();
59                std::process::exit(1);
60            }
61        }
62    }
63}
64
65fn cmd_profile_list() {
66    let list = profiles::list_profiles();
67    let active = profiles::active_profile_name();
68
69    let header = format!("  {:<16} {:<10} {}", "Name", "Source", "Description");
70    let sep = format!("  {}", "\u{2500}".repeat(60));
71    println!("Available profiles:\n");
72    println!("{header}");
73    println!("{sep}");
74
75    for p in &list {
76        let marker = if p.name == active { " *" } else { "  " };
77        println!("{marker}{:<16} {:<10} {}", p.name, p.source, p.description);
78    }
79
80    println!("\n  Active: {active}");
81    println!("  Set via: LEAN_CTX_PROFILE=<name> or lean-ctx profile set <name>");
82}
83
84fn cmd_profile_show(name: &str) {
85    if let Some(profile) = profiles::load_profile(name) {
86        println!("Profile: {name}\n");
87        println!("{}", profiles::format_as_toml(&profile));
88    } else {
89        eprintln!("Profile '{name}' not found.");
90        eprintln!("Run 'lean-ctx profile list' to see available profiles.");
91        std::process::exit(1);
92    }
93}
94
95fn cmd_profile_active() {
96    let name = profiles::active_profile_name();
97    let profile = profiles::active_profile();
98    println!("Active profile: {name}\n");
99    println!("{}", profiles::format_as_toml(&profile));
100}
101
102fn cmd_profile_diff(name_a: &str, name_b: &str) {
103    let Some(a) = profiles::load_profile(name_a) else {
104        eprintln!("Profile '{name_a}' not found.");
105        std::process::exit(1);
106    };
107    let Some(b) = profiles::load_profile(name_b) else {
108        eprintln!("Profile '{name_b}' not found.");
109        std::process::exit(1);
110    };
111
112    println!("Profile diff: {name_a} vs {name_b}\n");
113
114    let diffs = collect_diffs(&a, &b);
115    if diffs.is_empty() {
116        println!("  No differences.");
117    } else {
118        println!("  {:<32} {:<20} {:<20}", "Field", name_a, name_b);
119        println!("  {}", "\u{2500}".repeat(72));
120        for (field, val_a, val_b) in &diffs {
121            println!("  {field:<32} {val_a:<20} {val_b:<20}");
122        }
123    }
124}
125
126fn collect_diffs(a: &profiles::Profile, b: &profiles::Profile) -> Vec<(String, String, String)> {
127    let mut diffs = Vec::new();
128
129    macro_rules! cmp {
130        ($section:ident . $field:ident) => {
131            let va = format!("{:?}", a.$section.$field);
132            let vb = format!("{:?}", b.$section.$field);
133            if va != vb {
134                diffs.push((
135                    format!("{}.{}", stringify!($section), stringify!($field)),
136                    va,
137                    vb,
138                ));
139            }
140        };
141    }
142
143    cmp!(read.default_mode);
144    cmp!(read.max_tokens_per_file);
145    cmp!(read.prefer_cache);
146    cmp!(compression.crp_mode);
147    cmp!(compression.output_density);
148    cmp!(compression.entropy_threshold);
149    cmp!(translation.enabled);
150    cmp!(translation.ruleset);
151    cmp!(layout.enabled);
152    cmp!(layout.min_lines);
153    cmp!(budget.max_context_tokens);
154    cmp!(budget.max_shell_invocations);
155    cmp!(budget.max_cost_usd);
156    cmp!(pipeline.intent);
157    cmp!(pipeline.relevance);
158    cmp!(pipeline.compression);
159    cmp!(pipeline.translation);
160    cmp!(autonomy.enabled);
161    cmp!(autonomy.auto_preload);
162    cmp!(autonomy.auto_dedup);
163    cmp!(autonomy.auto_related);
164    cmp!(autonomy.silent_preload);
165    cmp!(autonomy.auto_prefetch);
166    cmp!(autonomy.auto_response);
167    cmp!(autonomy.dedup_threshold);
168    cmp!(autonomy.prefetch_max_files);
169    cmp!(autonomy.prefetch_budget_tokens);
170    cmp!(autonomy.response_min_tokens);
171    cmp!(autonomy.checkpoint_interval);
172
173    diffs
174}
175
176fn cmd_profile_create(name: &str, base: Option<&str>, global: bool) {
177    let base_profile = base
178        .and_then(profiles::load_profile)
179        .unwrap_or_else(profiles::active_profile);
180
181    let mut new_profile = base_profile;
182    new_profile.profile.name = name.to_string();
183    new_profile.profile.inherits = base.map(String::from);
184    new_profile.profile.description = String::new();
185
186    let dir = if global {
187        let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
188            eprintln!("Cannot determine global data directory.");
189            std::process::exit(1);
190        };
191        data_dir.join("profiles")
192    } else {
193        std::env::current_dir()
194            .unwrap_or_default()
195            .join(".lean-ctx")
196            .join("profiles")
197    };
198
199    if let Err(e) = std::fs::create_dir_all(&dir) {
200        eprintln!("Cannot create directory {}: {e}", dir.display());
201        std::process::exit(1);
202    }
203
204    let path = dir.join(format!("{name}.toml"));
205    let toml_content = profiles::format_as_toml(&new_profile);
206
207    if let Err(e) = std::fs::write(&path, &toml_content) {
208        eprintln!("Error writing {}: {e}", path.display());
209        std::process::exit(1);
210    }
211
212    println!("Created profile '{name}' at {}", path.display());
213    if let Some(b) = base {
214        println!("  Based on: {b}");
215    }
216    println!("\nEdit the file to customize, then activate with:");
217    println!("  LEAN_CTX_PROFILE={name}");
218}
219
220fn cmd_profile_set(name: &str) {
221    if profiles::load_profile(name).is_none() {
222        eprintln!("Profile '{name}' not found. Available profiles:");
223        for p in profiles::list_profiles() {
224            eprintln!("  {}", p.name);
225        }
226        std::process::exit(1);
227    }
228
229    println!("To activate profile '{name}', run:\n");
230    println!("  export LEAN_CTX_PROFILE={name}\n");
231    println!(
232        "Or add it to your shell config ({}).",
233        crate::shell_hook::shell_rc_file()
234    );
235}
236
237// ─── Tool Profile Commands ───────────────────────────────────────────────
238
239fn cmd_tool_profile(args: &[String]) {
240    let action = args.first().map_or("show", String::as_str);
241
242    match action {
243        "list" | "ls" => cmd_tool_profile_list(),
244        "show" | "current" => cmd_tool_profile_show(),
245        "minimal" | "min" | "standard" | "std" | "power" | "full" | "all" | "lean" | "lazy"
246        | "reset" => {
247            cmd_tool_profile_switch(action);
248        }
249        _ => {
250            if ToolProfile::parse(action).is_some() {
251                cmd_tool_profile_switch(action);
252            } else {
253                eprintln!("Unknown tool profile '{action}'.");
254                eprintln!("Available: lean (default), minimal, standard, power");
255                std::process::exit(1);
256            }
257        }
258    }
259}
260
261fn cmd_tool_profile_show() {
262    let cfg = crate::core::config::Config::load();
263    let profile = cfg.tool_profile_effective();
264    let registry_count = crate::server::registry::tool_count();
265    let pinned = cfg.tool_profile.is_some()
266        || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
267        || !cfg.tools_enabled.is_empty();
268
269    if !pinned {
270        let lazy_count = crate::tool_defs::core_tool_names().len();
271        println!("Tool Profile: lean (default)");
272        println!("  Tools advertised: {lazy_count} (lazy core)");
273        println!("  All {registry_count} registered tools stay callable via ctx_call.");
274        println!("\n  Advertised tools:");
275        for name in crate::tool_defs::core_tool_names() {
276            println!("    {name}");
277        }
278        println!("\n  Switch with: lean-ctx tools <minimal|standard|power>");
279        return;
280    }
281
282    let count_str = match &profile {
283        ToolProfile::Power => format!("{registry_count}"),
284        ToolProfile::Custom(list) => format!("{}", list.len()),
285        other => format!("{}", other.tool_count()),
286    };
287
288    println!("Tool Profile: {}", profile.as_str());
289    println!("  Tools exposed: {count_str}");
290    println!("  Description:   {}", profile.description());
291
292    if let Some(ref cfg_val) = cfg.tool_profile {
293        println!("  Source:         config.toml (tool_profile = \"{cfg_val}\")");
294    }
295    if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
296        println!("  Source:         LEAN_CTX_TOOL_PROFILE env var (overrides config)");
297    }
298
299    if !matches!(profile, ToolProfile::Power) {
300        println!("\n  Enabled tools:");
301        let names = profile.tool_names();
302        for name in &names {
303            println!("    {name}");
304        }
305    }
306
307    println!("\n  Switch with: lean-ctx tools <lean|minimal|standard|power>");
308    if matches!(profile, ToolProfile::Power) {
309        println!("  Tip: `lean-ctx tools lean` advertises only the lazy core (lowest overhead).");
310    }
311}
312
313fn cmd_tool_profile_list() {
314    let cfg = crate::core::config::Config::load();
315    let active = cfg.tool_profile_effective();
316    let registry_count = crate::server::registry::tool_count();
317    let pinned = cfg.tool_profile.is_some()
318        || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
319        || !cfg.tools_enabled.is_empty();
320    let active_name = if pinned { active.as_str() } else { "lean" };
321    let lazy_count = crate::tool_defs::core_tool_names().len();
322
323    println!("Tool Profiles:\n");
324    println!("  {:<12} {:<8} Description", "Name", "Tools");
325    println!("  {}", "\u{2500}".repeat(60));
326
327    let lean_marker = if active_name == "lean" { "* " } else { "  " };
328    println!(
329        "{lean_marker}{:<12} {lazy_count:<8} Lazy core advertised, all tools via ctx_call (default)",
330        "lean"
331    );
332    for info in tool_profiles::list_profiles() {
333        let marker = if info.name == active_name { "* " } else { "  " };
334        let count = if info.name == "power" {
335            format!("{registry_count}")
336        } else {
337            info.tool_count.to_string()
338        };
339        println!(
340            "{marker}{:<12} {:<8} {}",
341            info.name, count, info.description
342        );
343    }
344
345    println!("\n  Active: {active_name}");
346    println!("  Switch: lean-ctx profile <name>");
347    println!("  Env:    LEAN_CTX_TOOL_PROFILE=<name>");
348}
349
350fn cmd_tool_profile_switch(name: &str) {
351    // "lean" is not a pinned profile — it removes the config key, restoring
352    // the default: lazy core advertised (~13 schemas), everything reachable
353    // through ctx_call (#575).
354    if matches!(name, "lean" | "lazy" | "reset") {
355        if let Err(e) = tool_profiles::clear_profile_in_config() {
356            eprintln!("Error saving profile: {e}");
357            std::process::exit(1);
358        }
359        let lazy_count = crate::tool_defs::core_tool_names().len();
360        println!("Tool profile set to: lean (default)");
361        println!("  Tools advertised: {lazy_count} (lazy core)");
362        println!("  All other tools stay callable via ctx_call.");
363        println!("\n  Restart your AI tool / IDE for changes to take effect.");
364        return;
365    }
366
367    let Some(profile) = ToolProfile::parse(name) else {
368        eprintln!("Unknown tool profile '{name}'.");
369        eprintln!("Available: lean (default), minimal, standard, power");
370        std::process::exit(1);
371    };
372
373    let canonical = profile.as_str();
374
375    if let Err(e) = tool_profiles::set_profile_in_config(canonical) {
376        eprintln!("Error saving profile: {e}");
377        std::process::exit(1);
378    }
379
380    let registry_count = crate::server::registry::tool_count();
381    let count_str = match &profile {
382        ToolProfile::Power => format!("{registry_count}"),
383        other => format!("{}", other.tool_count()),
384    };
385
386    println!("Tool profile set to: {canonical}");
387    println!("  Tools exposed: {count_str}");
388    println!("  Description:   {}", profile.description());
389
390    if !matches!(profile, ToolProfile::Power) {
391        println!("\n  Enabled tools:");
392        for name in profile.tool_names() {
393            println!("    {name}");
394        }
395    }
396
397    println!("\n  Restart your AI tool / IDE for changes to take effect.");
398}
399
400fn print_profile_help() {
401    eprintln!(
402        "lean-ctx has two kinds of profiles — here is which command to use:
403
404TOOL PROFILES — how many MCP tools your agent sees:
405  lean-ctx tools                Show current tool profile
406  lean-ctx tools lean           Lazy core advertised, all via ctx_call (default)
407  lean-ctx tools minimal        6 essential tools
408  lean-ctx tools standard       22 balanced tools
409  lean-ctx tools power          All tools (highest context overhead)
410  lean-ctx tools list           List tool profiles with counts
411
412CONTEXT PROFILES — how lean-ctx compresses and reads (this command):
413  lean-ctx profile list         List available context profiles
414  lean-ctx profile show [name]  Show context profile details (default: active)
415  lean-ctx profile active       Show the currently active context profile
416  lean-ctx profile diff <a> <b> Compare two context profiles side by side
417  lean-ctx profile create <name> [--from <base>] [--global]
418  lean-ctx profile set <name>   Show how to activate a context profile"
419    );
420}