1use std::path::Path;
2
3use crate::core::compressor;
4use crate::core::deps as dep_extract;
5use crate::core::entropy;
6use crate::core::io_boundary;
7use crate::core::patterns::deps_cmd;
8use crate::core::protocol;
9use crate::core::roles;
10use crate::core::signatures;
11
12fn resolve_cli_path(raw: &str) -> String {
13 if let Ok(abs) = std::path::Path::new(raw).canonicalize() {
14 return abs.to_string_lossy().to_string();
15 }
16 if Path::new(raw).is_relative() {
17 if let Ok(cwd) = std::env::current_dir() {
18 return cwd.join(raw).to_string_lossy().into_owned();
19 }
20 }
21 raw.to_string()
22}
23use crate::core::tokens::count_tokens;
24
25use super::common::print_savings;
26
27fn cap_cli_to_raw(framed: String, raw_content: &str, raw_tokens: usize) -> String {
34 if raw_tokens > 0 && count_tokens(&framed) > raw_tokens {
35 raw_content.to_string()
36 } else {
37 framed
38 }
39}
40
41pub fn cmd_read(args: &[String]) {
42 if args.is_empty() {
43 eprintln!(
44 "Usage: lean-ctx read <file> [--mode auto|full|map|signatures|aggressive|entropy] [--fresh]"
45 );
46 std::process::exit(1);
47 }
48
49 let raw_path = &args[0];
50 let path = if Path::new(raw_path).is_relative() {
51 std::env::current_dir().ok().map_or_else(
52 || raw_path.clone(),
53 |cwd| cwd.join(raw_path).to_string_lossy().into_owned(),
54 )
55 } else {
56 raw_path.clone()
57 };
58 let path = path.as_str();
59 let mode = args
60 .iter()
61 .position(|a| a == "--mode" || a == "-m")
62 .and_then(|i| args.get(i + 1))
63 .map_or("auto", std::string::String::as_str);
64 let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
65 let requested_auto = mode == "auto";
68
69 let short = protocol::shorten_path(path);
70
71 if let Ok(abs) = std::fs::canonicalize(path) {
74 match io_boundary::check_secret_path_for_tool("cli_read", &abs) {
75 Ok(Some(w)) => eprintln!("{w}"),
76 Ok(None) => {}
77 Err(e) => {
78 eprintln!("{e}");
79 std::process::exit(1);
80 }
81 }
82 } else {
83 let raw = std::path::Path::new(path);
85 match io_boundary::check_secret_path_for_tool("cli_read", raw) {
86 Ok(Some(w)) => eprintln!("{w}"),
87 Ok(None) => {}
88 Err(e) => {
89 eprintln!("{e}");
90 std::process::exit(1);
91 }
92 }
93 }
94
95 #[cfg(unix)]
96 {
97 #[cfg(unix)]
98 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
99 "ctx_read",
100 Some(serde_json::json!({
101 "path": path,
102 "mode": mode,
103 "fresh": force_fresh,
104 })),
105 ) {
106 let filtered = super::common::filter_daemon_output(&out);
107 if !filtered.trim().is_empty() {
108 println!("{filtered}");
109 return;
110 }
111 }
112 }
113 super::common::daemon_fallback_hint();
114
115 if !force_fresh && mode == "full" {
116 use crate::core::cli_cache::{self, CacheResult};
117 match cli_cache::check_and_read(path) {
118 CacheResult::Hit { entry, file_ref } => {
119 let msg = cli_cache::format_hit(&entry, &file_ref, &short);
120 println!("{msg}");
121 let sent = count_tokens(&msg);
122 super::common::cli_track_read_cached(path, "full", entry.original_tokens, sent);
123 return;
124 }
125 CacheResult::Miss { content } if content.is_empty() => {
126 eprintln!("Error: could not read {path}");
127 std::process::exit(1);
128 }
129 CacheResult::Miss { content } => {
130 let line_count = content.lines().count();
131 let raw_tokens = count_tokens(&content);
132 let framed = format!("{short} [{line_count}L]\n{content}");
133 let output = cap_cli_to_raw(framed, &content, raw_tokens);
134 println!("{output}");
135 let sent = count_tokens(&output);
136 super::common::cli_track_read(path, "full", raw_tokens, sent);
137 return;
138 }
139 }
140 }
141
142 let content = match crate::tools::ctx_read::read_file_lossy(path) {
143 Ok(c) => c,
144 Err(e) => {
145 eprintln!("Error: {e}");
146 std::process::exit(1);
147 }
148 };
149
150 let ext = Path::new(path)
151 .extension()
152 .and_then(|e| e.to_str())
153 .unwrap_or("");
154 let line_count = content.lines().count();
155 let original_tokens = count_tokens(&content);
156
157 let mode = if mode == "auto" {
158 crate::core::auto_mode_resolver::resolve(
164 &crate::core::auto_mode_resolver::AutoModeContext {
165 path,
166 token_count: original_tokens,
167 task: None,
168 cache: None,
169 },
170 )
171 .mode
172 } else if mode != "full" && crate::tools::ctx_read::is_instruction_file(path) {
173 "full".to_string()
174 } else {
175 mode.to_string()
176 };
177 let mode = mode.as_str();
178
179 match mode {
180 "map" => {
181 let structured = match ext {
182 "md" | "mdx" | "rst" => {
183 crate::core::structured_read::extract_markdown_outline(&content)
184 }
185 "json" => crate::core::structured_read::extract_json_structure(&content),
186 "yaml" | "yml" => crate::core::structured_read::extract_yaml_structure(&content),
187 "toml" => crate::core::structured_read::extract_toml_structure(&content),
188 _ if path.to_lowercase().ends_with(".lock")
189 || path.to_lowercase().ends_with("go.sum") =>
190 {
191 crate::core::structured_read::extract_lock_summary(&content, path)
192 }
193 _ => String::new(),
194 };
195
196 let mut output_buf = if structured.is_empty() {
197 let sigs = signatures::extract_signatures(&content, ext);
198 let dep_info = dep_extract::extract_deps(&content, ext);
199 let mut buf = format!("{short} [{line_count}L]");
200 if !dep_info.imports.is_empty() {
201 buf.push_str(&format!("\n deps: {}", dep_info.imports.join(", ")));
202 }
203 let key_sigs: Vec<&signatures::Signature> = sigs
204 .iter()
205 .filter(|s| s.is_exported || s.indent == 0)
206 .collect();
207 let extra_exports =
211 signatures::exports_not_in_signatures(&dep_info.exports, &key_sigs);
212 if !extra_exports.is_empty() {
213 buf.push_str(&format!("\n exports: {}", extra_exports.join(", ")));
214 }
215 if !key_sigs.is_empty() {
216 buf.push_str("\n API:");
217 for sig in &key_sigs {
218 buf.push_str(&format!("\n {}", sig.to_compact_located()));
219 }
220 }
221 buf
222 } else {
223 format!("{short} [{line_count}L]\n{structured}")
224 };
225
226 let sent = count_tokens(&output_buf);
227 output_buf = protocol::append_savings(&output_buf, original_tokens, sent);
228 if requested_auto {
229 output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
230 }
231 let sent = count_tokens(&output_buf);
232 println!("{output_buf}");
233 super::common::cli_track_read(path, "map", original_tokens, sent);
234 }
235 "signatures" => {
236 let sigs = signatures::extract_signatures(&content, ext);
237 let mut output_buf = format!("{short} [{line_count}L]");
238 for sig in &sigs {
239 output_buf.push_str(&format!("\n{}", sig.to_compact_located()));
240 }
241 if requested_auto {
242 output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
243 }
244 println!("{output_buf}");
245 let sent = count_tokens(&output_buf);
246 print_savings(original_tokens, sent);
247 super::common::cli_track_read(path, "signatures", original_tokens, sent);
248 }
249 "aggressive" => {
250 let compressed = compressor::aggressive_compress(&content, Some(ext));
251 println!("{short} [{line_count}L]");
252 println!("{compressed}");
253 let sent = count_tokens(&compressed);
254 print_savings(original_tokens, sent);
255 super::common::cli_track_read(path, "aggressive", original_tokens, sent);
256 }
257 "entropy" => {
258 let result = entropy::entropy_compress(&content);
259 let avg_h = entropy::analyze_entropy(&content).avg_entropy;
260 println!("{short} [{line_count}L] (H̄={avg_h:.1})");
261 for tech in &result.techniques {
262 println!("{tech}");
263 }
264 println!("{}", result.output);
265 let sent = count_tokens(&result.output);
266 print_savings(original_tokens, sent);
267 super::common::cli_track_read(path, "entropy", original_tokens, sent);
268 }
269 _ => {
270 let mut output = format!("{short} [{line_count}L]\n{content}");
277 if !crate::core::terse::is_verbatim_read("ctx_read", Some(mode)) {
278 let config = crate::core::config::Config::load();
279 let level = crate::core::config::CompressionLevel::effective(&config);
280 if level.is_active() {
281 let terse_result =
282 crate::core::terse::pipeline::compress(&output, &level, None);
283 if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
284 output = terse_result.output;
285 }
286 }
287 }
288 let output = cap_cli_to_raw(output, &content, original_tokens);
291 println!("{output}");
292 let sent = count_tokens(&output);
293 super::common::cli_track_read(path, "full", original_tokens, sent);
294 }
295 }
296}
297
298pub fn cmd_diff(args: &[String]) {
299 if args.len() < 2 {
300 eprintln!("Usage: lean-ctx diff <file1> <file2>");
301 std::process::exit(1);
302 }
303
304 let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
305 Ok(c) => c,
306 Err(e) => {
307 eprintln!("Error reading {}: {e}", args[0]);
308 std::process::exit(1);
309 }
310 };
311
312 let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
313 Ok(c) => c,
314 Err(e) => {
315 eprintln!("Error reading {}: {e}", args[1]);
316 std::process::exit(1);
317 }
318 };
319
320 let diff = compressor::diff_content(&content1, &content2);
321 let original = count_tokens(&content1) + count_tokens(&content2);
322 let sent = count_tokens(&diff);
323
324 println!(
325 "diff {} {}",
326 protocol::shorten_path(&args[0]),
327 protocol::shorten_path(&args[1])
328 );
329 println!("{diff}");
330 print_savings(original, sent);
331 crate::core::stats::record("cli_diff", original, sent);
332}
333
334pub fn cmd_grep(args: &[String]) {
335 if args.is_empty() {
336 eprintln!("Usage: lean-ctx grep <pattern> [path]");
337 std::process::exit(1);
338 }
339
340 let pattern = &args[0];
341 let raw_path = args.get(1).map_or(".", std::string::String::as_str);
342 let abs_path = resolve_cli_path(raw_path);
343 let path = abs_path.as_str();
344
345 #[cfg(unix)]
346 {
347 #[cfg(unix)]
348 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
349 "ctx_search",
350 Some(serde_json::json!({
351 "pattern": pattern,
352 "path": path,
353 })),
354 ) {
355 let out = super::common::filter_daemon_output(&out);
356 println!("{out}");
357 if out.trim_start().starts_with("0 matches") {
358 std::process::exit(1);
359 }
360 return;
361 }
362 }
363 super::common::daemon_fallback_hint();
364
365 let outcome = crate::tools::ctx_search::handle(
366 pattern,
367 path,
368 None,
369 20,
370 crate::tools::CrpMode::effective(),
371 true,
372 roles::active_role().io.allow_secret_paths,
373 );
374 let out = outcome.text;
375 println!("{out}");
376 super::common::cli_track_search(
377 outcome.modeled_baseline,
378 outcome.observed_tokens,
379 count_tokens(&out),
380 );
381 if outcome.modeled_baseline == 0 && out.trim_start().starts_with("0 matches") {
382 std::process::exit(1);
383 }
384}
385
386pub fn cmd_find(args: &[String]) {
387 if args.is_empty() {
388 eprintln!("Usage: lean-ctx find <pattern> [path]");
389 std::process::exit(1);
390 }
391
392 let raw_pattern = &args[0];
393 let path = args.get(1).map_or(".", std::string::String::as_str);
394
395 let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
396 let glob_matcher = if is_glob {
397 glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
398 } else {
399 None
400 };
401 let substring = raw_pattern.to_lowercase();
402
403 let mut found = false;
404 for entry in ignore::WalkBuilder::new(path)
405 .hidden(true)
406 .git_ignore(true)
407 .git_global(true)
408 .git_exclude(true)
409 .require_git(false)
410 .max_depth(Some(10))
411 .filter_entry(crate::core::walk_filter::keep_entry)
412 .build()
413 .flatten()
414 {
415 let name = entry.file_name().to_string_lossy().to_lowercase();
416 let matches = if let Some(ref g) = glob_matcher {
417 g.matches(&name)
418 } else {
419 name.contains(&substring)
420 };
421 if matches {
422 println!("{}", entry.path().display());
423 found = true;
424 }
425 }
426
427 crate::core::stats::record("cli_find", 0, 0);
428
429 if !found {
430 std::process::exit(1);
431 }
432}
433
434pub fn cmd_ls(args: &[String]) {
435 let mut raw_path = ".";
436 let mut depth = 3usize;
437 let mut show_hidden = false;
438 let mut respect_gitignore = true;
439 let mut i = 0;
440
441 while i < args.len() {
442 let arg = &args[i];
443 if arg == "--depth" {
444 i += 1;
445 if let Some(d) = args.get(i).and_then(|s| s.parse::<usize>().ok()) {
446 depth = d.min(10);
447 }
448 } else if arg == "--all" || arg == "-a" {
449 show_hidden = true;
450 } else if arg == "--no-gitignore" {
451 respect_gitignore = false;
452 } else if arg.starts_with('-') {
453 eprintln!("Error: lean-ctx ls does not support flag '{arg}'.\n");
454 eprintln!("lean-ctx ls is a compressed directory tree viewer for AI context, not a drop-in ls replacement.");
455 eprintln!("The shell hook (lean-ctx -t ls {arg} ...) passes flags to system ls transparently.\n");
456 eprintln!("Usage: lean-ctx ls [path] [--depth N] [--all] [--no-gitignore]");
457 std::process::exit(1);
458 } else {
459 raw_path = arg;
460 }
461 i += 1;
462 }
463
464 let abs_path = resolve_cli_path(raw_path);
465 let path = abs_path.as_str();
466
467 #[cfg(unix)]
468 {
469 #[cfg(unix)]
470 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
471 "ctx_tree",
472 Some(serde_json::json!({
473 "path": path,
474 "depth": depth,
475 "show_hidden": show_hidden,
476 "respect_gitignore": respect_gitignore,
477 })),
478 ) {
479 println!("{}", super::common::filter_daemon_output(&out));
480 return;
481 }
482 }
483 super::common::daemon_fallback_hint();
484
485 let (out, _original) =
486 crate::tools::ctx_tree::handle(path, depth, show_hidden, respect_gitignore);
487 println!("{out}");
488 super::common::cli_track_tree(0, count_tokens(&out));
489}
490
491pub fn cmd_deps(args: &[String]) {
492 let path = args.first().map_or(".", std::string::String::as_str);
493
494 if let Some(result) = deps_cmd::detect_and_compress(path) {
495 println!("{result}");
496 crate::core::stats::record("cli_deps", 0, 0);
497 } else {
498 eprintln!("No dependency file found in {path}");
499 std::process::exit(1);
500 }
501}