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