Files
rtk-ai__rtk/src/main.rs
T
Adrien Eppling 223dda2996 fix(env): clean up feature from secrets rewrite
Masking env secrets is not what core RTK (OSS) is for -- it compresses command
output, it does not redact what the command itself already reveals. It also
broke raw-vs-filtered consistency, which the never-worse guard relies on.
2026-06-24 11:18:32 +02:00

3434 lines
112 KiB
Rust

mod analytics;
mod cmds;
mod core;
mod discover;
mod hooks;
mod learn;
mod parser;
// Re-export command modules for routing
use cmds::cloud::{aws_cmd, container, curl_cmd, psql_cmd, wget_cmd};
use cmds::dotnet::{binlog, dotnet_cmd, dotnet_format_report, dotnet_trx};
use cmds::git::{diff_cmd, gh_cmd, git, glab_cmd, gt_cmd};
use cmds::go::{go_cmd, golangci_cmd};
use cmds::js::{
lint_cmd, next_cmd, npm_cmd, playwright_cmd, pnpm_cmd, prettier_cmd, prisma_cmd, tsc_cmd,
vitest_cmd,
};
use cmds::jvm::{gradlew_cmd, mvn_cmd};
use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd};
use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd};
use cmds::rust::{cargo_cmd, runner};
use cmds::system::{
deps, env_cmd, find_cmd, format_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd,
read, summary, tree, wc_cmd,
};
use anyhow::{Context, Result};
use clap::error::ErrorKind;
use clap::{Parser, Subcommand, ValueEnum};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// Target agent for hook installation.
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
pub enum AgentTarget {
/// Claude Code (default)
Claude,
/// Cursor Agent (editor and CLI)
Cursor,
/// Windsurf IDE (Cascade)
Windsurf,
/// Cline / Roo Code (VS Code)
Cline,
/// Kilo Code
Kilocode,
/// Google Antigravity
Antigravity,
/// Pi coding agent
Pi,
/// Hermes CLI
Hermes,
}
#[derive(Parser)]
#[command(
name = "rtk",
version,
about = "Rust Token Killer - Minimize LLM token consumption",
long_about = "A high-performance CLI proxy designed to filter and summarize system outputs before they reach your LLM context."
)]
struct Cli {
#[command(subcommand)]
command: Commands,
/// Verbosity level (-v, -vv, -vvv) — only recognized before the subcommand
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
/// Ultra-compact mode: ASCII icons, inline format (Level 2 optimizations)
#[arg(long, global = true)]
ultra_compact: bool,
/// Set SKIP_ENV_VALIDATION=1 for child processes (Next.js, tsc, lint, prisma)
#[arg(long = "skip-env", global = true)]
skip_env: bool,
}
#[derive(Debug, Subcommand)]
enum Commands {
/// List directory contents with token-optimized output (proxy to native ls)
Ls {
/// Arguments passed to ls (supports all native ls flags like -l, -a, -h, -R)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Directory tree with token-optimized output (proxy to native tree)
Tree {
/// Arguments passed to tree (supports all native tree flags like -L, -d, -a)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Read file with intelligent filtering
Read {
/// Files to read (supports multiple, like cat)
#[arg(required = true, num_args = 1..)]
files: Vec<PathBuf>,
/// Filter: none (default, full content), minimal, aggressive
#[arg(short, long, default_value = "none")]
level: core::filter::FilterLevel,
/// Max lines
#[arg(short, long, conflicts_with = "tail_lines")]
max_lines: Option<usize>,
/// Keep only last N lines
#[arg(long, conflicts_with = "max_lines")]
tail_lines: Option<usize>,
/// Show line numbers
#[arg(short = 'n', long)]
line_numbers: bool,
},
/// Generate 2-line technical summary (heuristic-based)
Smart {
/// File to analyze
file: PathBuf,
/// Model: heuristic
#[arg(short, long, default_value = "heuristic")]
model: String,
/// Force model download
#[arg(long)]
force_download: bool,
},
/// Git commands with compact output
Git {
/// Change to directory before executing (like git -C <path>, can be repeated)
#[arg(short = 'C', action = clap::ArgAction::Append)]
directory: Vec<String>,
/// Git configuration override (like git -c key=value, can be repeated)
#[arg(short = 'c', action = clap::ArgAction::Append)]
config_override: Vec<String>,
/// Set the path to the .git directory
#[arg(long = "git-dir")]
git_dir: Option<String>,
/// Set the path to the working tree
#[arg(long = "work-tree")]
work_tree: Option<String>,
/// Disable pager (like git --no-pager)
#[arg(long = "no-pager")]
no_pager: bool,
/// Skip optional locks (like git --no-optional-locks)
#[arg(long = "no-optional-locks")]
no_optional_locks: bool,
/// Treat repository as bare (like git --bare)
#[arg(long)]
bare: bool,
/// Treat pathspecs literally (like git --literal-pathspecs)
#[arg(long = "literal-pathspecs")]
literal_pathspecs: bool,
#[command(subcommand)]
command: GitCommands,
},
/// GitHub CLI (gh) commands with token-optimized output
Gh {
/// Subcommand: pr, issue, run, repo
subcommand: String,
/// Additional arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// GitLab CLI (glab) commands with token-optimized output
Glab {
/// Target repository (owner/repo), passed as glab -R flag
#[arg(short = 'R', long = "repo")]
repo: Option<String>,
/// Target group, passed as glab -g flag
#[arg(short = 'g', long = "group")]
group: Option<String>,
/// Subcommand: mr, issue, ci, pipeline, api
subcommand: String,
/// Additional arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// AWS CLI with compact output (force JSON, compress)
Aws {
/// AWS service subcommand (e.g., sts, s3, ec2, ecs, rds, cloudformation)
subcommand: String,
/// Additional arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// PostgreSQL client with compact output (strip borders, compress tables)
#[command(disable_help_flag = true)]
Psql {
/// psql arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// pnpm commands with ultra-compact output
Pnpm {
/// pnpm filter arguments (can be repeated: --filter @app1 --filter @app2)
#[arg(long, short = 'F')]
filter: Vec<String>,
#[command(subcommand)]
command: PnpmCommands,
},
/// Run command and show only errors/warnings
Err {
/// Command to run
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
/// Run tests and show only failures
Test {
/// Test command (e.g. cargo test)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
/// Show JSON (compact values by default, or keys-only with --keys-only)
Json {
/// JSON file
file: PathBuf,
/// Max depth
#[arg(short, long, default_value = "5")]
depth: usize,
/// Show keys only (strip all values, show structure)
#[arg(long)]
keys_only: bool,
},
/// Summarize project dependencies
Deps {
/// Project path
#[arg(default_value = ".")]
path: PathBuf,
},
/// Show environment variables (filtered)
Env {
/// Filter by name (e.g. PATH, AWS)
#[arg(short, long)]
filter: Option<String>,
},
/// Find files with compact tree output (accepts native find flags like -name, -type)
Find {
/// All find arguments (supports both RTK and native find syntax)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Ultra-condensed diff (only changed lines)
Diff {
/// First file or - for stdin (unified diff)
file1: PathBuf,
/// Second file (optional if stdin)
file2: Option<PathBuf>,
},
/// Filter and deduplicate log output
Log {
/// Log file (omit for stdin)
file: Option<PathBuf>,
},
/// .NET commands with compact output (build/test/restore/format)
Dotnet {
#[command(subcommand)]
command: DotnetCommands,
},
/// Docker commands with compact output
Docker {
#[command(subcommand)]
command: DockerCommands,
},
/// Kubectl commands with compact output
Kubectl {
#[command(subcommand)]
command: KubectlCommands,
},
/// OpenShift CLI (oc) commands with compact output
Oc {
#[command(subcommand)]
command: OcCommands,
},
/// Run command and show heuristic summary
Summary {
/// Command to run and summarize
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
/// Compact grep - strips whitespace, truncates, groups by file
Grep {
/// Max line length
#[arg(short = 'l', long, default_value = "80")]
max_len: usize,
/// Max results to show
#[arg(short, long, default_value = "200")]
max: usize,
/// Show only match context (not full line)
#[arg(long)]
context_only: bool,
/// Filter by file type (e.g., ts, py, rust)
#[arg(short = 't', long)]
file_type: Option<String>,
/// Pattern, path, and any grep/rg flags (e.g. -v, -i, -A 3, --glob, --version)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
extra_args: Vec<String>,
},
/// Initialize rtk instructions for assistant CLI usage
Init {
/// Add to global assistant config directory instead of local project file
#[arg(short, long)]
global: bool,
/// Install OpenCode plugin (in addition to Claude Code)
#[arg(long)]
opencode: bool,
/// Initialize for Gemini CLI instead of Claude Code
#[arg(long)]
gemini: bool,
/// Target agent to install hooks for (default: claude)
#[arg(long, value_enum)]
agent: Option<AgentTarget>,
/// Show current configuration
#[arg(long)]
show: bool,
/// Inject full instructions into CLAUDE.md (legacy mode)
#[arg(long = "claude-md", group = "mode")]
claude_md: bool,
/// Hook only, no RTK.md
#[arg(long = "hook-only", group = "mode")]
hook_only: bool,
/// Auto-patch settings.json without prompting
#[arg(long = "auto-patch", group = "patch")]
auto_patch: bool,
/// Skip settings.json patching (print manual instructions)
#[arg(long = "no-patch", group = "patch")]
no_patch: bool,
/// Remove RTK artifacts for the selected assistant mode
#[arg(long)]
uninstall: bool,
/// Target Codex CLI (uses AGENTS.md + RTK.md, no Claude hook patching)
#[arg(long)]
codex: bool,
/// Install GitHub Copilot integration (VS Code + CLI)
#[arg(long)]
copilot: bool,
/// Preview changes without writing any files (combine with -v to show content)
#[arg(long = "dry-run", conflicts_with = "show")]
dry_run: bool,
},
/// Download with compact output (strips progress bars)
Wget {
/// URL to download
url: String,
/// Output file (-O - for stdout)
#[arg(short = 'O', long = "output-document", allow_hyphen_values = true)]
output: Option<String>,
/// Additional wget arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Word/line/byte count with compact output (strips paths and padding)
Wc {
/// Arguments passed to wc (files, flags like -l, -w, -c)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Show token savings summary and history
Gain {
/// Filter statistics to current project (current working directory) // added
#[arg(short, long)]
project: bool,
/// Show ASCII graph of daily savings
#[arg(short, long)]
graph: bool,
/// Show recent command history
#[arg(short = 'H', long)]
history: bool,
/// Show monthly quota savings estimate
#[arg(short, long)]
quota: bool,
/// Subscription tier for quota calculation: pro, 5x, 20x
#[arg(short, long, default_value = "20x", requires = "quota")]
tier: String,
/// Show detailed daily breakdown (all days)
#[arg(short, long)]
daily: bool,
/// Show weekly breakdown
#[arg(short, long)]
weekly: bool,
/// Show monthly breakdown
#[arg(short, long)]
monthly: bool,
/// Show all time breakdowns (daily + weekly + monthly)
#[arg(short, long)]
all: bool,
/// Output format: text, json, csv
#[arg(short, long, default_value = "text")]
format: String,
/// Show parse failure log (commands that fell back to raw execution)
#[arg(short = 'F', long)]
failures: bool,
/// Reset all token savings stats to zero
#[arg(long)]
reset: bool,
/// Skip confirmation prompt when resetting
#[arg(long, requires = "reset")]
yes: bool,
},
/// Claude Code economics: spending (ccusage) vs savings (rtk) analysis
CcEconomics {
/// Show detailed daily breakdown
#[arg(short, long)]
daily: bool,
/// Show weekly breakdown
#[arg(short, long)]
weekly: bool,
/// Show monthly breakdown
#[arg(short, long)]
monthly: bool,
/// Show all time breakdowns (daily + weekly + monthly)
#[arg(short, long)]
all: bool,
/// Output format: text, json, csv
#[arg(short, long, default_value = "text")]
format: String,
},
/// Show or create configuration file
Config {
/// Create default config file
#[arg(long)]
create: bool,
},
/// Jest commands with compact output
Jest {
/// Additional jest arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Vitest commands with compact output
Vitest {
/// Additional vitest arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Prisma commands with compact output (no ASCII art)
Prisma {
#[command(subcommand)]
command: PrismaCommands,
},
/// TypeScript compiler with grouped error output
Tsc {
/// TypeScript compiler arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Next.js build with compact output
Next {
/// Next.js build arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// ESLint with grouped rule violations
Lint {
/// Linter arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Prettier format checker with compact output
Prettier {
/// Prettier arguments (e.g., --check, --write)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Universal format checker (prettier, black, ruff format)
Format {
/// Formatter arguments (auto-detects formatter from project files)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Playwright E2E tests with compact output
Playwright {
/// Playwright arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Cargo commands with compact output
Cargo {
#[command(subcommand)]
command: CargoCommands,
},
/// npm run with filtered output (strip boilerplate)
Npm {
/// npm run arguments (script name + options)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// npx with intelligent routing (tsc, eslint, prisma -> specialized filters)
Npx {
/// npx arguments (command + options)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Curl with auto-JSON detection and schema output
Curl {
/// Curl arguments (URL + options)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Discover missed RTK savings from Claude Code history
Discover {
/// Filter by project path (substring match)
#[arg(short, long)]
project: Option<String>,
/// Max commands per section
#[arg(short, long, default_value = "15")]
limit: usize,
/// Scan all projects (default: current project only)
#[arg(short, long)]
all: bool,
/// Limit to sessions from last N days
#[arg(short, long, default_value = "30")]
since: u64,
/// Output format: text, json
#[arg(short, long, default_value = "text")]
format: String,
},
/// Show RTK adoption across Claude Code sessions
Session {},
/// Manage telemetry consent and data (RGPD/GDPR)
Telemetry {
#[command(subcommand)]
command: core::telemetry_cmd::TelemetrySubcommand,
},
/// Learn CLI corrections from Claude Code error history
Learn {
/// Filter by project path (substring match)
#[arg(short, long)]
project: Option<String>,
/// Scan all projects (default: current project only)
#[arg(short, long)]
all: bool,
/// Limit to sessions from last N days
#[arg(short, long, default_value = "30")]
since: u64,
/// Output format: text, json
#[arg(short, long, default_value = "text")]
format: String,
/// Generate .claude/rules/cli-corrections.md file
#[arg(short, long)]
write_rules: bool,
/// Minimum confidence threshold (0.0-1.0)
#[arg(long, default_value = "0.6")]
min_confidence: f64,
/// Minimum occurrences to include in report
#[arg(long, default_value = "1")]
min_occurrences: usize,
},
/// Execute a shell command via sh -c (raw, no filtering or tracking)
Run {
/// Command string to execute (use -c for shell-like invocation)
#[arg(short = 'c', long = "command")]
command: Option<String>,
/// Positional command arguments (alternative to -c)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Execute command without filtering but track usage
Proxy {
/// Command and arguments to execute
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<OsString>,
},
/// Read stdin, apply filter, print filtered output (Unix pipe mode)
Pipe {
/// Filter name (cargo-test, pytest, grep, find, git-log, etc.)
#[arg(short, long)]
filter: Option<String>,
/// Pass stdin through without filtering
#[arg(long)]
passthrough: bool,
},
/// Trust project-local TOML filters in current directory
Trust {
/// List all trusted projects
#[arg(long)]
list: bool,
},
/// Revoke trust for project-local TOML filters
Untrust,
/// Verify hook integrity and run TOML filter inline tests
Verify {
/// Run tests only for this filter name
#[arg(long)]
filter: Option<String>,
/// Fail if any filter has no inline tests (CI mode)
#[arg(long)]
require_all: bool,
},
/// Ruff linter/formatter with compact output
Ruff {
/// Ruff arguments (e.g., check, format --check)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Pytest test runner with compact output
Pytest {
/// Pytest arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Mypy type checker with grouped error output
Mypy {
/// Mypy arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Rake/Rails test with compact Minitest output (Ruby)
Rake {
/// Rake arguments (e.g., test, test TEST=path/to/test.rb)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// RuboCop linter with compact output (Ruby)
Rubocop {
/// RuboCop arguments (e.g., --auto-correct, -A)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// RSpec test runner with compact output (Rails/Ruby)
Rspec {
/// RSpec arguments (e.g., spec/models, --tag focus)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Pip package manager with compact output (auto-detects uv)
Pip {
/// Pip arguments (e.g., list, outdated, install)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Go commands with compact output
Go {
#[command(subcommand)]
command: GoCommands,
},
/// Graphite (gt) stacked PR commands with compact output
Gt {
#[command(subcommand)]
command: GtCommands,
},
/// golangci-lint wrapper with compact `run` support and passthrough for other invocations
#[command(name = "golangci-lint")]
GolangciLint {
/// Additional golangci-lint arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Android Gradle wrapper with compact output (build, test, lint)
#[command(name = "gradlew")]
Gradlew {
/// Gradle tasks and arguments (e.g., assembleDebug, testDebugUnitTest, lint, --info)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Apache Maven wrapper with compact output (test, integration-test, compile, package, install, verify, deploy)
#[command(name = "mvn")]
Mvn {
/// Maven goals and arguments (e.g., clean install, -DskipTests test, -X)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Show hook rewrite audit metrics (requires RTK_HOOK_AUDIT=1)
#[command(name = "hook-audit")]
HookAudit {
/// Show entries from last N days (0 = all time)
#[arg(short, long, default_value = "7")]
since: u64,
},
/// Rewrite a raw command to its RTK equivalent (single source of truth for hooks)
///
/// Exits 0 and prints the rewritten command if supported.
/// Exits 1 with no output if the command has no RTK equivalent.
///
/// Used by Claude Code, Gemini CLI, and other LLM hooks:
/// REWRITTEN=$(rtk rewrite "$CMD") || exit 0
Rewrite {
/// Raw command to rewrite (e.g. "git status", "cargo test && git push")
/// Accepts multiple args: `rtk rewrite ls -al` is equivalent to `rtk rewrite "ls -al"`
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Hook processors for LLM CLI tools (Gemini CLI, Copilot, etc.)
Hook {
#[command(subcommand)]
command: HookCommands,
},
}
#[derive(Debug, Subcommand)]
enum HookCommands {
/// Process Claude Code PreToolUse hook (reads JSON from stdin)
Claude,
/// Process Cursor Agent hook (reads JSON from stdin)
Cursor,
/// Process Gemini CLI BeforeTool hook (reads JSON from stdin)
Gemini,
/// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin)
Copilot,
/// Check how a command would be rewritten by the hook engine (dry-run)
Check {
/// Target agent
#[arg(long, default_value = "claude")]
agent: String,
/// Command to check
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
}
#[derive(Debug, Subcommand)]
enum GitCommands {
/// Condensed diff output
Diff {
/// Git arguments (supports all git diff flags like --stat, --cached, etc)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// One-line commit history
Log {
/// Git arguments (supports all git log flags like --oneline, --graph, --all)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Compact status (supports all git status flags)
Status {
/// Git arguments (supports all git status flags like --porcelain, --short, -s)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Compact show (commit summary + stat + compacted diff)
Show {
/// Git arguments (supports all git show flags)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Add files → "ok"
Add {
/// Files and flags to add (supports all git add flags like -A, -p, --all, etc)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Commit → "ok \<hash\>"
Commit {
/// Git commit arguments (supports -a, -m, --amend, --allow-empty, etc)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Push → "ok \<branch\>"
Push {
/// Git push arguments (supports -u, remote, branch, etc.)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Pull → "ok \<stats\>"
Pull {
/// Git pull arguments (supports --rebase, remote, branch, etc.)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Compact branch listing (current/local/remote)
Branch {
/// Git branch arguments (supports -d, -D, -m, etc.)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Fetch → "ok fetched (N new refs)"
Fetch {
/// Git fetch arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Stash management (list, show, pop, apply, drop)
Stash {
/// Subcommand: list, show, pop, apply, drop, push
subcommand: Option<String>,
/// Additional arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Compact worktree listing
Worktree {
/// Git worktree arguments (add, remove, prune, or empty for list)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Passthrough: runs any unsupported git subcommand directly
#[command(external_subcommand)]
Other(Vec<OsString>),
}
#[derive(Debug, Subcommand)]
enum PnpmCommands {
/// List installed packages (ultra-dense)
List {
/// Depth level (default: 0)
#[arg(short, long, default_value = "0")]
depth: usize,
/// Additional pnpm arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Show outdated packages (condensed: "pkg: old → new")
Outdated {
/// Additional pnpm arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Install packages (filter progress bars)
Install {
/// Additional pnpm arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Typecheck (delegates to tsc filter)
Typecheck {
/// Additional typecheck arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Passthrough: runs any unsupported pnpm subcommand directly
#[command(external_subcommand)]
Other(Vec<OsString>),
}
#[derive(Debug, Subcommand)]
enum DockerCommands {
/// List running containers
Ps {
#[arg(short = 'a', long)]
all: bool,
},
/// List images
Images,
/// Show container logs (deduplicated)
Logs { container: String },
/// Docker Compose commands with compact output
Compose {
#[command(subcommand)]
command: ComposeCommands,
},
/// Passthrough: runs any unsupported docker subcommand directly
#[command(external_subcommand)]
Other(Vec<OsString>),
}
#[derive(Debug, Subcommand)]
enum ComposeCommands {
/// List compose services (compact)
Ps {
#[arg(short = 'a', long)]
all: bool,
},
/// Show compose logs (deduplicated)
Logs {
/// Optional service name
service: Option<String>,
/// Number of log lines to fetch
#[arg(long, default_value_t = 100)]
tail: u32,
},
/// Build compose services (summary)
Build {
/// Optional service name
service: Option<String>,
},
/// Passthrough: runs any unsupported compose subcommand directly
#[command(external_subcommand)]
Other(Vec<OsString>),
}
#[derive(Debug, Subcommand)]
enum KubectlCommands {
/// Get Kubernetes resources (compact for pods/services)
Get {
/// kubectl get arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// List pods
Pods {
#[arg(short, long)]
namespace: Option<String>,
/// All namespaces
#[arg(short = 'A', long)]
all: bool,
},
/// List services
Services {
#[arg(short, long)]
namespace: Option<String>,
/// All namespaces
#[arg(short = 'A', long)]
all: bool,
},
/// Show pod logs (deduplicated)
Logs {
pod: String,
#[arg(short, long)]
container: Option<String>,
},
/// Passthrough: runs any unsupported kubectl subcommand directly
#[command(external_subcommand)]
Other(Vec<OsString>),
}
#[derive(Debug, Subcommand)]
enum OcCommands {
/// Get OpenShift resources (compact for pods/services)
Get {
/// oc get arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// List pods
Pods {
#[arg(short, long)]
namespace: Option<String>,
/// All namespaces
#[arg(short = 'A', long)]
all: bool,
},
/// List services
Services {
#[arg(short, long)]
namespace: Option<String>,
/// All namespaces
#[arg(short = 'A', long)]
all: bool,
},
/// Show pod logs (deduplicated)
Logs {
pod: String,
#[arg(short, long)]
container: Option<String>,
},
/// Passthrough: runs any unsupported oc subcommand directly
#[command(external_subcommand)]
Other(Vec<OsString>),
}
#[derive(Debug, Subcommand)]
enum PrismaCommands {
/// Generate Prisma Client (strip ASCII art)
Generate {
/// Additional prisma arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Manage migrations
Migrate {
#[command(subcommand)]
command: PrismaMigrateCommands,
},
/// Push schema to database
DbPush {
/// Additional prisma arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
}
#[derive(Debug, Subcommand)]
enum PrismaMigrateCommands {
/// Create and apply migration
Dev {
/// Migration name
#[arg(short, long)]
name: Option<String>,
/// Additional arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Check migration status
Status {
/// Additional arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Deploy migrations to production
Deploy {
/// Additional arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
}
#[derive(Debug, Subcommand)]
enum CargoCommands {
/// Build with compact output (strip Compiling lines, keep errors)
Build {
/// Additional cargo build arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Test with failures-only output
Test {
/// Additional cargo test arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Clippy with warnings grouped by lint rule
Clippy {
/// Additional cargo clippy arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Check with compact output (strip Checking lines, keep errors)
Check {
/// Additional cargo check arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Install with compact output (strip dep compilation, keep installed/errors)
Install {
/// Additional cargo install arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Nextest with failures-only output
Nextest {
/// Additional cargo nextest arguments (e.g., run, list, --lib)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Passthrough: runs any unsupported cargo subcommand directly
#[command(external_subcommand)]
Other(Vec<OsString>),
}
#[derive(Debug, Subcommand)]
enum DotnetCommands {
/// Build with compact output
Build {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Test with compact output
Test {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Restore with compact output
Restore {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Format with compact output
Format {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Passthrough: runs any unsupported dotnet subcommand directly
#[command(external_subcommand)]
Other(Vec<OsString>),
}
#[derive(Debug, Subcommand)]
enum GoCommands {
/// Run tests with compact output (90% token reduction via JSON streaming)
Test {
/// Additional go test arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Build with compact output (errors only)
Build {
/// Additional go build arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Vet with compact output
Vet {
/// Additional go vet arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Passthrough: runs any unsupported go subcommand directly
#[command(external_subcommand)]
Other(Vec<OsString>),
}
/// RTK-only subcommands that should never fall back to raw execution.
/// If Clap fails to parse these, show the Clap error directly.
/// When adding a new RTK-only subcommand to `Commands`, add its clap name here.
const RTK_META_COMMANDS: &[&str] = &[
"gain",
"discover",
"learn",
"init",
"config",
"proxy",
"run",
"hook",
"hook-audit",
"pipe",
"cc-economics",
"verify",
"trust",
"untrust",
"session",
"rewrite",
"telemetry",
"smart",
"deps",
"json",
];
fn run_fallback(parse_error: clap::Error) -> Result<i32> {
let args: Vec<String> = std::env::args().skip(1).collect();
// No args → show Clap's error (user ran just "rtk" with bad syntax)
if args.is_empty() {
parse_error.exit();
}
// RTK meta-commands should never fall back to raw execution.
// e.g. `rtk gain --badtypo` should show Clap's error, not try to run `gain` from $PATH.
if RTK_META_COMMANDS.contains(&args[0].as_str()) {
parse_error.exit();
}
let raw_command = args.join(" ");
let error_message = core::utils::strip_ansi(&parse_error.to_string());
// Start timer before execution to capture actual command runtime
let timer = core::tracking::TimedExecution::start();
// TOML filter lookup — bypass with RTK_NO_TOML=1
// Use basename of args[0] so absolute paths (/usr/bin/make) still match "^make\b".
let lookup_cmd = {
let base = std::path::Path::new(&args[0])
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| args[0].clone());
std::iter::once(base.as_str())
.chain(args[1..].iter().map(|s| s.as_str()))
.collect::<Vec<_>>()
.join(" ")
};
let toml_match = if std::env::var("RTK_NO_TOML").ok().as_deref() == Some("1") {
None
} else {
core::toml_filter::find_matching_filter(&lookup_cmd)
};
if let Some(filter) = toml_match {
// TOML match: capture stdout for filtering
let result = if filter.filter_stderr {
// Merge stderr into stdout so the filter can strip banners emitted by tools like liquibase
core::utils::resolved_command(&args[0])
.args(&args[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()) // captured for merging
.output()
} else {
core::utils::resolved_command(&args[0])
.args(&args[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::piped()) // capture
.stderr(std::process::Stdio::inherit()) // stderr always direct
.output()
};
match result {
Ok(output) => {
let exit_code = core::utils::exit_code_from_output(&output, &raw_command);
let stdout_raw = String::from_utf8_lossy(&output.stdout);
let stderr_raw = String::from_utf8_lossy(&output.stderr);
// Merge stderr into the text to filter when filter_stderr is enabled;
// otherwise emit stderr directly so it is always visible.
let combined_raw = if filter.filter_stderr {
format!("{}{}", stdout_raw, stderr_raw)
} else {
stdout_raw.to_string()
};
// Tee raw output BEFORE filtering on failure — lets LLM re-read if needed
let tee_hint = if !output.status.success() {
core::tee::tee_and_hint(&combined_raw, &raw_command, exit_code)
} else {
None
};
let filtered = core::toml_filter::apply_filter(filter, &combined_raw);
let shown =
core::runner::emit_guarded(&filtered, tee_hint.as_deref(), &combined_raw);
timer.track(
&raw_command,
&format!("rtk:toml {}", raw_command),
&combined_raw,
&shown,
);
core::tracking::record_parse_failure_silent(&raw_command, &error_message, true);
Ok(exit_code)
}
Err(e) => {
// Command not found — same behaviour as no-TOML path
core::tracking::record_parse_failure_silent(&raw_command, &error_message, false);
eprintln!("[rtk: {}]", e);
Ok(127)
}
}
} else {
// No TOML match: original passthrough behaviour (Stdio::inherit, streaming)
let status = core::utils::resolved_command(&args[0])
.args(&args[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status();
match status {
Ok(s) => {
timer.track_passthrough(&raw_command, &format!("rtk fallback: {}", raw_command));
core::tracking::record_parse_failure_silent(&raw_command, &error_message, true);
Ok(core::utils::exit_code_from_status(&s, &raw_command))
}
Err(e) => {
core::tracking::record_parse_failure_silent(&raw_command, &error_message, false);
// Command not found or other OS error — single message, no duplicate Clap error
eprintln!("[rtk: {}]", e);
Ok(127)
}
}
}
}
#[derive(Debug, Subcommand)]
enum GtCommands {
/// Compact stack log output
Log {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Compact submit output
Submit {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Compact sync output
Sync {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Compact restack output
Restack {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Compact create output
Create {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Branch info and management
Branch {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Passthrough: git-passthrough detection or direct gt execution
#[command(external_subcommand)]
Other(Vec<OsString>),
}
/// Split a string into shell-like tokens, respecting single and double quotes.
/// e.g. `git log --format="%H %s"` → ["git", "log", "--format=%H %s"]
fn shell_split(input: &str) -> Vec<String> {
discover::lexer::shell_split(input)
}
fn build_k8s_namespace_args(namespace: Option<String>, all: bool) -> Vec<String> {
let mut args = Vec::new();
if all {
args.push("-A".to_string());
} else if let Some(n) = namespace {
args.push("-n".to_string());
args.push(n);
}
args
}
fn build_k8s_logs_args(pod: String, container: Option<String>) -> Vec<String> {
let mut args = vec![pod];
if let Some(cont) = container {
args.push("-c".to_string());
args.push(cont);
}
args
}
/// Merge pnpm global filters args with other ones for standard String-based commands
fn merge_pnpm_args(filters: &[String], args: &[String]) -> Vec<String> {
filters
.iter()
.map(|filter| format!("--filter={}", filter))
.chain(args.iter().cloned())
.collect()
}
/// Merge pnpm global filters args with other ones, using OsString for passthrough compatibility
fn merge_pnpm_args_os(filters: &[String], args: &[OsString]) -> Vec<OsString> {
filters
.iter()
.map(|filter| OsString::from(format!("--filter={}", filter)))
.chain(args.iter().cloned())
.collect()
}
/// Validate that pnpm filters are only used in the global context, not before subcommands like tsc.
fn validate_pnpm_filters(filters: &[String], command: &PnpmCommands) -> Option<String> {
// Check if this is a Build or Typecheck command with filters
match command {
PnpmCommands::Typecheck { .. } => {
// FIXME: if filters are present, we should find out which workspaces are selected before running rtk dedicated commands
if !filters.is_empty() {
let cmd_name = match command {
PnpmCommands::Typecheck { .. } => "tsc",
_ => unreachable!(),
};
let msg = format!(
"[rtk] warning: --filter is not yet supported for pnpm {}, filters preceding the subcommand will be ignored",
cmd_name
);
return Some(msg);
}
None
}
_ => None,
}
}
fn main() {
// Reset SIGPIPE to default handler so writing to a closed pipe
// e.g `rtk git log | head` exits silently instead of panicking.
// Rust ignores SIGPIPE by default and with panic="abort" in the
// release profile that becomes SIGABRT + coredump.
#[cfg(unix)]
#[allow(unsafe_code)]
// nosemgrep: unsafe-block
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let code = match run_cli() {
Ok(code) => code,
Err(e) => {
eprintln!("rtk: {:#}", e);
1
}
};
std::process::exit(code);
}
fn uninstall_init_dispatch<UninstallHermes, UninstallStandard>(
agent: Option<AgentTarget>,
global: bool,
gemini: bool,
codex: bool,
ctx: hooks::init::InitContext,
uninstall_hermes: UninstallHermes,
uninstall_standard: UninstallStandard,
) -> Result<()>
where
UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>,
UninstallStandard: FnOnce(bool, bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>,
{
if agent == Some(AgentTarget::Hermes) {
uninstall_hermes(ctx)
} else {
let cursor = agent == Some(AgentTarget::Cursor);
let pi = agent == Some(AgentTarget::Pi);
uninstall_standard(global, gemini, codex, cursor, pi, ctx)
}
}
fn run_cli() -> Result<i32> {
// Fire-and-forget telemetry ping (1/day, non-blocking)
core::telemetry::maybe_ping();
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
e.exit();
}
return run_fallback(e);
}
};
// Warn if installed hook is outdated/missing (1/day, non-blocking).
// Skip for Gain — it shows its own inline hook warning.
if !matches!(cli.command, Commands::Gain { .. }) {
hooks::hook_check::maybe_warn();
}
// Runtime integrity check for operational commands.
// Meta commands (init, gain, verify, config, etc.) skip the check
// because they don't go through the hook pipeline.
if is_operational_command(&cli.command) {
hooks::integrity::runtime_check()?;
}
let code = match cli.command {
Commands::Ls { args } => ls::run(&args, cli.verbose)?,
Commands::Tree { args } => tree::run(&args, cli.verbose)?,
// ISSUE #989: support multiple files (cat file1 file2 → rtk read file1 file2)
Commands::Read {
files,
level,
max_lines,
tail_lines,
line_numbers,
} => {
let mut had_error = false;
let mut stdin_seen = false;
for file in &files {
let result = if file == Path::new("-") {
if stdin_seen {
eprintln!("rtk: warning: stdin specified more than once");
continue;
}
stdin_seen = true;
read::run_stdin(level, max_lines, tail_lines, line_numbers, cli.verbose)
} else {
read::run(
file,
level,
max_lines,
tail_lines,
line_numbers,
cli.verbose,
)
};
if let Err(e) = result {
eprintln!("cat: {}: {}", file.display(), e.root_cause());
had_error = true;
}
}
if had_error {
1
} else {
0
}
}
Commands::Smart {
file,
model,
force_download,
} => {
local_llm::run(&file, &model, force_download, cli.verbose)?;
0
}
Commands::Git {
directory,
config_override,
git_dir,
work_tree,
no_pager,
no_optional_locks,
bare,
literal_pathspecs,
command,
} => {
// Build global git args (inserted between "git" and subcommand)
let mut global_args: Vec<String> = Vec::new();
for dir in &directory {
global_args.push("-C".to_string());
global_args.push(dir.clone());
}
for cfg in &config_override {
global_args.push("-c".to_string());
global_args.push(cfg.clone());
}
if let Some(ref dir) = git_dir {
global_args.push("--git-dir".to_string());
global_args.push(dir.clone());
}
if let Some(ref tree) = work_tree {
global_args.push("--work-tree".to_string());
global_args.push(tree.clone());
}
if no_pager {
global_args.push("--no-pager".to_string());
}
if no_optional_locks {
global_args.push("--no-optional-locks".to_string());
}
if bare {
global_args.push("--bare".to_string());
}
if literal_pathspecs {
global_args.push("--literal-pathspecs".to_string());
}
match command {
GitCommands::Diff { args } => git::run(
git::GitCommand::Diff,
&args,
None,
cli.verbose,
&global_args,
)?,
GitCommands::Log { args } => {
git::run(git::GitCommand::Log, &args, None, cli.verbose, &global_args)?
}
GitCommands::Status { args } => git::run(
git::GitCommand::Status,
&args,
None,
cli.verbose,
&global_args,
)?,
GitCommands::Show { args } => git::run(
git::GitCommand::Show,
&args,
None,
cli.verbose,
&global_args,
)?,
GitCommands::Add { args } => {
git::run(git::GitCommand::Add, &args, None, cli.verbose, &global_args)?
}
GitCommands::Commit { args } => git::run(
git::GitCommand::Commit,
&args,
None,
cli.verbose,
&global_args,
)?,
GitCommands::Push { args } => git::run(
git::GitCommand::Push,
&args,
None,
cli.verbose,
&global_args,
)?,
GitCommands::Pull { args } => git::run(
git::GitCommand::Pull,
&args,
None,
cli.verbose,
&global_args,
)?,
GitCommands::Branch { args } => git::run(
git::GitCommand::Branch,
&args,
None,
cli.verbose,
&global_args,
)?,
GitCommands::Fetch { args } => git::run(
git::GitCommand::Fetch,
&args,
None,
cli.verbose,
&global_args,
)?,
GitCommands::Stash { subcommand, args } => git::run(
git::GitCommand::Stash { subcommand },
&args,
None,
cli.verbose,
&global_args,
)?,
GitCommands::Worktree { args } => git::run(
git::GitCommand::Worktree,
&args,
None,
cli.verbose,
&global_args,
)?,
GitCommands::Other(args) => git::run_passthrough(&args, &global_args, cli.verbose)?,
}
}
Commands::Gh { subcommand, args } => {
gh_cmd::run(&subcommand, &args, cli.verbose, cli.ultra_compact)?
}
Commands::Glab {
repo,
group,
subcommand,
mut args,
} => {
// Append -R / -g flags at end so they don't interfere with
// subcommand dispatch (args[0] must be the sub-subcommand like "list")
if let Some(r) = repo {
args.push("-R".to_string());
args.push(r);
}
if let Some(g) = group {
args.push("-g".to_string());
args.push(g);
}
glab_cmd::run(&subcommand, &args, cli.verbose, cli.ultra_compact)?
}
Commands::Aws { subcommand, args } => aws_cmd::run(&subcommand, &args, cli.verbose)?,
Commands::Psql { args } => psql_cmd::run(&args, cli.verbose)?,
Commands::Pnpm { filter, command } => {
// Warns user if filters are used with unsupported subcommands like typecheck
if let Some(warning) = validate_pnpm_filters(&filter, &command) {
eprintln!("{}", warning);
}
match command {
PnpmCommands::List { depth, args } => pnpm_cmd::run(
pnpm_cmd::PnpmCommand::List { depth },
&merge_pnpm_args(&filter, &args),
cli.verbose,
)?,
PnpmCommands::Outdated { args } => pnpm_cmd::run(
pnpm_cmd::PnpmCommand::Outdated,
&merge_pnpm_args(&filter, &args),
cli.verbose,
)?,
PnpmCommands::Install { args } => pnpm_cmd::run(
pnpm_cmd::PnpmCommand::Install,
&merge_pnpm_args(&filter, &args),
cli.verbose,
)?,
PnpmCommands::Typecheck { args } => tsc_cmd::run(&args, cli.verbose)?,
PnpmCommands::Other(args) => {
pnpm_cmd::run_passthrough(&merge_pnpm_args_os(&filter, &args), cli.verbose)?
}
}
}
Commands::Err { command } => {
let cmd = command.join(" ");
runner::run_err(&cmd, cli.verbose)?
}
Commands::Test { command } => {
let cmd = command.join(" ");
runner::run_test(&cmd, cli.verbose)?
}
Commands::Json {
file,
depth,
keys_only,
} => {
if file == Path::new("-") {
json_cmd::run_stdin(depth, keys_only, cli.verbose)?;
} else {
json_cmd::run(&file, depth, keys_only, cli.verbose)?;
}
0
}
Commands::Deps { path } => {
deps::run(&path, cli.verbose)?;
0
}
Commands::Env { filter } => {
env_cmd::run(filter.as_deref(), cli.verbose)?;
0
}
Commands::Find { args } => {
find_cmd::run_from_args(&args, cli.verbose)?;
0
}
Commands::Diff { file1, file2 } => {
if let Some(f2) = file2 {
diff_cmd::run(&file1, &f2, cli.verbose)?
} else {
diff_cmd::run_stdin(cli.verbose)?;
0
}
}
Commands::Log { file } => {
if let Some(f) = file {
log_cmd::run_file(&f, cli.verbose)?;
} else {
log_cmd::run_stdin(cli.verbose)?;
}
0
}
Commands::Dotnet { command } => match command {
DotnetCommands::Build { args } => dotnet_cmd::run_build(&args, cli.verbose)?,
DotnetCommands::Test { args } => dotnet_cmd::run_test(&args, cli.verbose)?,
DotnetCommands::Restore { args } => dotnet_cmd::run_restore(&args, cli.verbose)?,
DotnetCommands::Format { args } => dotnet_cmd::run_format(&args, cli.verbose)?,
DotnetCommands::Other(args) => dotnet_cmd::run_passthrough(&args, cli.verbose)?,
},
Commands::Docker { command } => match command {
DockerCommands::Ps { all } => {
let cmd = if all {
container::ContainerCmd::DockerPsAll
} else {
container::ContainerCmd::DockerPs
};
container::run(cmd, &[], cli.verbose)?
}
DockerCommands::Images => {
container::run(container::ContainerCmd::DockerImages, &[], cli.verbose)?
}
DockerCommands::Logs { container: c } => {
container::run(container::ContainerCmd::DockerLogs, &[c], cli.verbose)?
}
DockerCommands::Compose { command: compose } => match compose {
ComposeCommands::Ps { all } => container::run_compose_ps(all, cli.verbose)?,
ComposeCommands::Logs { service, tail } => {
container::run_compose_logs(service.as_deref(), tail, cli.verbose)?
}
ComposeCommands::Build { service } => {
container::run_compose_build(service.as_deref(), cli.verbose)?
}
ComposeCommands::Other(args) => {
container::run_compose_passthrough(&args, cli.verbose)?
}
},
DockerCommands::Other(args) => container::run_docker_passthrough(&args, cli.verbose)?,
},
Commands::Kubectl { command } => match command {
KubectlCommands::Get { args } => container::run_kubectl_get(&args, cli.verbose)?,
KubectlCommands::Pods { namespace, all } => {
let args = build_k8s_namespace_args(namespace, all);
container::run(container::ContainerCmd::KubectlPods, &args, cli.verbose)?
}
KubectlCommands::Services { namespace, all } => {
let args = build_k8s_namespace_args(namespace, all);
container::run(container::ContainerCmd::KubectlServices, &args, cli.verbose)?
}
KubectlCommands::Logs { pod, container: c } => {
let args = build_k8s_logs_args(pod, c);
container::run(container::ContainerCmd::KubectlLogs, &args, cli.verbose)?
}
KubectlCommands::Other(args) => container::run_kubectl_passthrough(&args, cli.verbose)?,
},
Commands::Oc { command } => match command {
OcCommands::Get { args } => container::run_oc_get(&args, cli.verbose)?,
OcCommands::Pods { namespace, all } => {
let args = build_k8s_namespace_args(namespace, all);
container::k8s_pods("oc", &args, cli.verbose)?
}
OcCommands::Services { namespace, all } => {
let args = build_k8s_namespace_args(namespace, all);
container::k8s_services("oc", &args, cli.verbose)?
}
OcCommands::Logs { pod, container: c } => {
let args = build_k8s_logs_args(pod, c);
container::k8s_logs("oc", &args, cli.verbose)?
}
OcCommands::Other(args) => container::run_oc_passthrough(&args, cli.verbose)?,
},
Commands::Summary { command } => {
let cmd = command.join(" ");
summary::run(&cmd, cli.verbose)?
}
Commands::Grep {
max_len,
max,
context_only,
file_type,
extra_args,
} => grep_cmd::run(
max_len,
max,
context_only,
file_type.as_deref(),
&extra_args,
cli.verbose,
)?,
Commands::Init {
global,
opencode,
gemini,
agent,
show,
claude_md,
hook_only,
auto_patch,
no_patch,
uninstall,
codex,
copilot,
dry_run,
} => {
let ctx = hooks::init::InitContext {
verbose: cli.verbose,
dry_run,
};
if show {
hooks::init::show_config(codex)?;
} else if uninstall && copilot {
if global {
hooks::init::uninstall_copilot_global(ctx)?;
} else {
hooks::init::uninstall_copilot(ctx)?;
}
} else if uninstall {
uninstall_init_dispatch(
agent,
global,
gemini,
codex,
ctx,
hooks::init::uninstall_hermes,
hooks::init::uninstall,
)?;
} else if gemini {
let patch_mode = if auto_patch {
hooks::init::PatchMode::Auto
} else if no_patch {
hooks::init::PatchMode::Skip
} else {
hooks::init::PatchMode::Ask
};
hooks::init::run_gemini(global, hook_only, patch_mode, ctx)?;
} else if copilot {
if global {
hooks::init::run_copilot_global(ctx)?;
} else {
hooks::init::run_copilot(ctx)?;
}
} else if agent == Some(AgentTarget::Pi) {
hooks::init::run_pi_mode(global, ctx)?
} else if agent == Some(AgentTarget::Kilocode) {
if global {
anyhow::bail!("Kilo Code is project-scoped. Use: rtk init --agent kilocode");
}
hooks::init::run_kilocode_mode(ctx)?;
} else if agent == Some(AgentTarget::Antigravity) {
if global {
anyhow::bail!(
"Antigravity is project-scoped. Use: rtk init --agent antigravity"
);
}
hooks::init::run_antigravity_mode(ctx)?;
} else if agent == Some(AgentTarget::Hermes) {
hooks::init::run_hermes_mode(ctx)?;
} else {
let install_opencode = opencode;
let install_claude = !opencode;
let install_cursor = agent == Some(AgentTarget::Cursor);
let install_windsurf = agent == Some(AgentTarget::Windsurf);
let install_cline = agent == Some(AgentTarget::Cline);
let patch_mode = if auto_patch {
hooks::init::PatchMode::Auto
} else if no_patch {
hooks::init::PatchMode::Skip
} else {
hooks::init::PatchMode::Ask
};
hooks::init::run(
global,
install_claude,
install_opencode,
install_cursor,
install_windsurf,
install_cline,
claude_md,
hook_only,
codex,
patch_mode,
ctx,
)?;
}
0
}
Commands::Wget { url, output, args } => {
if output.as_deref() == Some("-") {
wget_cmd::run_stdout(&url, &args, cli.verbose)?
} else {
// Pass -O <file> through to wget via args
let mut all_args = Vec::new();
if let Some(out_file) = &output {
all_args.push("-O".to_string());
all_args.push(out_file.clone());
}
all_args.extend(args);
wget_cmd::run(&url, &all_args, cli.verbose)?
}
}
Commands::Wc { args } => wc_cmd::run(&args, cli.verbose)?,
Commands::Gain {
project, // added
graph,
history,
quota,
tier,
daily,
weekly,
monthly,
all,
format,
failures,
reset,
yes,
} => {
analytics::gain::run(
project, // added: pass project flag
graph,
history,
quota,
&tier,
daily,
weekly,
monthly,
all,
&format,
failures,
reset,
yes,
cli.verbose,
)?;
0
}
Commands::CcEconomics {
daily,
weekly,
monthly,
all,
format,
} => {
analytics::cc_economics::run(daily, weekly, monthly, all, &format, cli.verbose)?;
0
}
Commands::Config { create } => {
if create {
let path = core::config::Config::create_default()?;
println!("Created: {}", path.display());
} else {
core::config::show_config()?;
}
0
}
Commands::Jest { ref args } | Commands::Vitest { ref args } => {
vitest_cmd::run_test(&cli.command, args, cli.verbose)?
}
Commands::Prisma { command } => match command {
PrismaCommands::Generate { args } => {
prisma_cmd::run(prisma_cmd::PrismaCommand::Generate, &args, cli.verbose)?
}
PrismaCommands::Migrate { command } => match command {
PrismaMigrateCommands::Dev { name, args } => prisma_cmd::run(
prisma_cmd::PrismaCommand::Migrate {
subcommand: prisma_cmd::MigrateSubcommand::Dev { name },
},
&args,
cli.verbose,
)?,
PrismaMigrateCommands::Status { args } => prisma_cmd::run(
prisma_cmd::PrismaCommand::Migrate {
subcommand: prisma_cmd::MigrateSubcommand::Status,
},
&args,
cli.verbose,
)?,
PrismaMigrateCommands::Deploy { args } => prisma_cmd::run(
prisma_cmd::PrismaCommand::Migrate {
subcommand: prisma_cmd::MigrateSubcommand::Deploy,
},
&args,
cli.verbose,
)?,
},
PrismaCommands::DbPush { args } => {
prisma_cmd::run(prisma_cmd::PrismaCommand::DbPush, &args, cli.verbose)?
}
},
Commands::Tsc { args } => tsc_cmd::run(&args, cli.verbose)?,
Commands::Next { args } => next_cmd::run(&args, cli.verbose)?,
Commands::Lint { args } => lint_cmd::run(&args, cli.verbose)?,
Commands::Prettier { args } => prettier_cmd::run(&args, cli.verbose)?,
Commands::Format { args } => format_cmd::run(&args, cli.verbose)?,
Commands::Playwright { args } => playwright_cmd::run(&args, cli.verbose)?,
Commands::Cargo { command } => match command {
CargoCommands::Build { args } => {
cargo_cmd::run(cargo_cmd::CargoCommand::Build, &args, cli.verbose)?
}
CargoCommands::Test { args } => {
cargo_cmd::run(cargo_cmd::CargoCommand::Test, &args, cli.verbose)?
}
CargoCommands::Clippy { args } => {
cargo_cmd::run(cargo_cmd::CargoCommand::Clippy, &args, cli.verbose)?
}
CargoCommands::Check { args } => {
cargo_cmd::run(cargo_cmd::CargoCommand::Check, &args, cli.verbose)?
}
CargoCommands::Install { args } => {
cargo_cmd::run(cargo_cmd::CargoCommand::Install, &args, cli.verbose)?
}
CargoCommands::Nextest { args } => {
cargo_cmd::run(cargo_cmd::CargoCommand::Nextest, &args, cli.verbose)?
}
CargoCommands::Other(args) => cargo_cmd::run_passthrough(&args, cli.verbose)?,
},
Commands::Npm { args } => npm_cmd::run(&args, cli.verbose, cli.skip_env)?,
Commands::Curl { args } => curl_cmd::run(&args, cli.verbose)?,
Commands::Discover {
project,
limit,
all,
since,
format,
} => {
discover::run(project.as_deref(), all, since, limit, &format, cli.verbose)?;
0
}
Commands::Session {} => {
analytics::session_cmd::run(cli.verbose)?;
0
}
Commands::Telemetry { command } => {
core::telemetry_cmd::run(&command)?;
0
}
Commands::Learn {
project,
all,
since,
format,
write_rules,
min_confidence,
min_occurrences,
} => {
learn::run(
project,
all,
since,
format,
write_rules,
min_confidence,
min_occurrences,
)?;
0
}
Commands::Npx { args } => {
if args.is_empty() {
anyhow::bail!("npx requires a command argument");
}
// Intelligent routing: delegate to specialized filters
match args[0].as_str() {
"tsc" | "typescript" => tsc_cmd::run(&args[1..], cli.verbose)?,
"eslint" => lint_cmd::run(&args[1..], cli.verbose)?,
"prisma" => {
// Route to prisma_cmd based on subcommand
if args.len() > 1 {
let prisma_args: Vec<String> = args[2..].to_vec();
match args[1].as_str() {
"generate" => prisma_cmd::run(
prisma_cmd::PrismaCommand::Generate,
&prisma_args,
cli.verbose,
)?,
"db" if args.len() > 2 && args[2] == "push" => prisma_cmd::run(
prisma_cmd::PrismaCommand::DbPush,
&args[3..],
cli.verbose,
)?,
_ => {
// Passthrough other prisma subcommands
let timer = core::tracking::TimedExecution::start();
let mut cmd = core::utils::resolved_command("npx");
for arg in &args {
cmd.arg(arg);
}
let status = cmd.status().context("Failed to run npx prisma")?;
let args_str = args.join(" ");
timer.track_passthrough(
&format!("npx {}", args_str),
&format!("rtk npx {} (passthrough)", args_str),
);
core::utils::exit_code_from_status(&status, "npx prisma")
}
}
} else {
let timer = core::tracking::TimedExecution::start();
let status = core::utils::resolved_command("npx")
.arg("prisma")
.status()
.context("Failed to run npx prisma")?;
timer.track_passthrough("npx prisma", "rtk npx prisma (passthrough)");
core::utils::exit_code_from_status(&status, "npx prisma")
}
}
"next" => next_cmd::run(&args[1..], cli.verbose)?,
"prettier" => prettier_cmd::run(&args[1..], cli.verbose)?,
"playwright" => playwright_cmd::run(&args[1..], cli.verbose)?,
_ => npm_cmd::exec(&args, cli.verbose, cli.skip_env)?,
}
}
Commands::Ruff { args } => ruff_cmd::run(&args, cli.verbose)?,
Commands::Pytest { args } => pytest_cmd::run(&args, cli.verbose)?,
Commands::Mypy { args } => mypy_cmd::run(&args, cli.verbose)?,
Commands::Rake { args } => rake_cmd::run(&args, cli.verbose)?,
Commands::Rubocop { args } => rubocop_cmd::run(&args, cli.verbose)?,
Commands::Rspec { args } => rspec_cmd::run(&args, cli.verbose)?,
Commands::Pip { args } => pip_cmd::run(&args, cli.verbose)?,
Commands::Go { command } => match command {
GoCommands::Test { args } => go_cmd::run_test(&args, cli.verbose)?,
GoCommands::Build { args } => go_cmd::run_build(&args, cli.verbose)?,
GoCommands::Vet { args } => go_cmd::run_vet(&args, cli.verbose)?,
GoCommands::Other(args) => go_cmd::run_other(&args, cli.verbose)?,
},
Commands::Gt { command } => match command {
GtCommands::Log { args } => gt_cmd::run_log(&args, cli.verbose)?,
GtCommands::Submit { args } => gt_cmd::run_submit(&args, cli.verbose)?,
GtCommands::Sync { args } => gt_cmd::run_sync(&args, cli.verbose)?,
GtCommands::Restack { args } => gt_cmd::run_restack(&args, cli.verbose)?,
GtCommands::Create { args } => gt_cmd::run_create(&args, cli.verbose)?,
GtCommands::Branch { args } => gt_cmd::run_branch(&args, cli.verbose)?,
GtCommands::Other(args) => gt_cmd::run_other(&args, cli.verbose)?,
},
Commands::GolangciLint { args } => golangci_cmd::run(&args, cli.verbose)?,
Commands::Gradlew { args } => gradlew_cmd::run(&args, cli.verbose)?,
Commands::Mvn { args } => mvn_cmd::run(&args, cli.verbose)?,
Commands::HookAudit { since } => {
hooks::hook_audit_cmd::run(since, cli.verbose)?;
0
}
Commands::Hook { command } => match command {
HookCommands::Claude => {
hooks::hook_cmd::run_claude()?;
0
}
HookCommands::Cursor => {
hooks::hook_cmd::run_cursor()?;
0
}
HookCommands::Gemini => {
hooks::hook_cmd::run_gemini()?;
0
}
HookCommands::Copilot => {
hooks::hook_cmd::run_copilot()?;
0
}
HookCommands::Check { agent: _, command } => {
use crate::discover::registry::rewrite_command;
let raw = command.join(" ");
let (excluded, transparent_prefixes) = crate::core::config::Config::load()
.map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes))
.unwrap_or_default();
match rewrite_command(&raw, &excluded, &transparent_prefixes) {
Some(rewritten) => {
println!("{}", rewritten);
0
}
None => {
eprintln!("No rewrite for: {}", raw);
1
}
}
}
},
Commands::Rewrite { args } => {
let cmd = args.join(" ");
hooks::rewrite_cmd::run(&cmd)?;
0
}
Commands::Pipe {
filter,
passthrough,
} => {
pipe_cmd::run(filter.as_deref(), passthrough)?;
0
}
Commands::Run { command, args } => {
let raw = match command {
Some(c) => c,
None if !args.is_empty() => args.join(" "),
None => String::new(),
};
if raw.trim().is_empty() {
0
} else {
use std::process::Command as ProcCommand;
let shell = if cfg!(windows) { "cmd" } else { "sh" };
let flag = if cfg!(windows) { "/C" } else { "-c" };
let status = ProcCommand::new(shell)
.arg(flag)
.arg(&raw)
.status()
.with_context(|| format!("Failed to execute: {}", raw))?;
status.code().unwrap_or(1)
}
}
Commands::Proxy { args } => {
use std::io::{Read, Write};
use std::process::Stdio;
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;
if args.is_empty() {
anyhow::bail!(
"proxy requires a command to execute\nUsage: rtk proxy <command> [args...]"
);
}
let timer = core::tracking::TimedExecution::start();
// If a single quoted arg contains spaces, split it respecting quotes (#388).
// e.g. rtk proxy 'head -50 file.php' → cmd=head, args=["-50", "file.php"]
// e.g. rtk proxy 'git log --format="%H %s"' → cmd=git, args=["log", "--format=%H %s"]
let (cmd_name, cmd_args): (String, Vec<String>) = if args.len() == 1 {
let full = args[0].to_string_lossy();
let parts = shell_split(&full);
if parts.len() > 1 {
(parts[0].clone(), parts[1..].to_vec())
} else {
(full.into_owned(), vec![])
}
} else {
(
args[0].to_string_lossy().into_owned(),
args[1..]
.iter()
.map(|s| s.to_string_lossy().into_owned())
.collect(),
)
};
if cli.verbose > 0 {
eprintln!("Proxy mode: {} {}", cmd_name, cmd_args.join(" "));
}
// ISSUE #897: Kill proxy child on SIGINT/SIGTERM to prevent orphan
// processes. Drop-based ChildGuard doesn't run on signals with
// panic=abort, so we register a signal handler that kills the child
// PID stored in this atomic.
static PROXY_CHILD_PID: AtomicU32 = AtomicU32::new(0);
#[cfg(unix)]
#[allow(unsafe_code)]
{
unsafe extern "C" fn handle_signal(sig: libc::c_int) {
let pid = PROXY_CHILD_PID.load(Ordering::SeqCst);
if pid != 0 {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
libc::waitpid(pid as libc::pid_t, std::ptr::null_mut(), 0);
}
libc::signal(sig, libc::SIG_DFL);
libc::raise(sig);
}
// nosemgrep: unsafe-block
unsafe {
libc::signal(
libc::SIGINT,
handle_signal as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGTERM,
handle_signal as *const () as libc::sighandler_t,
);
}
}
struct ChildGuard(Option<std::process::Child>);
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(mut child) = self.0.take() {
let _ = child.kill();
let _ = child.wait();
}
PROXY_CHILD_PID.store(0, Ordering::SeqCst);
}
}
let mut child = ChildGuard(Some(
core::utils::resolved_command(cmd_name.as_ref())
.args(&cmd_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context(format!("Failed to execute command: {}", cmd_name))?,
));
// Store child PID for signal handler before anything can fail
if let Some(ref inner) = child.0 {
PROXY_CHILD_PID.store(inner.id(), Ordering::SeqCst);
}
let inner = child.0.as_mut().context("Child process missing")?;
let stdout_pipe = inner
.stdout
.take()
.context("Failed to capture child stdout")?;
let stderr_pipe = inner
.stderr
.take()
.context("Failed to capture child stderr")?;
const CAP: usize = 1_048_576;
let stdout_handle = thread::spawn(move || -> std::io::Result<Vec<u8>> {
let mut reader = stdout_pipe;
let mut captured = Vec::new();
let mut buf = [0u8; 8192];
loop {
let count = reader.read(&mut buf)?;
if count == 0 {
break;
}
if captured.len() < CAP {
let take = count.min(CAP - captured.len());
captured.extend_from_slice(&buf[..take]);
}
let mut out = std::io::stdout().lock();
out.write_all(&buf[..count])?;
out.flush()?;
}
Ok(captured)
});
let stderr_handle = thread::spawn(move || -> std::io::Result<Vec<u8>> {
let mut reader = stderr_pipe;
let mut captured = Vec::new();
let mut buf = [0u8; 8192];
loop {
let count = reader.read(&mut buf)?;
if count == 0 {
break;
}
if captured.len() < CAP {
let take = count.min(CAP - captured.len());
captured.extend_from_slice(&buf[..take]);
}
let mut err = std::io::stderr().lock();
err.write_all(&buf[..count])?;
err.flush()?;
}
Ok(captured)
});
let status = child
.0
.take()
.context("Child process missing")?
.wait()
.context(format!("Failed waiting for command: {}", cmd_name))?;
let stdout_bytes = stdout_handle
.join()
.map_err(|_| anyhow::anyhow!("stdout streaming thread panicked"))??;
let stderr_bytes = stderr_handle
.join()
.map_err(|_| anyhow::anyhow!("stderr streaming thread panicked"))??;
let stdout = String::from_utf8_lossy(&stdout_bytes);
let stderr = String::from_utf8_lossy(&stderr_bytes);
let full_output = format!("{}{}", stdout, stderr);
// Track usage (input = output since no filtering)
timer.track(
&format!("{} {}", cmd_name, cmd_args.join(" ")),
&format!("rtk proxy {} {}", cmd_name, cmd_args.join(" ")),
&full_output,
&full_output,
);
core::utils::exit_code_from_status(&status, &cmd_name)
}
Commands::Trust { list } => {
hooks::trust::run_trust(list)?;
0
}
Commands::Untrust => {
hooks::trust::run_untrust()?;
0
}
Commands::Verify {
filter,
require_all,
} => {
if filter.is_some() {
// Filter-specific mode: run only that filter's tests
hooks::verify_cmd::run(filter, require_all)?;
} else {
// Default or --require-all: always run integrity check first
hooks::integrity::run_verify(cli.verbose)?;
hooks::verify_cmd::run(None, require_all)?;
}
0
}
};
Ok(code)
}
/// Returns true for commands that are invoked via the hook pipeline
/// (i.e., commands that process rewritten shell commands).
/// Meta commands (init, gain, verify, etc.) are excluded because
/// they are run directly by the user, not through the hook.
/// Returns true for commands that go through the hook pipeline
/// and therefore require integrity verification.
///
/// SECURITY: whitelist pattern — new commands are NOT integrity-checked
/// until explicitly added here. A forgotten command fails open (no check)
/// rather than creating false confidence about what's protected.
fn is_operational_command(cmd: &Commands) -> bool {
matches!(
cmd,
Commands::Ls { .. }
| Commands::Tree { .. }
| Commands::Read { .. }
| Commands::Smart { .. }
| Commands::Git { .. }
| Commands::Gh { .. }
| Commands::Glab { .. }
| Commands::Pnpm { .. }
| Commands::Err { .. }
| Commands::Test { .. }
| Commands::Json { .. }
| Commands::Deps { .. }
| Commands::Env { .. }
| Commands::Find { .. }
| Commands::Diff { .. }
| Commands::Log { .. }
| Commands::Dotnet { .. }
| Commands::Docker { .. }
| Commands::Kubectl { .. }
| Commands::Oc { .. }
| Commands::Summary { .. }
| Commands::Grep { .. }
| Commands::Wget { .. }
| Commands::Vitest { .. }
| Commands::Prisma { .. }
| Commands::Tsc { .. }
| Commands::Next { .. }
| Commands::Lint { .. }
| Commands::Prettier { .. }
| Commands::Playwright { .. }
| Commands::Cargo { .. }
| Commands::Npm { .. }
| Commands::Npx { .. }
| Commands::Curl { .. }
| Commands::Ruff { .. }
| Commands::Pytest { .. }
| Commands::Rake { .. }
| Commands::Rubocop { .. }
| Commands::Rspec { .. }
| Commands::Pip { .. }
| Commands::Go { .. }
| Commands::GolangciLint { .. }
| Commands::Gt { .. }
)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::cell::Cell;
#[test]
fn test_git_commit_single_message() {
let cli = Cli::try_parse_from(["rtk", "git", "commit", "-m", "fix: typo"]).unwrap();
match cli.command {
Commands::Git {
command: GitCommands::Commit { args },
..
} => {
assert_eq!(args, vec!["-m", "fix: typo"]);
}
_ => panic!("Expected Git Commit command"),
}
}
#[test]
fn test_git_commit_multiple_messages() {
let cli = Cli::try_parse_from([
"rtk",
"git",
"commit",
"-m",
"feat: add support",
"-m",
"Body paragraph here.",
])
.unwrap();
match cli.command {
Commands::Git {
command: GitCommands::Commit { args },
..
} => {
assert_eq!(
args,
vec!["-m", "feat: add support", "-m", "Body paragraph here."]
);
}
_ => panic!("Expected Git Commit command"),
}
}
// #327: git commit -am "msg" was rejected by Clap
#[test]
fn test_git_commit_am_flag() {
let cli = Cli::try_parse_from(["rtk", "git", "commit", "-am", "quick fix"]).unwrap();
match cli.command {
Commands::Git {
command: GitCommands::Commit { args },
..
} => {
assert_eq!(args, vec!["-am", "quick fix"]);
}
_ => panic!("Expected Git Commit command"),
}
}
#[test]
fn test_git_commit_amend() {
let cli =
Cli::try_parse_from(["rtk", "git", "commit", "--amend", "-m", "new msg"]).unwrap();
match cli.command {
Commands::Git {
command: GitCommands::Commit { args },
..
} => {
assert_eq!(args, vec!["--amend", "-m", "new msg"]);
}
_ => panic!("Expected Git Commit command"),
}
}
#[test]
fn test_git_global_options_parsing() {
let cli =
Cli::try_parse_from(["rtk", "git", "--no-pager", "--no-optional-locks", "status"])
.unwrap();
match cli.command {
Commands::Git {
no_pager,
no_optional_locks,
bare,
literal_pathspecs,
..
} => {
assert!(no_pager);
assert!(no_optional_locks);
assert!(!bare);
assert!(!literal_pathspecs);
}
_ => panic!("Expected Git command"),
}
}
#[test]
fn test_git_commit_long_flag_multiple() {
let cli = Cli::try_parse_from([
"rtk",
"git",
"commit",
"--message",
"title",
"--message",
"body",
"--message",
"footer",
])
.unwrap();
match cli.command {
Commands::Git {
command: GitCommands::Commit { args },
..
} => {
assert_eq!(
args,
vec![
"--message",
"title",
"--message",
"body",
"--message",
"footer"
]
);
}
_ => panic!("Expected Git Commit command"),
}
}
#[test]
fn test_try_parse_valid_git_status() {
let result = Cli::try_parse_from(["rtk", "git", "status"]);
assert!(result.is_ok(), "git status should parse successfully");
}
#[test]
fn test_try_parse_init_agent_hermes() {
let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes"]).unwrap();
match cli.command {
Commands::Init { agent, .. } => {
assert_eq!(agent, Some(AgentTarget::Hermes));
}
_ => panic!("Expected Init command"),
}
}
#[test]
fn test_try_parse_kubectl_get_alias() {
let cli = Cli::try_parse_from(["rtk", "kubectl", "get", "pods", "-n", "default"]).unwrap();
match cli.command {
Commands::Kubectl {
command: KubectlCommands::Get { args },
} => assert_eq!(args, vec!["pods", "-n", "default"]),
_ => panic!("Expected Kubectl Get command"),
}
}
#[test]
fn test_try_parse_oc_get() {
let cli = Cli::try_parse_from(["rtk", "oc", "get", "pods", "-n", "default"]).unwrap();
match cli.command {
Commands::Oc {
command: OcCommands::Get { args },
} => assert_eq!(args, vec!["pods", "-n", "default"]),
_ => panic!("Expected Oc Get command"),
}
}
#[test]
fn test_try_parse_oc_other() {
let cli = Cli::try_parse_from(["rtk", "oc", "new-project", "test"]).unwrap();
match cli.command {
Commands::Oc {
command: OcCommands::Other(_),
} => {}
_ => panic!("Expected Oc Other command"),
}
}
#[test]
fn test_try_parse_init_agent_hermes_uninstall() {
let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes", "--uninstall"]).unwrap();
match cli.command {
Commands::Init {
agent, uninstall, ..
} => {
assert_eq!(agent, Some(AgentTarget::Hermes));
assert!(uninstall);
}
_ => panic!("Expected Init command"),
}
}
#[test]
fn test_init_uninstall_dispatch_routes_hermes_to_hermes_cleanup() {
let hermes_called = Cell::new(false);
let standard_called = Cell::new(false);
let ctx = hooks::init::InitContext {
verbose: 2,
dry_run: true,
};
let result = uninstall_init_dispatch(
Some(AgentTarget::Hermes),
true,
false,
false,
ctx,
|ctx| {
hermes_called.set(true);
assert_eq!(ctx.verbose, 2);
assert!(ctx.dry_run);
Ok(())
},
|_, _, _, _, _, _| {
standard_called.set(true);
Ok(())
},
);
assert!(result.is_ok());
assert!(hermes_called.get());
assert!(!standard_called.get());
}
#[test]
fn test_try_parse_help_is_display_help() {
match Cli::try_parse_from(["rtk", "--help"]) {
Err(e) => assert_eq!(e.kind(), ErrorKind::DisplayHelp),
Ok(_) => panic!("Expected DisplayHelp error"),
}
}
#[test]
fn test_try_parse_version_is_display_version() {
match Cli::try_parse_from(["rtk", "--version"]) {
Err(e) => assert_eq!(e.kind(), ErrorKind::DisplayVersion),
Ok(_) => panic!("Expected DisplayVersion error"),
}
}
#[test]
fn test_try_parse_unknown_subcommand_is_error() {
match Cli::try_parse_from(["rtk", "nonexistent-command"]) {
Err(e) => assert!(!matches!(
e.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
)),
Ok(_) => panic!("Expected parse error for unknown subcommand"),
}
}
#[test]
fn test_try_parse_git_with_dash_c_succeeds() {
let result = Cli::try_parse_from(["rtk", "git", "-C", "/path", "status"]);
assert!(
result.is_ok(),
"git -C /path status should parse successfully"
);
if let Ok(cli) = result {
match cli.command {
Commands::Git { directory, .. } => {
assert_eq!(directory, vec!["/path"]);
}
_ => panic!("Expected Git command"),
}
}
}
#[test]
fn test_gain_failures_flag_parses() {
let result = Cli::try_parse_from(["rtk", "gain", "--failures"]);
assert!(result.is_ok());
if let Ok(cli) = result {
match cli.command {
Commands::Gain { failures, .. } => assert!(failures),
_ => panic!("Expected Gain command"),
}
}
}
#[test]
fn test_gain_failures_short_flag_parses() {
let result = Cli::try_parse_from(["rtk", "gain", "-F"]);
assert!(result.is_ok());
if let Ok(cli) = result {
match cli.command {
Commands::Gain { failures, .. } => assert!(failures),
_ => panic!("Expected Gain command"),
}
}
}
#[test]
fn test_meta_commands_reject_bad_flags() {
// RTK meta-commands should produce parse errors (not fall through to raw execution).
// Skip "proxy" because it uses trailing_var_arg (accepts any args by design).
for cmd in RTK_META_COMMANDS {
if matches!(*cmd, "proxy" | "run" | "rewrite" | "session") {
continue; // these use trailing_var_arg (accept any args by design)
}
let result = Cli::try_parse_from(["rtk", cmd, "--nonexistent-flag-xyz"]);
assert!(
result.is_err(),
"Meta-command '{}' with bad flag should fail to parse",
cmd
);
}
}
/// Every subcommand must be in `RTK_META_COMMANDS` (fail closed) or
/// `PASSTHROUGH` (falls through to the real binary). Fails for any new
/// command until it's classified.
#[test]
fn test_every_subcommand_is_classified() {
use clap::CommandFactory;
const PASSTHROUGH: &[&str] = &[
"ls",
"tree",
"read",
"git",
"gh",
"glab",
"aws",
"psql",
"pnpm",
"err",
"test",
"env",
"find",
"diff",
"log",
"dotnet",
"docker",
"kubectl",
"oc",
"summary",
"grep",
"wget",
"wc",
"jest",
"vitest",
"prisma",
"tsc",
"next",
"lint",
"prettier",
"format",
"playwright",
"cargo",
"npm",
"npx",
"curl",
"ruff",
"pytest",
"mypy",
"rake",
"rubocop",
"rspec",
"pip",
"go",
"gt",
"golangci-lint",
"gradlew",
"mvn",
];
let unclassified: Vec<String> = Cli::command()
.get_subcommands()
.map(|c| c.get_name().to_string())
.filter(|name| {
!RTK_META_COMMANDS.contains(&name.as_str()) && !PASSTHROUGH.contains(&name.as_str())
})
.collect();
assert!(
unclassified.is_empty(),
"unclassified subcommand(s) {:?}: add to RTK_META_COMMANDS (no system \
binary) or PASSTHROUGH (wraps a real tool)",
unclassified
);
}
#[test]
fn test_run_command_with_dash_c() {
let cli = Cli::try_parse_from(["rtk", "run", "-c", "git status && echo done"]).unwrap();
match cli.command {
Commands::Run { command, args } => {
assert_eq!(command, Some("git status && echo done".to_string()));
assert!(args.is_empty());
}
_ => panic!("Expected Run command"),
}
}
#[test]
fn test_run_command_positional_args() {
let cli = Cli::try_parse_from(["rtk", "run", "echo", "hello"]).unwrap();
match cli.command {
Commands::Run { command, args } => {
assert!(command.is_none());
assert_eq!(args, vec!["echo", "hello"]);
}
_ => panic!("Expected Run command"),
}
}
#[test]
fn test_hook_claude_parses() {
let cli = Cli::try_parse_from(["rtk", "hook", "claude"]).unwrap();
assert!(matches!(
cli.command,
Commands::Hook {
command: HookCommands::Claude
}
));
}
#[test]
fn test_hook_check_parses() {
let cli = Cli::try_parse_from(["rtk", "hook", "check", "git", "status"]).unwrap();
match cli.command {
Commands::Hook {
command: HookCommands::Check { agent, command },
} => {
assert_eq!(agent, "claude");
assert_eq!(command, vec!["git", "status"]);
}
_ => panic!("Expected Hook Check command"),
}
}
#[test]
fn test_hook_check_with_agent() {
let cli =
Cli::try_parse_from(["rtk", "hook", "check", "--agent", "gemini", "cargo", "test"])
.unwrap();
match cli.command {
Commands::Hook {
command: HookCommands::Check { agent, command },
} => {
assert_eq!(agent, "gemini");
assert_eq!(command, vec!["cargo", "test"]);
}
_ => panic!("Expected Hook Check command"),
}
}
#[test]
fn test_hook_check_preserves_double_dash_in_command() {
let cli = Cli::try_parse_from([
"rtk",
"hook",
"check",
"shadowenv",
"exec",
"--",
"git",
"status",
])
.unwrap();
match cli.command {
Commands::Hook {
command: HookCommands::Check { agent, command },
} => {
assert_eq!(agent, "claude");
assert_eq!(command, vec!["shadowenv", "exec", "--", "git", "status"]);
}
_ => panic!("Expected Hook Check command"),
}
}
#[test]
fn test_meta_command_list_is_complete() {
// Verify all meta-commands are in the guard list by checking they parse with valid syntax
let meta_cmds_that_parse = [
vec!["rtk", "gain"],
vec!["rtk", "discover"],
vec!["rtk", "learn"],
vec!["rtk", "init"],
vec!["rtk", "config"],
vec!["rtk", "proxy", "echo", "hi"],
vec!["rtk", "run", "-c", "echo hi"],
vec!["rtk", "hook-audit"],
vec!["rtk", "cc-economics"],
];
for args in &meta_cmds_that_parse {
let result = Cli::try_parse_from(args.iter());
assert!(
result.is_ok(),
"Meta-command {:?} should parse successfully",
args
);
}
}
#[test]
fn test_shell_split_simple() {
assert_eq!(
shell_split("head -50 file.php"),
vec!["head", "-50", "file.php"]
);
}
#[test]
fn test_shell_split_double_quotes() {
assert_eq!(
shell_split(r#"git log --format="%H %s""#),
vec!["git", "log", "--format=%H %s"]
);
}
#[test]
fn test_shell_split_single_quotes() {
assert_eq!(
shell_split("grep -r 'hello world' ."),
vec!["grep", "-r", "hello world", "."]
);
}
#[test]
fn test_shell_split_single_word() {
assert_eq!(shell_split("ls"), vec!["ls"]);
}
#[test]
fn test_shell_split_empty() {
let result: Vec<String> = shell_split("");
assert!(result.is_empty());
}
#[test]
fn test_rewrite_clap_multi_args() {
// This is the bug KuSh reported: `rtk rewrite ls -al` failed because
// Clap rejected `-al` as an unknown flag. With trailing_var_arg + allow_hyphen_values,
// multiple args are accepted and joined into a single command string.
let cases = vec![
vec!["rtk", "rewrite", "ls", "-al"],
vec!["rtk", "rewrite", "git", "status"],
vec!["rtk", "rewrite", "npm", "exec"],
vec!["rtk", "rewrite", "cargo", "test"],
vec!["rtk", "rewrite", "du", "-sh", "."],
vec!["rtk", "rewrite", "head", "-50", "file.txt"],
];
for args in &cases {
let result = Cli::try_parse_from(args.iter());
assert!(
result.is_ok(),
"rtk rewrite {:?} should parse (was failing before trailing_var_arg fix)",
&args[2..]
);
if let Ok(cli) = result {
match cli.command {
Commands::Rewrite { ref args } => {
assert!(args.len() >= 2, "rewrite args should capture all tokens");
}
_ => panic!("expected Rewrite command"),
}
}
}
}
#[test]
fn test_rewrite_clap_quoted_single_arg() {
// Quoted form: `rtk rewrite "git status"` — single arg containing spaces
let result = Cli::try_parse_from(["rtk", "rewrite", "git status"]);
assert!(result.is_ok());
if let Ok(cli) = result {
match cli.command {
Commands::Rewrite { ref args } => {
assert_eq!(args.len(), 1);
assert_eq!(args[0], "git status");
}
_ => panic!("expected Rewrite command"),
}
}
}
#[test]
fn test_merge_filters_with_no_args() {
let filters = vec![];
let args = vec!["--depth=0".to_string(), "--no-verbose".to_string()];
let expected_args = vec!["--depth=0", "--no-verbose"];
assert_eq!(merge_pnpm_args(&filters, &args), expected_args);
}
#[test]
fn test_merge_filters_with_args() {
let filters = vec!["@app1".to_string(), "@app2".to_string()];
let args = vec![
"--filter=@app3".to_string(),
"--depth=0".to_string(),
"--no-verbose".to_string(),
];
let expected_args = vec![
"--filter=@app1",
"--filter=@app2",
"--filter=@app3",
"--depth=0",
"--no-verbose",
];
assert_eq!(merge_pnpm_args(&filters, &args), expected_args);
}
#[test]
fn test_merge_filters_with_no_args_os() {
let filters = vec![];
let args = vec![OsString::from("--depth=0")];
let expected_args = vec![OsString::from("--depth=0")];
assert_eq!(merge_pnpm_args_os(&filters, &args), expected_args);
}
#[test]
fn test_merge_filters_with_args_os() {
let filters = vec!["@app1".to_string()];
let args = vec![OsString::from("--depth=0")];
let expected_args = vec![
OsString::from("--filter=@app1"),
OsString::from("--depth=0"),
];
assert_eq!(merge_pnpm_args_os(&filters, &args), expected_args);
}
#[test]
fn test_pnpm_subcommand_with_filter() {
let cli = Cli::try_parse_from([
"rtk", "pnpm", "--filter", "@app1", "--filter", "@app2", "list", "--filter", "@app3",
"--filter", "@app4", "--prod",
])
.unwrap();
match cli.command {
Commands::Pnpm {
filter,
command: PnpmCommands::List { depth, args },
} => {
assert_eq!(depth, 0);
assert_eq!(filter, vec!["@app1", "@app2"]);
assert_eq!(
args,
vec!["--filter", "@app3", "--filter", "@app4", "--prod"]
);
}
_ => panic!("Expected Pnpm List command"),
}
}
#[test]
fn test_git_push_u_flag_passes_through() {
let cli = Cli::try_parse_from(["rtk", "git", "push", "-u", "origin", "my-branch"]).unwrap();
assert!(
!cli.ultra_compact,
"-u on git push must NOT be consumed as --ultra-compact"
);
match cli.command {
Commands::Git {
command: GitCommands::Push { args },
..
} => {
assert!(
args.contains(&"-u".to_string()),
"-u must be forwarded to git push, got: {:?}",
args
);
}
_ => panic!("Expected Git Push command"),
}
}
#[test]
fn test_pnpm_subcommand_with_short_filter() {
// -F is the short form of --filter in pnpm
let cli =
Cli::try_parse_from(["rtk", "pnpm", "-F", "@app1", "-F", "@app2", "list"]).unwrap();
match cli.command {
Commands::Pnpm { filter, .. } => {
assert_eq!(filter, vec!["@app1", "@app2"]);
}
_ => panic!("Expected Pnpm command"),
}
}
#[test]
fn test_pnpm_typecheck_without_filters() {
let cli = Cli::try_parse_from([
"rtk",
"pnpm",
"typecheck",
"--filter",
"@app3",
"--filter",
"@app4",
])
.unwrap();
match cli.command {
Commands::Pnpm { filter, command } => {
let warning = validate_pnpm_filters(&filter, &command);
assert!(filter.is_empty());
assert!(warning.is_none())
}
_ => panic!("Expected Pnpm Build command"),
}
}
#[test]
fn test_pnpm_typecheck_with_filters() {
let cli = Cli::try_parse_from([
"rtk",
"pnpm",
"--filter",
"@app1",
"--filter",
"@app2",
"typecheck",
"--filter",
"@app3",
"--filter",
"@app4",
])
.unwrap();
match cli.command {
Commands::Pnpm { filter, command } => {
let warning = validate_pnpm_filters(&filter, &command).unwrap();
assert_eq!(filter, vec!["@app1", "@app2"]);
assert_eq!(warning, "[rtk] warning: --filter is not yet supported for pnpm tsc, filters preceding the subcommand will be ignored")
}
_ => panic!("Expected Pnpm Build command"),
}
}
#[test]
#[ignore] // Integration test: requires `cargo build` first
fn test_broken_pipe_does_not_crash() {
let bin_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("debug")
.join("rtk");
assert!(
bin_path.exists(),
"Debug binary not found at {:?} - run `cargo build` first",
bin_path
);
let mut child = std::process::Command::new(&bin_path)
.args(["git", "log", "--oneline", "-50"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("Failed to spawn rtk");
// Read one byte then drop stdout to close the pipe.
let mut stdout = child.stdout.take().unwrap();
let mut buf = [0u8; 1];
let _ = std::io::Read::read(&mut stdout, &mut buf);
let status = child.wait().expect("Failed to wait for rtk");
let code = status.code().unwrap_or(-1);
assert_ne!(
code, 134,
"rtk crashed with SIGABRT (exit 134) on broken pipe - SIGPIPE handler missing"
);
}
#[test]
fn test_ultra_compact_long_form_still_works() {
let cli = Cli::try_parse_from(["rtk", "--ultra-compact", "git", "status"]).unwrap();
assert!(
cli.ultra_compact,
"--ultra-compact long form must still enable ultra-compact mode"
);
}
#[test]
fn test_npx_unknown_tool_passthrough() {
// The bug (rtk-ai/rtk#815) was that unknown tools under `rtk npx`
// were dispatched to `npm` instead of `npx`. At the parse level, the
// Npx variant must carry all args through unchanged so the dispatch
// arm can forward them to npx.
let cli = Cli::try_parse_from(["rtk", "npx", "cowsay", "hello"]).unwrap();
match cli.command {
Commands::Npx { args } => {
assert_eq!(args, vec!["cowsay", "hello"]);
}
_ => panic!("Expected Commands::Npx for unknown tool"),
}
}
#[test]
fn test_init_pi_flag_rejected() {
// --pi has been removed; --agent pi is the canonical form
let result = Cli::try_parse_from(["rtk", "init", "--pi"]);
assert!(result.is_err(), "--pi must be rejected as unknown argument");
}
#[test]
fn test_init_agent_pi_parses() {
let cli = Cli::try_parse_from(["rtk", "init", "--agent", "pi"]).unwrap();
match cli.command {
Commands::Init { agent, .. } => {
assert_eq!(
agent,
Some(AgentTarget::Pi),
"--agent pi must set Pi variant"
);
}
_ => panic!("Expected Init command"),
}
}
#[test]
fn test_init_uninstall_agent_pi_parses() {
let cli = Cli::try_parse_from(["rtk", "init", "--uninstall", "--agent", "pi", "--global"])
.unwrap();
match cli.command {
Commands::Init {
uninstall,
agent,
global,
..
} => {
assert!(uninstall);
assert_eq!(agent, Some(AgentTarget::Pi));
assert!(global);
}
_ => panic!("Expected Init command"),
}
}
}