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