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 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 files or directories — signatures with line ranges, no method bodies.
46    Map {
47        /// Files or directories to map.
48        #[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        /// Emit output as JSON instead of text
64        #[arg(long)]
65        json: bool,
66        /// With --json: emit compact (single-line) JSON instead of pretty-printed
67        #[arg(long)]
68        compact: bool,
69    },
70    /// Extract source of a symbol
71    Show {
72        path: PathBuf,
73        symbol: String,
74        #[arg(num_args = 0..)]
75        others: Vec<String>,
76        /// Emit output as JSON instead of text
77        #[arg(long)]
78        json: bool,
79        /// With --json: emit compact (single-line) JSON
80        #[arg(long)]
81        compact: bool,
82    },
83    /// One-page module map
84    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        /// Emit output as JSON instead of text
95        #[arg(long)]
96        json: bool,
97        /// With --json: emit compact (single-line) JSON
98        #[arg(long)]
99        compact: bool,
100    },
101    /// Find subclasses / implementations
102    Implements {
103        target: String,
104        #[arg(num_args = 1..)]
105        paths: Vec<PathBuf>,
106
107        #[arg(short, long)]
108        direct: bool,
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    /// Print the agent prompt snippet
117    Prompt,
118    /// Install ast-bro into a coding-agent CLI
119    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        /// Install ast-bro as an MCP server entry instead of the CLAUDE.md prompt.
137        /// Combine with `--skills` to install both.
138        #[arg(long)]
139        mcp: bool,
140        /// Install ast-bro as a Claude Code skill instead of the CLAUDE.md prompt.
141        /// Combine with `--mcp` to install both.
142        #[arg(long)]
143        skills: bool,
144    },
145    /// Remove ast-bro from a coding-agent CLI
146    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    /// Report what's installed where
159    Status {
160        #[arg(long)]
161        local: bool,
162        #[arg(long, conflicts_with = "local")]
163        global: bool,
164    },
165    /// Internal: read a tool-call event from stdin and respond
166    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    /// Run as an MCP (Model Context Protocol) server over stdio
175    Mcp,
176    /// Hybrid BM25 + dense semantic search over the repo
177    Search {
178        /// Search query (free-form text or symbol name)
179        query: String,
180        /// Repository root to search in (default: ".")
181        #[arg(default_value = ".")]
182        path: PathBuf,
183        /// Number of results to return
184        #[arg(short = 'k', long = "top-k", default_value_t = 10)]
185        top_k: usize,
186        /// Override auto alpha (semantic vs. BM25 weight, 0.0–1.0)
187        #[arg(long)]
188        alpha: Option<f32>,
189        /// Filter by language (repeatable, e.g. `--lang rust --lang python`)
190        #[arg(long = "lang")]
191        languages: Vec<String>,
192        /// Force a full rebuild of the index before searching
193        #[arg(long)]
194        rebuild: bool,
195        /// Emit output as JSON instead of text
196        #[arg(long)]
197        json: bool,
198        /// With --json: emit compact (single-line) JSON
199        #[arg(long)]
200        compact: bool,
201    },
202    /// Find chunks semantically similar to a given file:line
203    ///
204    /// Pass the source location either as a positional `<FILE>:<LINE>`
205    /// (matches grep / search-result output you can paste back) or via
206    /// `--file <FILE> --line <LINE>` for scripting use.
207    FindRelated {
208        /// Source location as `<FILE>:<LINE>`. Optional when `--file` and
209        /// `--line` are passed together.
210        #[arg(required_unless_present_all = ["file", "line"], conflicts_with_all = ["file", "line"])]
211        target: Option<String>,
212        /// Repository root containing the index (default: ".")
213        #[arg(default_value = ".")]
214        path: PathBuf,
215        /// Alternative to the positional `<FILE>:<LINE>` form
216        #[arg(long, requires = "line")]
217        file: Option<String>,
218        /// 1-indexed line number when using `--file`
219        #[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    /// True public API surface — resolves `pub use` / `__all__` re-exports.
229    Surface {
230        /// Crate root file, package init, or directory to auto-detect.
231        #[arg(default_value = ".")]
232        path: PathBuf,
233        /// Render as a hierarchical tree grouped by module.
234        #[arg(long)]
235        tree: bool,
236        /// Append the via-chain on each entry (text mode only).
237        #[arg(long)]
238        include_chain: bool,
239        /// Recursion guard for re-export chains.
240        #[arg(long, default_value_t = 16)]
241        max_depth: usize,
242        /// Include private items (only meaningful for fallback languages).
243        #[arg(long)]
244        include_private: bool,
245        /// Force a specific resolver: `rust`, `python`, or `fallback`.
246        #[arg(long)]
247        lang: Option<String>,
248        /// Emit output as JSON instead of text.
249        #[arg(long)]
250        json: bool,
251        /// With --json: emit compact (single-line) JSON.
252        #[arg(long)]
253        compact: bool,
254    },
255    /// Forward import-graph traversal: what does this file import (transitively)?
256    Deps {
257        file: PathBuf,
258        #[arg(long, default_value_t = 3)]
259        depth: usize,
260        /// Force a fresh dep-graph build.
261        #[arg(long)]
262        rebuild: bool,
263        #[arg(long)]
264        json: bool,
265        #[arg(long)]
266        compact: bool,
267    },
268    /// Reverse import-graph: who imports this file (transitively)?
269    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    /// Find import cycles via Tarjan SCC.
283    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    /// Emit the dep graph (text or JSON).
296    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    /// Find callers of a symbol — AST-accurate, no grep noise.
309    ///
310    /// Pass the symbol either as a positional `<TARGET>` (suffix-matched
311    /// like `show`/`implements`: `TakeDamage`, `Player.TakeDamage`, or
312    /// `src/Player.cs:TakeDamage` to scope to one file), or via
313    /// `--file <FILE> --symbol <NAME>` for scripting use.
314    Callers {
315        /// Symbol to look up. Optional when `--file` and `--symbol` are passed.
316        #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
317        target: Option<String>,
318        /// Repository root (default: ".").
319        #[arg(default_value = ".")]
320        path: PathBuf,
321        /// Alternative to the `<FILE>:<NAME>` positional form.
322        #[arg(long, requires = "symbol")]
323        file: Option<String>,
324        /// Symbol name when using `--file`.
325        #[arg(long, requires = "file")]
326        symbol: Option<String>,
327        /// Max BFS depth (1 = direct callers only).
328        #[arg(long, default_value_t = 1)]
329        depth: usize,
330        /// Cap result count (mirrors reverse-deps).
331        #[arg(long, default_value_t = 200)]
332        limit: usize,
333        /// Include callers whose target is `Ambiguous` (off by default — noisy).
334        #[arg(long)]
335        include_ambiguous: bool,
336        /// Force a fresh call-graph build.
337        #[arg(long)]
338        rebuild: bool,
339        #[arg(long)]
340        json: bool,
341        #[arg(long)]
342        compact: bool,
343    },
344    /// What does this symbol call? — AST-accurate forward call traversal.
345    ///
346    /// Same target-spec rules as `callers`: positional `<TARGET>` (with
347    /// optional `<FILE>:<NAME>` scoping) or `--file --symbol`.
348    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        /// Include unresolved callees (the `Bare`/`External` bucket).
360        #[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    /// Build, refresh, or inspect the per-repo search index
370    Index {
371        /// Repository root (default: ".")
372        #[arg(default_value = ".")]
373        path: PathBuf,
374        /// Drop any existing cache and rebuild from scratch
375        #[arg(long)]
376        rebuild: bool,
377        /// Print index stats and exit
378        #[arg(long)]
379        stats: bool,
380        /// With --stats: emit output as JSON
381        #[arg(long)]
382        json: bool,
383        /// With --json: emit compact (single-line) JSON
384        #[arg(long)]
385        compact: bool,
386    },
387    /// AST-aware search and rewrite using pattern matching with metavariables
388    Run {
389        /// Pattern to match (e.g. '$FUNC($$$)', 'if ($COND) { $$$BODY }')
390        #[arg(short, long)]
391        pattern: String,
392
393        /// Replacement template (e.g. 'bar($A)'). Omit for search-only mode.
394        #[arg(short, long)]
395        rewrite: Option<String>,
396
397        /// Language (auto-detected from file extension if omitted)
398        #[arg(short, long)]
399        lang: Option<String>,
400
401        /// Paths to search (files or directories). Defaults to current directory.
402        paths: Vec<PathBuf>,
403
404        /// Filter files by glob pattern
405        #[arg(long)]
406        glob: Option<String>,
407
408        /// Actually write changes. Without this flag, only shows matches/dry-run.
409        #[arg(long)]
410        write: bool,
411
412        /// Emit output as JSON
413        #[arg(long)]
414        json: bool,
415
416        /// With --json: compact single-line JSON
417        #[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
426/// Parse `<FILE>:<LINE>` into the two parts. Returns `None` if there's no
427/// colon or the suffix doesn't parse as a u32. Used by `find-related`.
428fn 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
436/// Filter out non-existent paths, build a WalkBuilder with filters and glob overrides,
437/// and return (builder, existing_paths). Returns None if no paths exist.
438fn 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    // Agent-friendly arg handling: instead of dying on a typo or unknown
543    // flag, print the help text so the calling agent can self-correct
544    // without a separate `--help` round-trip. `--help` / `--version` keep
545    // their normal exit-0 behaviour; everything else prints help to stdout
546    // and exits 0 too (agents see "output" rather than "error").
547    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                            // JSON consumers see [] in the payload; humans/agents
625                            // glancing at stderr-free output get a hint too.
626                            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                // Clap guarantees one of: (target alone) or (file + line).
833                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
1059/// Fold `--file <F> --symbol <S>` into the same `<file>:<symbol>` canonical
1060/// form the positional `<TARGET>` arg uses. Clap's `required_unless_present_all`
1061/// guarantees exactly one of the two arms is populated.
1062fn 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(&registry, 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(&registry)
1099                );
1100                return 2;
1101            }
1102        }
1103    } else {
1104        eprintln!(
1105            "must pass --target <name> or --all. Known: {}",
1106            names(&registry)
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(&registry, 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(&registry)
1228                );
1229                return 2;
1230            }
1231        }
1232    } else {
1233        eprintln!(
1234            "must pass --target <name> or --all. Known: {}",
1235            names(&registry)
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/// Picks the adapters to act on for `--all`. For `Scope::Global`, we
1292/// skip targets whose `detect()` reports the CLI is absent (and print a
1293/// note). For `Scope::Local`, the user explicitly opted into this repo
1294/// so detection is bypassed.
1295#[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}