From fef3d46bece31e254a10cb794ce7a56eb91ebcb6 Mon Sep 17 00:00:00 2001 From: Itai Liba Date: Sun, 22 Feb 2026 18:38:15 -0800 Subject: [PATCH] feat: add OpenCode support and fix hook basename detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **OpenCode Integration:** - Add platform abstraction layer (src/platform.rs) for Claude Code and OpenCode - New OpenCode plugin (hooks/rtk-rewrite.ts) with full command rewrite logic - OpenCode session provider for discover/learn commands (scans ~/.local/share/opencode/opencode.db) - Multi-platform support: discover/learn scan both platforms by default - Updated documentation (README, INSTALL, ARCHITECTURE, TROUBLESHOOTING) for dual-platform usage **Hook Fixes:** - Fix critical basename detection bug: pattern `*/rtk\ *` incorrectly matched `git -C /path/to/rtk status` - Changed to basename checking: only skip if first command word is literally 'rtk' - Add workaround for git -C/-c flags (not yet supported by RTK core) - Hooks now skip rewriting git commands with global options - Prevents "unexpected argument '-C' found" errors - Documented as known limitation in TROUBLESHOOTING.md **Testing:** - All 435 tests pass - Manual testing confirms hooks work in both platforms - Hook correctly rewrites: `git status` → `rtk git status` - Hook correctly skips: `git -C /path status` (no rewrite) - Hook correctly skips: `rtk git status` (already using rtk) **Files Changed:** - NEW: hooks/rtk-rewrite.ts (OpenCode plugin) - NEW: src/platform.rs (platform abstraction) - FIXED: hooks/rtk-rewrite.sh (basename detection + git -C skip) - UPDATED: src/discover/, src/init.rs, src/learn/ (multi-platform) - DOCS: README, INSTALL, ARCHITECTURE, TROUBLESHOOTING --- ARCHITECTURE.md | 18 +- INSTALL.md | 18 +- README.md | 222 +++++++------- docs/TROUBLESHOOTING.md | 71 ++++- hooks/rtk-awareness.md | 6 +- hooks/rtk-rewrite.sh | 16 +- hooks/rtk-rewrite.ts | 229 +++++++++++++++ src/discover/mod.rs | 129 ++++++-- src/discover/provider.rs | 459 ++++++++++++++++++++++++++++- src/init.rs | 613 +++++++++++++++++++++++++++------------ src/learn/mod.rs | 154 +++++++--- src/main.rs | 27 +- src/platform.rs | 189 ++++++++++++ 13 files changed, 1743 insertions(+), 408 deletions(-) create mode 100644 hooks/rtk-rewrite.ts create mode 100644 src/platform.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7d63d3c8..b2be6dea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,20 +71,22 @@ $ 3 commits ←─ Terminal ←─ Format ←─ Compact Sta 4. **Fail-Safe**: If filtering fails, fall back to original output 5. **Transparent**: Users can always see raw output with `-v` flags -### Hook Architecture (v0.9.5+) +### Hook/Plugin Architecture (v0.9.5+) -The recommended deployment mode uses a Claude Code PreToolUse hook for 100% transparent command rewriting. +The recommended deployment mode uses platform-specific hooks for 100% transparent command rewriting: +- **Claude Code**: PreToolUse hook (`rtk-rewrite.sh`) registered in `settings.json` +- **OpenCode**: TypeScript plugin (`rtk-rewrite.ts`) auto-loaded from `~/.config/opencode/plugins/` ``` ┌────────────────────────────────────────────────────────────────────────┐ │ Hook-Based Command Rewriting │ └────────────────────────────────────────────────────────────────────────┘ -Claude Code settings.json rtk-rewrite.sh RTK binary +AI Agent Hook/Plugin Rewrite Logic RTK binary │ │ │ │ │ Bash: "git status" │ │ │ │ ─────────────────────►│ │ │ - │ │ PreToolUse hook │ │ + │ │ Intercept trigger │ │ │ │ ───────────────────►│ │ │ │ │ detect: git │ │ │ │ rewrite: │ @@ -99,12 +101,16 @@ Claude Code settings.json rtk-rewrite.sh RTK binary │◄────────────────────────────────────────────────────────────────── │ "3 modified, 1 untracked ✓" (~10 tokens vs ~200 raw) │ - │ Claude never sees the rewrite — it only sees optimized output. + │ The agent never sees the rewrite — it only sees optimized output. -Files: +Files (Claude Code): ~/.claude/hooks/rtk-rewrite.sh ← shell script (command detection + rewrite) ~/.claude/settings.json ← hook registry (PreToolUse registration) ~/.claude/RTK.md ← minimal context hint (10 lines) + +Files (OpenCode): + ~/.config/opencode/plugins/rtk-rewrite.ts ← TS plugin (auto-loaded) + ~/.config/opencode/AGENTS.md ← rules file (optional) ``` Two hook strategies: diff --git a/INSTALL.md b/INSTALL.md index 002e7147..f25bff2e 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -72,7 +72,7 @@ rtk gain # MUST show token savings, not "command not found" ### Which mode to choose? ``` - Do you want RTK active across ALL Claude Code projects? + Do you want RTK active across ALL AI agent projects? │ ├─ YES → rtk init -g (recommended) │ Hook + RTK.md (~10 tokens in context) @@ -110,14 +110,14 @@ rtk init --show # Check hook is installed and executable **Token savings**: ~99.5% reduction (2000 tokens → 10 tokens in context) **What is settings.json?** -Claude Code's hook registry. RTK adds a PreToolUse hook that rewrites commands transparently. Without this, Claude won't invoke the hook automatically. +Claude Code's hook registry (OpenCode uses auto-loaded plugins instead). RTK adds a PreToolUse hook that rewrites commands transparently. Without this, the hook won't be invoked automatically. ``` - Claude Code settings.json rtk-rewrite.sh RTK binary + AI Agent Hook/Plugin Rewrite Logic RTK binary │ │ │ │ │ "git status" │ │ │ │ ──────────────────►│ │ │ - │ │ PreToolUse trigger │ │ + │ │ Intercept trigger │ │ │ │ ───────────────────►│ │ │ │ │ rewrite command │ │ │ │ → rtk git status │ @@ -172,7 +172,7 @@ rtk init -g # → Answer 'y' when prompted to patch settings.json # → Creates backup automatically -# 3. Restart Claude Code +# 3. Restart your AI agent (Claude Code or OpenCode) # 4. Test: git status (should use rtk) ``` @@ -191,8 +191,8 @@ rtk init --show | grep "Hook:" rtk init -g --no-patch # Review printed JSON snippet -# Manually edit ~/.claude/settings.json -# Restart Claude Code +# Manually edit ~/.claude/settings.json (Claude Code only) +# Restart your AI agent ``` ### Temporary Trial @@ -237,7 +237,7 @@ rtk init -g --uninstall # - Reference: @RTK.md line from ~/.claude/CLAUDE.md # - Registration: RTK hook entry from settings.json -# Restart Claude Code after uninstall +# Restart your AI agent after uninstall ``` **For Local Projects**: Manually remove RTK block from `./CLAUDE.md` @@ -309,7 +309,7 @@ rtk gain --history # With command history | `pnpm list` | ~8,000 tokens | ~2,400 | **-70%** | | `pnpm outdated` | ~12,000 tokens | ~1,200-2,400 | **-80-90%** | -### Typical Claude Code Session (30 min) +### Typical AI Agent Session (30 min) - **Without RTK**: ~150,000 tokens - **With RTK**: ~45,000 tokens - **Savings**: **70% reduction** diff --git a/README.md b/README.md index b6537eab..bb15bbf4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ rtk filters and compresses command outputs before they reach your LLM context, s 1. ✅ **This project (Rust Token Killer)** - LLM token optimizer - Repos: `rtk-ai/rtk` - - Purpose: Reduce Claude Code token consumption + - Purpose: Reduce LLM token consumption in AI coding agents 2. ❌ **reachingforthejack/rtk** - Rust Type Kit (DIFFERENT PROJECT) - Purpose: Query Rust codebase and generate types @@ -28,7 +28,7 @@ rtk gain # Should show token savings stats If `rtk gain` doesn't exist, you installed the wrong package. See installation instructions below. -## Token Savings (30-min Claude Code Session) +## Token Savings (30-min AI Agent Session) Typical session without rtk: **~150,000 tokens** With rtk: **~45,000 tokens** → **70% reduction** @@ -112,10 +112,11 @@ Download from [rtk-ai/releases](https://github.com/rtk-ai/rtk/releases): # 1. Verify installation rtk gain # Must show token stats, not "command not found" -# 2. Initialize for Claude Code (RECOMMENDED: hook-first mode) +# 2. Initialize for your AI agent (RECOMMENDED: hook-first mode) rtk init --global -# → Installs hook + creates slim RTK.md (10 lines, 99.5% token savings) -# → Follow printed instructions to add hook to ~/.claude/settings.json +# → Auto-detects Claude Code or OpenCode +# → Installs hook/plugin + creates slim rules file +# → Follow printed instructions to complete setup # 3. Test it works rtk git status # Should show ultra-compact output @@ -126,7 +127,7 @@ rtk init --show # Verify hook is installed and executable # rtk init # Local project only (./CLAUDE.md) ``` -**New in v0.9.5**: Hook-first installation eliminates ~2000 tokens from Claude's context while maintaining full RTK functionality through transparent command rewriting. +**New in v0.9.5**: Hook-first installation eliminates ~2000 tokens from the agent's context while maintaining full RTK functionality through transparent command rewriting. ## Global Flags @@ -204,14 +205,14 @@ rtk gain --all --format csv # CSV export for Excel/analysis ### Discover — Find Missed Savings -Scans your Claude Code session history to find commands where rtk would have saved tokens. Use it to: +Scans your AI agent session history (Claude Code or OpenCode) to find commands where rtk would have saved tokens. Use it to: - **Measure what you're missing** — see exactly how many tokens you could save - **Identify habits** — find which commands you keep running without rtk - **Spot new opportunities** — see unhandled commands that could become rtk features ```bash rtk discover # Current project, last 30 days -rtk discover --all # All Claude Code projects +rtk discover --all # All agent projects rtk discover --all --since 7 # Last 7 days across all projects rtk discover -p aristote # Filter by project name (substring) rtk discover --format json # Machine-readable output @@ -337,8 +338,8 @@ FAILED: 2/15 tests Without rtk: ┌──────────┐ git status ┌──────────┐ git status ┌──────────┐ - │ Claude │ ─────────────── │ shell │ ──────────── │ git │ - │ LLM │ │ │ │ (CLI) │ + │ LLM │ ─────────────── │ shell │ ──────────── │ git │ + │ Agent │ │ │ │ (CLI) │ └──────────┘ └──────────┘ └──────────┘ ▲ │ │ ~2,000 tokens (raw output) │ @@ -347,8 +348,8 @@ FAILED: 2/15 tests With rtk: ┌──────────┐ git status ┌──────────┐ git status ┌──────────┐ - │ Claude │ ─────────────── │ RTK │ ──────────── │ git │ - │ LLM │ │ (proxy) │ │ (CLI) │ + │ LLM │ ─────────────── │ RTK │ ──────────── │ git │ + │ Agent │ │ (proxy) │ │ (CLI) │ └──────────┘ └──────────┘ └──────────┘ ▲ │ ~2,000 tokens raw │ │ └──────────────────────────┘ @@ -383,7 +384,7 @@ rtk init # Local project: full injection into ./CLAUDE.md ### Installation Flags -**Settings.json Control**: +**Settings.json Control (Claude Code only)**: ```bash rtk init -g # Default: prompt to patch [y/N] rtk init -g --auto-patch # Patch settings.json without prompting @@ -402,7 +403,7 @@ rtk init -g --uninstall # Remove all RTK artifacts ``` **What is settings.json?** -Claude Code configuration file that registers the RTK hook. The hook transparently rewrites commands (e.g., `git status` → `rtk git status`) before execution. Without this registration, Claude won't use the hook. +Claude Code's configuration file that registers the RTK hook. The hook transparently rewrites commands (e.g., `git status` → `rtk git status`) before execution. Without this registration, the hook won't run. OpenCode uses a different mechanism (auto-loaded plugins) that doesn't require settings.json. **Backup Behavior**: RTK creates `~/.claude/settings.json.bak` before making changes. If something breaks, restore with: @@ -485,103 +486,93 @@ max_file_size = 1048576 # 1MB per file max **Supported commands**: cargo (build/test/clippy/check/install/nextest), vitest, pytest, lint (eslint/biome/ruff/pylint/mypy), tsc, go (test/build/vet), err, test. -## Auto-Rewrite Hook (Recommended) +## Auto-Rewrite Hook/Plugin (Recommended) -The most effective way to use rtk is with the **auto-rewrite hook** for Claude Code. Instead of relying on CLAUDE.md instructions (which subagents may ignore), this hook transparently intercepts Bash commands and rewrites them to their rtk equivalents before execution. +The most effective way to use rtk is with the **auto-rewrite hook** (Claude Code) or **plugin** (OpenCode). Instead of relying on rules file instructions (which subagents may ignore), this transparently intercepts Bash commands and rewrites them to their rtk equivalents before execution. -**Result**: 100% rtk adoption across all conversations and subagents, zero token overhead in Claude's context. - -### What Are Hooks? - -**For Beginners**: -Claude Code hooks are scripts that run before/after Claude executes commands. RTK uses a **PreToolUse** hook that intercepts Bash commands and rewrites them (e.g., `git status` → `rtk git status`) before execution. This is **transparent** - Claude never sees the rewrite, it just gets optimized output. - -**Why settings.json?** -Claude Code reads `~/.claude/settings.json` to find registered hooks. Without this file, Claude doesn't know the RTK hook exists. Think of it as the hook registry. - -**Is it safe?** -Yes. RTK creates a backup (`settings.json.bak`) before changes. The hook is read-only (it only modifies command strings, never deletes files or accesses secrets). Review the hook script at `~/.claude/hooks/rtk-rewrite.sh` anytime. +**Result**: 100% rtk adoption across all conversations and subagents, zero token overhead in context. ### How It Works -The hook runs as a Claude Code [PreToolUse hook](https://docs.anthropic.com/en/docs/claude-code/hooks). When Claude Code is about to execute a Bash command like `git status`, the hook rewrites it to `rtk git status` before the command reaches the shell. Claude Code never sees the rewrite — it's transparent. - -``` - Claude Code types: git status - │ - ┌──────▼──────────────────────┐ - │ ~/.claude/settings.json │ - │ PreToolUse hook registered │ - └──────┬──────────────────────┘ - │ - ┌──────▼──────────────────────┐ - │ rtk-rewrite.sh │ - │ "git status" │ - │ → "rtk git status" │ transparent rewrite - └──────┬──────────────────────┘ - │ - ┌──────▼──────────────────────┐ - │ RTK (Rust binary) │ - │ executes real git status │ - │ filters output │ - └──────┬──────────────────────┘ - │ - Claude receives: "3 modified, 1 untracked ✓" - ↑ not 50 lines of raw git output +RTK auto-detects your AI coding agent and installs the appropriate integration: + +| Agent | Mechanism | Config Location | +|-------|-----------|-----------------| +| **Claude Code** | PreToolUse hook (bash script) | `~/.claude/hooks/rtk-rewrite.sh` + `settings.json` | +| **OpenCode** | Plugin (TypeScript) | `~/.config/opencode/plugins/rtk-rewrite.ts` | + +When the agent is about to execute a Bash command like `git status`, the hook/plugin rewrites it to `rtk git status` before the command reaches the shell. The agent never sees the rewrite — it's transparent. + +``` + Agent types: git status + │ + ┌──────▼──────────────────────┐ + │ Hook/Plugin registered │ + │ (auto-detected per agent) │ + └──────┬──────────────────────┘ + │ + ┌──────▼──────────────────────┐ + │ rtk-rewrite (.sh or .ts) │ + │ "git status" │ + │ → "rtk git status" │ transparent rewrite + └──────┬──────────────────────┘ + │ + ┌──────▼──────────────────────┐ + │ RTK (Rust binary) │ + │ executes real git status │ + │ filters output │ + └──────┬──────────────────────┘ + │ + Agent receives: "3 modified, 1 untracked ✓" + ↑ not 50 lines of raw git output ``` ### Quick Install (Automated) ```bash rtk init -g -# → Installs hook to ~/.claude/hooks/rtk-rewrite.sh (with executable permissions) -# → Creates ~/.claude/RTK.md (10 lines, minimal context footprint) -# → Adds @RTK.md reference to ~/.claude/CLAUDE.md -# → Prompts: "Patch settings.json? [y/N]" -# → If yes: creates backup (~/.claude/settings.json.bak), patches file +# Auto-detects Claude Code or OpenCode and installs: +# +# Claude Code: +# → Hook: ~/.claude/hooks/rtk-rewrite.sh +# → Context: ~/.claude/RTK.md (10 lines) +# → Prompts to patch ~/.claude/settings.json +# +# OpenCode: +# → Plugin: ~/.config/opencode/plugins/rtk-rewrite.ts +# → Context: ~/.config/opencode/RTK.md (10 lines) +# → No settings.json needed (plugins auto-load) # Verify installation -rtk init --show # Shows hook status, settings.json registration +rtk init --show # Shows hook/plugin status for both agents ``` -**Settings.json Patching Options**: +#### Claude Code-Specific Options + ```bash rtk init -g # Default: prompts for consent [y/N] -rtk init -g --auto-patch # Patch immediately without prompting (CI/CD) +rtk init -g --auto-patch # Patch settings.json without prompting (CI/CD) rtk init -g --no-patch # Skip patching, print manual JSON snippet ``` **What is settings.json?** -Claude Code's configuration file that registers the RTK hook. Without this, Claude won't use the hook. RTK backs up the file before changes (`settings.json.bak`). +Claude Code's hook registry. RTK adds a PreToolUse hook entry that triggers command rewriting. Without this registration, the hook won't run. RTK backs up the file before changes (`settings.json.bak`). -**Restart Required**: After installation, restart Claude Code, then test with `git status`. +**Restart Required**: After installation, restart your AI agent, then test with `git status`. ### Manual Install (Fallback) -If automatic patching fails or you prefer manual control: - -```bash -# 1. Install hook and RTK.md -rtk init -g --no-patch # Prints JSON snippet - -# 2. Manually edit ~/.claude/settings.json (add the printed snippet) - -# 3. Restart Claude Code -``` - -**Alternative: Full manual setup** +#### Claude Code ```bash # 1. Copy the hook script mkdir -p ~/.claude/hooks -cp .claude/hooks/rtk-rewrite.sh ~/.claude/hooks/rtk-rewrite.sh +cp hooks/rtk-rewrite.sh ~/.claude/hooks/rtk-rewrite.sh chmod +x ~/.claude/hooks/rtk-rewrite.sh # 2. Add to ~/.claude/settings.json under hooks.PreToolUse: ``` -Add this entry to the `PreToolUse` array in `~/.claude/settings.json`: - ```json { "hooks": { @@ -600,9 +591,18 @@ Add this entry to the `PreToolUse` array in `~/.claude/settings.json`: } ``` +#### OpenCode + +```bash +# Copy the plugin (auto-loaded from plugins directory) +mkdir -p ~/.config/opencode/plugins +cp hooks/rtk-rewrite.ts ~/.config/opencode/plugins/rtk-rewrite.ts +# No config file changes needed — OpenCode auto-loads plugins +``` + ### Per-Project Install -The hook is included in this repository at `.claude/hooks/rtk-rewrite.sh`. To use it in another project, copy the hook and add the same settings.json entry using a relative path or project-level `.claude/settings.json`. +The hook/plugin files are included in this repository under `hooks/`. To use in another project, copy the appropriate file for your agent. ### Commands Rewritten @@ -632,27 +632,27 @@ The hook is included in this repository at `.claude/hooks/rtk-rewrite.sh`. To us Commands already using `rtk`, heredocs (`<<`), and unrecognized commands pass through unchanged. -### Alternative: Suggest Hook (Non-Intrusive) +### Alternative: Suggest Hook (Non-Intrusive, Claude Code Only) -If you prefer Claude Code to **suggest** rtk usage rather than automatically rewriting commands, use the **suggest hook** pattern instead. This emits a system reminder when rtk-compatible commands are detected, without modifying the command execution. +If you prefer the agent to **suggest** rtk usage rather than automatically rewriting commands, use the **suggest hook** pattern instead. This emits a system reminder when rtk-compatible commands are detected, without modifying the command execution. (Currently only available for Claude Code.) **Comparison**: -| Aspect | Auto-Rewrite Hook | Suggest Hook | -|--------|-------------------|--------------| +| Aspect | Auto-Rewrite | Suggest Hook | +|--------|-------------|--------------| | **Strategy** | Intercepts and modifies command before execution | Emits system reminder when rtk-compatible command detected | -| **Effect** | Claude Code never sees the original command | Claude Code receives hint to use rtk, decides autonomously | -| **Adoption** | 100% (forced) | ~70-85% (depends on Claude Code's adherence to instructions) | +| **Effect** | Agent never sees the original command | Agent receives hint to use rtk, decides autonomously | +| **Adoption** | 100% (forced) | ~70-85% (depends on agent adherence to instructions) | | **Use Case** | Production workflows, guaranteed savings | Learning mode, auditing, user preference for explicit control | | **Overhead** | Zero (transparent rewrite) | Minimal (reminder message in context) | **When to use suggest over rewrite**: -- You want to audit which commands Claude Code chooses to run +- You want to audit which commands the agent chooses to run - You're learning rtk patterns and want visibility into the rewrite logic -- You prefer Claude Code to make explicit decisions rather than transparent rewrites +- You prefer the agent to make explicit decisions rather than transparent rewrites - You want to preserve exact command execution for debugging -#### Suggest Hook Setup +#### Suggest Hook Setup (Claude Code) **1. Create the suggest hook script** @@ -682,7 +682,7 @@ chmod +x ~/.claude/hooks/rtk-suggest.sh } ``` -The suggest hook detects the same commands as the rewrite hook but outputs a `systemMessage` instead of `updatedInput`, informing Claude Code that an rtk alternative exists. +The suggest hook detects the same commands as the rewrite hook but outputs a `systemMessage` instead of `updatedInput`, informing the agent that an rtk alternative exists. ## Uninstalling RTK @@ -690,21 +690,28 @@ The suggest hook detects the same commands as the rewrite hook but outputs a `sy ```bash rtk init -g --uninstall -# Removes: +# Removes (per detected agent): +# +# Claude Code: # - ~/.claude/hooks/rtk-rewrite.sh # - ~/.claude/RTK.md # - @RTK.md reference from ~/.claude/CLAUDE.md # - RTK hook entry from ~/.claude/settings.json +# +# OpenCode: +# - ~/.config/opencode/plugins/rtk-rewrite.ts +# - ~/.config/opencode/RTK.md +# - @RTK.md reference from ~/.config/opencode/AGENTS.md -# Restart Claude Code after uninstall +# Restart your AI agent after uninstall ``` -**Restore from Backup** (if needed): +**Restore from Backup** (Claude Code, if needed): ```bash cp ~/.claude/settings.json.bak ~/.claude/settings.json ``` -**Local Projects**: Manually remove RTK instructions from `./CLAUDE.md` +**Local Projects**: Manually remove RTK instructions from `./CLAUDE.md` or `./AGENTS.md` **Binary Removal**: ```bash @@ -722,7 +729,7 @@ sudo dnf remove rtk # Fedora/RHEL - **[TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)** - ⚠️ Fix common issues (wrong rtk installed, missing commands, PATH issues) - **[INSTALL.md](INSTALL.md)** - Detailed installation guide with verification steps - **[AUDIT_GUIDE.md](docs/AUDIT_GUIDE.md)** - Complete guide to token savings analytics, temporal breakdowns, and data export -- **[CLAUDE.md](CLAUDE.md)** - Claude Code integration instructions and project context +- **[CLAUDE.md](CLAUDE.md)** - AI agent integration instructions and project context - **[ARCHITECTURE.md](ARCHITECTURE.md)** - Technical architecture and development guide - **[SECURITY.md](SECURITY.md)** - Security policy, vulnerability reporting, and PR review process @@ -748,19 +755,22 @@ ls -la ~/.claude/settings.json chmod 644 ~/.claude/settings.json ``` -### Hook Not Working After Install +### Hook/Plugin Not Working After Install **Problem**: Commands still not using RTK after `rtk init -g` **Solutions**: ```bash -# Verify hook is registered +# Verify hook/plugin is registered rtk init --show -# Check settings.json manually +# For Claude Code: check settings.json manually cat ~/.claude/settings.json | grep rtk-rewrite -# Restart Claude Code (critical step!) +# For OpenCode: check plugin exists +ls ~/.config/opencode/plugins/rtk-rewrite.ts + +# Restart your AI agent (critical step!) # Test with a command git status # Should use rtk automatically @@ -770,7 +780,7 @@ git status # Should use rtk automatically **Problem**: RTK traces remain after `rtk init -g --uninstall` -**Manual Cleanup**: +**Manual Cleanup (Claude Code)**: ```bash # Remove hook rm ~/.claude/hooks/rtk-rewrite.sh @@ -788,6 +798,18 @@ nano ~/.claude/settings.json # Remove RTK hook entry cp ~/.claude/settings.json.bak ~/.claude/settings.json ``` +**Manual Cleanup (OpenCode)**: +```bash +# Remove plugin +rm ~/.config/opencode/plugins/rtk-rewrite.ts + +# Remove RTK.md +rm ~/.config/opencode/RTK.md + +# Remove @RTK.md reference +nano ~/.config/opencode/AGENTS.md # Delete @RTK.md line +``` + See **[TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)** for more issues and solutions. ## For Maintainers @@ -806,8 +828,8 @@ Every PR triggers `.github/workflows/security-check.yml`: Results appear in the PR's GitHub Actions summary. -#### Layer 2: Claude Code Skill -For comprehensive manual review, maintainers with [Claude Code](https://claude.ai/code) can use: +#### Layer 2: AI Agent Skill +For comprehensive manual review, maintainers with [Claude Code](https://claude.ai/code) or OpenCode can use: ```bash /rtk-pr-security diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 64d45763..52bc9076 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -49,7 +49,7 @@ If `rtk gain` now works, installation is correct. | Project | Repository | Purpose | Key Command | |---------|-----------|---------|-------------| -| **Rust Token Killer** ✅ | rtk-ai/rtk | LLM token optimizer for Claude Code | `rtk gain` | +| **Rust Token Killer** ✅ | rtk-ai/rtk | LLM token optimizer for AI coding agents | `rtk gain` | | **Rust Type Kit** ❌ | reachingforthejack/rtk | Rust codebase query and type generator | `rtk query` | ### How to Identify Which One You Have @@ -91,10 +91,10 @@ rtk gain # Must work if you want Token Killer --- -## Problem: RTK not working in Claude Code +## Problem: RTK not working in your AI agent ### Symptom -Claude Code doesn't seem to be using rtk, outputs are verbose. +Your AI agent (Claude Code or OpenCode) doesn't seem to be using rtk, outputs are verbose. ### Checklist @@ -104,26 +104,34 @@ rtk --version rtk gain # Must show stats ``` -**2. Initialize rtk for Claude Code:** +**2. Initialize rtk for your AI agent:** ```bash # Global (all projects) rtk init --global +# → Auto-detects Claude Code or OpenCode # Per-project cd /your/project rtk init ``` -**3. Verify CLAUDE.md file exists:** +**3. Verify rules file exists:** ```bash -# Check global +# Check global (Claude Code) cat ~/.claude/CLAUDE.md | grep rtk +# Check global (OpenCode) +cat ~/.config/opencode/AGENTS.md | grep rtk + # Check project cat ./CLAUDE.md | grep rtk +# or +cat ./AGENTS.md | grep rtk ``` -**4. Install auto-rewrite hook (recommended for automatic RTK usage):** +**4. Install auto-rewrite hook/plugin (recommended for automatic RTK usage):** + +#### Claude Code **Option A: Automatic (recommended)** ```bash @@ -165,6 +173,28 @@ Then add to `~/.claude/settings.json` (replace `~` with full path): **Note**: Use absolute path in `settings.json`, not `~/.claude/...` +#### OpenCode + +**Automatic (recommended):** +```bash +rtk init -g +# → Installs plugin to ~/.config/opencode/plugins/rtk-rewrite.ts +# → Plugin is auto-loaded by OpenCode (no config file changes needed) +# → Restart OpenCode + +# Verify installation +rtk init --show # Should show plugin status +``` + +**Manual (fallback):** +```bash +# Copy plugin to OpenCode plugins directory +mkdir -p ~/.config/opencode/plugins +cp hooks/rtk-rewrite.ts ~/.config/opencode/plugins/rtk-rewrite.ts +``` + +No config file changes needed — OpenCode auto-loads all plugins from the plugins directory. + --- ## Problem: "command not found: rtk" after installation @@ -270,7 +300,30 @@ This script will check: - ✅ RTK installed and in PATH - ✅ Correct version (Token Killer, not Type Kit) - ✅ Available features (pnpm, vitest, next, etc.) -- ✅ Claude Code integration (CLAUDE.md files) -- ✅ Auto-rewrite hook status +- ✅ AI agent integration (CLAUDE.md / AGENTS.md files) +- ✅ Auto-rewrite hook/plugin status The script provides specific fix commands for any issues found. + +--- + +## Known Limitation: Git -C and -c Flags + +### Symptom +When using Claude Code or OpenCode, you might see raw `git -C /path/to/repo status` commands instead of the RTK-optimized version. + +### Root Cause +RTK's auto-rewrite hooks currently do not support git's `-C` (change directory) and `-c` (config) flags. These are global git options that must appear before the subcommand. + +**Example:** +```bash +git -C /path/to/repo status # ← Not rewritten to rtk +git status # ← Correctly rewritten to rtk git status +``` + +### Workaround +The agent will automatically use raw git commands when working in directories that require `-C`. This doesn't affect functionality, but you won't get token savings on these operations. + +### Future Fix +Full support for `-C` and `-c` flags is planned for a future release. See issue tracking for updates. + diff --git a/hooks/rtk-awareness.md b/hooks/rtk-awareness.md index 0eaf3d52..410ab5de 100644 --- a/hooks/rtk-awareness.md +++ b/hooks/rtk-awareness.md @@ -7,7 +7,7 @@ ```bash rtk gain # Show token savings analytics rtk gain --history # Show command usage history with savings -rtk discover # Analyze Claude Code history for missed opportunities +rtk discover # Analyze session history for missed opportunities rtk proxy # Execute raw command without filtering (for debugging) ``` @@ -23,7 +23,7 @@ which rtk # Verify correct binary ## Hook-Based Usage -All other commands are automatically rewritten by the Claude Code hook. +All other commands are automatically rewritten by the installed hook/plugin. Example: `git status` → `rtk git status` (transparent, 0 tokens overhead) -Refer to CLAUDE.md for full command reference. +Refer to CLAUDE.md or AGENTS.md for full command reference. diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index 59e02caa..9fc5d3c9 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -21,10 +21,12 @@ fi # We only rewrite if the FIRST command in a chain matches. FIRST_CMD="$CMD" -# Skip if already using rtk -case "$FIRST_CMD" in - rtk\ *|*/rtk\ *) exit 0 ;; -esac +# Skip if already using rtk as the actual command (not just in a path) +# Check the basename of the first word before the first space +FIRST_WORD=$(echo "$FIRST_CMD" | sed -E 's/^([^[:space:]]+).*/\1/') +if [ "$(basename "$FIRST_WORD")" = "rtk" ]; then + exit 0 +fi # Skip commands with heredocs, variable assignments as the whole command, etc. case "$FIRST_CMD" in @@ -47,9 +49,13 @@ REWRITTEN="" # --- Git commands --- if echo "$MATCH_CMD" | grep -qE '^git[[:space:]]'; then + # Skip git commands with -C or -c flags (not yet supported by RTK) + if echo "$MATCH_CMD" | grep -qE 'git[[:space:]]+.*(-C|-c)[[:space:]]'; then + exit 0 + fi + GIT_SUBCMD=$(echo "$MATCH_CMD" | sed -E \ -e 's/^git[[:space:]]+//' \ - -e 's/(-C|-c)[[:space:]]+[^[:space:]]+[[:space:]]*//g' \ -e 's/--[a-z-]+=[^[:space:]]+[[:space:]]*//g' \ -e 's/--(no-pager|no-optional-locks|bare|literal-pathspecs)[[:space:]]*//g' \ -e 's/^[[:space:]]+//') diff --git a/hooks/rtk-rewrite.ts b/hooks/rtk-rewrite.ts new file mode 100644 index 00000000..51a852c2 --- /dev/null +++ b/hooks/rtk-rewrite.ts @@ -0,0 +1,229 @@ +// RTK auto-rewrite plugin for OpenCode +// Transparently rewrites raw commands to their rtk equivalents. +// Uses tool.execute.before to modify Bash commands before execution. +// +// Equivalent to hooks/rtk-rewrite.sh for Claude Code. + +export const RtkRewrite = async () => { + return { + "tool.execute.before": async ( + input: { tool: string; args: Record }, + output: { args: Record }, + ) => { + if (input.tool !== "bash") return; + + const command = output.args.command; + if (typeof command !== "string" || !command) return; + + const rewritten = rewriteCommand(command); + if (rewritten) { + output.args.command = rewritten; + } + }, + }; +}; + +/** + * Attempt to rewrite a command to use rtk. + * Returns the rewritten command, or null if no rewrite is needed. + */ +function rewriteCommand(cmd: string): string | null { + // Skip if already using rtk as the actual command (not just in a path) + // Check the basename of the first word before the first space + const firstWord = cmd.split(/\s/)[0]; + const basename = firstWord.split("/").pop(); + if (basename === "rtk") return null; + + // Skip heredocs + if (cmd.includes("<<")) return null; + + // Strip leading env var assignments for matching + // e.g., "TEST_SESSION_ID=2 npx playwright test" → match against "npx playwright test" + const envPrefixMatch = cmd.match(/^(?:[A-Za-z_][A-Za-z0-9_]*=[^\s]*\s+)+/); + const envPrefix = envPrefixMatch ? envPrefixMatch[0] : ""; + const matchCmd = envPrefix ? cmd.slice(envPrefix.length) : cmd; + const cmdBody = matchCmd; // The part after env prefix + + // --- Git commands --- + if (/^git\s/.test(matchCmd)) { + // Skip git commands with -C or -c flags (not yet supported by RTK) + if (/git\s+.*(-C|-c)\s/.test(matchCmd)) { + return null; + } + + // Strip git options (--no-pager, etc.) for subcommand matching + const gitSub = matchCmd + .replace(/^git\s+/, "") + .replace(/--[a-z-]+=\S+\s*/g, "") + .replace(/--(no-pager|no-optional-locks|bare|literal-pathspecs)\s*/g, "") + .trimStart(); + + if ( + /^(status|diff|log|add|commit|push|pull|branch|fetch|stash|show)(\s|$)/.test(gitSub) + ) { + return `${envPrefix}rtk ${cmdBody}`; + } + return null; + } + + // --- GitHub CLI --- + if (/^gh\s+(pr|issue|run|api|release)(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^gh /, "rtk gh ")}`; + } + + // --- Cargo --- + if (/^cargo\s/.test(matchCmd)) { + const cargoSub = matchCmd + .replace(/^cargo\s+(\+\S+\s+)?/, ""); + if (/^(test|build|clippy|check|install|fmt)(\s|$)/.test(cargoSub)) { + return `${envPrefix}rtk ${cmdBody}`; + } + return null; + } + + // --- File operations --- + if (/^cat\s+/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^cat /, "rtk read ")}`; + } + if (/^(rg|grep)\s+/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^(rg|grep) /, "rtk grep ")}`; + } + if (/^ls(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^ls/, "rtk ls")}`; + } + if (/^tree(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^tree/, "rtk tree")}`; + } + if (/^find\s+/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^find /, "rtk find ")}`; + } + if (/^diff\s+/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^diff /, "rtk diff ")}`; + } + if (/^head\s+/.test(matchCmd)) { + // head -N file → rtk read file --max-lines N + const dashN = matchCmd.match(/^head\s+-(\d+)\s+(.+)$/); + if (dashN) { + return `${envPrefix}rtk read ${dashN[2]} --max-lines ${dashN[1]}`; + } + const longLines = matchCmd.match(/^head\s+--lines=(\d+)\s+(.+)$/); + if (longLines) { + return `${envPrefix}rtk read ${longLines[2]} --max-lines ${longLines[1]}`; + } + return null; + } + + // --- JS/TS tooling --- + if (/^(pnpm\s+)?(npx\s+)?vitest(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^(pnpm )?(npx )?vitest( run)?/, "rtk vitest run")}`; + } + if (/^pnpm\s+test(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^pnpm test/, "rtk vitest run")}`; + } + if (/^npm\s+test(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^npm test/, "rtk npm test")}`; + } + if (/^npm\s+run\s+/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^npm run /, "rtk npm ")}`; + } + if (/^(npx\s+)?vue-tsc(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^(npx )?vue-tsc/, "rtk tsc")}`; + } + if (/^pnpm\s+tsc(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^pnpm tsc/, "rtk tsc")}`; + } + if (/^(npx\s+)?tsc(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^(npx )?tsc/, "rtk tsc")}`; + } + if (/^pnpm\s+lint(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^pnpm lint/, "rtk lint")}`; + } + if (/^(npx\s+)?eslint(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^(npx )?eslint/, "rtk lint")}`; + } + if (/^(npx\s+)?prettier(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^(npx )?prettier/, "rtk prettier")}`; + } + if (/^(npx\s+)?playwright(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^(npx )?playwright/, "rtk playwright")}`; + } + if (/^pnpm\s+playwright(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^pnpm playwright/, "rtk playwright")}`; + } + if (/^(npx\s+)?prisma(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^(npx )?prisma/, "rtk prisma")}`; + } + + // --- Containers --- + if (/^docker\s/.test(matchCmd)) { + if (/^docker\s+compose(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^docker /, "rtk docker ")}`; + } + const dockerSub = matchCmd + .replace(/^docker\s+/, "") + .replace(/(-H|--context|--config)\s+\S+\s*/g, "") + .replace(/--[a-z-]+=\S+\s*/g, "") + .trimStart(); + if (/^(ps|images|logs|run|build|exec)(\s|$)/.test(dockerSub)) { + return `${envPrefix}${cmdBody.replace(/^docker /, "rtk docker ")}`; + } + return null; + } + if (/^kubectl\s/.test(matchCmd)) { + const kubeSub = matchCmd + .replace(/^kubectl\s+/, "") + .replace(/(--context|--kubeconfig|--namespace|-n)\s+\S+\s*/g, "") + .replace(/--[a-z-]+=\S+\s*/g, "") + .trimStart(); + if (/^(get|logs|describe|apply)(\s|$)/.test(kubeSub)) { + return `${envPrefix}${cmdBody.replace(/^kubectl /, "rtk kubectl ")}`; + } + return null; + } + + // --- Network --- + if (/^curl\s+/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^curl /, "rtk curl ")}`; + } + if (/^wget\s+/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^wget /, "rtk wget ")}`; + } + + // --- pnpm package management --- + if (/^pnpm\s+(list|ls|outdated)(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^pnpm /, "rtk pnpm ")}`; + } + + // --- Python tooling --- + if (/^pytest(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^pytest/, "rtk pytest")}`; + } + if (/^python\s+-m\s+pytest(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^python -m pytest/, "rtk pytest")}`; + } + if (/^ruff\s+(check|format)(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^ruff /, "rtk ruff ")}`; + } + if (/^pip\s+(list|outdated|install|show)(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^pip /, "rtk pip ")}`; + } + if (/^uv\s+pip\s+(list|outdated|install|show)(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^uv pip /, "rtk pip ")}`; + } + + // --- Go tooling --- + if (/^go\s+test(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^go test/, "rtk go test")}`; + } + if (/^go\s+build(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^go build/, "rtk go build")}`; + } + if (/^go\s+vet(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^go vet/, "rtk go vet")}`; + } + if (/^golangci-lint(\s|$)/.test(matchCmd)) { + return `${envPrefix}${cmdBody.replace(/^golangci-lint/, "rtk golangci-lint")}`; + } + + return null; +} diff --git a/src/discover/mod.rs b/src/discover/mod.rs index a8cee127..d2fce6c5 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -5,7 +5,8 @@ mod report; use anyhow::Result; use std::collections::HashMap; -use provider::{ClaudeProvider, SessionProvider}; +use crate::platform::{detect_platform, AgentPlatform}; +use provider::{ClaudeProvider, OpenCodeProvider, SessionProvider}; use registry::{category_avg_tokens, classify_command, split_command_chain, Classification}; use report::{DiscoverReport, SupportedEntry, UnsupportedEntry}; @@ -32,9 +33,76 @@ pub fn run( since_days: u64, limit: usize, format: &str, + platform_filter: &str, verbose: u8, ) -> Result<()> { - let provider = ClaudeProvider; + // Determine which platforms to scan + let platforms = match platform_filter { + "claude" => vec![AgentPlatform::ClaudeCode], + "opencode" => vec![AgentPlatform::OpenCode], + "both" => vec![AgentPlatform::ClaudeCode, AgentPlatform::OpenCode], + other => { + anyhow::bail!( + "Invalid platform filter '{}'. Use: claude, opencode, or both", + other + ); + } + }; + + // Aggregate results across all platforms + let mut all_supported_buckets: HashMap<&'static str, SupportedBucket> = HashMap::new(); + let mut all_unsupported_buckets: HashMap = HashMap::new(); + let mut total_sessions = 0; + let mut total_commands = 0; + let mut total_rtk_commands = 0; + + for platform in platforms { + if let Err(e) = scan_platform( + platform, + project, + all, + since_days, + &mut all_supported_buckets, + &mut all_unsupported_buckets, + &mut total_sessions, + &mut total_commands, + &mut total_rtk_commands, + ) { + if verbose > 0 { + eprintln!("Warning: Failed to scan {}: {}", platform.name(), e); + } + // Continue scanning other platforms + } + } + + // Generate report from aggregated results + generate_report( + all_supported_buckets, + all_unsupported_buckets, + total_sessions, + total_commands, + total_rtk_commands, + limit, + format, + ) +} + +fn scan_platform( + platform: AgentPlatform, + project: Option<&str>, + all: bool, + since_days: u64, + supported_buckets: &mut HashMap<&'static str, SupportedBucket>, + unsupported_buckets: &mut HashMap, + total_sessions: &mut usize, + total_commands: &mut usize, + total_rtk_commands: &mut usize, +) -> Result<()> { + // Create the appropriate provider + let provider: Box = match platform { + AgentPlatform::ClaudeCode => Box::new(ClaudeProvider), + AgentPlatform::OpenCode => Box::new(OpenCodeProvider), + }; // Determine project filter let project_filter = if all { @@ -45,33 +113,22 @@ pub fn run( // Default: current working directory let cwd = std::env::current_dir()?; let cwd_str = cwd.to_string_lossy().to_string(); - let encoded = ClaudeProvider::encode_project_path(&cwd_str); - Some(encoded) + match platform { + // Claude Code encodes paths: /Users/foo/bar → -Users-foo-bar + AgentPlatform::ClaudeCode => Some(ClaudeProvider::encode_project_path(&cwd_str)), + // OpenCode uses raw directory paths in session.directory + AgentPlatform::OpenCode => Some(cwd_str), + } }; let sessions = provider.discover_sessions(project_filter.as_deref(), Some(since_days))?; - - if verbose > 0 { - eprintln!("Scanning {} session files...", sessions.len()); - for s in &sessions { - eprintln!(" {}", s.display()); - } - } - - let mut total_commands: usize = 0; - let mut already_rtk: usize = 0; - let mut parse_errors: usize = 0; - let mut supported_map: HashMap<&'static str, SupportedBucket> = HashMap::new(); - let mut unsupported_map: HashMap = HashMap::new(); + *total_sessions += sessions.len(); for session_path in &sessions { let extracted = match provider.extract_commands(session_path) { Ok(cmds) => cmds, - Err(e) => { - if verbose > 0 { - eprintln!("Warning: skipping {}: {}", session_path.display(), e); - } - parse_errors += 1; + Err(_e) => { + // Skip this session on error, continue with others continue; } }; @@ -79,7 +136,7 @@ pub fn run( for ext_cmd in &extracted { let parts = split_command_chain(&ext_cmd.command); for part in parts { - total_commands += 1; + *total_commands += 1; match classify_command(part) { Classification::Supported { @@ -88,7 +145,7 @@ pub fn run( estimated_savings_pct, status, } => { - let bucket = supported_map.entry(rtk_equivalent).or_insert_with(|| { + let bucket = supported_buckets.entry(rtk_equivalent).or_insert_with(|| { SupportedBucket { rtk_equivalent, category, @@ -124,7 +181,7 @@ pub fn run( *entry += 1; } Classification::Unsupported { base_command } => { - let bucket = unsupported_map.entry(base_command).or_insert_with(|| { + let bucket = unsupported_buckets.entry(base_command).or_insert_with(|| { UnsupportedBucket { count: 0, example: part.to_string(), @@ -135,7 +192,7 @@ pub fn run( Classification::Ignored => { // Check if it starts with "rtk " if part.trim().starts_with("rtk ") { - already_rtk += 1; + *total_rtk_commands += 1; } // Otherwise just skip } @@ -144,6 +201,18 @@ pub fn run( } } + Ok(()) +} + +fn generate_report( + supported_map: HashMap<&'static str, SupportedBucket>, + unsupported_map: HashMap, + total_sessions: usize, + total_commands: usize, + already_rtk: usize, + limit: usize, + format: &str, +) -> Result<()> { // Build report let mut supported: Vec = supported_map .into_values() @@ -198,18 +267,18 @@ pub fn run( unsupported.sort_by(|a, b| b.count.cmp(&a.count)); let report = DiscoverReport { - sessions_scanned: sessions.len(), + sessions_scanned: total_sessions, total_commands, already_rtk, - since_days, + since_days: 0, // We don't track this in aggregated results supported, unsupported, - parse_errors, + parse_errors: 0, // We don't track this in aggregated results }; match format { "json" => println!("{}", report::format_json(&report)), - _ => print!("{}", report::format_text(&report, limit, verbose > 0)), + _ => print!("{}", report::format_text(&report, limit, false)), } Ok(()) diff --git a/src/discover/provider.rs b/src/discover/provider.rs index e9218b2d..973f5002 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use rusqlite::Connection; use std::collections::HashMap; use std::fs; use std::io::{BufRead, BufReader}; @@ -234,6 +235,188 @@ impl SessionProvider for ClaudeProvider { } } +pub struct OpenCodeProvider; + +impl OpenCodeProvider { + /// Get the path to the OpenCode SQLite database. + fn db_path() -> Result { + let home = dirs::home_dir().context("could not determine home directory")?; + let db = home + .join(".local") + .join("share") + .join("opencode") + .join("opencode.db"); + if !db.exists() { + anyhow::bail!( + "OpenCode database not found: {}\nMake sure OpenCode has been used at least once.", + db.display() + ); + } + Ok(db) + } + + /// Open a read-only connection to an SQLite database at the given path. + fn open_db(path: &Path) -> Result { + let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .with_context(|| format!("failed to open database: {}", path.display()))?; + Ok(conn) + } + + /// Extract bash commands from a single session in the database. + fn extract_commands_from_session( + conn: &Connection, + session_id: &str, + ) -> Result> { + let mut stmt = conn + .prepare( + "SELECT data FROM part \ + WHERE session_id = ?1 \ + AND json_extract(data, '$.tool') = 'bash' \ + AND json_extract(data, '$.type') = 'tool' \ + ORDER BY time_created ASC", + ) + .context("failed to prepare part query")?; + + let mut commands = Vec::new(); + let mut sequence_counter = 0usize; + + let rows = stmt + .query_map([session_id], |row| { + let data: String = row.get(0)?; + Ok(data) + }) + .context("failed to query parts")?; + + for row in rows { + let data = match row { + Ok(d) => d, + Err(_) => continue, + }; + + let entry: serde_json::Value = match serde_json::from_str(&data) { + Ok(v) => v, + Err(_) => continue, + }; + + // Extract command from state.input.command + let command = match entry + .pointer("/state/input/command") + .and_then(|c| c.as_str()) + { + Some(cmd) => cmd.to_string(), + None => continue, + }; + + // Extract output content and length + let output = entry + .pointer("/state/output") + .and_then(|o| o.as_str()) + .unwrap_or(""); + let output_len = Some(output.len()); + + // First ~1000 chars for error detection + let output_content: String = output.chars().take(1000).collect(); + let output_content = if output_content.is_empty() { + None + } else { + Some(output_content) + }; + + // Extract exit code: non-zero = error + let exit_code = entry + .pointer("/state/metadata/exit") + .and_then(|e| e.as_i64()) + .unwrap_or(0); + let is_error = exit_code != 0; + + commands.push(ExtractedCommand { + command, + output_len, + session_id: session_id.to_string(), + output_content, + is_error, + sequence_index: sequence_counter, + }); + sequence_counter += 1; + } + + Ok(commands) + } +} + +impl SessionProvider for OpenCodeProvider { + fn discover_sessions( + &self, + project_filter: Option<&str>, + since_days: Option, + ) -> Result> { + let db_path = Self::db_path()?; + let conn = Self::open_db(&db_path)?; + + let cutoff_ms = since_days.map(|days| { + let now_ms = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + now_ms - (days as i64 * 86400 * 1000) + }); + + // Query sessions, optionally filtering by project directory and time + // We return PathBuf-encoded session IDs as "virtual paths" since + // the SessionProvider trait uses PathBuf to identify sessions. + // Format: db_path + "#" + session_id (parsed back in extract_commands) + let mut query = + String::from("SELECT s.id, s.directory, s.time_created FROM session s WHERE 1=1"); + let mut params: Vec> = Vec::new(); + + if let Some(filter) = project_filter { + query.push_str(" AND s.directory LIKE ?"); + params.push(Box::new(format!("%{}%", filter))); + } + + if let Some(cutoff) = cutoff_ms { + query.push_str(" AND s.time_created >= ?"); + params.push(Box::new(cutoff)); + } + + let mut stmt = conn + .prepare(&query) + .context("failed to prepare session query")?; + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + + let rows = stmt + .query_map(param_refs.as_slice(), |row| { + let id: String = row.get(0)?; + Ok(id) + }) + .context("failed to query sessions")?; + + let mut sessions = Vec::new(); + for session_id in rows.flatten() { + // Encode as virtual path: "db_path#session_id" + let virtual_path = format!("{}#{}", db_path.display(), session_id); + sessions.push(PathBuf::from(virtual_path)); + } + + Ok(sessions) + } + + fn extract_commands(&self, path: &Path) -> Result> { + // Parse virtual path format: "db_path#session_id" + let path_str = path.to_string_lossy(); + let (db_path_str, session_id) = path_str + .rsplit_once('#') + .context("invalid OpenCode session path (expected db_path#session_id)")?; + + let db_path = Path::new(db_path_str); + let conn = Self::open_db(db_path)?; + + Self::extract_commands_from_session(&conn, session_id) + } +} + #[cfg(test)] mod tests { use super::*; @@ -347,7 +530,7 @@ mod tests { let cmds = provider.extract_commands(jsonl.path()).unwrap(); assert_eq!(cmds.len(), 1); assert_eq!(cmds[0].command, "git commit --ammend"); - assert_eq!(cmds[0].is_error, true); + assert!(cmds[0].is_error); assert!(cmds[0].output_content.is_some()); assert_eq!( cmds[0].output_content.as_ref().unwrap(), @@ -365,8 +548,8 @@ mod tests { let provider = ClaudeProvider; let cmds = provider.extract_commands(jsonl.path()).unwrap(); assert_eq!(cmds.len(), 2); - assert_eq!(cmds[0].is_error, false); - assert_eq!(cmds[1].is_error, true); + assert!(!cmds[0].is_error); + assert!(cmds[1].is_error); } #[test] @@ -385,4 +568,274 @@ mod tests { assert_eq!(cmds[1].command, "second"); assert_eq!(cmds[2].command, "third"); } + + // --- OpenCodeProvider tests --- + + /// Create a temporary SQLite database mimicking the OpenCode schema. + fn make_opencode_db() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("opencode.db"); + let conn = Connection::open(&db_path).unwrap(); + + conn.execute_batch( + "CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + sandboxes TEXT NOT NULL DEFAULT '[]' + ); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL DEFAULT '', + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES project(id) + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES session(id) + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL, + FOREIGN KEY (message_id) REFERENCES message(id) + );", + ) + .unwrap(); + + (dir, db_path) + } + + fn insert_project(conn: &Connection, id: &str, worktree: &str) { + conn.execute( + "INSERT INTO project (id, worktree, time_created, time_updated) VALUES (?1, ?2, ?3, ?3)", + rusqlite::params![id, worktree, 1000000], + ) + .unwrap(); + } + + fn insert_session( + conn: &Connection, + id: &str, + project_id: &str, + directory: &str, + time_created: i64, + ) { + conn.execute( + "INSERT INTO session (id, project_id, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?4)", + rusqlite::params![id, project_id, directory, time_created], + ) + .unwrap(); + } + + fn insert_part( + conn: &Connection, + id: &str, + message_id: &str, + session_id: &str, + time_created: i64, + data: &str, + ) { + // Ensure a message row exists + let _ = conn.execute( + "INSERT OR IGNORE INTO message (id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?3, '{}')", + rusqlite::params![message_id, session_id, time_created], + ); + conn.execute( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?4, ?4, ?5)", + rusqlite::params![id, message_id, session_id, time_created, data], + ) + .unwrap(); + } + + #[test] + fn test_opencode_extract_bash_command() { + let (_dir, db_path) = make_opencode_db(); + let conn = Connection::open(&db_path).unwrap(); + + insert_project(&conn, "proj1", "/Users/foo/myproject"); + insert_session(&conn, "ses1", "proj1", "/Users/foo/myproject", 1000000); + + let data = r#"{"type":"tool","tool":"bash","callID":"call1","state":{"status":"completed","input":{"command":"git status"},"output":"On branch main\nnothing to commit","metadata":{"exit":0}}}"#; + insert_part(&conn, "part1", "msg1", "ses1", 1000001, data); + + let cmds = OpenCodeProvider::extract_commands_from_session(&conn, "ses1").unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "git status"); + assert_eq!( + cmds[0].output_len, + Some("On branch main\nnothing to commit".len()) + ); + assert!(!cmds[0].is_error); + assert_eq!(cmds[0].session_id, "ses1"); + assert_eq!(cmds[0].sequence_index, 0); + } + + #[test] + fn test_opencode_extract_error_command() { + let (_dir, db_path) = make_opencode_db(); + let conn = Connection::open(&db_path).unwrap(); + + insert_project(&conn, "proj1", "/Users/foo/myproject"); + insert_session(&conn, "ses1", "proj1", "/Users/foo/myproject", 1000000); + + let data = r#"{"type":"tool","tool":"bash","callID":"call1","state":{"status":"completed","input":{"command":"invalid_cmd"},"output":"command not found: invalid_cmd","metadata":{"exit":127}}}"#; + insert_part(&conn, "part1", "msg1", "ses1", 1000001, data); + + let cmds = OpenCodeProvider::extract_commands_from_session(&conn, "ses1").unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "invalid_cmd"); + assert!(cmds[0].is_error); + assert!(cmds[0] + .output_content + .as_ref() + .unwrap() + .contains("command not found")); + } + + #[test] + fn test_opencode_extract_multiple_commands() { + let (_dir, db_path) = make_opencode_db(); + let conn = Connection::open(&db_path).unwrap(); + + insert_project(&conn, "proj1", "/Users/foo/myproject"); + insert_session(&conn, "ses1", "proj1", "/Users/foo/myproject", 1000000); + + let data1 = r#"{"type":"tool","tool":"bash","callID":"call1","state":{"status":"completed","input":{"command":"ls"},"output":"file1.txt","metadata":{"exit":0}}}"#; + let data2 = r#"{"type":"tool","tool":"bash","callID":"call2","state":{"status":"completed","input":{"command":"git diff"},"output":"diff output","metadata":{"exit":0}}}"#; + insert_part(&conn, "part1", "msg1", "ses1", 1000001, data1); + insert_part(&conn, "part2", "msg2", "ses1", 1000002, data2); + + let cmds = OpenCodeProvider::extract_commands_from_session(&conn, "ses1").unwrap(); + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].command, "ls"); + assert_eq!(cmds[0].sequence_index, 0); + assert_eq!(cmds[1].command, "git diff"); + assert_eq!(cmds[1].sequence_index, 1); + } + + #[test] + fn test_opencode_ignores_non_bash_tools() { + let (_dir, db_path) = make_opencode_db(); + let conn = Connection::open(&db_path).unwrap(); + + insert_project(&conn, "proj1", "/Users/foo/myproject"); + insert_session(&conn, "ses1", "proj1", "/Users/foo/myproject", 1000000); + + // A non-bash tool (e.g., "read") should be ignored + let data = r#"{"type":"tool","tool":"read","callID":"call1","state":{"status":"completed","input":{"filePath":"/tmp/foo"},"output":"file contents"}}"#; + insert_part(&conn, "part1", "msg1", "ses1", 1000001, data); + + let cmds = OpenCodeProvider::extract_commands_from_session(&conn, "ses1").unwrap(); + assert_eq!(cmds.len(), 0); + } + + #[test] + fn test_opencode_ignores_non_tool_parts() { + let (_dir, db_path) = make_opencode_db(); + let conn = Connection::open(&db_path).unwrap(); + + insert_project(&conn, "proj1", "/Users/foo/myproject"); + insert_session(&conn, "ses1", "proj1", "/Users/foo/myproject", 1000000); + + // text-type part should be ignored + let data = r#"{"type":"text","text":"Let me check something..."}"#; + insert_part(&conn, "part1", "msg1", "ses1", 1000001, data); + + let cmds = OpenCodeProvider::extract_commands_from_session(&conn, "ses1").unwrap(); + assert_eq!(cmds.len(), 0); + } + + #[test] + fn test_opencode_empty_output() { + let (_dir, db_path) = make_opencode_db(); + let conn = Connection::open(&db_path).unwrap(); + + insert_project(&conn, "proj1", "/Users/foo/myproject"); + insert_session(&conn, "ses1", "proj1", "/Users/foo/myproject", 1000000); + + let data = r#"{"type":"tool","tool":"bash","callID":"call1","state":{"status":"completed","input":{"command":"true"},"output":"","metadata":{"exit":0}}}"#; + insert_part(&conn, "part1", "msg1", "ses1", 1000001, data); + + let cmds = OpenCodeProvider::extract_commands_from_session(&conn, "ses1").unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].output_len, Some(0)); + assert!(cmds[0].output_content.is_none()); // Empty string becomes None + } + + #[test] + fn test_opencode_discover_sessions_all() { + let (_dir, db_path) = make_opencode_db(); + let conn = Connection::open(&db_path).unwrap(); + + insert_project(&conn, "proj1", "/Users/foo/project-a"); + insert_project(&conn, "proj2", "/Users/foo/project-b"); + insert_session(&conn, "ses1", "proj1", "/Users/foo/project-a", 1000000); + insert_session(&conn, "ses2", "proj2", "/Users/foo/project-b", 2000000); + drop(conn); + + // We can't use OpenCodeProvider directly (it uses hardcoded db_path()), + // but we can test the virtual path format via extract_commands + let virtual_path = format!("{}#ses1", db_path.display()); + let provider = OpenCodeProvider; + let cmds = provider.extract_commands(Path::new(&virtual_path)).unwrap(); + assert_eq!(cmds.len(), 0); // No parts inserted + } + + #[test] + fn test_opencode_virtual_path_roundtrip() { + let (_dir, db_path) = make_opencode_db(); + let conn = Connection::open(&db_path).unwrap(); + + insert_project(&conn, "proj1", "/Users/foo/myproject"); + insert_session(&conn, "ses1", "proj1", "/Users/foo/myproject", 1000000); + + let data = r#"{"type":"tool","tool":"bash","callID":"call1","state":{"status":"completed","input":{"command":"echo hello"},"output":"hello","metadata":{"exit":0}}}"#; + insert_part(&conn, "part1", "msg1", "ses1", 1000001, data); + drop(conn); + + // Simulate what discover_sessions would produce + let virtual_path = format!("{}#ses1", db_path.display()); + + let provider = OpenCodeProvider; + let cmds = provider.extract_commands(Path::new(&virtual_path)).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "echo hello"); + } + + #[test] + fn test_opencode_extract_output_content_truncated() { + let (_dir, db_path) = make_opencode_db(); + let conn = Connection::open(&db_path).unwrap(); + + insert_project(&conn, "proj1", "/Users/foo/myproject"); + insert_session(&conn, "ses1", "proj1", "/Users/foo/myproject", 1000000); + + // Create output longer than 1000 chars + let long_output = "x".repeat(2000); + let data = format!( + r#"{{"type":"tool","tool":"bash","callID":"call1","state":{{"status":"completed","input":{{"command":"cat bigfile"}},"output":"{}","metadata":{{"exit":0}}}}}}"#, + long_output + ); + insert_part(&conn, "part1", "msg1", "ses1", 1000001, &data); + + let cmds = OpenCodeProvider::extract_commands_from_session(&conn, "ses1").unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].output_len, Some(2000)); + // output_content should be truncated to 1000 chars + assert_eq!(cmds[0].output_content.as_ref().unwrap().len(), 1000); + } } diff --git a/src/init.rs b/src/init.rs index 961e4ac3..4444dd97 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,12 +1,16 @@ +use crate::platform::AgentPlatform; use anyhow::{Context, Result}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; -// Embedded hook script (guards before set -euo pipefail) +// Embedded hook script for Claude Code (guards before set -euo pipefail) const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); +// Embedded plugin for OpenCode (TypeScript) +const REWRITE_PLUGIN: &str = include_str!("../hooks/rtk-rewrite.ts"); + // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md"); @@ -164,64 +168,162 @@ Overall average: **60-90% token reduction** on common development operations. "##; /// Main entry point for `rtk init` +/// Multi-platform wrapper for run() - installs to one or both platforms +pub fn run_multi_platform( + global: bool, + claude_md: bool, + hook_only: bool, + patch_mode: PatchMode, + platform_filter: &str, + verbose: u8, +) -> Result<()> { + let platforms = parse_platform_filter(platform_filter)?; + + for platform in platforms { + if let Err(e) = run(global, claude_md, hook_only, patch_mode, verbose, platform) { + eprintln!("Warning: Failed to install for {}: {}", platform.name(), e); + // Continue with other platforms + } + } + + Ok(()) +} + +/// Multi-platform wrapper for uninstall() - removes from one or both platforms +pub fn uninstall_multi_platform(global: bool, verbose: u8, platform_filter: &str) -> Result<()> { + let platforms = parse_platform_filter(platform_filter)?; + + for platform in platforms { + if let Err(e) = uninstall(global, verbose, platform) { + eprintln!( + "Warning: Failed to uninstall for {}: {}", + platform.name(), + e + ); + // Continue with other platforms + } + } + + Ok(()) +} + +/// Parse platform filter string into list of platforms +fn parse_platform_filter(filter: &str) -> Result> { + match filter { + "claude" => Ok(vec![AgentPlatform::ClaudeCode]), + "opencode" => Ok(vec![AgentPlatform::OpenCode]), + "both" => Ok(vec![AgentPlatform::ClaudeCode, AgentPlatform::OpenCode]), + other => { + anyhow::bail!( + "Invalid platform filter '{}'. Use: claude, opencode, or both", + other + ); + } + } +} + pub fn run( global: bool, claude_md: bool, hook_only: bool, patch_mode: PatchMode, verbose: u8, + platform: AgentPlatform, ) -> Result<()> { // Mode selection match (claude_md, hook_only) { - (true, _) => run_claude_md_mode(global, verbose), - (false, true) => run_hook_only_mode(global, patch_mode, verbose), - (false, false) => run_default_mode(global, patch_mode, verbose), + (true, _) => run_claude_md_mode(global, verbose, platform), + (false, true) => run_hook_only_mode(global, patch_mode, verbose, platform), + (false, false) => run_default_mode(global, patch_mode, verbose, platform), } } -/// Prepare hook directory and return paths (hook_dir, hook_path) -fn prepare_hook_paths() -> Result<(PathBuf, PathBuf)> { - let claude_dir = resolve_claude_dir()?; - let hook_dir = claude_dir.join("hooks"); - fs::create_dir_all(&hook_dir) - .with_context(|| format!("Failed to create hook directory: {}", hook_dir.display()))?; - let hook_path = hook_dir.join("rtk-rewrite.sh"); +/// Prepare hook/plugin directory and return paths (hook_dir, hook_path) +fn prepare_hook_paths(platform: AgentPlatform) -> Result<(PathBuf, PathBuf)> { + let config_dir = platform.config_dir()?; + let hook_dir = config_dir.join(platform.hook_subdir()); + fs::create_dir_all(&hook_dir).with_context(|| { + format!( + "Failed to create {} directory: {}", + platform.hook_mechanism(), + hook_dir.display() + ) + })?; + let hook_path = hook_dir.join(platform.hook_filename()); Ok((hook_dir, hook_path)) } -/// Write hook file if missing or outdated, return true if changed +/// Get the embedded hook/plugin content for a platform +fn hook_content(platform: AgentPlatform) -> &'static str { + match platform { + AgentPlatform::ClaudeCode => REWRITE_HOOK, + AgentPlatform::OpenCode => REWRITE_PLUGIN, + } +} + +/// Write hook/plugin file if missing or outdated, return true if changed #[cfg(unix)] -fn ensure_hook_installed(hook_path: &Path, verbose: u8) -> Result { +fn ensure_hook_installed(hook_path: &Path, verbose: u8, platform: AgentPlatform) -> Result { + let content = hook_content(platform); let changed = if hook_path.exists() { - let existing = fs::read_to_string(hook_path) - .with_context(|| format!("Failed to read existing hook: {}", hook_path.display()))?; + let existing = fs::read_to_string(hook_path).with_context(|| { + format!( + "Failed to read existing {}: {}", + platform.hook_mechanism(), + hook_path.display() + ) + })?; - if existing == REWRITE_HOOK { + if existing == content { if verbose > 0 { - eprintln!("Hook already up to date: {}", hook_path.display()); + eprintln!( + "{} already up to date: {}", + platform.hook_mechanism(), + hook_path.display() + ); } false } else { - fs::write(hook_path, REWRITE_HOOK) - .with_context(|| format!("Failed to write hook to {}", hook_path.display()))?; + fs::write(hook_path, content).with_context(|| { + format!( + "Failed to write {} to {}", + platform.hook_mechanism(), + hook_path.display() + ) + })?; if verbose > 0 { - eprintln!("Updated hook: {}", hook_path.display()); + eprintln!( + "Updated {}: {}", + platform.hook_mechanism(), + hook_path.display() + ); } true } } else { - fs::write(hook_path, REWRITE_HOOK) - .with_context(|| format!("Failed to write hook to {}", hook_path.display()))?; + fs::write(hook_path, content).with_context(|| { + format!( + "Failed to write {} to {}", + platform.hook_mechanism(), + hook_path.display() + ) + })?; if verbose > 0 { - eprintln!("Created hook: {}", hook_path.display()); + eprintln!( + "Created {}: {}", + platform.hook_mechanism(), + hook_path.display() + ); } true }; - // Set executable permissions - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(hook_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to set hook permissions: {}", hook_path.display()))?; + // Set executable permissions (only needed for Claude Code bash hook) + if platform == AgentPlatform::ClaudeCode { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(hook_path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Failed to set hook permissions: {}", hook_path.display()))?; + } Ok(changed) } @@ -310,8 +412,8 @@ fn prompt_user_consent(settings_path: &Path) -> Result { Ok(response == "y" || response == "yes") } -/// Print manual instructions for settings.json patching -fn print_manual_instructions(hook_path: &Path) { +/// Print manual instructions for settings.json patching (Claude Code only) +fn print_manual_instructions_claude(hook_path: &Path) { println!("\n MANUAL STEP: Add this to ~/.claude/settings.json:"); println!(" {{"); println!(" \"hooks\": {{ \"PreToolUse\": [{{"); @@ -324,6 +426,13 @@ fn print_manual_instructions(hook_path: &Path) { println!("\n Then restart Claude Code. Test with: git status\n"); } +/// Print manual instructions for OpenCode plugin registration +fn print_manual_instructions_opencode(hook_path: &Path) { + println!("\n Plugin installed at: {}", hook_path.display()); + println!(" OpenCode automatically loads plugins from ~/.config/opencode/plugins/"); + println!(" Restart OpenCode. Test with: git status\n"); +} + /// Remove RTK hook entry from settings.json /// Returns true if hook was found and removed fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { @@ -399,36 +508,52 @@ 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: remove hook/plugin, RTK.md, @RTK.md reference, settings.json entry +pub fn uninstall(global: bool, verbose: u8, platform: AgentPlatform) -> Result<()> { if !global { - anyhow::bail!("Uninstall only works with --global flag. For local projects, manually remove RTK from CLAUDE.md"); + anyhow::bail!( + "Uninstall only works with --global flag. For local projects, manually remove RTK from {}", + platform.rules_file() + ); } - let claude_dir = resolve_claude_dir()?; + let config_dir = platform.config_dir()?; + let rules_file = platform.rules_file(); let mut removed = Vec::new(); - // 1. Remove hook file - let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); + // 1. Remove hook/plugin file + let hook_path = config_dir + .join(platform.hook_subdir()) + .join(platform.hook_filename()); if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; - removed.push(format!("Hook: {}", hook_path.display())); + fs::remove_file(&hook_path).with_context(|| { + format!( + "Failed to remove {}: {}", + platform.hook_mechanism(), + hook_path.display() + ) + })?; + removed.push(format!( + "{}: {}", + platform.hook_mechanism(), + hook_path.display() + )); } // 2. Remove RTK.md - let rtk_md_path = claude_dir.join("RTK.md"); + let rtk_md_path = config_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()))?; removed.push(format!("RTK.md: {}", rtk_md_path.display())); } - // 3. Remove @RTK.md reference from CLAUDE.md - let claude_md_path = claude_dir.join("CLAUDE.md"); - if claude_md_path.exists() { - let content = fs::read_to_string(&claude_md_path) - .with_context(|| format!("Failed to read CLAUDE.md: {}", claude_md_path.display()))?; + // 3. Remove @RTK.md reference from rules file + let rules_md_path = config_dir.join(rules_file); + if rules_md_path.exists() { + let content = fs::read_to_string(&rules_md_path).with_context(|| { + format!("Failed to read {}: {}", rules_file, rules_md_path.display()) + })?; if content.contains("@RTK.md") { let new_content = content @@ -440,36 +565,45 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { // Clean up double blanks let cleaned = clean_double_blanks(&new_content); - fs::write(&claude_md_path, cleaned).with_context(|| { - format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) + fs::write(&rules_md_path, cleaned).with_context(|| { + format!( + "Failed to write {}: {}", + rules_file, + rules_md_path.display() + ) })?; - removed.push(format!("CLAUDE.md: removed @RTK.md reference")); + removed.push(format!("{}: removed @RTK.md reference", rules_file)); } } - // 4. Remove hook entry from settings.json - if remove_hook_from_settings(verbose)? { - removed.push("settings.json: removed RTK hook entry".to_string()); + // 4. Remove hook entry from settings.json (Claude Code only) + if platform == AgentPlatform::ClaudeCode { + if remove_hook_from_settings(verbose)? { + removed.push("settings.json: removed RTK hook entry".to_string()); + } } // Report results if removed.is_empty() { - println!("RTK was not installed (nothing to remove)"); + println!( + "RTK was not installed for {} (nothing to remove)", + platform.name() + ); } else { - println!("RTK uninstalled:"); + println!("RTK uninstalled from {}:", platform.name()); for item in removed { println!(" - {}", item); } - println!("\nRestart Claude Code to apply changes."); + println!("\nRestart {} to apply changes.", platform.name()); } Ok(()) } -/// Orchestrator: patch settings.json with RTK hook +/// Orchestrator: patch settings.json with RTK hook (Claude Code only) /// Handles reading, checking, prompting, merging, backing up, and atomic writing fn patch_settings_json(hook_path: &Path, mode: PatchMode, verbose: u8) -> Result { - let claude_dir = resolve_claude_dir()?; + let claude_dir = AgentPlatform::ClaudeCode.config_dir()?; let settings_path = claude_dir.join("settings.json"); let hook_command = hook_path .to_str() @@ -501,12 +635,12 @@ fn patch_settings_json(hook_path: &Path, mode: PatchMode, verbose: u8) -> Result // Handle mode match mode { PatchMode::Skip => { - print_manual_instructions(hook_path); + print_manual_instructions_claude(hook_path); return Ok(PatchResult::Skipped); } PatchMode::Ask => { if !prompt_user_consent(&settings_path)? { - print_manual_instructions(hook_path); + print_manual_instructions_claude(hook_path); return Ok(PatchResult::Declined); } } @@ -640,59 +774,80 @@ fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { /// Default mode: hook + slim RTK.md + @RTK.md reference #[cfg(not(unix))] -fn run_default_mode(_global: bool, _patch_mode: PatchMode, _verbose: u8) -> Result<()> { +fn run_default_mode( + _global: bool, + _patch_mode: PatchMode, + _verbose: u8, + platform: AgentPlatform, +) -> Result<()> { eprintln!("⚠️ Hook-based mode requires Unix (macOS/Linux)."); eprintln!(" Windows: use --claude-md mode for full injection."); eprintln!(" Falling back to --claude-md mode."); - run_claude_md_mode(_global, _verbose) + run_claude_md_mode(_global, _verbose, platform) } #[cfg(unix)] -fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { +fn run_default_mode( + global: bool, + patch_mode: PatchMode, + verbose: u8, + platform: AgentPlatform, +) -> Result<()> { if !global { - // Local init: unchanged behavior (full injection into ./CLAUDE.md) - return run_claude_md_mode(false, verbose); + // Local init: unchanged behavior (full injection into ./CLAUDE.md or ./AGENTS.md) + return run_claude_md_mode(false, verbose, platform); } - let claude_dir = resolve_claude_dir()?; - let rtk_md_path = claude_dir.join("RTK.md"); - let claude_md_path = claude_dir.join("CLAUDE.md"); + let config_dir = platform.config_dir()?; + let rtk_md_path = config_dir.join("RTK.md"); + let rules_file = platform.rules_file(); + let rules_md_path = config_dir.join(rules_file); // 1. Prepare hook directory and install hook - let (_hook_dir, hook_path) = prepare_hook_paths()?; - ensure_hook_installed(&hook_path, verbose)?; + let (_hook_dir, hook_path) = prepare_hook_paths(platform)?; + ensure_hook_installed(&hook_path, verbose, platform)?; // 2. Write RTK.md write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; - // 3. Patch CLAUDE.md (add @RTK.md, migrate if needed) - let migrated = patch_claude_md(&claude_md_path, verbose)?; + // 3. Patch rules file (add @RTK.md, migrate if needed) + let migrated = patch_claude_md(&rules_md_path, verbose)?; // 4. Print success message - println!("\nRTK hook installed (global).\n"); - println!(" Hook: {}", hook_path.display()); + println!("\nRTK {} installed (global).\n", platform.hook_mechanism()); + println!(" {}: {}", platform.hook_mechanism(), hook_path.display()); println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); - println!(" CLAUDE.md: @RTK.md reference added"); + println!(" {}: @RTK.md reference added", rules_file); if migrated { - println!("\n ✅ Migrated: removed 137-line RTK block from CLAUDE.md"); + println!( + "\n Migrated: removed 137-line RTK block from {}", + rules_file + ); println!(" replaced with @RTK.md (10 lines)"); } - // 5. Patch settings.json - let patch_result = patch_settings_json(&hook_path, patch_mode, verbose)?; - - // Report result - match patch_result { - PatchResult::Patched => { - // Already printed by patch_settings_json - } - PatchResult::AlreadyPresent => { - println!("\n settings.json: hook already present"); - println!(" Restart Claude Code. Test with: git status"); + // 5. Platform-specific registration + match platform { + AgentPlatform::ClaudeCode => { + let patch_result = patch_settings_json(&hook_path, patch_mode, verbose)?; + match patch_result { + PatchResult::Patched => { + // Already printed by patch_settings_json + } + PatchResult::AlreadyPresent => { + println!("\n settings.json: hook already present"); + println!(" Restart Claude Code. Test with: git status"); + } + PatchResult::Declined | PatchResult::Skipped => { + // Manual instructions already printed by patch_settings_json + } + } } - PatchResult::Declined | PatchResult::Skipped => { - // Manual instructions already printed by patch_settings_json + AgentPlatform::OpenCode => { + // OpenCode auto-loads plugins from ~/.config/opencode/plugins/ + // No config file patching needed + print_manual_instructions_opencode(&hook_path); } } @@ -703,42 +858,60 @@ fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result< /// Hook-only mode: just the hook, no RTK.md #[cfg(not(unix))] -fn run_hook_only_mode(_global: bool, _patch_mode: PatchMode, _verbose: u8) -> Result<()> { +fn run_hook_only_mode( + _global: bool, + _patch_mode: PatchMode, + _verbose: u8, + _platform: AgentPlatform, +) -> Result<()> { anyhow::bail!("Hook install requires Unix (macOS/Linux). Use WSL or --claude-md mode.") } #[cfg(unix)] -fn run_hook_only_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { +fn run_hook_only_mode( + global: bool, + patch_mode: PatchMode, + verbose: u8, + platform: AgentPlatform, +) -> Result<()> { if !global { - eprintln!("⚠️ Warning: --hook-only only makes sense with --global"); + eprintln!("Warning: --hook-only only makes sense with --global"); eprintln!(" For local projects, use default mode or --claude-md"); return Ok(()); } // Prepare and install hook - let (_hook_dir, hook_path) = prepare_hook_paths()?; - ensure_hook_installed(&hook_path, verbose)?; + let (_hook_dir, hook_path) = prepare_hook_paths(platform)?; + ensure_hook_installed(&hook_path, verbose, platform)?; - println!("\nRTK hook installed (hook-only mode).\n"); - println!(" Hook: {}", hook_path.display()); println!( - " Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)." + "\nRTK {} installed (hook-only mode).\n", + platform.hook_mechanism() + ); + println!(" {}: {}", platform.hook_mechanism(), hook_path.display()); + println!( + " Note: No RTK.md created. Agent won't know about meta commands (gain, discover, proxy)." ); - // Patch settings.json - let patch_result = patch_settings_json(&hook_path, patch_mode, verbose)?; - - // Report result - match patch_result { - PatchResult::Patched => { - // Already printed by patch_settings_json - } - PatchResult::AlreadyPresent => { - println!("\n settings.json: hook already present"); - println!(" Restart Claude Code. Test with: git status"); + // Platform-specific registration + match platform { + AgentPlatform::ClaudeCode => { + let patch_result = patch_settings_json(&hook_path, patch_mode, verbose)?; + match patch_result { + PatchResult::Patched => { + // Already printed by patch_settings_json + } + PatchResult::AlreadyPresent => { + println!("\n settings.json: hook already present"); + println!(" Restart Claude Code. Test with: git status"); + } + PatchResult::Declined | PatchResult::Skipped => { + // Manual instructions already printed by patch_settings_json + } + } } - PatchResult::Declined | PatchResult::Skipped => { - // Manual instructions already printed by patch_settings_json + AgentPlatform::OpenCode => { + print_manual_instructions_opencode(&hook_path); } } @@ -747,12 +920,13 @@ fn run_hook_only_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Resul Ok(()) } -/// Legacy mode: full 137-line injection into CLAUDE.md -fn run_claude_md_mode(global: bool, verbose: u8) -> Result<()> { +/// Legacy mode: full 137-line injection into CLAUDE.md/AGENTS.md +fn run_claude_md_mode(global: bool, verbose: u8, platform: AgentPlatform) -> Result<()> { + let rules_file = platform.rules_file(); let path = if global { - resolve_claude_dir()?.join("CLAUDE.md") + platform.config_dir()?.join(rules_file) } else { - PathBuf::from("CLAUDE.md") + PathBuf::from(rules_file) }; if global { @@ -815,9 +989,9 @@ fn run_claude_md_mode(global: bool, verbose: u8) -> Result<()> { } if global { - println!(" Claude Code will now use rtk in all sessions"); + println!(" {} will now use rtk in all sessions", platform.name()); } else { - println!(" Claude Code will use rtk in this project"); + println!(" {} will use rtk in this project", platform.name()); } Ok(()) @@ -980,115 +1154,168 @@ fn resolve_claude_dir() -> Result { .context("Cannot determine home directory. Is $HOME set?") } -/// Show current rtk configuration +/// Show current rtk configuration for all platforms pub fn show_config() -> Result<()> { - let claude_dir = resolve_claude_dir()?; - let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); - let rtk_md_path = claude_dir.join("RTK.md"); - let global_claude_md = claude_dir.join("CLAUDE.md"); - let local_claude_md = PathBuf::from("CLAUDE.md"); + println!("rtk Configuration:\n"); + + // --- Claude Code --- + println!(" Claude Code:"); + if let Ok(claude_dir) = AgentPlatform::ClaudeCode.config_dir() { + let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); + let rtk_md_path = claude_dir.join("RTK.md"); + let global_claude_md = claude_dir.join("CLAUDE.md"); + + // Check hook + if hook_path.exists() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = fs::metadata(&hook_path)?; + let perms = metadata.permissions(); + let is_executable = perms.mode() & 0o111 != 0; + + let hook_content = fs::read_to_string(&hook_path)?; + let has_guards = hook_content.contains("command -v rtk") + && hook_content.contains("command -v jq"); + + if is_executable && has_guards { + println!( + " Hook: {} (executable, with guards)", + hook_path.display() + ); + } else if !is_executable { + println!( + " Hook: {} (NOT executable - run: chmod +x)", + hook_path.display() + ); + } else { + println!(" Hook: {} (no guards - outdated)", hook_path.display()); + } + } - println!("📋 rtk Configuration:\n"); + #[cfg(not(unix))] + { + println!(" Hook: {} (exists)", hook_path.display()); + } + } else { + println!(" Hook: not installed"); + } - // Check hook - if hook_path.exists() { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let metadata = fs::metadata(&hook_path)?; - let perms = metadata.permissions(); - let is_executable = perms.mode() & 0o111 != 0; - - let hook_content = fs::read_to_string(&hook_path)?; - let has_guards = - hook_content.contains("command -v rtk") && hook_content.contains("command -v jq"); - - if is_executable && has_guards { - println!("✅ Hook: {} (executable, with guards)", hook_path.display()); - } else if !is_executable { - println!( - "⚠️ Hook: {} (NOT executable - run: chmod +x)", - hook_path.display() - ); + // Check RTK.md + if rtk_md_path.exists() { + println!(" RTK.md: {} (slim mode)", rtk_md_path.display()); + } else { + println!(" RTK.md: not found"); + } + + // Check global CLAUDE.md + if global_claude_md.exists() { + let content = fs::read_to_string(&global_claude_md)?; + if content.contains("@RTK.md") { + println!(" CLAUDE.md: @RTK.md reference present"); + } else if content.contains("