Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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%)
Expand Down
2 changes: 2 additions & 0 deletions src/cmds/system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
116 changes: 116 additions & 0 deletions src/cmds/system/direnv_cmd.rs
Original file line number Diff line number Diff line change
@@ -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<i32> {
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<W: Write>(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'"
);
}
}
2 changes: 2 additions & 0 deletions src/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 67 additions & 9 deletions src/core/toml_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -270,6 +271,7 @@ const RUST_HANDLED_COMMANDS: &[&str] = &[
"json",
"deps",
"env",
"direnv",
"find",
"diff",
"log",
Expand Down Expand Up @@ -316,9 +318,13 @@ fn compile_filter(name: String, def: TomlFilterDef) -> Result<CompiledFilter, St
let match_regex = Regex::new(&def.match_command)
.map_err(|e| format!("invalid match_command regex: {}", e))?;

// Shadow warning: if match_command matches a Rust-handled command, this filter
// will never activate (Clap routes before run_fallback). Warn the author.
// Shadow warning: if match_command matches a Rust-handled command that does
// not opt into shared TOML lookup, this filter will never activate
// (Clap routes before run_fallback). Warn the author.
for cmd in RUST_HANDLED_COMMANDS {
if *cmd == "direnv" {
continue;
}
if match_regex.is_match(cmd) {
eprintln!(
"[rtk] warning: filter '{}' match_command matches '{}' which is already \
Expand Down Expand Up @@ -1563,6 +1569,10 @@ match_command = "^make\\b"
"brew-install",
"composer-install",
"df",
"direnv-01-env-dump",
"direnv-02-shell-env-dump",
"direnv-03-gh-auth-token",
"direnv-04-node-auth-token",
"dotnet-build",
"du",
"fail2ban-client",
Expand Down Expand Up @@ -1613,13 +1623,58 @@ match_command = "^make\\b"
let filters = make_filters(BUILTIN_TOML);
assert_eq!(
filters.len(),
58,
"Expected exactly 58 built-in filters, got {}. \
62,
"Expected exactly 62 built-in filters, got {}. \
Update this count when adding/removing filters in src/filters/.",
filters.len()
);
}

#[test]
fn test_builtin_direnv_filters_match_expected_commands() {
let filters = make_filters(BUILTIN_TOML);

assert_eq!(
find_filter_in("direnv exec . env", &filters).map(|f| f.name.as_str()),
Some("direnv-01-env-dump")
);
assert_eq!(
find_filter_in("direnv exec . printenv", &filters).map(|f| f.name.as_str()),
Some("direnv-01-env-dump")
);
assert_eq!(
find_filter_in("direnv exec . bash -lc 'printenv'", &filters).map(|f| f.name.as_str()),
Some("direnv-02-shell-env-dump")
);
assert_eq!(
find_filter_in("direnv exec . gh auth token", &filters).map(|f| f.name.as_str()),
Some("direnv-03-gh-auth-token")
);
assert_eq!(
find_filter_in("direnv exec . sh -lc 'gh auth token'", &filters)
.map(|f| f.name.as_str()),
Some("direnv-03-gh-auth-token")
);
assert_eq!(
find_filter_in(
"direnv exec . pnpm config get //registry.npmjs.org/:_authToken",
&filters
)
.map(|f| f.name.as_str()),
Some("direnv-04-node-auth-token")
);
}

#[test]
fn test_builtin_direnv_filters_skip_safe_commands() {
let filters = make_filters(BUILTIN_TOML);

assert!(find_filter_in("direnv exec . pnpm install", &filters).is_none());
assert!(find_filter_in("direnv exec . gh auth status", &filters).is_none());
assert!(find_filter_in("direnv exec . bash -lc 'envsubst < file'", &filters).is_none());
assert!(find_filter_in("direnv allow .", &filters).is_none());
}

/// Verify that every built-in filter has at least one inline test.
/// Prevents shipping filters with zero test coverage.
#[test]
Expand Down Expand Up @@ -1669,13 +1724,16 @@ input = "output line 1\n\noutput line 2"
expected = "output line 1\noutput line 2"
"#;
let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter);
let builtin_count = make_filters(BUILTIN_TOML).len();
let filters = make_filters(&combined);

// All 58 existing filters still present + 1 new = 59
// All existing filters still present + 1 new filter.
assert_eq!(
filters.len(),
59,
"Expected 59 filters after concat (58 built-in + 1 new)"
builtin_count + 1,
"Expected {} filters after concat ({} built-in + 1 new)",
builtin_count + 1,
builtin_count
);

// New filter is discoverable
Expand Down
34 changes: 34 additions & 0 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,19 @@ mod tests {
);
}

#[test]
fn test_classify_direnv_exec() {
assert_eq!(
classify_command("direnv exec . env"),
Classification::Supported {
rtk_equivalent: "rtk direnv",
category: "Env",
estimated_savings_pct: 90.0,
status: RtkStatus::Existing,
}
);
}

#[test]
fn test_classify_cargo_check() {
assert_eq!(
Expand Down Expand Up @@ -1542,6 +1555,27 @@ mod tests {
);
}

#[test]
fn test_rewrite_direnv_exec() {
assert_eq!(
rewrite_command("direnv exec . env", &[]),
Some("rtk direnv exec . env".into())
);
}

#[test]
fn test_rewrite_direnv_exec_pipe_first_only() {
assert_eq!(
rewrite_command("direnv exec . env | rg '^GITHUB_TOKEN='", &[]),
Some("rtk direnv exec . env | rg '^GITHUB_TOKEN='".into())
);
}

#[test]
fn test_rewrite_direnv_allow_skipped() {
assert_eq!(rewrite_command("direnv allow .", &[]), None);
}

#[test]
fn test_rewrite_cargo_install() {
assert_eq!(
Expand Down
Loading