Skip to main content

rtk/
lib.rs

1mod analytics;
2mod cmds;
3mod core;
4mod discover;
5mod hooks;
6mod learn;
7mod parser;
8
9// Re-export command modules for routing
10use cmds::cloud::{aws_cmd, container, curl_cmd, psql_cmd, wget_cmd};
11use cmds::dotnet::{binlog, dotnet_cmd, dotnet_format_report, dotnet_trx};
12use cmds::git::{diff_cmd, gh_cmd, git, glab_cmd, gt_cmd};
13use cmds::go::{go_cmd, golangci_cmd};
14use cmds::js::{
15    lint_cmd, next_cmd, npm_cmd, playwright_cmd, pnpm_cmd, prettier_cmd, prisma_cmd, tsc_cmd,
16    vitest_cmd,
17};
18use cmds::jvm::{gradlew_cmd, mvn_cmd};
19use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd};
20use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd};
21use cmds::rust::{cargo_cmd, runner};
22use cmds::system::{
23    deps, env_cmd, find_cmd, format_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd,
24    read, summary, tree, wc_cmd,
25};
26
27use anyhow::{Context, Result};
28use clap::error::ErrorKind;
29use clap::{Parser, Subcommand, ValueEnum};
30use std::ffi::OsString;
31use std::path::{Path, PathBuf};
32use std::process::ExitCode;
33
34pub use discover::registry::rewrite_command_with_proxy;
35
36pub mod tracking {
37    pub use crate::core::tracking::*;
38}
39
40pub mod utils {
41    pub use crate::core::utils::*;
42}
43
44/// Target agent for hook installation.
45#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
46pub enum AgentTarget {
47    /// Claude Code (default)
48    Claude,
49    /// Cursor Agent (editor and CLI)
50    Cursor,
51    /// Windsurf IDE (Cascade)
52    Windsurf,
53    /// Cline / Roo Code (VS Code)
54    Cline,
55    /// Kilo Code
56    Kilocode,
57    /// Google Antigravity
58    Antigravity,
59    /// Pi coding agent
60    Pi,
61    /// Hermes CLI
62    Hermes,
63}
64
65#[derive(Parser)]
66#[command(
67    name = "rtk",
68    version,
69    about = "Rust Token Killer - Minimize LLM token consumption",
70    long_about = "A high-performance CLI proxy designed to filter and summarize system outputs before they reach your LLM context."
71)]
72struct Cli {
73    #[command(subcommand)]
74    command: Commands,
75
76    /// Verbosity level (-v, -vv, -vvv)
77    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
78    verbose: u8,
79
80    /// Ultra-compact mode: ASCII icons, inline format (Level 2 optimizations)
81    #[arg(long, global = true)]
82    ultra_compact: bool,
83
84    /// Set SKIP_ENV_VALIDATION=1 for child processes (Next.js, tsc, lint, prisma)
85    #[arg(long = "skip-env", global = true)]
86    skip_env: bool,
87}
88
89#[derive(Debug, Subcommand)]
90enum Commands {
91    /// List directory contents with token-optimized output (proxy to native ls)
92    Ls {
93        /// Arguments passed to ls (supports all native ls flags like -l, -a, -h, -R)
94        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
95        args: Vec<String>,
96    },
97
98    /// Directory tree with token-optimized output (proxy to native tree)
99    Tree {
100        /// Arguments passed to tree (supports all native tree flags like -L, -d, -a)
101        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
102        args: Vec<String>,
103    },
104
105    /// Read file with intelligent filtering
106    Read {
107        /// Files to read (supports multiple, like cat)
108        #[arg(required = true, num_args = 1..)]
109        files: Vec<PathBuf>,
110        /// Filter: none (default, full content), minimal, aggressive
111        #[arg(short, long, default_value = "none")]
112        level: core::filter::FilterLevel,
113        /// Max lines
114        #[arg(short, long, conflicts_with = "tail_lines")]
115        max_lines: Option<usize>,
116        /// Keep only last N lines
117        #[arg(long, conflicts_with = "max_lines")]
118        tail_lines: Option<usize>,
119        /// Show line numbers
120        #[arg(short = 'n', long)]
121        line_numbers: bool,
122    },
123
124    /// Generate 2-line technical summary (heuristic-based)
125    Smart {
126        /// File to analyze
127        file: PathBuf,
128        /// Model: heuristic
129        #[arg(short, long, default_value = "heuristic")]
130        model: String,
131        /// Force model download
132        #[arg(long)]
133        force_download: bool,
134    },
135
136    /// Git commands with compact output
137    Git {
138        /// Change to directory before executing (like git -C <path>, can be repeated)
139        #[arg(short = 'C', action = clap::ArgAction::Append)]
140        directory: Vec<String>,
141
142        /// Git configuration override (like git -c key=value, can be repeated)
143        #[arg(short = 'c', action = clap::ArgAction::Append)]
144        config_override: Vec<String>,
145
146        /// Set the path to the .git directory
147        #[arg(long = "git-dir")]
148        git_dir: Option<String>,
149
150        /// Set the path to the working tree
151        #[arg(long = "work-tree")]
152        work_tree: Option<String>,
153
154        /// Disable pager (like git --no-pager)
155        #[arg(long = "no-pager")]
156        no_pager: bool,
157
158        /// Skip optional locks (like git --no-optional-locks)
159        #[arg(long = "no-optional-locks")]
160        no_optional_locks: bool,
161
162        /// Treat repository as bare (like git --bare)
163        #[arg(long)]
164        bare: bool,
165
166        /// Treat pathspecs literally (like git --literal-pathspecs)
167        #[arg(long = "literal-pathspecs")]
168        literal_pathspecs: bool,
169
170        #[command(subcommand)]
171        command: GitCommands,
172    },
173
174    /// GitHub CLI (gh) commands with token-optimized output
175    Gh {
176        /// Subcommand: pr, issue, run, repo
177        subcommand: String,
178        /// Additional arguments
179        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
180        args: Vec<String>,
181    },
182
183    /// GitLab CLI (glab) commands with token-optimized output
184    Glab {
185        /// Target repository (owner/repo), passed as glab -R flag
186        #[arg(short = 'R', long = "repo")]
187        repo: Option<String>,
188        /// Target group, passed as glab -g flag
189        #[arg(short = 'g', long = "group")]
190        group: Option<String>,
191        /// Subcommand: mr, issue, ci, pipeline, api
192        subcommand: String,
193        /// Additional arguments
194        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
195        args: Vec<String>,
196    },
197
198    /// AWS CLI with compact output (force JSON, compress)
199    Aws {
200        /// AWS service subcommand (e.g., sts, s3, ec2, ecs, rds, cloudformation)
201        subcommand: String,
202        /// Additional arguments
203        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
204        args: Vec<String>,
205    },
206
207    /// PostgreSQL client with compact output (strip borders, compress tables)
208    #[command(disable_help_flag = true)]
209    Psql {
210        /// psql arguments
211        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
212        args: Vec<String>,
213    },
214
215    /// pnpm commands with ultra-compact output
216    Pnpm {
217        /// pnpm filter arguments (can be repeated: --filter @app1 --filter @app2)
218        #[arg(long, short = 'F')]
219        filter: Vec<String>,
220
221        #[command(subcommand)]
222        command: PnpmCommands,
223    },
224
225    /// Run command and show only errors/warnings
226    Err {
227        /// Command to run
228        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
229        command: Vec<String>,
230    },
231
232    /// Run tests and show only failures
233    Test {
234        /// Test command (e.g. cargo test)
235        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
236        command: Vec<String>,
237    },
238
239    /// Show JSON (compact values by default, or keys-only with --keys-only)
240    Json {
241        /// JSON file
242        file: PathBuf,
243        /// Max depth
244        #[arg(short, long, default_value = "5")]
245        depth: usize,
246        /// Show keys only (strip all values, show structure)
247        #[arg(long)]
248        keys_only: bool,
249    },
250
251    /// Summarize project dependencies
252    Deps {
253        /// Project path
254        #[arg(default_value = ".")]
255        path: PathBuf,
256    },
257
258    /// Show environment variables (filtered, sensitive masked)
259    Env {
260        /// Filter by name (e.g. PATH, AWS)
261        #[arg(short, long)]
262        filter: Option<String>,
263        /// Show all (include sensitive)
264        #[arg(long)]
265        show_all: bool,
266    },
267
268    /// Find files with compact tree output (accepts native find flags like -name, -type)
269    Find {
270        /// All find arguments (supports both RTK and native find syntax)
271        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
272        args: Vec<String>,
273    },
274
275    /// Ultra-condensed diff (only changed lines)
276    Diff {
277        /// First file or - for stdin (unified diff)
278        file1: PathBuf,
279        /// Second file (optional if stdin)
280        file2: Option<PathBuf>,
281    },
282
283    /// Filter and deduplicate log output
284    Log {
285        /// Log file (omit for stdin)
286        file: Option<PathBuf>,
287    },
288
289    /// .NET commands with compact output (build/test/restore/format)
290    Dotnet {
291        #[command(subcommand)]
292        command: DotnetCommands,
293    },
294
295    /// Docker commands with compact output
296    Docker {
297        #[command(subcommand)]
298        command: DockerCommands,
299    },
300
301    /// Kubectl commands with compact output
302    Kubectl {
303        #[command(subcommand)]
304        command: KubectlCommands,
305    },
306
307    /// Run command and show heuristic summary
308    Summary {
309        /// Command to run and summarize
310        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
311        command: Vec<String>,
312    },
313
314    /// Compact grep - strips whitespace, truncates, groups by file
315    Grep {
316        /// Pattern to search
317        pattern: String,
318        /// Path to search in
319        #[arg(default_value = ".")]
320        path: String,
321        /// Max line length
322        #[arg(short = 'l', long, default_value = "80")]
323        max_len: usize,
324        /// Max results to show
325        #[arg(short, long, default_value = "200")]
326        max: usize,
327        /// Show only match context (not full line)
328        #[arg(long)]
329        context_only: bool,
330        /// Filter by file type (e.g., ts, py, rust)
331        #[arg(short = 't', long)]
332        file_type: Option<String>,
333        /// Show line numbers (always on, accepted for grep/rg compatibility)
334        #[arg(short = 'n', long)]
335        line_numbers: bool,
336        /// Extra ripgrep arguments (e.g., -i, -A 3, -w, --glob)
337        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
338        extra_args: Vec<String>,
339    },
340
341    /// Initialize rtk instructions for assistant CLI usage
342    Init {
343        /// Add to global assistant config directory instead of local project file
344        #[arg(short, long)]
345        global: bool,
346
347        /// Install OpenCode plugin (in addition to Claude Code)
348        #[arg(long)]
349        opencode: bool,
350
351        /// Initialize for Gemini CLI instead of Claude Code
352        #[arg(long)]
353        gemini: bool,
354
355        /// Target agent to install hooks for (default: claude)
356        #[arg(long, value_enum)]
357        agent: Option<AgentTarget>,
358
359        /// Show current configuration
360        #[arg(long)]
361        show: bool,
362
363        /// Inject full instructions into CLAUDE.md (legacy mode)
364        #[arg(long = "claude-md", group = "mode")]
365        claude_md: bool,
366
367        /// Hook only, no RTK.md
368        #[arg(long = "hook-only", group = "mode")]
369        hook_only: bool,
370
371        /// Auto-patch settings.json without prompting
372        #[arg(long = "auto-patch", group = "patch")]
373        auto_patch: bool,
374
375        /// Skip settings.json patching (print manual instructions)
376        #[arg(long = "no-patch", group = "patch")]
377        no_patch: bool,
378
379        /// Remove RTK artifacts for the selected assistant mode
380        #[arg(long)]
381        uninstall: bool,
382
383        /// Target Codex CLI (uses AGENTS.md + RTK.md, no Claude hook patching)
384        #[arg(long)]
385        codex: bool,
386
387        /// Install GitHub Copilot integration (VS Code + CLI)
388        #[arg(long)]
389        copilot: bool,
390        /// Preview changes without writing any files (combine with -v to show content)
391        #[arg(long = "dry-run", conflicts_with = "show")]
392        dry_run: bool,
393    },
394
395    /// Download with compact output (strips progress bars)
396    Wget {
397        /// URL to download
398        url: String,
399        /// Output file (-O - for stdout)
400        #[arg(short = 'O', long = "output-document", allow_hyphen_values = true)]
401        output: Option<String>,
402        /// Additional wget arguments
403        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
404        args: Vec<String>,
405    },
406
407    /// Word/line/byte count with compact output (strips paths and padding)
408    Wc {
409        /// Arguments passed to wc (files, flags like -l, -w, -c)
410        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
411        args: Vec<String>,
412    },
413
414    /// Show token savings summary and history
415    Gain {
416        /// Filter statistics to current project (current working directory) // added
417        #[arg(short, long)]
418        project: bool,
419        /// Show ASCII graph of daily savings
420        #[arg(short, long)]
421        graph: bool,
422        /// Show recent command history
423        #[arg(short = 'H', long)]
424        history: bool,
425        /// Show monthly quota savings estimate
426        #[arg(short, long)]
427        quota: bool,
428        /// Subscription tier for quota calculation: pro, 5x, 20x
429        #[arg(short, long, default_value = "20x", requires = "quota")]
430        tier: String,
431        /// Show detailed daily breakdown (all days)
432        #[arg(short, long)]
433        daily: bool,
434        /// Show weekly breakdown
435        #[arg(short, long)]
436        weekly: bool,
437        /// Show monthly breakdown
438        #[arg(short, long)]
439        monthly: bool,
440        /// Show all time breakdowns (daily + weekly + monthly)
441        #[arg(short, long)]
442        all: bool,
443        /// Output format: text, json, csv
444        #[arg(short, long, default_value = "text")]
445        format: String,
446        /// Show parse failure log (commands that fell back to raw execution)
447        #[arg(short = 'F', long)]
448        failures: bool,
449        /// Reset all token savings stats to zero
450        #[arg(long)]
451        reset: bool,
452        /// Skip confirmation prompt when resetting
453        #[arg(long, requires = "reset")]
454        yes: bool,
455    },
456
457    /// Claude Code economics: spending (ccusage) vs savings (rtk) analysis
458    CcEconomics {
459        /// Show detailed daily breakdown
460        #[arg(short, long)]
461        daily: bool,
462        /// Show weekly breakdown
463        #[arg(short, long)]
464        weekly: bool,
465        /// Show monthly breakdown
466        #[arg(short, long)]
467        monthly: bool,
468        /// Show all time breakdowns (daily + weekly + monthly)
469        #[arg(short, long)]
470        all: bool,
471        /// Output format: text, json, csv
472        #[arg(short, long, default_value = "text")]
473        format: String,
474    },
475
476    /// Show or create configuration file
477    Config {
478        /// Create default config file
479        #[arg(long)]
480        create: bool,
481    },
482
483    /// Jest commands with compact output
484    Jest {
485        /// Additional jest arguments
486        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
487        args: Vec<String>,
488    },
489
490    /// Vitest commands with compact output
491    Vitest {
492        /// Additional vitest arguments
493        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
494        args: Vec<String>,
495    },
496
497    /// Prisma commands with compact output (no ASCII art)
498    Prisma {
499        #[command(subcommand)]
500        command: PrismaCommands,
501    },
502
503    /// TypeScript compiler with grouped error output
504    Tsc {
505        /// TypeScript compiler arguments
506        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
507        args: Vec<String>,
508    },
509
510    /// Next.js build with compact output
511    Next {
512        /// Next.js build arguments
513        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
514        args: Vec<String>,
515    },
516
517    /// ESLint with grouped rule violations
518    Lint {
519        /// Linter arguments
520        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
521        args: Vec<String>,
522    },
523
524    /// Prettier format checker with compact output
525    Prettier {
526        /// Prettier arguments (e.g., --check, --write)
527        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
528        args: Vec<String>,
529    },
530
531    /// Universal format checker (prettier, black, ruff format)
532    Format {
533        /// Formatter arguments (auto-detects formatter from project files)
534        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
535        args: Vec<String>,
536    },
537
538    /// Playwright E2E tests with compact output
539    Playwright {
540        /// Playwright arguments
541        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
542        args: Vec<String>,
543    },
544
545    /// Cargo commands with compact output
546    Cargo {
547        #[command(subcommand)]
548        command: CargoCommands,
549    },
550
551    /// npm run with filtered output (strip boilerplate)
552    Npm {
553        /// npm run arguments (script name + options)
554        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
555        args: Vec<String>,
556    },
557
558    /// npx with intelligent routing (tsc, eslint, prisma -> specialized filters)
559    Npx {
560        /// npx arguments (command + options)
561        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
562        args: Vec<String>,
563    },
564
565    /// Curl with auto-JSON detection and schema output
566    Curl {
567        /// Curl arguments (URL + options)
568        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
569        args: Vec<String>,
570    },
571
572    /// Discover missed RTK savings from Claude Code history
573    Discover {
574        /// Filter by project path (substring match)
575        #[arg(short, long)]
576        project: Option<String>,
577        /// Max commands per section
578        #[arg(short, long, default_value = "15")]
579        limit: usize,
580        /// Scan all projects (default: current project only)
581        #[arg(short, long)]
582        all: bool,
583        /// Limit to sessions from last N days
584        #[arg(short, long, default_value = "30")]
585        since: u64,
586        /// Output format: text, json
587        #[arg(short, long, default_value = "text")]
588        format: String,
589    },
590
591    /// Show RTK adoption across Claude Code sessions
592    Session {},
593
594    /// Manage telemetry consent and data (RGPD/GDPR)
595    Telemetry {
596        #[command(subcommand)]
597        command: core::telemetry_cmd::TelemetrySubcommand,
598    },
599
600    /// Learn CLI corrections from Claude Code error history
601    Learn {
602        /// Filter by project path (substring match)
603        #[arg(short, long)]
604        project: Option<String>,
605        /// Scan all projects (default: current project only)
606        #[arg(short, long)]
607        all: bool,
608        /// Limit to sessions from last N days
609        #[arg(short, long, default_value = "30")]
610        since: u64,
611        /// Output format: text, json
612        #[arg(short, long, default_value = "text")]
613        format: String,
614        /// Generate .claude/rules/cli-corrections.md file
615        #[arg(short, long)]
616        write_rules: bool,
617        /// Minimum confidence threshold (0.0-1.0)
618        #[arg(long, default_value = "0.6")]
619        min_confidence: f64,
620        /// Minimum occurrences to include in report
621        #[arg(long, default_value = "1")]
622        min_occurrences: usize,
623    },
624
625    /// Execute a shell command via sh -c (raw, no filtering or tracking)
626    Run {
627        /// Command string to execute (use -c for shell-like invocation)
628        #[arg(short = 'c', long = "command")]
629        command: Option<String>,
630        /// Positional command arguments (alternative to -c)
631        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
632        args: Vec<String>,
633    },
634
635    /// Execute command without filtering but track usage
636    Proxy {
637        /// Command and arguments to execute
638        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
639        args: Vec<OsString>,
640    },
641
642    /// Read stdin, apply filter, print filtered output (Unix pipe mode)
643    Pipe {
644        /// Filter name (cargo-test, pytest, grep, find, git-log, etc.)
645        #[arg(short, long)]
646        filter: Option<String>,
647
648        /// Pass stdin through without filtering
649        #[arg(long)]
650        passthrough: bool,
651    },
652
653    /// Trust project-local TOML filters in current directory
654    Trust {
655        /// List all trusted projects
656        #[arg(long)]
657        list: bool,
658    },
659
660    /// Revoke trust for project-local TOML filters
661    Untrust,
662
663    /// Verify hook integrity and run TOML filter inline tests
664    Verify {
665        /// Run tests only for this filter name
666        #[arg(long)]
667        filter: Option<String>,
668        /// Fail if any filter has no inline tests (CI mode)
669        #[arg(long)]
670        require_all: bool,
671    },
672
673    /// Ruff linter/formatter with compact output
674    Ruff {
675        /// Ruff arguments (e.g., check, format --check)
676        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
677        args: Vec<String>,
678    },
679
680    /// Pytest test runner with compact output
681    Pytest {
682        /// Pytest arguments
683        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
684        args: Vec<String>,
685    },
686
687    /// Mypy type checker with grouped error output
688    Mypy {
689        /// Mypy arguments
690        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
691        args: Vec<String>,
692    },
693
694    /// Rake/Rails test with compact Minitest output (Ruby)
695    Rake {
696        /// Rake arguments (e.g., test, test TEST=path/to/test.rb)
697        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
698        args: Vec<String>,
699    },
700
701    /// RuboCop linter with compact output (Ruby)
702    Rubocop {
703        /// RuboCop arguments (e.g., --auto-correct, -A)
704        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
705        args: Vec<String>,
706    },
707
708    /// RSpec test runner with compact output (Rails/Ruby)
709    Rspec {
710        /// RSpec arguments (e.g., spec/models, --tag focus)
711        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
712        args: Vec<String>,
713    },
714
715    /// Pip package manager with compact output (auto-detects uv)
716    Pip {
717        /// Pip arguments (e.g., list, outdated, install)
718        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
719        args: Vec<String>,
720    },
721
722    /// Go commands with compact output
723    Go {
724        #[command(subcommand)]
725        command: GoCommands,
726    },
727
728    /// Graphite (gt) stacked PR commands with compact output
729    Gt {
730        #[command(subcommand)]
731        command: GtCommands,
732    },
733
734    /// golangci-lint wrapper with compact `run` support and passthrough for other invocations
735    #[command(name = "golangci-lint")]
736    GolangciLint {
737        /// Additional golangci-lint arguments
738        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
739        args: Vec<String>,
740    },
741
742    /// Android Gradle wrapper with compact output (build, test, lint)
743    #[command(name = "gradlew")]
744    Gradlew {
745        /// Gradle tasks and arguments (e.g., assembleDebug, testDebugUnitTest, lint, --info)
746        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
747        args: Vec<String>,
748    },
749
750    /// Apache Maven wrapper with compact output (test, integration-test, compile, package, install, verify, deploy)
751    #[command(name = "mvn")]
752    Mvn {
753        /// Maven goals and arguments (e.g., clean install, -DskipTests test, -X)
754        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
755        args: Vec<String>,
756    },
757
758    /// Show hook rewrite audit metrics (requires RTK_HOOK_AUDIT=1)
759    #[command(name = "hook-audit")]
760    HookAudit {
761        /// Show entries from last N days (0 = all time)
762        #[arg(short, long, default_value = "7")]
763        since: u64,
764    },
765
766    /// Rewrite a raw command to its RTK equivalent (single source of truth for hooks)
767    ///
768    /// Exits 0 and prints the rewritten command if supported.
769    /// Exits 1 with no output if the command has no RTK equivalent.
770    ///
771    /// Used by Claude Code, Gemini CLI, and other LLM hooks:
772    ///   REWRITTEN=$(rtk rewrite "$CMD") || exit 0
773    Rewrite {
774        /// Raw command to rewrite (e.g. "git status", "cargo test && git push")
775        /// Accepts multiple args: `rtk rewrite ls -al` is equivalent to `rtk rewrite "ls -al"`
776        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
777        args: Vec<String>,
778    },
779
780    /// Hook processors for LLM CLI tools (Gemini CLI, Copilot, etc.)
781    Hook {
782        #[command(subcommand)]
783        command: HookCommands,
784    },
785}
786
787#[derive(Debug, Subcommand)]
788enum HookCommands {
789    /// Process Claude Code PreToolUse hook (reads JSON from stdin)
790    Claude,
791    /// Process Cursor Agent hook (reads JSON from stdin)
792    Cursor,
793    /// Process Gemini CLI BeforeTool hook (reads JSON from stdin)
794    Gemini,
795    /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin)
796    Copilot,
797    /// Check how a command would be rewritten by the hook engine (dry-run)
798    Check {
799        /// Target agent
800        #[arg(long, default_value = "claude")]
801        agent: String,
802        /// Command to check
803        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
804        command: Vec<String>,
805    },
806}
807
808#[derive(Debug, Subcommand)]
809enum GitCommands {
810    /// Condensed diff output
811    Diff {
812        /// Git arguments (supports all git diff flags like --stat, --cached, etc)
813        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
814        args: Vec<String>,
815    },
816    /// One-line commit history
817    Log {
818        /// Git arguments (supports all git log flags like --oneline, --graph, --all)
819        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
820        args: Vec<String>,
821    },
822    /// Compact status (supports all git status flags)
823    Status {
824        /// Git arguments (supports all git status flags like --porcelain, --short, -s)
825        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
826        args: Vec<String>,
827    },
828    /// Compact show (commit summary + stat + compacted diff)
829    Show {
830        /// Git arguments (supports all git show flags)
831        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
832        args: Vec<String>,
833    },
834    /// Add files → "ok"
835    Add {
836        /// Files and flags to add (supports all git add flags like -A, -p, --all, etc)
837        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
838        args: Vec<String>,
839    },
840    /// Commit → "ok \<hash\>"
841    Commit {
842        /// Git commit arguments (supports -a, -m, --amend, --allow-empty, etc)
843        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
844        args: Vec<String>,
845    },
846    /// Push → "ok \<branch\>"
847    Push {
848        /// Git push arguments (supports -u, remote, branch, etc.)
849        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
850        args: Vec<String>,
851    },
852    /// Pull → "ok \<stats\>"
853    Pull {
854        /// Git pull arguments (supports --rebase, remote, branch, etc.)
855        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
856        args: Vec<String>,
857    },
858    /// Compact branch listing (current/local/remote)
859    Branch {
860        /// Git branch arguments (supports -d, -D, -m, etc.)
861        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
862        args: Vec<String>,
863    },
864    /// Fetch → "ok fetched (N new refs)"
865    Fetch {
866        /// Git fetch arguments
867        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
868        args: Vec<String>,
869    },
870    /// Stash management (list, show, pop, apply, drop)
871    Stash {
872        /// Subcommand: list, show, pop, apply, drop, push
873        subcommand: Option<String>,
874        /// Additional arguments
875        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
876        args: Vec<String>,
877    },
878    /// Compact worktree listing
879    Worktree {
880        /// Git worktree arguments (add, remove, prune, or empty for list)
881        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
882        args: Vec<String>,
883    },
884    /// Passthrough: runs any unsupported git subcommand directly
885    #[command(external_subcommand)]
886    Other(Vec<OsString>),
887}
888
889#[derive(Debug, Subcommand)]
890enum PnpmCommands {
891    /// List installed packages (ultra-dense)
892    List {
893        /// Depth level (default: 0)
894        #[arg(short, long, default_value = "0")]
895        depth: usize,
896        /// Additional pnpm arguments
897        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
898        args: Vec<String>,
899    },
900    /// Show outdated packages (condensed: "pkg: old → new")
901    Outdated {
902        /// Additional pnpm arguments
903        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
904        args: Vec<String>,
905    },
906    /// Install packages (filter progress bars)
907    Install {
908        /// Additional pnpm arguments
909        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
910        args: Vec<String>,
911    },
912    /// Typecheck (delegates to tsc filter)
913    Typecheck {
914        /// Additional typecheck arguments
915        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
916        args: Vec<String>,
917    },
918    /// Passthrough: runs any unsupported pnpm subcommand directly
919    #[command(external_subcommand)]
920    Other(Vec<OsString>),
921}
922
923#[derive(Debug, Subcommand)]
924enum DockerCommands {
925    /// List running containers
926    Ps {
927        #[arg(short = 'a', long)]
928        all: bool,
929    },
930    /// List images
931    Images,
932    /// Show container logs (deduplicated)
933    Logs { container: String },
934    /// Docker Compose commands with compact output
935    Compose {
936        #[command(subcommand)]
937        command: ComposeCommands,
938    },
939    /// Passthrough: runs any unsupported docker subcommand directly
940    #[command(external_subcommand)]
941    Other(Vec<OsString>),
942}
943
944#[derive(Debug, Subcommand)]
945enum ComposeCommands {
946    /// List compose services (compact)
947    Ps {
948        #[arg(short = 'a', long)]
949        all: bool,
950    },
951    /// Show compose logs (deduplicated)
952    Logs {
953        /// Optional service name
954        service: Option<String>,
955        /// Number of log lines to fetch
956        #[arg(long, default_value_t = 100)]
957        tail: u32,
958    },
959    /// Build compose services (summary)
960    Build {
961        /// Optional service name
962        service: Option<String>,
963    },
964    /// Passthrough: runs any unsupported compose subcommand directly
965    #[command(external_subcommand)]
966    Other(Vec<OsString>),
967}
968
969#[derive(Debug, Subcommand)]
970enum KubectlCommands {
971    /// Get Kubernetes resources (compact for pods/services)
972    Get {
973        /// kubectl get arguments
974        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
975        args: Vec<String>,
976    },
977    /// List pods
978    Pods {
979        #[arg(short, long)]
980        namespace: Option<String>,
981        /// All namespaces
982        #[arg(short = 'A', long)]
983        all: bool,
984    },
985    /// List services
986    Services {
987        #[arg(short, long)]
988        namespace: Option<String>,
989        /// All namespaces
990        #[arg(short = 'A', long)]
991        all: bool,
992    },
993    /// Show pod logs (deduplicated)
994    Logs {
995        pod: String,
996        #[arg(short, long)]
997        container: Option<String>,
998    },
999    /// Passthrough: runs any unsupported kubectl subcommand directly
1000    #[command(external_subcommand)]
1001    Other(Vec<OsString>),
1002}
1003
1004#[derive(Debug, Subcommand)]
1005enum PrismaCommands {
1006    /// Generate Prisma Client (strip ASCII art)
1007    Generate {
1008        /// Additional prisma arguments
1009        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1010        args: Vec<String>,
1011    },
1012    /// Manage migrations
1013    Migrate {
1014        #[command(subcommand)]
1015        command: PrismaMigrateCommands,
1016    },
1017    /// Push schema to database
1018    DbPush {
1019        /// Additional prisma arguments
1020        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1021        args: Vec<String>,
1022    },
1023}
1024
1025#[derive(Debug, Subcommand)]
1026enum PrismaMigrateCommands {
1027    /// Create and apply migration
1028    Dev {
1029        /// Migration name
1030        #[arg(short, long)]
1031        name: Option<String>,
1032        /// Additional arguments
1033        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1034        args: Vec<String>,
1035    },
1036    /// Check migration status
1037    Status {
1038        /// Additional arguments
1039        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1040        args: Vec<String>,
1041    },
1042    /// Deploy migrations to production
1043    Deploy {
1044        /// Additional arguments
1045        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1046        args: Vec<String>,
1047    },
1048}
1049
1050#[derive(Debug, Subcommand)]
1051enum CargoCommands {
1052    /// Build with compact output (strip Compiling lines, keep errors)
1053    Build {
1054        /// Additional cargo build arguments
1055        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1056        args: Vec<String>,
1057    },
1058    /// Test with failures-only output
1059    Test {
1060        /// Additional cargo test arguments
1061        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1062        args: Vec<String>,
1063    },
1064    /// Clippy with warnings grouped by lint rule
1065    Clippy {
1066        /// Additional cargo clippy arguments
1067        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1068        args: Vec<String>,
1069    },
1070    /// Check with compact output (strip Checking lines, keep errors)
1071    Check {
1072        /// Additional cargo check arguments
1073        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1074        args: Vec<String>,
1075    },
1076    /// Install with compact output (strip dep compilation, keep installed/errors)
1077    Install {
1078        /// Additional cargo install arguments
1079        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1080        args: Vec<String>,
1081    },
1082    /// Nextest with failures-only output
1083    Nextest {
1084        /// Additional cargo nextest arguments (e.g., run, list, --lib)
1085        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1086        args: Vec<String>,
1087    },
1088    /// Passthrough: runs any unsupported cargo subcommand directly
1089    #[command(external_subcommand)]
1090    Other(Vec<OsString>),
1091}
1092
1093#[derive(Debug, Subcommand)]
1094enum DotnetCommands {
1095    /// Build with compact output
1096    Build {
1097        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1098        args: Vec<String>,
1099    },
1100    /// Test with compact output
1101    Test {
1102        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1103        args: Vec<String>,
1104    },
1105    /// Restore with compact output
1106    Restore {
1107        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1108        args: Vec<String>,
1109    },
1110    /// Format with compact output
1111    Format {
1112        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1113        args: Vec<String>,
1114    },
1115    /// Passthrough: runs any unsupported dotnet subcommand directly
1116    #[command(external_subcommand)]
1117    Other(Vec<OsString>),
1118}
1119
1120#[derive(Debug, Subcommand)]
1121enum GoCommands {
1122    /// Run tests with compact output (90% token reduction via JSON streaming)
1123    Test {
1124        /// Additional go test arguments
1125        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1126        args: Vec<String>,
1127    },
1128    /// Build with compact output (errors only)
1129    Build {
1130        /// Additional go build arguments
1131        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1132        args: Vec<String>,
1133    },
1134    /// Vet with compact output
1135    Vet {
1136        /// Additional go vet arguments
1137        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1138        args: Vec<String>,
1139    },
1140    /// Passthrough: runs any unsupported go subcommand directly
1141    #[command(external_subcommand)]
1142    Other(Vec<OsString>),
1143}
1144
1145/// RTK-only subcommands that should never fall back to raw execution.
1146/// If Clap fails to parse these, show the Clap error directly.
1147const RTK_META_COMMANDS: &[&str] = &[
1148    "gain",
1149    "discover",
1150    "learn",
1151    "init",
1152    "config",
1153    "proxy",
1154    "run",
1155    "hook",
1156    "hook-audit",
1157    "pipe",
1158    "cc-economics",
1159    "verify",
1160    "trust",
1161    "untrust",
1162    "session",
1163    "rewrite",
1164];
1165
1166fn run_fallback(parse_error: clap::Error, argv: &[OsString]) -> Result<i32> {
1167    // `argv` is the full command line given to the entrypoint (binary name
1168    // first). Hosted callers pass a synthetic argv that differs from
1169    // `std::env::args()`, so never re-read the process arguments here.
1170    let args: Vec<String> = argv
1171        .iter()
1172        .skip(1)
1173        .map(|s| s.to_string_lossy().into_owned())
1174        .collect();
1175
1176    // No args → show Clap's error (user ran just "rtk" with bad syntax)
1177    if args.is_empty() {
1178        parse_error.exit();
1179    }
1180
1181    // RTK meta-commands should never fall back to raw execution.
1182    // e.g. `rtk gain --badtypo` should show Clap's error, not try to run `gain` from $PATH.
1183    if RTK_META_COMMANDS.contains(&args[0].as_str()) {
1184        parse_error.exit();
1185    }
1186
1187    let raw_command = args.join(" ");
1188    let error_message = core::utils::strip_ansi(&parse_error.to_string());
1189
1190    // Start timer before execution to capture actual command runtime
1191    let timer = core::tracking::TimedExecution::start();
1192
1193    // TOML filter lookup — bypass with RTK_NO_TOML=1
1194    // Use basename of args[0] so absolute paths (/usr/bin/make) still match "^make\b".
1195    let lookup_cmd = {
1196        let base = std::path::Path::new(&args[0])
1197            .file_name()
1198            .map(|n| n.to_string_lossy().into_owned())
1199            .unwrap_or_else(|| args[0].clone());
1200        std::iter::once(base.as_str())
1201            .chain(args[1..].iter().map(|s| s.as_str()))
1202            .collect::<Vec<_>>()
1203            .join(" ")
1204    };
1205    let toml_match = if std::env::var("RTK_NO_TOML").ok().as_deref() == Some("1") {
1206        None
1207    } else {
1208        core::toml_filter::find_matching_filter(&lookup_cmd)
1209    };
1210
1211    if let Some(filter) = toml_match {
1212        // TOML match: capture stdout for filtering
1213        let result = if filter.filter_stderr {
1214            // Merge stderr into stdout so the filter can strip banners emitted by tools like liquibase
1215            core::utils::resolved_command(&args[0])
1216                .args(&args[1..])
1217                .stdin(std::process::Stdio::inherit())
1218                .stdout(std::process::Stdio::piped())
1219                .stderr(std::process::Stdio::piped()) // captured for merging
1220                .output()
1221        } else {
1222            core::utils::resolved_command(&args[0])
1223                .args(&args[1..])
1224                .stdin(std::process::Stdio::inherit())
1225                .stdout(std::process::Stdio::piped()) // capture
1226                .stderr(std::process::Stdio::inherit()) // stderr always direct
1227                .output()
1228        };
1229
1230        match result {
1231            Ok(output) => {
1232                let exit_code = core::utils::exit_code_from_output(&output, &raw_command);
1233                let stdout_raw = String::from_utf8_lossy(&output.stdout);
1234                let stderr_raw = String::from_utf8_lossy(&output.stderr);
1235
1236                // Merge stderr into the text to filter when filter_stderr is enabled;
1237                // otherwise emit stderr directly so it is always visible.
1238                let combined_raw = if filter.filter_stderr {
1239                    format!("{}{}", stdout_raw, stderr_raw)
1240                } else {
1241                    stdout_raw.to_string()
1242                };
1243                // Tee raw output BEFORE filtering on failure — lets LLM re-read if needed
1244                let tee_hint = if !output.status.success() {
1245                    core::tee::tee_and_hint(&combined_raw, &raw_command, exit_code)
1246                } else {
1247                    None
1248                };
1249
1250                let filtered = core::toml_filter::apply_filter(filter, &combined_raw);
1251                println!("{}", filtered);
1252                if let Some(hint) = tee_hint {
1253                    println!("{}", hint);
1254                }
1255
1256                timer.track(
1257                    &raw_command,
1258                    &format!("rtk:toml {}", raw_command),
1259                    &combined_raw,
1260                    &filtered,
1261                );
1262                core::tracking::record_parse_failure_silent(&raw_command, &error_message, true);
1263
1264                Ok(exit_code)
1265            }
1266            Err(e) => {
1267                // Command not found — same behaviour as no-TOML path
1268                core::tracking::record_parse_failure_silent(&raw_command, &error_message, false);
1269                eprintln!("[rtk: {}]", e);
1270                Ok(127)
1271            }
1272        }
1273    } else {
1274        // No TOML match: original passthrough behaviour (Stdio::inherit, streaming)
1275        let status = core::utils::resolved_command(&args[0])
1276            .args(&args[1..])
1277            .stdin(std::process::Stdio::inherit())
1278            .stdout(std::process::Stdio::inherit())
1279            .stderr(std::process::Stdio::inherit())
1280            .status();
1281
1282        match status {
1283            Ok(s) => {
1284                timer.track_passthrough(&raw_command, &format!("rtk fallback: {}", raw_command));
1285
1286                core::tracking::record_parse_failure_silent(&raw_command, &error_message, true);
1287
1288                Ok(core::utils::exit_code_from_status(&s, &raw_command))
1289            }
1290            Err(e) => {
1291                core::tracking::record_parse_failure_silent(&raw_command, &error_message, false);
1292                // Command not found or other OS error — single message, no duplicate Clap error
1293                eprintln!("[rtk: {}]", e);
1294                Ok(127)
1295            }
1296        }
1297    }
1298}
1299
1300#[derive(Debug, Subcommand)]
1301enum GtCommands {
1302    /// Compact stack log output
1303    Log {
1304        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1305        args: Vec<String>,
1306    },
1307    /// Compact submit output
1308    Submit {
1309        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1310        args: Vec<String>,
1311    },
1312    /// Compact sync output
1313    Sync {
1314        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1315        args: Vec<String>,
1316    },
1317    /// Compact restack output
1318    Restack {
1319        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1320        args: Vec<String>,
1321    },
1322    /// Compact create output
1323    Create {
1324        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1325        args: Vec<String>,
1326    },
1327    /// Branch info and management
1328    Branch {
1329        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1330        args: Vec<String>,
1331    },
1332    /// Passthrough: git-passthrough detection or direct gt execution
1333    #[command(external_subcommand)]
1334    Other(Vec<OsString>),
1335}
1336
1337/// Split a string into shell-like tokens, respecting single and double quotes.
1338/// e.g. `git log --format="%H %s"` → ["git", "log", "--format=%H %s"]
1339fn shell_split(input: &str) -> Vec<String> {
1340    discover::lexer::shell_split(input)
1341}
1342
1343/// Merge pnpm global filters args with other ones for standard String-based commands
1344fn merge_pnpm_args(filters: &[String], args: &[String]) -> Vec<String> {
1345    filters
1346        .iter()
1347        .map(|filter| format!("--filter={}", filter))
1348        .chain(args.iter().cloned())
1349        .collect()
1350}
1351
1352/// Merge pnpm global filters args with other ones, using OsString for passthrough compatibility
1353fn merge_pnpm_args_os(filters: &[String], args: &[OsString]) -> Vec<OsString> {
1354    filters
1355        .iter()
1356        .map(|filter| OsString::from(format!("--filter={}", filter)))
1357        .chain(args.iter().cloned())
1358        .collect()
1359}
1360
1361/// Validate that pnpm filters are only used in the global context, not before subcommands like tsc.
1362fn validate_pnpm_filters(filters: &[String], command: &PnpmCommands) -> Option<String> {
1363    // Check if this is a Build or Typecheck command with filters
1364    match command {
1365        PnpmCommands::Typecheck { .. } => {
1366            // FIXME: if filters are present, we should find out which workspaces are selected before running rtk dedicated commands
1367            if !filters.is_empty() {
1368                let cmd_name = match command {
1369                    PnpmCommands::Typecheck { .. } => "tsc",
1370                    _ => unreachable!(),
1371                };
1372                let msg = format!(
1373                    "[rtk] warning: --filter is not yet supported for pnpm {}, filters preceding the subcommand will be ignored",
1374                    cmd_name
1375                );
1376                return Some(msg);
1377            }
1378            None
1379        }
1380        _ => None,
1381    }
1382}
1383
1384fn reset_sigpipe() {
1385    // Reset SIGPIPE to default handler so writing to a closed pipe
1386    // e.g `rtk git log | head` exits silently instead of panicking.
1387    // Rust ignores SIGPIPE by default and with panic="abort" in the
1388    // release profile that becomes SIGABRT + coredump.
1389    #[cfg(unix)]
1390    #[allow(unsafe_code)]
1391    // nosemgrep: unsafe-block
1392    unsafe {
1393        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1394    }
1395}
1396
1397pub fn main_entry() -> Result<()> {
1398    std::process::exit(cli_entry_code(std::env::args_os()));
1399}
1400
1401/// Run the rtk CLI with a caller-supplied argv and return its [`ExitCode`].
1402///
1403/// `args` is a full argv: clap skips the first item as the program name, so
1404/// hosts must pass a placeholder (e.g. `"rtk"`) ahead of the real arguments —
1405/// `["rtk", "git", "status"]`, not `["git", "status"]`.
1406///
1407/// # Process exit
1408///
1409/// This dispatch is exec-like, not a pure function call: several paths
1410/// terminate the process directly instead of returning. `--help` and
1411/// `--version` exit after printing, parse errors on rtk meta-commands exit
1412/// with clap's error, and filters propagate a failing child command's exit
1413/// code via `std::process::exit`. Hosts must not rely on code running after
1414/// this call (cleanup, `atexit`, destructors).
1415pub fn cli_entry(args: impl IntoIterator<Item = OsString>) -> ExitCode {
1416    i32_to_exit_code(cli_entry_code(args))
1417}
1418
1419/// Like [`cli_entry`], but returns the raw `i32` exit code.
1420///
1421/// The same argv shape and process-exit caveats as [`cli_entry`] apply.
1422pub fn cli_entry_code(args: impl IntoIterator<Item = OsString>) -> i32 {
1423    reset_sigpipe();
1424    match run_cli_from(args) {
1425        Ok(code) => code,
1426        Err(e) => {
1427            eprintln!("rtk: {:#}", e);
1428            1
1429        }
1430    }
1431}
1432
1433fn i32_to_exit_code(code: i32) -> ExitCode {
1434    if code == 0 {
1435        ExitCode::SUCCESS
1436    } else if (1..=255).contains(&code) {
1437        ExitCode::from(code as u8)
1438    } else {
1439        ExitCode::from(1)
1440    }
1441}
1442
1443fn uninstall_init_dispatch<UninstallHermes, UninstallStandard>(
1444    agent: Option<AgentTarget>,
1445    global: bool,
1446    gemini: bool,
1447    codex: bool,
1448    ctx: hooks::init::InitContext,
1449    uninstall_hermes: UninstallHermes,
1450    uninstall_standard: UninstallStandard,
1451) -> Result<()>
1452where
1453    UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>,
1454    UninstallStandard: FnOnce(bool, bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>,
1455{
1456    if agent == Some(AgentTarget::Hermes) {
1457        uninstall_hermes(ctx)
1458    } else {
1459        let cursor = agent == Some(AgentTarget::Cursor);
1460        let pi = agent == Some(AgentTarget::Pi);
1461        uninstall_standard(global, gemini, codex, cursor, pi, ctx)
1462    }
1463}
1464
1465fn run_cli_from(args: impl IntoIterator<Item = OsString>) -> Result<i32> {
1466    let argv: Vec<OsString> = args.into_iter().collect();
1467    let hosted = hosted_mode();
1468
1469    // Fire-and-forget telemetry ping (1/day, non-blocking)
1470    if !hosted {
1471        core::telemetry::maybe_ping();
1472    }
1473
1474    let cli = match Cli::try_parse_from(&argv) {
1475        Ok(cli) => cli,
1476        Err(e) => {
1477            if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
1478                e.exit();
1479            }
1480            return run_fallback(e, &argv);
1481        }
1482    };
1483
1484    // Warn if installed hook is outdated/missing (1/day, non-blocking).
1485    // Skip for Gain — it shows its own inline hook warning.
1486    if !hosted && !matches!(cli.command, Commands::Gain { .. }) {
1487        hooks::hook_check::maybe_warn();
1488    }
1489
1490    // Runtime integrity check for operational commands.
1491    // Meta commands (init, gain, verify, config, etc.) skip the check
1492    // because they don't go through the hook pipeline.
1493    if !hosted && is_operational_command(&cli.command) {
1494        hooks::integrity::runtime_check()?;
1495    }
1496
1497    let code = match cli.command {
1498        Commands::Ls { args } => ls::run(&args, cli.verbose)?,
1499
1500        Commands::Tree { args } => tree::run(&args, cli.verbose)?,
1501
1502        // ISSUE #989: support multiple files (cat file1 file2 → rtk read file1 file2)
1503        Commands::Read {
1504            files,
1505            level,
1506            max_lines,
1507            tail_lines,
1508            line_numbers,
1509        } => {
1510            let mut had_error = false;
1511            let mut stdin_seen = false;
1512            for file in &files {
1513                let result = if file == Path::new("-") {
1514                    if stdin_seen {
1515                        eprintln!("rtk: warning: stdin specified more than once");
1516                        continue;
1517                    }
1518                    stdin_seen = true;
1519                    read::run_stdin(level, max_lines, tail_lines, line_numbers, cli.verbose)
1520                } else {
1521                    read::run(
1522                        file,
1523                        level,
1524                        max_lines,
1525                        tail_lines,
1526                        line_numbers,
1527                        cli.verbose,
1528                    )
1529                };
1530                if let Err(e) = result {
1531                    eprintln!("cat: {}: {}", file.display(), e.root_cause());
1532                    had_error = true;
1533                }
1534            }
1535            if had_error {
1536                1
1537            } else {
1538                0
1539            }
1540        }
1541
1542        Commands::Smart {
1543            file,
1544            model,
1545            force_download,
1546        } => {
1547            local_llm::run(&file, &model, force_download, cli.verbose)?;
1548            0
1549        }
1550
1551        Commands::Git {
1552            directory,
1553            config_override,
1554            git_dir,
1555            work_tree,
1556            no_pager,
1557            no_optional_locks,
1558            bare,
1559            literal_pathspecs,
1560            command,
1561        } => {
1562            // Build global git args (inserted between "git" and subcommand)
1563            let mut global_args: Vec<String> = Vec::new();
1564            for dir in &directory {
1565                global_args.push("-C".to_string());
1566                global_args.push(dir.clone());
1567            }
1568            for cfg in &config_override {
1569                global_args.push("-c".to_string());
1570                global_args.push(cfg.clone());
1571            }
1572            if let Some(ref dir) = git_dir {
1573                global_args.push("--git-dir".to_string());
1574                global_args.push(dir.clone());
1575            }
1576            if let Some(ref tree) = work_tree {
1577                global_args.push("--work-tree".to_string());
1578                global_args.push(tree.clone());
1579            }
1580            if no_pager {
1581                global_args.push("--no-pager".to_string());
1582            }
1583            if no_optional_locks {
1584                global_args.push("--no-optional-locks".to_string());
1585            }
1586            if bare {
1587                global_args.push("--bare".to_string());
1588            }
1589            if literal_pathspecs {
1590                global_args.push("--literal-pathspecs".to_string());
1591            }
1592
1593            match command {
1594                GitCommands::Diff { args } => git::run(
1595                    git::GitCommand::Diff,
1596                    &args,
1597                    None,
1598                    cli.verbose,
1599                    &global_args,
1600                )?,
1601                GitCommands::Log { args } => {
1602                    git::run(git::GitCommand::Log, &args, None, cli.verbose, &global_args)?
1603                }
1604                GitCommands::Status { args } => git::run(
1605                    git::GitCommand::Status,
1606                    &args,
1607                    None,
1608                    cli.verbose,
1609                    &global_args,
1610                )?,
1611                GitCommands::Show { args } => git::run(
1612                    git::GitCommand::Show,
1613                    &args,
1614                    None,
1615                    cli.verbose,
1616                    &global_args,
1617                )?,
1618                GitCommands::Add { args } => {
1619                    git::run(git::GitCommand::Add, &args, None, cli.verbose, &global_args)?
1620                }
1621                GitCommands::Commit { args } => git::run(
1622                    git::GitCommand::Commit,
1623                    &args,
1624                    None,
1625                    cli.verbose,
1626                    &global_args,
1627                )?,
1628                GitCommands::Push { args } => git::run(
1629                    git::GitCommand::Push,
1630                    &args,
1631                    None,
1632                    cli.verbose,
1633                    &global_args,
1634                )?,
1635                GitCommands::Pull { args } => git::run(
1636                    git::GitCommand::Pull,
1637                    &args,
1638                    None,
1639                    cli.verbose,
1640                    &global_args,
1641                )?,
1642                GitCommands::Branch { args } => git::run(
1643                    git::GitCommand::Branch,
1644                    &args,
1645                    None,
1646                    cli.verbose,
1647                    &global_args,
1648                )?,
1649                GitCommands::Fetch { args } => git::run(
1650                    git::GitCommand::Fetch,
1651                    &args,
1652                    None,
1653                    cli.verbose,
1654                    &global_args,
1655                )?,
1656                GitCommands::Stash { subcommand, args } => git::run(
1657                    git::GitCommand::Stash { subcommand },
1658                    &args,
1659                    None,
1660                    cli.verbose,
1661                    &global_args,
1662                )?,
1663                GitCommands::Worktree { args } => git::run(
1664                    git::GitCommand::Worktree,
1665                    &args,
1666                    None,
1667                    cli.verbose,
1668                    &global_args,
1669                )?,
1670                GitCommands::Other(args) => git::run_passthrough(&args, &global_args, cli.verbose)?,
1671            }
1672        }
1673
1674        Commands::Gh { subcommand, args } => {
1675            gh_cmd::run(&subcommand, &args, cli.verbose, cli.ultra_compact)?
1676        }
1677
1678        Commands::Glab {
1679            repo,
1680            group,
1681            subcommand,
1682            mut args,
1683        } => {
1684            // Append -R / -g flags at end so they don't interfere with
1685            // subcommand dispatch (args[0] must be the sub-subcommand like "list")
1686            if let Some(r) = repo {
1687                args.push("-R".to_string());
1688                args.push(r);
1689            }
1690            if let Some(g) = group {
1691                args.push("-g".to_string());
1692                args.push(g);
1693            }
1694            glab_cmd::run(&subcommand, &args, cli.verbose, cli.ultra_compact)?
1695        }
1696
1697        Commands::Aws { subcommand, args } => aws_cmd::run(&subcommand, &args, cli.verbose)?,
1698
1699        Commands::Psql { args } => psql_cmd::run(&args, cli.verbose)?,
1700
1701        Commands::Pnpm { filter, command } => {
1702            // Warns user if filters are used with unsupported subcommands like typecheck
1703            if let Some(warning) = validate_pnpm_filters(&filter, &command) {
1704                eprintln!("{}", warning);
1705            }
1706
1707            match command {
1708                PnpmCommands::List { depth, args } => pnpm_cmd::run(
1709                    pnpm_cmd::PnpmCommand::List { depth },
1710                    &merge_pnpm_args(&filter, &args),
1711                    cli.verbose,
1712                )?,
1713                PnpmCommands::Outdated { args } => pnpm_cmd::run(
1714                    pnpm_cmd::PnpmCommand::Outdated,
1715                    &merge_pnpm_args(&filter, &args),
1716                    cli.verbose,
1717                )?,
1718                PnpmCommands::Install { args } => pnpm_cmd::run(
1719                    pnpm_cmd::PnpmCommand::Install,
1720                    &merge_pnpm_args(&filter, &args),
1721                    cli.verbose,
1722                )?,
1723                PnpmCommands::Typecheck { args } => tsc_cmd::run(&args, cli.verbose)?,
1724                PnpmCommands::Other(args) => {
1725                    pnpm_cmd::run_passthrough(&merge_pnpm_args_os(&filter, &args), cli.verbose)?
1726                }
1727            }
1728        }
1729
1730        Commands::Err { command } => {
1731            let cmd = command.join(" ");
1732            runner::run_err(&cmd, cli.verbose)?
1733        }
1734
1735        Commands::Test { command } => {
1736            let cmd = command.join(" ");
1737            runner::run_test(&cmd, cli.verbose)?
1738        }
1739
1740        Commands::Json {
1741            file,
1742            depth,
1743            keys_only,
1744        } => {
1745            if file == Path::new("-") {
1746                json_cmd::run_stdin(depth, keys_only, cli.verbose)?;
1747            } else {
1748                json_cmd::run(&file, depth, keys_only, cli.verbose)?;
1749            }
1750            0
1751        }
1752
1753        Commands::Deps { path } => {
1754            deps::run(&path, cli.verbose)?;
1755            0
1756        }
1757
1758        Commands::Env { filter, show_all } => {
1759            env_cmd::run(filter.as_deref(), show_all, cli.verbose)?;
1760            0
1761        }
1762
1763        Commands::Find { args } => {
1764            find_cmd::run_from_args(&args, cli.verbose)?;
1765            0
1766        }
1767
1768        Commands::Diff { file1, file2 } => {
1769            if let Some(f2) = file2 {
1770                diff_cmd::run(&file1, &f2, cli.verbose)?;
1771            } else {
1772                diff_cmd::run_stdin(cli.verbose)?;
1773            }
1774            0
1775        }
1776
1777        Commands::Log { file } => {
1778            if let Some(f) = file {
1779                log_cmd::run_file(&f, cli.verbose)?;
1780            } else {
1781                log_cmd::run_stdin(cli.verbose)?;
1782            }
1783            0
1784        }
1785
1786        Commands::Dotnet { command } => match command {
1787            DotnetCommands::Build { args } => dotnet_cmd::run_build(&args, cli.verbose)?,
1788            DotnetCommands::Test { args } => dotnet_cmd::run_test(&args, cli.verbose)?,
1789            DotnetCommands::Restore { args } => dotnet_cmd::run_restore(&args, cli.verbose)?,
1790            DotnetCommands::Format { args } => dotnet_cmd::run_format(&args, cli.verbose)?,
1791            DotnetCommands::Other(args) => dotnet_cmd::run_passthrough(&args, cli.verbose)?,
1792        },
1793
1794        Commands::Docker { command } => match command {
1795            DockerCommands::Ps { all } => {
1796                let cmd = if all {
1797                    container::ContainerCmd::DockerPsAll
1798                } else {
1799                    container::ContainerCmd::DockerPs
1800                };
1801                container::run(cmd, &[], cli.verbose)?
1802            }
1803            DockerCommands::Images => {
1804                container::run(container::ContainerCmd::DockerImages, &[], cli.verbose)?
1805            }
1806            DockerCommands::Logs { container: c } => {
1807                container::run(container::ContainerCmd::DockerLogs, &[c], cli.verbose)?
1808            }
1809            DockerCommands::Compose { command: compose } => match compose {
1810                ComposeCommands::Ps { all } => container::run_compose_ps(all, cli.verbose)?,
1811                ComposeCommands::Logs { service, tail } => {
1812                    container::run_compose_logs(service.as_deref(), tail, cli.verbose)?
1813                }
1814                ComposeCommands::Build { service } => {
1815                    container::run_compose_build(service.as_deref(), cli.verbose)?
1816                }
1817                ComposeCommands::Other(args) => {
1818                    container::run_compose_passthrough(&args, cli.verbose)?
1819                }
1820            },
1821            DockerCommands::Other(args) => container::run_docker_passthrough(&args, cli.verbose)?,
1822        },
1823
1824        Commands::Kubectl { command } => match command {
1825            KubectlCommands::Get { args } => container::run_kubectl_get(&args, cli.verbose)?,
1826            KubectlCommands::Pods { namespace, all } => {
1827                let mut args: Vec<String> = Vec::new();
1828                if all {
1829                    args.push("-A".to_string());
1830                } else if let Some(n) = namespace {
1831                    args.push("-n".to_string());
1832                    args.push(n);
1833                }
1834                container::run(container::ContainerCmd::KubectlPods, &args, cli.verbose)?
1835            }
1836            KubectlCommands::Services { namespace, all } => {
1837                let mut args: Vec<String> = Vec::new();
1838                if all {
1839                    args.push("-A".to_string());
1840                } else if let Some(n) = namespace {
1841                    args.push("-n".to_string());
1842                    args.push(n);
1843                }
1844                container::run(container::ContainerCmd::KubectlServices, &args, cli.verbose)?
1845            }
1846            KubectlCommands::Logs { pod, container: c } => {
1847                let mut args = vec![pod];
1848                if let Some(cont) = c {
1849                    args.push("-c".to_string());
1850                    args.push(cont);
1851                }
1852                container::run(container::ContainerCmd::KubectlLogs, &args, cli.verbose)?
1853            }
1854            KubectlCommands::Other(args) => container::run_kubectl_passthrough(&args, cli.verbose)?,
1855        },
1856
1857        Commands::Summary { command } => {
1858            let cmd = command.join(" ");
1859            summary::run(&cmd, cli.verbose)?
1860        }
1861
1862        Commands::Grep {
1863            pattern,
1864            path,
1865            max_len,
1866            max,
1867            context_only,
1868            file_type,
1869            line_numbers: _, // no-op: line numbers always enabled in grep_cmd::run
1870            extra_args,
1871        } => grep_cmd::run(
1872            &pattern,
1873            &path,
1874            max_len,
1875            max,
1876            context_only,
1877            file_type.as_deref(),
1878            &extra_args,
1879            cli.verbose,
1880        )?,
1881
1882        Commands::Init {
1883            global,
1884            opencode,
1885            gemini,
1886            agent,
1887            show,
1888            claude_md,
1889            hook_only,
1890            auto_patch,
1891            no_patch,
1892            uninstall,
1893            codex,
1894            copilot,
1895            dry_run,
1896        } => {
1897            let ctx = hooks::init::InitContext {
1898                verbose: cli.verbose,
1899                dry_run,
1900            };
1901            if show {
1902                hooks::init::show_config(codex)?;
1903            } else if uninstall && copilot {
1904                if global {
1905                    hooks::init::uninstall_copilot_global(ctx)?;
1906                } else {
1907                    hooks::init::uninstall_copilot(ctx)?;
1908                }
1909            } else if uninstall {
1910                uninstall_init_dispatch(
1911                    agent,
1912                    global,
1913                    gemini,
1914                    codex,
1915                    ctx,
1916                    hooks::init::uninstall_hermes,
1917                    hooks::init::uninstall,
1918                )?;
1919            } else if gemini {
1920                let patch_mode = if auto_patch {
1921                    hooks::init::PatchMode::Auto
1922                } else if no_patch {
1923                    hooks::init::PatchMode::Skip
1924                } else {
1925                    hooks::init::PatchMode::Ask
1926                };
1927                hooks::init::run_gemini(global, hook_only, patch_mode, ctx)?;
1928            } else if copilot {
1929                if global {
1930                    hooks::init::run_copilot_global(ctx)?;
1931                } else {
1932                    hooks::init::run_copilot(ctx)?;
1933                }
1934            } else if agent == Some(AgentTarget::Pi) {
1935                hooks::init::run_pi_mode(global, ctx)?
1936            } else if agent == Some(AgentTarget::Kilocode) {
1937                if global {
1938                    anyhow::bail!("Kilo Code is project-scoped. Use: rtk init --agent kilocode");
1939                }
1940                hooks::init::run_kilocode_mode(ctx)?;
1941            } else if agent == Some(AgentTarget::Antigravity) {
1942                if global {
1943                    anyhow::bail!(
1944                        "Antigravity is project-scoped. Use: rtk init --agent antigravity"
1945                    );
1946                }
1947                hooks::init::run_antigravity_mode(ctx)?;
1948            } else if agent == Some(AgentTarget::Hermes) {
1949                hooks::init::run_hermes_mode(ctx)?;
1950            } else {
1951                let install_opencode = opencode;
1952                let install_claude = !opencode;
1953                let install_cursor = agent == Some(AgentTarget::Cursor);
1954                let install_windsurf = agent == Some(AgentTarget::Windsurf);
1955                let install_cline = agent == Some(AgentTarget::Cline);
1956
1957                let patch_mode = if auto_patch {
1958                    hooks::init::PatchMode::Auto
1959                } else if no_patch {
1960                    hooks::init::PatchMode::Skip
1961                } else {
1962                    hooks::init::PatchMode::Ask
1963                };
1964                hooks::init::run(
1965                    global,
1966                    install_claude,
1967                    install_opencode,
1968                    install_cursor,
1969                    install_windsurf,
1970                    install_cline,
1971                    claude_md,
1972                    hook_only,
1973                    codex,
1974                    patch_mode,
1975                    ctx,
1976                )?;
1977            }
1978            0
1979        }
1980
1981        Commands::Wget { url, output, args } => {
1982            if output.as_deref() == Some("-") {
1983                wget_cmd::run_stdout(&url, &args, cli.verbose)?
1984            } else {
1985                // Pass -O <file> through to wget via args
1986                let mut all_args = Vec::new();
1987                if let Some(out_file) = &output {
1988                    all_args.push("-O".to_string());
1989                    all_args.push(out_file.clone());
1990                }
1991                all_args.extend(args);
1992                wget_cmd::run(&url, &all_args, cli.verbose)?
1993            }
1994        }
1995
1996        Commands::Wc { args } => wc_cmd::run(&args, cli.verbose)?,
1997
1998        Commands::Gain {
1999            project, // added
2000            graph,
2001            history,
2002            quota,
2003            tier,
2004            daily,
2005            weekly,
2006            monthly,
2007            all,
2008            format,
2009            failures,
2010            reset,
2011            yes,
2012        } => {
2013            analytics::gain::run(
2014                project, // added: pass project flag
2015                graph,
2016                history,
2017                quota,
2018                &tier,
2019                daily,
2020                weekly,
2021                monthly,
2022                all,
2023                &format,
2024                failures,
2025                reset,
2026                yes,
2027                cli.verbose,
2028            )?;
2029            0
2030        }
2031
2032        Commands::CcEconomics {
2033            daily,
2034            weekly,
2035            monthly,
2036            all,
2037            format,
2038        } => {
2039            analytics::cc_economics::run(daily, weekly, monthly, all, &format, cli.verbose)?;
2040            0
2041        }
2042
2043        Commands::Config { create } => {
2044            if create {
2045                let path = core::config::Config::create_default()?;
2046                println!("Created: {}", path.display());
2047            } else {
2048                core::config::show_config()?;
2049            }
2050            0
2051        }
2052
2053        Commands::Jest { ref args } | Commands::Vitest { ref args } => {
2054            vitest_cmd::run_test(&cli.command, args, cli.verbose)?
2055        }
2056
2057        Commands::Prisma { command } => match command {
2058            PrismaCommands::Generate { args } => {
2059                prisma_cmd::run(prisma_cmd::PrismaCommand::Generate, &args, cli.verbose)?
2060            }
2061            PrismaCommands::Migrate { command } => match command {
2062                PrismaMigrateCommands::Dev { name, args } => prisma_cmd::run(
2063                    prisma_cmd::PrismaCommand::Migrate {
2064                        subcommand: prisma_cmd::MigrateSubcommand::Dev { name },
2065                    },
2066                    &args,
2067                    cli.verbose,
2068                )?,
2069                PrismaMigrateCommands::Status { args } => prisma_cmd::run(
2070                    prisma_cmd::PrismaCommand::Migrate {
2071                        subcommand: prisma_cmd::MigrateSubcommand::Status,
2072                    },
2073                    &args,
2074                    cli.verbose,
2075                )?,
2076                PrismaMigrateCommands::Deploy { args } => prisma_cmd::run(
2077                    prisma_cmd::PrismaCommand::Migrate {
2078                        subcommand: prisma_cmd::MigrateSubcommand::Deploy,
2079                    },
2080                    &args,
2081                    cli.verbose,
2082                )?,
2083            },
2084            PrismaCommands::DbPush { args } => {
2085                prisma_cmd::run(prisma_cmd::PrismaCommand::DbPush, &args, cli.verbose)?
2086            }
2087        },
2088
2089        Commands::Tsc { args } => tsc_cmd::run(&args, cli.verbose)?,
2090
2091        Commands::Next { args } => next_cmd::run(&args, cli.verbose)?,
2092
2093        Commands::Lint { args } => lint_cmd::run(&args, cli.verbose)?,
2094
2095        Commands::Prettier { args } => prettier_cmd::run(&args, cli.verbose)?,
2096
2097        Commands::Format { args } => format_cmd::run(&args, cli.verbose)?,
2098
2099        Commands::Playwright { args } => playwright_cmd::run(&args, cli.verbose)?,
2100
2101        Commands::Cargo { command } => match command {
2102            CargoCommands::Build { args } => {
2103                cargo_cmd::run(cargo_cmd::CargoCommand::Build, &args, cli.verbose)?
2104            }
2105            CargoCommands::Test { args } => {
2106                cargo_cmd::run(cargo_cmd::CargoCommand::Test, &args, cli.verbose)?
2107            }
2108            CargoCommands::Clippy { args } => {
2109                cargo_cmd::run(cargo_cmd::CargoCommand::Clippy, &args, cli.verbose)?
2110            }
2111            CargoCommands::Check { args } => {
2112                cargo_cmd::run(cargo_cmd::CargoCommand::Check, &args, cli.verbose)?
2113            }
2114            CargoCommands::Install { args } => {
2115                cargo_cmd::run(cargo_cmd::CargoCommand::Install, &args, cli.verbose)?
2116            }
2117            CargoCommands::Nextest { args } => {
2118                cargo_cmd::run(cargo_cmd::CargoCommand::Nextest, &args, cli.verbose)?
2119            }
2120            CargoCommands::Other(args) => cargo_cmd::run_passthrough(&args, cli.verbose)?,
2121        },
2122
2123        Commands::Npm { args } => npm_cmd::run(&args, cli.verbose, cli.skip_env)?,
2124
2125        Commands::Curl { args } => curl_cmd::run(&args, cli.verbose)?,
2126
2127        Commands::Discover {
2128            project,
2129            limit,
2130            all,
2131            since,
2132            format,
2133        } => {
2134            discover::run(project.as_deref(), all, since, limit, &format, cli.verbose)?;
2135            0
2136        }
2137
2138        Commands::Session {} => {
2139            analytics::session_cmd::run(cli.verbose)?;
2140            0
2141        }
2142
2143        Commands::Telemetry { command } => {
2144            core::telemetry_cmd::run(&command)?;
2145            0
2146        }
2147
2148        Commands::Learn {
2149            project,
2150            all,
2151            since,
2152            format,
2153            write_rules,
2154            min_confidence,
2155            min_occurrences,
2156        } => {
2157            learn::run(
2158                project,
2159                all,
2160                since,
2161                format,
2162                write_rules,
2163                min_confidence,
2164                min_occurrences,
2165            )?;
2166            0
2167        }
2168
2169        Commands::Npx { args } => {
2170            if args.is_empty() {
2171                anyhow::bail!("npx requires a command argument");
2172            }
2173
2174            // Intelligent routing: delegate to specialized filters
2175            match args[0].as_str() {
2176                "tsc" | "typescript" => tsc_cmd::run(&args[1..], cli.verbose)?,
2177                "eslint" => lint_cmd::run(&args[1..], cli.verbose)?,
2178                "prisma" => {
2179                    // Route to prisma_cmd based on subcommand
2180                    if args.len() > 1 {
2181                        let prisma_args: Vec<String> = args[2..].to_vec();
2182                        match args[1].as_str() {
2183                            "generate" => prisma_cmd::run(
2184                                prisma_cmd::PrismaCommand::Generate,
2185                                &prisma_args,
2186                                cli.verbose,
2187                            )?,
2188                            "db" if args.len() > 2 && args[2] == "push" => prisma_cmd::run(
2189                                prisma_cmd::PrismaCommand::DbPush,
2190                                &args[3..],
2191                                cli.verbose,
2192                            )?,
2193                            _ => {
2194                                // Passthrough other prisma subcommands
2195                                let timer = core::tracking::TimedExecution::start();
2196                                let mut cmd = core::utils::resolved_command("npx");
2197                                for arg in &args {
2198                                    cmd.arg(arg);
2199                                }
2200                                let status = cmd.status().context("Failed to run npx prisma")?;
2201                                let args_str = args.join(" ");
2202                                timer.track_passthrough(
2203                                    &format!("npx {}", args_str),
2204                                    &format!("rtk npx {} (passthrough)", args_str),
2205                                );
2206                                core::utils::exit_code_from_status(&status, "npx prisma")
2207                            }
2208                        }
2209                    } else {
2210                        let timer = core::tracking::TimedExecution::start();
2211                        let status = core::utils::resolved_command("npx")
2212                            .arg("prisma")
2213                            .status()
2214                            .context("Failed to run npx prisma")?;
2215                        timer.track_passthrough("npx prisma", "rtk npx prisma (passthrough)");
2216                        core::utils::exit_code_from_status(&status, "npx prisma")
2217                    }
2218                }
2219                "next" => next_cmd::run(&args[1..], cli.verbose)?,
2220                "prettier" => prettier_cmd::run(&args[1..], cli.verbose)?,
2221                "playwright" => playwright_cmd::run(&args[1..], cli.verbose)?,
2222                _ => npm_cmd::exec(&args, cli.verbose, cli.skip_env)?,
2223            }
2224        }
2225
2226        Commands::Ruff { args } => ruff_cmd::run(&args, cli.verbose)?,
2227
2228        Commands::Pytest { args } => pytest_cmd::run(&args, cli.verbose)?,
2229
2230        Commands::Mypy { args } => mypy_cmd::run(&args, cli.verbose)?,
2231
2232        Commands::Rake { args } => rake_cmd::run(&args, cli.verbose)?,
2233
2234        Commands::Rubocop { args } => rubocop_cmd::run(&args, cli.verbose)?,
2235
2236        Commands::Rspec { args } => rspec_cmd::run(&args, cli.verbose)?,
2237
2238        Commands::Pip { args } => pip_cmd::run(&args, cli.verbose)?,
2239
2240        Commands::Go { command } => match command {
2241            GoCommands::Test { args } => go_cmd::run_test(&args, cli.verbose)?,
2242            GoCommands::Build { args } => go_cmd::run_build(&args, cli.verbose)?,
2243            GoCommands::Vet { args } => go_cmd::run_vet(&args, cli.verbose)?,
2244            GoCommands::Other(args) => go_cmd::run_other(&args, cli.verbose)?,
2245        },
2246
2247        Commands::Gt { command } => match command {
2248            GtCommands::Log { args } => gt_cmd::run_log(&args, cli.verbose)?,
2249            GtCommands::Submit { args } => gt_cmd::run_submit(&args, cli.verbose)?,
2250            GtCommands::Sync { args } => gt_cmd::run_sync(&args, cli.verbose)?,
2251            GtCommands::Restack { args } => gt_cmd::run_restack(&args, cli.verbose)?,
2252            GtCommands::Create { args } => gt_cmd::run_create(&args, cli.verbose)?,
2253            GtCommands::Branch { args } => gt_cmd::run_branch(&args, cli.verbose)?,
2254            GtCommands::Other(args) => gt_cmd::run_other(&args, cli.verbose)?,
2255        },
2256
2257        Commands::GolangciLint { args } => golangci_cmd::run(&args, cli.verbose)?,
2258
2259        Commands::Gradlew { args } => gradlew_cmd::run(&args, cli.verbose)?,
2260
2261        Commands::Mvn { args } => mvn_cmd::run(&args, cli.verbose)?,
2262
2263        Commands::HookAudit { since } => {
2264            hooks::hook_audit_cmd::run(since, cli.verbose)?;
2265            0
2266        }
2267
2268        Commands::Hook { command } => match command {
2269            HookCommands::Claude => {
2270                hooks::hook_cmd::run_claude()?;
2271                0
2272            }
2273            HookCommands::Cursor => {
2274                hooks::hook_cmd::run_cursor()?;
2275                0
2276            }
2277            HookCommands::Gemini => {
2278                hooks::hook_cmd::run_gemini()?;
2279                0
2280            }
2281            HookCommands::Copilot => {
2282                hooks::hook_cmd::run_copilot()?;
2283                0
2284            }
2285            HookCommands::Check { agent: _, command } => {
2286                use crate::discover::registry::rewrite_command;
2287                let raw = command.join(" ");
2288                let (excluded, transparent_prefixes) = crate::core::config::Config::load()
2289                    .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes))
2290                    .unwrap_or_default();
2291                match rewrite_command(&raw, &excluded, &transparent_prefixes) {
2292                    Some(rewritten) => {
2293                        println!("{}", rewritten);
2294                        0
2295                    }
2296                    None => {
2297                        eprintln!("No rewrite for: {}", raw);
2298                        1
2299                    }
2300                }
2301            }
2302        },
2303
2304        Commands::Rewrite { args } => {
2305            let cmd = args.join(" ");
2306            hooks::rewrite_cmd::run(&cmd)?;
2307            0
2308        }
2309
2310        Commands::Pipe {
2311            filter,
2312            passthrough,
2313        } => {
2314            pipe_cmd::run(filter.as_deref(), passthrough)?;
2315            0
2316        }
2317
2318        Commands::Run { command, args } => {
2319            let raw = match command {
2320                Some(c) => c,
2321                None if !args.is_empty() => args.join(" "),
2322                None => String::new(),
2323            };
2324            if raw.trim().is_empty() {
2325                0
2326            } else {
2327                use std::process::Command as ProcCommand;
2328                let shell = if cfg!(windows) { "cmd" } else { "sh" };
2329                let flag = if cfg!(windows) { "/C" } else { "-c" };
2330                let status = ProcCommand::new(shell)
2331                    .arg(flag)
2332                    .arg(&raw)
2333                    .status()
2334                    .with_context(|| format!("Failed to execute: {}", raw))?;
2335                status.code().unwrap_or(1)
2336            }
2337        }
2338
2339        Commands::Proxy { args } => {
2340            use std::io::{Read, Write};
2341            use std::process::Stdio;
2342            use std::sync::atomic::{AtomicU32, Ordering};
2343            use std::thread;
2344
2345            if args.is_empty() {
2346                anyhow::bail!(
2347                    "proxy requires a command to execute\nUsage: rtk proxy <command> [args...]"
2348                );
2349            }
2350
2351            let timer = core::tracking::TimedExecution::start();
2352
2353            // If a single quoted arg contains spaces, split it respecting quotes (#388).
2354            // e.g. rtk proxy 'head -50 file.php' → cmd=head, args=["-50", "file.php"]
2355            // e.g. rtk proxy 'git log --format="%H %s"' → cmd=git, args=["log", "--format=%H %s"]
2356            let (cmd_name, cmd_args): (String, Vec<String>) = if args.len() == 1 {
2357                let full = args[0].to_string_lossy();
2358                let parts = shell_split(&full);
2359                if parts.len() > 1 {
2360                    (parts[0].clone(), parts[1..].to_vec())
2361                } else {
2362                    (full.into_owned(), vec![])
2363                }
2364            } else {
2365                (
2366                    args[0].to_string_lossy().into_owned(),
2367                    args[1..]
2368                        .iter()
2369                        .map(|s| s.to_string_lossy().into_owned())
2370                        .collect(),
2371                )
2372            };
2373
2374            if cli.verbose > 0 {
2375                eprintln!("Proxy mode: {} {}", cmd_name, cmd_args.join(" "));
2376            }
2377
2378            // ISSUE #897: Kill proxy child on SIGINT/SIGTERM to prevent orphan
2379            // processes. Drop-based ChildGuard doesn't run on signals with
2380            // panic=abort, so we register a signal handler that kills the child
2381            // PID stored in this atomic.
2382            static PROXY_CHILD_PID: AtomicU32 = AtomicU32::new(0);
2383
2384            #[cfg(unix)]
2385            #[allow(unsafe_code)]
2386            {
2387                unsafe extern "C" fn handle_signal(sig: libc::c_int) {
2388                    let pid = PROXY_CHILD_PID.load(Ordering::SeqCst);
2389                    if pid != 0 {
2390                        libc::kill(pid as libc::pid_t, libc::SIGTERM);
2391                        libc::waitpid(pid as libc::pid_t, std::ptr::null_mut(), 0);
2392                    }
2393                    libc::signal(sig, libc::SIG_DFL);
2394                    libc::raise(sig);
2395                }
2396                // nosemgrep: unsafe-block
2397                unsafe {
2398                    libc::signal(
2399                        libc::SIGINT,
2400                        handle_signal as *const () as libc::sighandler_t,
2401                    );
2402                    libc::signal(
2403                        libc::SIGTERM,
2404                        handle_signal as *const () as libc::sighandler_t,
2405                    );
2406                }
2407            }
2408
2409            struct ChildGuard(Option<std::process::Child>);
2410            impl Drop for ChildGuard {
2411                fn drop(&mut self) {
2412                    if let Some(mut child) = self.0.take() {
2413                        let _ = child.kill();
2414                        let _ = child.wait();
2415                    }
2416                    PROXY_CHILD_PID.store(0, Ordering::SeqCst);
2417                }
2418            }
2419
2420            let mut child = ChildGuard(Some(
2421                core::utils::resolved_command(cmd_name.as_ref())
2422                    .args(&cmd_args)
2423                    .stdout(Stdio::piped())
2424                    .stderr(Stdio::piped())
2425                    .spawn()
2426                    .context(format!("Failed to execute command: {}", cmd_name))?,
2427            ));
2428
2429            // Store child PID for signal handler before anything can fail
2430            if let Some(ref inner) = child.0 {
2431                PROXY_CHILD_PID.store(inner.id(), Ordering::SeqCst);
2432            }
2433
2434            let inner = child.0.as_mut().context("Child process missing")?;
2435            let stdout_pipe = inner
2436                .stdout
2437                .take()
2438                .context("Failed to capture child stdout")?;
2439            let stderr_pipe = inner
2440                .stderr
2441                .take()
2442                .context("Failed to capture child stderr")?;
2443
2444            const CAP: usize = 1_048_576;
2445
2446            let stdout_handle = thread::spawn(move || -> std::io::Result<Vec<u8>> {
2447                let mut reader = stdout_pipe;
2448                let mut captured = Vec::new();
2449                let mut buf = [0u8; 8192];
2450
2451                loop {
2452                    let count = reader.read(&mut buf)?;
2453                    if count == 0 {
2454                        break;
2455                    }
2456                    if captured.len() < CAP {
2457                        let take = count.min(CAP - captured.len());
2458                        captured.extend_from_slice(&buf[..take]);
2459                    }
2460                    let mut out = std::io::stdout().lock();
2461                    out.write_all(&buf[..count])?;
2462                    out.flush()?;
2463                }
2464
2465                Ok(captured)
2466            });
2467
2468            let stderr_handle = thread::spawn(move || -> std::io::Result<Vec<u8>> {
2469                let mut reader = stderr_pipe;
2470                let mut captured = Vec::new();
2471                let mut buf = [0u8; 8192];
2472
2473                loop {
2474                    let count = reader.read(&mut buf)?;
2475                    if count == 0 {
2476                        break;
2477                    }
2478                    if captured.len() < CAP {
2479                        let take = count.min(CAP - captured.len());
2480                        captured.extend_from_slice(&buf[..take]);
2481                    }
2482                    let mut err = std::io::stderr().lock();
2483                    err.write_all(&buf[..count])?;
2484                    err.flush()?;
2485                }
2486
2487                Ok(captured)
2488            });
2489
2490            let status = child
2491                .0
2492                .take()
2493                .context("Child process missing")?
2494                .wait()
2495                .context(format!("Failed waiting for command: {}", cmd_name))?;
2496
2497            let stdout_bytes = stdout_handle
2498                .join()
2499                .map_err(|_| anyhow::anyhow!("stdout streaming thread panicked"))??;
2500            let stderr_bytes = stderr_handle
2501                .join()
2502                .map_err(|_| anyhow::anyhow!("stderr streaming thread panicked"))??;
2503
2504            let stdout = String::from_utf8_lossy(&stdout_bytes);
2505            let stderr = String::from_utf8_lossy(&stderr_bytes);
2506            let full_output = format!("{}{}", stdout, stderr);
2507
2508            // Track usage (input = output since no filtering)
2509            timer.track(
2510                &format!("{} {}", cmd_name, cmd_args.join(" ")),
2511                &format!("rtk proxy {} {}", cmd_name, cmd_args.join(" ")),
2512                &full_output,
2513                &full_output,
2514            );
2515
2516            core::utils::exit_code_from_status(&status, &cmd_name)
2517        }
2518
2519        Commands::Trust { list } => {
2520            hooks::trust::run_trust(list)?;
2521            0
2522        }
2523
2524        Commands::Untrust => {
2525            hooks::trust::run_untrust()?;
2526            0
2527        }
2528
2529        Commands::Verify {
2530            filter,
2531            require_all,
2532        } => {
2533            if filter.is_some() {
2534                // Filter-specific mode: run only that filter's tests
2535                hooks::verify_cmd::run(filter, require_all)?;
2536            } else {
2537                // Default or --require-all: always run integrity check first
2538                hooks::integrity::run_verify(cli.verbose)?;
2539                hooks::verify_cmd::run(None, require_all)?;
2540            }
2541            0
2542        }
2543    };
2544
2545    Ok(code)
2546}
2547
2548fn hosted_mode() -> bool {
2549    cfg!(feature = "hosted") || std::env::var("RTK_HOSTED").unwrap_or_default() == "1"
2550}
2551
2552/// Returns true for commands that are invoked via the hook pipeline
2553/// (i.e., commands that process rewritten shell commands).
2554/// Meta commands (init, gain, verify, etc.) are excluded because
2555/// they are run directly by the user, not through the hook.
2556/// Returns true for commands that go through the hook pipeline
2557/// and therefore require integrity verification.
2558///
2559/// SECURITY: whitelist pattern — new commands are NOT integrity-checked
2560/// until explicitly added here. A forgotten command fails open (no check)
2561/// rather than creating false confidence about what's protected.
2562fn is_operational_command(cmd: &Commands) -> bool {
2563    matches!(
2564        cmd,
2565        Commands::Ls { .. }
2566            | Commands::Tree { .. }
2567            | Commands::Read { .. }
2568            | Commands::Smart { .. }
2569            | Commands::Git { .. }
2570            | Commands::Gh { .. }
2571            | Commands::Glab { .. }
2572            | Commands::Pnpm { .. }
2573            | Commands::Err { .. }
2574            | Commands::Test { .. }
2575            | Commands::Json { .. }
2576            | Commands::Deps { .. }
2577            | Commands::Env { .. }
2578            | Commands::Find { .. }
2579            | Commands::Diff { .. }
2580            | Commands::Log { .. }
2581            | Commands::Dotnet { .. }
2582            | Commands::Docker { .. }
2583            | Commands::Kubectl { .. }
2584            | Commands::Summary { .. }
2585            | Commands::Grep { .. }
2586            | Commands::Wget { .. }
2587            | Commands::Vitest { .. }
2588            | Commands::Prisma { .. }
2589            | Commands::Tsc { .. }
2590            | Commands::Next { .. }
2591            | Commands::Lint { .. }
2592            | Commands::Prettier { .. }
2593            | Commands::Playwright { .. }
2594            | Commands::Cargo { .. }
2595            | Commands::Npm { .. }
2596            | Commands::Npx { .. }
2597            | Commands::Curl { .. }
2598            | Commands::Ruff { .. }
2599            | Commands::Pytest { .. }
2600            | Commands::Rake { .. }
2601            | Commands::Rubocop { .. }
2602            | Commands::Rspec { .. }
2603            | Commands::Pip { .. }
2604            | Commands::Go { .. }
2605            | Commands::GolangciLint { .. }
2606            | Commands::Gt { .. }
2607    )
2608}
2609
2610#[cfg(test)]
2611mod tests {
2612    use super::*;
2613    use clap::Parser;
2614    use std::cell::Cell;
2615
2616    /// Hosted entrypoints (`cli_entry`) pass a synthetic argv that differs
2617    /// from the real process argv. The fallback path for non-subcommands
2618    /// must execute the passed argv — under `cargo test` the process argv
2619    /// belongs to the test harness, so re-reading `std::env::args()` there
2620    /// executes the wrong command (exit 127 instead of 0).
2621    #[test]
2622    #[cfg(unix)]
2623    fn test_run_fallback_uses_passed_argv() {
2624        // `true` is not an rtk subcommand, so parsing fails and the command
2625        // is dispatched through run_fallback.
2626        let argv: Vec<OsString> = ["rtk", "true"].iter().map(OsString::from).collect();
2627        let parse_error = match Cli::try_parse_from(&argv) {
2628            Ok(_) => panic!("`true` must not parse as an rtk subcommand"),
2629            Err(e) => e,
2630        };
2631
2632        let code = run_fallback(parse_error, &argv).expect("fallback should execute `true`");
2633
2634        assert_eq!(code, 0, "fallback must run `true` from the passed argv");
2635    }
2636
2637    #[test]
2638    fn test_git_commit_single_message() {
2639        let cli = Cli::try_parse_from(["rtk", "git", "commit", "-m", "fix: typo"]).unwrap();
2640        match cli.command {
2641            Commands::Git {
2642                command: GitCommands::Commit { args },
2643                ..
2644            } => {
2645                assert_eq!(args, vec!["-m", "fix: typo"]);
2646            }
2647            _ => panic!("Expected Git Commit command"),
2648        }
2649    }
2650
2651    #[test]
2652    fn test_git_commit_multiple_messages() {
2653        let cli = Cli::try_parse_from([
2654            "rtk",
2655            "git",
2656            "commit",
2657            "-m",
2658            "feat: add support",
2659            "-m",
2660            "Body paragraph here.",
2661        ])
2662        .unwrap();
2663        match cli.command {
2664            Commands::Git {
2665                command: GitCommands::Commit { args },
2666                ..
2667            } => {
2668                assert_eq!(
2669                    args,
2670                    vec!["-m", "feat: add support", "-m", "Body paragraph here."]
2671                );
2672            }
2673            _ => panic!("Expected Git Commit command"),
2674        }
2675    }
2676
2677    // #327: git commit -am "msg" was rejected by Clap
2678    #[test]
2679    fn test_git_commit_am_flag() {
2680        let cli = Cli::try_parse_from(["rtk", "git", "commit", "-am", "quick fix"]).unwrap();
2681        match cli.command {
2682            Commands::Git {
2683                command: GitCommands::Commit { args },
2684                ..
2685            } => {
2686                assert_eq!(args, vec!["-am", "quick fix"]);
2687            }
2688            _ => panic!("Expected Git Commit command"),
2689        }
2690    }
2691
2692    #[test]
2693    fn test_git_commit_amend() {
2694        let cli =
2695            Cli::try_parse_from(["rtk", "git", "commit", "--amend", "-m", "new msg"]).unwrap();
2696        match cli.command {
2697            Commands::Git {
2698                command: GitCommands::Commit { args },
2699                ..
2700            } => {
2701                assert_eq!(args, vec!["--amend", "-m", "new msg"]);
2702            }
2703            _ => panic!("Expected Git Commit command"),
2704        }
2705    }
2706
2707    #[test]
2708    fn test_git_global_options_parsing() {
2709        let cli =
2710            Cli::try_parse_from(["rtk", "git", "--no-pager", "--no-optional-locks", "status"])
2711                .unwrap();
2712        match cli.command {
2713            Commands::Git {
2714                no_pager,
2715                no_optional_locks,
2716                bare,
2717                literal_pathspecs,
2718                ..
2719            } => {
2720                assert!(no_pager);
2721                assert!(no_optional_locks);
2722                assert!(!bare);
2723                assert!(!literal_pathspecs);
2724            }
2725            _ => panic!("Expected Git command"),
2726        }
2727    }
2728
2729    #[test]
2730    fn test_git_commit_long_flag_multiple() {
2731        let cli = Cli::try_parse_from([
2732            "rtk",
2733            "git",
2734            "commit",
2735            "--message",
2736            "title",
2737            "--message",
2738            "body",
2739            "--message",
2740            "footer",
2741        ])
2742        .unwrap();
2743        match cli.command {
2744            Commands::Git {
2745                command: GitCommands::Commit { args },
2746                ..
2747            } => {
2748                assert_eq!(
2749                    args,
2750                    vec![
2751                        "--message",
2752                        "title",
2753                        "--message",
2754                        "body",
2755                        "--message",
2756                        "footer"
2757                    ]
2758                );
2759            }
2760            _ => panic!("Expected Git Commit command"),
2761        }
2762    }
2763
2764    #[test]
2765    fn test_try_parse_valid_git_status() {
2766        let result = Cli::try_parse_from(["rtk", "git", "status"]);
2767        assert!(result.is_ok(), "git status should parse successfully");
2768    }
2769
2770    #[test]
2771    fn test_try_parse_init_agent_hermes() {
2772        let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes"]).unwrap();
2773        match cli.command {
2774            Commands::Init { agent, .. } => {
2775                assert_eq!(agent, Some(AgentTarget::Hermes));
2776            }
2777            _ => panic!("Expected Init command"),
2778        }
2779    }
2780
2781    #[test]
2782    fn test_try_parse_kubectl_get_alias() {
2783        let cli = Cli::try_parse_from(["rtk", "kubectl", "get", "pods", "-n", "default"]).unwrap();
2784
2785        match cli.command {
2786            Commands::Kubectl {
2787                command: KubectlCommands::Get { args },
2788            } => assert_eq!(args, vec!["pods", "-n", "default"]),
2789            _ => panic!("Expected Kubectl Get command"),
2790        }
2791    }
2792
2793    #[test]
2794    fn test_try_parse_init_agent_hermes_uninstall() {
2795        let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes", "--uninstall"]).unwrap();
2796        match cli.command {
2797            Commands::Init {
2798                agent, uninstall, ..
2799            } => {
2800                assert_eq!(agent, Some(AgentTarget::Hermes));
2801                assert!(uninstall);
2802            }
2803            _ => panic!("Expected Init command"),
2804        }
2805    }
2806
2807    #[test]
2808    fn test_init_uninstall_dispatch_routes_hermes_to_hermes_cleanup() {
2809        let hermes_called = Cell::new(false);
2810        let standard_called = Cell::new(false);
2811        let ctx = hooks::init::InitContext {
2812            verbose: 2,
2813            dry_run: true,
2814        };
2815
2816        let result = uninstall_init_dispatch(
2817            Some(AgentTarget::Hermes),
2818            true,
2819            false,
2820            false,
2821            ctx,
2822            |ctx| {
2823                hermes_called.set(true);
2824                assert_eq!(ctx.verbose, 2);
2825                assert!(ctx.dry_run);
2826                Ok(())
2827            },
2828            |_, _, _, _, _, _| {
2829                standard_called.set(true);
2830                Ok(())
2831            },
2832        );
2833
2834        assert!(result.is_ok());
2835        assert!(hermes_called.get());
2836        assert!(!standard_called.get());
2837    }
2838
2839    #[test]
2840    fn test_try_parse_help_is_display_help() {
2841        match Cli::try_parse_from(["rtk", "--help"]) {
2842            Err(e) => assert_eq!(e.kind(), ErrorKind::DisplayHelp),
2843            Ok(_) => panic!("Expected DisplayHelp error"),
2844        }
2845    }
2846
2847    #[test]
2848    fn test_try_parse_version_is_display_version() {
2849        match Cli::try_parse_from(["rtk", "--version"]) {
2850            Err(e) => assert_eq!(e.kind(), ErrorKind::DisplayVersion),
2851            Ok(_) => panic!("Expected DisplayVersion error"),
2852        }
2853    }
2854
2855    #[test]
2856    fn test_try_parse_unknown_subcommand_is_error() {
2857        match Cli::try_parse_from(["rtk", "nonexistent-command"]) {
2858            Err(e) => assert!(!matches!(
2859                e.kind(),
2860                ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
2861            )),
2862            Ok(_) => panic!("Expected parse error for unknown subcommand"),
2863        }
2864    }
2865
2866    #[test]
2867    fn test_try_parse_git_with_dash_c_succeeds() {
2868        let result = Cli::try_parse_from(["rtk", "git", "-C", "/path", "status"]);
2869        assert!(
2870            result.is_ok(),
2871            "git -C /path status should parse successfully"
2872        );
2873        if let Ok(cli) = result {
2874            match cli.command {
2875                Commands::Git { directory, .. } => {
2876                    assert_eq!(directory, vec!["/path"]);
2877                }
2878                _ => panic!("Expected Git command"),
2879            }
2880        }
2881    }
2882
2883    #[test]
2884    fn test_gain_failures_flag_parses() {
2885        let result = Cli::try_parse_from(["rtk", "gain", "--failures"]);
2886        assert!(result.is_ok());
2887        if let Ok(cli) = result {
2888            match cli.command {
2889                Commands::Gain { failures, .. } => assert!(failures),
2890                _ => panic!("Expected Gain command"),
2891            }
2892        }
2893    }
2894
2895    #[test]
2896    fn test_gain_failures_short_flag_parses() {
2897        let result = Cli::try_parse_from(["rtk", "gain", "-F"]);
2898        assert!(result.is_ok());
2899        if let Ok(cli) = result {
2900            match cli.command {
2901                Commands::Gain { failures, .. } => assert!(failures),
2902                _ => panic!("Expected Gain command"),
2903            }
2904        }
2905    }
2906
2907    #[test]
2908    fn test_meta_commands_reject_bad_flags() {
2909        // RTK meta-commands should produce parse errors (not fall through to raw execution).
2910        // Skip "proxy" because it uses trailing_var_arg (accepts any args by design).
2911        for cmd in RTK_META_COMMANDS {
2912            if matches!(*cmd, "proxy" | "run" | "rewrite" | "session") {
2913                continue; // these use trailing_var_arg (accept any args by design)
2914            }
2915            let result = Cli::try_parse_from(["rtk", cmd, "--nonexistent-flag-xyz"]);
2916            assert!(
2917                result.is_err(),
2918                "Meta-command '{}' with bad flag should fail to parse",
2919                cmd
2920            );
2921        }
2922    }
2923
2924    #[test]
2925    fn test_run_command_with_dash_c() {
2926        let cli = Cli::try_parse_from(["rtk", "run", "-c", "git status && echo done"]).unwrap();
2927        match cli.command {
2928            Commands::Run { command, args } => {
2929                assert_eq!(command, Some("git status && echo done".to_string()));
2930                assert!(args.is_empty());
2931            }
2932            _ => panic!("Expected Run command"),
2933        }
2934    }
2935
2936    #[test]
2937    fn test_run_command_positional_args() {
2938        let cli = Cli::try_parse_from(["rtk", "run", "echo", "hello"]).unwrap();
2939        match cli.command {
2940            Commands::Run { command, args } => {
2941                assert!(command.is_none());
2942                assert_eq!(args, vec!["echo", "hello"]);
2943            }
2944            _ => panic!("Expected Run command"),
2945        }
2946    }
2947
2948    #[test]
2949    fn test_hook_claude_parses() {
2950        let cli = Cli::try_parse_from(["rtk", "hook", "claude"]).unwrap();
2951        assert!(matches!(
2952            cli.command,
2953            Commands::Hook {
2954                command: HookCommands::Claude
2955            }
2956        ));
2957    }
2958
2959    #[test]
2960    fn test_hook_check_parses() {
2961        let cli = Cli::try_parse_from(["rtk", "hook", "check", "git", "status"]).unwrap();
2962        match cli.command {
2963            Commands::Hook {
2964                command: HookCommands::Check { agent, command },
2965            } => {
2966                assert_eq!(agent, "claude");
2967                assert_eq!(command, vec!["git", "status"]);
2968            }
2969            _ => panic!("Expected Hook Check command"),
2970        }
2971    }
2972
2973    #[test]
2974    fn test_hook_check_with_agent() {
2975        let cli =
2976            Cli::try_parse_from(["rtk", "hook", "check", "--agent", "gemini", "cargo", "test"])
2977                .unwrap();
2978        match cli.command {
2979            Commands::Hook {
2980                command: HookCommands::Check { agent, command },
2981            } => {
2982                assert_eq!(agent, "gemini");
2983                assert_eq!(command, vec!["cargo", "test"]);
2984            }
2985            _ => panic!("Expected Hook Check command"),
2986        }
2987    }
2988
2989    #[test]
2990    fn test_hook_check_preserves_double_dash_in_command() {
2991        let cli = Cli::try_parse_from([
2992            "rtk",
2993            "hook",
2994            "check",
2995            "shadowenv",
2996            "exec",
2997            "--",
2998            "git",
2999            "status",
3000        ])
3001        .unwrap();
3002        match cli.command {
3003            Commands::Hook {
3004                command: HookCommands::Check { agent, command },
3005            } => {
3006                assert_eq!(agent, "claude");
3007                assert_eq!(command, vec!["shadowenv", "exec", "--", "git", "status"]);
3008            }
3009            _ => panic!("Expected Hook Check command"),
3010        }
3011    }
3012
3013    #[test]
3014    fn test_meta_command_list_is_complete() {
3015        // Verify all meta-commands are in the guard list by checking they parse with valid syntax
3016        let meta_cmds_that_parse = [
3017            vec!["rtk", "gain"],
3018            vec!["rtk", "discover"],
3019            vec!["rtk", "learn"],
3020            vec!["rtk", "init"],
3021            vec!["rtk", "config"],
3022            vec!["rtk", "proxy", "echo", "hi"],
3023            vec!["rtk", "run", "-c", "echo hi"],
3024            vec!["rtk", "hook-audit"],
3025            vec!["rtk", "cc-economics"],
3026        ];
3027        for args in &meta_cmds_that_parse {
3028            let result = Cli::try_parse_from(args.iter());
3029            assert!(
3030                result.is_ok(),
3031                "Meta-command {:?} should parse successfully",
3032                args
3033            );
3034        }
3035    }
3036
3037    #[test]
3038    fn test_shell_split_simple() {
3039        assert_eq!(
3040            shell_split("head -50 file.php"),
3041            vec!["head", "-50", "file.php"]
3042        );
3043    }
3044
3045    #[test]
3046    fn test_shell_split_double_quotes() {
3047        assert_eq!(
3048            shell_split(r#"git log --format="%H %s""#),
3049            vec!["git", "log", "--format=%H %s"]
3050        );
3051    }
3052
3053    #[test]
3054    fn test_shell_split_single_quotes() {
3055        assert_eq!(
3056            shell_split("grep -r 'hello world' ."),
3057            vec!["grep", "-r", "hello world", "."]
3058        );
3059    }
3060
3061    #[test]
3062    fn test_shell_split_single_word() {
3063        assert_eq!(shell_split("ls"), vec!["ls"]);
3064    }
3065
3066    #[test]
3067    fn test_shell_split_empty() {
3068        let result: Vec<String> = shell_split("");
3069        assert!(result.is_empty());
3070    }
3071
3072    #[test]
3073    fn test_rewrite_clap_multi_args() {
3074        // This is the bug KuSh reported: `rtk rewrite ls -al` failed because
3075        // Clap rejected `-al` as an unknown flag. With trailing_var_arg + allow_hyphen_values,
3076        // multiple args are accepted and joined into a single command string.
3077        let cases = vec![
3078            vec!["rtk", "rewrite", "ls", "-al"],
3079            vec!["rtk", "rewrite", "git", "status"],
3080            vec!["rtk", "rewrite", "npm", "exec"],
3081            vec!["rtk", "rewrite", "cargo", "test"],
3082            vec!["rtk", "rewrite", "du", "-sh", "."],
3083            vec!["rtk", "rewrite", "head", "-50", "file.txt"],
3084        ];
3085        for args in &cases {
3086            let result = Cli::try_parse_from(args.iter());
3087            assert!(
3088                result.is_ok(),
3089                "rtk rewrite {:?} should parse (was failing before trailing_var_arg fix)",
3090                &args[2..]
3091            );
3092            if let Ok(cli) = result {
3093                match cli.command {
3094                    Commands::Rewrite { ref args } => {
3095                        assert!(args.len() >= 2, "rewrite args should capture all tokens");
3096                    }
3097                    _ => panic!("expected Rewrite command"),
3098                }
3099            }
3100        }
3101    }
3102
3103    #[test]
3104    fn test_rewrite_clap_quoted_single_arg() {
3105        // Quoted form: `rtk rewrite "git status"` — single arg containing spaces
3106        let result = Cli::try_parse_from(["rtk", "rewrite", "git status"]);
3107        assert!(result.is_ok());
3108        if let Ok(cli) = result {
3109            match cli.command {
3110                Commands::Rewrite { ref args } => {
3111                    assert_eq!(args.len(), 1);
3112                    assert_eq!(args[0], "git status");
3113                }
3114                _ => panic!("expected Rewrite command"),
3115            }
3116        }
3117    }
3118
3119    #[test]
3120    fn test_merge_filters_with_no_args() {
3121        let filters = vec![];
3122        let args = vec!["--depth=0".to_string(), "--no-verbose".to_string()];
3123        let expected_args = vec!["--depth=0", "--no-verbose"];
3124        assert_eq!(merge_pnpm_args(&filters, &args), expected_args);
3125    }
3126
3127    #[test]
3128    fn test_merge_filters_with_args() {
3129        let filters = vec!["@app1".to_string(), "@app2".to_string()];
3130        let args = vec![
3131            "--filter=@app3".to_string(),
3132            "--depth=0".to_string(),
3133            "--no-verbose".to_string(),
3134        ];
3135        let expected_args = vec![
3136            "--filter=@app1",
3137            "--filter=@app2",
3138            "--filter=@app3",
3139            "--depth=0",
3140            "--no-verbose",
3141        ];
3142        assert_eq!(merge_pnpm_args(&filters, &args), expected_args);
3143    }
3144
3145    #[test]
3146    fn test_merge_filters_with_no_args_os() {
3147        let filters = vec![];
3148        let args = vec![OsString::from("--depth=0")];
3149        let expected_args = vec![OsString::from("--depth=0")];
3150        assert_eq!(merge_pnpm_args_os(&filters, &args), expected_args);
3151    }
3152
3153    #[test]
3154    fn test_merge_filters_with_args_os() {
3155        let filters = vec!["@app1".to_string()];
3156        let args = vec![OsString::from("--depth=0")];
3157        let expected_args = vec![
3158            OsString::from("--filter=@app1"),
3159            OsString::from("--depth=0"),
3160        ];
3161        assert_eq!(merge_pnpm_args_os(&filters, &args), expected_args);
3162    }
3163
3164    #[test]
3165    fn test_pnpm_subcommand_with_filter() {
3166        let cli = Cli::try_parse_from([
3167            "rtk", "pnpm", "--filter", "@app1", "--filter", "@app2", "list", "--filter", "@app3",
3168            "--filter", "@app4", "--prod",
3169        ])
3170        .unwrap();
3171        match cli.command {
3172            Commands::Pnpm {
3173                filter,
3174                command: PnpmCommands::List { depth, args },
3175            } => {
3176                assert_eq!(depth, 0);
3177                assert_eq!(filter, vec!["@app1", "@app2"]);
3178                assert_eq!(
3179                    args,
3180                    vec!["--filter", "@app3", "--filter", "@app4", "--prod"]
3181                );
3182            }
3183            _ => panic!("Expected Pnpm List command"),
3184        }
3185    }
3186
3187    #[test]
3188    fn test_git_push_u_flag_passes_through() {
3189        let cli = Cli::try_parse_from(["rtk", "git", "push", "-u", "origin", "my-branch"]).unwrap();
3190        assert!(
3191            !cli.ultra_compact,
3192            "-u on git push must NOT be consumed as --ultra-compact"
3193        );
3194        match cli.command {
3195            Commands::Git {
3196                command: GitCommands::Push { args },
3197                ..
3198            } => {
3199                assert!(
3200                    args.contains(&"-u".to_string()),
3201                    "-u must be forwarded to git push, got: {:?}",
3202                    args
3203                );
3204            }
3205            _ => panic!("Expected Git Push command"),
3206        }
3207    }
3208
3209    #[test]
3210    fn test_pnpm_subcommand_with_short_filter() {
3211        // -F is the short form of --filter in pnpm
3212        let cli =
3213            Cli::try_parse_from(["rtk", "pnpm", "-F", "@app1", "-F", "@app2", "list"]).unwrap();
3214        match cli.command {
3215            Commands::Pnpm { filter, .. } => {
3216                assert_eq!(filter, vec!["@app1", "@app2"]);
3217            }
3218            _ => panic!("Expected Pnpm command"),
3219        }
3220    }
3221
3222    #[test]
3223    fn test_pnpm_typecheck_without_filters() {
3224        let cli = Cli::try_parse_from([
3225            "rtk",
3226            "pnpm",
3227            "typecheck",
3228            "--filter",
3229            "@app3",
3230            "--filter",
3231            "@app4",
3232        ])
3233        .unwrap();
3234        match cli.command {
3235            Commands::Pnpm { filter, command } => {
3236                let warning = validate_pnpm_filters(&filter, &command);
3237
3238                assert!(filter.is_empty());
3239                assert!(warning.is_none())
3240            }
3241            _ => panic!("Expected Pnpm Build command"),
3242        }
3243    }
3244
3245    #[test]
3246    fn test_pnpm_typecheck_with_filters() {
3247        let cli = Cli::try_parse_from([
3248            "rtk",
3249            "pnpm",
3250            "--filter",
3251            "@app1",
3252            "--filter",
3253            "@app2",
3254            "typecheck",
3255            "--filter",
3256            "@app3",
3257            "--filter",
3258            "@app4",
3259        ])
3260        .unwrap();
3261        match cli.command {
3262            Commands::Pnpm { filter, command } => {
3263                let warning = validate_pnpm_filters(&filter, &command).unwrap();
3264
3265                assert_eq!(filter, vec!["@app1", "@app2"]);
3266                assert_eq!(warning, "[rtk] warning: --filter is not yet supported for pnpm tsc, filters preceding the subcommand will be ignored")
3267            }
3268            _ => panic!("Expected Pnpm Build command"),
3269        }
3270    }
3271
3272    #[test]
3273    #[ignore] // Integration test: requires `cargo build` first
3274    fn test_broken_pipe_does_not_crash() {
3275        let bin_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3276            .join("target")
3277            .join("debug")
3278            .join("rtk");
3279        assert!(
3280            bin_path.exists(),
3281            "Debug binary not found at {:?} - run `cargo build` first",
3282            bin_path
3283        );
3284
3285        let mut child = std::process::Command::new(&bin_path)
3286            .args(["git", "log", "--oneline", "-50"])
3287            .stdout(std::process::Stdio::piped())
3288            .stderr(std::process::Stdio::piped())
3289            .spawn()
3290            .expect("Failed to spawn rtk");
3291
3292        // Read one byte then drop stdout to close the pipe.
3293        let mut stdout = child.stdout.take().unwrap();
3294        let mut buf = [0u8; 1];
3295        let _ = std::io::Read::read(&mut stdout, &mut buf);
3296
3297        let status = child.wait().expect("Failed to wait for rtk");
3298        let code = status.code().unwrap_or(-1);
3299
3300        assert_ne!(
3301            code, 134,
3302            "rtk crashed with SIGABRT (exit 134) on broken pipe - SIGPIPE handler missing"
3303        );
3304    }
3305
3306    #[test]
3307    fn test_ultra_compact_long_form_still_works() {
3308        let cli = Cli::try_parse_from(["rtk", "--ultra-compact", "git", "status"]).unwrap();
3309        assert!(
3310            cli.ultra_compact,
3311            "--ultra-compact long form must still enable ultra-compact mode"
3312        );
3313    }
3314
3315    #[test]
3316    fn test_npx_unknown_tool_passthrough() {
3317        // The bug (rtk-ai/rtk#815) was that unknown tools under `rtk npx`
3318        // were dispatched to `npm` instead of `npx`. At the parse level, the
3319        // Npx variant must carry all args through unchanged so the dispatch
3320        // arm can forward them to npx.
3321        let cli = Cli::try_parse_from(["rtk", "npx", "cowsay", "hello"]).unwrap();
3322        match cli.command {
3323            Commands::Npx { args } => {
3324                assert_eq!(args, vec!["cowsay", "hello"]);
3325            }
3326            _ => panic!("Expected Commands::Npx for unknown tool"),
3327        }
3328    }
3329
3330    #[test]
3331    fn test_init_pi_flag_rejected() {
3332        // --pi has been removed; --agent pi is the canonical form
3333        let result = Cli::try_parse_from(["rtk", "init", "--pi"]);
3334        assert!(result.is_err(), "--pi must be rejected as unknown argument");
3335    }
3336
3337    #[test]
3338    fn test_init_agent_pi_parses() {
3339        let cli = Cli::try_parse_from(["rtk", "init", "--agent", "pi"]).unwrap();
3340        match cli.command {
3341            Commands::Init { agent, .. } => {
3342                assert_eq!(
3343                    agent,
3344                    Some(AgentTarget::Pi),
3345                    "--agent pi must set Pi variant"
3346                );
3347            }
3348            _ => panic!("Expected Init command"),
3349        }
3350    }
3351
3352    #[test]
3353    fn test_init_uninstall_agent_pi_parses() {
3354        let cli = Cli::try_parse_from(["rtk", "init", "--uninstall", "--agent", "pi", "--global"])
3355            .unwrap();
3356        match cli.command {
3357            Commands::Init {
3358                uninstall,
3359                agent,
3360                global,
3361                ..
3362            } => {
3363                assert!(uninstall);
3364                assert_eq!(agent, Some(AgentTarget::Pi));
3365                assert!(global);
3366            }
3367            _ => panic!("Expected Init command"),
3368        }
3369    }
3370}