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}