Skip to main content

lean_ctx/cli/
mod.rs

1pub mod connect;
2pub mod dispatch;
3pub(crate) mod shell_init;
4
5pub use dispatch::run;
6pub use shell_init::*;
7
8use std::path::Path;
9use std::str::FromStr;
10
11use crate::core::compressor;
12use crate::core::config;
13use crate::core::deps as dep_extract;
14use crate::core::entropy;
15use crate::core::patterns::deps_cmd;
16use crate::core::protocol;
17use crate::core::signatures;
18use crate::core::stats;
19use crate::core::theme;
20use crate::core::tokens::count_tokens;
21use crate::hooks::to_bash_compatible_path;
22
23pub(crate) fn has_flag(args: &[String], flags: &[&str]) -> bool {
24    args.iter().any(|arg| flags.contains(&arg.as_str()))
25}
26
27pub(crate) fn option_value(args: &[String], flags: &[&str]) -> Option<String> {
28    let mut index = 0;
29    while index < args.len() {
30        let arg = &args[index];
31        if flags.contains(&arg.as_str()) {
32            return args.get(index + 1).cloned();
33        }
34
35        for flag in flags {
36            let prefix = format!("{flag}=");
37            if let Some(value) = arg.strip_prefix(&prefix) {
38                return Some(value.to_string());
39            }
40        }
41
42        index += 1;
43    }
44
45    None
46}
47
48pub(crate) fn option_value_parsed<T: FromStr>(args: &[String], flags: &[&str]) -> Option<T> {
49    option_value(args, flags).and_then(|value| value.parse::<T>().ok())
50}
51
52pub fn cmd_read(args: &[String]) {
53    if args.is_empty() {
54        eprintln!(
55            "Usage: nebu-ctx read <file> [--mode full|map|signatures|aggressive|entropy] [--fresh]"
56        );
57        std::process::exit(1);
58    }
59
60    let path = &args[0];
61    let mode = args
62        .iter()
63        .position(|a| a == "--mode" || a == "-m")
64        .and_then(|i| args.get(i + 1))
65        .map(|s| s.as_str())
66        .unwrap_or("full");
67    let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
68
69    let short = protocol::shorten_path(path);
70
71    if !force_fresh && mode == "full" {
72        use crate::core::cli_cache::{self, CacheResult};
73        match cli_cache::check_and_read(path) {
74            CacheResult::Hit { entry, file_ref } => {
75                let msg = cli_cache::format_hit(&entry, &file_ref, &short);
76                println!("{msg}");
77                stats::record("cli_read", entry.original_tokens, count_tokens(&msg));
78                return;
79            }
80            CacheResult::Miss { content } if content.is_empty() => {
81                eprintln!("Error: could not read {path}");
82                std::process::exit(1);
83            }
84            CacheResult::Miss { content } => {
85                let line_count = content.lines().count();
86                println!("{short} [{line_count}L]");
87                println!("{content}");
88                stats::record("cli_read", count_tokens(&content), count_tokens(&content));
89                return;
90            }
91        }
92    }
93
94    let content = match crate::tools::ctx_read::read_file_lossy(path) {
95        Ok(c) => c,
96        Err(e) => {
97            eprintln!("Error: {e}");
98            std::process::exit(1);
99        }
100    };
101
102    let ext = Path::new(path)
103        .extension()
104        .and_then(|e| e.to_str())
105        .unwrap_or("");
106    let line_count = content.lines().count();
107    let original_tokens = count_tokens(&content);
108
109    let mode = if mode == "auto" {
110        let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
111        let predictor = crate::core::mode_predictor::ModePredictor::new();
112        predictor
113            .predict_best_mode(&sig)
114            .unwrap_or_else(|| "full".to_string())
115    } else {
116        mode.to_string()
117    };
118    let mode = mode.as_str();
119
120    match mode {
121        "map" => {
122            let sigs = signatures::extract_signatures(&content, ext);
123            let dep_info = dep_extract::extract_deps(&content, ext);
124
125            println!("{short} [{line_count}L]");
126            if !dep_info.imports.is_empty() {
127                println!("  deps: {}", dep_info.imports.join(", "));
128            }
129            if !dep_info.exports.is_empty() {
130                println!("  exports: {}", dep_info.exports.join(", "));
131            }
132            let key_sigs: Vec<_> = sigs
133                .iter()
134                .filter(|s| s.is_exported || s.indent == 0)
135                .collect();
136            if !key_sigs.is_empty() {
137                println!("  API:");
138                for sig in &key_sigs {
139                    println!("    {}", sig.to_compact());
140                }
141            }
142            let sent = count_tokens(&short.to_string());
143            print_savings(original_tokens, sent);
144        }
145        "signatures" => {
146            let sigs = signatures::extract_signatures(&content, ext);
147            println!("{short} [{line_count}L]");
148            for sig in &sigs {
149                println!("{}", sig.to_compact());
150            }
151            let sent = count_tokens(&short.to_string());
152            print_savings(original_tokens, sent);
153        }
154        "aggressive" => {
155            let compressed = compressor::aggressive_compress(&content, Some(ext));
156            println!("{short} [{line_count}L]");
157            println!("{compressed}");
158            let sent = count_tokens(&compressed);
159            print_savings(original_tokens, sent);
160        }
161        "entropy" => {
162            let result = entropy::entropy_compress(&content);
163            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
164            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
165            for tech in &result.techniques {
166                println!("{tech}");
167            }
168            println!("{}", result.output);
169            let sent = count_tokens(&result.output);
170            print_savings(original_tokens, sent);
171        }
172        _ => {
173            println!("{short} [{line_count}L]");
174            println!("{content}");
175        }
176    }
177}
178
179pub fn cmd_diff(args: &[String]) {
180    if args.len() < 2 {
181        eprintln!("Usage: nebu-ctx diff <file1> <file2>");
182        std::process::exit(1);
183    }
184
185    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
186        Ok(c) => c,
187        Err(e) => {
188            eprintln!("Error reading {}: {e}", args[0]);
189            std::process::exit(1);
190        }
191    };
192
193    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
194        Ok(c) => c,
195        Err(e) => {
196            eprintln!("Error reading {}: {e}", args[1]);
197            std::process::exit(1);
198        }
199    };
200
201    let diff = compressor::diff_content(&content1, &content2);
202    let original = count_tokens(&content1) + count_tokens(&content2);
203    let sent = count_tokens(&diff);
204
205    println!(
206        "diff {} {}",
207        protocol::shorten_path(&args[0]),
208        protocol::shorten_path(&args[1])
209    );
210    println!("{diff}");
211    print_savings(original, sent);
212}
213
214pub fn cmd_grep(args: &[String]) {
215    if args.is_empty() {
216        eprintln!("Usage: nebu-ctx grep <pattern> [path]");
217        std::process::exit(1);
218    }
219
220    let pattern = &args[0];
221    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
222
223    let re = match regex::Regex::new(pattern) {
224        Ok(r) => r,
225        Err(e) => {
226            eprintln!("Invalid regex pattern: {e}");
227            std::process::exit(1);
228        }
229    };
230
231    let mut found = false;
232    for entry in ignore::WalkBuilder::new(path)
233        .hidden(true)
234        .git_ignore(true)
235        .git_global(true)
236        .git_exclude(true)
237        .max_depth(Some(10))
238        .build()
239        .flatten()
240    {
241        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
242            continue;
243        }
244        let file_path = entry.path();
245        if let Ok(content) = std::fs::read_to_string(file_path) {
246            for (i, line) in content.lines().enumerate() {
247                if re.is_match(line) {
248                    println!("{}:{}:{}", file_path.display(), i + 1, line);
249                    found = true;
250                }
251            }
252        }
253    }
254
255    if !found {
256        std::process::exit(1);
257    }
258}
259
260pub fn cmd_find(args: &[String]) {
261    if args.is_empty() {
262        eprintln!("Usage: nebu-ctx find <pattern> [path]");
263        std::process::exit(1);
264    }
265
266    let raw_pattern = &args[0];
267    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
268
269    let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
270    let glob_matcher = if is_glob {
271        glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
272    } else {
273        None
274    };
275    let substring = raw_pattern.to_lowercase();
276
277    let mut found = false;
278    for entry in ignore::WalkBuilder::new(path)
279        .hidden(true)
280        .git_ignore(true)
281        .git_global(true)
282        .git_exclude(true)
283        .max_depth(Some(10))
284        .build()
285        .flatten()
286    {
287        let name = entry.file_name().to_string_lossy().to_lowercase();
288        let matches = if let Some(ref g) = glob_matcher {
289            g.matches(&name)
290        } else {
291            name.contains(&substring)
292        };
293        if matches {
294            println!("{}", entry.path().display());
295            found = true;
296        }
297    }
298
299    if !found {
300        std::process::exit(1);
301    }
302}
303
304pub fn cmd_ls(args: &[String]) {
305    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
306    let command = if cfg!(windows) {
307        format!("dir {}", path.replace('/', "\\"))
308    } else {
309        format!("ls -la {path}")
310    };
311    let code = crate::shell::exec(&command);
312    std::process::exit(code);
313}
314
315pub fn cmd_deps(args: &[String]) {
316    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
317
318    match deps_cmd::detect_and_compress(path) {
319        Some(result) => println!("{result}"),
320        None => {
321            eprintln!("No dependency file found in {path}");
322            std::process::exit(1);
323        }
324    }
325}
326
327pub fn cmd_discover(_args: &[String]) {
328    let history = load_shell_history();
329    if history.is_empty() {
330        println!("No shell history found.");
331        return;
332    }
333
334    let result = crate::tools::ctx_discover::analyze_history(&history, 20);
335    println!("{}", crate::tools::ctx_discover::format_cli_output(&result));
336}
337
338pub fn cmd_session() {
339    let history = load_shell_history();
340    let gain = stats::load_stats();
341
342    let compressible_commands = [
343        "git ",
344        "npm ",
345        "yarn ",
346        "pnpm ",
347        "cargo ",
348        "docker ",
349        "kubectl ",
350        "gh ",
351        "pip ",
352        "pip3 ",
353        "eslint",
354        "prettier",
355        "ruff ",
356        "go ",
357        "golangci-lint",
358        "curl ",
359        "wget ",
360        "grep ",
361        "rg ",
362        "find ",
363        "ls ",
364    ];
365
366    let mut total = 0u32;
367    let mut via_hook = 0u32;
368
369    for line in &history {
370        let cmd = line.trim().to_lowercase();
371        if cmd.starts_with("lean-ctx") || cmd.starts_with("nebu-ctx") {
372            via_hook += 1;
373            total += 1;
374        } else {
375            for p in &compressible_commands {
376                if cmd.starts_with(p) {
377                    total += 1;
378                    break;
379                }
380            }
381        }
382    }
383
384    let pct = if total > 0 {
385        (via_hook as f64 / total as f64 * 100.0).round() as u32
386    } else {
387        0
388    };
389
390    println!("nebu-ctx session statistics\n");
391    println!(
392        "Adoption:    {}% ({}/{} compressible commands)",
393        pct, via_hook, total
394    );
395    println!("Saved:       {} tokens total", gain.total_saved);
396    println!("Calls:       {} compressed", gain.total_calls);
397
398    if total > via_hook {
399        let missed = total - via_hook;
400        let est = missed * 150;
401        println!(
402            "Missed:      {} commands (~{} tokens saveable)",
403            missed, est
404        );
405    }
406
407    println!("\nRun 'nebu-ctx discover' for details on missed commands.");
408}
409
410pub fn cmd_wrapped(args: &[String]) {
411    let period = if args.iter().any(|a| a == "--month") {
412        "month"
413    } else if args.iter().any(|a| a == "--all") {
414        "all"
415    } else {
416        "week"
417    };
418
419    let report = crate::core::wrapped::WrappedReport::generate(period);
420    println!("{}", report.format_ascii());
421}
422
423pub fn cmd_sessions(args: &[String]) {
424    use crate::core::session::SessionState;
425
426    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
427
428    match action {
429        "list" | "ls" => {
430            let sessions = SessionState::list_sessions();
431            if sessions.is_empty() {
432                println!("No sessions found.");
433                return;
434            }
435            println!("Sessions ({}):\n", sessions.len());
436            for s in sessions.iter().take(20) {
437                let task = s.task.as_deref().unwrap_or("(no task)");
438                let task_short: String = task.chars().take(50).collect();
439                let date = s.updated_at.format("%Y-%m-%d %H:%M");
440                println!(
441                    "  {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
442                    s.id,
443                    s.version,
444                    s.tool_calls,
445                    format_tokens_cli(s.tokens_saved),
446                    date,
447                    task_short
448                );
449            }
450            if sessions.len() > 20 {
451                println!("  ... +{} more", sessions.len() - 20);
452            }
453        }
454        "show" => {
455            let id = args.get(1);
456            let session = if let Some(id) = id {
457                SessionState::load_by_id(id)
458            } else {
459                SessionState::load_latest()
460            };
461            match session {
462                Some(s) => println!("{}", s.format_compact()),
463                None => println!("Session not found."),
464            }
465        }
466        "cleanup" => {
467            let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
468            let removed = SessionState::cleanup_old_sessions(days);
469            println!("Cleaned up {removed} session(s) older than {days} days.");
470        }
471        _ => {
472            eprintln!("Usage: nebu-ctx sessions [list|show [id]|cleanup [days]]");
473            std::process::exit(1);
474        }
475    }
476}
477
478pub fn cmd_benchmark(args: &[String]) {
479    use crate::core::benchmark;
480
481    let action = args.first().map(|s| s.as_str()).unwrap_or("run");
482
483    match action {
484        "run" => {
485            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
486            let is_json = args.iter().any(|a| a == "--json");
487
488            let result = benchmark::run_project_benchmark(path);
489            if is_json {
490                println!("{}", benchmark::format_json(&result));
491            } else {
492                println!("{}", benchmark::format_terminal(&result));
493            }
494        }
495        "report" => {
496            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
497            let result = benchmark::run_project_benchmark(path);
498            println!("{}", benchmark::format_markdown(&result));
499        }
500        _ => {
501            if std::path::Path::new(action).exists() {
502                let result = benchmark::run_project_benchmark(action);
503                println!("{}", benchmark::format_terminal(&result));
504            } else {
505                eprintln!("Usage: nebu-ctx benchmark run [path] [--json]");
506                eprintln!("       nebu-ctx benchmark report [path]");
507                std::process::exit(1);
508            }
509        }
510    }
511}
512
513fn format_tokens_cli(tokens: u64) -> String {
514    if tokens >= 1_000_000 {
515        format!("{:.1}M", tokens as f64 / 1_000_000.0)
516    } else if tokens >= 1_000 {
517        format!("{:.1}K", tokens as f64 / 1_000.0)
518    } else {
519        format!("{tokens}")
520    }
521}
522
523pub fn cmd_cache(args: &[String]) {
524    use crate::core::cli_cache;
525    match args.first().map(|s| s.as_str()) {
526        Some("clear") => {
527            let count = cli_cache::clear();
528            println!("Cleared {count} cached entries.");
529        }
530        Some("reset") => {
531            let project_flag = args.get(1).map(|s| s.as_str()) == Some("--project");
532            if project_flag {
533                let root =
534                    crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
535                match root {
536                    Some(root) => {
537                        let count = cli_cache::clear_project(&root);
538                        println!("Reset {count} cache entries for project: {root}");
539                    }
540                    None => {
541                        eprintln!("No active project root found. Start a session first.");
542                        std::process::exit(1);
543                    }
544                }
545            } else {
546                let count = cli_cache::clear();
547                println!("Reset all {count} cache entries.");
548            }
549        }
550        Some("stats") => {
551            let (hits, reads, entries) = cli_cache::stats();
552            let rate = if reads > 0 {
553                (hits as f64 / reads as f64 * 100.0).round() as u32
554            } else {
555                0
556            };
557            println!("CLI Cache Stats:");
558            println!("  Entries:   {entries}");
559            println!("  Reads:     {reads}");
560            println!("  Hits:      {hits}");
561            println!("  Hit Rate:  {rate}%");
562        }
563        Some("invalidate") => {
564            if args.len() < 2 {
565                eprintln!("Usage: nebu-ctx cache invalidate <path>");
566                std::process::exit(1);
567            }
568            cli_cache::invalidate(&args[1]);
569            println!("Invalidated cache for {}", args[1]);
570        }
571        _ => {
572            let (hits, reads, entries) = cli_cache::stats();
573            let rate = if reads > 0 {
574                (hits as f64 / reads as f64 * 100.0).round() as u32
575            } else {
576                0
577            };
578            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
579            println!();
580            println!("Subcommands:");
581            println!("  cache stats       Show detailed stats");
582            println!("  cache clear       Clear all cached entries");
583            println!("  cache reset       Reset all cache (or --project for current project only)");
584            println!("  cache invalidate  Remove specific file from cache");
585        }
586    }
587}
588
589pub fn cmd_config(args: &[String]) {
590    let cfg = config::Config::load();
591
592    if args.is_empty() {
593        println!("{}", cfg.show());
594        return;
595    }
596
597    match args[0].as_str() {
598        "init" | "create" => {
599            let default = config::Config::default();
600            match default.save() {
601                Ok(()) => {
602                    let path = config::Config::path()
603                        .map(|p| p.to_string_lossy().to_string())
604                        .unwrap_or_else(|| "~/.nebu-ctx/config.toml".to_string());
605                    println!("Created default config at {path}");
606                }
607                Err(e) => eprintln!("Error: {e}"),
608            }
609        }
610        "set" => {
611            if args.len() < 3 {
612                eprintln!("Usage: nebu-ctx config set <key> <value>");
613                std::process::exit(1);
614            }
615            let mut cfg = cfg;
616            let key = &args[1];
617            let val = &args[2];
618            match key.as_str() {
619                "ultra_compact" => cfg.ultra_compact = val == "true",
620                "tee_on_error" | "tee_mode" => {
621                    cfg.tee_mode = match val.as_str() {
622                        "true" | "failures" => config::TeeMode::Failures,
623                        "always" => config::TeeMode::Always,
624                        "false" | "never" => config::TeeMode::Never,
625                        _ => {
626                            eprintln!("Valid tee_mode values: always, failures, never");
627                            std::process::exit(1);
628                        }
629                    };
630                }
631                "checkpoint_interval" => {
632                    cfg.checkpoint_interval = val.parse().unwrap_or(15);
633                }
634                "theme" => {
635                    if theme::from_preset(val).is_some() || val == "custom" {
636                        cfg.theme = val.to_string();
637                    } else {
638                        eprintln!(
639                            "Unknown theme '{val}'. Available: {}",
640                            theme::PRESET_NAMES.join(", ")
641                        );
642                        std::process::exit(1);
643                    }
644                }
645                "slow_command_threshold_ms" => {
646                    cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
647                }
648                "passthrough_urls" => {
649                    cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
650                }
651                "rules_scope" => match val.as_str() {
652                    "global" | "project" | "both" => {
653                        cfg.rules_scope = Some(val.to_string());
654                    }
655                    _ => {
656                        eprintln!("Valid rules_scope values: global, project, both");
657                        std::process::exit(1);
658                    }
659                },
660                _ => {
661                    eprintln!("Unknown config key: {key}");
662                    std::process::exit(1);
663                }
664            }
665            match cfg.save() {
666                Ok(()) => println!("Updated {key} = {val}"),
667                Err(e) => eprintln!("Error saving config: {e}"),
668            }
669        }
670        _ => {
671            eprintln!("Usage: nebu-ctx config [init|set <key> <value>]");
672            std::process::exit(1);
673        }
674    }
675}
676
677pub fn cmd_cheatsheet() {
678    let ver = env!("CARGO_PKG_VERSION");
679    let ver_pad = format!("v{ver}");
680    let header = format!(
681        "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
682\x1b[1;36m║\x1b[0m  \x1b[1;37mnebu-ctx Workflow Cheat Sheet\x1b[0m                     \x1b[2m{ver_pad:>6}\x1b[0m  \x1b[1;36m║\x1b[0m
683\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m");
684    println!(
685        "{header}
686
687\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
688  ctx_session load               \x1b[2m# restore previous session\x1b[0m
689  ctx_overview task=\"...\"         \x1b[2m# task-aware file map\x1b[0m
690  ctx_graph action=build          \x1b[2m# index project (first time)\x1b[0m
691  ctx_knowledge action=recall     \x1b[2m# check stored project facts\x1b[0m
692
693\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
694  ctx_read mode=full    \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
695  ctx_read mode=map     \x1b[2m# context-only files (~93% saved)\x1b[0m
696  ctx_read mode=diff    \x1b[2m# after editing (~98% saved)\x1b[0m
697  ctx_read mode=sigs    \x1b[2m# API surface of large files (~95%)\x1b[0m
698  ctx_multi_read        \x1b[2m# read multiple files at once\x1b[0m
699  ctx_search            \x1b[2m# search with compressed results (~70%)\x1b[0m
700  ctx_shell             \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
701
702\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
703  ctx_session finding \"...\"       \x1b[2m# record what you discovered\x1b[0m
704  ctx_session decision \"...\"      \x1b[2m# record architectural choices\x1b[0m
705  ctx_knowledge action=remember   \x1b[2m# store permanent project facts\x1b[0m
706  ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
707  ctx_metrics                     \x1b[2m# see session statistics\x1b[0m
708
709\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
710  ctx_agent action=register       \x1b[2m# announce yourself\x1b[0m
711  ctx_agent action=list           \x1b[2m# see other active agents\x1b[0m
712  ctx_agent action=post           \x1b[2m# share findings\x1b[0m
713  ctx_agent action=read           \x1b[2m# check messages\x1b[0m
714
715\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
716  Will edit?  → \x1b[1mfull\x1b[0m (re-reads: 13 tokens)  → after edit: \x1b[1mdiff\x1b[0m
717  API only?   → \x1b[1msignatures\x1b[0m
718  Deps/exports? → \x1b[1mmap\x1b[0m
719  Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
720  Browsing?   → \x1b[1maggressive\x1b[0m (syntax stripped)
721
722\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
723    nebu-ctx gain              \x1b[2m# fetch analytics from server\x1b[0m
724    nebu-ctx wrapped       \x1b[2m# weekly savings report\x1b[0m
725    nebu-ctx discover      \x1b[2m# find uncompressed commands\x1b[0m
726    nebu-ctx doctor        \x1b[2m# diagnose installation\x1b[0m
727    nebu-ctx update        \x1b[2m# self-update to latest\x1b[0m
728
729\x1b[2m  Full guide: https://nebu-ctx.com/docs/workflow\x1b[0m"
730    );
731}
732
733pub fn cmd_terse(args: &[String]) {
734    use crate::core::config::{Config, TerseAgent};
735
736    let action = args.first().map(|s| s.as_str());
737    match action {
738        Some("off" | "lite" | "full" | "ultra") => {
739            let level = action.unwrap();
740            let mut cfg = Config::load();
741            cfg.terse_agent = match level {
742                "lite" => TerseAgent::Lite,
743                "full" => TerseAgent::Full,
744                "ultra" => TerseAgent::Ultra,
745                _ => TerseAgent::Off,
746            };
747            if let Err(e) = cfg.save() {
748                eprintln!("Error saving config: {e}");
749                std::process::exit(1);
750            }
751            let desc = match level {
752                "lite" => "concise responses, bullet points over paragraphs",
753                "full" => "maximum density, diff-only code, 1-sentence explanations",
754                "ultra" => "expert pair-programmer mode, minimal narration",
755                _ => "normal verbose output",
756            };
757            println!("Terse agent mode: {level} ({desc})");
758            println!("Restart your agent/IDE for changes to take effect.");
759        }
760        _ => {
761            let cfg = Config::load();
762            let effective = TerseAgent::effective(&cfg.terse_agent);
763            let name = match &effective {
764                TerseAgent::Off => "off",
765                TerseAgent::Lite => "lite",
766                TerseAgent::Full => "full",
767                TerseAgent::Ultra => "ultra",
768            };
769            println!("Terse agent mode: {name}");
770            println!();
771            println!("Usage: nebu-ctx terse <off|lite|full|ultra>");
772            println!("  off   — Normal verbose output (default)");
773            println!("  lite  — Concise: bullet points, skip narration");
774            println!("  full  — Dense: diff-only, 1-sentence max");
775            println!("  ultra — Expert: minimal narration, code speaks");
776            println!();
777            println!("Override per session: NEBU_CTX_TERSE_AGENT=full");
778            println!("Override per project: terse_agent = \"full\" in .nebu-ctx.toml");
779        }
780    }
781}
782
783pub fn cmd_slow_log(args: &[String]) {
784    use crate::core::slow_log;
785
786    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
787    match action {
788        "list" | "ls" | "" => println!("{}", slow_log::list()),
789        "clear" | "purge" => println!("{}", slow_log::clear()),
790        _ => {
791            eprintln!("Usage: nebu-ctx slow-log [list|clear]");
792            std::process::exit(1);
793        }
794    }
795}
796
797pub fn cmd_tee(args: &[String]) {
798    let tee_dir = match dirs::home_dir() {
799        Some(h) => h.join(".nebu-ctx").join("tee"),
800        None => {
801            eprintln!("Cannot determine home directory");
802            std::process::exit(1);
803        }
804    };
805
806    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
807    match action {
808        "list" | "ls" => {
809            if !tee_dir.exists() {
810                println!("No tee logs found (~/.nebu-ctx/tee/ does not exist)");
811                return;
812            }
813            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
814                .unwrap_or_else(|e| {
815                    eprintln!("Error: {e}");
816                    std::process::exit(1);
817                })
818                .filter_map(|e| e.ok())
819                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
820                .collect();
821            entries.sort_by_key(|e| e.file_name());
822
823            if entries.is_empty() {
824                println!("No tee logs found.");
825                return;
826            }
827
828            println!("Tee logs ({}):\n", entries.len());
829            for entry in &entries {
830                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
831                let name = entry.file_name();
832                let size_str = if size > 1024 {
833                    format!("{}K", size / 1024)
834                } else {
835                    format!("{}B", size)
836                };
837                println!("  {:<60} {}", name.to_string_lossy(), size_str);
838            }
839            println!("\nUse 'nebu-ctx tee clear' to delete all logs.");
840        }
841        "clear" | "purge" => {
842            if !tee_dir.exists() {
843                println!("No tee logs to clear.");
844                return;
845            }
846            let mut count = 0u32;
847            if let Ok(entries) = std::fs::read_dir(&tee_dir) {
848                for entry in entries.flatten() {
849                    if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
850                        && std::fs::remove_file(entry.path()).is_ok()
851                    {
852                        count += 1;
853                    }
854                }
855            }
856            println!("Cleared {count} tee log(s) from {}", tee_dir.display());
857        }
858        "show" => {
859            let filename = args.get(1);
860            if filename.is_none() {
861                eprintln!("Usage: nebu-ctx tee show <filename>");
862                std::process::exit(1);
863            }
864            let path = tee_dir.join(filename.unwrap());
865            match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
866                Ok(content) => print!("{content}"),
867                Err(e) => {
868                    eprintln!("Error reading {}: {e}", path.display());
869                    std::process::exit(1);
870                }
871            }
872        }
873        "last" => {
874            if !tee_dir.exists() {
875                println!("No tee logs found.");
876                return;
877            }
878            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
879                .ok()
880                .into_iter()
881                .flat_map(|d| d.filter_map(|e| e.ok()))
882                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
883                .collect();
884            entries.sort_by_key(|e| {
885                e.metadata()
886                    .and_then(|m| m.modified())
887                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
888            });
889            match entries.last() {
890                Some(entry) => {
891                    let path = entry.path();
892                    println!(
893                        "--- {} ---\n",
894                        path.file_name().unwrap_or_default().to_string_lossy()
895                    );
896                    match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
897                        Ok(content) => print!("{content}"),
898                        Err(e) => eprintln!("Error: {e}"),
899                    }
900                }
901                None => println!("No tee logs found."),
902            }
903        }
904        _ => {
905            eprintln!("Usage: nebu-ctx tee [list|clear|show <file>|last]");
906            std::process::exit(1);
907        }
908    }
909}
910
911pub fn cmd_filter(args: &[String]) {
912    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
913    match action {
914        "list" | "ls" => match crate::core::filters::FilterEngine::load() {
915            Some(engine) => {
916                let rules = engine.list_rules();
917                println!("Loaded {} filter rule(s):\n", rules.len());
918                for rule in &rules {
919                    println!("{rule}");
920                }
921            }
922            None => {
923                println!("No custom filters found.");
924                println!("Create one: nebu-ctx filter init");
925            }
926        },
927        "validate" => {
928            let path = args.get(1);
929            if path.is_none() {
930                eprintln!("Usage: nebu-ctx filter validate <file.toml>");
931                std::process::exit(1);
932            }
933            match crate::core::filters::validate_filter_file(path.unwrap()) {
934                Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
935                Err(e) => {
936                    eprintln!("Validation failed: {e}");
937                    std::process::exit(1);
938                }
939            }
940        }
941        "init" => match crate::core::filters::create_example_filter() {
942            Ok(path) => {
943                println!("Created example filter: {path}");
944                println!("Edit it to add your custom compression rules.");
945            }
946            Err(e) => {
947                eprintln!("{e}");
948                std::process::exit(1);
949            }
950        },
951        _ => {
952            eprintln!("Usage: nebu-ctx filter [list|validate <file>|init]");
953            std::process::exit(1);
954        }
955    }
956}
957
958fn quiet_enabled() -> bool {
959    matches!(std::env::var("NEBU_CTX_QUIET"), Ok(v) if v.trim() == "1")
960}
961
962pub fn hosted_analytics_only_message(surface: &str) -> String {
963    format!(
964        "nebu-ctx {surface} is no longer available as a local client surface.\nAnalytics and dashboards now live on the NebuCtx host.\nRun the .NET NebuCtx server and use its hosted dashboard for canonical stats, gain, cost, and heatmap views."
965    )
966}
967
968pub fn exit_hosted_analytics_only(surface: &str) -> ! {
969    eprintln!("{}", hosted_analytics_only_message(surface));
970    std::process::exit(1);
971}
972
973macro_rules! qprintln {
974    ($($t:tt)*) => {
975        if !quiet_enabled() {
976            println!($($t)*);
977        }
978    };
979}
980
981pub fn cmd_init(args: &[String]) {
982    let global = args.iter().any(|a| a == "--global" || a == "-g");
983    let dry_run = args.iter().any(|a| a == "--dry-run");
984
985    let agents: Vec<&str> = args
986        .windows(2)
987        .filter(|w| w[0] == "--agent")
988        .map(|w| w[1].as_str())
989        .collect();
990
991    if !agents.is_empty() {
992        for agent_name in &agents {
993            crate::hooks::install_agent_hook(agent_name, global);
994            if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
995                eprintln!("MCP config for '{agent_name}' not updated: {e}");
996            }
997        }
998        if !global {
999            crate::hooks::install_project_rules();
1000        }
1001        qprintln!("\nAnalytics are served by the NebuCtx host; this client no longer renders local dashboards.");
1002        return;
1003    }
1004
1005    let eval_shell = args
1006        .iter()
1007        .find(|a| matches!(a.as_str(), "bash" | "zsh" | "fish" | "powershell" | "pwsh"));
1008    if let Some(shell) = eval_shell {
1009        if !global {
1010            shell_init::print_hook_stdout(shell);
1011            return;
1012        }
1013    }
1014
1015    let shell_name = std::env::var("SHELL").unwrap_or_default();
1016    let is_zsh = shell_name.contains("zsh");
1017    let is_fish = shell_name.contains("fish");
1018    let is_powershell = cfg!(windows) && shell_name.is_empty();
1019
1020    let binary = crate::core::portable_binary::resolve_portable_binary();
1021
1022    if dry_run {
1023        let rc = if is_powershell {
1024            "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
1025        } else if is_fish {
1026            "~/.config/fish/config.fish".to_string()
1027        } else if is_zsh {
1028            "~/.zshrc".to_string()
1029        } else {
1030            "~/.bashrc".to_string()
1031        };
1032        qprintln!("\nnebu-ctx init --dry-run\n");
1033        qprintln!("  Would modify:  {rc}");
1034        qprintln!("  Would backup:  {rc}.nebu-ctx.bak");
1035        qprintln!("  Would alias:   git npm pnpm yarn cargo docker docker-compose kubectl");
1036        qprintln!("                 gh pip pip3 ruff go golangci-lint eslint prettier tsc");
1037        qprintln!("                 curl wget php composer (24 commands + k)");
1038        qprintln!("  Would create:  ~/.nebu-ctx/");
1039        qprintln!("  Binary:        {binary}");
1040        qprintln!("\n  Safety: aliases auto-fallback to original command if nebu-ctx is removed.");
1041        qprintln!("\n  Run without --dry-run to apply.");
1042        return;
1043    }
1044
1045    if is_powershell {
1046        init_powershell(&binary);
1047    } else {
1048        let bash_binary = to_bash_compatible_path(&binary);
1049        if is_fish {
1050            init_fish(&bash_binary);
1051        } else {
1052            init_posix(is_zsh, &bash_binary);
1053        }
1054    }
1055
1056    let lean_dir = dirs::home_dir().map(|h| h.join(".nebu-ctx"));
1057    if let Some(dir) = lean_dir {
1058        if !dir.exists() {
1059            let _ = std::fs::create_dir_all(&dir);
1060            qprintln!("Created {}", dir.display());
1061        }
1062    }
1063
1064    let rc = if is_powershell {
1065        "$PROFILE"
1066    } else if is_fish {
1067        "config.fish"
1068    } else if is_zsh {
1069        ".zshrc"
1070    } else {
1071        ".bashrc"
1072    };
1073
1074    qprintln!("\nnebu-ctx init complete (24 aliases installed)");
1075    qprintln!();
1076    qprintln!("  Disable temporarily:  nebu-ctx-off");
1077    qprintln!("  Re-enable:            nebu-ctx-on");
1078    qprintln!("  Check status:         nebu-ctx-status");
1079    qprintln!("  Full uninstall:       nebu-ctx uninstall");
1080    qprintln!("  Diagnose issues:      nebu-ctx doctor");
1081    qprintln!("  Preview changes:      nebu-ctx init --global --dry-run");
1082    qprintln!();
1083    if is_powershell {
1084        qprintln!("  Restart PowerShell or run: . {rc}");
1085    } else {
1086        qprintln!("  Restart your shell or run: source ~/{rc}");
1087    }
1088    qprintln!();
1089    qprintln!("For AI tool integration: nebu-ctx init --agent <tool>");
1090    qprintln!("  Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1091    qprintln!("    crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1092    qprintln!("    pi, qwen, roo, sublime, trae, verdent, windsurf");
1093}
1094
1095pub fn cmd_init_quiet(args: &[String]) {
1096    let previous = std::env::var_os("NEBU_CTX_QUIET");
1097    std::env::set_var("NEBU_CTX_QUIET", "1");
1098    cmd_init(args);
1099    if let Some(previous) = previous {
1100        std::env::set_var("NEBU_CTX_QUIET", previous);
1101    } else {
1102        std::env::remove_var("NEBU_CTX_QUIET");
1103    }
1104}
1105
1106pub fn load_shell_history_pub() -> Vec<String> {
1107    load_shell_history()
1108}
1109
1110fn load_shell_history() -> Vec<String> {
1111    let shell = std::env::var("SHELL").unwrap_or_default();
1112    let home = match dirs::home_dir() {
1113        Some(h) => h,
1114        None => return Vec::new(),
1115    };
1116
1117    let history_file = if shell.contains("zsh") {
1118        home.join(".zsh_history")
1119    } else if shell.contains("fish") {
1120        home.join(".local/share/fish/fish_history")
1121    } else if cfg!(windows) && shell.is_empty() {
1122        home.join("AppData")
1123            .join("Roaming")
1124            .join("Microsoft")
1125            .join("Windows")
1126            .join("PowerShell")
1127            .join("PSReadLine")
1128            .join("ConsoleHost_history.txt")
1129    } else {
1130        home.join(".bash_history")
1131    };
1132
1133    match std::fs::read_to_string(&history_file) {
1134        Ok(content) => content
1135            .lines()
1136            .filter_map(|l| {
1137                let trimmed = l.trim();
1138                if trimmed.starts_with(':') {
1139                    trimmed.split(';').nth(1).map(|s| s.to_string())
1140                } else {
1141                    Some(trimmed.to_string())
1142                }
1143            })
1144            .filter(|l| !l.is_empty())
1145            .collect(),
1146        Err(_) => Vec::new(),
1147    }
1148}
1149
1150fn print_savings(original: usize, sent: usize) {
1151    let saved = original.saturating_sub(sent);
1152    if original > 0 && saved > 0 {
1153        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1154        println!("[{saved} tok saved ({pct}%)]");
1155    }
1156}
1157
1158pub fn cmd_theme(args: &[String]) {
1159    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1160    let r = theme::rst();
1161    let b = theme::bold();
1162    let d = theme::dim();
1163
1164    match sub {
1165        "list" => {
1166            let cfg = config::Config::load();
1167            let active = cfg.theme.as_str();
1168            println!();
1169            println!("  {b}Available themes:{r}");
1170            println!("  {ln}", ln = "─".repeat(40));
1171            for name in theme::PRESET_NAMES {
1172                let marker = if *name == active { " ◀ active" } else { "" };
1173                let t = theme::from_preset(name).unwrap();
1174                let preview = format!(
1175                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1176                    p = t.primary.fg(),
1177                    s = t.secondary.fg(),
1178                    a = t.accent.fg(),
1179                    sc = t.success.fg(),
1180                    w = t.warning.fg(),
1181                );
1182                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1183            }
1184            if let Some(path) = theme::theme_file_path() {
1185                if path.exists() {
1186                    let custom = theme::load_theme("_custom_");
1187                    let preview = format!(
1188                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1189                        p = custom.primary.fg(),
1190                        s = custom.secondary.fg(),
1191                        a = custom.accent.fg(),
1192                        sc = custom.success.fg(),
1193                        w = custom.warning.fg(),
1194                    );
1195                    let marker = if active == "custom" {
1196                        " ◀ active"
1197                    } else {
1198                        ""
1199                    };
1200                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1201                }
1202            }
1203            println!();
1204            println!("  {d}Set theme: nebu-ctx theme set <name>{r}");
1205            println!();
1206        }
1207        "set" => {
1208            if args.len() < 2 {
1209                eprintln!("Usage: nebu-ctx theme set <name>");
1210                std::process::exit(1);
1211            }
1212            let name = &args[1];
1213            if theme::from_preset(name).is_none() && name != "custom" {
1214                eprintln!(
1215                    "Unknown theme '{name}'. Available: {}",
1216                    theme::PRESET_NAMES.join(", ")
1217                );
1218                std::process::exit(1);
1219            }
1220            let mut cfg = config::Config::load();
1221            cfg.theme = name.to_string();
1222            match cfg.save() {
1223                Ok(()) => {
1224                    let t = theme::load_theme(name);
1225                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1226                    let preview = t.gradient_bar(0.75, 30);
1227                    println!("  {preview}");
1228                }
1229                Err(e) => eprintln!("Error: {e}"),
1230            }
1231        }
1232        "export" => {
1233            let cfg = config::Config::load();
1234            let t = theme::load_theme(&cfg.theme);
1235            println!("{}", t.to_toml());
1236        }
1237        "import" => {
1238            if args.len() < 2 {
1239                eprintln!("Usage: nebu-ctx theme import <path>");
1240                std::process::exit(1);
1241            }
1242            let path = std::path::Path::new(&args[1]);
1243            if !path.exists() {
1244                eprintln!("File not found: {}", args[1]);
1245                std::process::exit(1);
1246            }
1247            match std::fs::read_to_string(path) {
1248                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1249                    Ok(imported) => match theme::save_theme(&imported) {
1250                        Ok(()) => {
1251                            let mut cfg = config::Config::load();
1252                            cfg.theme = "custom".to_string();
1253                            let _ = cfg.save();
1254                            println!(
1255                                "  {sc}✓{r} Imported theme '{name}' → ~/.nebu-ctx/theme.toml",
1256                                sc = imported.success.fg(),
1257                                name = imported.name,
1258                            );
1259                            println!("  Config updated: theme = custom");
1260                        }
1261                        Err(e) => eprintln!("Error saving theme: {e}"),
1262                    },
1263                    Err(e) => eprintln!("Invalid theme file: {e}"),
1264                },
1265                Err(e) => eprintln!("Error reading file: {e}"),
1266            }
1267        }
1268        "preview" => {
1269            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1270            let t = match theme::from_preset(name) {
1271                Some(t) => t,
1272                None => {
1273                    eprintln!("Unknown theme: {name}");
1274                    std::process::exit(1);
1275                }
1276            };
1277            println!();
1278            println!(
1279                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1280                icon = t.header_icon(),
1281                title = t.brand_title(),
1282            );
1283            println!("  {ln}", ln = t.border_line(50));
1284            println!();
1285            println!(
1286                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1287                sc = t.success.fg(),
1288                sec = t.secondary.fg(),
1289                wrn = t.warning.fg(),
1290                acc = t.accent.fg(),
1291            );
1292            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1293            println!();
1294            println!(
1295                "  {b}{txt}Gradient Bar{r}      {bar}",
1296                txt = t.text.fg(),
1297                bar = t.gradient_bar(0.85, 30),
1298            );
1299            println!(
1300                "  {b}{txt}Sparkline{r}         {spark}",
1301                txt = t.text.fg(),
1302                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1303            );
1304            println!();
1305            println!("  {top}", top = t.box_top(50));
1306            println!(
1307                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1308                side = t.box_side(),
1309                side_r = t.box_side(),
1310                txt = t.text.fg(),
1311            );
1312            println!("  {bot}", bot = t.box_bottom(50));
1313            println!();
1314        }
1315        _ => {
1316            eprintln!("Usage: nebu-ctx theme [list|set|export|import|preview]");
1317            std::process::exit(1);
1318        }
1319    }
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325    use tempfile;
1326
1327    #[test]
1328    fn test_remove_nebu_ctx_block_posix() {
1329        let input = r#"# existing config
1330export PATH="$HOME/bin:$PATH"
1331
1332# nebu-ctx shell hook — transparent CLI compression (90+ patterns)
1333if [ -z "$NEBU_CTX_ACTIVE" ]; then
1334alias git='nebu-ctx -c git'
1335alias npm='nebu-ctx -c npm'
1336fi
1337
1338# other stuff
1339export EDITOR=vim
1340"#;
1341        let result = remove_nebu_ctx_block(input);
1342        assert!(!result.contains("lean-ctx"), "block should be removed");
1343        assert!(result.contains("export PATH"), "other content preserved");
1344        assert!(
1345            result.contains("export EDITOR"),
1346            "trailing content preserved"
1347        );
1348    }
1349
1350    #[test]
1351    fn test_remove_nebu_ctx_block_fish() {
1352        let input = "# other fish config\nset -x FOO bar\n\n# nebu-ctx shell hook — transparent CLI compression (90+ patterns)\nif not set -q NEBU_CTX_ACTIVE\n\talias git 'nebu-ctx -c git'\n\talias npm 'nebu-ctx -c npm'\nend\n\n# more config\nset -x BAZ qux\n";
1353        let result = remove_nebu_ctx_block(input);
1354        assert!(!result.contains("lean-ctx"), "block should be removed");
1355        assert!(result.contains("set -x FOO"), "other content preserved");
1356        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1357    }
1358
1359    #[test]
1360    fn test_remove_nebu_ctx_block_ps() {
1361        let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# nebu-ctx shell hook — transparent CLI compression (90+ patterns)\nif (-not $env:NEBU_CTX_ACTIVE) {\n  $LeanCtxBin = \"C:\\\\bin\\\\nebu-ctx.exe\"\n  function git { & $LeanCtxBin -c \"git $($args -join ' ')\" }\n}\n\n# other stuff\n$env:EDITOR = 'vim'\n";
1362        let result = remove_nebu_ctx_block_ps(input);
1363        assert!(
1364            !result.contains("nebu-ctx shell hook"),
1365            "block should be removed"
1366        );
1367        assert!(result.contains("$env:FOO"), "other content preserved");
1368        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1369    }
1370
1371    #[test]
1372    fn test_remove_nebu_ctx_block_ps_nested() {
1373        let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# nebu-ctx shell hook — transparent CLI compression (90+ patterns)\nif (-not $env:NEBU_CTX_ACTIVE) {\n  $LeanCtxBin = \"lean-ctx\"\n  function _lc {\n    & $LeanCtxBin -c \"$($args -join ' ')\"\n  }\n  if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {\n    function git { _lc git @args }\n    foreach ($c in @('npm','pnpm')) {\n      if ($a) {\n        Set-Variable -Name \"_lc_$c\" -Value $a.Source -Scope Script\n      }\n    }\n  }\n}\n\n# other stuff\n$env:EDITOR = 'vim'\n";
1374        let result = remove_nebu_ctx_block_ps(input);
1375        assert!(
1376            !result.contains("nebu-ctx shell hook"),
1377            "block should be removed"
1378        );
1379        assert!(!result.contains("_lc"), "function should be removed");
1380        assert!(result.contains("$env:FOO"), "other content preserved");
1381        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1382    }
1383
1384    #[test]
1385    fn test_remove_block_no_nebu_ctx() {
1386        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1387        let result = remove_nebu_ctx_block(input);
1388        assert!(result.contains("export PATH"), "content unchanged");
1389    }
1390
1391    #[test]
1392    fn test_bash_hook_contains_pipe_guard() {
1393        let binary = "/usr/local/bin/nebu-ctx";
1394        let hook = format!(
1395            r#"_lc() {{
1396    if [ -n "${{NEBU_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1397        command "$@"
1398        return
1399    fi
1400    '{binary}' -t "$@"
1401}}"#
1402        );
1403        assert!(
1404            hook.contains("! -t 1"),
1405            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1406        );
1407        assert!(
1408            hook.contains("NEBU_CTX_DISABLED") && hook.contains("! -t 1"),
1409            "pipe guard must be in the same conditional as NEBU_CTX_DISABLED"
1410        );
1411    }
1412
1413    #[test]
1414    fn test_lc_uses_track_mode_by_default() {
1415        let binary = "/usr/local/bin/nebu-ctx";
1416        let alias_list = crate::rewrite_registry::shell_alias_list();
1417        let aliases = format!(
1418            r#"_lc() {{
1419    '{binary}' -t "$@"
1420}}
1421_lc_compress() {{
1422    '{binary}' -c "$@"
1423}}"#
1424        );
1425        assert!(
1426            aliases.contains("-t \"$@\""),
1427            "_lc must use -t (track mode) by default"
1428        );
1429        assert!(
1430            aliases.contains("-c \"$@\""),
1431            "_lc_compress must use -c (compress mode)"
1432        );
1433        let _ = alias_list;
1434    }
1435
1436    #[test]
1437    fn test_posix_shell_has_nebu_ctx_mode() {
1438        let alias_list = crate::rewrite_registry::shell_alias_list();
1439        let aliases = r#"
1440nebu-ctx-mode() {{
1441    case "${{1:-}}" in
1442        compress) echo compress ;;
1443        track) echo track ;;
1444        off) echo off ;;
1445    esac
1446}}
1447"#
1448        .to_string();
1449        assert!(
1450            aliases.contains("nebu-ctx-mode()"),
1451            "nebu-ctx-mode function must exist"
1452        );
1453        assert!(
1454            aliases.contains("compress"),
1455            "compress mode must be available"
1456        );
1457        assert!(aliases.contains("track"), "track mode must be available");
1458        let _ = alias_list;
1459    }
1460
1461    #[test]
1462    fn test_fish_hook_contains_pipe_guard() {
1463        let hook = "function _lc\n\tif set -q NEBU_CTX_DISABLED; or not isatty stdout\n\t\tcommand $argv\n\t\treturn\n\tend\nend";
1464        assert!(
1465            hook.contains("isatty stdout"),
1466            "fish hook must contain pipe guard (isatty stdout)"
1467        );
1468    }
1469
1470    #[test]
1471    fn test_powershell_hook_contains_pipe_guard() {
1472        let hook = "function _lc { if ($env:NEBU_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1473        assert!(
1474            hook.contains("IsOutputRedirected"),
1475            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1476        );
1477    }
1478
1479    #[test]
1480    fn test_remove_nebu_ctx_block_new_format_with_end_marker() {
1481        let input = r#"# existing config
1482export PATH="$HOME/bin:$PATH"
1483
1484# nebu-ctx shell hook — transparent CLI compression (90+ patterns)
1485_nebu_ctx_cmds=(git npm pnpm)
1486
1487nebu-ctx-on() {
1488    for _lc_cmd in "${_nebu_ctx_cmds[@]}"; do
1489        alias "$_lc_cmd"='nebu-ctx -c '"$_lc_cmd"
1490    done
1491    export NEBU_CTX_ENABLED=1
1492    [ -t 1 ] && echo "nebu-ctx: ON"
1493}
1494
1495nebu-ctx-off() {
1496    unset NEBU_CTX_ENABLED
1497    [ -t 1 ] && echo "nebu-ctx: OFF"
1498}
1499
1500if [ -z "${NEBU_CTX_ACTIVE:-}" ] && [ "${NEBU_CTX_ENABLED:-1}" != "0" ]; then
1501    nebu-ctx-on
1502fi
1503# nebu-ctx shell hook — end
1504
1505# other stuff
1506export EDITOR=vim
1507"#;
1508        let result = remove_nebu_ctx_block(input);
1509        assert!(!result.contains("nebu-ctx-on"), "block should be removed");
1510        assert!(!result.contains("nebu-ctx shell hook"), "marker removed");
1511        assert!(result.contains("export PATH"), "other content preserved");
1512        assert!(
1513            result.contains("export EDITOR"),
1514            "trailing content preserved"
1515        );
1516    }
1517
1518    #[test]
1519    fn env_sh_for_containers_includes_self_heal() {
1520        let _g = crate::core::data_dir::test_env_lock();
1521        let tmp = tempfile::tempdir().expect("tempdir");
1522        let data_dir = tmp.path().join("data");
1523        std::fs::create_dir_all(&data_dir).expect("mkdir data");
1524        std::env::set_var("NEBU_CTX_DATA_DIR", &data_dir);
1525
1526        write_env_sh_for_containers("alias git='nebu-ctx -c git'\n");
1527        let env_sh = data_dir.join("env.sh");
1528        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1529        assert!(content.contains("nebu-ctx docker self-heal"));
1530        assert!(content.contains("claude mcp list"));
1531        assert!(content.contains("nebu-ctx init --agent claude"));
1532
1533        std::env::remove_var("NEBU_CTX_DATA_DIR");
1534    }
1535}