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        "tools" => cmd_tool_profile(&args[1..]),
9        "minimal" | "min" | "standard" | "std" | "power" | "full" | "all" | "lean" | "lazy"
10        | "reset" => {
11            cmd_tool_profile_switch(action);
12            println!("  \x1b[2mTip: the canonical command is `lean-ctx tools {action}`.\x1b[0m");
13        }
14
15        "list" | "ls" => cmd_profile_list(),
16        "show" => {
17            let name = args
18                .get(1)
19                .map_or_else(profiles::active_profile_name, Clone::clone);
20            cmd_profile_show(&name);
21        }
22        "active" | "current" => cmd_profile_active(),
23        "diff" => {
24            if args.len() < 3 {
25                eprintln!("Usage: lean-ctx profile diff <profile-a> <profile-b>");
26                std::process::exit(1);
27            }
28            cmd_profile_diff(&args[1], &args[2]);
29        }
30        "create" => {
31            if args.len() < 2 {
32                eprintln!("Usage: lean-ctx profile create <name> [--from <base>] [--global]");
33                std::process::exit(1);
34            }
35            let name = &args[1];
36            let base = args
37                .iter()
38                .position(|a| a == "--from")
39                .and_then(|i| args.get(i + 1))
40                .map(String::as_str);
41            let global = args.iter().any(|a| a == "--global");
42            cmd_profile_create(name, base, global);
43        }
44        "set" => {
45            if args.len() < 2 {
46                eprintln!("Usage: lean-ctx profile set <name>");
47                eprintln!("  Sets LEAN_CTX_PROFILE for the current shell.");
48                std::process::exit(1);
49            }
50            cmd_profile_set(&args[1]);
51        }
52        "suggest" => cmd_profile_suggest(&args[1..]),
53        _ => {
54            if profiles::load_profile(action).is_some() {
55                cmd_profile_show(action);
56            } else {
57                print_profile_help();
58                std::process::exit(1);
59            }
60        }
61    }
62}
63
64/// `lean-ctx profile suggest [--json] [--root <path>]` (#851).
65///
66/// Read-only: scans the repo, prints a recommended profile + settings and the
67/// exact commands to apply them. Never writes config.
68fn cmd_profile_suggest(args: &[String]) {
69    use crate::core::profile_suggest;
70
71    let root = super::common::detect_project_root(args);
72    let signals = profile_suggest::analyze(&root);
73    let suggestion = profile_suggest::suggest(&signals);
74
75    if args.iter().any(|a| a == "--json") {
76        let payload = serde_json::json!({ "signals": signals, "suggestion": suggestion });
77        println!(
78            "{}",
79            serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
80        );
81        return;
82    }
83
84    render_profile_suggestion(&signals, &suggestion);
85}
86
87fn render_profile_suggestion(
88    signals: &crate::core::profile_suggest::RepoSignals,
89    suggestion: &crate::core::profile_suggest::Suggestion,
90) {
91    const BOLD: &str = "\x1b[1m";
92    const DIM: &str = "\x1b[2m";
93    const CYAN: &str = "\x1b[36m";
94    const RST: &str = "\x1b[0m";
95
96    println!(
97        "{BOLD}Profile suggestion{RST} {DIM}for {}{RST}",
98        signals.root
99    );
100    println!();
101
102    let langs = if signals.languages.is_empty() {
103        "(none detected)".to_string()
104    } else {
105        signals
106            .languages
107            .iter()
108            .take(6)
109            .map(|l| format!("{} {}", l.language, l.files))
110            .collect::<Vec<_>>()
111            .join(", ")
112    };
113    println!("  {BOLD}Detected{RST}");
114    println!("    languages   {langs}");
115    println!("    files       {} source files", signals.source_files);
116    println!(
117        "    monorepo    {}",
118        if signals.monorepo {
119            let m = if signals.workspace_markers.is_empty() {
120                "nested project roots".to_string()
121            } else {
122                signals.workspace_markers.join(", ")
123            };
124            format!("yes ({m})")
125        } else {
126            "no".to_string()
127        }
128    );
129    if !signals.build_markers.is_empty() {
130        println!("    build       {}", signals.build_markers.join(", "));
131    }
132    println!("    CI          {}", if signals.ci { "yes" } else { "no" });
133    let providers = if signals.providers.is_empty() {
134        "(none detected)".to_string()
135    } else {
136        signals.providers.join(", ")
137    };
138    println!("    providers   {providers}");
139    println!();
140
141    println!(
142        "  {BOLD}Suggested profile:{RST} {CYAN}{}{RST}",
143        suggestion.profile
144    );
145    for reason in &suggestion.rationale {
146        println!("    • {reason}");
147    }
148    println!();
149
150    println!("  {BOLD}Recommended settings{RST}");
151    println!("    profile          {}", suggestion.profile);
152    if let Some(hm) = &suggestion.settings.history_mode {
153        println!("    proxy.history_mode  {hm}");
154    }
155    println!(
156        "    output_density   {}",
157        suggestion.settings.output_density
158    );
159    match &suggestion.settings.effort {
160        Some(e) => println!("    proxy.effort     {e}"),
161        None => println!("    proxy.effort     {DIM}off (opt-in; raise per task){RST}"),
162    }
163    println!();
164
165    println!("  {BOLD}Apply{RST} {DIM}(you choose — nothing is changed automatically){RST}");
166    println!(
167        "    {DIM}# session-only:{RST} export LEAN_CTX_PROFILE={}",
168        suggestion.profile
169    );
170    println!(
171        "    {DIM}# persistent: {RST} lean-ctx config set profile {}",
172        suggestion.profile
173    );
174    if let Some(hm) = &suggestion.settings.history_mode {
175        println!("    lean-ctx config set proxy.history_mode {hm}");
176    }
177    println!(
178        "    lean-ctx config set output_density {}",
179        suggestion.settings.output_density
180    );
181    println!();
182
183    if !suggestion.alternatives.is_empty() {
184        println!("  {BOLD}Task profiles you can switch to{RST}");
185        for alt in &suggestion.alternatives {
186            println!("    {:<9} {DIM}— {}{RST}", alt.profile, alt.when);
187        }
188    }
189}
190
191fn cmd_profile_list() {
192    let list = profiles::list_profiles();
193    let active = profiles::active_profile_name();
194
195    let header = format!("  {:<16} {:<10} {}", "Name", "Source", "Description");
196    let sep = format!("  {}", "\u{2500}".repeat(60));
197    println!("Available profiles:\n");
198    println!("{header}");
199    println!("{sep}");
200
201    for p in &list {
202        let marker = if p.name == active { " *" } else { "  " };
203        println!("{marker}{:<16} {:<10} {}", p.name, p.source, p.description);
204    }
205
206    println!("\n  Active: {active}");
207    println!("  Set via: LEAN_CTX_PROFILE=<name> or lean-ctx profile set <name>");
208}
209
210fn cmd_profile_show(name: &str) {
211    if let Some(profile) = profiles::load_profile(name) {
212        println!("Profile: {name}\n");
213        println!("{}", profiles::format_as_toml(&profile));
214    } else {
215        eprintln!("Profile '{name}' not found.");
216        eprintln!("Run 'lean-ctx profile list' to see available profiles.");
217        std::process::exit(1);
218    }
219}
220
221fn cmd_profile_active() {
222    let name = profiles::active_profile_name();
223    let profile = profiles::active_profile();
224    println!("Active profile: {name}\n");
225    println!("{}", profiles::format_as_toml(&profile));
226}
227
228fn cmd_profile_diff(name_a: &str, name_b: &str) {
229    let Some(a) = profiles::load_profile(name_a) else {
230        eprintln!("Profile '{name_a}' not found.");
231        std::process::exit(1);
232    };
233    let Some(b) = profiles::load_profile(name_b) else {
234        eprintln!("Profile '{name_b}' not found.");
235        std::process::exit(1);
236    };
237
238    println!("Profile diff: {name_a} vs {name_b}\n");
239
240    let diffs = collect_diffs(&a, &b);
241    if diffs.is_empty() {
242        println!("  No differences.");
243    } else {
244        println!("  {:<32} {:<20} {:<20}", "Field", name_a, name_b);
245        println!("  {}", "\u{2500}".repeat(72));
246        for (field, val_a, val_b) in &diffs {
247            println!("  {field:<32} {val_a:<20} {val_b:<20}");
248        }
249    }
250}
251
252fn collect_diffs(a: &profiles::Profile, b: &profiles::Profile) -> Vec<(String, String, String)> {
253    let mut diffs = Vec::new();
254
255    macro_rules! cmp {
256        ($section:ident . $field:ident) => {
257            let va = format!("{:?}", a.$section.$field);
258            let vb = format!("{:?}", b.$section.$field);
259            if va != vb {
260                diffs.push((
261                    format!("{}.{}", stringify!($section), stringify!($field)),
262                    va,
263                    vb,
264                ));
265            }
266        };
267    }
268
269    cmp!(read.default_mode);
270    cmp!(read.max_tokens_per_file);
271    cmp!(read.prefer_cache);
272    cmp!(compression.crp_mode);
273    cmp!(compression.output_density);
274    cmp!(compression.entropy_threshold);
275    cmp!(translation.enabled);
276    cmp!(translation.ruleset);
277    cmp!(layout.enabled);
278    cmp!(layout.min_lines);
279    cmp!(budget.max_context_tokens);
280    cmp!(budget.max_shell_invocations);
281    cmp!(budget.max_cost_usd);
282    cmp!(pipeline.intent);
283    cmp!(pipeline.relevance);
284    cmp!(pipeline.compression);
285    cmp!(pipeline.translation);
286    cmp!(autonomy.enabled);
287    cmp!(autonomy.auto_preload);
288    cmp!(autonomy.auto_dedup);
289    cmp!(autonomy.auto_related);
290    cmp!(autonomy.silent_preload);
291    cmp!(autonomy.auto_prefetch);
292    cmp!(autonomy.auto_response);
293    cmp!(autonomy.dedup_threshold);
294    cmp!(autonomy.prefetch_max_files);
295    cmp!(autonomy.prefetch_budget_tokens);
296    cmp!(autonomy.response_min_tokens);
297    cmp!(autonomy.checkpoint_interval);
298
299    diffs
300}
301
302fn cmd_profile_create(name: &str, base: Option<&str>, global: bool) {
303    let base_profile = base
304        .and_then(profiles::load_profile)
305        .unwrap_or_else(profiles::active_profile);
306
307    let mut new_profile = base_profile;
308    new_profile.profile.name = name.to_string();
309    new_profile.profile.inherits = base.map(String::from);
310    new_profile.profile.description = String::new();
311
312    let dir = if global {
313        let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
314            eprintln!("Cannot determine global data directory.");
315            std::process::exit(1);
316        };
317        data_dir.join("profiles")
318    } else {
319        std::env::current_dir()
320            .unwrap_or_default()
321            .join(".lean-ctx")
322            .join("profiles")
323    };
324
325    if let Err(e) = std::fs::create_dir_all(&dir) {
326        eprintln!("Cannot create directory {}: {e}", dir.display());
327        std::process::exit(1);
328    }
329
330    let path = dir.join(format!("{name}.toml"));
331    let toml_content = profiles::format_as_toml(&new_profile);
332
333    if let Err(e) = std::fs::write(&path, &toml_content) {
334        eprintln!("Error writing {}: {e}", path.display());
335        std::process::exit(1);
336    }
337
338    println!("Created profile '{name}' at {}", path.display());
339    if let Some(b) = base {
340        println!("  Based on: {b}");
341    }
342    println!("\nEdit the file to customize, then activate with:");
343    println!("  LEAN_CTX_PROFILE={name}");
344}
345
346fn cmd_profile_set(name: &str) {
347    if profiles::load_profile(name).is_none() {
348        eprintln!("Profile '{name}' not found. Available profiles:");
349        for p in profiles::list_profiles() {
350            eprintln!("  {}", p.name);
351        }
352        std::process::exit(1);
353    }
354
355    println!("To activate profile '{name}', run:\n");
356    println!("  export LEAN_CTX_PROFILE={name}\n");
357    println!(
358        "Or add it to your shell config ({}).",
359        crate::shell_hook::shell_rc_file()
360    );
361}
362
363// ─── Tool Profile Commands ───────────────────────────────────────────────
364
365fn cmd_tool_profile(args: &[String]) {
366    let action = args.first().map_or("show", String::as_str);
367
368    match action {
369        "list" | "ls" => cmd_tool_profile_list(),
370        "show" | "current" => cmd_tool_profile_show(),
371        "minimal" | "min" | "standard" | "std" | "power" | "full" | "all" | "lean" | "lazy"
372        | "reset" => {
373            cmd_tool_profile_switch(action);
374        }
375        _ => {
376            if ToolProfile::parse(action).is_some() {
377                cmd_tool_profile_switch(action);
378            } else {
379                eprintln!("Unknown tool profile '{action}'.");
380                eprintln!("Available: lean (default), minimal, standard, power");
381                std::process::exit(1);
382            }
383        }
384    }
385}
386
387fn cmd_tool_profile_show() {
388    let cfg = crate::core::config::Config::load();
389    let profile = cfg.tool_profile_effective();
390    let registry_count = crate::server::registry::tool_count();
391    let pinned = cfg.tool_profile.is_some()
392        || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
393        || !cfg.tools_enabled.is_empty();
394
395    if !pinned {
396        let lazy_count = crate::tool_defs::core_tool_names().len();
397        println!("Tool Profile: lean (default)");
398        println!("  Tools advertised: {lazy_count} (lazy core)");
399        println!("  All {registry_count} registered tools stay callable via ctx_call.");
400        println!("\n  Advertised tools:");
401        for name in crate::tool_defs::core_tool_names() {
402            println!("    {name}");
403        }
404        println!("\n  Switch with: lean-ctx tools <minimal|standard|power>");
405        return;
406    }
407
408    let count_str = match &profile {
409        ToolProfile::Power => format!("{registry_count}"),
410        ToolProfile::Custom(list) => format!("{}", list.len()),
411        other => format!("{}", other.tool_count()),
412    };
413
414    println!("Tool Profile: {}", profile.as_str());
415    println!("  Tools exposed: {count_str}");
416    println!("  Description:   {}", profile.description());
417
418    if let Some(ref cfg_val) = cfg.tool_profile {
419        println!("  Source:         config.toml (tool_profile = \"{cfg_val}\")");
420    }
421    if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
422        println!("  Source:         LEAN_CTX_TOOL_PROFILE env var (overrides config)");
423    }
424
425    if !matches!(profile, ToolProfile::Power) {
426        println!("\n  Enabled tools:");
427        let names = profile.tool_names();
428        for name in &names {
429            println!("    {name}");
430        }
431    }
432
433    println!("\n  Switch with: lean-ctx tools <lean|minimal|standard|power>");
434    if matches!(profile, ToolProfile::Power) {
435        println!("  Tip: `lean-ctx tools lean` advertises only the lazy core (lowest overhead).");
436    }
437}
438
439fn cmd_tool_profile_list() {
440    let cfg = crate::core::config::Config::load();
441    let active = cfg.tool_profile_effective();
442    let registry_count = crate::server::registry::tool_count();
443    // A persisted unpin alias (`tool_profile = "lean"`) or `…=lean` in the env
444    // is NOT a pin — otherwise `show` would report "power" for the default (#431).
445    let pinned = cfg
446        .tool_profile
447        .as_deref()
448        .is_some_and(|p| !tool_profiles::is_unpinned_alias(p))
449        || std::env::var("LEAN_CTX_TOOL_PROFILE")
450            .is_ok_and(|v| !v.trim().is_empty() && !tool_profiles::is_unpinned_alias(v.trim()))
451        || !cfg.tools_enabled.is_empty();
452    let active_name = if pinned { active.as_str() } else { "lean" };
453    let lazy_count = crate::tool_defs::core_tool_names().len();
454
455    println!("Tool Profiles:\n");
456    println!("  {:<12} {:<8} Description", "Name", "Tools");
457    println!("  {}", "\u{2500}".repeat(60));
458
459    let lean_marker = if active_name == "lean" { "* " } else { "  " };
460    println!(
461        "{lean_marker}{:<12} {lazy_count:<8} Lazy core advertised, all tools via ctx_call (default)",
462        "lean"
463    );
464    for info in tool_profiles::list_profiles() {
465        let marker = if info.name == active_name { "* " } else { "  " };
466        let count = if info.name == "power" {
467            format!("{registry_count}")
468        } else {
469            info.tool_count.to_string()
470        };
471        println!(
472            "{marker}{:<12} {:<8} {}",
473            info.name, count, info.description
474        );
475    }
476
477    println!("\n  Active: {active_name}");
478    println!("  Switch: lean-ctx profile <name>");
479    println!("  Env:    LEAN_CTX_TOOL_PROFILE=<name>");
480}
481
482fn cmd_tool_profile_switch(name: &str) {
483    // "lean" is not a pinned profile — it removes the config key, restoring
484    // the default: lazy core advertised (~13 schemas), everything reachable
485    // through ctx_call (#575).
486    if tool_profiles::is_unpinned_alias(name) {
487        if let Err(e) = tool_profiles::clear_profile_in_config() {
488            eprintln!("Error saving profile: {e}");
489            std::process::exit(1);
490        }
491        let lazy_count = crate::tool_defs::core_tool_names().len();
492        println!("Tool profile set to: lean (default)");
493        println!("  Tools advertised: {lazy_count} (lazy core)");
494        println!("  All other tools stay callable via ctx_call.");
495        println!("\n  Restart your AI tool / IDE for changes to take effect.");
496        return;
497    }
498
499    let Some(profile) = ToolProfile::parse(name) else {
500        eprintln!("Unknown tool profile '{name}'.");
501        eprintln!("Available: lean (default), minimal, standard, power");
502        std::process::exit(1);
503    };
504
505    let canonical = profile.as_str();
506
507    if let Err(e) = tool_profiles::set_profile_in_config(canonical) {
508        eprintln!("Error saving profile: {e}");
509        std::process::exit(1);
510    }
511
512    let registry_count = crate::server::registry::tool_count();
513    let count_str = match &profile {
514        ToolProfile::Power => format!("{registry_count}"),
515        other => format!("{}", other.tool_count()),
516    };
517
518    println!("Tool profile set to: {canonical}");
519    println!("  Tools exposed: {count_str}");
520    println!("  Description:   {}", profile.description());
521
522    if !matches!(profile, ToolProfile::Power) {
523        println!("\n  Enabled tools:");
524        for name in profile.tool_names() {
525            println!("    {name}");
526        }
527    }
528
529    println!("\n  Restart your AI tool / IDE for changes to take effect.");
530}
531
532fn print_profile_help() {
533    eprintln!(
534        "lean-ctx has two kinds of profiles — here is which command to use:
535
536TOOL PROFILES — how many MCP tools your agent sees:
537  lean-ctx tools                Show current tool profile
538  lean-ctx tools lean           Lazy core advertised, all via ctx_call (default)
539  lean-ctx tools minimal        5 essential tools
540  lean-ctx tools standard       16 balanced tools
541  lean-ctx tools power          All tools (highest context overhead)
542  lean-ctx tools list           List tool profiles with counts
543
544CONTEXT PROFILES — how lean-ctx compresses and reads (this command):
545  lean-ctx profile list         List available context profiles
546  lean-ctx profile show [name]  Show context profile details (default: active)
547  lean-ctx profile active       Show the currently active context profile
548  lean-ctx profile diff <a> <b> Compare two context profiles side by side
549  lean-ctx profile create <name> [--from <base>] [--global]
550  lean-ctx profile set <name>   Show how to activate a context profile
551  lean-ctx profile suggest      Recommend a profile from repo signals (read-only) [--json]"
552    );
553}