1use clap::{Parser, Subcommand};
2use ignore::WalkBuilder;
3use std::path::{Path, PathBuf};
4
5mod adapters;
6mod calls;
7mod core;
8mod deps;
9mod file_filter;
10mod graph_cache;
11mod hook;
12mod installers;
13mod main_helpers;
14mod mcp;
15mod path_glob;
16mod project_root;
17mod prompt;
18mod run;
19mod search;
20mod squeeze;
21mod surface;
22
23use crate::core::{DigestOptions, MapOptions, ParseResult};
24
25#[derive(Parser)]
26#[command(name = "ast-bro")]
27#[command(version)]
28#[command(about = "Fast, AST-based structural outline for source files", long_about = None)]
29struct Cli {
30 #[command(subcommand)]
31 command: Commands,
32}
33
34#[derive(Subcommand)]
35enum Commands {
36 Map {
38 #[arg(num_args = 1..)]
40 paths: Vec<PathBuf>,
41
42 #[arg(long)]
43 no_private: bool,
44 #[arg(long)]
45 no_fields: bool,
46 #[arg(long)]
47 no_docs: bool,
48 #[arg(long)]
49 no_attrs: bool,
50 #[arg(long)]
51 no_lines: bool,
52 #[arg(long)]
53 glob: Option<String>,
54 #[arg(long)]
56 json: bool,
57 #[arg(long)]
59 compact: bool,
60 },
61 Show {
63 path: PathBuf,
64 symbol: String,
65 #[arg(num_args = 0..)]
66 others: Vec<String>,
67 #[arg(long)]
69 json: bool,
70 #[arg(long)]
72 compact: bool,
73 },
74 Squeeze {
81 path: PathBuf,
83 #[arg(value_parser = parse_line_range)]
85 range: Option<LineRange>,
86 #[arg(long)]
88 raw: bool,
89 #[arg(long)]
91 json: bool,
92 #[arg(long)]
94 compact: bool,
95 },
96 Digest {
98 #[arg(num_args = 1..)]
99 paths: Vec<PathBuf>,
100
101 #[arg(long)]
102 include_private: bool,
103 #[arg(long)]
104 include_fields: bool,
105 #[arg(long, default_value_t = 50)]
106 max_members: usize,
107 #[arg(long)]
109 json: bool,
110 #[arg(long)]
112 compact: bool,
113 },
114 Implements {
116 target: String,
117 #[arg(num_args = 1..)]
118 paths: Vec<PathBuf>,
119
120 #[arg(short, long)]
121 direct: bool,
122 #[arg(long)]
124 json: bool,
125 #[arg(long)]
127 compact: bool,
128 },
129 Prompt,
131 Install {
133 #[arg(long, conflicts_with = "all")]
134 target: Option<String>,
135 #[arg(long, conflicts_with = "target")]
136 all: bool,
137 #[arg(long)]
138 local: bool,
139 #[arg(long, conflicts_with = "local")]
140 global: bool,
141 #[arg(long)]
142 always: bool,
143 #[arg(long, default_value_t = 200)]
144 min_lines: usize,
145 #[arg(long)]
146 dry_run: bool,
147 #[arg(long)]
148 force: bool,
149 #[arg(long)]
152 mcp: bool,
153 #[arg(long)]
156 skills: bool,
157 },
158 Uninstall {
160 #[arg(long, conflicts_with = "all")]
161 target: Option<String>,
162 #[arg(long, conflicts_with = "target")]
163 all: bool,
164 #[arg(long)]
165 local: bool,
166 #[arg(long, conflicts_with = "local")]
167 global: bool,
168 #[arg(long)]
169 dry_run: bool,
170 },
171 Status {
173 #[arg(long)]
174 local: bool,
175 #[arg(long, conflicts_with = "local")]
176 global: bool,
177 },
178 Hook {
180 #[arg(long)]
181 protocol: String,
182 #[arg(long, default_value_t = 200)]
183 min_lines: usize,
184 #[arg(long)]
185 always: bool,
186 },
187 Mcp,
189 Search {
191 query: String,
193 #[arg(default_value = ".")]
195 path: PathBuf,
196 #[arg(short = 'k', long = "top-k", default_value_t = 10)]
198 top_k: usize,
199 #[arg(long)]
201 alpha: Option<f32>,
202 #[arg(long = "lang")]
204 languages: Vec<String>,
205 #[arg(long)]
207 rebuild: bool,
208 #[arg(long)]
210 json: bool,
211 #[arg(long)]
213 compact: bool,
214 },
215 FindRelated {
221 #[arg(required_unless_present_all = ["file", "line"], conflicts_with_all = ["file", "line"])]
224 target: Option<String>,
225 #[arg(default_value = ".")]
227 path: PathBuf,
228 #[arg(long, requires = "line")]
230 file: Option<String>,
231 #[arg(long, requires = "file")]
233 line: Option<u32>,
234 #[arg(short = 'k', long = "top-k", default_value_t = 10)]
235 top_k: usize,
236 #[arg(long)]
237 json: bool,
238 #[arg(long)]
239 compact: bool,
240 },
241 Surface {
243 #[arg(default_value = ".")]
245 path: PathBuf,
246 #[arg(long)]
248 tree: bool,
249 #[arg(long)]
251 include_chain: bool,
252 #[arg(long, default_value_t = 16)]
254 max_depth: usize,
255 #[arg(long)]
257 include_private: bool,
258 #[arg(long)]
260 lang: Option<String>,
261 #[arg(long)]
263 json: bool,
264 #[arg(long)]
266 compact: bool,
267 },
268 Deps {
270 file: PathBuf,
271 #[arg(long, default_value_t = 3)]
272 depth: usize,
273 #[arg(long)]
275 rebuild: bool,
276 #[arg(long)]
277 json: bool,
278 #[arg(long)]
279 compact: bool,
280 },
281 ReverseDeps {
283 file: PathBuf,
284 #[arg(long, default_value_t = 3)]
285 depth: usize,
286 #[arg(long, default_value_t = 200)]
287 limit: usize,
288 #[arg(long)]
289 rebuild: bool,
290 #[arg(long)]
291 json: bool,
292 #[arg(long)]
293 compact: bool,
294 },
295 Cycles {
297 #[arg(default_value = ".")]
298 path: PathBuf,
299 #[arg(long, default_value_t = 2)]
300 min_size: usize,
301 #[arg(long)]
302 rebuild: bool,
303 #[arg(long)]
304 json: bool,
305 #[arg(long)]
306 compact: bool,
307 },
308 Graph {
310 #[arg(default_value = ".")]
311 path: PathBuf,
312 #[arg(long)]
313 json: bool,
314 #[arg(long)]
315 include_external: bool,
316 #[arg(long)]
317 rebuild: bool,
318 #[arg(long)]
319 compact: bool,
320 },
321 Callers {
328 #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
330 target: Option<String>,
331 #[arg(default_value = ".")]
333 path: PathBuf,
334 #[arg(long, requires = "symbol")]
336 file: Option<String>,
337 #[arg(long, requires = "file")]
339 symbol: Option<String>,
340 #[arg(long, default_value_t = 1)]
342 depth: usize,
343 #[arg(long, default_value_t = 200)]
345 limit: usize,
346 #[arg(long)]
348 include_ambiguous: bool,
349 #[arg(long)]
351 rebuild: bool,
352 #[arg(long)]
353 json: bool,
354 #[arg(long)]
355 compact: bool,
356 },
357 Callees {
362 #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
363 target: Option<String>,
364 #[arg(default_value = ".")]
365 path: PathBuf,
366 #[arg(long, requires = "symbol")]
367 file: Option<String>,
368 #[arg(long, requires = "file")]
369 symbol: Option<String>,
370 #[arg(long, default_value_t = 1)]
371 depth: usize,
372 #[arg(long)]
374 external: bool,
375 #[arg(long)]
376 rebuild: bool,
377 #[arg(long)]
378 json: bool,
379 #[arg(long)]
380 compact: bool,
381 },
382 Trace {
391 from: String,
393 to: String,
395 #[arg(default_value = ".")]
397 path: PathBuf,
398 #[arg(long, default_value_t = 12)]
400 depth: usize,
401 #[arg(long)]
403 rebuild: bool,
404 #[arg(long)]
405 json: bool,
406 #[arg(long)]
407 compact: bool,
408 },
409 Index {
411 #[arg(default_value = ".")]
413 path: PathBuf,
414 #[arg(long)]
416 rebuild: bool,
417 #[arg(long)]
419 stats: bool,
420 #[arg(long)]
422 json: bool,
423 #[arg(long)]
425 compact: bool,
426 },
427 Run {
429 #[arg(short, long)]
431 pattern: String,
432
433 #[arg(short, long)]
435 rewrite: Option<String>,
436
437 #[arg(short, long)]
439 lang: Option<String>,
440
441 paths: Vec<PathBuf>,
443
444 #[arg(long)]
446 glob: Option<String>,
447
448 #[arg(long)]
450 write: bool,
451
452 #[arg(long)]
454 json: bool,
455
456 #[arg(long)]
458 compact: bool,
459 },
460}
461
462pub(crate) fn parse_file(path: &Path) -> Option<ParseResult> {
463 crate::main_helpers::parse_file_for_hook(path)
464}
465
466#[derive(Clone, Debug)]
470pub struct LineRange {
471 pub start: Option<usize>,
472 pub end: Option<usize>,
473}
474
475impl LineRange {
476 fn resolve(&self, line_count: usize) -> (usize, usize) {
480 clamp_line_range(self.start.unwrap_or(1), self.end, line_count)
481 }
482}
483
484pub(crate) fn clamp_line_range(
486 start: usize,
487 end: Option<usize>,
488 line_count: usize,
489) -> (usize, usize) {
490 let end = end.unwrap_or(line_count).min(line_count).max(start);
491 (start, end)
492}
493
494fn parse_line_range(s: &str) -> Result<LineRange, String> {
499 let parse_bound = |part: &str| -> Result<Option<usize>, String> {
500 if part.is_empty() {
501 return Ok(None);
502 }
503 match part.parse::<usize>() {
504 Ok(0) => Err("line numbers are 1-indexed (got 0)".to_string()),
505 Ok(n) => Ok(Some(n)),
506 Err(_) => Err(format!("invalid line number: {part:?}")),
507 }
508 };
509
510 let range = match s.split_once(':') {
511 Some((a, b)) => LineRange {
513 start: parse_bound(a)?,
514 end: parse_bound(b)?,
515 },
516 None => {
518 let n = parse_bound(s)?;
519 LineRange { start: n, end: n }
520 }
521 };
522
523 if let (Some(start), Some(end)) = (range.start, range.end) {
524 if start > end {
525 return Err(format!("range start {start} is after end {end}"));
526 }
527 }
528
529 Ok(range)
530}
531
532fn parse_file_line(s: &str) -> Option<(String, u32)> {
535 let (file, line) = s.rsplit_once(':')?;
536 if file.is_empty() {
537 return None;
538 }
539 Some((file.to_string(), line.parse().ok()?))
540}
541
542fn build_filtered_walker(
545 paths: &[PathBuf],
546 glob_str: Option<&str>,
547) -> Option<(WalkBuilder, Vec<PathBuf>)> {
548 if paths.is_empty() {
549 return None;
550 }
551
552 let existing: Vec<PathBuf> = paths
553 .iter()
554 .flat_map(|p| {
555 let expanded = path_glob::expand_existing(p);
556 if expanded.is_empty() {
557 println!("# note: path not found: {}", p.display());
558 }
559 expanded
560 })
561 .collect();
562 if existing.is_empty() {
563 return None;
564 }
565
566 let mut builder = WalkBuilder::new(&existing[0]);
567 for p in existing.iter().skip(1) {
568 builder.add(p);
569 }
570
571 builder.hidden(false);
572 file_filter::add_filters(&mut builder, &existing[0]);
573
574 if let Some(g) = glob_str {
575 if let Ok(override_builder) = ignore::overrides::OverrideBuilder::new("").add(g) {
576 if let Ok(over) = override_builder.build() {
577 builder.overrides(over);
578 }
579 }
580 }
581
582 Some((builder, existing))
583}
584
585pub(crate) fn walk_paths(paths: &[PathBuf], glob_str: Option<&str>) -> Vec<PathBuf> {
586 let (tx, rx) = std::sync::mpsc::channel();
587 let Some((builder, existing)) = build_filtered_walker(paths, glob_str) else {
588 return Vec::new();
589 };
590 let walker = builder.build_parallel();
591 let root = existing[0].clone();
592
593 walker.run(|| {
594 let tx = tx.clone();
595 let root = root.clone();
596 Box::new(move |result| {
597 if let Ok(entry) = result {
598 if entry.file_type().is_some_and(|ft| ft.is_file())
599 && !file_filter::should_skip_path(entry.path(), &root)
600 {
601 let _ = tx.send(entry.path().to_path_buf());
602 }
603 }
604 ignore::WalkState::Continue
605 })
606 });
607
608 drop(tx);
609 let mut results: Vec<_> = rx.into_iter().collect();
610 results.sort();
611 results
612}
613
614pub(crate) fn walk_and_parse(paths: &[PathBuf], glob_str: Option<&str>) -> Vec<ParseResult> {
615 let (tx, rx) = std::sync::mpsc::channel();
616 let Some((builder, existing)) = build_filtered_walker(paths, glob_str) else {
617 return Vec::new();
618 };
619 let walker = builder.build_parallel();
620 let root = existing[0].clone();
621
622 walker.run(|| {
623 let tx = tx.clone();
624 let root = root.clone();
625 Box::new(move |result| {
626 if let Ok(entry) = result {
627 if entry.file_type().is_some_and(|ft| ft.is_file())
628 && !file_filter::should_skip_path(entry.path(), &root)
629 {
630 if let Some(parsed) = parse_file(entry.path()) {
631 let _ = tx.send(parsed);
632 }
633 }
634 }
635 ignore::WalkState::Continue
636 })
637 });
638
639 drop(tx);
640 let mut results: Vec<_> = rx.into_iter().collect();
641 results.sort_by(|a, b| a.path.cmp(&b.path));
642 results
643}
644
645pub fn run() {
646 use clap::error::ErrorKind;
647 use clap::CommandFactory;
648
649 let cli = match Cli::try_parse() {
655 Ok(c) => c,
656 Err(e) => match e.kind() {
657 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
658 e.exit();
659 }
660 _ => {
661 let mut cmd = Cli::command();
662 let _ = cmd.print_help();
663 println!();
664 println!(
665 "# note: could not parse args ({}). Showing help instead.",
666 e.kind()
667 );
668 std::process::exit(0);
669 }
670 },
671 };
672
673 match &cli.command {
674 Commands::Map {
675 paths,
676 no_private,
677 no_fields,
678 no_docs,
679 no_attrs,
680 no_lines,
681 glob,
682 json,
683 compact,
684 } => {
685 let results = walk_and_parse(paths, glob.as_deref());
686 let opts = MapOptions {
687 include_private: !(*no_private),
688 include_fields: !(*no_fields),
689 include_docs: !(*no_docs),
690 include_attributes: !(*no_attrs),
691 include_line_numbers: !(*no_lines),
692 max_doc_lines: 6,
693 max_members: None,
694 };
695 let json_on = *json;
696 let pretty = !(*compact);
697 if json_on {
698 println!("{}", crate::core::render_json_map(&results, &opts, pretty));
699 } else {
700 for res in results {
701 println!("{}", crate::core::render_map(&res, &opts));
702 println!();
703 }
704 }
705 }
706 Commands::Show {
707 path,
708 symbol,
709 others,
710 json,
711 compact,
712 } => {
713 if !path.exists() {
714 println!("# note: path not found: {}", path.display());
715 } else if let Some(res) = parse_file(path) {
716 let mut symbols = vec![symbol.as_str()];
717 symbols.extend(others.iter().map(|s| s.as_str()));
718 if *json {
719 let mut seen = std::collections::HashSet::new();
720 let mut all_matches = Vec::new();
721 for sym in &symbols {
722 for m in crate::core::find_symbols(&res, sym) {
723 let key = (m.start_line, m.end_line, m.qualified_name.clone());
724 if seen.insert(key) {
725 all_matches.push(m);
726 }
727 }
728 }
729 println!(
730 "{}",
731 crate::core::render_json_show(&res, &all_matches, !(*compact))
732 );
733 if all_matches.is_empty() {
734 println!(
737 "# note: no symbol matching {:?} in {}",
738 symbol,
739 path.display()
740 );
741 }
742 } else {
743 let mut any_match = false;
744 for sym in &symbols {
745 let matches = crate::core::find_symbols(&res, sym);
746 for m in matches {
747 any_match = true;
748 println!(
749 "# {}:{}-{} {} ({})",
750 res.path.display(),
751 m.start_line,
752 m.end_line,
753 m.qualified_name,
754 m.kind
755 );
756 if !m.ancestor_signatures.is_empty() {
757 println!("# in: {}", m.ancestor_signatures.join(" → "));
758 }
759 println!("{}", m.source);
760 }
761 }
762 if !any_match {
763 let joined = symbols.join(", ");
764 println!(
765 "# note: no symbol matching '{}' in {}",
766 joined,
767 path.display()
768 );
769 }
770 }
771 } else {
772 println!(
773 "# note: unsupported file type for `show`: {}",
774 path.display()
775 );
776 }
777 }
778 Commands::Squeeze {
779 path,
780 range,
781 raw,
782 json,
783 compact,
784 } => {
785 if !path.exists() {
786 println!("# note: path not found: {}", path.display());
787 return;
788 }
789 let text = match std::fs::read_to_string(path) {
790 Ok(t) => t,
791 Err(e) => {
792 if path.is_dir() {
793 println!("# note: path is a directory: {}", path.display());
794 } else {
795 println!("# note: could not read {}: {}", path.display(), e);
796 }
797 return;
798 }
799 };
800 let line_count = text.lines().count();
801 let resolved: Option<(usize, usize)> = range.as_ref().map(|r| r.resolve(line_count));
802 let sliced = crate::squeeze::render::slice_lines(&text, resolved);
803 let path_str = path.display().to_string();
804 let report = crate::squeeze::render::SqueezeReport {
805 path: &path_str,
806 range: resolved,
807 raw: &sliced,
808 raw_requested: *raw,
809 };
810 if *json {
811 println!(
812 "{}",
813 crate::squeeze::render::render_json(&report, !(*compact))
814 );
815 } else {
816 println!("{}", crate::squeeze::render::render_text(&report));
817 }
818 }
819 Commands::Digest {
820 paths,
821 include_private,
822 include_fields,
823 max_members,
824 json,
825 compact,
826 } => {
827 let results = walk_and_parse(paths, None);
828 if *json {
829 let opts = MapOptions {
830 include_private: *include_private,
831 include_fields: *include_fields,
832 include_docs: true,
833 include_attributes: true,
834 include_line_numbers: true,
835 max_doc_lines: 6,
836 max_members: Some(*max_members),
837 };
838 println!(
839 "{}",
840 crate::core::render_json_map(&results, &opts, !(*compact))
841 );
842 } else {
843 let opts = DigestOptions {
844 include_private: *include_private,
845 include_fields: *include_fields,
846 max_members_per_type: *max_members,
847 max_heading_depth: 3,
848 };
849 let root = if paths.len() == 1 && paths[0].is_dir() {
850 Some(paths[0].as_path())
851 } else {
852 None
853 };
854 println!("{}", crate::core::render_digest(&results, &opts, root));
855 }
856 }
857 Commands::Implements {
858 target,
859 paths,
860 direct,
861 json,
862 compact,
863 } => {
864 let results = walk_and_parse(paths, None);
865 let transitive = !direct;
866 let matches = crate::core::find_implementations(&results, target, transitive);
867 if *json {
868 println!(
869 "{}",
870 crate::core::render_json_implements(target, &matches, transitive, !(*compact),)
871 );
872 } else {
873 println!(
874 "# {} match(es) for '{}' (incl. transitive):",
875 matches.len(),
876 target
877 );
878 for m in matches {
879 let via = if m.via.is_empty() {
880 String::new()
881 } else {
882 format!(" [via {}]", m.via.last().unwrap())
883 };
884 println!("{}:{} {} {}{}", m.path, m.start_line, m.kind, m.name, via);
885 }
886 }
887 }
888 Commands::Prompt => {
889 println!("{}", crate::prompt::AGENT_PROMPT);
890 }
891 Commands::Install {
892 target,
893 all,
894 local,
895 global,
896 always,
897 min_lines,
898 dry_run,
899 force,
900 mcp,
901 skills,
902 } => {
903 let scope = resolve_scope(*local, *global);
904 let opts = installers::InstallOpts {
905 min_lines: *min_lines,
906 always: *always,
907 dry_run: *dry_run,
908 force: *force,
909 };
910 let exit = run_install(target.as_deref(), *all, *mcp, *skills, &scope, &opts);
911 std::process::exit(exit);
912 }
913 Commands::Uninstall {
914 target,
915 all,
916 local,
917 global,
918 dry_run,
919 } => {
920 let scope = resolve_scope(*local, *global);
921 let opts = installers::InstallOpts {
922 dry_run: *dry_run,
923 ..installers::InstallOpts::default()
924 };
925 let exit = run_uninstall(target.as_deref(), *all, &scope, &opts);
926 std::process::exit(exit);
927 }
928 Commands::Status { local, global } => {
929 let scope = resolve_scope(*local, *global);
930 run_status(&scope);
931 }
932 Commands::Hook {
933 protocol,
934 min_lines,
935 always,
936 } => {
937 let exit = hook::run(protocol, *min_lines, *always);
938 std::process::exit(exit);
939 }
940 Commands::Mcp => {
941 let exit = mcp::run();
942 std::process::exit(exit);
943 }
944 Commands::Search {
945 query,
946 path,
947 top_k,
948 alpha,
949 languages,
950 rebuild,
951 json,
952 compact,
953 } => {
954 if *rebuild {
955 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
956 if let Err(e) = crate::search::index::Index::build(path, &cwd) {
957 eprintln!("ast-bro: rebuild failed: {e}");
958 std::process::exit(1);
959 }
960 }
961 let exit = crate::search::cli::run_search(
962 query,
963 path,
964 *top_k,
965 *alpha,
966 languages.clone(),
967 *json,
968 !(*compact),
969 );
970 std::process::exit(exit);
971 }
972 Commands::FindRelated {
973 target,
974 path,
975 file,
976 line,
977 top_k,
978 json,
979 compact,
980 } => {
981 let (file_path, line_num) = match (target, file, line) {
983 (Some(t), _, _) => match parse_file_line(t) {
984 Some(parsed) => parsed,
985 None => {
986 println!(
987 "# note: expected <FILE>:<LINE>, got {t:?} \
988 (or use --file FILE --line N instead)"
989 );
990 return;
991 }
992 },
993 (None, Some(f), Some(l)) => (f.clone(), *l),
994 _ => unreachable!("clap should have rejected this argument combination"),
995 };
996 let exit = crate::search::cli::run_find_related(
997 &file_path,
998 line_num,
999 path,
1000 *top_k,
1001 *json,
1002 !(*compact),
1003 );
1004 std::process::exit(exit);
1005 }
1006 Commands::Surface {
1007 path,
1008 tree,
1009 include_chain,
1010 max_depth,
1011 include_private,
1012 lang,
1013 json,
1014 compact,
1015 } => {
1016 let lang_override = match lang {
1017 Some(s) => match crate::surface::LangOverride::parse(s) {
1018 Some(l) => Some(l),
1019 None => {
1020 println!(
1021 "# note: unknown --lang value '{}'. Expected rust|python|fallback.",
1022 s
1023 );
1024 return;
1025 }
1026 },
1027 None => None,
1028 };
1029 let json_on = *json;
1030 let pretty = !(*compact);
1031 let output = if json_on {
1032 crate::surface::OutputMode::Json { compact: !pretty }
1033 } else if *tree {
1034 crate::surface::OutputMode::Tree
1035 } else {
1036 crate::surface::OutputMode::Flat
1037 };
1038 let opts = crate::surface::SurfaceOptions {
1039 output,
1040 include_private: *include_private,
1041 max_depth: *max_depth,
1042 include_chain: *include_chain,
1043 lang_override,
1044 };
1045 match crate::surface::resolve_surface(path, &opts) {
1046 Ok(entries) => {
1047 let rendered =
1048 crate::surface::render::render(&entries, opts.output, opts.include_chain);
1049 print!("{}", rendered);
1050 }
1051 Err(e) => {
1052 println!("# note: {e}");
1053 }
1054 }
1055 }
1056 Commands::Deps {
1057 file,
1058 depth,
1059 rebuild,
1060 json,
1061 compact,
1062 } => {
1063 let exit = crate::deps::cli::run_deps(file, *depth, *json, !(*compact), *rebuild);
1064 std::process::exit(exit);
1065 }
1066 Commands::ReverseDeps {
1067 file,
1068 depth,
1069 limit,
1070 rebuild,
1071 json,
1072 compact,
1073 } => {
1074 let exit = crate::deps::cli::run_reverse_deps(
1075 file,
1076 *depth,
1077 *limit,
1078 *json,
1079 !(*compact),
1080 *rebuild,
1081 );
1082 std::process::exit(exit);
1083 }
1084 Commands::Cycles {
1085 path,
1086 min_size,
1087 rebuild,
1088 json,
1089 compact,
1090 } => {
1091 let exit = crate::deps::cli::run_cycles(path, *min_size, *json, !(*compact), *rebuild);
1092 std::process::exit(exit);
1093 }
1094 Commands::Graph {
1095 path,
1096 json,
1097 include_external,
1098 rebuild,
1099 compact,
1100 } => {
1101 let exit =
1102 crate::deps::cli::run_graph(path, *json, *include_external, !(*compact), *rebuild);
1103 std::process::exit(exit);
1104 }
1105 Commands::Index {
1106 path,
1107 rebuild,
1108 stats,
1109 json,
1110 compact,
1111 } => {
1112 let exit = crate::search::cli::run_index(path, *rebuild, *stats, *json, !(*compact));
1113 std::process::exit(exit);
1114 }
1115 Commands::Callers {
1116 target,
1117 path,
1118 file,
1119 symbol,
1120 depth,
1121 limit,
1122 include_ambiguous,
1123 rebuild,
1124 json,
1125 compact,
1126 } => {
1127 let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1128 let exit = crate::calls::cli::run_callers(
1129 &resolved,
1130 path,
1131 *depth,
1132 *limit,
1133 *include_ambiguous,
1134 *rebuild,
1135 *json,
1136 !(*compact),
1137 );
1138 std::process::exit(exit);
1139 }
1140 Commands::Callees {
1141 target,
1142 path,
1143 file,
1144 symbol,
1145 depth,
1146 external,
1147 rebuild,
1148 json,
1149 compact,
1150 } => {
1151 let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1152 let exit = crate::calls::cli::run_callees(
1153 &resolved,
1154 path,
1155 *depth,
1156 *external,
1157 *rebuild,
1158 *json,
1159 !(*compact),
1160 );
1161 std::process::exit(exit);
1162 }
1163 Commands::Trace {
1164 from,
1165 to,
1166 path,
1167 depth,
1168 rebuild,
1169 json,
1170 compact,
1171 } => {
1172 let exit =
1173 crate::calls::cli::run_trace(from, to, path, *depth, *rebuild, *json, !(*compact));
1174 std::process::exit(exit);
1175 }
1176 Commands::Run {
1177 pattern,
1178 rewrite,
1179 lang,
1180 paths,
1181 glob,
1182 write,
1183 json,
1184 compact,
1185 } => {
1186 let exit = crate::run::cli::run(
1187 pattern,
1188 rewrite.as_deref(),
1189 lang.as_deref(),
1190 paths,
1191 glob.as_deref(),
1192 *write,
1193 *json,
1194 !(*compact),
1195 );
1196 std::process::exit(exit);
1197 }
1198 }
1199}
1200
1201fn compose_target(target: Option<&str>, file: Option<&str>, symbol: Option<&str>) -> String {
1205 if let Some(t) = target {
1206 return t.to_string();
1207 }
1208 match (file, symbol) {
1209 (Some(f), Some(s)) => format!("{}:{}", f, s),
1210 _ => unreachable!("clap guarantees target XOR (file && symbol)"),
1211 }
1212}
1213
1214fn resolve_scope(local: bool, _global: bool) -> installers::Scope {
1215 if local {
1216 installers::Scope::Local(std::env::current_dir().expect("cwd"))
1217 } else {
1218 installers::Scope::Global
1219 }
1220}
1221
1222fn run_install(
1223 target: Option<&str>,
1224 all: bool,
1225 mcp: bool,
1226 skills: bool,
1227 scope: &installers::Scope,
1228 opts: &installers::InstallOpts,
1229) -> i32 {
1230 let registry = installers::registry();
1231 let chosen: Vec<&Box<dyn installers::Installer>> = if all {
1232 select_all(®istry, scope)
1233 } else if let Some(name) = target {
1234 match registry.iter().find(|i| i.name() == name) {
1235 Some(i) => vec![i],
1236 None => {
1237 eprintln!("unknown --target '{}'. Known: {}", name, names(®istry));
1238 return 2;
1239 }
1240 }
1241 } else {
1242 eprintln!(
1243 "must pass --target <name> or --all. Known: {}",
1244 names(®istry)
1245 );
1246 return 2;
1247 };
1248
1249 let exclusive_mode = mcp || skills;
1250 let mut any_installed = false;
1251 let mut any_failed = false;
1252 for inst in chosen {
1253 let label = inst.name();
1254 if !exclusive_mode {
1255 match inst.install_prompt(scope, opts) {
1256 Ok(c) => {
1257 print_change(label, "prompt", &c);
1258 if !matches!(
1259 c,
1260 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1261 ) {
1262 any_installed = true;
1263 }
1264 }
1265 Err(e) => {
1266 eprintln!("{}: prompt: {}", label, e);
1267 any_failed = true;
1268 }
1269 }
1270 match inst.install_hook(scope, opts) {
1271 Ok(c) => {
1272 print_change(label, "hook", &c);
1273 if !matches!(
1274 c,
1275 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1276 ) {
1277 any_installed = true;
1278 }
1279 }
1280 Err(e) => {
1281 eprintln!("{}: hook: {}", label, e);
1282 any_failed = true;
1283 }
1284 }
1285 match inst.install_subagents(scope, opts) {
1286 Ok(changes) => {
1287 for c in &changes {
1288 print_change(label, "subagent", c);
1289 if !matches!(
1290 c,
1291 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1292 ) {
1293 any_installed = true;
1294 }
1295 }
1296 }
1297 Err(e) => {
1298 eprintln!("{}: subagent: {}", label, e);
1299 any_failed = true;
1300 }
1301 }
1302 } else {
1303 if mcp {
1304 match inst.install_mcp(scope, opts) {
1305 Ok(c) => {
1306 print_change(label, "mcp", &c);
1307 if !matches!(
1308 c,
1309 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1310 ) {
1311 any_installed = true;
1312 }
1313 }
1314 Err(e) => {
1315 eprintln!("{}: mcp: {}", label, e);
1316 any_failed = true;
1317 }
1318 }
1319 }
1320 if skills {
1321 match inst.install_skills(scope, opts) {
1322 Ok(c) => {
1323 print_change(label, "skills", &c);
1324 if !matches!(
1325 c,
1326 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1327 ) {
1328 any_installed = true;
1329 }
1330 }
1331 Err(e) => {
1332 eprintln!("{}: skills: {}", label, e);
1333 any_failed = true;
1334 }
1335 }
1336 }
1337 }
1338 }
1339
1340 if any_failed && any_installed {
1341 1
1342 } else if any_failed {
1343 2
1344 } else {
1345 0
1346 }
1347}
1348
1349fn run_uninstall(
1350 target: Option<&str>,
1351 all: bool,
1352 scope: &installers::Scope,
1353 opts: &installers::InstallOpts,
1354) -> i32 {
1355 let registry = installers::registry();
1356 let chosen: Vec<&Box<dyn installers::Installer>> = if all {
1357 select_all(®istry, scope)
1358 } else if let Some(name) = target {
1359 match registry.iter().find(|i| i.name() == name) {
1360 Some(i) => vec![i],
1361 None => {
1362 eprintln!("unknown --target '{}'. Known: {}", name, names(®istry));
1363 return 2;
1364 }
1365 }
1366 } else {
1367 eprintln!(
1368 "must pass --target <name> or --all. Known: {}",
1369 names(®istry)
1370 );
1371 return 2;
1372 };
1373
1374 let mut any_failed = false;
1375 for inst in chosen {
1376 match inst.uninstall(scope, opts) {
1377 Ok(changes) => {
1378 for c in changes {
1379 print_change(inst.name(), "uninstall", &c);
1380 }
1381 }
1382 Err(e) => {
1383 eprintln!("{}: {}", inst.name(), e);
1384 any_failed = true;
1385 }
1386 }
1387 }
1388 if any_failed {
1389 1
1390 } else {
1391 0
1392 }
1393}
1394
1395fn run_status(scope: &installers::Scope) {
1396 for inst in installers::registry() {
1397 let s = inst.status(scope);
1398 let prompt = if s.prompt_installed {
1399 format!("prompt {}", s.prompt_version.unwrap_or_else(|| "?".into()))
1400 } else {
1401 "prompt -".to_string()
1402 };
1403 let hook = if s.hook_installed {
1404 "hook ✓"
1405 } else {
1406 "hook -"
1407 };
1408 let mcp = if s.mcp_installed { "mcp ✓" } else { "mcp -" };
1409 let skills = if s.skills_installed {
1410 "skills ✓"
1411 } else {
1412 "skills -"
1413 };
1414 println!(
1415 "{:<14} {:<14} {:<8} {:<8} {}",
1416 inst.name(),
1417 prompt,
1418 hook,
1419 mcp,
1420 skills
1421 );
1422 }
1423}
1424
1425fn names(registry: &[Box<dyn installers::Installer>]) -> String {
1426 registry
1427 .iter()
1428 .map(|i| i.name())
1429 .collect::<Vec<_>>()
1430 .join(", ")
1431}
1432
1433#[allow(clippy::borrowed_box)]
1438fn select_all<'a>(
1439 registry: &'a [Box<dyn installers::Installer>],
1440 scope: &installers::Scope,
1441) -> Vec<&'a Box<dyn installers::Installer>> {
1442 let bypass_detection = matches!(scope, installers::Scope::Local(_));
1443 registry
1444 .iter()
1445 .filter(|inst| {
1446 if bypass_detection {
1447 return true;
1448 }
1449 let d = inst.detect(scope);
1450 if !d.present {
1451 println!(
1452 "{:<14} {:<10} skipped (not detected on this system)",
1453 inst.name(),
1454 "detect"
1455 );
1456 }
1457 d.present
1458 })
1459 .collect()
1460}
1461
1462fn print_change(target: &str, phase: &str, change: &installers::Change) {
1463 use installers::Change::*;
1464 match change {
1465 Created(p) => println!("{:<14} {:<10} created {}", target, phase, p.display()),
1466 Updated(p) => println!("{:<14} {:<10} updated {}", target, phase, p.display()),
1467 Removed(p) => println!("{:<14} {:<10} removed {}", target, phase, p.display()),
1468 Skipped { path, reason } => {
1469 println!(
1470 "{:<14} {:<10} skipped {} ({})",
1471 target,
1472 phase,
1473 path.display(),
1474 reason
1475 )
1476 }
1477 NotApplicable => println!("{:<14} {:<10} n/a", target, phase),
1478 }
1479}