Skip to main content

lean_ctx/cli/
mod.rs

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