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