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