diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c203a381..ef5ae093e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **aws:** expand CLI filters from 8 to 25 subcommands — CloudWatch Logs, CloudFormation events, Lambda, IAM, DynamoDB (with type unwrapping), ECS tasks, EC2 security groups, S3API objects, S3 sync/cp, EKS, SQS, Secrets Manager ([#885](https://github.com/rtk-ai/rtk/pull/885)) * **aws:** add shared runner `run_aws_filtered()` eliminating per-handler boilerplate * **tee:** add `force_tee_hint()` — truncated output saves full data to file with recovery hint +* **direnv:** add `rtk direnv` with `direnv exec` rewrite support and built-in TOML guards for env dumps and auth token reads ## [0.34.1](https://github.com/rtk-ai/rtk/compare/v0.34.0...v0.34.1) (2026-03-28) diff --git a/README.md b/README.md index e9663830d..2dc6213a3 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ git status # Automatically rewritten to rtk git status The hook transparently rewrites Bash commands (e.g., `git status` -> `rtk git status`) before execution. Claude never sees the rewrite, it just gets compressed output. +This also applies to literal `direnv exec ...` commands: RTK rewrites only `direnv exec` to `rtk direnv ...`, while leaving `direnv allow`, `direnv status`, and other subcommands untouched. + **Important:** the hook only runs on Bash tool calls. Claude Code built-in tools like `Read`, `Grep`, and `Glob` do not pass through the Bash hook, so they are not auto-rewritten. To get RTK's compact output for those workflows, use shell commands (`cat`/`head`/`tail`, `rg`/`grep`, `find`) or call `rtk read`, `rtk grep`, or `rtk find` directly. ## How It Works @@ -165,6 +167,14 @@ rtk gh issue list # Compact issue listing rtk gh run list # Workflow run status ``` +### Environment & direnv +```bash +rtk env -f AWS # Filtered env vars +rtk direnv exec . env # Redact KEY=*** under direnv exec +rtk direnv exec . gh auth token # Redact token output to *** +rtk direnv exec . pnpm install # Passthrough when no direnv guard filter matches +``` + ### Test Runners ```bash rtk test cargo test # Show failures only (-90%) diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index ec3b327b3..a82c1e50c 100644 --- a/src/cmds/system/README.md +++ b/src/cmds/system/README.md @@ -8,7 +8,9 @@ - `grep_cmd.rs` reads `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file` - `local_llm.rs` (`rtk smart`) uses `core/filter` for heuristic file summarization - `format_cmd.rs` is a cross-ecosystem dispatcher: auto-detects and routes to `prettier_cmd` or `ruff_cmd` (black is handled inline, not as a separate module) +- `direnv_cmd.rs` handles `direnv exec` passthrough with shared TOML lookup and dual-stream (`stdout` + `stderr`) filtering for matched guard rules ## Cross-command - `format_cmd` routes to `cmds/js/prettier_cmd` and `cmds/python/ruff_cmd` +- `direnv_cmd` uses `core/toml_filter` for standard project/user/built-in precedence while keeping `direnv` as a first-class Rust command diff --git a/src/cmds/system/direnv_cmd.rs b/src/cmds/system/direnv_cmd.rs new file mode 100644 index 000000000..c11f39a0c --- /dev/null +++ b/src/cmds/system/direnv_cmd.rs @@ -0,0 +1,116 @@ +//! Filters `direnv exec` output using the shared TOML registry. + +use crate::core::runner; +use crate::core::toml_filter; +use crate::core::tracking; +use crate::core::utils::{exit_code_from_output, resolved_command}; +use anyhow::{bail, Context, Result}; +use std::ffi::{OsStr, OsString}; +use std::io::{self, Write}; +use std::process::Stdio; + +pub fn run(args: &[OsString], verbose: u8) -> Result { + if args.is_empty() { + bail!("direnv: no arguments specified"); + } + + let toml_disabled = std::env::var("RTK_NO_TOML").ok().as_deref() == Some("1"); + if toml_disabled || !matches!(args.first().and_then(|arg| arg.to_str()), Some("exec")) { + return runner::run_passthrough("direnv", args, verbose); + } + + let args_str = tracking::args_display(args); + let original_cmd = format!("direnv {}", args_str); + let rtk_cmd = format!("rtk direnv {}", args_str); + + if verbose > 0 { + eprintln!("Running: {}", original_cmd); + } + + let lookup_cmd = render_lookup_command(args); + let Some(filter) = toml_filter::find_matching_filter(&lookup_cmd) else { + return runner::run_passthrough("direnv", args, verbose); + }; + + let timer = tracking::TimedExecution::start(); + let output = resolved_command("direnv") + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .context("Failed to run direnv exec")?; + + let stdout_raw = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr_raw = String::from_utf8_lossy(&output.stderr).into_owned(); + let filtered_stdout = toml_filter::apply_filter(filter, &stdout_raw); + let filtered_stderr = toml_filter::apply_filter(filter, &stderr_raw); + + write_filtered(&mut io::stdout().lock(), &filtered_stdout, &stdout_raw)?; + write_filtered(&mut io::stderr().lock(), &filtered_stderr, &stderr_raw)?; + + timer.track( + &original_cmd, + &rtk_cmd, + &format!("{}{}", stdout_raw, stderr_raw), + &format!("{}{}", filtered_stdout, filtered_stderr), + ); + + Ok(exit_code_from_output(&output, "direnv")) +} + +fn render_lookup_command(args: &[OsString]) -> String { + let mut rendered = Vec::with_capacity(args.len() + 1); + rendered.push("direnv".to_string()); + rendered.extend(args.iter().map(|arg| shell_quote(arg))); + rendered.join(" ") +} + +fn shell_quote(arg: &OsStr) -> String { + let value = arg.to_string_lossy(); + if value.is_empty() { + return "''".to_string(); + } + + if value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | ':' | '=')) + { + return value.into_owned(); + } + + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn write_filtered(writer: &mut W, filtered: &str, raw: &str) -> io::Result<()> { + if filtered.is_empty() { + return Ok(()); + } + + writer.write_all(filtered.as_bytes())?; + if raw.ends_with('\n') && !filtered.ends_with('\n') { + writer.write_all(b"\n")?; + } + writer.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_lookup_command_quotes_shell_fragments() { + let args = vec![ + OsString::from("exec"), + OsString::from("."), + OsString::from("sh"), + OsString::from("-lc"), + OsString::from("printenv >&2"), + ]; + + assert_eq!( + render_lookup_command(&args), + "direnv exec . sh -lc 'printenv >&2'" + ); + } +} diff --git a/src/core/README.md b/src/core/README.md index 6c15314cc..ff21d00e4 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -31,6 +31,8 @@ Three-tier filter lookup (first match wins): 2. `~/.config/rtk/filters.toml` (user-global) 3. Built-in filters concatenated by `build.rs` at compile time +Most TOML filters are consumed by the fallback path, but selected Rust commands can opt into the same registry directly when they need shared precedence plus custom execution behavior. `rtk direnv` uses this to apply TOML rules to both `stdout` and `stderr` for matched `direnv exec` commands. + ## Tracking Database Schema ```sql diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index bd294f8af..24b9e2d23 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -253,8 +253,9 @@ impl TomlFilterRegistry { } /// Commands already handled by dedicated Rust modules (routed by Clap before TOML). -/// A TOML filter whose match_command matches one of these will never activate — -/// Clap routes the command before `run_fallback()` is reached. +/// Most TOML filters matching one of these will never activate through +/// `run_fallback()`. Commands that opt into shared TOML lookup from their Rust +/// module are skipped by the shadow warning below. const RUST_HANDLED_COMMANDS: &[&str] = &[ "ls", "tree", @@ -270,6 +271,7 @@ const RUST_HANDLED_COMMANDS: &[&str] = &[ "json", "deps", "env", + "direnv", "find", "diff", "log", @@ -316,9 +318,13 @@ fn compile_filter(name: String, def: TomlFilterDef) -> Result, + }, + /// Find files with compact tree output (accepts native find flags like -name, -type) Find { /// All find arguments (supports both RTK and native find syntax) @@ -1462,6 +1469,8 @@ fn run_cli() -> Result { 0 } + Commands::Direnv { args } => direnv_cmd::run(&args, cli.verbose)?, + Commands::Find { args } => { find_cmd::run_from_args(&args, cli.verbose)?; 0 @@ -2101,6 +2110,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Json { .. } | Commands::Deps { .. } | Commands::Env { .. } + | Commands::Direnv { .. } | Commands::Find { .. } | Commands::Diff { .. } | Commands::Log { .. } diff --git a/tests/direnv_support.rs b/tests/direnv_support.rs new file mode 100644 index 000000000..108ec8dea --- /dev/null +++ b/tests/direnv_support.rs @@ -0,0 +1,322 @@ +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; + +#[cfg(unix)] +fn fake_direnv_script(dir: &Path) { + use std::os::unix::fs::PermissionsExt; + + let path = dir.join("direnv"); + let script = r#"#!/bin/sh +if [ "$1" = "exec" ]; then + shift + DIR="$1" + shift + echo "direnv: loading ${DIR}/.envrc" >&2 + exec "$@" +fi +printf 'real direnv passthrough %s\n' "$*" +"#; + fs::write(&path, script).expect("write fake direnv"); + let mut perms = fs::metadata(&path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).expect("chmod"); +} + +#[cfg(unix)] +fn test_path(dir: &Path) -> std::ffi::OsString { + test_path_with_prefixes(&[dir]) +} + +#[cfg(unix)] +fn test_path_with_prefixes(prefixes: &[&Path]) -> std::ffi::OsString { + let mut paths: Vec<_> = prefixes.iter().map(|path| path.to_path_buf()).collect(); + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + env::join_paths(paths).expect("join PATH") +} + +#[cfg(unix)] +fn fake_gh_script(dir: &Path) { + use std::os::unix::fs::PermissionsExt; + + let path = dir.join("gh"); + let script = r#"#!/bin/sh +if [ "$1" = "auth" ] && [ "$2" = "token" ]; then + printf '%s\n' "$GITHUB_TOKEN" + exit 0 +fi +printf 'fake gh passthrough %s\n' "$*" +"#; + fs::write(&path, script).expect("write fake gh"); + let mut perms = fs::metadata(&path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).expect("chmod"); +} + +fn rtk_bin() -> &'static str { + env!("CARGO_BIN_EXE_rtk") +} + +#[cfg(unix)] +#[test] +fn direnv_non_exec_passthrough_without_filters() { + let temp = tempfile::tempdir().expect("tempdir"); + fake_direnv_script(temp.path()); + + let output = Command::new(rtk_bin()) + .args(["direnv", "status"]) + .env("PATH", test_path(temp.path())) + .output() + .expect("run rtk direnv status"); + + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "real direnv passthrough status" + ); +} + +#[cfg(unix)] +#[test] +fn direnv_exec_uses_builtin_filter_without_user_global_config() { + let temp = tempfile::tempdir().expect("tempdir"); + fake_direnv_script(temp.path()); + + let output = Command::new(rtk_bin()) + .args(["direnv", "exec", ".", "env"]) + .env("PATH", test_path(temp.path())) + .env("GITHUB_TOKEN", "ghp_secret_value") + .output() + .expect("run rtk direnv exec"); + + assert!(output.status.success()); + assert!( + String::from_utf8_lossy(&output.stdout).contains("GITHUB_TOKEN=***"), + "stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); +} + +#[cfg(unix)] +#[test] +fn direnv_exec_redacts_stdout_and_stderr() { + let temp = tempfile::tempdir().expect("tempdir"); + fake_direnv_script(temp.path()); + + let output = Command::new(rtk_bin()) + .args(["direnv", "exec", ".", "sh", "-lc", "printenv >&2; env"]) + .env("PATH", test_path(temp.path())) + .env("GITHUB_TOKEN", "ghp_secret_value") + .env("OPENAI_API_KEY", "sk-secret_value") + .output() + .expect("run rtk direnv exec"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("GITHUB_TOKEN=***")); + assert!(stderr.contains("GITHUB_TOKEN=***")); + assert!(!stdout.contains("ghp_secret_value")); + assert!(!stderr.contains("ghp_secret_value")); + assert!(stderr.contains("direnv: loading ./.envrc")); +} + +#[cfg(unix)] +#[test] +fn direnv_exec_unmatched_command_passthrough() { + let temp = tempfile::tempdir().expect("tempdir"); + fake_direnv_script(temp.path()); + + let output = Command::new(rtk_bin()) + .args(["direnv", "exec", ".", "sh", "-lc", "printf 1"]) + .env("PATH", test_path(temp.path())) + .output() + .expect("run rtk direnv passthrough"); + + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "1"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("direnv: loading ./.envrc"), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +#[test] +fn direnv_exec_user_global_override_applies() { + let temp = tempfile::tempdir().expect("tempdir"); + fake_direnv_script(temp.path()); + + #[cfg(target_os = "macos")] + let filters_path = temp + .path() + .join("Library") + .join("Application Support") + .join("rtk") + .join("filters.toml"); + #[cfg(not(target_os = "macos"))] + let filters_path = temp.path().join(".config").join("rtk").join("filters.toml"); + + fs::create_dir_all(filters_path.parent().expect("config dir")).expect("mkdir config"); + fs::write( + &filters_path, + r#"schema_version = 1 + +[filters.direnv-user-override] +description = "Override built-in direnv env filtering" +match_command = "^direnv\\s+exec\\s+\\S+\\s+env(?:\\s|$)" +keep_lines_matching = ["^GITHUB_TOKEN="] +replace = [ + { pattern = "^([A-Za-z_][A-Za-z0-9_]*)=.*$", replacement = "$1=USER" }, +] +"#, + ) + .expect("write user-global override"); + + let output = Command::new(rtk_bin()) + .args(["direnv", "exec", ".", "env"]) + .env("PATH", test_path(temp.path())) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join(".config")) + .env("GITHUB_TOKEN", "ghp_secret_value") + .env("OPENAI_API_KEY", "sk-secret_value") + .output() + .expect("run rtk direnv env with user-global override"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("GITHUB_TOKEN=USER")); + assert!(!stdout.contains("OPENAI_API_KEY")); +} + +#[cfg(unix)] +#[test] +fn direnv_exec_respects_rtk_no_toml_bypass() { + let temp = tempfile::tempdir().expect("tempdir"); + fake_direnv_script(temp.path()); + + let output = Command::new(rtk_bin()) + .args(["direnv", "exec", ".", "sh", "-lc", "printenv >&2; env"]) + .env("PATH", test_path(temp.path())) + .env("RTK_NO_TOML", "1") + .env("GITHUB_TOKEN", "ghp_secret_value") + .output() + .expect("run rtk direnv exec with RTK_NO_TOML"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("GITHUB_TOKEN=ghp_secret_value")); + assert!(stderr.contains("GITHUB_TOKEN=ghp_secret_value")); + assert!(!stdout.contains("GITHUB_TOKEN=***")); + assert!(!stderr.contains("GITHUB_TOKEN=***")); +} + +#[cfg(unix)] +#[test] +fn direnv_exec_real_source_up_redacts_gh_auth_token() { + let temp = tempfile::tempdir().expect("tempdir"); + let parent = temp.path().join("parent"); + let child = parent.join("child"); + let bin = temp.path().join("bin"); + let xdg_config = temp.path().join(".config"); + let xdg_data = temp.path().join(".local").join("share"); + + fs::create_dir_all(&child).expect("mkdir child"); + fs::create_dir_all(&bin).expect("mkdir bin"); + fs::create_dir_all(&xdg_config).expect("mkdir xdg config"); + fs::create_dir_all(&xdg_data).expect("mkdir xdg data"); + fs::write( + parent.join(".envrc"), + "export GITHUB_TOKEN=ghp_source_up_secret\n", + ) + .expect("write parent .envrc"); + fs::write(child.join(".envrc"), "source_up\n").expect("write child .envrc"); + fake_gh_script(&bin); + + let path = test_path_with_prefixes(&[&bin]); + let home = temp.path(); + + let parent_allow = Command::new("direnv") + .current_dir(&parent) + .arg("allow") + .env("PATH", &path) + .env("HOME", home) + .env("XDG_CONFIG_HOME", &xdg_config) + .env("XDG_DATA_HOME", &xdg_data) + .output() + .expect("direnv allow parent"); + assert!( + parent_allow.status.success(), + "parent allow stderr: {}", + String::from_utf8_lossy(&parent_allow.stderr) + ); + + let child_allow = Command::new("direnv") + .current_dir(&child) + .arg("allow") + .env("PATH", &path) + .env("HOME", home) + .env("XDG_CONFIG_HOME", &xdg_config) + .env("XDG_DATA_HOME", &xdg_data) + .output() + .expect("direnv allow child"); + assert!( + child_allow.status.success(), + "child allow stderr: {}", + String::from_utf8_lossy(&child_allow.stderr) + ); + + let output = Command::new(rtk_bin()) + .current_dir(&child) + .args(["direnv", "exec", ".", "gh", "auth", "token"]) + .env("PATH", &path) + .env("HOME", home) + .env("XDG_CONFIG_HOME", &xdg_config) + .env("XDG_DATA_HOME", &xdg_data) + .output() + .expect("run rtk direnv exec gh auth token"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("***"), "stdout: {}", stdout); + assert!( + !stdout.contains("ghp_source_up_secret"), + "stdout: {}", + stdout + ); + assert!( + !stderr.contains("ghp_source_up_secret"), + "stderr: {}", + stderr + ); +} + +#[cfg(unix)] +#[test] +fn direnv_exec_shell_wrapped_gh_auth_token_is_redacted() { + let temp = tempfile::tempdir().expect("tempdir"); + fake_direnv_script(temp.path()); + fake_gh_script(temp.path()); + + let output = Command::new(rtk_bin()) + .args(["direnv", "exec", ".", "sh", "-lc", "gh auth token"]) + .env("PATH", test_path(temp.path())) + .env("GITHUB_TOKEN", "ghp_wrapped_secret_value") + .output() + .expect("run rtk direnv exec sh -lc gh auth token"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("***"), "stdout: {}", stdout); + assert!(!stdout.contains("ghp_wrapped_secret_value"), "stdout: {}", stdout); + assert!( + !stderr.contains("ghp_wrapped_secret_value"), + "stderr: {}", + stderr + ); +}