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/.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/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/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" 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/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"); 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)?;