mirror of
https://github.com/rtk-ai/rtk.git
synced 2026-09-19 07:33:17 +08:00
223dda2996
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.
3434 lines
112 KiB
Rust
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"),
|
|
}
|
|
}
|
|
}
|