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}