Skip to main content

reflex/cli/
mod.rs

1//! CLI argument parsing and command router
2
3use crate::cache::CacheManager;
4use anyhow::Result;
5use clap::{CommandFactory, Parser, Subcommand};
6use std::path::PathBuf;
7
8mod ask;
9mod deps;
10mod index;
11mod llm;
12mod misc;
13mod pulse;
14mod query;
15mod serve;
16mod snapshot;
17mod watch;
18
19pub use self::query::truncate_preview;
20
21/// Reflex: Local-first, structure-aware code search for AI agents
22#[derive(Parser, Debug)]
23#[command(
24    name = "rfx",
25    version,
26    about = "A fast, deterministic code search engine built for AI",
27    long_about = "Reflex is a local-first, structure-aware code search engine that returns \
28                  structured results (symbols, spans, scopes) with sub-100ms latency. \
29                  Designed for AI coding agents and automation."
30)]
31pub struct Cli {
32    /// Enable verbose logging (can be repeated for more verbosity)
33    #[arg(short, long, action = clap::ArgAction::Count)]
34    pub verbose: u8,
35
36    #[command(subcommand)]
37    pub command: Option<Command>,
38}
39
40#[derive(Subcommand, Debug)]
41pub enum IndexSubcommand {
42    /// Show background symbol indexing status
43    Status,
44
45    /// Compact the cache by removing deleted files
46    ///
47    /// Removes files from the cache that no longer exist on disk and reclaims
48    /// disk space using SQLite VACUUM. This operation is also performed automatically
49    /// in the background every 24 hours during normal usage.
50    ///
51    /// Examples:
52    ///   rfx index compact                # Show compaction results
53    ///   rfx index compact --json         # JSON output
54    Compact {
55        /// Output format as JSON
56        #[arg(long)]
57        json: bool,
58
59        /// Pretty-print JSON output (only with --json)
60        #[arg(long)]
61        pretty: bool,
62    },
63}
64
65#[derive(Subcommand, Debug)]
66pub enum Command {
67    /// Build or update the local code index
68    Index {
69        /// Directory to index (defaults to current directory)
70        #[arg(value_name = "PATH", default_value = ".")]
71        path: PathBuf,
72
73        /// Force full rebuild (ignore incremental cache)
74        #[arg(short, long)]
75        force: bool,
76
77        /// Languages to include (empty = all)
78        #[arg(short, long, value_delimiter = ',')]
79        languages: Vec<String>,
80
81        /// Suppress all output (no progress bar, no summary)
82        #[arg(short, long)]
83        quiet: bool,
84
85        /// Subcommand (status, compact)
86        #[command(subcommand)]
87        command: Option<IndexSubcommand>,
88    },
89
90    /// Query the code index
91    ///
92    /// If no pattern is provided, launches interactive mode (TUI).
93    ///
94    /// Search modes:
95    ///   - Default: Word-boundary matching (precise, finds complete identifiers)
96    ///     Example: rfx query "Error" → finds "Error" but not "NetworkError"
97    ///     Example: rfx query "test" → finds "test" but not "test_helper"
98    ///
99    ///   - Symbol search: Word-boundary for text, exact match for symbols
100    ///     Example: rfx query "parse" --symbols → finds only "parse" function/class
101    ///     Example: rfx query "parse" --kind function → finds only "parse" functions
102    ///
103    ///   - Substring search: Expansive matching (opt-in with --contains)
104    ///     Example: rfx query "mb" --contains → finds "mb", "kmb_dai_ops", "symbol", etc.
105    ///
106    ///   - Regex search: Pattern-controlled matching (opt-in with --regex)
107    ///     Example: rfx query "^mb_.*" --regex → finds "mb_init", "mb_start", etc.
108    ///
109    /// Interactive mode:
110    ///   - Launch with: rfx query
111    ///   - Search, filter, and navigate code results in a live TUI
112    ///   - Press '?' for help, 'q' to quit
113    Query {
114        /// Search pattern (omit to launch interactive mode)
115        pattern: Option<String>,
116
117        /// Search symbol definitions only (functions, classes, etc.)
118        #[arg(short, long)]
119        symbols: bool,
120
121        /// Filter by language
122        /// Supported: rust, python, javascript, typescript, vue, svelte, go, java, php, c, c++, c#, ruby, kotlin, zig
123        #[arg(short, long)]
124        lang: Option<String>,
125
126        /// Filter by symbol kind (implies --symbols)
127        /// Supported: function, class, struct, enum, interface, trait, constant, variable, method, module, namespace, type, macro, property, event, import, export, attribute
128        #[arg(short, long)]
129        kind: Option<String>,
130
131        /// Use AST pattern matching (SLOW: 500ms-2s+, scans all files)
132        ///
133        /// WARNING: AST queries bypass trigram optimization and scan the entire codebase.
134        /// In 95% of cases, use --symbols instead which is 10-100x faster.
135        ///
136        /// When --ast is set, the pattern parameter is interpreted as a Tree-sitter
137        /// S-expression query instead of text search.
138        ///
139        /// RECOMMENDED: Always use --glob to limit scope for better performance.
140        ///
141        /// Examples:
142        ///   Fast (2-50ms):    rfx query "fetch" --symbols --kind function --lang python
143        ///   Slow (500ms-2s):  rfx query "(function_definition) @fn" --ast --lang python
144        ///   Faster with glob: rfx query "(class_declaration) @class" --ast --lang typescript --glob "src/**/*.ts"
145        #[arg(long)]
146        ast: bool,
147
148        /// Use regex pattern matching
149        ///
150        /// Enables standard regex syntax in the search pattern:
151        ///   |  for alternation (OR) - NO backslash needed
152        ///   .  matches any character
153        ///   .*  matches zero or more characters
154        ///   ^  anchors to start of line
155        ///   $  anchors to end of line
156        ///
157        /// Examples:
158        ///   --regex "belongsTo|hasMany"       Match belongsTo OR hasMany
159        ///   --regex "^import.*from"           Lines starting with import...from
160        ///   --regex "fn.*test"                Functions containing 'test'
161        ///
162        /// Note: Cannot be combined with --contains (mutually exclusive)
163        #[arg(short = 'r', long)]
164        regex: bool,
165
166        /// Output format as JSON
167        #[arg(long)]
168        json: bool,
169
170        /// Pretty-print JSON output (only with --json)
171        /// By default, JSON is minified to reduce token usage
172        #[arg(long)]
173        pretty: bool,
174
175        /// AI-optimized mode: returns JSON with ai_instruction field
176        /// Implies --json (minified by default, use --pretty for formatted output)
177        /// Provides context-aware guidance to AI agents on response format and next actions
178        #[arg(long)]
179        ai: bool,
180
181        /// Maximum number of results
182        #[arg(short = 'n', long)]
183        limit: Option<usize>,
184
185        /// Pagination offset (skip first N results after sorting)
186        /// Use with --limit for pagination: --offset 0 --limit 10, then --offset 10 --limit 10
187        #[arg(short = 'o', long)]
188        offset: Option<usize>,
189
190        /// Show full symbol definition (entire function/class body)
191        /// Only applicable to symbol searches
192        #[arg(long)]
193        expand: bool,
194
195        /// Filter by file path (supports substring matching)
196        /// Example: --file math.rs or --file helpers/
197        #[arg(short = 'f', long)]
198        file: Option<String>,
199
200        /// Exact symbol name match (no substring matching)
201        /// Only applicable to symbol searches
202        #[arg(long)]
203        exact: bool,
204
205        /// Use substring matching for both text and symbols (expansive search)
206        ///
207        /// Default behavior uses word-boundary matching for precision:
208        ///   "Error" matches "Error" but not "NetworkError"
209        ///
210        /// With --contains, enables substring matching (expansive):
211        ///   "Error" matches "Error", "NetworkError", "error_handler", etc.
212        ///
213        /// Use cases:
214        ///   - Finding partial matches: --contains "partial"
215        ///   - When you're unsure of exact names
216        ///   - Exploratory searches
217        ///
218        /// Note: Cannot be combined with --regex or --exact (mutually exclusive)
219        #[arg(long)]
220        contains: bool,
221
222        /// Only show count and timing, not the actual results
223        #[arg(short, long)]
224        count: bool,
225
226        /// Query timeout in seconds (0 = no timeout, default: 30)
227        #[arg(short = 't', long, default_value = "30")]
228        timeout: u64,
229
230        /// Use plain text output (disable colors and syntax highlighting)
231        #[arg(long)]
232        plain: bool,
233
234        /// Include files matching glob pattern (can be repeated)
235        ///
236        /// Pattern syntax (NO shell quotes in the pattern itself):
237        ///   ** = recursive match (all subdirectories)
238        ///   *  = single level match (one directory)
239        ///
240        /// Examples:
241        ///   --glob src/**/*.rs          All .rs files under src/ (recursive)
242        ///   --glob app/Models/*.php     PHP files directly in Models/ (not subdirs)
243        ///   --glob tests/**/*_test.go   All test files under tests/
244        ///
245        /// Tip: Use --file for simple substring matching instead:
246        ///   --file User.php             Simpler than --glob **/User.php
247        #[arg(short = 'g', long)]
248        glob: Vec<String>,
249
250        /// Exclude files matching glob pattern (can be repeated)
251        ///
252        /// Same syntax as --glob (** for recursive, * for single level)
253        ///
254        /// Examples:
255        ///   --exclude target/**         Exclude all files under target/
256        ///   --exclude **/*.gen.rs       Exclude generated Rust files
257        ///   --exclude node_modules/**   Exclude npm dependencies
258        #[arg(short = 'x', long)]
259        exclude: Vec<String>,
260
261        /// Return only unique file paths (no line numbers or content)
262        /// Compatible with --json to output ["path1", "path2", ...]
263        #[arg(short = 'p', long)]
264        paths: bool,
265
266        /// Disable smart preview truncation (show full lines)
267        /// By default, previews are truncated to ~100 chars to reduce token usage
268        #[arg(long)]
269        no_truncate: bool,
270
271        /// Number of context lines to show before and after each match (max: 10)
272        /// Example: -C 3 shows 3 lines before and after each match
273        #[arg(short = 'C', long, value_name = "N")]
274        context: Option<usize>,
275
276        /// Return all results (no limit)
277        #[arg(short = 'a', long)]
278        all: bool,
279
280        /// Force execution of potentially expensive queries
281        /// Bypasses broad query detection that prevents queries with:
282        /// • Short patterns (< 3 characters)
283        /// • High candidate counts (> 5,000 files for symbol/AST queries)
284        /// • AST queries without --glob restrictions
285        #[arg(long)]
286        force: bool,
287
288        /// Include dependency information (imports) in results
289        /// Currently only available for Rust files
290        #[arg(long)]
291        dependencies: bool,
292    },
293
294    /// Start a local HTTP API server
295    Serve {
296        /// Port to listen on
297        #[arg(short, long, default_value = "7878")]
298        port: u16,
299
300        /// Host to bind to
301        #[arg(long, default_value = "127.0.0.1")]
302        host: String,
303    },
304
305    /// Show index statistics and cache information
306    Stats {
307        /// Output format as JSON
308        #[arg(long)]
309        json: bool,
310
311        /// Pretty-print JSON output (only with --json)
312        #[arg(long)]
313        pretty: bool,
314    },
315
316    /// Clear the local cache
317    Clear {
318        /// Skip confirmation prompt
319        #[arg(short, long)]
320        yes: bool,
321    },
322
323    /// List all indexed files
324    ListFiles {
325        /// Output format as JSON
326        #[arg(long)]
327        json: bool,
328
329        /// Pretty-print JSON output (only with --json)
330        #[arg(long)]
331        pretty: bool,
332
333        /// Filter by language (e.g. rust, python, typescript)
334        #[arg(short, long)]
335        lang: Option<String>,
336
337        /// Include files matching glob pattern (can be repeated)
338        /// Example: --glob "src/**/*.rs"
339        #[arg(short = 'g', long)]
340        glob: Vec<String>,
341    },
342
343    /// Watch for file changes and auto-reindex
344    ///
345    /// Continuously monitors the workspace for changes and automatically
346    /// triggers incremental reindexing. Useful for IDE integrations and
347    /// keeping the index always fresh during active development.
348    ///
349    /// The debounce timer resets on every file change, batching rapid edits
350    /// (e.g., multi-file refactors, format-on-save) into a single reindex.
351    Watch {
352        /// Directory to watch (defaults to current directory)
353        #[arg(value_name = "PATH", default_value = ".")]
354        path: PathBuf,
355
356        /// Debounce duration in milliseconds (default: 15000 = 15s)
357        /// Waits this long after the last change before reindexing
358        /// Valid range: 5000-30000 (5-30 seconds)
359        #[arg(short, long, default_value = "15000")]
360        debounce: u64,
361
362        /// Suppress output (only log errors)
363        #[arg(short, long)]
364        quiet: bool,
365    },
366
367    /// Start MCP server for AI agent integration
368    ///
369    /// Runs Reflex as a Model Context Protocol (MCP) server using stdio transport.
370    /// This command is automatically invoked by MCP clients like Claude Code and
371    /// should not be run manually.
372    ///
373    /// Configuration example for Claude Code (~/.claude/claude_code_config.json):
374    /// {
375    ///   "mcpServers": {
376    ///     "reflex": {
377    ///       "type": "stdio",
378    ///       "command": "rfx",
379    ///       "args": ["mcp"]
380    ///     }
381    ///   }
382    /// }
383    Mcp,
384
385    /// Analyze codebase structure and dependencies
386    ///
387    /// Perform graph-wide dependency analysis to understand code architecture.
388    /// By default, shows a summary report with counts. Use specific flags for
389    /// detailed results.
390    ///
391    /// Examples:
392    ///   rfx analyze                                # Summary report
393    ///   rfx analyze --circular                     # Find cycles
394    ///   rfx analyze --hotspots                     # Most-imported files
395    ///   rfx analyze --hotspots --min-dependents 5  # Filter by minimum
396    ///   rfx analyze --unused                       # Orphaned files
397    ///   rfx analyze --islands                      # Disconnected components
398    ///   rfx analyze --hotspots --count             # Just show count
399    ///   rfx analyze --circular --glob "src/**"     # Limit to src/
400    Analyze {
401        /// Show circular dependencies
402        #[arg(long)]
403        circular: bool,
404
405        /// Show most-imported files (hotspots)
406        #[arg(long)]
407        hotspots: bool,
408
409        /// Minimum number of dependents for hotspots (default: 2)
410        #[arg(long, default_value = "2", requires = "hotspots")]
411        min_dependents: usize,
412
413        /// Show unused/orphaned files
414        #[arg(long)]
415        unused: bool,
416
417        /// Show disconnected components (islands)
418        #[arg(long)]
419        islands: bool,
420
421        /// Minimum island size (default: 2)
422        #[arg(long, default_value = "2", requires = "islands")]
423        min_island_size: usize,
424
425        /// Maximum island size (default: 500 or 50% of total files)
426        #[arg(long, requires = "islands")]
427        max_island_size: Option<usize>,
428
429        /// Output format: tree (default), table, dot
430        #[arg(short = 'f', long, default_value = "tree")]
431        format: String,
432
433        /// Output as JSON
434        #[arg(long)]
435        json: bool,
436
437        /// Pretty-print JSON output
438        #[arg(long)]
439        pretty: bool,
440
441        /// Only show count and timing, not the actual results
442        #[arg(short, long)]
443        count: bool,
444
445        /// Return all results (no limit)
446        /// Equivalent to --limit 0, convenience flag for unlimited results
447        #[arg(short = 'a', long)]
448        all: bool,
449
450        /// Use plain text output (disable colors and syntax highlighting)
451        #[arg(long)]
452        plain: bool,
453
454        /// Include files matching glob pattern (can be repeated)
455        /// Example: --glob "src/**/*.rs" --glob "tests/**/*.rs"
456        #[arg(short = 'g', long)]
457        glob: Vec<String>,
458
459        /// Exclude files matching glob pattern (can be repeated)
460        /// Example: --exclude "target/**" --exclude "*.gen.rs"
461        #[arg(short = 'x', long)]
462        exclude: Vec<String>,
463
464        /// Force execution of potentially expensive queries
465        /// Bypasses broad query detection
466        #[arg(long)]
467        force: bool,
468
469        /// Maximum number of results
470        #[arg(short = 'n', long)]
471        limit: Option<usize>,
472
473        /// Pagination offset
474        #[arg(short = 'o', long)]
475        offset: Option<usize>,
476
477        /// Sort order for results: asc (ascending) or desc (descending)
478        /// Applies to --hotspots (by import_count), --islands (by size), --circular (by cycle length)
479        /// Default: desc (most important first)
480        #[arg(long)]
481        sort: Option<String>,
482    },
483
484    /// Analyze dependencies for a specific file
485    ///
486    /// Show dependencies and dependents for a single file.
487    /// For graph-wide analysis, use 'rfx analyze' instead.
488    ///
489    /// Examples:
490    ///   rfx deps src/main.rs                  # Show dependencies
491    ///   rfx deps src/config.rs --reverse      # Show dependents
492    ///   rfx deps src/api.rs --depth 3         # Transitive deps
493    Deps {
494        /// File path to analyze
495        file: PathBuf,
496
497        /// Show files that depend on this file (reverse lookup)
498        #[arg(short, long)]
499        reverse: bool,
500
501        /// Traversal depth for transitive dependencies (default: 1)
502        #[arg(short, long, default_value = "1")]
503        depth: usize,
504
505        /// Output format: tree (default), table, dot
506        #[arg(short = 'f', long, default_value = "tree")]
507        format: String,
508
509        /// Output as JSON
510        #[arg(long)]
511        json: bool,
512
513        /// Pretty-print JSON output
514        #[arg(long)]
515        pretty: bool,
516    },
517
518    /// Ask a natural language question and generate search queries
519    ///
520    /// Uses an LLM to translate natural language questions into `rfx query` commands.
521    /// Requires API key configuration for one of: OpenAI, Anthropic, or OpenRouter.
522    ///
523    /// If no question is provided, launches interactive chat mode by default.
524    ///
525    /// Configuration:
526    ///   1. Run interactive setup wizard (recommended):
527    ///      rfx ask --configure
528    ///
529    ///   2. OR set API key via environment variable:
530    ///      - OPENAI_API_KEY, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY
531    ///
532    ///   3. Optional: Configure provider in .reflex/config.toml:
533    ///      [semantic]
534    ///      provider = "openai"  # or anthropic, openrouter
535    ///      model = "gpt-5.1-mini"  # optional, defaults to provider default
536    ///
537    /// Examples:
538    ///   rfx ask --configure                           # Interactive setup wizard
539    ///   rfx ask                                       # Launch interactive chat (default)
540    ///   rfx ask "Find all TODOs in Rust files"
541    ///   rfx ask "Where is the main function defined?" --execute
542    ///   rfx ask "Show me error handling code" --provider openrouter
543    Ask {
544        /// Natural language question
545        question: Option<String>,
546
547        /// Execute queries immediately without confirmation
548        #[arg(short, long)]
549        execute: bool,
550
551        /// Override configured LLM provider (openai, anthropic, openrouter, openai-compatible)
552        #[arg(short, long)]
553        provider: Option<String>,
554
555        /// Output format as JSON
556        #[arg(long)]
557        json: bool,
558
559        /// Pretty-print JSON output (only with --json)
560        #[arg(long)]
561        pretty: bool,
562
563        /// Additional context to inject into prompt (e.g., from `rfx context`)
564        #[arg(long)]
565        additional_context: Option<String>,
566
567        /// Launch interactive configuration wizard to set up AI provider and API key
568        #[arg(long)]
569        configure: bool,
570
571        /// Enable agentic mode (multi-step reasoning with context gathering)
572        #[arg(long)]
573        agentic: bool,
574
575        /// Maximum iterations for query refinement in agentic mode (default: 2)
576        #[arg(long, default_value = "2")]
577        max_iterations: usize,
578
579        /// Skip result evaluation in agentic mode
580        #[arg(long)]
581        no_eval: bool,
582
583        /// Show LLM reasoning blocks at each phase (agentic mode only)
584        #[arg(long)]
585        show_reasoning: bool,
586
587        /// Verbose output: show tool results and details (agentic mode only)
588        #[arg(long)]
589        verbose: bool,
590
591        /// Quiet mode: suppress progress output (agentic mode only)
592        #[arg(long)]
593        quiet: bool,
594
595        /// Generate a conversational answer based on search results
596        #[arg(long)]
597        answer: bool,
598
599        /// Launch interactive chat mode (TUI) with conversation history
600        #[arg(short = 'i', long)]
601        interactive: bool,
602
603        /// Debug mode: output full LLM prompts and retain terminal history
604        #[arg(long)]
605        debug: bool,
606    },
607
608    /// Generate codebase context for AI prompts
609    ///
610    /// Provides structural and organizational context about the project to help
611    /// LLMs understand project layout. Use with `rfx ask --additional-context`.
612    ///
613    /// By default (no flags), shows all context types. Use individual flags to
614    /// select specific context types.
615    ///
616    /// Examples:
617    ///   rfx context                                    # Full context (all types)
618    ///   rfx context --path services/backend            # Full context for monorepo subdirectory
619    ///   rfx context --framework --entry-points         # Specific context types only
620    ///   rfx context --structure --depth 5              # Deep directory tree
621    ///
622    ///   # Use with semantic queries
623    ///   rfx ask "find auth" --additional-context "$(rfx context --framework)"
624    Context {
625        /// Show directory structure (enabled by default)
626        #[arg(long)]
627        structure: bool,
628
629        /// Focus on specific directory path
630        #[arg(short, long)]
631        path: Option<String>,
632
633        /// Show file type distribution (enabled by default)
634        #[arg(long)]
635        file_types: bool,
636
637        /// Detect project type (CLI/library/webapp/monorepo)
638        #[arg(long)]
639        project_type: bool,
640
641        /// Detect frameworks and conventions
642        #[arg(long)]
643        framework: bool,
644
645        /// Show entry point files
646        #[arg(long)]
647        entry_points: bool,
648
649        /// Show test organization pattern
650        #[arg(long)]
651        test_layout: bool,
652
653        /// List important configuration files
654        #[arg(long)]
655        config_files: bool,
656
657        /// Tree depth for --structure (default: 1)
658        #[arg(long, default_value = "1")]
659        depth: usize,
660
661        /// Output as JSON
662        #[arg(long)]
663        json: bool,
664    },
665
666    /// Internal command: Run background symbol indexing (hidden from help)
667    #[command(hide = true)]
668    IndexSymbolsInternal {
669        /// Cache directory path
670        cache_dir: PathBuf,
671    },
672
673    /// Take and manage codebase snapshots for structural tracking
674    ///
675    /// Snapshots capture the structural state of the index (files, dependencies,
676    /// metrics) for diffing and historical analysis.
677    ///
678    /// With no subcommand, creates a new snapshot.
679    ///
680    /// Examples:
681    ///   rfx snapshot               # Create a new snapshot
682    ///   rfx snapshot list           # List available snapshots
683    ///   rfx snapshot diff           # Diff latest vs previous
684    ///   rfx snapshot gc             # Run retention policy
685    Snapshot {
686        #[command(subcommand)]
687        command: Option<SnapshotSubcommand>,
688    },
689
690    /// Generate codebase intelligence surfaces (changelog, wiki, map, site)
691    ///
692    /// Pulse turns structural facts from the index into browsable documentation.
693    /// The `generate` command creates a Zola project and builds it into a static HTML site.
694    ///
695    /// Examples:
696    ///   rfx pulse changelog --no-llm         # Structural-only changelog
697    ///   rfx pulse wiki --no-llm             # Generate wiki pages
698    ///   rfx pulse map                        # Architecture map (mermaid)
699    ///   rfx pulse generate --no-llm          # Full static site (Zola)
700    Pulse {
701        #[command(subcommand)]
702        command: PulseSubcommand,
703    },
704
705    /// Manage LLM provider configuration (shared by `ask` and `pulse`)
706    ///
707    /// Examples:
708    ///   rfx llm config                       # Launch interactive setup wizard
709    ///   rfx llm status                       # Show current LLM configuration
710    Llm {
711        #[command(subcommand)]
712        command: LlmSubcommand,
713    },
714}
715
716#[derive(Subcommand, Debug)]
717pub enum SnapshotSubcommand {
718    /// Compare two snapshots
719    ///
720    /// Defaults to latest vs previous snapshot.
721    Diff {
722        /// Baseline snapshot ID (defaults to second-most-recent)
723        #[arg(long)]
724        baseline: Option<String>,
725
726        /// Current snapshot ID (defaults to most recent)
727        #[arg(long)]
728        current: Option<String>,
729
730        /// Output as JSON
731        #[arg(long)]
732        json: bool,
733
734        /// Pretty-print JSON output
735        #[arg(long)]
736        pretty: bool,
737    },
738
739    /// List available snapshots
740    List {
741        /// Output as JSON
742        #[arg(long)]
743        json: bool,
744
745        /// Pretty-print JSON output
746        #[arg(long)]
747        pretty: bool,
748    },
749
750    /// Run snapshot garbage collection
751    Gc {
752        /// Output as JSON
753        #[arg(long)]
754        json: bool,
755    },
756}
757
758#[derive(Subcommand, Debug)]
759pub enum PulseSubcommand {
760    /// Generate a product-level changelog from recent commits
761    Changelog {
762        /// Number of recent commits to include (default: 20)
763        #[arg(long, default_value = "20")]
764        count: usize,
765
766        /// Skip LLM narration (structural content only)
767        #[arg(long)]
768        no_llm: bool,
769
770        /// Output as JSON
771        #[arg(long)]
772        json: bool,
773
774        /// Pretty-print JSON output
775        #[arg(long)]
776        pretty: bool,
777    },
778
779    /// Generate living wiki pages
780    Wiki {
781        /// Skip LLM narration
782        #[arg(long)]
783        no_llm: bool,
784
785        /// Output directory for markdown files
786        #[arg(short, long)]
787        output: Option<PathBuf>,
788
789        /// Output as JSON
790        #[arg(long)]
791        json: bool,
792    },
793
794    /// Export an architecture map
795    Map {
796        /// Output format (mermaid, d2)
797        #[arg(short, long, default_value = "mermaid")]
798        format: String,
799
800        /// Output file (prints to stdout if not set)
801        #[arg(short, long)]
802        output: Option<PathBuf>,
803
804        /// Zoom level: repo (default) or module path
805        #[arg(short, long)]
806        zoom: Option<String>,
807    },
808
809    /// Generate a complete static site (Zola project + HTML build)
810    ///
811    /// Creates a Zola project with markdown content, templates, and CSS,
812    /// then downloads Zola and builds it into a static HTML site.
813    /// The --base-url maps to Zola's base_url config.
814    Generate {
815        /// Output directory for the Zola project
816        #[arg(short, long, default_value = "pulse-site")]
817        output: PathBuf,
818
819        /// Base URL for the site (maps to Zola's base_url)
820        #[arg(long, default_value = "/")]
821        base_url: String,
822
823        /// Site title
824        #[arg(long)]
825        title: Option<String>,
826
827        /// Surfaces to include (comma-separated: wiki,changelog,map,onboard,timeline,glossary,explorer)
828        #[arg(long)]
829        include: Option<String>,
830
831        /// Skip LLM narration
832        #[arg(long)]
833        no_llm: bool,
834
835        /// Clean output directory before generating
836        #[arg(long)]
837        clean: bool,
838
839        /// Force re-narration (ignore LLM cache)
840        #[arg(long)]
841        force_renarrate: bool,
842
843        /// Maximum concurrent LLM requests (0 = unlimited, default)
844        #[arg(long, default_value = "0")]
845        concurrency: usize,
846
847        /// Maximum directory depth for module discovery (1=top-level only, 2=default)
848        #[arg(long, default_value = "2")]
849        depth: u8,
850
851        /// Minimum file count for a module to be included
852        #[arg(long, default_value = "1")]
853        min_files: usize,
854    },
855
856    /// Serve the generated site locally
857    ///
858    /// Starts a local development server for the Pulse site.
859    /// Uses Zola's built-in server with live reload.
860    Serve {
861        /// Directory containing the generated Zola project
862        #[arg(short, long, default_value = "pulse-site")]
863        output: PathBuf,
864
865        /// Port to serve on
866        #[arg(short, long, default_value = "1111")]
867        port: u16,
868
869        /// Open browser automatically
870        #[arg(long, default_value = "true")]
871        open: bool,
872    },
873
874    /// Generate a developer onboarding guide
875    Onboard {
876        /// Skip LLM narration
877        #[arg(long)]
878        no_llm: bool,
879
880        /// Output as JSON
881        #[arg(long)]
882        json: bool,
883    },
884
885    /// Show development timeline from git history
886    Timeline {
887        /// Output as JSON
888        #[arg(long)]
889        json: bool,
890    },
891
892    /// Generate cross-cutting symbol glossary
893    Glossary {
894        /// Output as JSON
895        #[arg(long)]
896        json: bool,
897    },
898}
899
900#[derive(Subcommand, Debug)]
901pub enum LlmSubcommand {
902    /// Launch interactive configuration wizard for AI provider and API key
903    Config,
904    /// Show current LLM configuration status
905    Status,
906}
907
908/// Format a byte count into a human-readable string (B, KB, MB, GB, TB).
909fn format_bytes(bytes: u64) -> String {
910    const KB: u64 = 1024;
911    const MB: u64 = KB * 1024;
912    const GB: u64 = MB * 1024;
913    const TB: u64 = GB * 1024;
914
915    if bytes >= TB {
916        format!("{:.2} TB", bytes as f64 / TB as f64)
917    } else if bytes >= GB {
918        format!("{:.2} GB", bytes as f64 / GB as f64)
919    } else if bytes >= MB {
920        format!("{:.2} MB", bytes as f64 / MB as f64)
921    } else if bytes >= KB {
922        format!("{:.2} KB", bytes as f64 / KB as f64)
923    } else if bytes > 0 {
924        format!("{} bytes", bytes)
925    } else {
926        "< 1 KB".to_string()
927    }
928}
929
930/// Try to run background cache compaction if needed
931///
932/// Checks if 24+ hours have passed since last compaction.
933/// If yes, spawns a non-blocking background thread to compact the cache.
934/// Main command continues immediately without waiting for compaction.
935///
936/// Compaction is skipped for commands that don't need it:
937/// - Clear (will delete the cache anyway)
938/// - Mcp (long-running server process)
939/// - Watch (long-running watcher process)
940/// - Serve (long-running HTTP server)
941fn try_background_compact(cache: &CacheManager, command: &Command) {
942    // Skip compaction for certain commands
943    match command {
944        Command::Clear { .. } => {
945            log::debug!("Skipping compaction for Clear command");
946            return;
947        }
948        Command::Watch { .. } => {
949            log::debug!("Skipping compaction for Watch command");
950            return;
951        }
952        Command::Serve { .. } => {
953            log::debug!("Skipping compaction for Serve command");
954            return;
955        }
956        _ => {}
957    }
958
959    // Check if compaction should run
960    let should_compact = match cache.should_compact() {
961        Ok(true) => true,
962        Ok(false) => {
963            log::debug!("Compaction not needed yet (last run <24h ago)");
964            return;
965        }
966        Err(e) => {
967            log::warn!("Failed to check compaction status: {}", e);
968            return;
969        }
970    };
971
972    if !should_compact {
973        return;
974    }
975
976    log::info!("Starting background cache compaction...");
977
978    // Clone cache path for background thread
979    let cache_path = cache.path().to_path_buf();
980
981    // Spawn background thread for compaction
982    std::thread::spawn(move || {
983        let cache = CacheManager::new(
984            cache_path
985                .parent()
986                .expect("Cache should have parent directory"),
987        );
988
989        match cache.compact() {
990            Ok(report) => {
991                log::info!(
992                    "Background compaction completed: {} files removed, {:.2} MB saved, took {}ms",
993                    report.files_removed,
994                    report.space_saved_bytes as f64 / 1_048_576.0,
995                    report.duration_ms
996                );
997            }
998            Err(e) => {
999                log::warn!("Background compaction failed: {}", e);
1000            }
1001        }
1002    });
1003
1004    log::debug!("Background compaction thread spawned - main command continuing");
1005}
1006
1007impl Cli {
1008    /// Execute the CLI command
1009    pub fn execute(self) -> Result<()> {
1010        // Setup logging based on verbosity
1011        let log_level = match self.verbose {
1012            0 => "warn",  // Default: only warnings and errors
1013            1 => "info",  // -v: show info messages
1014            2 => "debug", // -vv: show debug messages
1015            _ => "trace", // -vvv: show trace messages
1016        };
1017        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level))
1018            .init();
1019
1020        // Try background compaction (non-blocking) before command execution
1021        if let Some(ref command) = self.command {
1022            // Use current directory as default cache location
1023            let cache = CacheManager::new(".");
1024            try_background_compact(&cache, command);
1025        }
1026
1027        // Execute the subcommand, or show help if no command provided
1028        match self.command {
1029            None => {
1030                // No subcommand: show help
1031                Cli::command().print_help()?;
1032                println!(); // Add newline after help
1033                Ok(())
1034            }
1035            Some(Command::Index {
1036                path,
1037                force,
1038                languages,
1039                quiet,
1040                command,
1041            }) => {
1042                match command {
1043                    None => {
1044                        // Default: run index build
1045                        index::handle_index_build(&path, &force, &languages, &quiet)
1046                    }
1047                    Some(IndexSubcommand::Status) => index::handle_index_status(),
1048                    Some(IndexSubcommand::Compact { json, pretty }) => {
1049                        index::handle_index_compact(&json, &pretty)
1050                    }
1051                }
1052            }
1053            Some(Command::Query {
1054                pattern,
1055                symbols,
1056                lang,
1057                kind,
1058                ast,
1059                regex,
1060                json,
1061                pretty,
1062                ai,
1063                limit,
1064                offset,
1065                expand,
1066                file,
1067                exact,
1068                contains,
1069                count,
1070                timeout,
1071                plain,
1072                glob,
1073                exclude,
1074                paths,
1075                no_truncate,
1076                context,
1077                all,
1078                force,
1079                dependencies,
1080            }) => {
1081                // If no pattern provided, launch interactive mode (REF-68: require TTY)
1082                match pattern {
1083                    None => {
1084                        use crossterm::tty::IsTty;
1085                        if !std::io::stdin().is_tty() {
1086                            eprintln!("error: interactive mode requires a terminal (TTY).");
1087                            eprintln!("Use 'rfx query <pattern>' for non-interactive search.");
1088                            std::process::exit(1);
1089                        }
1090                        query::handle_interactive()
1091                    }
1092                    Some(pattern) => query::handle_query(
1093                        pattern,
1094                        symbols,
1095                        lang,
1096                        kind,
1097                        ast,
1098                        regex,
1099                        json,
1100                        pretty,
1101                        ai,
1102                        limit,
1103                        offset,
1104                        expand,
1105                        file,
1106                        exact,
1107                        contains,
1108                        count,
1109                        timeout,
1110                        plain,
1111                        glob,
1112                        exclude,
1113                        paths,
1114                        no_truncate,
1115                        context,
1116                        all,
1117                        force,
1118                        dependencies,
1119                    ),
1120                }
1121            }
1122            Some(Command::Serve { port, host }) => serve::handle_serve(port, host),
1123            Some(Command::Stats { json, pretty }) => misc::handle_stats(json, pretty),
1124            Some(Command::Clear { yes }) => misc::handle_clear(yes),
1125            Some(Command::ListFiles {
1126                json,
1127                pretty,
1128                lang,
1129                glob,
1130            }) => misc::handle_list_files(json, pretty, lang, glob),
1131            Some(Command::Watch {
1132                path,
1133                debounce,
1134                quiet,
1135            }) => watch::handle_watch(path, debounce, quiet),
1136            Some(Command::Mcp) => misc::handle_mcp(),
1137            Some(Command::Analyze {
1138                circular,
1139                hotspots,
1140                min_dependents,
1141                unused,
1142                islands,
1143                min_island_size,
1144                max_island_size,
1145                format,
1146                json,
1147                pretty,
1148                count,
1149                all,
1150                plain,
1151                glob,
1152                exclude,
1153                force,
1154                limit,
1155                offset,
1156                sort,
1157            }) => deps::handle_analyze(
1158                circular,
1159                hotspots,
1160                min_dependents,
1161                unused,
1162                islands,
1163                min_island_size,
1164                max_island_size,
1165                format,
1166                json,
1167                pretty,
1168                count,
1169                all,
1170                plain,
1171                glob,
1172                exclude,
1173                force,
1174                limit,
1175                offset,
1176                sort,
1177            ),
1178            Some(Command::Deps {
1179                file,
1180                reverse,
1181                depth,
1182                format,
1183                json,
1184                pretty,
1185            }) => deps::handle_deps(file, reverse, depth, format, json, pretty),
1186            Some(Command::Ask {
1187                question,
1188                execute,
1189                provider,
1190                json,
1191                pretty,
1192                additional_context,
1193                configure,
1194                agentic,
1195                max_iterations,
1196                no_eval,
1197                show_reasoning,
1198                verbose,
1199                quiet,
1200                answer,
1201                interactive,
1202                debug,
1203            }) => ask::handle_ask(
1204                question,
1205                execute,
1206                provider,
1207                json,
1208                pretty,
1209                additional_context,
1210                configure,
1211                agentic,
1212                max_iterations,
1213                no_eval,
1214                show_reasoning,
1215                verbose,
1216                quiet,
1217                answer,
1218                interactive,
1219                debug,
1220            ),
1221            Some(Command::Context {
1222                structure,
1223                path,
1224                file_types,
1225                project_type,
1226                framework,
1227                entry_points,
1228                test_layout,
1229                config_files,
1230                depth,
1231                json,
1232            }) => misc::handle_context(
1233                structure,
1234                path,
1235                file_types,
1236                project_type,
1237                framework,
1238                entry_points,
1239                test_layout,
1240                config_files,
1241                depth,
1242                json,
1243            ),
1244            Some(Command::IndexSymbolsInternal { cache_dir }) => {
1245                index::handle_index_symbols_internal(cache_dir)
1246            }
1247            Some(Command::Snapshot { command }) => match command {
1248                None => snapshot::handle_snapshot_create(),
1249                Some(SnapshotSubcommand::List { json, pretty }) => {
1250                    snapshot::handle_snapshot_list(json, pretty)
1251                }
1252                Some(SnapshotSubcommand::Diff {
1253                    baseline,
1254                    current,
1255                    json,
1256                    pretty,
1257                }) => snapshot::handle_snapshot_diff(baseline, current, json, pretty),
1258                Some(SnapshotSubcommand::Gc { json }) => snapshot::handle_snapshot_gc(json),
1259            },
1260            Some(Command::Pulse { command }) => match command {
1261                PulseSubcommand::Changelog {
1262                    count,
1263                    no_llm,
1264                    json,
1265                    pretty,
1266                } => pulse::handle_pulse_changelog(count, no_llm, json, pretty),
1267                PulseSubcommand::Wiki {
1268                    no_llm,
1269                    output,
1270                    json,
1271                } => pulse::handle_pulse_wiki(no_llm, output, json),
1272                PulseSubcommand::Map {
1273                    format,
1274                    output,
1275                    zoom,
1276                } => pulse::handle_pulse_map(format, output, zoom),
1277                PulseSubcommand::Generate {
1278                    output,
1279                    base_url,
1280                    title,
1281                    include,
1282                    no_llm,
1283                    clean,
1284                    force_renarrate,
1285                    concurrency,
1286                    depth,
1287                    min_files,
1288                } => pulse::handle_pulse_generate(
1289                    output,
1290                    base_url,
1291                    title,
1292                    include,
1293                    no_llm,
1294                    clean,
1295                    force_renarrate,
1296                    concurrency,
1297                    depth,
1298                    min_files,
1299                ),
1300                PulseSubcommand::Serve { output, port, open } => {
1301                    pulse::handle_pulse_serve(output, port, open)
1302                }
1303                PulseSubcommand::Onboard { no_llm, json } => {
1304                    pulse::handle_pulse_onboard(no_llm, json)
1305                }
1306                PulseSubcommand::Timeline { json } => pulse::handle_pulse_timeline(json),
1307                PulseSubcommand::Glossary { json } => pulse::handle_pulse_glossary(json),
1308            },
1309            Some(Command::Llm { command }) => match command {
1310                LlmSubcommand::Config => llm::handle_llm_config(),
1311                LlmSubcommand::Status => llm::handle_llm_status(),
1312            },
1313        }
1314    }
1315}