Skip to main content

ast_bro/
lib.rs

1use clap::{Parser, Subcommand};
2use ignore::WalkBuilder;
3use std::path::{Path, PathBuf};
4
5mod adapters;
6mod calls;
7mod context;
8mod core;
9mod deps;
10mod file_filter;
11mod graph_cache;
12mod hook;
13mod impact;
14mod installers;
15mod main_helpers;
16mod mcp;
17mod path_glob;
18mod project_root;
19mod prompt;
20mod run;
21mod search;
22mod squeeze;
23mod surface;
24
25use crate::core::{DigestOptions, MapOptions, ParseResult};
26
27#[derive(Parser)]
28#[command(name = "ast-bro")]
29#[command(version)]
30#[command(about = "Fast, AST-based structural outline for source files", long_about = None)]
31struct Cli {
32    #[command(subcommand)]
33    command: Commands,
34}
35
36#[derive(Subcommand)]
37enum Commands {
38    /// Map files or directories — signatures with line ranges, no method bodies.
39    Map {
40        /// Files or directories to map.
41        #[arg(num_args = 1..)]
42        paths: Vec<PathBuf>,
43
44        #[arg(long)]
45        no_private: bool,
46        #[arg(long)]
47        no_fields: bool,
48        #[arg(long)]
49        no_docs: bool,
50        #[arg(long)]
51        no_attrs: bool,
52        #[arg(long)]
53        no_lines: bool,
54        #[arg(long)]
55        glob: Option<String>,
56        /// Emit output as JSON instead of text
57        #[arg(long)]
58        json: bool,
59        /// With --json: emit compact (single-line) JSON instead of pretty-printed
60        #[arg(long)]
61        compact: bool,
62    },
63    /// Extract source of a symbol
64    Show {
65        path: PathBuf,
66        symbol: String,
67        #[arg(num_args = 0..)]
68        others: Vec<String>,
69        /// Emit output as JSON instead of text
70        #[arg(long)]
71        json: bool,
72        /// With --json: emit compact (single-line) JSON
73        #[arg(long)]
74        compact: bool,
75    },
76    /// Compress repetitive log/text into a smaller, reversible form with a legend.
77    ///
78    /// For **logs and text**, not code — for code use `map` / `digest` / `show`.
79    /// Shrinks repeated lines, tags, timestamps and token sequences; prints a legend
80    /// so the original is recoverable. Falls back to the raw input when squeezing
81    /// would make it larger.
82    Squeeze {
83        /// Path to the log/text file to read.
84        path: PathBuf,
85        /// Optional 1-indexed inclusive line range: `N`, `A:B`, `A:`, or `:B`.
86        #[arg(value_parser = parse_line_range)]
87        range: Option<LineRange>,
88        /// Skip compression — emit the raw text with a header (for diffing/inspecting).
89        #[arg(long)]
90        raw: bool,
91        /// Emit output as JSON instead of text
92        #[arg(long)]
93        json: bool,
94        /// With --json: emit compact (single-line) JSON
95        #[arg(long)]
96        compact: bool,
97    },
98    /// One-page module map
99    Digest {
100        #[arg(num_args = 1..)]
101        paths: Vec<PathBuf>,
102
103        #[arg(long)]
104        include_private: bool,
105        #[arg(long)]
106        include_fields: bool,
107        #[arg(long, default_value_t = 50)]
108        max_members: usize,
109        /// Emit output as JSON instead of text
110        #[arg(long)]
111        json: bool,
112        /// With --json: emit compact (single-line) JSON
113        #[arg(long)]
114        compact: bool,
115    },
116    /// Find subclasses / implementations
117    Implements {
118        target: String,
119        #[arg(num_args = 1..)]
120        paths: Vec<PathBuf>,
121
122        #[arg(short, long)]
123        direct: bool,
124        /// Emit output as JSON instead of text
125        #[arg(long)]
126        json: bool,
127        /// With --json: emit compact (single-line) JSON
128        #[arg(long)]
129        compact: bool,
130    },
131    /// Print the agent prompt snippet
132    Prompt,
133    /// Install ast-bro into a coding-agent CLI
134    Install {
135        #[arg(long, conflicts_with = "all")]
136        target: Option<String>,
137        #[arg(long, conflicts_with = "target")]
138        all: bool,
139        #[arg(long)]
140        local: bool,
141        #[arg(long, conflicts_with = "local")]
142        global: bool,
143        #[arg(long)]
144        always: bool,
145        #[arg(long, default_value_t = 200)]
146        min_lines: usize,
147        #[arg(long)]
148        dry_run: bool,
149        #[arg(long)]
150        force: bool,
151        /// Install ast-bro as an MCP server entry instead of the CLAUDE.md prompt.
152        /// Combine with `--skills` to install both.
153        #[arg(long)]
154        mcp: bool,
155        /// Install ast-bro as a Claude Code skill instead of the CLAUDE.md prompt.
156        /// Combine with `--mcp` to install both.
157        #[arg(long)]
158        skills: bool,
159    },
160    /// Remove ast-bro from a coding-agent CLI
161    Uninstall {
162        #[arg(long, conflicts_with = "all")]
163        target: Option<String>,
164        #[arg(long, conflicts_with = "target")]
165        all: bool,
166        #[arg(long)]
167        local: bool,
168        #[arg(long, conflicts_with = "local")]
169        global: bool,
170        #[arg(long)]
171        dry_run: bool,
172    },
173    /// Report what's installed where
174    Status {
175        #[arg(long)]
176        local: bool,
177        #[arg(long, conflicts_with = "local")]
178        global: bool,
179    },
180    /// Internal: read a tool-call event from stdin and respond
181    Hook {
182        #[arg(long)]
183        protocol: String,
184        #[arg(long, default_value_t = 200)]
185        min_lines: usize,
186        #[arg(long)]
187        always: bool,
188    },
189    /// Run as an MCP (Model Context Protocol) server over stdio
190    Mcp,
191    /// Hybrid BM25 + dense semantic search over the repo
192    Search {
193        /// Search query (free-form text or symbol name)
194        query: String,
195        /// Repository root to search in (default: ".")
196        #[arg(default_value = ".")]
197        path: PathBuf,
198        /// Number of results to return
199        #[arg(short = 'k', long = "top-k", default_value_t = 10)]
200        top_k: usize,
201        /// Override auto alpha (semantic vs. BM25 weight, 0.0–1.0)
202        #[arg(long)]
203        alpha: Option<f32>,
204        /// Filter by language (repeatable, e.g. `--lang rust --lang python`)
205        #[arg(long = "lang")]
206        languages: Vec<String>,
207        /// Force a full rebuild of the index before searching
208        #[arg(long)]
209        rebuild: bool,
210        /// Emit output as JSON instead of text
211        #[arg(long)]
212        json: bool,
213        /// With --json: emit compact (single-line) JSON
214        #[arg(long)]
215        compact: bool,
216    },
217    /// Find chunks semantically similar to a given file:line
218    ///
219    /// Pass the source location either as a positional `<FILE>:<LINE>`
220    /// (matches grep / search-result output you can paste back) or via
221    /// `--file <FILE> --line <LINE>` for scripting use.
222    FindRelated {
223        /// Source location as `<FILE>:<LINE>`. Optional when `--file` and
224        /// `--line` are passed together.
225        #[arg(required_unless_present_all = ["file", "line"], conflicts_with_all = ["file", "line"])]
226        target: Option<String>,
227        /// Repository root containing the index (default: ".")
228        #[arg(default_value = ".")]
229        path: PathBuf,
230        /// Alternative to the positional `<FILE>:<LINE>` form
231        #[arg(long, requires = "line")]
232        file: Option<String>,
233        /// 1-indexed line number when using `--file`
234        #[arg(long, requires = "file")]
235        line: Option<u32>,
236        #[arg(short = 'k', long = "top-k", default_value_t = 10)]
237        top_k: usize,
238        #[arg(long)]
239        json: bool,
240        #[arg(long)]
241        compact: bool,
242    },
243    /// True public API surface — resolves `pub use` / `__all__` re-exports.
244    Surface {
245        /// Crate root file, package init, or directory to auto-detect.
246        #[arg(default_value = ".")]
247        path: PathBuf,
248        /// Render as a hierarchical tree grouped by module.
249        #[arg(long)]
250        tree: bool,
251        /// Append the via-chain on each entry (text mode only).
252        #[arg(long)]
253        include_chain: bool,
254        /// Recursion guard for re-export chains.
255        #[arg(long, default_value_t = 16)]
256        max_depth: usize,
257        /// Include private items (only meaningful for fallback languages).
258        #[arg(long)]
259        include_private: bool,
260        /// Force a specific resolver: `rust`, `python`, or `fallback`.
261        #[arg(long)]
262        lang: Option<String>,
263        /// Emit output as JSON instead of text.
264        #[arg(long)]
265        json: bool,
266        /// With --json: emit compact (single-line) JSON.
267        #[arg(long)]
268        compact: bool,
269    },
270    /// Forward import-graph traversal: what does this file import (transitively)?
271    Deps {
272        file: PathBuf,
273        #[arg(long, default_value_t = 3)]
274        depth: usize,
275        /// Hide unresolved external imports from the footer.
276        /// Shown by default (tagged `[external]`); set this flag to drop them.
277        #[arg(long)]
278        hide_external: bool,
279        /// Force a fresh dep-graph build.
280        #[arg(long)]
281        rebuild: bool,
282        #[arg(long)]
283        json: bool,
284        #[arg(long)]
285        compact: bool,
286    },
287    /// Reverse import-graph: who imports this file (transitively)?
288    ReverseDeps {
289        file: PathBuf,
290        #[arg(long, default_value_t = 3)]
291        depth: usize,
292        #[arg(long, default_value_t = 200)]
293        limit: usize,
294        /// Show only importers from test files (path heuristic: tests/, __tests__/, *_test.*, *.spec.*, …).
295        #[arg(long)]
296        tests: bool,
297        /// Exclude importers from test files (same path heuristic as --tests).
298        #[arg(long, conflicts_with = "tests")]
299        exclude_tests: bool,
300        #[arg(long)]
301        rebuild: bool,
302        #[arg(long)]
303        json: bool,
304        #[arg(long)]
305        compact: bool,
306    },
307    /// Find import cycles via Tarjan SCC.
308    Cycles {
309        #[arg(default_value = ".")]
310        path: PathBuf,
311        #[arg(long, default_value_t = 2)]
312        min_size: usize,
313        #[arg(long)]
314        rebuild: bool,
315        #[arg(long)]
316        json: bool,
317        #[arg(long)]
318        compact: bool,
319    },
320    /// Emit the dep graph (text or JSON).
321    Graph {
322        #[arg(default_value = ".")]
323        path: PathBuf,
324        #[arg(long)]
325        json: bool,
326        /// Hide unresolved external imports from the graph.
327        /// Shown by default (tagged `[external]`); set this flag to drop them.
328        #[arg(long)]
329        hide_external: bool,
330        /// (Deprecated — unresolved externals are shown by default now.)
331        #[arg(long, hide = true)]
332        include_external: bool,
333        #[arg(long)]
334        rebuild: bool,
335        #[arg(long)]
336        compact: bool,
337    },
338    /// Token-budgeted context for a symbol — target body + relevant deps/callers in one call.
339    Context {
340        /// Symbol to build context for (same form as callers/callees).
341        #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
342        target: Option<String>,
343        /// Repository root (default: ".").
344        #[arg(default_value = ".")]
345        path: PathBuf,
346        /// Alternative to the positional target.
347        #[arg(long, requires = "symbol")]
348        file: Option<String>,
349        /// Symbol name when using `--file`.
350        #[arg(long, requires = "file")]
351        symbol: Option<String>,
352        /// Token budget (default 8000). Rough estimate: 1 token ≈ 4 bytes.
353        #[arg(long, default_value_t = 8000)]
354        budget: usize,
355        #[arg(long)]
356        rebuild: bool,
357        #[arg(long)]
358        json: bool,
359        #[arg(long)]
360        compact: bool,
361    },
362    /// Find callers of a symbol — AST-accurate, no grep noise.
363    ///
364    /// Pass the symbol either as a positional `<TARGET>` (suffix-matched
365    /// like `show`/`implements`: `TakeDamage`, `Player.TakeDamage`, or
366    /// `src/Player.cs:TakeDamage` to scope to one file), or via
367    /// `--file <FILE> --symbol <NAME>` for scripting use.
368    Callers {
369        /// Symbol to look up. Optional when `--file` and `--symbol` are passed.
370        #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
371        target: Option<String>,
372        /// Repository root (default: ".").
373        #[arg(default_value = ".")]
374        path: PathBuf,
375        /// Alternative to the `<FILE>:<NAME>` positional form.
376        #[arg(long, requires = "symbol")]
377        file: Option<String>,
378        /// Symbol name when using `--file`.
379        #[arg(long, requires = "file")]
380        symbol: Option<String>,
381        /// Max BFS depth (1 = direct callers only).
382        #[arg(long, default_value_t = 1)]
383        depth: usize,
384        /// Cap result count (mirrors reverse-deps).
385        #[arg(long, default_value_t = 200)]
386        limit: usize,
387        /// Hide callers whose target is `Ambiguous` (multiple candidates).
388        /// Shown by default (tagged red); set this flag to drop them.
389        #[arg(long)]
390        hide_ambiguous: bool,
391        /// (Deprecated — ambiguous matches are shown by default now.)
392        #[arg(long, hide = true)]
393        include_ambiguous: bool,
394        /// Show only callers in test files (path heuristic: tests/, __tests__/, *_test.*, *.spec.*, …).
395        #[arg(long)]
396        tests: bool,
397        /// Exclude callers from test files (same path heuristic as --tests).
398        #[arg(long, conflicts_with = "tests")]
399        exclude_tests: bool,
400        /// Force a fresh call-graph build.
401        #[arg(long)]
402        rebuild: bool,
403        #[arg(long)]
404        json: bool,
405        #[arg(long)]
406        compact: bool,
407    },
408    /// What does this symbol call? — AST-accurate forward call traversal.
409    ///
410    /// Same target-spec rules as `callers`: positional `<TARGET>` (with
411    /// optional `<FILE>:<NAME>` scoping) or `--file --symbol`.
412    Callees {
413        #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
414        target: Option<String>,
415        #[arg(default_value = ".")]
416        path: PathBuf,
417        #[arg(long, requires = "symbol")]
418        file: Option<String>,
419        #[arg(long, requires = "file")]
420        symbol: Option<String>,
421        #[arg(long, default_value_t = 1)]
422        depth: usize,
423        /// Hide unresolved callees (the `Bare`/`External` bucket).
424        /// Shown by default (tagged cyan/red); set this flag to drop them.
425        #[arg(long)]
426        hide_external: bool,
427        /// (Deprecated — unresolved/external callees are shown by default now.)
428        #[arg(long, hide = true)]
429        external: bool,
430        #[arg(long)]
431        rebuild: bool,
432        #[arg(long)]
433        json: bool,
434        #[arg(long)]
435        compact: bool,
436    },
437    /// Trace the static call path between two symbols — "how does <FROM> reach <TO>?"
438    ///
439    /// Shortest-path BFS over the call graph, with each hop's source body
440    /// inlined so a flow question is answered in one call instead of chaining
441    /// `callees`. When no static path exists (the chain broke at dynamic
442    /// dispatch), both endpoints + the target file's siblings are shown.
443    /// Targets are suffix-matched like `callers` (`run`, `Type.method`, or
444    /// `src/f.rs:name`).
445    Trace {
446        /// Source symbol — where the call path starts.
447        from: String,
448        /// Destination symbol — where the call path should reach.
449        to: String,
450        /// Repository root (default: ".").
451        #[arg(default_value = ".")]
452        path: PathBuf,
453        /// Max path length in hops.
454        #[arg(long, default_value_t = 12)]
455        depth: usize,
456        /// Force a fresh call-graph build.
457        #[arg(long)]
458        rebuild: bool,
459        #[arg(long)]
460        json: bool,
461        #[arg(long)]
462        compact: bool,
463    },
464    /// Cross-file impact analysis: callers + callees + file reverse-deps + test detection, one command.
465    Impact {
466        /// Symbol to analyse (same form as callers/callees: `TakeDamage`, `Player.TakeDamage`, `src/Player.cs:TakeDamage`).
467        #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
468        target: Option<String>,
469        /// Repository root (default: ".").
470        #[arg(default_value = ".")]
471        path: PathBuf,
472        /// Alternative to the positional target.
473        #[arg(long, requires = "symbol")]
474        file: Option<String>,
475        /// Symbol name when using `--file`.
476        #[arg(long, requires = "file")]
477        symbol: Option<String>,
478        /// Transitive depth (default 2).
479        #[arg(long, default_value_t = 2)]
480        depth: usize,
481        /// Cap number of results per section.
482        #[arg(long, default_value_t = 200)]
483        limit: usize,
484        /// Section to show: `deps`, `dependents`, `tests`, or `all` (default).
485        #[arg(long, default_value = "all")]
486        mode: String,
487        /// Hide ambiguous call-edge matches from impact output.
488        /// Shown by default (tagged red); set this flag to drop them.
489        #[arg(long)]
490        hide_ambiguous: bool,
491        /// Show only test files (path heuristic: tests/, __tests__/, *_test.*, *.spec.*, …).
492        #[arg(long)]
493        tests: bool,
494        /// Exclude test files from the output (same path heuristic as --tests).
495        #[arg(long, conflicts_with = "tests")]
496        exclude_tests: bool,
497        #[arg(long)]
498        rebuild: bool,
499        #[arg(long)]
500        json: bool,
501        #[arg(long)]
502        compact: bool,
503    },
504    /// Build, refresh, or inspect the per-repo search index
505    Index {
506        /// Repository root (default: ".")
507        #[arg(default_value = ".")]
508        path: PathBuf,
509        /// Drop any existing cache and rebuild from scratch
510        #[arg(long)]
511        rebuild: bool,
512        /// Print index stats and exit
513        #[arg(long)]
514        stats: bool,
515        /// With --stats: emit output as JSON
516        #[arg(long)]
517        json: bool,
518        /// With --json: emit compact (single-line) JSON
519        #[arg(long)]
520        compact: bool,
521    },
522    /// AST-aware search and rewrite using pattern matching with metavariables
523    Run {
524        /// Pattern to match (e.g. '$FUNC($$$)', 'if ($COND) { $$$BODY }')
525        #[arg(short, long)]
526        pattern: String,
527
528        /// Replacement template (e.g. 'bar($A)'). Omit for search-only mode.
529        #[arg(short, long)]
530        rewrite: Option<String>,
531
532        /// Language (auto-detected from file extension if omitted)
533        #[arg(short, long)]
534        lang: Option<String>,
535
536        /// Paths to search (files or directories). Defaults to current directory.
537        paths: Vec<PathBuf>,
538
539        /// Filter files by glob pattern
540        #[arg(long)]
541        glob: Option<String>,
542
543        /// Actually write changes. Without this flag, only shows matches/dry-run.
544        #[arg(long)]
545        write: bool,
546
547        /// Emit output as JSON
548        #[arg(long)]
549        json: bool,
550
551        /// With --json: compact single-line JSON
552        #[arg(long)]
553        compact: bool,
554    },
555}
556
557pub(crate) fn parse_file(path: &Path) -> Option<ParseResult> {
558    crate::main_helpers::parse_file_for_hook(path)
559}
560
561/// A 1-indexed, inclusive line range for `squeeze`. Either bound may be open:
562/// `start` defaults to line 1, `end` defaults to EOF. Maps cleanly to the
563/// `Option<(usize, usize)>` the squeeze renderer expects via [`LineRange::resolve`].
564#[derive(Clone, Debug)]
565pub struct LineRange {
566    pub start: Option<usize>,
567    pub end: Option<usize>,
568}
569
570impl LineRange {
571    /// Collapse to the `(start, end)` pair the report uses, filling open bounds
572    /// with line 1 / EOF and clamping `end` to the real line count so JSON
573    /// consumers never see an out-of-range sentinel like `usize::MAX`.
574    fn resolve(&self, line_count: usize) -> (usize, usize) {
575        clamp_line_range(self.start.unwrap_or(1), self.end, line_count)
576    }
577}
578
579/// Clamp a 1-indexed inclusive line range to a file's real line count.
580pub(crate) fn clamp_line_range(
581    start: usize,
582    end: Option<usize>,
583    line_count: usize,
584) -> (usize, usize) {
585    let end = end.unwrap_or(line_count).min(line_count).max(start);
586    (start, end)
587}
588
589/// Clap value parser for `squeeze`'s optional range argument. Accepts:
590/// `N` (single line), `A:B` (inclusive), `A:` (A to EOF), `:B` (start to B).
591/// All bounds are 1-indexed; `0` and `A > B` are rejected. Clamping to the
592/// file's real line count happens later in the slicer.
593fn parse_line_range(s: &str) -> Result<LineRange, String> {
594    let parse_bound = |part: &str| -> Result<Option<usize>, String> {
595        if part.is_empty() {
596            return Ok(None);
597        }
598        match part.parse::<usize>() {
599            Ok(0) => Err("line numbers are 1-indexed (got 0)".to_string()),
600            Ok(n) => Ok(Some(n)),
601            Err(_) => Err(format!("invalid line number: {part:?}")),
602        }
603    };
604
605    let range = match s.split_once(':') {
606        // `A:B`, `A:`, `:B`, or `:`
607        Some((a, b)) => LineRange {
608            start: parse_bound(a)?,
609            end: parse_bound(b)?,
610        },
611        // `N` — single line, both bounds equal
612        None => {
613            let n = parse_bound(s)?;
614            LineRange { start: n, end: n }
615        }
616    };
617
618    if let (Some(start), Some(end)) = (range.start, range.end) {
619        if start > end {
620            return Err(format!("range start {start} is after end {end}"));
621        }
622    }
623
624    Ok(range)
625}
626
627/// Parse `<FILE>:<LINE>` into the two parts. Returns `None` if there's no
628/// colon or the suffix doesn't parse as a u32. Used by `find-related`.
629fn parse_file_line(s: &str) -> Option<(String, u32)> {
630    let (file, line) = s.rsplit_once(':')?;
631    if file.is_empty() {
632        return None;
633    }
634    Some((file.to_string(), line.parse().ok()?))
635}
636
637/// Filter out non-existent paths, build a WalkBuilder with filters and glob overrides,
638/// and return (builder, existing_paths). Returns None if no paths exist.
639fn build_filtered_walker(
640    paths: &[PathBuf],
641    glob_str: Option<&str>,
642) -> Option<(WalkBuilder, Vec<PathBuf>)> {
643    if paths.is_empty() {
644        return None;
645    }
646
647    let existing: Vec<PathBuf> = paths
648        .iter()
649        .flat_map(|p| {
650            let expanded = path_glob::expand_existing(p);
651            if expanded.is_empty() {
652                println!("# note: path not found: {}", p.display());
653            }
654            expanded
655        })
656        .collect();
657    if existing.is_empty() {
658        return None;
659    }
660
661    let mut builder = WalkBuilder::new(&existing[0]);
662    for p in existing.iter().skip(1) {
663        builder.add(p);
664    }
665
666    builder.hidden(false);
667    file_filter::add_filters(&mut builder, &existing[0]);
668
669    if let Some(g) = glob_str {
670        if let Ok(override_builder) = ignore::overrides::OverrideBuilder::new("").add(g) {
671            if let Ok(over) = override_builder.build() {
672                builder.overrides(over);
673            }
674        }
675    }
676
677    Some((builder, existing))
678}
679
680pub(crate) fn walk_paths(paths: &[PathBuf], glob_str: Option<&str>) -> Vec<PathBuf> {
681    let (tx, rx) = std::sync::mpsc::channel();
682    let Some((builder, existing)) = build_filtered_walker(paths, glob_str) else {
683        return Vec::new();
684    };
685    let walker = builder.build_parallel();
686    let root = existing[0].clone();
687
688    walker.run(|| {
689        let tx = tx.clone();
690        let root = root.clone();
691        Box::new(move |result| {
692            if let Ok(entry) = result {
693                if entry.file_type().is_some_and(|ft| ft.is_file())
694                    && !file_filter::should_skip_path(entry.path(), &root)
695                {
696                    let _ = tx.send(entry.path().to_path_buf());
697                }
698            }
699            ignore::WalkState::Continue
700        })
701    });
702
703    drop(tx);
704    let mut results: Vec<_> = rx.into_iter().collect();
705    results.sort();
706    results
707}
708
709pub(crate) fn walk_and_parse(paths: &[PathBuf], glob_str: Option<&str>) -> Vec<ParseResult> {
710    let (tx, rx) = std::sync::mpsc::channel();
711    let Some((builder, existing)) = build_filtered_walker(paths, glob_str) else {
712        return Vec::new();
713    };
714    let walker = builder.build_parallel();
715    let root = existing[0].clone();
716
717    walker.run(|| {
718        let tx = tx.clone();
719        let root = root.clone();
720        Box::new(move |result| {
721            if let Ok(entry) = result {
722                if entry.file_type().is_some_and(|ft| ft.is_file())
723                    && !file_filter::should_skip_path(entry.path(), &root)
724                {
725                    if let Some(parsed) = parse_file(entry.path()) {
726                        let _ = tx.send(parsed);
727                    }
728                }
729            }
730            ignore::WalkState::Continue
731        })
732    });
733
734    drop(tx);
735    let mut results: Vec<_> = rx.into_iter().collect();
736    results.sort_by(|a, b| a.path.cmp(&b.path));
737    results
738}
739
740pub fn run() {
741    use clap::error::ErrorKind;
742    use clap::CommandFactory;
743
744    // Agent-friendly arg handling: instead of dying on a typo or unknown
745    // flag, print the help text so the calling agent can self-correct
746    // without a separate `--help` round-trip. `--help` / `--version` keep
747    // their normal exit-0 behaviour; everything else prints help to stdout
748    // and exits 0 too (agents see "output" rather than "error").
749    let cli = match Cli::try_parse() {
750        Ok(c) => c,
751        Err(e) => match e.kind() {
752            ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
753                e.exit();
754            }
755            _ => {
756                let mut cmd = Cli::command();
757                let _ = cmd.print_help();
758                println!();
759                println!(
760                    "# note: could not parse args ({}). Showing help instead.",
761                    e.kind()
762                );
763                std::process::exit(0);
764            }
765        },
766    };
767
768    match &cli.command {
769        Commands::Map {
770            paths,
771            no_private,
772            no_fields,
773            no_docs,
774            no_attrs,
775            no_lines,
776            glob,
777            json,
778            compact,
779        } => {
780            let results = walk_and_parse(paths, glob.as_deref());
781            let opts = MapOptions {
782                include_private: !(*no_private),
783                include_fields: !(*no_fields),
784                include_docs: !(*no_docs),
785                include_attributes: !(*no_attrs),
786                include_line_numbers: !(*no_lines),
787                max_doc_lines: 6,
788                max_members: None,
789            };
790            let json_on = *json;
791            let pretty = !(*compact);
792            if json_on {
793                println!("{}", crate::core::render_json_map(&results, &opts, pretty));
794            } else {
795                for res in results {
796                    println!("{}", crate::core::render_map(&res, &opts));
797                    println!();
798                }
799            }
800        }
801        Commands::Show {
802            path,
803            symbol,
804            others,
805            json,
806            compact,
807        } => {
808            if !path.exists() {
809                println!("# note: path not found: {}", path.display());
810            } else if let Some(res) = parse_file(path) {
811                let mut symbols = vec![symbol.as_str()];
812                symbols.extend(others.iter().map(|s| s.as_str()));
813                if *json {
814                    let mut seen = std::collections::HashSet::new();
815                    let mut all_matches = Vec::new();
816                    for sym in &symbols {
817                        for m in crate::core::find_symbols(&res, sym) {
818                            let key = (m.start_line, m.end_line, m.qualified_name.clone());
819                            if seen.insert(key) {
820                                all_matches.push(m);
821                            }
822                        }
823                    }
824                    println!(
825                        "{}",
826                        crate::core::render_json_show(&res, &all_matches, !(*compact))
827                    );
828                    if all_matches.is_empty() {
829                        // JSON consumers see [] in the payload; humans/agents
830                        // glancing at stderr-free output get a hint too.
831                        println!(
832                            "# note: no symbol matching {:?} in {}",
833                            symbol,
834                            path.display()
835                        );
836                    }
837                } else {
838                    let mut any_match = false;
839                    for sym in &symbols {
840                        let matches = crate::core::find_symbols(&res, sym);
841                        for m in matches {
842                            any_match = true;
843                            println!(
844                                "# {}:{}-{} {} ({})",
845                                res.path.display(),
846                                m.start_line,
847                                m.end_line,
848                                m.qualified_name,
849                                m.kind
850                            );
851                            if !m.ancestor_signatures.is_empty() {
852                                println!("# in: {}", m.ancestor_signatures.join(" → "));
853                            }
854                            println!("{}", m.source);
855                        }
856                    }
857                    if !any_match {
858                        let joined = symbols.join(", ");
859                        println!(
860                            "# note: no symbol matching '{}' in {}",
861                            joined,
862                            path.display()
863                        );
864                    }
865                }
866            } else {
867                println!(
868                    "# note: unsupported file type for `show`: {}",
869                    path.display()
870                );
871            }
872        }
873        Commands::Squeeze {
874            path,
875            range,
876            raw,
877            json,
878            compact,
879        } => {
880            if !path.exists() {
881                println!("# note: path not found: {}", path.display());
882                return;
883            }
884            let text = match std::fs::read_to_string(path) {
885                Ok(t) => t,
886                Err(e) => {
887                    if path.is_dir() {
888                        println!("# note: path is a directory: {}", path.display());
889                    } else {
890                        println!("# note: could not read {}: {}", path.display(), e);
891                    }
892                    return;
893                }
894            };
895            let line_count = text.lines().count();
896            let resolved: Option<(usize, usize)> = range.as_ref().map(|r| r.resolve(line_count));
897            let sliced = crate::squeeze::render::slice_lines(&text, resolved);
898            let path_str = path.display().to_string();
899            let report = crate::squeeze::render::SqueezeReport {
900                path: &path_str,
901                range: resolved,
902                raw: &sliced,
903                raw_requested: *raw,
904            };
905            if *json {
906                println!(
907                    "{}",
908                    crate::squeeze::render::render_json(&report, !(*compact))
909                );
910            } else {
911                println!("{}", crate::squeeze::render::render_text(&report));
912            }
913        }
914        Commands::Digest {
915            paths,
916            include_private,
917            include_fields,
918            max_members,
919            json,
920            compact,
921        } => {
922            let results = walk_and_parse(paths, None);
923            if *json {
924                let opts = MapOptions {
925                    include_private: *include_private,
926                    include_fields: *include_fields,
927                    include_docs: true,
928                    include_attributes: true,
929                    include_line_numbers: true,
930                    max_doc_lines: 6,
931                    max_members: Some(*max_members),
932                };
933                println!(
934                    "{}",
935                    crate::core::render_json_map(&results, &opts, !(*compact))
936                );
937            } else {
938                let opts = DigestOptions {
939                    include_private: *include_private,
940                    include_fields: *include_fields,
941                    max_members_per_type: *max_members,
942                    max_heading_depth: 3,
943                };
944                let root = if paths.len() == 1 && paths[0].is_dir() {
945                    Some(paths[0].as_path())
946                } else {
947                    None
948                };
949                println!("{}", crate::core::render_digest(&results, &opts, root));
950            }
951        }
952        Commands::Implements {
953            target,
954            paths,
955            direct,
956            json,
957            compact,
958        } => {
959            let results = walk_and_parse(paths, None);
960            let transitive = !direct;
961            let matches = crate::core::find_implementations(&results, target, transitive);
962            if *json {
963                println!(
964                    "{}",
965                    crate::core::render_json_implements(target, &matches, transitive, !(*compact),)
966                );
967            } else {
968                println!(
969                    "# {} match(es) for '{}' (incl. transitive):",
970                    matches.len(),
971                    target
972                );
973                for m in matches {
974                    let via = if m.via.is_empty() {
975                        String::new()
976                    } else {
977                        format!(" [via {}]", m.via.last().unwrap())
978                    };
979                    println!("{}:{}  {} {}{}", m.path, m.start_line, m.kind, m.name, via);
980                }
981            }
982        }
983        Commands::Prompt => {
984            println!("{}", crate::prompt::agent_prompt());
985        }
986        Commands::Install {
987            target,
988            all,
989            local,
990            global,
991            always,
992            min_lines,
993            dry_run,
994            force,
995            mcp,
996            skills,
997        } => {
998            let scope = resolve_scope(*local, *global);
999            let opts = installers::InstallOpts {
1000                min_lines: *min_lines,
1001                always: *always,
1002                dry_run: *dry_run,
1003                force: *force,
1004            };
1005            let exit = run_install(target.as_deref(), *all, *mcp, *skills, &scope, &opts);
1006            std::process::exit(exit);
1007        }
1008        Commands::Uninstall {
1009            target,
1010            all,
1011            local,
1012            global,
1013            dry_run,
1014        } => {
1015            let scope = resolve_scope(*local, *global);
1016            let opts = installers::InstallOpts {
1017                dry_run: *dry_run,
1018                ..installers::InstallOpts::default()
1019            };
1020            let exit = run_uninstall(target.as_deref(), *all, &scope, &opts);
1021            std::process::exit(exit);
1022        }
1023        Commands::Status { local, global } => {
1024            let scope = resolve_scope(*local, *global);
1025            run_status(&scope);
1026        }
1027        Commands::Hook {
1028            protocol,
1029            min_lines,
1030            always,
1031        } => {
1032            let exit = hook::run(protocol, *min_lines, *always);
1033            std::process::exit(exit);
1034        }
1035        Commands::Mcp => {
1036            let exit = mcp::run();
1037            std::process::exit(exit);
1038        }
1039        Commands::Search {
1040            query,
1041            path,
1042            top_k,
1043            alpha,
1044            languages,
1045            rebuild,
1046            json,
1047            compact,
1048        } => {
1049            if *rebuild {
1050                let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1051                if let Err(e) = crate::search::index::Index::build(path, &cwd) {
1052                    eprintln!("ast-bro: rebuild failed: {e}");
1053                    std::process::exit(1);
1054                }
1055            }
1056            let exit = crate::search::cli::run_search(
1057                query,
1058                path,
1059                *top_k,
1060                *alpha,
1061                languages.clone(),
1062                *json,
1063                !(*compact),
1064            );
1065            std::process::exit(exit);
1066        }
1067        Commands::FindRelated {
1068            target,
1069            path,
1070            file,
1071            line,
1072            top_k,
1073            json,
1074            compact,
1075        } => {
1076            // Clap guarantees one of: (target alone) or (file + line).
1077            let (file_path, line_num) = match (target, file, line) {
1078                (Some(t), _, _) => match parse_file_line(t) {
1079                    Some(parsed) => parsed,
1080                    None => {
1081                        println!(
1082                            "# note: expected <FILE>:<LINE>, got {t:?} \
1083                                 (or use --file FILE --line N instead)"
1084                        );
1085                        return;
1086                    }
1087                },
1088                (None, Some(f), Some(l)) => (f.clone(), *l),
1089                _ => unreachable!("clap should have rejected this argument combination"),
1090            };
1091            let exit = crate::search::cli::run_find_related(
1092                &file_path,
1093                line_num,
1094                path,
1095                *top_k,
1096                *json,
1097                !(*compact),
1098            );
1099            std::process::exit(exit);
1100        }
1101        Commands::Surface {
1102            path,
1103            tree,
1104            include_chain,
1105            max_depth,
1106            include_private,
1107            lang,
1108            json,
1109            compact,
1110        } => {
1111            let lang_override = match lang {
1112                Some(s) => match crate::surface::LangOverride::parse(s) {
1113                    Some(l) => Some(l),
1114                    None => {
1115                        println!(
1116                            "# note: unknown --lang value '{}'. Expected rust|python|fallback.",
1117                            s
1118                        );
1119                        return;
1120                    }
1121                },
1122                None => None,
1123            };
1124            let json_on = *json;
1125            let pretty = !(*compact);
1126            let output = if json_on {
1127                crate::surface::OutputMode::Json { compact: !pretty }
1128            } else if *tree {
1129                crate::surface::OutputMode::Tree
1130            } else {
1131                crate::surface::OutputMode::Flat
1132            };
1133            let opts = crate::surface::SurfaceOptions {
1134                output,
1135                include_private: *include_private,
1136                max_depth: *max_depth,
1137                include_chain: *include_chain,
1138                lang_override,
1139            };
1140            match crate::surface::resolve_surface(path, &opts) {
1141                Ok(entries) => {
1142                    let rendered =
1143                        crate::surface::render::render(&entries, opts.output, opts.include_chain);
1144                    print!("{}", rendered);
1145                }
1146                Err(e) => {
1147                    println!("# note: {e}");
1148                }
1149            }
1150        }
1151        Commands::Deps {
1152            file,
1153            depth,
1154            hide_external,
1155            rebuild,
1156            json,
1157            compact,
1158        } => {
1159            let exit = crate::deps::cli::run_deps(
1160                file,
1161                *depth,
1162                !(*hide_external),
1163                *json,
1164                !(*compact),
1165                *rebuild,
1166            );
1167            std::process::exit(exit);
1168        }
1169        Commands::ReverseDeps {
1170            file,
1171            depth,
1172            limit,
1173            tests,
1174            exclude_tests,
1175            rebuild,
1176            json,
1177            compact,
1178        } => {
1179            let exit = crate::deps::cli::run_reverse_deps(
1180                file,
1181                *depth,
1182                *limit,
1183                *tests,
1184                *exclude_tests,
1185                *json,
1186                !(*compact),
1187                *rebuild,
1188            );
1189            std::process::exit(exit);
1190        }
1191        Commands::Cycles {
1192            path,
1193            min_size,
1194            rebuild,
1195            json,
1196            compact,
1197        } => {
1198            let exit = crate::deps::cli::run_cycles(path, *min_size, *json, !(*compact), *rebuild);
1199            std::process::exit(exit);
1200        }
1201        Commands::Graph {
1202            path,
1203            json,
1204            hide_external,
1205            include_external,
1206            rebuild,
1207            compact,
1208        } => {
1209            if *include_external {
1210                eprintln!("# note: --include-external is deprecated; unresolved imports are shown by default now (use --hide-external to drop them)");
1211            }
1212            let exit =
1213                crate::deps::cli::run_graph(path, *json, !(*hide_external), !(*compact), *rebuild);
1214            std::process::exit(exit);
1215        }
1216        Commands::Index {
1217            path,
1218            rebuild,
1219            stats,
1220            json,
1221            compact,
1222        } => {
1223            let exit = crate::search::cli::run_index(path, *rebuild, *stats, *json, !(*compact));
1224            std::process::exit(exit);
1225        }
1226        Commands::Callers {
1227            target,
1228            path,
1229            file,
1230            symbol,
1231            depth,
1232            limit,
1233            hide_ambiguous,
1234            include_ambiguous,
1235            tests,
1236            exclude_tests,
1237            rebuild,
1238            json,
1239            compact,
1240        } => {
1241            if *include_ambiguous {
1242                eprintln!("# note: --include-ambiguous is deprecated; ambiguous callers are shown by default now (use --hide-ambiguous to drop them)");
1243            }
1244            let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1245            let exit = crate::calls::cli::run_callers(
1246                &resolved,
1247                path,
1248                *depth,
1249                *limit,
1250                !(*hide_ambiguous),
1251                *tests,
1252                *exclude_tests,
1253                *rebuild,
1254                *json,
1255                !(*compact),
1256            );
1257            std::process::exit(exit);
1258        }
1259        Commands::Context {
1260            target,
1261            path,
1262            file,
1263            symbol,
1264            budget,
1265            rebuild,
1266            json,
1267            compact,
1268        } => {
1269            let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1270            let exit = crate::context::run_context(
1271                &resolved,
1272                path,
1273                &crate::context::ContextOptions {
1274                    budget: *budget,
1275                    json: *json,
1276                    pretty: !(*compact),
1277                },
1278                *rebuild,
1279            );
1280            std::process::exit(exit);
1281        }
1282        Commands::Callees {
1283            target,
1284            path,
1285            file,
1286            symbol,
1287            depth,
1288            hide_external,
1289            external,
1290            rebuild,
1291            json,
1292            compact,
1293        } => {
1294            if *external {
1295                eprintln!("# note: --external is deprecated; unresolved/external callees are shown by default now (use --hide-external to drop them)");
1296            }
1297            let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1298            let exit = crate::calls::cli::run_callees(
1299                &resolved,
1300                path,
1301                *depth,
1302                !(*hide_external),
1303                *rebuild,
1304                *json,
1305                !(*compact),
1306            );
1307            std::process::exit(exit);
1308        }
1309        Commands::Trace {
1310            from,
1311            to,
1312            path,
1313            depth,
1314            rebuild,
1315            json,
1316            compact,
1317        } => {
1318            let exit =
1319                crate::calls::cli::run_trace(from, to, path, *depth, *rebuild, *json, !(*compact));
1320            std::process::exit(exit);
1321        }
1322        Commands::Impact {
1323            target,
1324            path,
1325            file,
1326            symbol,
1327            depth,
1328            limit,
1329            mode,
1330            hide_ambiguous,
1331            tests,
1332            exclude_tests,
1333            rebuild,
1334            json,
1335            compact,
1336        } => {
1337            let impact_mode = match crate::impact::ImpactMode::parse(mode) {
1338                Some(m) => m,
1339                None => {
1340                    eprintln!(
1341                        "# note: unknown --mode '{}'. Expected: deps, dependents, tests, all",
1342                        mode
1343                    );
1344                    std::process::exit(2);
1345                }
1346            };
1347            let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1348            let opts = crate::impact::ImpactOptions {
1349                depth: *depth,
1350                limit: *limit,
1351                mode: impact_mode,
1352                include_ambiguous: !(*hide_ambiguous),
1353                tests: *tests,
1354                exclude_tests: *exclude_tests,
1355                json: *json,
1356                pretty: !(*compact),
1357            };
1358            let exit = crate::impact::run_impact(&resolved, path, &opts, *rebuild);
1359            std::process::exit(exit);
1360        }
1361        Commands::Run {
1362            pattern,
1363            rewrite,
1364            lang,
1365            paths,
1366            glob,
1367            write,
1368            json,
1369            compact,
1370        } => {
1371            let exit = crate::run::cli::run(
1372                pattern,
1373                rewrite.as_deref(),
1374                lang.as_deref(),
1375                paths,
1376                glob.as_deref(),
1377                *write,
1378                *json,
1379                !(*compact),
1380            );
1381            std::process::exit(exit);
1382        }
1383    }
1384}
1385
1386/// Fold `--file <F> --symbol <S>` into the same `<file>:<symbol>` canonical
1387/// form the positional `<TARGET>` arg uses. Clap's `required_unless_present_all`
1388/// guarantees exactly one of the two arms is populated.
1389fn compose_target(target: Option<&str>, file: Option<&str>, symbol: Option<&str>) -> String {
1390    if let Some(t) = target {
1391        return t.to_string();
1392    }
1393    match (file, symbol) {
1394        (Some(f), Some(s)) => format!("{}:{}", f, s),
1395        _ => unreachable!("clap guarantees target XOR (file && symbol)"),
1396    }
1397}
1398
1399fn resolve_scope(local: bool, _global: bool) -> installers::Scope {
1400    if local {
1401        installers::Scope::Local(std::env::current_dir().expect("cwd"))
1402    } else {
1403        installers::Scope::Global
1404    }
1405}
1406
1407fn run_install(
1408    target: Option<&str>,
1409    all: bool,
1410    mcp: bool,
1411    skills: bool,
1412    scope: &installers::Scope,
1413    opts: &installers::InstallOpts,
1414) -> i32 {
1415    let registry = installers::registry();
1416    let chosen: Vec<&Box<dyn installers::Installer>> = if all {
1417        select_all(&registry, scope)
1418    } else if let Some(name) = target {
1419        match registry.iter().find(|i| i.name() == name) {
1420            Some(i) => vec![i],
1421            None => {
1422                eprintln!("unknown --target '{}'. Known: {}", name, names(&registry));
1423                return 2;
1424            }
1425        }
1426    } else {
1427        eprintln!(
1428            "must pass --target <name> or --all. Known: {}",
1429            names(&registry)
1430        );
1431        return 2;
1432    };
1433
1434    let exclusive_mode = mcp || skills;
1435    let mut any_installed = false;
1436    let mut any_failed = false;
1437    for inst in chosen {
1438        let label = inst.name();
1439        if !exclusive_mode {
1440            match inst.install_prompt(scope, opts) {
1441                Ok(c) => {
1442                    print_change(label, "prompt", &c);
1443                    if !matches!(
1444                        c,
1445                        installers::Change::Skipped { .. } | installers::Change::NotApplicable
1446                    ) {
1447                        any_installed = true;
1448                    }
1449                }
1450                Err(e) => {
1451                    eprintln!("{}: prompt: {}", label, e);
1452                    any_failed = true;
1453                }
1454            }
1455            match inst.install_hook(scope, opts) {
1456                Ok(c) => {
1457                    print_change(label, "hook", &c);
1458                    if !matches!(
1459                        c,
1460                        installers::Change::Skipped { .. } | installers::Change::NotApplicable
1461                    ) {
1462                        any_installed = true;
1463                    }
1464                }
1465                Err(e) => {
1466                    eprintln!("{}: hook: {}", label, e);
1467                    any_failed = true;
1468                }
1469            }
1470            match inst.install_subagents(scope, opts) {
1471                Ok(changes) => {
1472                    for c in &changes {
1473                        print_change(label, "subagent", c);
1474                        if !matches!(
1475                            c,
1476                            installers::Change::Skipped { .. } | installers::Change::NotApplicable
1477                        ) {
1478                            any_installed = true;
1479                        }
1480                    }
1481                }
1482                Err(e) => {
1483                    eprintln!("{}: subagent: {}", label, e);
1484                    any_failed = true;
1485                }
1486            }
1487        } else {
1488            if mcp {
1489                match inst.install_mcp(scope, opts) {
1490                    Ok(c) => {
1491                        print_change(label, "mcp", &c);
1492                        if !matches!(
1493                            c,
1494                            installers::Change::Skipped { .. } | installers::Change::NotApplicable
1495                        ) {
1496                            any_installed = true;
1497                        }
1498                    }
1499                    Err(e) => {
1500                        eprintln!("{}: mcp: {}", label, e);
1501                        any_failed = true;
1502                    }
1503                }
1504            }
1505            if skills {
1506                match inst.install_skills(scope, opts) {
1507                    Ok(c) => {
1508                        print_change(label, "skills", &c);
1509                        if !matches!(
1510                            c,
1511                            installers::Change::Skipped { .. } | installers::Change::NotApplicable
1512                        ) {
1513                            any_installed = true;
1514                        }
1515                    }
1516                    Err(e) => {
1517                        eprintln!("{}: skills: {}", label, e);
1518                        any_failed = true;
1519                    }
1520                }
1521            }
1522        }
1523    }
1524
1525    if any_failed && any_installed {
1526        1
1527    } else if any_failed {
1528        2
1529    } else {
1530        0
1531    }
1532}
1533
1534fn run_uninstall(
1535    target: Option<&str>,
1536    all: bool,
1537    scope: &installers::Scope,
1538    opts: &installers::InstallOpts,
1539) -> i32 {
1540    let registry = installers::registry();
1541    let chosen: Vec<&Box<dyn installers::Installer>> = if all {
1542        select_all(&registry, scope)
1543    } else if let Some(name) = target {
1544        match registry.iter().find(|i| i.name() == name) {
1545            Some(i) => vec![i],
1546            None => {
1547                eprintln!("unknown --target '{}'. Known: {}", name, names(&registry));
1548                return 2;
1549            }
1550        }
1551    } else {
1552        eprintln!(
1553            "must pass --target <name> or --all. Known: {}",
1554            names(&registry)
1555        );
1556        return 2;
1557    };
1558
1559    let mut any_failed = false;
1560    for inst in chosen {
1561        match inst.uninstall(scope, opts) {
1562            Ok(changes) => {
1563                for c in changes {
1564                    print_change(inst.name(), "uninstall", &c);
1565                }
1566            }
1567            Err(e) => {
1568                eprintln!("{}: {}", inst.name(), e);
1569                any_failed = true;
1570            }
1571        }
1572    }
1573    if any_failed {
1574        1
1575    } else {
1576        0
1577    }
1578}
1579
1580fn run_status(scope: &installers::Scope) {
1581    for inst in installers::registry() {
1582        let s = inst.status(scope);
1583        let prompt = if s.prompt_installed {
1584            format!("prompt {}", s.prompt_version.unwrap_or_else(|| "?".into()))
1585        } else {
1586            "prompt -".to_string()
1587        };
1588        let hook = if s.hook_installed {
1589            "hook ✓"
1590        } else {
1591            "hook -"
1592        };
1593        let mcp = if s.mcp_installed { "mcp ✓" } else { "mcp -" };
1594        let skills = if s.skills_installed {
1595            "skills ✓"
1596        } else {
1597            "skills -"
1598        };
1599        println!(
1600            "{:<14} {:<14} {:<8} {:<8} {}",
1601            inst.name(),
1602            prompt,
1603            hook,
1604            mcp,
1605            skills
1606        );
1607    }
1608}
1609
1610fn names(registry: &[Box<dyn installers::Installer>]) -> String {
1611    registry
1612        .iter()
1613        .map(|i| i.name())
1614        .collect::<Vec<_>>()
1615        .join(", ")
1616}
1617
1618/// Picks the adapters to act on for `--all`. For `Scope::Global`, we
1619/// skip targets whose `detect()` reports the CLI is absent (and print a
1620/// note). For `Scope::Local`, the user explicitly opted into this repo
1621/// so detection is bypassed.
1622#[allow(clippy::borrowed_box)]
1623fn select_all<'a>(
1624    registry: &'a [Box<dyn installers::Installer>],
1625    scope: &installers::Scope,
1626) -> Vec<&'a Box<dyn installers::Installer>> {
1627    let bypass_detection = matches!(scope, installers::Scope::Local(_));
1628    registry
1629        .iter()
1630        .filter(|inst| {
1631            if bypass_detection {
1632                return true;
1633            }
1634            let d = inst.detect(scope);
1635            if !d.present {
1636                println!(
1637                    "{:<14} {:<10} skipped  (not detected on this system)",
1638                    inst.name(),
1639                    "detect"
1640                );
1641            }
1642            d.present
1643        })
1644        .collect()
1645}
1646
1647fn print_change(target: &str, phase: &str, change: &installers::Change) {
1648    use installers::Change::*;
1649    match change {
1650        Created(p) => println!("{:<14} {:<10} created  {}", target, phase, p.display()),
1651        Updated(p) => println!("{:<14} {:<10} updated  {}", target, phase, p.display()),
1652        Removed(p) => println!("{:<14} {:<10} removed  {}", target, phase, p.display()),
1653        Skipped { path, reason } => {
1654            println!(
1655                "{:<14} {:<10} skipped  {} ({})",
1656                target,
1657                phase,
1658                path.display(),
1659                reason
1660            )
1661        }
1662        NotApplicable => println!("{:<14} {:<10} n/a", target, phase),
1663    }
1664}