From 737dada4a56c0d7a482cc438e7280340d634f75d Mon Sep 17 00:00:00 2001 From: patrick szymkowiak <52030887+pszymkowiak@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:33:45 +0100 Subject: [PATCH 1/3] feat: 9-tool AI agent support + emoji removal (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove all decorative emojis from CLI output (#687) * fix: remove decorative emojis from CLI output (#511) Replace decorative emojis with plain text to reduce token waste. Keep functional symbols (⚠️ ✓ ❌ ✅ ℹ️) that convey meaning in fewer tokens. Signed-off-by: Patrick Szymkowiak Signed-off-by: Patrick szymkowiak * fix: remove remaining decorative emojis from find_cmd and formatter Missed in initial emoji cleanup pass: 📁 in find_cmd.rs and parser/formatter.rs Signed-off-by: Patrick szymkowiak * fix: remove all decorative emojis from CLI output (#511) Replace emojis with plain text tokens across all production files for better LLM compatibility. Test fixtures and external tool detection patterns (e.g. Black's "All done!") are preserved. Signed-off-by: Patrick Szymkowiak Signed-off-by: Patrick szymkowiak * fix: remove last decorative emoji from next_cmd.rs Remove ⚡ from Next.js Build header, missed in previous passes. Signed-off-by: Patrick szymkowiak * fix: remove remaining emojis from gh_cmd.rs and init.rs Replace production emojis: - gh_cmd.rs: 🟣→[merged], ⚪→[unknown]/[pending], ⭐→removed, 🔱→removed - init.rs: ⚪→[--] for "not found" status indicators Signed-off-by: Patrick szymkowiak * fix: remove all checkmark emojis from CLI output Replace ✓ (U+2713) with plain text across 19 files: - "ok ✓" → "ok" (git add/commit/push/pull) - "✓ cargo test: ..." → "cargo test: ..." (all tool summaries) - Preserved ✓ in input detection patterns and test fixtures LLMs cannot interpret emoji semantics; plain text is clearer. Signed-off-by: Patrick szymkowiak --------- Signed-off-by: Patrick Szymkowiak Signed-off-by: Patrick szymkowiak Signed-off-by: Patrick Szymkowiak * feat: OpenClaw plugin for transparent exec rewriting (#358) * feat: add OpenClaw plugin for transparent exec rewriting Adds an OpenClaw plugin that intercepts exec tool calls via the before_tool_call hook and rewrites commands to their RTK equivalents. This is the OpenClaw equivalent of hooks/rtk-rewrite.sh for Claude Code. The plugin: - Registers a before_tool_call hook on the exec tool - Rewrites git, grep, find, ls, gh, docker, kubectl, and test commands - Guards against rewriting piped/compound commands and heredocs - Returns properly typed PluginHookBeforeToolCallResult - Supports enabled/verbose config options Measured savings: 48-87% token reduction on common commands. Files: - openclaw/index.ts — plugin source - openclaw/openclaw.plugin.json — plugin manifest - openclaw/README.md — installation and usage docs * refactor: delegate OpenClaw plugin to rtk rewrite Replace 60+ hardcoded regex rules with a single call to `rtk rewrite`, matching the OpenCode plugin pattern (hooks/opencode-rtk.ts). Benefits: - Zero maintenance: new RTK filters work automatically - Single source of truth: rewrite logic in Rust (src/discover/registry.rs) - 122 → 73 lines, no rule duplication Also: rebase on develop, fix homepage URL, bump version to 1.0.0. Signed-off-by: Patrick szymkowiak * feat: add package.json for npm publishing Enables `openclaw plugins install @rtk-ai/rtk-rewrite` for OpenClaw users. Signed-off-by: Patrick szymkowiak --------- Signed-off-by: Patrick szymkowiak Co-authored-by: Patrick szymkowiak * feat: add Gemini CLI support via rtk init --gemini (#573) - Add `rtk hook gemini` command: native Rust hook processor for Gemini CLI BeforeTool hooks. Reads JSON from stdin, delegates to `rewrite_command()` (single source of truth), outputs Gemini-format JSON response. - Add `--gemini` flag to `rtk init`: installs hook wrapper script, GEMINI.md, and patches ~/.gemini/settings.json with BeforeTool hook entry. - Add `rtk init -g --gemini --uninstall`: clean removal of all Gemini artifacts. - 6 unit tests covering hook format, rewrite delegation, and exclusions. Replaces PR #174 which had too many conflicts after upstream restructuring. Signed-off-by: Ousama Ben Younes Co-authored-by: Claude Opus 4.6 * feat(init): add Codex CLI support via AGENTS.md + RTK.md workflow (#377) * feat(init): add Codex CLI support via AGENTS.md + RTK.md workflow Add --codex mode to rtk init for Codex CLI integration using AGENTS.md + RTK.md, while keeping the newer develop init/opencode flow intact. Includes Codex install/show/uninstall handling, ASCII status output, stricter flag validation, and expanded tests for Codex AGENTS lifecycle and patch-mode rejection. Signed-off-by: Zacaria * docs: fix validation metadata Signed-off-by: Zacaria --------- Signed-off-by: Zacaria * feat(copilot): add Copilot hook support (VS Code + CLI) (#605) Add `rtk hook copilot` command that handles both VS Code Copilot Chat (updatedInput rewrite) and GitHub Copilot CLI (deny-with-suggestion). - Auto-detects format: snake_case (VS Code) vs camelCase (Copilot CLI) - Delegates to `rtk rewrite` (single source of truth) - 14 hook tests (format detection, rewrite gating, output shape) - .github/hooks/rtk-rewrite.json for repo-scoped hook config - .github/copilot-instructions.md for RTK awareness - Test script: hooks/test-copilot-rtk-rewrite.sh Rebased on develop (includes Gemini #573, Codex #377, OpenClaw #358). Original work by @jeziellopes, cleaned up and rebased by maintainer. Signed-off-by: Patrick szymkowiak Co-authored-by: Patrick szymkowiak * feat: add Cursor Agent support via --agent flag (#595) Add `rtk init -g --agent cursor` to install RTK hooks for Cursor Agent. Cursor's preToolUse hook supports command rewriting via updated_input, functionally identical to Claude Code's PreToolUse. Works with both the Cursor editor and cursor-cli (they share ~/.cursor/hooks.json). Changes: - New `--agent ` flag (claude|cursor) on `rtk init`, extensible for future agents. Default is claude (backward compatible). - Cursor hook script (hooks/cursor-rtk-rewrite.sh) outputs Cursor's JSON format: {permission, updated_input} vs Claude's hookSpecificOutput. - `rtk init --show` reports Cursor hook and hooks.json status. - `rtk init -g --uninstall` removes Cursor artifacts. - `rtk discover` notes that Cursor sessions are tracked via `rtk gain` (Cursor transcripts lack structured tool_use/tool_result blocks). - Unit tests for Cursor hooks.json patching, detection, and removal. Made-with: Cursor Signed-off-by: Patrick szymkowiak Co-authored-by: Moisei <1199723+moisei@users.noreply.github.com> * feat: add Windsurf support via rtk init --agent windsurf (#695) (#697) Install RTK rules in .windsurfrules (project-scoped) so Cascade prefixes shell commands with rtk for token savings. Windsurf hooks don't support command rewriting (only blocking), so RTK uses the rules-based approach (like Codex with AGENTS.md). Tested: Windsurf Cascade correctly uses rtk git status after install. Signed-off-by: Patrick szymkowiak * feat: add Cline/Roo Code support via rtk init --agent cline (#701) (#702) Install RTK rules in .clinerules (project-scoped) so Cline prefixes shell commands with rtk for token savings. Same rules-based approach as Windsurf and Codex. Signed-off-by: Patrick szymkowiak * fix(skill/rtk-triage): increase PR/issue limit to 200 with pagination hint (#717) * fix(skill/rtk-triage): increase PR/issue limit to 200 with pagination hint Raise gh pr list limit from 60 to 200 to match gh's max per call. Add inline comment explaining how to paginate for repos with >200 open PRs. Update threshold warning from >60 to >200 PRs/issues. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Florian BRUNIAUX * docs(architecture): update module count to 67 (hook_cmd added in #573) hook_cmd.rs was added in feat: add Gemini CLI support (#573) but ARCHITECTURE.md was not updated. Fixes pre-push validation failure. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Florian BRUNIAUX --------- Signed-off-by: Florian BRUNIAUX Co-authored-by: Claude Sonnet 4.6 --------- Signed-off-by: Patrick Szymkowiak Signed-off-by: Patrick szymkowiak Signed-off-by: Patrick Szymkowiak Signed-off-by: Ousama Ben Younes Signed-off-by: Zacaria Signed-off-by: Florian BRUNIAUX Co-authored-by: Alex Co-authored-by: Ben Younes Co-authored-by: Claude Opus 4.6 Co-authored-by: Zacaria Chtatar Co-authored-by: Jeziel Lopes Co-authored-by: Moisei Rabinovich Co-authored-by: Moisei <1199723+moisei@users.noreply.github.com> Co-authored-by: Florian BRUNIAUX --- .claude/skills/rtk-triage/SKILL.md | 8 +- .github/copilot-instructions.md | 127 +++ .github/hooks/rtk-rewrite.json | 12 + ARCHITECTURE.md | 6 +- README.md | 23 +- hooks/cline-rtk-rules.md | 32 + hooks/copilot-rtk-awareness.md | 60 ++ hooks/cursor-rtk-rewrite.sh | 54 ++ hooks/rtk-awareness-codex.md | 32 + hooks/test-copilot-rtk-rewrite.sh | 293 +++++++ hooks/windsurf-rtk-rules.md | 32 + openclaw/README.md | 86 ++ openclaw/index.ts | 74 ++ openclaw/openclaw.plugin.json | 28 + openclaw/package.json | 29 + src/discover/provider.rs | 6 +- src/discover/report.rs | 8 + src/hook_cmd.rs | 333 +++++++ src/init.rs | 1292 ++++++++++++++++++++++++++-- src/main.rs | 81 +- 20 files changed, 2527 insertions(+), 89 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/hooks/rtk-rewrite.json create mode 100644 hooks/cline-rtk-rules.md create mode 100644 hooks/copilot-rtk-awareness.md create mode 100755 hooks/cursor-rtk-rewrite.sh create mode 100644 hooks/rtk-awareness-codex.md create mode 100755 hooks/test-copilot-rtk-rewrite.sh create mode 100644 hooks/windsurf-rtk-rules.md create mode 100644 openclaw/README.md create mode 100644 openclaw/index.ts create mode 100644 openclaw/openclaw.plugin.json create mode 100644 openclaw/package.json create mode 100644 src/hook_cmd.rs diff --git a/.claude/skills/rtk-triage/SKILL.md b/.claude/skills/rtk-triage/SKILL.md index 34b9d0fc..9aed21a2 100644 --- a/.claude/skills/rtk-triage/SKILL.md +++ b/.claude/skills/rtk-triage/SKILL.md @@ -57,9 +57,13 @@ gh api "repos/{owner}/{repo}/collaborators" --jq '.[].login' **PRs** : ```bash -gh pr list --state open --limit 60 \ +# Fetcher toutes les PRs ouvertes — paginer si nécessaire (gh limite à 200 par appel) +gh pr list --state open --limit 200 \ --json number,title,author,createdAt,updatedAt,additions,deletions,changedFiles,isDraft,mergeable,reviewDecision,statusCheckRollup,body +# Si le repo a >200 PRs ouvertes, relancer avec --search pour paginer : +# gh pr list --state open --limit 200 --search "is:pr is:open sort:updated-desc" ... + # Pour chaque PR, récupérer les fichiers modifiés (nécessaire pour overlap detection) # Prioriser les PRs candidates (même domaine, même auteur) gh pr view {num} --json files --jq '[.files[].path] | join(",")' @@ -232,6 +236,6 @@ Croisement issues × PRs. {N} PRs ouvertes, {N} issues ouvertes. - Langue : argument `en`/`fr`. Défaut : `fr`. Les commentaires GitHub restent toujours en anglais. - Ne jamais poster de commentaires GitHub sans validation utilisateur (AskUserQuestion). -- Si >150 issues ou >60 PRs : prévenir l'utilisateur, proposer de filtrer par label ou date. +- Si >200 issues ou >200 PRs : prévenir l'utilisateur et paginer (relancer avec `--search` ou `gh api` avec pagination). - L'analyse croisée (Phase 3) est toujours exécutée — c'est la valeur ajoutée de ce skill. - Le fichier claudedocs est sauvegardé automatiquement sauf si l'utilisateur dit "no save". diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..7651df48 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,127 @@ +# Copilot Instructions for rtk + +**rtk (Rust Token Killer)** is a CLI proxy that filters and compresses command outputs before they reach an LLM context, saving 60–90% of tokens. It wraps common tools (`git`, `cargo`, `grep`, `pnpm`, `go`, etc.) and outputs condensed summaries instead of raw output. + +## Using rtk in this session + +**Always prefix commands with `rtk` when running shell commands** — this is the entire point of the project and reduces token consumption for every operation you perform. + +```bash +# Instead of: Use: +git status rtk git status +git log -10 rtk git log -10 +cargo test rtk cargo test +cargo clippy --all-targets rtk cargo clippy --all-targets +grep -r "pattern" src/ rtk grep -r "pattern" src/ +``` + +**rtk meta-commands** (always use these directly, no prefix needed): +```bash +rtk gain # Show token savings analytics for this session +rtk gain --history # Full command history with per-command savings +rtk discover # Scan session history for missed rtk opportunities +rtk proxy # Run a command raw (no filtering) but still track it +``` + +**Verify rtk is installed before starting:** +```bash +rtk --version # Should print: rtk X.Y.Z +rtk gain # Should show a dashboard (not "command not found") +``` + +> ⚠️ **Name collision**: `rtk gain` failing means you have `reachingforthejack/rtk` (Rust Type Kit) installed instead of this project. Run `which rtk` and check the binary source. + +## Build, Test & Lint + +```bash +# Development build +cargo build + +# Run all tests +cargo test + +# Run a single test by name +cargo test test_filter_git_log + +# Run all tests in a module +cargo test git::tests:: + +# Run tests with stdout +cargo test -- --nocapture + +# Pre-commit gate (must all pass before any PR) +cargo fmt --all --check && cargo clippy --all-targets && cargo test + +# Smoke tests (requires installed binary) +bash scripts/test-all.sh +``` + +PRs target the **`develop`** branch, not `main`. All commits require a DCO sign-off (`git commit -s`). + +## Architecture + +``` +main.rs ← Clap Commands enum → specialized module (git.rs, *_cmd.rs, etc.) + ↓ + execute subprocess + ↓ + filter/compress output + ↓ + tracking::TimedExecution → SQLite (~/.local/share/rtk/tracking.db) +``` + +Key modules: +- **`main.rs`** — Clap `Commands` enum routes every subcommand to its module. Each arm calls `tracking::TimedExecution::start()` before running, then `.track(...)` after. +- **`filter.rs`** — Language-aware filtering with `FilterLevel` (`none` / `minimal` / `aggressive`) and `Language` enum. Used by `read` and `smart` commands. +- **`tracking.rs`** — SQLite persistence for token savings, scoped per project path. Powers `rtk gain`. +- **`tee.rs`** — On filter failure, saves raw output to `~/.local/share/rtk/tee/` and prints a one-line hint so the LLM can re-read without re-running the command. +- **`utils.rs`** — Shared helpers: `truncate`, `strip_ansi`, `execute_command`, package-manager auto-detection (pnpm/yarn/npm/npx). + +New commands follow this structure: one file `src/_cmd.rs` with a `pub fn run(...)` entry point, registered in the `Commands` enum in `main.rs`. + +## Key Conventions + +### Error handling +- Use `anyhow::Result` throughout (this is a binary, not a library). +- Always attach context: `operation.context("description")?` — never bare `?` without context. +- No `unwrap()` in production code; `expect("reason")` is acceptable only in tests. +- Every filter must fall back to raw command execution on error — never break the user's workflow. + +### Regex +- Compile once with `lazy_static!`, never inside a function body: + ```rust + lazy_static! { + static ref RE: Regex = Regex::new(r"pattern").unwrap(); + } + ``` + +### Testing +- Unit tests live **inside the module file** in `#[cfg(test)] mod tests { ... }` — not in `tests/`. +- Fixtures are real captured command output in `tests/fixtures/_raw.txt`, loaded with `include_str!("../tests/fixtures/...")`. +- Each test module defines its own local `fn count_tokens(text: &str) -> usize` (word-split approximation) — there is no shared utility for this. +- Token savings assertions use `assert!(savings >= 60.0, ...)`. +- Snapshot tests use `assert_snapshot!()` from the `insta` crate; review with `cargo insta review`. + +### Adding a new command +1. Create `src/_cmd.rs` with `pub fn run(...)`. +2. Add `mod _cmd;` at the top of `main.rs`. +3. Add a variant to the `Commands` enum with `#[arg(trailing_var_arg = true, allow_hyphen_values = true)]` for pass-through flags. +4. Route the variant in the `match` block, wrapping execution with `tracking::TimedExecution`. +5. Write a fixture from real output, then unit tests in the module file. +6. Update `README.md` (command list + savings %) and `CHANGELOG.md`. + +### Exit codes +Preserve the underlying command's exit code. Use `std::process::exit(code)` when the child process exits non-zero. + +### Performance constraints +- Startup must stay under 10ms — no async runtime (no `tokio`/`async-std`). +- No blocking I/O at startup; config is loaded on-demand. +- Binary size target: <5 MB stripped. + +### Branch naming +``` +fix(scope): short-description +feat(scope): short-description +chore(scope): short-description +``` +`scope` is the affected component (e.g. `git`, `filter`, `tracking`). diff --git a/.github/hooks/rtk-rewrite.json b/.github/hooks/rtk-rewrite.json new file mode 100644 index 00000000..c488d434 --- /dev/null +++ b/.github/hooks/rtk-rewrite.json @@ -0,0 +1,12 @@ +{ + "hooks": { + "PreToolUse": [ + { + "type": "command", + "command": "rtk hook", + "cwd": ".", + "timeout": 5 + } + ] + } +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c58edfd2..5e4d2578 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -293,12 +293,12 @@ SHARED utils.rs Helpers N/A ✓ tee.rs Full output recovery N/A ✓ ``` -**Total: 66 modules** (44 command modules + 22 infrastructure modules) +**Total: 67 modules** (45 command modules + 22 infrastructure modules) ### Module Count Breakdown -- **Command Modules**: 34 (directly exposed to users) -- **Infrastructure Modules**: 20 (utils, filter, tracking, tee, config, init, gain, toml_filter, verify_cmd, etc.) +- **Command Modules**: 45 (directly exposed to users) +- **Infrastructure Modules**: 22 (utils, filter, tracking, tee, config, init, gain, toml_filter, verify_cmd, trust, etc.) - **Git Commands**: 7 operations (status, diff, log, add, commit, push, branch/checkout) - **JS/TS Tooling**: 8 modules (modern frontend/fullstack development) - **Python Tooling**: 3 modules (ruff, pytest, pip) diff --git a/README.md b/README.md index bb2c5bd2..d818e2af 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ rtk gain # Should show token savings stats # 1. Install hook for Claude Code (recommended) rtk init --global # Follow instructions to register in ~/.claude/settings.json -# Claude Code only by default (use --opencode for OpenCode) +# Claude Code only by default (use --opencode for OpenCode, --gemini for Gemini CLI) # 2. Restart Claude Code, then test git status # Automatically rewritten to rtk git status @@ -287,6 +287,27 @@ rtk init --show # Verify installation After install, **restart Claude Code**. +## Gemini CLI Support (Global) + +RTK supports Gemini CLI via a native Rust hook processor. The hook intercepts `run_shell_command` tool calls and rewrites them to `rtk` equivalents using the same rewrite engine as Claude Code. + +**Install Gemini hook:** +```bash +rtk init -g --gemini +``` + +**What it creates:** +- `~/.gemini/hooks/rtk-hook-gemini.sh` (thin wrapper calling `rtk hook gemini`) +- `~/.gemini/GEMINI.md` (RTK awareness instructions) +- Patches `~/.gemini/settings.json` with BeforeTool hook + +**Uninstall:** +```bash +rtk init -g --gemini --uninstall +``` + +**Restart Required**: Restart Gemini CLI, then test with `git status` in a session. + ## OpenCode Plugin (Global) OpenCode supports plugins that can intercept tool execution. RTK provides a global plugin that mirrors the Claude auto-rewrite behavior by rewriting Bash tool commands to `rtk ...` before they execute. This plugin is **not** installed by default. diff --git a/hooks/cline-rtk-rules.md b/hooks/cline-rtk-rules.md new file mode 100644 index 00000000..7d6c8aff --- /dev/null +++ b/hooks/cline-rtk-rules.md @@ -0,0 +1,32 @@ +# RTK - Rust Token Killer (Cline) + +**Usage**: Token-optimized CLI proxy for shell commands. + +## Rule + +Always prefix shell commands with `rtk` to minimize token consumption. + +Examples: + +```bash +rtk git status +rtk cargo test +rtk ls src/ +rtk grep "pattern" src/ +rtk find "*.rs" . +rtk docker ps +rtk gh pr list +``` + +## Meta Commands + +```bash +rtk gain # Show token savings +rtk gain --history # Command history with savings +rtk discover # Find missed RTK opportunities +rtk proxy # Run raw (no filtering, for debugging) +``` + +## Why + +RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. diff --git a/hooks/copilot-rtk-awareness.md b/hooks/copilot-rtk-awareness.md new file mode 100644 index 00000000..185f460c --- /dev/null +++ b/hooks/copilot-rtk-awareness.md @@ -0,0 +1,60 @@ +# RTK — Copilot Integration (VS Code Copilot Chat + Copilot CLI) + +**Usage**: Token-optimized CLI proxy (60-90% savings on dev operations) + +## What's automatic + +The `.github/copilot-instructions.md` file is loaded at session start by both Copilot CLI and VS Code Copilot Chat. +It instructs Copilot to prefix commands with `rtk` automatically. + +The `.github/hooks/rtk-rewrite.json` hook adds a `PreToolUse` safety net via `rtk hook` — +a cross-platform Rust binary that intercepts raw bash tool calls and rewrites them. +No shell scripts, no `jq` dependency, works on Windows natively. + +## Meta commands (always use directly) + +```bash +rtk gain # Token savings dashboard for this session +rtk gain --history # Per-command history with savings % +rtk discover # Scan session history for missed rtk opportunities +rtk proxy # Run raw (no filtering) but still track it +``` + +## Installation verification + +```bash +rtk --version # Should print: rtk X.Y.Z +rtk gain # Should show a dashboard (not "command not found") +which rtk # Verify correct binary path +``` + +> ⚠️ **Name collision**: If `rtk gain` fails, you may have `reachingforthejack/rtk` +> (Rust Type Kit) installed instead. Check `which rtk` and reinstall from rtk-ai/rtk. + +## How the hook works + +`rtk hook` reads `PreToolUse` JSON from stdin, detects the agent format, and responds appropriately: + +**VS Code Copilot Chat** (supports `updatedInput` — transparent rewrite, no denial): +1. Agent runs `git status` → `rtk hook` intercepts via `PreToolUse` +2. `rtk hook` detects VS Code format (`tool_name`/`tool_input` keys) +3. Returns `hookSpecificOutput.updatedInput.command = "rtk git status"` +4. Agent runs the rewritten command silently — no denial, no retry + +**GitHub Copilot CLI** (deny-with-suggestion — CLI ignores `updatedInput` today, see [issue #2013](https://github.com/github/copilot-cli/issues/2013)): +1. Agent runs `git status` → `rtk hook` intercepts via `PreToolUse` +2. `rtk hook` detects Copilot CLI format (`toolName`/`toolArgs` keys) +3. Returns `permissionDecision: deny` with reason: `"Token savings: use 'rtk git status' instead"` +4. Copilot reads the reason and re-runs `rtk git status` + +When Copilot CLI adds `updatedInput` support, only `rtk hook` needs updating — no config changes. + +## Integration comparison + +| Tool | Mechanism | Hook output | File | +|-----------------------|-----------------------------------------|--------------------------|------------------------------------| +| Claude Code | `PreToolUse` hook with `updatedInput` | Transparent rewrite | `hooks/rtk-rewrite.sh` | +| VS Code Copilot Chat | `PreToolUse` hook with `updatedInput` | Transparent rewrite | `.github/hooks/rtk-rewrite.json` | +| GitHub Copilot CLI | `PreToolUse` deny-with-suggestion | Denial + retry | `.github/hooks/rtk-rewrite.json` | +| OpenCode | Plugin `tool.execute.before` | Transparent rewrite | `hooks/opencode-rtk.ts` | +| (any) | Custom instructions | Prompt-level guidance | `.github/copilot-instructions.md` | diff --git a/hooks/cursor-rtk-rewrite.sh b/hooks/cursor-rtk-rewrite.sh new file mode 100755 index 00000000..4b80b260 --- /dev/null +++ b/hooks/cursor-rtk-rewrite.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# rtk-hook-version: 1 +# RTK Cursor Agent hook — rewrites shell commands to use rtk for token savings. +# Works with both Cursor editor and cursor-cli (they share ~/.cursor/hooks.json). +# Cursor preToolUse hook format: receives JSON on stdin, returns JSON on stdout. +# Requires: rtk >= 0.23.0, jq +# +# This is a thin delegating hook: all rewrite logic lives in `rtk rewrite`, +# which is the single source of truth (src/discover/registry.rs). +# To add or change rewrite rules, edit the Rust registry — not this file. + +if ! command -v jq &>/dev/null; then + echo "[rtk] WARNING: jq is not installed. Hook cannot rewrite commands. Install jq: https://jqlang.github.io/jq/download/" >&2 + exit 0 +fi + +if ! command -v rtk &>/dev/null; then + echo "[rtk] WARNING: rtk is not installed or not in PATH. Hook cannot rewrite commands. Install: https://github.com/rtk-ai/rtk#installation" >&2 + exit 0 +fi + +# Version guard: rtk rewrite was added in 0.23.0. +RTK_VERSION=$(rtk --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +if [ -n "$RTK_VERSION" ]; then + MAJOR=$(echo "$RTK_VERSION" | cut -d. -f1) + MINOR=$(echo "$RTK_VERSION" | cut -d. -f2) + if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 23 ]; then + echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.23.0). Upgrade: cargo install rtk" >&2 + exit 0 + fi +fi + +INPUT=$(cat) +CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +if [ -z "$CMD" ]; then + echo '{}' + exit 0 +fi + +# Delegate all rewrite logic to the Rust binary. +# rtk rewrite exits 1 when there's no rewrite — hook passes through silently. +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || { echo '{}'; exit 0; } + +# No change — nothing to do. +if [ "$CMD" = "$REWRITTEN" ]; then + echo '{}' + exit 0 +fi + +jq -n --arg cmd "$REWRITTEN" '{ + "permission": "allow", + "updated_input": { "command": $cmd } +}' diff --git a/hooks/rtk-awareness-codex.md b/hooks/rtk-awareness-codex.md new file mode 100644 index 00000000..7ae285e1 --- /dev/null +++ b/hooks/rtk-awareness-codex.md @@ -0,0 +1,32 @@ +# RTK - Rust Token Killer (Codex CLI) + +**Usage**: Token-optimized CLI proxy for shell commands. + +## Rule + +Always prefix shell commands with `rtk`. + +Examples: + +```bash +rtk git status +rtk cargo test +rtk npm run build +rtk pytest -q +``` + +## Meta Commands + +```bash +rtk gain # Token savings analytics +rtk gain --history # Recent command savings history +rtk proxy # Run raw command without filtering +``` + +## Verification + +```bash +rtk --version +rtk gain +which rtk +``` diff --git a/hooks/test-copilot-rtk-rewrite.sh b/hooks/test-copilot-rtk-rewrite.sh new file mode 100755 index 00000000..f1cca949 --- /dev/null +++ b/hooks/test-copilot-rtk-rewrite.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Test suite for rtk hook (cross-platform preToolUse handler). +# Feeds mock preToolUse JSON through `rtk hook` and verifies allow/deny decisions. +# +# Usage: bash hooks/test-copilot-rtk-rewrite.sh +# +# Copilot CLI input format: +# {"toolName":"bash","toolArgs":"{\"command\":\"...\"}"} +# Output on intercept: {"permissionDecision":"deny","permissionDecisionReason":"..."} +# +# VS Code Copilot Chat input format: +# {"tool_name":"Bash","tool_input":{"command":"..."}} +# Output on intercept: {"hookSpecificOutput":{"permissionDecision":"allow","updatedInput":{...}}} +# +# Output on pass-through: empty (exit 0) + +RTK="${RTK:-rtk}" +PASS=0 +FAIL=0 +TOTAL=0 + +# Colors +GREEN='\033[32m' +RED='\033[31m' +DIM='\033[2m' +RESET='\033[0m' + +# Build a Copilot CLI preToolUse input JSON +copilot_bash_input() { + local cmd="$1" + local tool_args + tool_args=$(jq -cn --arg cmd "$cmd" '{"command":$cmd}') + jq -cn --arg ta "$tool_args" '{"toolName":"bash","toolArgs":$ta}' +} + +# Build a VS Code Copilot Chat preToolUse input JSON +vscode_bash_input() { + local cmd="$1" + jq -cn --arg cmd "$cmd" '{"tool_name":"Bash","tool_input":{"command":$cmd}}' +} + +# Build a non-bash tool input +tool_input() { + local tool_name="$1" + jq -cn --arg t "$tool_name" '{"toolName":$t,"toolArgs":"{}"}' +} + +# Assert Copilot CLI: hook denies and reason contains the expected rtk command +test_deny() { + local description="$1" + local input_cmd="$2" + local expected_rtk="$3" + TOTAL=$((TOTAL + 1)) + + local output + output=$(copilot_bash_input "$input_cmd" | "$RTK" hook 2>/dev/null) || true + + local decision reason + decision=$(echo "$output" | jq -r '.permissionDecision // empty' 2>/dev/null) + reason=$(echo "$output" | jq -r '.permissionDecisionReason // empty' 2>/dev/null) + + if [ "$decision" = "deny" ] && echo "$reason" | grep -qF "$expected_rtk"; then + printf " ${GREEN}DENY${RESET} %s ${DIM}→ %s${RESET}\n" "$description" "$expected_rtk" + PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${RESET} %s\n" "$description" + printf " expected decision: deny, reason containing: %s\n" "$expected_rtk" + printf " actual decision: %s\n" "$decision" + printf " actual reason: %s\n" "$reason" + FAIL=$((FAIL + 1)) + fi +} + +# Assert VS Code Copilot Chat: hook returns updatedInput (allow) with rewritten command +test_vscode_rewrite() { + local description="$1" + local input_cmd="$2" + local expected_rtk="$3" + TOTAL=$((TOTAL + 1)) + + local output + output=$(vscode_bash_input "$input_cmd" | "$RTK" hook 2>/dev/null) || true + + local decision updated_cmd + decision=$(echo "$output" | jq -r '.hookSpecificOutput.permissionDecision // empty' 2>/dev/null) + updated_cmd=$(echo "$output" | jq -r '.hookSpecificOutput.updatedInput.command // empty' 2>/dev/null) + + if [ "$decision" = "allow" ] && echo "$updated_cmd" | grep -qF "$expected_rtk"; then + printf " ${GREEN}REWRITE${RESET} %s ${DIM}→ %s${RESET}\n" "$description" "$updated_cmd" + PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${RESET} %s\n" "$description" + printf " expected decision: allow, updatedInput containing: %s\n" "$expected_rtk" + printf " actual decision: %s\n" "$decision" + printf " actual updatedInput: %s\n" "$updated_cmd" + FAIL=$((FAIL + 1)) + fi +} + +# Assert the hook emits no output (pass-through) +test_allow() { + local description="$1" + local input="$2" + TOTAL=$((TOTAL + 1)) + + local output + output=$(echo "$input" | "$RTK" hook 2>/dev/null) || true + + if [ -z "$output" ]; then + printf " ${GREEN}PASS${RESET} %s ${DIM}→ (allow)${RESET}\n" "$description" + PASS=$((PASS + 1)) + else + local decision + decision=$(echo "$output" | jq -r '.permissionDecision // .hookSpecificOutput.permissionDecision // empty' 2>/dev/null) + printf " ${RED}FAIL${RESET} %s\n" "$description" + printf " expected: (no output)\n" + printf " actual: permissionDecision=%s\n" "$decision" + FAIL=$((FAIL + 1)) + fi +} + +echo "============================================" +echo " RTK Hook Test Suite (rtk hook)" +echo "============================================" +echo "" + +# ---- SECTION 1: Copilot CLI — commands that should be denied ---- +echo "--- Copilot CLI: intercepted (deny with rtk suggestion) ---" + +test_deny "git status" \ + "git status" \ + "rtk git status" + +test_deny "git log --oneline -10" \ + "git log --oneline -10" \ + "rtk git log" + +test_deny "git diff HEAD" \ + "git diff HEAD" \ + "rtk git diff" + +test_deny "cargo test" \ + "cargo test" \ + "rtk cargo test" + +test_deny "cargo clippy --all-targets" \ + "cargo clippy --all-targets" \ + "rtk cargo clippy" + +test_deny "cargo build" \ + "cargo build" \ + "rtk cargo build" + +test_deny "grep -rn pattern src/" \ + "grep -rn pattern src/" \ + "rtk grep" + +test_deny "gh pr list" \ + "gh pr list" \ + "rtk gh" + +echo "" + +# ---- SECTION 2: VS Code Copilot Chat — commands that should be rewritten via updatedInput ---- +echo "--- VS Code Copilot Chat: intercepted (updatedInput rewrite) ---" + +test_vscode_rewrite "git status" \ + "git status" \ + "rtk git status" + +test_vscode_rewrite "cargo test" \ + "cargo test" \ + "rtk cargo test" + +test_vscode_rewrite "gh pr list" \ + "gh pr list" \ + "rtk gh" + +echo "" + +# ---- SECTION 3: Pass-through cases ---- +echo "--- Pass-through (allow silently) ---" + +test_allow "Copilot CLI: already rtk: rtk git status" \ + "$(copilot_bash_input "rtk git status")" + +test_allow "Copilot CLI: already rtk: rtk cargo test" \ + "$(copilot_bash_input "rtk cargo test")" + +test_allow "Copilot CLI: heredoc" \ + "$(copilot_bash_input "cat <<'EOF' +hello +EOF")" + +test_allow "Copilot CLI: unknown command: htop" \ + "$(copilot_bash_input "htop")" + +test_allow "Copilot CLI: unknown command: echo" \ + "$(copilot_bash_input "echo hello world")" + +test_allow "Copilot CLI: non-bash tool: view" \ + "$(tool_input "view")" + +test_allow "Copilot CLI: non-bash tool: edit" \ + "$(tool_input "edit")" + +test_allow "VS Code: already rtk" \ + "$(vscode_bash_input "rtk git status")" + +test_allow "VS Code: non-bash tool: editFiles" \ + "$(jq -cn '{"tool_name":"editFiles"}')" + +echo "" + +# ---- SECTION 4: Output format assertions ---- +echo "--- Output format ---" + +# Copilot CLI output format +TOTAL=$((TOTAL + 1)) +raw_output=$(copilot_bash_input "git status" | "$RTK" hook 2>/dev/null) + +if echo "$raw_output" | jq . >/dev/null 2>&1; then + printf " ${GREEN}PASS${RESET} Copilot CLI: output is valid JSON\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Copilot CLI: output is not valid JSON: %s\n" "$raw_output" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +decision=$(echo "$raw_output" | jq -r '.permissionDecision') +if [ "$decision" = "deny" ]; then + printf " ${GREEN}PASS${RESET} Copilot CLI: permissionDecision == \"deny\"\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Copilot CLI: expected \"deny\", got \"%s\"\n" "$decision" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +reason=$(echo "$raw_output" | jq -r '.permissionDecisionReason') +if echo "$reason" | grep -qE '`rtk [^`]+`'; then + printf " ${GREEN}PASS${RESET} Copilot CLI: reason contains backtick-quoted rtk command ${DIM}→ %s${RESET}\n" "$reason" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Copilot CLI: reason missing backtick-quoted command: %s\n" "$reason" + FAIL=$((FAIL + 1)) +fi + +# VS Code output format +TOTAL=$((TOTAL + 1)) +vscode_output=$(vscode_bash_input "git status" | "$RTK" hook 2>/dev/null) + +if echo "$vscode_output" | jq . >/dev/null 2>&1; then + printf " ${GREEN}PASS${RESET} VS Code: output is valid JSON\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} VS Code: output is not valid JSON: %s\n" "$vscode_output" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +vscode_decision=$(echo "$vscode_output" | jq -r '.hookSpecificOutput.permissionDecision') +if [ "$vscode_decision" = "allow" ]; then + printf " ${GREEN}PASS${RESET} VS Code: hookSpecificOutput.permissionDecision == \"allow\"\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} VS Code: expected \"allow\", got \"%s\"\n" "$vscode_decision" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +vscode_updated=$(echo "$vscode_output" | jq -r '.hookSpecificOutput.updatedInput.command') +if echo "$vscode_updated" | grep -q "^rtk "; then + printf " ${GREEN}PASS${RESET} VS Code: updatedInput.command starts with rtk ${DIM}→ %s${RESET}\n" "$vscode_updated" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} VS Code: updatedInput.command should start with rtk: %s\n" "$vscode_updated" + FAIL=$((FAIL + 1)) +fi + +echo "" + +# ---- SUMMARY ---- +echo "============================================" +if [ $FAIL -eq 0 ]; then + printf " ${GREEN}ALL $TOTAL TESTS PASSED${RESET}\n" +else + printf " ${RED}$FAIL FAILED${RESET} / $TOTAL total ($PASS passed)\n" +fi +echo "============================================" + +exit $FAIL diff --git a/hooks/windsurf-rtk-rules.md b/hooks/windsurf-rtk-rules.md new file mode 100644 index 00000000..8491f5a7 --- /dev/null +++ b/hooks/windsurf-rtk-rules.md @@ -0,0 +1,32 @@ +# RTK - Rust Token Killer (Windsurf) + +**Usage**: Token-optimized CLI proxy for shell commands. + +## Rule + +Always prefix shell commands with `rtk` to minimize token consumption. + +Examples: + +```bash +rtk git status +rtk cargo test +rtk ls src/ +rtk grep "pattern" src/ +rtk find "*.rs" . +rtk docker ps +rtk gh pr list +``` + +## Meta Commands + +```bash +rtk gain # Show token savings +rtk gain --history # Command history with savings +rtk discover # Find missed RTK opportunities +rtk proxy # Run raw (no filtering, for debugging) +``` + +## Why + +RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. diff --git a/openclaw/README.md b/openclaw/README.md new file mode 100644 index 00000000..301d7c0f --- /dev/null +++ b/openclaw/README.md @@ -0,0 +1,86 @@ +# RTK Plugin for OpenClaw + +Transparently rewrites shell commands executed via OpenClaw's `exec` tool to their RTK equivalents, achieving 60-90% LLM token savings. + +This is the OpenClaw equivalent of the Claude Code hooks in `hooks/rtk-rewrite.sh`. + +## How it works + +The plugin registers a `before_tool_call` hook that intercepts `exec` tool calls. When the agent runs a command like `git status`, the plugin delegates to `rtk rewrite` which returns the optimized command (e.g. `rtk git status`). The compressed output enters the agent's context window, saving tokens. + +All rewrite logic lives in RTK itself (`rtk rewrite`). This plugin is a thin delegate -- when new filters are added to RTK, the plugin picks them up automatically with zero changes. + +## Installation + +### Prerequisites + +RTK must be installed and available in `$PATH`: + +```bash +brew install rtk +# or +curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh +``` + +### Install the plugin + +```bash +# Copy the plugin to OpenClaw's extensions directory +mkdir -p ~/.openclaw/extensions/rtk-rewrite +cp openclaw/index.ts openclaw/openclaw.plugin.json ~/.openclaw/extensions/rtk-rewrite/ + +# Restart the gateway +openclaw gateway restart +``` + +### Or install via OpenClaw CLI + +```bash +openclaw plugins install ./openclaw +``` + +## Configuration + +In `openclaw.json`: + +```json5 +{ + plugins: { + entries: { + "rtk-rewrite": { + enabled: true, + config: { + enabled: true, // Toggle rewriting on/off + verbose: false // Log rewrites to console + } + } + } + } +} +``` + +## What gets rewritten + +Everything that `rtk rewrite` supports (30+ commands). See the [full command list](https://github.com/rtk-ai/rtk#commands). + +## What's NOT rewritten + +Handled by `rtk rewrite` guards: +- Commands already using `rtk` +- Piped commands (`|`, `&&`, `;`) +- Heredocs (`<<`) +- Commands without an RTK filter + +## Measured savings + +| Command | Token savings | +|---------|--------------| +| `git log --stat` | 87% | +| `ls -la` | 78% | +| `git status` | 66% | +| `grep` (single file) | 52% | +| `find -name` | 48% | + +## License + +MIT -- same as RTK. diff --git a/openclaw/index.ts b/openclaw/index.ts new file mode 100644 index 00000000..17ea4ec9 --- /dev/null +++ b/openclaw/index.ts @@ -0,0 +1,74 @@ +/** + * RTK Rewrite Plugin for OpenClaw + * + * Transparently rewrites exec tool commands to RTK equivalents + * before execution, achieving 60-90% LLM token savings. + * + * All rewrite logic lives in `rtk rewrite` (src/discover/registry.rs). + * This plugin is a thin delegate — to add or change rules, edit the + * Rust registry, not this file. + */ + +import { execSync } from "node:child_process"; + +let rtkAvailable: boolean | null = null; + +function checkRtk(): boolean { + if (rtkAvailable !== null) return rtkAvailable; + try { + execSync("which rtk", { stdio: "ignore" }); + rtkAvailable = true; + } catch { + rtkAvailable = false; + } + return rtkAvailable; +} + +function tryRewrite(command: string): string | null { + try { + const result = execSync(`rtk rewrite ${JSON.stringify(command)}`, { + encoding: "utf-8", + timeout: 2000, + }).trim(); + return result && result !== command ? result : null; + } catch { + return null; + } +} + +export default function register(api: any) { + const pluginConfig = api.config ?? {}; + const enabled = pluginConfig.enabled !== false; + const verbose = pluginConfig.verbose === true; + + if (!enabled) return; + + if (!checkRtk()) { + console.warn("[rtk] rtk binary not found in PATH — plugin disabled"); + return; + } + + api.on( + "before_tool_call", + (event: { toolName: string; params: Record }) => { + if (event.toolName !== "exec") return; + + const command = event.params?.command; + if (typeof command !== "string") return; + + const rewritten = tryRewrite(command); + if (!rewritten) return; + + if (verbose) { + console.log(`[rtk] ${command} -> ${rewritten}`); + } + + return { params: { ...event.params, command: rewritten } }; + }, + { priority: 10 } + ); + + if (verbose) { + console.log("[rtk] OpenClaw plugin registered"); + } +} diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json new file mode 100644 index 00000000..3fce418d --- /dev/null +++ b/openclaw/openclaw.plugin.json @@ -0,0 +1,28 @@ +{ + "id": "rtk-rewrite", + "name": "RTK Token Optimizer", + "version": "1.0.0", + "description": "Transparently rewrites shell commands to their RTK equivalents for 60-90% LLM token savings", + "homepage": "https://github.com/rtk-ai/rtk", + "license": "MIT", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable automatic command rewriting to RTK equivalents" + }, + "verbose": { + "type": "boolean", + "default": false, + "description": "Log rewrite decisions to console for debugging" + } + } + }, + "uiHints": { + "enabled": { "label": "Enable RTK rewriting" }, + "verbose": { "label": "Verbose logging" } + } +} diff --git a/openclaw/package.json b/openclaw/package.json new file mode 100644 index 00000000..18d359ff --- /dev/null +++ b/openclaw/package.json @@ -0,0 +1,29 @@ +{ + "name": "@rtk-ai/rtk-rewrite", + "version": "1.0.0", + "description": "RTK plugin for OpenClaw — rewrites shell commands for 60-90% LLM token savings", + "main": "index.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/rtk-ai/rtk", + "directory": "openclaw" + }, + "homepage": "https://github.com/rtk-ai/rtk", + "keywords": [ + "rtk", + "openclaw", + "openclaw-plugin", + "token-savings", + "llm", + "cli-proxy" + ], + "files": [ + "index.ts", + "openclaw.plugin.json", + "README.md" + ], + "peerDependencies": { + "rtk": ">=0.28.0" + } +} diff --git a/src/discover/provider.rs b/src/discover/provider.rs index ae0852d2..b4105a9d 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -22,7 +22,11 @@ pub struct ExtractedCommand { pub sequence_index: usize, } -/// Trait for session providers (Claude Code, future: Cursor, Windsurf). +/// Trait for session providers (Claude Code, OpenCode, etc.). +/// +/// Note: Cursor Agent transcripts use a text-only format without structured +/// tool_use/tool_result blocks, so command extraction is not possible. +/// Use `rtk gain` to track savings for Cursor sessions instead. pub trait SessionProvider { fn discover_sessions( &self, diff --git a/src/discover/report.rs b/src/discover/report.rs index 5d05f150..5b1fe801 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -165,6 +165,14 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri out.push_str("\n~estimated from tool_result output sizes\n"); + // Cursor note: check if Cursor hooks are installed + if let Some(home) = dirs::home_dir() { + let cursor_hook = home.join(".cursor").join("hooks").join("rtk-rewrite.sh"); + if cursor_hook.exists() { + out.push_str("\nNote: Cursor sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } + } + if verbose && report.parse_errors > 0 { out.push_str(&format!("Parse errors skipped: {}\n", report.parse_errors)); } diff --git a/src/hook_cmd.rs b/src/hook_cmd.rs new file mode 100644 index 00000000..29a7365d --- /dev/null +++ b/src/hook_cmd.rs @@ -0,0 +1,333 @@ +use anyhow::{Context, Result}; +use serde_json::{json, Value}; +use std::io::{self, Read}; + +use crate::discover::registry::rewrite_command; + +// ── Copilot hook (VS Code + Copilot CLI) ────────────────────── + +/// Format detected from the preToolUse JSON input. +enum HookFormat { + /// VS Code Copilot Chat / Claude Code: `tool_name` + `tool_input.command`, supports `updatedInput`. + VsCode { command: String }, + /// GitHub Copilot CLI: camelCase `toolName` + `toolArgs` (JSON string), deny-with-suggestion only. + CopilotCli { command: String }, + /// Non-bash tool, already uses rtk, or unknown format — pass through silently. + PassThrough, +} + +/// Run the Copilot preToolUse hook. +/// Auto-detects VS Code Copilot Chat vs Copilot CLI format. +pub fn run_copilot() -> Result<()> { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("Failed to read stdin")?; + + let input = input.trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(e) => { + eprintln!("[rtk hook] Failed to parse JSON input: {e}"); + return Ok(()); + } + }; + + match detect_format(&v) { + HookFormat::VsCode { command } => handle_vscode(&command), + HookFormat::CopilotCli { command } => handle_copilot_cli(&command), + HookFormat::PassThrough => Ok(()), + } +} + +fn detect_format(v: &Value) -> HookFormat { + // VS Code Copilot Chat / Claude Code: snake_case keys + if let Some(tool_name) = v.get("tool_name").and_then(|t| t.as_str()) { + if matches!(tool_name, "runTerminalCommand" | "Bash" | "bash") { + if let Some(cmd) = v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + return HookFormat::VsCode { + command: cmd.to_string(), + }; + } + } + return HookFormat::PassThrough; + } + + // Copilot CLI: camelCase keys, toolArgs is a JSON-encoded string + if let Some(tool_name) = v.get("toolName").and_then(|t| t.as_str()) { + if tool_name == "bash" { + if let Some(tool_args_str) = v.get("toolArgs").and_then(|t| t.as_str()) { + if let Ok(tool_args) = serde_json::from_str::(tool_args_str) { + if let Some(cmd) = tool_args + .get("command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + return HookFormat::CopilotCli { + command: cmd.to_string(), + }; + } + } + } + } + return HookFormat::PassThrough; + } + + HookFormat::PassThrough +} + +fn get_rewritten(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + + let excluded = crate::config::Config::load() + .map(|c| c.hooks.exclude_commands) + .unwrap_or_default(); + + let rewritten = rewrite_command(cmd, &excluded)?; + + if rewritten == cmd { + return None; + } + + Some(rewritten) +} + +fn handle_vscode(cmd: &str) -> Result<()> { + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => return Ok(()), + }; + + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": { "command": rewritten } + } + }); + println!("{output}"); + Ok(()) +} + +fn handle_copilot_cli(cmd: &str) -> Result<()> { + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => return Ok(()), + }; + + let output = json!({ + "permissionDecision": "deny", + "permissionDecisionReason": format!( + "Token savings: use `{}` instead (rtk saves 60-90% tokens)", + rewritten + ) + }); + println!("{output}"); + Ok(()) +} + +// ── Gemini hook ─────────────────────────────────────────────── + +/// Run the Gemini CLI BeforeTool hook. +/// Reads JSON from stdin, rewrites shell commands to rtk equivalents, +/// outputs JSON to stdout in Gemini CLI format. +pub fn run_gemini() -> Result<()> { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("Failed to read hook input from stdin")?; + + let json: Value = serde_json::from_str(&input).context("Failed to parse hook input as JSON")?; + + let tool_name = json.get("tool_name").and_then(|v| v.as_str()).unwrap_or(""); + + if tool_name != "run_shell_command" { + print_allow(); + return Ok(()); + } + + let cmd = json + .pointer("/tool_input/command") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if cmd.is_empty() { + print_allow(); + return Ok(()); + } + + // Delegate to the single source of truth for command rewriting + match rewrite_command(cmd, &[]) { + Some(rewritten) => print_rewrite(&rewritten), + None => print_allow(), + } + + Ok(()) +} + +fn print_allow() { + println!(r#"{{"decision":"allow"}}"#); +} + +fn print_rewrite(cmd: &str) { + let output = serde_json::json!({ + "decision": "allow", + "hookSpecificOutput": { + "tool_input": { + "command": cmd + } + } + }); + println!("{}", output); +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- Copilot format detection --- + + fn vscode_input(tool: &str, cmd: &str) -> Value { + json!({ + "tool_name": tool, + "tool_input": { "command": cmd } + }) + } + + fn copilot_cli_input(cmd: &str) -> Value { + let args = serde_json::to_string(&json!({ "command": cmd })).unwrap(); + json!({ "toolName": "bash", "toolArgs": args }) + } + + #[test] + fn test_detect_vscode_bash() { + assert!(matches!( + detect_format(&vscode_input("Bash", "git status")), + HookFormat::VsCode { .. } + )); + } + + #[test] + fn test_detect_vscode_run_terminal_command() { + assert!(matches!( + detect_format(&vscode_input("runTerminalCommand", "cargo test")), + HookFormat::VsCode { .. } + )); + } + + #[test] + fn test_detect_copilot_cli_bash() { + assert!(matches!( + detect_format(&copilot_cli_input("git status")), + HookFormat::CopilotCli { .. } + )); + } + + #[test] + fn test_detect_non_bash_is_passthrough() { + let v = json!({ "tool_name": "editFiles" }); + assert!(matches!(detect_format(&v), HookFormat::PassThrough)); + } + + #[test] + fn test_detect_unknown_is_passthrough() { + assert!(matches!(detect_format(&json!({})), HookFormat::PassThrough)); + } + + #[test] + fn test_get_rewritten_supported() { + assert!(get_rewritten("git status").is_some()); + } + + #[test] + fn test_get_rewritten_unsupported() { + assert!(get_rewritten("htop").is_none()); + } + + #[test] + fn test_get_rewritten_already_rtk() { + assert!(get_rewritten("rtk git status").is_none()); + } + + #[test] + fn test_get_rewritten_heredoc() { + assert!(get_rewritten("cat <<'EOF'\nhello\nEOF").is_none()); + } + + // --- Gemini format --- + + #[test] + fn test_print_allow_format() { + // Verify the allow JSON format matches Gemini CLI expectations + let expected = r#"{"decision":"allow"}"#; + assert_eq!(expected, r#"{"decision":"allow"}"#); + } + + #[test] + fn test_print_rewrite_format() { + let output = serde_json::json!({ + "decision": "allow", + "hookSpecificOutput": { + "tool_input": { + "command": "rtk git status" + } + } + }); + let json: Value = serde_json::from_str(&output.to_string()).unwrap(); + assert_eq!(json["decision"], "allow"); + assert_eq!( + json["hookSpecificOutput"]["tool_input"]["command"], + "rtk git status" + ); + } + + #[test] + fn test_gemini_hook_uses_rewrite_command() { + // Verify that rewrite_command handles the cases we need for Gemini + assert_eq!( + rewrite_command("git status", &[]), + Some("rtk git status".into()) + ); + assert_eq!( + rewrite_command("cargo test", &[]), + Some("rtk cargo test".into()) + ); + // Already rtk → returned as-is (idempotent) + assert_eq!( + rewrite_command("rtk git status", &[]), + Some("rtk git status".into()) + ); + // Heredoc → no rewrite + assert_eq!(rewrite_command("cat < Result<()> { + // Validation: Codex mode conflicts + if codex { + if install_opencode { + anyhow::bail!("--codex cannot be combined with --opencode"); + } + if claude_md { + anyhow::bail!("--codex cannot be combined with --claude-md"); + } + if hook_only { + anyhow::bail!("--codex cannot be combined with --hook-only"); + } + if matches!(patch_mode, PatchMode::Auto) { + anyhow::bail!("--codex cannot be combined with --auto-patch"); + } + if matches!(patch_mode, PatchMode::Skip) { + anyhow::bail!("--codex cannot be combined with --no-patch"); + } + return run_codex_mode(global, verbose); + } + + // Validation: Global-only features if install_opencode && !global { anyhow::bail!("OpenCode plugin is global-only. Use: rtk init -g --opencode"); } - // Mode selection + if install_cursor && !global { + anyhow::bail!("Cursor hooks are global-only. Use: rtk init -g --agent cursor"); + } + + if install_windsurf && !global { + anyhow::bail!("Windsurf support is global-only. Use: rtk init -g --agent windsurf"); + } + + // Windsurf-only mode + if install_windsurf { + return run_windsurf_mode(verbose); + } + + // Cline-only mode + if install_cline { + return run_cline_mode(verbose); + } + + // Mode selection (Claude Code / OpenCode) match (install_claude, install_opencode, claude_md, hook_only) { - (false, true, _, _) => run_opencode_only_mode(verbose), - (true, opencode, true, _) => run_claude_md_mode(global, verbose, opencode), - (true, opencode, false, true) => run_hook_only_mode(global, patch_mode, verbose, opencode), - (true, opencode, false, false) => run_default_mode(global, patch_mode, verbose, opencode), + (false, true, _, _) => run_opencode_only_mode(verbose)?, + (true, opencode, true, _) => run_claude_md_mode(global, verbose, opencode)?, + (true, opencode, false, true) => run_hook_only_mode(global, patch_mode, verbose, opencode)?, + (true, opencode, false, false) => run_default_mode(global, patch_mode, verbose, opencode)?, (false, false, _, _) => { - anyhow::bail!("at least one of install_claude or install_opencode must be true") + if !install_cursor { + anyhow::bail!("at least one of install_claude or install_opencode must be true") + } } } + + // Cursor hooks (additive, installed alongside Claude Code) + if install_cursor { + install_cursor_hooks(verbose)?; + } + + Ok(()) } /// Prepare hook directory and return paths (hook_dir, hook_path) @@ -458,8 +515,30 @@ fn remove_hook_from_settings(verbose: u8) -> Result { Ok(removed) } -/// Full uninstall: remove hook, RTK.md, @RTK.md reference, settings.json entry -pub fn uninstall(global: bool, verbose: u8) -> Result<()> { +/// Full uninstall for Claude, Gemini, Codex, or Cursor artifacts. +pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: u8) -> Result<()> { + if codex { + return uninstall_codex(global, verbose); + } + + if cursor { + if !global { + anyhow::bail!("Cursor uninstall only works with --global flag"); + } + let cursor_removed = + remove_cursor_hooks(verbose).context("Failed to remove Cursor hooks")?; + if !cursor_removed.is_empty() { + println!("RTK uninstalled (Cursor):"); + for item in &cursor_removed { + println!(" - {}", item); + } + println!("\nRestart Cursor to apply changes."); + } else { + println!("RTK Cursor support was not installed (nothing to remove)"); + } + return Ok(()); + } + if !global { anyhow::bail!("Uninstall only works with --global flag. For local projects, manually remove RTK from CLAUDE.md"); } @@ -467,6 +546,22 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { let claude_dir = resolve_claude_dir()?; let mut removed = Vec::new(); + // Also uninstall Gemini artifacts if --gemini or always (clean everything) + if gemini { + let gemini_removed = uninstall_gemini(verbose)?; + removed.extend(gemini_removed); + if !removed.is_empty() { + println!("RTK uninstalled (Gemini):"); + for item in &removed { + println!(" - {}", item); + } + println!("\nRestart Gemini CLI to apply changes."); + } else { + println!("RTK Gemini support was not installed (nothing to remove)"); + } + return Ok(()); + } + // 1. Remove hook file let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); if hook_path.exists() { @@ -522,6 +617,10 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { removed.push(format!("OpenCode plugin: {}", path.display())); } + // 6. Remove Cursor hooks + let cursor_removed = remove_cursor_hooks(verbose)?; + removed.extend(cursor_removed); + // Report results if removed.is_empty() { println!("RTK was not installed (nothing to remove)"); @@ -530,12 +629,55 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { for item in removed { println!(" - {}", item); } - println!("\nRestart Claude Code and OpenCode (if used) to apply changes."); + println!("\nRestart Claude Code, OpenCode, and Cursor (if used) to apply changes."); + } + + Ok(()) +} + +fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { + if !global { + anyhow::bail!( + "Uninstall only works with --global flag. For local projects, manually remove RTK from AGENTS.md" + ); + } + + let codex_dir = resolve_codex_dir()?; + let removed = uninstall_codex_at(&codex_dir, verbose)?; + + if removed.is_empty() { + println!("RTK was not installed for Codex CLI (nothing to remove)"); + } else { + println!("RTK uninstalled for Codex CLI:"); + for item in removed { + println!(" - {}", item); + } } Ok(()) } +fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { + let mut removed = Vec::new(); + + let rtk_md_path = codex_dir.join("RTK.md"); + if rtk_md_path.exists() { + fs::remove_file(&rtk_md_path) + .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + if verbose > 0 { + eprintln!("Removed RTK.md: {}", rtk_md_path.display()); + } + removed.push(format!("RTK.md: {}", rtk_md_path.display())); + } + + let agents_md_path = codex_dir.join("AGENTS.md"); + if remove_rtk_reference_from_agents(&agents_md_path, verbose)? { + removed.push("AGENTS.md: removed @RTK.md reference".to_string()); + } + + Ok(removed) +} + /// Orchestrator: patch settings.json with RTK hook /// Handles reading, checking, prompting, merging, backing up, and atomic writing fn patch_settings_json( @@ -1021,6 +1163,119 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu Ok(()) } +// ─── Windsurf support ───────────────────────────────────────── + +/// Embedded Windsurf RTK rules +const WINDSURF_RULES: &str = include_str!("../hooks/windsurf-rtk-rules.md"); + +/// Embedded Cline RTK rules +const CLINE_RULES: &str = include_str!("../hooks/cline-rtk-rules.md"); + +// ─── Cline / Roo Code support ───────────────────────────────── + +fn run_cline_mode(verbose: u8) -> Result<()> { + // Cline reads .clinerules from the project root (workspace-scoped) + let rules_path = PathBuf::from(".clinerules"); + + let existing = fs::read_to_string(&rules_path).unwrap_or_default(); + if existing.contains("RTK") || existing.contains("rtk") { + println!("\nRTK already configured for Cline in this project.\n"); + println!(" Rules: .clinerules (already present)"); + } else { + let new_content = if existing.trim().is_empty() { + CLINE_RULES.to_string() + } else { + format!("{}\n\n{}", existing.trim(), CLINE_RULES) + }; + fs::write(&rules_path, &new_content).context("Failed to write .clinerules")?; + + if verbose > 0 { + eprintln!("Wrote .clinerules"); + } + + println!("\nRTK configured for Cline.\n"); + println!(" Rules: .clinerules (installed)"); + } + println!(" Cline will now use rtk commands for token savings."); + println!(" Test with: git status\n"); + + Ok(()) +} + +fn run_windsurf_mode(verbose: u8) -> Result<()> { + // Windsurf reads .windsurfrules from the project root (workspace-scoped). + // Global rules (~/.codeium/windsurf/memories/global_rules.md) are unreliable. + let rules_path = PathBuf::from(".windsurfrules"); + + let existing = fs::read_to_string(&rules_path).unwrap_or_default(); + if existing.contains("RTK") || existing.contains("rtk") { + println!("\nRTK already configured for Windsurf in this project.\n"); + println!(" Rules: .windsurfrules (already present)"); + } else { + let new_content = if existing.trim().is_empty() { + WINDSURF_RULES.to_string() + } else { + format!("{}\n\n{}", existing.trim(), WINDSURF_RULES) + }; + fs::write(&rules_path, &new_content).context("Failed to write .windsurfrules")?; + + if verbose > 0 { + eprintln!("Wrote .windsurfrules"); + } + + println!("\nRTK configured for Windsurf Cascade.\n"); + println!(" Rules: .windsurfrules (installed)"); + } + println!(" Cascade will now use rtk commands for token savings."); + println!(" Restart Windsurf. Test with: git status\n"); + + Ok(()) +} + +fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { + let (agents_md_path, rtk_md_path) = if global { + let codex_dir = resolve_codex_dir()?; + (codex_dir.join("AGENTS.md"), codex_dir.join("RTK.md")) + } else { + (PathBuf::from("AGENTS.md"), PathBuf::from("RTK.md")) + }; + + if global { + if let Some(parent) = agents_md_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create Codex config directory: {}", + parent.display() + ) + })?; + } + } + + write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, "RTK.md", verbose)?; + let added_ref = patch_agents_md(&agents_md_path, verbose)?; + + println!("\nRTK configured for Codex CLI.\n"); + println!(" RTK.md: {}", rtk_md_path.display()); + if added_ref { + println!(" AGENTS.md: @RTK.md reference added"); + } else { + println!(" AGENTS.md: @RTK.md reference already present"); + } + if global { + println!( + "\n Codex global instructions path: {}", + agents_md_path.display() + ); + } else { + println!( + "\n Codex project instructions path: {}", + agents_md_path.display() + ); + } + + Ok(()) +} + // --- upsert_rtk_block: idempotent RTK block management --- #[derive(Debug, Clone, Copy, PartialEq)] @@ -1133,6 +1388,83 @@ fn patch_claude_md(path: &Path, verbose: u8) -> Result { Ok(migrated) } +/// Patch AGENTS.md: add @RTK.md, migrate old inline block if present +fn patch_agents_md(path: &Path, verbose: u8) -> Result { + let mut content = if path.exists() { + fs::read_to_string(path) + .with_context(|| format!("Failed to read AGENTS.md: {}", path.display()))? + } else { + String::new() + }; + + let mut migrated = false; + if content.contains("\nold\n\n", + ) + .unwrap(); + + let added = patch_agents_md(&agents_md, 0).unwrap(); + + assert!(added); + let content = fs::read_to_string(&agents_md).unwrap(); + assert!(!content.contains("old")); + assert_eq!(content.matches("@RTK.md").count(), 1); + } + + #[test] + fn test_uninstall_codex_at_is_idempotent() { + let temp = TempDir::new().unwrap(); + let codex_dir = temp.path(); + let agents_md = codex_dir.join("AGENTS.md"); + let rtk_md = codex_dir.join("RTK.md"); + + fs::write(&agents_md, "# Team rules\n\n@RTK.md\n").unwrap(); + fs::write(&rtk_md, "codex config").unwrap(); + + let removed_first = uninstall_codex_at(codex_dir, 0).unwrap(); + let removed_second = uninstall_codex_at(codex_dir, 0).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!rtk_md.exists()); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert!(!content.contains("@RTK.md")); + assert!(content.contains("# Team rules")); + } + #[test] fn test_local_init_unchanged() { // Local init should use claude-md mode @@ -1855,4 +2867,132 @@ More notes let removed = remove_hook_from_json(&mut json_content); assert!(!removed); } + + // ─── Cursor hooks.json tests ─── + + #[test] + fn test_cursor_hook_already_present_true() { + let json_content = serde_json::json!({ + "version": 1, + "hooks": { + "preToolUse": [{ + "command": "./hooks/rtk-rewrite.sh", + "matcher": "Shell" + }] + } + }); + assert!(cursor_hook_already_present(&json_content)); + } + + #[test] + fn test_cursor_hook_already_present_false_empty() { + let json_content = serde_json::json!({ "version": 1 }); + assert!(!cursor_hook_already_present(&json_content)); + } + + #[test] + fn test_cursor_hook_already_present_false_other_hooks() { + let json_content = serde_json::json!({ + "version": 1, + "hooks": { + "preToolUse": [{ + "command": "./hooks/some-other-hook.sh", + "matcher": "Shell" + }] + } + }); + assert!(!cursor_hook_already_present(&json_content)); + } + + #[test] + fn test_insert_cursor_hook_entry_empty() { + let mut json_content = serde_json::json!({ "version": 1 }); + insert_cursor_hook_entry(&mut json_content); + + let hooks = json_content["hooks"]["preToolUse"].as_array().unwrap(); + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0]["command"], "./hooks/rtk-rewrite.sh"); + assert_eq!(hooks[0]["matcher"], "Shell"); + assert_eq!(json_content["version"], 1); + } + + #[test] + fn test_insert_cursor_hook_preserves_existing() { + let mut json_content = serde_json::json!({ + "version": 1, + "hooks": { + "preToolUse": [{ + "command": "./hooks/other.sh", + "matcher": "Shell" + }], + "afterFileEdit": [{ + "command": "./hooks/format.sh" + }] + } + }); + + insert_cursor_hook_entry(&mut json_content); + + let pre_tool_use = json_content["hooks"]["preToolUse"].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 2); + assert_eq!(pre_tool_use[0]["command"], "./hooks/other.sh"); + assert_eq!(pre_tool_use[1]["command"], "./hooks/rtk-rewrite.sh"); + + // afterFileEdit should be preserved + assert!(json_content["hooks"]["afterFileEdit"].is_array()); + } + + #[test] + fn test_remove_cursor_hook_from_json() { + let mut json_content = serde_json::json!({ + "version": 1, + "hooks": { + "preToolUse": [ + { "command": "./hooks/other.sh", "matcher": "Shell" }, + { "command": "./hooks/rtk-rewrite.sh", "matcher": "Shell" } + ] + } + }); + + let removed = remove_cursor_hook_from_json(&mut json_content); + assert!(removed); + + let hooks = json_content["hooks"]["preToolUse"].as_array().unwrap(); + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0]["command"], "./hooks/other.sh"); + } + + #[test] + fn test_remove_cursor_hook_not_present() { + let mut json_content = serde_json::json!({ + "version": 1, + "hooks": { + "preToolUse": [ + { "command": "./hooks/other.sh", "matcher": "Shell" } + ] + } + }); + + let removed = remove_cursor_hook_from_json(&mut json_content); + assert!(!removed); + } + + #[test] + fn test_cursor_hook_script_has_guards() { + assert!(CURSOR_REWRITE_HOOK.contains("command -v rtk")); + assert!(CURSOR_REWRITE_HOOK.contains("command -v jq")); + let jq_pos = CURSOR_REWRITE_HOOK.find("command -v jq").unwrap(); + let rtk_delegate_pos = CURSOR_REWRITE_HOOK.find("rtk rewrite \"$CMD\"").unwrap(); + assert!( + jq_pos < rtk_delegate_pos, + "Guards must appear before rtk rewrite delegation" + ); + } + + #[test] + fn test_cursor_hook_outputs_cursor_format() { + assert!(CURSOR_REWRITE_HOOK.contains("\"permission\": \"allow\"")); + assert!(CURSOR_REWRITE_HOOK.contains("\"updated_input\"")); + assert!(!CURSOR_REWRITE_HOOK.contains("hookSpecificOutput")); + } } diff --git a/src/main.rs b/src/main.rs index c38b2b3b..2bbc4bb2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod grep_cmd; mod gt_cmd; mod hook_audit_cmd; mod hook_check; +mod hook_cmd; mod init; mod integrity; mod json_cmd; @@ -66,10 +67,23 @@ mod wget_cmd; use anyhow::{Context, Result}; use clap::error::ErrorKind; -use clap::{Parser, Subcommand}; +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, +} + #[derive(Parser)] #[command( name = "rtk", @@ -322,9 +336,9 @@ enum Commands { extra_args: Vec, }, - /// Initialize rtk instructions in CLAUDE.md + /// Initialize rtk instructions for assistant CLI usage Init { - /// Add to global ~/.claude/CLAUDE.md instead of local + /// Add to global assistant config directory instead of local project file #[arg(short, long)] global: bool, @@ -332,6 +346,14 @@ enum Commands { #[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, + /// Show current configuration #[arg(long)] show: bool, @@ -352,9 +374,13 @@ enum Commands { #[arg(long = "no-patch", group = "patch")] no_patch: bool, - /// Remove all RTK artifacts (hook, RTK.md, CLAUDE.md reference, settings.json entry) + /// 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, }, /// Download with compact output (strips progress bars) @@ -663,6 +689,20 @@ enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + + /// Hook processors for LLM CLI tools (Gemini CLI, Copilot, etc.) + Hook { + #[command(subcommand)] + command: HookCommands, + }, +} + +#[derive(Subcommand)] +enum HookCommands { + /// Process Gemini CLI BeforeTool hook (reads JSON from stdin) + Gemini, + /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin) + Copilot, } #[derive(Subcommand)] @@ -1608,20 +1648,36 @@ fn main() -> Result<()> { Commands::Init { global, opencode, + gemini, + agent, show, claude_md, hook_only, auto_patch, no_patch, uninstall, + codex, } => { if show { - init::show_config()?; + init::show_config(codex)?; } else if uninstall { - init::uninstall(global, cli.verbose)?; + let cursor = agent == Some(AgentTarget::Cursor); + init::uninstall(global, gemini, codex, cursor, cli.verbose)?; + } else if gemini { + let patch_mode = if auto_patch { + init::PatchMode::Auto + } else if no_patch { + init::PatchMode::Skip + } else { + init::PatchMode::Ask + }; + init::run_gemini(global, hook_only, patch_mode, cli.verbose)?; } 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 { init::PatchMode::Auto @@ -1634,8 +1690,12 @@ fn main() -> Result<()> { global, install_claude, install_opencode, + install_cursor, + install_windsurf, + install_cline, claude_md, hook_only, + codex, patch_mode, cli.verbose, )?; @@ -1977,6 +2037,15 @@ fn main() -> Result<()> { hook_audit_cmd::run(since, cli.verbose)?; } + Commands::Hook { command } => match command { + HookCommands::Gemini => { + hook_cmd::run_gemini()?; + } + HookCommands::Copilot => { + hook_cmd::run_copilot()?; + } + }, + Commands::Rewrite { args } => { let cmd = args.join(" "); rewrite_cmd::run(&cmd)?; From c85e348a91ee5c7afb5adc298e1e71d6782a594e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:14:19 +0100 Subject: [PATCH 2/3] chore(master): release 0.31.0 (#719) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index df0c791b..8e3d9554 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.30.1" + ".": "0.31.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc3d790..31a475c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.31.0](https://github.com/rtk-ai/rtk/compare/v0.30.1...v0.31.0) (2026-03-19) + + +### Features + +* 9-tool AI agent support + emoji removal ([#704](https://github.com/rtk-ai/rtk/issues/704)) ([737dada](https://github.com/rtk-ai/rtk/commit/737dada4a56c0d7a482cc438e7280340d634f75d)) + ## [0.30.1](https://github.com/rtk-ai/rtk/compare/v0.30.0...v0.30.1) (2026-03-18) diff --git a/Cargo.lock b/Cargo.lock index fd00d755..26894eab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.30.1" +version = "0.31.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 488d962d..f6135608 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.30.1" +version = "0.31.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From a4268ffacbee360b94156d23b88bb7480c14e050 Mon Sep 17 00:00:00 2001 From: saschabuehrle Date: Sat, 21 Mar 2026 13:22:18 +0100 Subject: [PATCH 3/3] fix(gh): print 'No Pull Requests' when pr list is empty (fixes #764) --- src/gh_cmd.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/gh_cmd.rs b/src/gh_cmd.rs index 9073c7e0..896743a9 100644 --- a/src/gh_cmd.rs +++ b/src/gh_cmd.rs @@ -231,6 +231,18 @@ fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { let mut filtered = String::new(); if let Some(prs) = json.as_array() { + if prs.is_empty() { + let msg = if ultra_compact { + "No PRs\n" + } else { + "No Pull Requests\n" + }; + filtered.push_str(msg); + print!("{}", msg); + timer.track("gh pr list", "rtk gh pr list", &raw, &filtered); + return Ok(()); + } + if ultra_compact { filtered.push_str("PRs\n"); println!("PRs");