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 prompt;
12mod installers;
13mod hook;
14mod main_helpers;
15mod mcp;
16mod project_root;
17mod search;
18mod run;
19mod surface;
20
21use crate::core::{DigestOptions, MapOptions, ParseResult};
22
23#[derive(Parser)]
24#[command(name = "ast-outline")]
25#[command(version)]
26#[command(about = "Fast, AST-based structural outline for source files", long_about = None)]
27#[command(after_help = "\
28DISCONTINUED — `ast-outline` has been renamed to `ast-bro`. This 2.1.1 release \
29is the final version under the old name. The `ast-bro` and `sb` commands ship \
30with this release so you can start using them immediately. To switch fully:\n\
31\n\
32 cargo install ast-bro\n\
33 npm install -g @ast-bro/cli\n\
34 pip install ast-bro\n\
35 brew install aeroxy/tap/ast-bro\n\
36\n\
37Repo: https://github.com/aeroxy/ast-bro")]
38struct Cli {
39 #[command(subcommand)]
40 command: Commands,
41}
42
43#[derive(Subcommand)]
44enum Commands {
45 Map {
47 #[arg(num_args = 1..)]
49 paths: Vec<PathBuf>,
50
51 #[arg(long)]
52 no_private: bool,
53 #[arg(long)]
54 no_fields: bool,
55 #[arg(long)]
56 no_docs: bool,
57 #[arg(long)]
58 no_attrs: bool,
59 #[arg(long)]
60 no_lines: bool,
61 #[arg(long)]
62 glob: Option<String>,
63 #[arg(long)]
65 json: bool,
66 #[arg(long)]
68 compact: bool,
69 },
70 Show {
72 path: PathBuf,
73 symbol: String,
74 #[arg(num_args = 0..)]
75 others: Vec<String>,
76 #[arg(long)]
78 json: bool,
79 #[arg(long)]
81 compact: bool,
82 },
83 Digest {
85 #[arg(num_args = 1..)]
86 paths: Vec<PathBuf>,
87
88 #[arg(long)]
89 include_private: bool,
90 #[arg(long)]
91 include_fields: bool,
92 #[arg(long, default_value_t = 50)]
93 max_members: usize,
94 #[arg(long)]
96 json: bool,
97 #[arg(long)]
99 compact: bool,
100 },
101 Implements {
103 target: String,
104 #[arg(num_args = 1..)]
105 paths: Vec<PathBuf>,
106
107 #[arg(short, long)]
108 direct: bool,
109 #[arg(long)]
111 json: bool,
112 #[arg(long)]
114 compact: bool,
115 },
116 Prompt,
118 Install {
120 #[arg(long, conflicts_with = "all")]
121 target: Option<String>,
122 #[arg(long, conflicts_with = "target")]
123 all: bool,
124 #[arg(long)]
125 local: bool,
126 #[arg(long, conflicts_with = "local")]
127 global: bool,
128 #[arg(long)]
129 always: bool,
130 #[arg(long, default_value_t = 200)]
131 min_lines: usize,
132 #[arg(long)]
133 dry_run: bool,
134 #[arg(long)]
135 force: bool,
136 #[arg(long)]
139 mcp: bool,
140 #[arg(long)]
143 skills: bool,
144 },
145 Uninstall {
147 #[arg(long, conflicts_with = "all")]
148 target: Option<String>,
149 #[arg(long, conflicts_with = "target")]
150 all: bool,
151 #[arg(long)]
152 local: bool,
153 #[arg(long, conflicts_with = "local")]
154 global: bool,
155 #[arg(long)]
156 dry_run: bool,
157 },
158 Status {
160 #[arg(long)]
161 local: bool,
162 #[arg(long, conflicts_with = "local")]
163 global: bool,
164 },
165 Hook {
167 #[arg(long)]
168 protocol: String,
169 #[arg(long, default_value_t = 200)]
170 min_lines: usize,
171 #[arg(long)]
172 always: bool,
173 },
174 Mcp,
176 Search {
178 query: String,
180 #[arg(default_value = ".")]
182 path: PathBuf,
183 #[arg(short = 'k', long = "top-k", default_value_t = 10)]
185 top_k: usize,
186 #[arg(long)]
188 alpha: Option<f32>,
189 #[arg(long = "lang")]
191 languages: Vec<String>,
192 #[arg(long)]
194 rebuild: bool,
195 #[arg(long)]
197 json: bool,
198 #[arg(long)]
200 compact: bool,
201 },
202 FindRelated {
208 #[arg(required_unless_present_all = ["file", "line"], conflicts_with_all = ["file", "line"])]
211 target: Option<String>,
212 #[arg(default_value = ".")]
214 path: PathBuf,
215 #[arg(long, requires = "line")]
217 file: Option<String>,
218 #[arg(long, requires = "file")]
220 line: Option<u32>,
221 #[arg(short = 'k', long = "top-k", default_value_t = 10)]
222 top_k: usize,
223 #[arg(long)]
224 json: bool,
225 #[arg(long)]
226 compact: bool,
227 },
228 Surface {
230 #[arg(default_value = ".")]
232 path: PathBuf,
233 #[arg(long)]
235 tree: bool,
236 #[arg(long)]
238 include_chain: bool,
239 #[arg(long, default_value_t = 16)]
241 max_depth: usize,
242 #[arg(long)]
244 include_private: bool,
245 #[arg(long)]
247 lang: Option<String>,
248 #[arg(long)]
250 json: bool,
251 #[arg(long)]
253 compact: bool,
254 },
255 Deps {
257 file: PathBuf,
258 #[arg(long, default_value_t = 3)]
259 depth: usize,
260 #[arg(long)]
262 rebuild: bool,
263 #[arg(long)]
264 json: bool,
265 #[arg(long)]
266 compact: bool,
267 },
268 ReverseDeps {
270 file: PathBuf,
271 #[arg(long, default_value_t = 3)]
272 depth: usize,
273 #[arg(long, default_value_t = 200)]
274 limit: usize,
275 #[arg(long)]
276 rebuild: bool,
277 #[arg(long)]
278 json: bool,
279 #[arg(long)]
280 compact: bool,
281 },
282 Cycles {
284 #[arg(default_value = ".")]
285 path: PathBuf,
286 #[arg(long, default_value_t = 2)]
287 min_size: usize,
288 #[arg(long)]
289 rebuild: bool,
290 #[arg(long)]
291 json: bool,
292 #[arg(long)]
293 compact: bool,
294 },
295 Graph {
297 #[arg(default_value = ".")]
298 path: PathBuf,
299 #[arg(long)]
300 json: bool,
301 #[arg(long)]
302 include_external: bool,
303 #[arg(long)]
304 rebuild: bool,
305 #[arg(long)]
306 compact: bool,
307 },
308 Callers {
315 #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
317 target: Option<String>,
318 #[arg(default_value = ".")]
320 path: PathBuf,
321 #[arg(long, requires = "symbol")]
323 file: Option<String>,
324 #[arg(long, requires = "file")]
326 symbol: Option<String>,
327 #[arg(long, default_value_t = 1)]
329 depth: usize,
330 #[arg(long, default_value_t = 200)]
332 limit: usize,
333 #[arg(long)]
335 include_ambiguous: bool,
336 #[arg(long)]
338 rebuild: bool,
339 #[arg(long)]
340 json: bool,
341 #[arg(long)]
342 compact: bool,
343 },
344 Callees {
349 #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
350 target: Option<String>,
351 #[arg(default_value = ".")]
352 path: PathBuf,
353 #[arg(long, requires = "symbol")]
354 file: Option<String>,
355 #[arg(long, requires = "file")]
356 symbol: Option<String>,
357 #[arg(long, default_value_t = 1)]
358 depth: usize,
359 #[arg(long)]
361 external: bool,
362 #[arg(long)]
363 rebuild: bool,
364 #[arg(long)]
365 json: bool,
366 #[arg(long)]
367 compact: bool,
368 },
369 Index {
371 #[arg(default_value = ".")]
373 path: PathBuf,
374 #[arg(long)]
376 rebuild: bool,
377 #[arg(long)]
379 stats: bool,
380 #[arg(long)]
382 json: bool,
383 #[arg(long)]
385 compact: bool,
386 },
387 Run {
389 #[arg(short, long)]
391 pattern: String,
392
393 #[arg(short, long)]
395 rewrite: Option<String>,
396
397 #[arg(short, long)]
399 lang: Option<String>,
400
401 paths: Vec<PathBuf>,
403
404 #[arg(long)]
406 glob: Option<String>,
407
408 #[arg(long)]
410 write: bool,
411
412 #[arg(long)]
414 json: bool,
415
416 #[arg(long)]
418 compact: bool,
419 },
420}
421
422pub(crate) fn parse_file(path: &Path) -> Option<ParseResult> {
423 crate::main_helpers::parse_file_for_hook(path)
424}
425
426fn parse_file_line(s: &str) -> Option<(String, u32)> {
429 let (file, line) = s.rsplit_once(':')?;
430 if file.is_empty() {
431 return None;
432 }
433 Some((file.to_string(), line.parse().ok()?))
434}
435
436fn build_filtered_walker(paths: &[PathBuf], glob_str: Option<&str>) -> Option<(WalkBuilder, Vec<PathBuf>)> {
439 if paths.is_empty() {
440 return None;
441 }
442
443 let existing: Vec<PathBuf> = paths
444 .iter()
445 .filter(|p| {
446 if p.exists() {
447 true
448 } else {
449 println!("# note: path not found: {}", p.display());
450 false
451 }
452 })
453 .cloned()
454 .collect();
455 if existing.is_empty() {
456 return None;
457 }
458
459 let mut builder = WalkBuilder::new(&existing[0]);
460 for p in existing.iter().skip(1) {
461 builder.add(p);
462 }
463
464 builder.hidden(false);
465 file_filter::add_filters(&mut builder, &existing[0]);
466
467 if let Some(g) = glob_str {
468 if let Ok(override_builder) = ignore::overrides::OverrideBuilder::new("").add(g) {
469 if let Ok(over) = override_builder.build() {
470 builder.overrides(over);
471 }
472 }
473 }
474
475 Some((builder, existing))
476}
477
478pub(crate) fn walk_paths(paths: &[PathBuf], glob_str: Option<&str>) -> Vec<PathBuf> {
479 let (tx, rx) = std::sync::mpsc::channel();
480 let Some((builder, existing)) = build_filtered_walker(paths, glob_str) else {
481 return Vec::new();
482 };
483 let walker = builder.build_parallel();
484 let root = existing[0].clone();
485
486 walker.run(|| {
487 let tx = tx.clone();
488 let root = root.clone();
489 Box::new(move |result| {
490 if let Ok(entry) = result {
491 if entry.file_type().is_some_and(|ft| ft.is_file())
492 && !file_filter::should_skip_path(entry.path(), &root)
493 {
494 let _ = tx.send(entry.path().to_path_buf());
495 }
496 }
497 ignore::WalkState::Continue
498 })
499 });
500
501 drop(tx);
502 let mut results: Vec<_> = rx.into_iter().collect();
503 results.sort();
504 results
505}
506
507pub(crate) fn walk_and_parse(paths: &[PathBuf], glob_str: Option<&str>) -> Vec<ParseResult> {
508 let (tx, rx) = std::sync::mpsc::channel();
509 let Some((builder, existing)) = build_filtered_walker(paths, glob_str) else {
510 return Vec::new();
511 };
512 let walker = builder.build_parallel();
513 let root = existing[0].clone();
514
515 walker.run(|| {
516 let tx = tx.clone();
517 let root = root.clone();
518 Box::new(move |result| {
519 if let Ok(entry) = result {
520 if entry.file_type().is_some_and(|ft| ft.is_file())
521 && !file_filter::should_skip_path(entry.path(), &root)
522 {
523 if let Some(parsed) = parse_file(entry.path()) {
524 let _ = tx.send(parsed);
525 }
526 }
527 }
528 ignore::WalkState::Continue
529 })
530 });
531
532 drop(tx);
533 let mut results: Vec<_> = rx.into_iter().collect();
534 results.sort_by(|a, b| a.path.cmp(&b.path));
535 results
536}
537
538pub fn run() {
539 use clap::CommandFactory;
540 use clap::error::ErrorKind;
541
542 let cli = match Cli::try_parse() {
548 Ok(c) => c,
549 Err(e) => match e.kind() {
550 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
551 e.exit();
552 }
553 _ => {
554 let mut cmd = Cli::command();
555 let _ = cmd.print_help();
556 println!();
557 println!("# note: could not parse args ({}). Showing help instead.", e.kind());
558 std::process::exit(0);
559 }
560 },
561 };
562
563 match &cli.command {
564 Commands::Map {
565 paths,
566 no_private,
567 no_fields,
568 no_docs,
569 no_attrs,
570 no_lines,
571 glob,
572 json,
573 compact,
574 } => {
575 let results = walk_and_parse(paths, glob.as_deref());
576 let opts = MapOptions {
577 include_private: !(*no_private),
578 include_fields: !(*no_fields),
579 include_docs: !(*no_docs),
580 include_attributes: !(*no_attrs),
581 include_line_numbers: !(*no_lines),
582 max_doc_lines: 6,
583 max_members: None,
584 };
585 let json_on = *json;
586 let pretty = !(*compact);
587 if json_on {
588 println!("{}", crate::core::render_json_map(&results, &opts, pretty));
589 } else {
590 for res in results {
591 println!("{}", crate::core::render_map(&res, &opts));
592 println!();
593 }
594 }
595 }
596 Commands::Show {
597 path,
598 symbol,
599 others,
600 json,
601 compact,
602 } => {
603 if !path.exists() {
604 println!("# note: path not found: {}", path.display());
605 } else if let Some(res) = parse_file(path) {
606 let mut symbols = vec![symbol.as_str()];
607 symbols.extend(others.iter().map(|s| s.as_str()));
608 if *json {
609 let mut seen = std::collections::HashSet::new();
610 let mut all_matches = Vec::new();
611 for sym in &symbols {
612 for m in crate::core::find_symbols(&res, sym) {
613 let key = (m.start_line, m.end_line, m.qualified_name.clone());
614 if seen.insert(key) {
615 all_matches.push(m);
616 }
617 }
618 }
619 println!(
620 "{}",
621 crate::core::render_json_show(&res, &all_matches, !(*compact))
622 );
623 if all_matches.is_empty() {
624 println!("# note: no symbol matching {:?} in {}", symbol, path.display());
627 }
628 } else {
629 let mut any_match = false;
630 for sym in &symbols {
631 let matches = crate::core::find_symbols(&res, sym);
632 for m in matches {
633 any_match = true;
634 println!(
635 "# {}:{}-{} {} ({})",
636 res.path.display(),
637 m.start_line,
638 m.end_line,
639 m.qualified_name,
640 m.kind
641 );
642 if !m.ancestor_signatures.is_empty() {
643 println!("# in: {}", m.ancestor_signatures.join(" → "));
644 }
645 println!("{}", m.source);
646 }
647 }
648 if !any_match {
649 let joined = symbols.join(", ");
650 println!(
651 "# note: no symbol matching '{}' in {}",
652 joined,
653 path.display()
654 );
655 }
656 }
657 } else {
658 println!(
659 "# note: unsupported file type for `show`: {}",
660 path.display()
661 );
662 }
663 }
664 Commands::Digest {
665 paths,
666 include_private,
667 include_fields,
668 max_members,
669 json,
670 compact,
671 } => {
672 let results = walk_and_parse(paths, None);
673 if *json {
674 let opts = MapOptions {
675 include_private: *include_private,
676 include_fields: *include_fields,
677 include_docs: true,
678 include_attributes: true,
679 include_line_numbers: true,
680 max_doc_lines: 6,
681 max_members: Some(*max_members),
682 };
683 println!(
684 "{}",
685 crate::core::render_json_map(&results, &opts, !(*compact))
686 );
687 } else {
688 let opts = DigestOptions {
689 include_private: *include_private,
690 include_fields: *include_fields,
691 max_members_per_type: *max_members,
692 max_heading_depth: 3,
693 };
694 let root = if paths.len() == 1 && paths[0].is_dir() {
695 Some(paths[0].as_path())
696 } else {
697 None
698 };
699 println!("{}", crate::core::render_digest(&results, &opts, root));
700 }
701 }
702 Commands::Implements {
703 target,
704 paths,
705 direct,
706 json,
707 compact,
708 } => {
709 let results = walk_and_parse(paths, None);
710 let transitive = !direct;
711 let matches = crate::core::find_implementations(&results, target, transitive);
712 if *json {
713 println!(
714 "{}",
715 crate::core::render_json_implements(
716 target,
717 &matches,
718 transitive,
719 !(*compact),
720 )
721 );
722 } else {
723 println!(
724 "# {} match(es) for '{}' (incl. transitive):",
725 matches.len(),
726 target
727 );
728 for m in matches {
729 let via = if m.via.is_empty() {
730 String::new()
731 } else {
732 format!(" [via {}]", m.via.last().unwrap())
733 };
734 println!("{}:{} {} {}{}", m.path, m.start_line, m.kind, m.name, via);
735 }
736 }
737 }
738 Commands::Prompt => {
739 println!("{}", crate::prompt::AGENT_PROMPT);
740 }
741 Commands::Install {
742 target,
743 all,
744 local,
745 global,
746 always,
747 min_lines,
748 dry_run,
749 force,
750 mcp,
751 skills,
752 } => {
753 let scope = resolve_scope(*local, *global);
754 let opts = installers::InstallOpts {
755 min_lines: *min_lines,
756 always: *always,
757 dry_run: *dry_run,
758 force: *force,
759 };
760 let exit = run_install(target.as_deref(), *all, *mcp, *skills, &scope, &opts);
761 std::process::exit(exit);
762 }
763 Commands::Uninstall {
764 target,
765 all,
766 local,
767 global,
768 dry_run,
769 } => {
770 let scope = resolve_scope(*local, *global);
771 let opts = installers::InstallOpts {
772 dry_run: *dry_run,
773 ..installers::InstallOpts::default()
774 };
775 let exit = run_uninstall(target.as_deref(), *all, &scope, &opts);
776 std::process::exit(exit);
777 }
778 Commands::Status { local, global } => {
779 let scope = resolve_scope(*local, *global);
780 run_status(&scope);
781 }
782 Commands::Hook {
783 protocol,
784 min_lines,
785 always,
786 } => {
787 let exit = hook::run(protocol, *min_lines, *always);
788 std::process::exit(exit);
789 }
790 Commands::Mcp => {
791 let exit = mcp::run();
792 std::process::exit(exit);
793 }
794 Commands::Search {
795 query,
796 path,
797 top_k,
798 alpha,
799 languages,
800 rebuild,
801 json,
802 compact,
803 } => {
804 if *rebuild {
805 let cwd = std::env::current_dir()
806 .unwrap_or_else(|_| std::path::PathBuf::from("."));
807 if let Err(e) = crate::search::index::Index::build(path, &cwd) {
808 eprintln!("ast-bro: rebuild failed: {e}");
809 std::process::exit(1);
810 }
811 }
812 let exit = crate::search::cli::run_search(
813 query,
814 path,
815 *top_k,
816 *alpha,
817 languages.clone(),
818 *json,
819 !(*compact),
820 );
821 std::process::exit(exit);
822 }
823 Commands::FindRelated {
824 target,
825 path,
826 file,
827 line,
828 top_k,
829 json,
830 compact,
831 } => {
832 let (file_path, line_num) = match (target, file, line) {
834 (Some(t), _, _) => match parse_file_line(t) {
835 Some(parsed) => parsed,
836 None => {
837 println!(
838 "# note: expected <FILE>:<LINE>, got {t:?} \
839 (or use --file FILE --line N instead)"
840 );
841 return;
842 }
843 },
844 (None, Some(f), Some(l)) => (f.clone(), *l),
845 _ => unreachable!("clap should have rejected this argument combination"),
846 };
847 let exit = crate::search::cli::run_find_related(
848 &file_path,
849 line_num,
850 path,
851 *top_k,
852 *json,
853 !(*compact),
854 );
855 std::process::exit(exit);
856 }
857 Commands::Surface {
858 path,
859 tree,
860 include_chain,
861 max_depth,
862 include_private,
863 lang,
864 json,
865 compact,
866 } => {
867 let lang_override = match lang {
868 Some(s) => match crate::surface::LangOverride::parse(s) {
869 Some(l) => Some(l),
870 None => {
871 println!("# note: unknown --lang value '{}'. Expected rust|python|fallback.", s);
872 return;
873 }
874 },
875 None => None,
876 };
877 let json_on = *json;
878 let pretty = !(*compact);
879 let output = if json_on {
880 crate::surface::OutputMode::Json { compact: !pretty }
881 } else if *tree {
882 crate::surface::OutputMode::Tree
883 } else {
884 crate::surface::OutputMode::Flat
885 };
886 let opts = crate::surface::SurfaceOptions {
887 output,
888 include_private: *include_private,
889 max_depth: *max_depth,
890 include_chain: *include_chain,
891 lang_override,
892 };
893 match crate::surface::resolve_surface(path, &opts) {
894 Ok(entries) => {
895 let rendered =
896 crate::surface::render::render(&entries, opts.output, opts.include_chain);
897 print!("{}", rendered);
898 }
899 Err(e) => {
900 println!("# note: {e}");
901 }
902 }
903 }
904 Commands::Deps {
905 file,
906 depth,
907 rebuild,
908 json,
909 compact,
910 } => {
911 let exit = crate::deps::cli::run_deps(
912 file,
913 *depth,
914 *json,
915 !(*compact),
916 *rebuild,
917 );
918 std::process::exit(exit);
919 }
920 Commands::ReverseDeps {
921 file,
922 depth,
923 limit,
924 rebuild,
925 json,
926 compact,
927 } => {
928 let exit = crate::deps::cli::run_reverse_deps(
929 file,
930 *depth,
931 *limit,
932 *json,
933 !(*compact),
934 *rebuild,
935 );
936 std::process::exit(exit);
937 }
938 Commands::Cycles {
939 path,
940 min_size,
941 rebuild,
942 json,
943 compact,
944 } => {
945 let exit = crate::deps::cli::run_cycles(
946 path,
947 *min_size,
948 *json,
949 !(*compact),
950 *rebuild,
951 );
952 std::process::exit(exit);
953 }
954 Commands::Graph {
955 path,
956 json,
957 include_external,
958 rebuild,
959 compact,
960 } => {
961 let exit = crate::deps::cli::run_graph(
962 path,
963 *json,
964 *include_external,
965 !(*compact),
966 *rebuild,
967 );
968 std::process::exit(exit);
969 }
970 Commands::Index {
971 path,
972 rebuild,
973 stats,
974 json,
975 compact,
976 } => {
977 let exit = crate::search::cli::run_index(
978 path,
979 *rebuild,
980 *stats,
981 *json,
982 !(*compact),
983 );
984 std::process::exit(exit);
985 }
986 Commands::Callers {
987 target,
988 path,
989 file,
990 symbol,
991 depth,
992 limit,
993 include_ambiguous,
994 rebuild,
995 json,
996 compact,
997 } => {
998 let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
999 let exit = crate::calls::cli::run_callers(
1000 &resolved,
1001 path,
1002 *depth,
1003 *limit,
1004 *include_ambiguous,
1005 *rebuild,
1006 *json,
1007 !(*compact),
1008 );
1009 std::process::exit(exit);
1010 }
1011 Commands::Callees {
1012 target,
1013 path,
1014 file,
1015 symbol,
1016 depth,
1017 external,
1018 rebuild,
1019 json,
1020 compact,
1021 } => {
1022 let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1023 let exit = crate::calls::cli::run_callees(
1024 &resolved,
1025 path,
1026 *depth,
1027 *external,
1028 *rebuild,
1029 *json,
1030 !(*compact),
1031 );
1032 std::process::exit(exit);
1033 }
1034 Commands::Run {
1035 pattern,
1036 rewrite,
1037 lang,
1038 paths,
1039 glob,
1040 write,
1041 json,
1042 compact,
1043 } => {
1044 let exit = crate::run::cli::run(
1045 pattern,
1046 rewrite.as_deref(),
1047 lang.as_deref(),
1048 paths,
1049 glob.as_deref(),
1050 *write,
1051 *json,
1052 !(*compact),
1053 );
1054 std::process::exit(exit);
1055 }
1056 }
1057}
1058
1059fn compose_target(target: Option<&str>, file: Option<&str>, symbol: Option<&str>) -> String {
1063 if let Some(t) = target {
1064 return t.to_string();
1065 }
1066 match (file, symbol) {
1067 (Some(f), Some(s)) => format!("{}:{}", f, s),
1068 _ => unreachable!("clap guarantees target XOR (file && symbol)"),
1069 }
1070}
1071
1072fn resolve_scope(local: bool, _global: bool) -> installers::Scope {
1073 if local {
1074 installers::Scope::Local(std::env::current_dir().expect("cwd"))
1075 } else {
1076 installers::Scope::Global
1077 }
1078}
1079
1080fn run_install(
1081 target: Option<&str>,
1082 all: bool,
1083 mcp: bool,
1084 skills: bool,
1085 scope: &installers::Scope,
1086 opts: &installers::InstallOpts,
1087) -> i32 {
1088 let registry = installers::registry();
1089 let chosen: Vec<&Box<dyn installers::Installer>> = if all {
1090 select_all(®istry, scope)
1091 } else if let Some(name) = target {
1092 match registry.iter().find(|i| i.name() == name) {
1093 Some(i) => vec![i],
1094 None => {
1095 eprintln!(
1096 "unknown --target '{}'. Known: {}",
1097 name,
1098 names(®istry)
1099 );
1100 return 2;
1101 }
1102 }
1103 } else {
1104 eprintln!(
1105 "must pass --target <name> or --all. Known: {}",
1106 names(®istry)
1107 );
1108 return 2;
1109 };
1110
1111 let exclusive_mode = mcp || skills;
1112 let mut any_installed = false;
1113 let mut any_failed = false;
1114 for inst in chosen {
1115 let label = inst.name();
1116 if !exclusive_mode {
1117 match inst.install_prompt(scope, opts) {
1118 Ok(c) => {
1119 print_change(label, "prompt", &c);
1120 if !matches!(
1121 c,
1122 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1123 ) {
1124 any_installed = true;
1125 }
1126 }
1127 Err(e) => {
1128 eprintln!("{}: prompt: {}", label, e);
1129 any_failed = true;
1130 }
1131 }
1132 match inst.install_hook(scope, opts) {
1133 Ok(c) => {
1134 print_change(label, "hook", &c);
1135 if !matches!(
1136 c,
1137 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1138 ) {
1139 any_installed = true;
1140 }
1141 }
1142 Err(e) => {
1143 eprintln!("{}: hook: {}", label, e);
1144 any_failed = true;
1145 }
1146 }
1147 match inst.install_subagents(scope, opts) {
1148 Ok(changes) => {
1149 for c in &changes {
1150 print_change(label, "subagent", c);
1151 if !matches!(
1152 c,
1153 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1154 ) {
1155 any_installed = true;
1156 }
1157 }
1158 }
1159 Err(e) => {
1160 eprintln!("{}: subagent: {}", label, e);
1161 any_failed = true;
1162 }
1163 }
1164 } else {
1165 if mcp {
1166 match inst.install_mcp(scope, opts) {
1167 Ok(c) => {
1168 print_change(label, "mcp", &c);
1169 if !matches!(
1170 c,
1171 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1172 ) {
1173 any_installed = true;
1174 }
1175 }
1176 Err(e) => {
1177 eprintln!("{}: mcp: {}", label, e);
1178 any_failed = true;
1179 }
1180 }
1181 }
1182 if skills {
1183 match inst.install_skills(scope, opts) {
1184 Ok(c) => {
1185 print_change(label, "skills", &c);
1186 if !matches!(
1187 c,
1188 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1189 ) {
1190 any_installed = true;
1191 }
1192 }
1193 Err(e) => {
1194 eprintln!("{}: skills: {}", label, e);
1195 any_failed = true;
1196 }
1197 }
1198 }
1199 }
1200 }
1201
1202 if any_failed && any_installed {
1203 1
1204 } else if any_failed {
1205 2
1206 } else {
1207 0
1208 }
1209}
1210
1211fn run_uninstall(
1212 target: Option<&str>,
1213 all: bool,
1214 scope: &installers::Scope,
1215 opts: &installers::InstallOpts,
1216) -> i32 {
1217 let registry = installers::registry();
1218 let chosen: Vec<&Box<dyn installers::Installer>> = if all {
1219 select_all(®istry, scope)
1220 } else if let Some(name) = target {
1221 match registry.iter().find(|i| i.name() == name) {
1222 Some(i) => vec![i],
1223 None => {
1224 eprintln!(
1225 "unknown --target '{}'. Known: {}",
1226 name,
1227 names(®istry)
1228 );
1229 return 2;
1230 }
1231 }
1232 } else {
1233 eprintln!(
1234 "must pass --target <name> or --all. Known: {}",
1235 names(®istry)
1236 );
1237 return 2;
1238 };
1239
1240 let mut any_failed = false;
1241 for inst in chosen {
1242 match inst.uninstall(scope, opts) {
1243 Ok(changes) => {
1244 for c in changes {
1245 print_change(inst.name(), "uninstall", &c);
1246 }
1247 }
1248 Err(e) => {
1249 eprintln!("{}: {}", inst.name(), e);
1250 any_failed = true;
1251 }
1252 }
1253 }
1254 if any_failed {
1255 1
1256 } else {
1257 0
1258 }
1259}
1260
1261fn run_status(scope: &installers::Scope) {
1262 for inst in installers::registry() {
1263 let s = inst.status(scope);
1264 let prompt = if s.prompt_installed {
1265 format!("prompt {}", s.prompt_version.unwrap_or_else(|| "?".into()))
1266 } else {
1267 "prompt -".to_string()
1268 };
1269 let hook = if s.hook_installed { "hook ✓" } else { "hook -" };
1270 let mcp = if s.mcp_installed { "mcp ✓" } else { "mcp -" };
1271 let skills = if s.skills_installed { "skills ✓" } else { "skills -" };
1272 println!(
1273 "{:<14} {:<14} {:<8} {:<8} {}",
1274 inst.name(),
1275 prompt,
1276 hook,
1277 mcp,
1278 skills
1279 );
1280 }
1281}
1282
1283fn names(registry: &[Box<dyn installers::Installer>]) -> String {
1284 registry
1285 .iter()
1286 .map(|i| i.name())
1287 .collect::<Vec<_>>()
1288 .join(", ")
1289}
1290
1291#[allow(clippy::borrowed_box)]
1296fn select_all<'a>(
1297 registry: &'a [Box<dyn installers::Installer>],
1298 scope: &installers::Scope,
1299) -> Vec<&'a Box<dyn installers::Installer>> {
1300 let bypass_detection = matches!(scope, installers::Scope::Local(_));
1301 registry
1302 .iter()
1303 .filter(|inst| {
1304 if bypass_detection {
1305 return true;
1306 }
1307 let d = inst.detect(scope);
1308 if !d.present {
1309 println!("{:<14} {:<10} skipped (not detected on this system)", inst.name(), "detect");
1310 }
1311 d.present
1312 })
1313 .collect()
1314}
1315
1316fn print_change(target: &str, phase: &str, change: &installers::Change) {
1317 use installers::Change::*;
1318 match change {
1319 Created(p) => println!("{:<14} {:<10} created {}", target, phase, p.display()),
1320 Updated(p) => println!("{:<14} {:<10} updated {}", target, phase, p.display()),
1321 Removed(p) => println!("{:<14} {:<10} removed {}", target, phase, p.display()),
1322 Skipped { path, reason } => {
1323 println!(
1324 "{:<14} {:<10} skipped {} ({})",
1325 target,
1326 phase,
1327 path.display(),
1328 reason
1329 )
1330 }
1331 NotApplicable => println!("{:<14} {:<10} n/a", target, phase),
1332 }
1333}