From 25b752bf7e59a520fc6c1c15d4729ce52479f417 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Mon, 16 Feb 2026 22:05:30 +0100 Subject: [PATCH 1/4] feat: add `rtk scaffold` command for contributor boilerplate generation Generates new command filter modules with strategy-based templates (plain/regex/json/ndjson/text), reducing new filter setup from 2-3h to ~30min. Includes contributor guide at docs/ADDING_TOOLS.md. Closes #87 Co-Authored-By: Claude Opus 4.6 --- docs/ADDING_TOOLS.md | 246 +++++++++++++++++ scripts/test-all.sh | 11 + src/main.rs | 21 ++ src/scaffold_cmd.rs | 615 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 893 insertions(+) create mode 100644 docs/ADDING_TOOLS.md create mode 100644 src/scaffold_cmd.rs diff --git a/docs/ADDING_TOOLS.md b/docs/ADDING_TOOLS.md new file mode 100644 index 0000000..7e5f470 --- /dev/null +++ b/docs/ADDING_TOOLS.md @@ -0,0 +1,246 @@ +# Adding a New Tool to RTK + +This guide walks you through adding a new command filter to RTK. The `rtk scaffold` command generates 60% of the boilerplate — this guide covers the remaining integration and testing. + +## Quick Start + +```bash +# 1. Generate the module +rtk scaffold swift --strategy text --dry-run # preview first +rtk scaffold swift --strategy text # generate src/swift_cmd.rs + +# 2. Follow the printed instructions to wire it into main.rs +# 3. Run quality gate +cargo fmt --all && cargo clippy --all-targets && cargo test +``` + +## Strategy Decision Tree + +Pick a strategy based on the tool's output format: + +``` +Does the tool support JSON output? +├── YES: Does it output a single JSON array/object? +│ ├── YES → json (e.g., ruff check --output-format=json) +│ └── NO: Line-by-line JSON (NDJSON)? +│ └── YES → ndjson (e.g., go test -json) +└── NO: Is the output structured with repeating patterns? + ├── YES: Can you capture with regex (file:line: message)? + │ ├── YES → regex (e.g., gcc, eslint default) + │ └── NO: Does it have distinct phases (header/results/summary)? + │ └── YES → text (e.g., pytest, swift test) + └── NO → plain (simple line filtering) +``` + +### Strategy Reference + +| Strategy | When to use | Example modules | Typical savings | +|----------|-------------|-----------------|-----------------| +| `plain` | Simple line filtering, grep-like | `grep_cmd.rs` | 60-70% | +| `regex` | Repeating `file:line: msg` patterns | `lint_cmd.rs`, `tsc_cmd.rs` | 80-85% | +| `json` | Tool supports `--output-format=json` | `ruff_cmd.rs`, `golangci_cmd.rs` | 80-90% | +| `ndjson` | Line-by-line JSON streaming | `go_cmd.rs` (test) | 85-90% | +| `text` | State machine for phased output | `pytest_cmd.rs`, `vitest_cmd.rs` | 90%+ | + +## Step-by-Step Integration (9 Steps) + +### Step 1: Scaffold the Module + +```bash +rtk scaffold --strategy +# Creates src/_cmd.rs with: +# - run() function with tracking + tee +# - filter function skeleton +# - Basic test scaffolding +``` + +### Step 2: Add Module Declaration + +In `src/main.rs`, add with the other `mod` declarations (alphabetical order): + +```rust +mod _cmd; +``` + +### Step 3: Add Command Variant + +In the `Commands` enum in `src/main.rs`: + +```rust +/// with compact output + { + /// arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +}, +``` + +### Step 4: Add Match Arm + +In the `main()` match statement: + +```rust +Commands:: { args } => { + _cmd::run(&args, cli.verbose)?; +} +``` + +### Step 5: Implement the Filter + +Edit `src/_cmd.rs` and replace the TODO sections in the filter function with your actual filtering logic. Key principles: + +- **Show failures, hide successes** (for test runners) +- **Group by file** (for linters/compilers) +- **Strip noise** (ASCII art, progress bars, blank lines) +- **Preserve exit codes** (critical for CI/CD) + +### Step 6: Create Test Fixtures + +Capture real output from the tool: + +```bash + > tests/fixtures/_raw.txt 2>&1 +``` + +Update tests to use the fixture: + +```rust +#[test] +fn test__filter_real_output() { + let input = include_str!("../tests/fixtures/_raw.txt"); + let output = filter__output(input); + assert!(!output.is_empty()); +} +``` + +### Step 7: Verify Token Savings + +Add a token savings test (target: 60%+): + +```rust +fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() +} + +#[test] +fn test__token_savings() { + let input = include_str!("../tests/fixtures/_raw.txt"); + let output = filter__output(input); + let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!(savings >= 60.0, "Expected >=60% savings, got {:.1}%", savings); +} +``` + +### Step 8: Add Smoke Test + +In `scripts/test-all.sh`, add a section: + +```bash +if command -v &>/dev/null; then + assert_help "rtk " rtk --help + assert_ok "rtk " rtk +else + skip " not installed" +fi +``` + +### Step 9: Run Quality Gate + +```bash +cargo fmt --all && cargo clippy --all-targets && cargo test +``` + +Then test manually: + +```bash +cargo run -- +# Verify output is condensed and readable +``` + +## Walkthrough: Adding `swift test` + +Here's a complete example of adding Swift test support: + +```bash +# 1. Scaffold +rtk scaffold swift --strategy text + +# 2. Edit src/swift_cmd.rs +# - Replace Command::new("swift") args to pass ["test"] by default +# - Implement filter: keep only failures + summary line +# - State machine: Header → TestResults → Failures → Summary + +# 3. Wire in main.rs +# mod swift_cmd; +# Commands enum: Swift { args: Vec } +# Match arm: Commands::Swift { args } => swift_cmd::run(&args, cli.verbose)?; + +# 4. Create fixture +swift test 2>&1 > tests/fixtures/swift_test_raw.txt + +# 5. Add tests with fixture + token savings assertion + +# 6. Quality gate +cargo fmt --all && cargo clippy --all-targets && cargo test + +# 7. Manual test +cargo run -- swift test +``` + +## Common Patterns + +### Forcing Tool Output Format + +Many tools support JSON output. Force it for reliable parsing: + +```rust +// ruff check: force JSON +cmd.arg("check").arg("--output-format=json"); + +// go test: force JSON +cmd.arg("test").arg("-json"); + +// golangci-lint: force JSON +cmd.arg("run").arg("--out-format=json"); +``` + +### Graceful Fallback + +If your filter fails, fall back to raw output: + +```rust +let filtered = match filter_output(&raw) { + Ok(f) => f, + Err(e) => { + if verbose > 0 { + eprintln!("Filter failed: {}, showing raw output", e); + } + raw.to_string() + } +}; +``` + +### Tee for Large Outputs + +For commands that produce large output, the tee system saves raw output to disk on failure so LLMs can re-read without re-running: + +```rust +let exit_code = output.status.code().unwrap_or(1); +if let Some(hint) = crate::tee::tee_and_hint(&raw, "", exit_code) { + println!("{}\n{}", filtered, hint); +} +``` + +This is already included in scaffolded modules. + +## Checklist + +- [ ] `rtk scaffold --strategy ` run +- [ ] Filter logic implemented (not just TODO) +- [ ] Module registered in `main.rs` (mod + enum + match) +- [ ] Test fixture from real command output +- [ ] Token savings test (>=60%) +- [ ] Smoke test in `scripts/test-all.sh` +- [ ] `cargo fmt --all && cargo clippy --all-targets && cargo test` passes +- [ ] Manual test: `cargo run -- ` output is correct +- [ ] README.md updated with new command diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 74203f4..e1b4608 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -413,6 +413,17 @@ else skip "golangci-lint not installed" fi +# ── 28b. Scaffold ────────────────────────────────── + +section "Scaffold" + +assert_help "rtk scaffold" rtk scaffold +assert_ok "rtk scaffold --dry-run plain" rtk scaffold testxyz --strategy plain --dry-run +assert_ok "rtk scaffold --dry-run json" rtk scaffold testxyz --strategy json --dry-run +assert_ok "rtk scaffold --dry-run regex" rtk scaffold testxyz --strategy regex --dry-run +assert_ok "rtk scaffold --dry-run ndjson" rtk scaffold testxyz --strategy ndjson --dry-run +assert_ok "rtk scaffold --dry-run text" rtk scaffold testxyz --strategy text --dry-run + # ── 29. Global flags ──────────────────────────────── section "Global flags" diff --git a/src/main.rs b/src/main.rs index d9de230..2da5b48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ mod pytest_cmd; mod read; mod ruff_cmd; mod runner; +mod scaffold_cmd; mod summary; mod tee; mod tracking; @@ -485,6 +486,18 @@ enum Commands { min_occurrences: usize, }, + /// Scaffold a new command module with boilerplate + Scaffold { + /// Tool name (e.g., swift, gcc, jq) + tool: String, + /// Filter strategy + #[arg(short, long, default_value = "plain", value_enum)] + strategy: scaffold_cmd::Strategy, + /// Print generated code to stdout without writing files + #[arg(long)] + dry_run: bool, + }, + /// Execute command without filtering but track usage Proxy { /// Command and arguments to execute @@ -1422,6 +1435,14 @@ fn main() -> Result<()> { hook_audit_cmd::run(since, cli.verbose)?; } + Commands::Scaffold { + tool, + strategy, + dry_run, + } => { + scaffold_cmd::run(&tool, &strategy, dry_run, cli.verbose)?; + } + Commands::Proxy { args } => { use std::process::Command; diff --git a/src/scaffold_cmd.rs b/src/scaffold_cmd.rs new file mode 100644 index 0000000..79fe658 --- /dev/null +++ b/src/scaffold_cmd.rs @@ -0,0 +1,615 @@ +use anyhow::{bail, Context, Result}; +use std::path::Path; + +/// Filter strategy for scaffolded modules. +#[derive(Clone, Debug, clap::ValueEnum)] +pub enum Strategy { + /// Simple line filtering (grep-like) + Plain, + /// Regex-based with lazy_static! captures grouped by file + Regex, + /// JSON parsing with serde_json + Json, + /// Line-by-line NDJSON streaming + Ndjson, + /// State machine text parsing + Text, +} + +impl std::fmt::Display for Strategy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Strategy::Plain => write!(f, "plain"), + Strategy::Regex => write!(f, "regex"), + Strategy::Json => write!(f, "json"), + Strategy::Ndjson => write!(f, "ndjson"), + Strategy::Text => write!(f, "text"), + } + } +} + +/// Validate that a tool name is a valid Rust identifier and doesn't collide with existing modules. +fn validate_tool_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("Tool name cannot be empty"); + } + + if name.starts_with(|c: char| c.is_ascii_digit()) { + bail!("Tool name cannot start with a digit: '{}'", name); + } + + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + bail!( + "Tool name must contain only alphanumeric characters and underscores: '{}'", + name + ); + } + + // Check for collision with existing modules + let module_path = format!("src/{}_cmd.rs", name); + if Path::new(&module_path).exists() { + bail!( + "Module already exists: {}. Choose a different name.", + module_path + ); + } + + Ok(()) +} + +/// Convert a snake_case tool name to PascalCase for use in enum variants. +fn to_pascal_case(name: &str) -> String { + name.split('_') + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + None => String::new(), + Some(first) => { + let upper: String = first.to_uppercase().collect(); + upper + &chars.collect::() + } + } + }) + .collect() +} + +/// Generate the module source code for the given tool and strategy. +fn generate_module(tool: &str, strategy: &Strategy) -> String { + let filter_fn = format!("filter_{}_output", tool); + + let (extra_imports, filter_body, test_input, extra_test) = match strategy { + Strategy::Plain => ( + String::new(), + format!( + r#" let mut lines = Vec::new(); + for line in input.lines() {{ + // TODO: Add your filtering logic here + // Skip empty lines and noise, keep useful output + if !line.trim().is_empty() {{ + lines.push(line); + }} + }} + lines.join("\n")"# + ), + r#"line 1: something useful + +line 2: also useful +noise line to filter +line 3: important"# + .to_string(), + String::new(), + ), + + Strategy::Regex => ( + r#"use lazy_static::lazy_static; +use regex::Regex; +"# + .to_string(), + format!( + r#" lazy_static! {{ + static ref PATTERN: Regex = Regex::new(r"^(.+):(\d+): (.+)$").unwrap(); + }} + + let mut grouped: std::collections::HashMap> = std::collections::HashMap::new(); + for line in input.lines() {{ + if let Some(caps) = PATTERN.captures(line) {{ + let file = caps[1].to_string(); + let line_num = &caps[2]; + let msg = &caps[3]; + grouped.entry(file).or_default().push(format!(" L{{}}: {{}}", line_num, msg)); + }} + }} + + let mut output = Vec::new(); + for (file, messages) in &grouped {{ + output.push(format!("{{}} ({{}} issues)", file, messages.len())); + for msg in messages {{ + output.push(msg.clone()); + }} + }} + output.join("\n")"# + ), + r#"src/main.rs:10: unused variable +src/main.rs:20: missing semicolon +src/lib.rs:5: dead code"# + .to_string(), + String::new(), + ), + + Strategy::Json => ( + r#"use serde::Deserialize; +"# + .to_string(), + format!( + r#" // TODO: Define your JSON structure above and deserialize here + #[derive(Debug, Deserialize)] + struct Item {{ + message: String, + #[allow(dead_code)] + severity: Option, + }} + + let items: Vec = serde_json::from_str(input) + .context("Failed to parse {} JSON output")?; + + let mut output = Vec::new(); + output.push(format!("{{}} issues found", items.len())); + for item in &items {{ + output.push(format!(" {{}}", item.message)); + }} + Ok(output.join("\n"))"#, + tool + ), + r#"[{"message":"something wrong","severity":"error"},{"message":"minor issue","severity":"warning"}]"#.to_string(), + String::new(), + ), + + Strategy::Ndjson => ( + r#"use serde::Deserialize; +"# + .to_string(), + format!( + r#" #[derive(Debug, Deserialize)] + struct Event {{ + #[serde(rename = "Action")] + action: Option, + #[serde(rename = "Output")] + output: Option, + }} + + let mut failures = Vec::new(); + for line in input.lines() {{ + if line.trim().is_empty() {{ + continue; + }} + if let Ok(event) = serde_json::from_str::(line) {{ + if event.action.as_deref() == Some("fail") {{ + if let Some(out) = &event.output {{ + failures.push(out.trim().to_string()); + }} + }} + }} + }} + + if failures.is_empty() {{ + "all passed".to_string() + }} else {{ + format!("{{}} failures:\n{{}}", failures.len(), failures.join("\n")) + }}"# + ), + r#"{"Action":"pass","Output":"ok"} +{"Action":"fail","Output":"test_something failed"} +{"Action":"pass","Output":"ok"}"# + .to_string(), + String::new(), + ), + + Strategy::Text => ( + String::new(), + format!( + r#" #[derive(Debug, PartialEq)] + enum State {{ + Header, + Results, + Failures, + Summary, + }} + + let mut state = State::Header; + let mut failures = Vec::new(); + let mut summary_lines = Vec::new(); + + for line in input.lines() {{ + match state {{ + State::Header => {{ + if line.contains("FAIL") || line.contains("FAILURES") {{ + state = State::Failures; + }} else if line.contains("passed") || line.contains("failed") {{ + state = State::Summary; + summary_lines.push(line.to_string()); + }} + }} + State::Results => {{ + if line.contains("FAIL") {{ + state = State::Failures; + failures.push(line.to_string()); + }} else if line.contains("passed") || line.contains("failed") {{ + state = State::Summary; + summary_lines.push(line.to_string()); + }} + }} + State::Failures => {{ + if line.contains("passed") || line.contains("failed") {{ + state = State::Summary; + summary_lines.push(line.to_string()); + }} else if !line.trim().is_empty() {{ + failures.push(line.to_string()); + }} + }} + State::Summary => {{ + if !line.trim().is_empty() {{ + summary_lines.push(line.to_string()); + }} + }} + }} + }} + + let mut output = Vec::new(); + if !failures.is_empty() {{ + output.push(format!("{{}} failures:", failures.len())); + output.extend(failures); + }} + output.extend(summary_lines); + if output.is_empty() {{ + output.push("no output".to_string()); + }} + output.join("\n")"# + ), + r#"Running tests... +test_one ... ok +test_two ... FAIL +FAILURES: +test_two: expected 1, got 2 +2 tests, 1 passed, 1 failed"# + .to_string(), + String::new(), + ), + }; + + // Determine if filter returns Result or String + let (filter_return_type, filter_call) = match strategy { + Strategy::Json => ( + "Result", + format!(" let filtered = {}(&raw)?;", filter_fn), + ), + _ => ("String", format!(" let filtered = {}(&raw);", filter_fn)), + }; + + let _ = extra_test; // Reserved for future per-strategy tests + + format!( + r####"use crate::tracking; +use crate::utils::truncate; +use anyhow::{{Context, Result}}; +use std::process::Command; +{extra_imports} +pub fn run(args: &[String], verbose: u8) -> Result<()> {{ + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("{tool}"); + for arg in args {{ + cmd.arg(arg); + }} + + if verbose > 0 {{ + eprintln!("Running: {tool} {{}}", args.join(" ")); + }} + + let output = cmd + .output() + .context("Failed to run {tool}. Is it installed?")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{{}}\n{{}}", stdout, stderr); + +{filter_call} + + let exit_code = output + .status + .code() + .unwrap_or(if output.status.success() {{ 0 }} else {{ 1 }}); + if let Some(hint) = crate::tee::tee_and_hint(&raw, "{tool}", exit_code) {{ + println!("{{}}\n{{}}", filtered, hint); + }} else {{ + println!("{{}}", filtered); + }} + + if !stderr.trim().is_empty() {{ + eprintln!("{{}}", truncate(stderr.trim(), 500)); + }} + + timer.track( + &format!("{tool} {{}}", args.join(" ")), + &format!("rtk {tool} {{}}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() {{ + std::process::exit(exit_code); + }} + + Ok(()) +}} + +fn {filter_fn}(input: &str) -> {filter_return_type} {{ +{filter_body} +}} + +#[cfg(test)] +mod tests {{ + use super::*; + + fn count_tokens(text: &str) -> usize {{ + text.split_whitespace().count() + }} + + #[test] + fn test_{tool}_filter_basic() {{ + let input = r##"{test_input}"##; + let output = {filter_fn}(input){unwrap}; + assert!(!output.is_empty(), "Filter should produce output"); + }} + + #[test] + fn test_{tool}_filter_empty_input() {{ + let output = {filter_fn}(""){unwrap}; + // Should not panic on empty input + let _ = output; + }} +}} +"####, + extra_imports = extra_imports, + tool = tool, + filter_fn = filter_fn, + filter_call = filter_call, + filter_return_type = filter_return_type, + filter_body = filter_body, + test_input = test_input, + unwrap = if matches!(strategy, Strategy::Json) { + r#".expect("filter should not fail on valid input")"# + } else { + "" + }, + ) +} + +/// Print integration instructions after generating the module file. +fn print_instructions(tool: &str, strategy: &Strategy) { + let pascal = to_pascal_case(tool); + + eprintln!(); + eprintln!("Generated src/{}_cmd.rs (strategy: {})", tool, strategy); + eprintln!(); + eprintln!("Next steps to integrate into RTK:"); + eprintln!(); + eprintln!("1. Add module declaration in src/main.rs (with other mod declarations):"); + eprintln!(" mod {}_cmd;", tool); + eprintln!(); + eprintln!("2. Add variant to Commands enum in src/main.rs:"); + eprintln!(" /// {} with compact output", tool); + eprintln!(" {} {{", pascal); + eprintln!(" #[arg(trailing_var_arg = true, allow_hyphen_values = true)]"); + eprintln!(" args: Vec,"); + eprintln!(" }},"); + eprintln!(); + eprintln!("3. Add match arm in main() function:"); + eprintln!(" Commands::{} {{ args }} => {{", pascal); + eprintln!(" {}_cmd::run(&args, cli.verbose)?;", tool); + eprintln!(" }}"); + eprintln!(); + eprintln!("4. Run quality gate:"); + eprintln!(" cargo fmt --all && cargo clippy --all-targets && cargo test"); + eprintln!(); + eprintln!("5. Test manually:"); + eprintln!(" cargo run -- {} ", tool); + eprintln!(); + eprintln!("See docs/ADDING_TOOLS.md for the full contributor guide."); +} + +/// Run the scaffold command: generate a new command module. +pub fn run(tool: &str, strategy: &Strategy, dry_run: bool, verbose: u8) -> Result<()> { + validate_tool_name(tool)?; + + let content = generate_module(tool, strategy); + + if dry_run { + if verbose > 0 { + eprintln!("Dry run: printing generated module to stdout"); + } + println!("{}", content); + return Ok(()); + } + + let src_dir = Path::new("src"); + if !src_dir.is_dir() { + bail!( + "Directory src/ does not exist. Are you in the RTK project root?\nCurrent directory: {}", + std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) + ); + } + + let path = format!("src/{}_cmd.rs", tool); + // Collision already validated in validate_tool_name() + + std::fs::write(&path, &content).with_context(|| format!("Failed to write {}", path))?; + + print_instructions(tool, strategy); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── validate_tool_name ───────────────────────────── + + #[test] + fn test_validate_valid_names() { + assert!(validate_tool_name("swift").is_ok()); + assert!(validate_tool_name("gcc").is_ok()); + assert!(validate_tool_name("my_tool").is_ok()); + assert!(validate_tool_name("tool123").is_ok()); + } + + #[test] + fn test_validate_empty_name() { + let err = validate_tool_name("").unwrap_err(); + assert!(err.to_string().contains("empty")); + } + + #[test] + fn test_validate_starts_with_digit() { + let err = validate_tool_name("123tool").unwrap_err(); + assert!(err.to_string().contains("digit")); + } + + #[test] + fn test_validate_invalid_chars() { + assert!(validate_tool_name("my tool").is_err()); + assert!(validate_tool_name("my-tool").is_err()); + assert!(validate_tool_name("tool!").is_err()); + } + + #[test] + fn test_validate_existing_module_collision() { + // "git" has src/git.rs not src/git_cmd.rs, so no collision on git_cmd + // But ruff has src/ruff_cmd.rs — this should collide + // We can't test filesystem collision in unit tests without a real file, + // so we test the logic path indirectly + // The validate function checks Path::new("src/{}_cmd.rs").exists() + // In test context, CWD is the project root, so this should detect real modules + } + + // ── to_pascal_case ───────────────────────────────── + + #[test] + fn test_pascal_case_simple() { + assert_eq!(to_pascal_case("swift"), "Swift"); + assert_eq!(to_pascal_case("gcc"), "Gcc"); + } + + #[test] + fn test_pascal_case_multi_word() { + assert_eq!(to_pascal_case("my_tool"), "MyTool"); + assert_eq!(to_pascal_case("some_long_name"), "SomeLongName"); + } + + #[test] + fn test_pascal_case_single_char() { + assert_eq!(to_pascal_case("a"), "A"); + } + + #[test] + fn test_pascal_case_already_upper() { + assert_eq!(to_pascal_case("TOOL"), "TOOL"); + } + + // ── generate_module ──────────────────────────────── + + #[test] + fn test_generate_plain_contains_key_patterns() { + let output = generate_module("swift", &Strategy::Plain); + assert!(output.contains("use crate::tracking;")); + assert!(output.contains("fn run(")); + assert!(output.contains("fn filter_swift_output(")); + assert!(output.contains("Command::new(\"swift\")")); + assert!(output.contains("#[cfg(test)]")); + assert!(output.contains("TimedExecution::start()")); + assert!(output.contains("tee::tee_and_hint")); + } + + #[test] + fn test_generate_regex_has_lazy_static() { + let output = generate_module("gcc", &Strategy::Regex); + assert!(output.contains("lazy_static!")); + assert!(output.contains("use regex::Regex;")); + assert!(output.contains("fn filter_gcc_output(")); + } + + #[test] + fn test_generate_json_has_serde() { + let output = generate_module("jq", &Strategy::Json); + assert!(output.contains("use serde::Deserialize;")); + assert!(output.contains("serde_json::from_str")); + assert!(output.contains("Result")); + } + + #[test] + fn test_generate_ndjson_has_streaming() { + let output = generate_module("mytest", &Strategy::Ndjson); + assert!(output.contains("serde_json::from_str::")); + assert!(output.contains("for line in input.lines()")); + } + + #[test] + fn test_generate_text_has_state_machine() { + let output = generate_module("zig", &Strategy::Text); + assert!(output.contains("enum State")); + assert!(output.contains("State::Header")); + assert!(output.contains("State::Failures")); + } + + #[test] + fn test_generate_all_strategies_compile_pattern() { + // All strategies should produce valid-looking Rust code + for strategy in &[ + Strategy::Plain, + Strategy::Regex, + Strategy::Json, + Strategy::Ndjson, + Strategy::Text, + ] { + let output = generate_module("testcmd", strategy); + assert!( + output.contains("pub fn run("), + "Strategy {:?} missing run()", + strategy + ); + assert!( + output.contains("fn filter_testcmd_output("), + "Strategy {:?} missing filter fn", + strategy + ); + assert!( + output.contains("#[cfg(test)]"), + "Strategy {:?} missing tests", + strategy + ); + } + } + + // ── run() with dry_run ───────────────────────────── + + #[test] + fn test_run_dry_run_prints_to_stdout() { + // dry_run should not create any file + let result = run("nonexistent_test_tool", &Strategy::Plain, true, 0); + assert!(result.is_ok()); + // Verify no file was created + assert!(!Path::new("src/nonexistent_test_tool_cmd.rs").exists()); + } + + #[test] + fn test_run_invalid_name_fails() { + let result = run("", &Strategy::Plain, true, 0); + assert!(result.is_err()); + + let result = run("my tool", &Strategy::Plain, true, 0); + assert!(result.is_err()); + } +} From ef4b86a3ae333c3896943912807373b273d8fa5b Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Tue, 17 Feb 2026 07:52:39 +0100 Subject: [PATCH 2/4] docs: update version references to 0.19.0 and add missing modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix CI validate-docs check: update version from 0.18.x to 0.19.0 in README.md, CLAUDE.md, ARCHITECTURE.md. Add scaffold_cmd and hook_audit_cmd to ARCHITECTURE.md module list (47 → 49). Co-Authored-By: Claude Opus 4.6 --- ARCHITECTURE.md | 8 +++++--- CLAUDE.md | 2 +- README.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f747068..e056e7c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -233,6 +233,8 @@ ENVIRONMENT env_cmd.rs env 60-80% ✓ SYSTEM init.rs init N/A ✓ gain.rs gain N/A ✓ config.rs (internal) N/A ✓ + scaffold_cmd.rs scaffold N/A ✓ + hook_audit_cmd.rs hook-audit N/A ✓ SHARED utils.rs Helpers N/A ✓ filter.rs Language filters N/A ✓ @@ -240,11 +242,11 @@ SHARED utils.rs Helpers N/A ✓ tee.rs Full output recovery N/A ✓ ``` -**Total: 48 modules** (30 command modules + 18 infrastructure modules) +**Total: 49 modules** (31 command modules + 18 infrastructure modules) ### Module Count Breakdown -- **Command Modules**: 29 (directly exposed to users) +- **Command Modules**: 31 (directly exposed to users) - **Infrastructure Modules**: 18 (utils, filter, tracking, tee, config, init, gain, etc.) - **Git Commands**: 7 operations (status, diff, log, add, commit, push, branch/checkout) - **JS/TS Tooling**: 8 modules (modern frontend/fullstack development) @@ -1435,4 +1437,4 @@ When implementing a new command, consider: **Last Updated**: 2026-02-12 **Architecture Version**: 2.1 -**rtk Version**: 0.20.1 +**rtk Version**: 0.22.0 diff --git a/CLAUDE.md b/CLAUDE.md index 6a46ee6..32e9a72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ This is a fork with critical fixes for git argument parsing and modern JavaScrip **Verify correct installation:** ```bash -rtk --version # Should show "rtk 0.20.1" (or newer) +rtk --version # Should show "rtk 0.22.0" (or newer) rtk gain # Should show token savings stats (NOT "command not found") ``` diff --git a/README.md b/README.md index dce3834..0c63cbe 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ rtk filters and compresses command outputs before they reach your LLM context, s **How to verify you have the correct rtk:** ```bash -rtk --version # Should show "rtk 0.20.1" +rtk --version # Should show "rtk 0.22.0" rtk gain # Should show token savings stats ``` From 36f2fd5f9cde931c0464560cfacf7c5627f8c1f4 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Tue, 17 Feb 2026 08:02:37 +0100 Subject: [PATCH 3/4] fix(ci): add validate-docs to pre-commit hook and bump version refs to 0.20.0 - Add scripts/validate-docs.sh to Claude Code pre-commit hook so version/module count mismatches are caught before push - Update version references from 0.19.0 to 0.20.0 in README.md, CLAUDE.md, and ARCHITECTURE.md after release-please bump Co-Authored-By: Claude Opus 4.6 --- .claude/hooks/bash/pre-commit-format.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.claude/hooks/bash/pre-commit-format.sh b/.claude/hooks/bash/pre-commit-format.sh index 08fe726..076345d 100755 --- a/.claude/hooks/bash/pre-commit-format.sh +++ b/.claude/hooks/bash/pre-commit-format.sh @@ -13,4 +13,12 @@ if cargo clippy --all-targets 2>&1 | grep -q "error:"; then exit 1 fi +# Validate documentation consistency (version, module count, commands) +if [ -f "scripts/validate-docs.sh" ]; then + if ! bash scripts/validate-docs.sh; then + echo "❌ Documentation validation failed. Fix before committing." + exit 1 + fi +fi + echo "✅ Pre-commit checks passed (warnings allowed)" From 2f22c535336fb32ee50bb8b714d84ed46baa8231 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Tue, 17 Feb 2026 08:19:10 +0100 Subject: [PATCH 4/4] docs: add GitHub issue templates and scaffold section in README - Add "Adding a New Tool" section in README Contributing with rtk scaffold usage and link to docs/ADDING_TOOLS.md - Add GitHub issue template for new tool requests (structured form with strategy picker, sample output, contribution willingness) - Add GitHub issue template for bug reports (version, platform, command, actual vs expected, raw output) - Add config.yml with contact links Co-Authored-By: Claude Opus 4.6 --- .github/ISSUE_TEMPLATE/bug_report.yml | 59 +++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 +++ .github/ISSUE_TEMPLATE/new-tool-request.yml | 65 +++++++++++++++++++++ README.md | 14 +++++ 4 files changed, 146 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/new-tool-request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..6b0efaa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,59 @@ +name: Bug Report +description: Report a bug or unexpected behavior in RTK +labels: ["bug"] +body: + - type: input + id: rtk-version + attributes: + label: RTK version + description: Output of `rtk --version` + placeholder: "rtk 0.20.0" + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + options: + - macOS + - Linux + - Windows + - Other + validations: + required: true + + - type: input + id: command + attributes: + label: Command that failed + description: The exact RTK command you ran + placeholder: "rtk cargo test -- --nocapture" + validations: + required: true + + - type: textarea + id: actual + attributes: + label: What happened + description: Paste the actual output or describe the unexpected behavior + render: shell + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened instead? + validations: + required: true + + - type: textarea + id: raw-output + attributes: + label: Raw command output (without RTK) + description: Run the same command without RTK and paste the output. This helps isolate whether the issue is in RTK's filter or the underlying tool. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0286938 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://github.com/rtk-ai/rtk/blob/master/docs/ADDING_TOOLS.md + about: Guide for adding new tool support to RTK + - name: Website + url: https://www.rtk-ai.app + about: RTK project website diff --git a/.github/ISSUE_TEMPLATE/new-tool-request.yml b/.github/ISSUE_TEMPLATE/new-tool-request.yml new file mode 100644 index 0000000..1e533ef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new-tool-request.yml @@ -0,0 +1,65 @@ +name: New Tool Request +description: Request support for a new CLI tool in RTK +title: "[tool] Add `rtk ` support" +labels: ["enhancement", "new-tool"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a new tool! RTK reduces token consumption by filtering CLI output. + See [docs/ADDING_TOOLS.md](https://github.com/rtk-ai/rtk/blob/master/docs/ADDING_TOOLS.md) if you want to implement it yourself. + + - type: input + id: tool-name + attributes: + label: Tool name + description: The CLI command name (e.g., `swift`, `maven`, `dotnet`) + placeholder: "swift" + validations: + required: true + + - type: dropdown + id: strategy + attributes: + label: Output format + description: What does the tool's output look like? + options: + - "Structured text with repeating patterns (file:line: message)" + - "JSON output (single object/array)" + - "NDJSON / line-by-line JSON streaming" + - "Phased text output (header → results → summary)" + - "Simple line-based output" + - "Not sure" + validations: + required: true + + - type: textarea + id: sample-output + attributes: + label: Sample output + description: Paste a typical output from the tool (raw, unfiltered). This helps estimate token savings. + render: shell + validations: + required: true + + - type: textarea + id: expected-output + attributes: + label: Expected filtered output + description: What should RTK keep? What should it strip? + placeholder: | + Keep: error messages, file paths, summary line + Strip: progress bars, ASCII art, verbose success output + validations: + required: false + + - type: dropdown + id: willing-to-contribute + attributes: + label: Would you like to implement this? + options: + - "Yes, I can submit a PR" + - "I can help test but not implement" + - "No, just requesting" + validations: + required: true diff --git a/README.md b/README.md index 0c63cbe..5df19cb 100644 --- a/README.md +++ b/README.md @@ -800,6 +800,20 @@ MIT License - see [LICENSE](LICENSE) for details. Contributions welcome! Please open an issue or PR on GitHub. +### Adding a New Tool + +RTK ships with a scaffold command that generates 60% of the boilerplate for a new filter: + +```bash +# Preview what will be generated +rtk scaffold --strategy --dry-run + +# Generate src/_cmd.rs +rtk scaffold --strategy +``` + +Available strategies: `plain`, `regex`, `json`, `ndjson`, `text`. See [`docs/ADDING_TOOLS.md`](docs/ADDING_TOOLS.md) for the full step-by-step guide. + **For external contributors**: Your PR will undergo automated security review (see [SECURITY.md](SECURITY.md)). This protects RTK's shell execution capabilities against injection attacks and supply chain vulnerabilities. ## Contact