From b44c802fa5a137263e141a0b2998f7506f58298a Mon Sep 17 00:00:00 2001 From: Alec McQuarrie Date: Thu, 19 Feb 2026 17:37:13 -0500 Subject: [PATCH 1/2] feat: add OpenCode integration support Add RTK support for OpenCode alongside existing Claude Code integration. OpenCode users can now install RTK via its native plugin system for transparent command rewriting and token optimization. New files: - hooks/opencode-rtk-plugin.ts: TypeScript plugin that ports the bash rewrite logic to OpenCode's tool.execute.before hook - hooks/opencode-rtk-awareness.md: Slim awareness doc for OpenCode - hooks/opencode-rtk-rules.md: Rules file for OpenCode's rules system CLI changes: - rtk init --opencode [-g]: Install plugin + rules for OpenCode - rtk init --opencode --show: Show OpenCode RTK configuration - rtk init --opencode --uninstall [-g]: Remove OpenCode RTK artifacts - rtk discover --opencode: Scan OpenCode sessions for missed savings Implementation: - OpenCode plugin uses tool.execute.before event to intercept and rewrite bash commands (same patterns as rtk-rewrite.sh) - OpenCodeProvider in discover module supports session scanning - All 418 tests pass, no new clippy errors --- README.md | 67 ++++++- hooks/opencode-rtk-awareness.md | 25 +++ hooks/opencode-rtk-plugin.ts | 273 +++++++++++++++++++++++++++ hooks/opencode-rtk-rules.md | 9 + src/discover/mod.rs | 16 +- src/discover/provider.rs | 254 +++++++++++++++++++++++++ src/init.rs | 321 ++++++++++++++++++++++++++++++++ src/main.rs | 35 +++- 8 files changed, 992 insertions(+), 8 deletions(-) create mode 100644 hooks/opencode-rtk-awareness.md create mode 100644 hooks/opencode-rtk-plugin.ts create mode 100644 hooks/opencode-rtk-rules.md diff --git a/README.md b/README.md index dce3834..44262c1 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,47 @@ rtk init --show # Verify hook is installed and executable **New in v0.9.5**: Hook-first installation eliminates ~2000 tokens from Claude's context while maintaining full RTK functionality through transparent command rewriting. +## OpenCode Support + +RTK also supports [OpenCode](https://opencode.ai) as an alternative to Claude Code. The integration uses OpenCode's native plugin system for transparent command rewriting. + +### Quick Start (OpenCode) + +```bash +# 1. Verify rtk is installed +rtk gain # Must show token stats + +# 2. Initialize for OpenCode (global) +rtk init --opencode --global +# → Installs plugin to ~/.config/opencode/plugins/rtk.ts +# → Installs rules to ~/.config/opencode/rules/rtk.md +# → Creates RTK.md reference doc + +# 3. Restart OpenCode, then test +git status # Plugin transparently rewrites to: rtk git status + +# Alternative: per-project install +rtk init --opencode +# → Installs to .opencode/plugins/ and .opencode/rules/ +``` + +### OpenCode Commands + +```bash +rtk init --opencode --global # Install globally for all OpenCode projects +rtk init --opencode # Install for current project only +rtk init --opencode --show # Show current OpenCode RTK configuration +rtk init --opencode --uninstall # Remove local RTK artifacts +rtk init --opencode --uninstall -g # Remove global RTK artifacts +rtk discover --opencode # Scan OpenCode sessions for missed savings +``` + +### How It Works (OpenCode) + +RTK's OpenCode integration uses the [plugin system](https://opencode.ai/docs/plugins). The plugin hooks into the `tool.execute.before` event to intercept bash commands and rewrite them to their rtk equivalents before execution. This is the same approach as Claude Code's PreToolUse hook, but implemented natively in TypeScript using OpenCode's plugin API. + +No configuration file patching is needed — OpenCode automatically discovers plugins in its plugins directory. + ## Global Flags ```bash @@ -342,6 +383,8 @@ FAILED: 2/15 tests ### Installation Modes +#### Claude Code + | Command | Scope | Hook | RTK.md | CLAUDE.md | Tokens in Context | Use Case | |---------|-------|------|--------|-----------|-------------------|----------| | `rtk init -g` | Global | ✅ | ✅ (10 lines) | @RTK.md | ~10 | **Recommended**: All projects, automatic | @@ -349,11 +392,20 @@ FAILED: 2/15 tests | `rtk init -g --hook-only` | Global | ✅ | ❌ | Nothing | 0 | Minimal setup, hook-only | | `rtk init` | Local | ❌ | ❌ | Full (137 lines) | ~2000 | Single project, no hook | +#### OpenCode + +| Command | Scope | Plugin | Rules | RTK.md | Use Case | +|---------|-------|--------|-------|--------|----------| +| `rtk init --opencode -g` | Global | ✅ | ✅ | ✅ | **Recommended**: All OpenCode projects | +| `rtk init --opencode` | Local | ✅ | ✅ | ✅ | Single project | + ```bash rtk init --show # Show current configuration -rtk init -g # Install hook + RTK.md (recommended) +rtk init -g # Install hook + RTK.md (recommended, Claude Code) rtk init -g --claude-md # Legacy: full injection into CLAUDE.md rtk init # Local project: full injection into ./CLAUDE.md +rtk init --opencode -g # Install plugin for OpenCode (global) +rtk init --opencode # Install plugin for OpenCode (local) ``` ### Installation Flags @@ -657,6 +709,19 @@ cp ~/.claude/settings.json.bak ~/.claude/settings.json **Local Projects**: Manually remove RTK instructions from `./CLAUDE.md` +**OpenCode Removal**: +```bash +rtk init --opencode -g --uninstall # Global +rtk init --opencode --uninstall # Local project + +# Removes: +# - plugins/rtk.ts +# - rules/rtk.md +# - RTK.md + +# Restart OpenCode after uninstall +``` + **Binary Removal**: ```bash # If installed via cargo diff --git a/hooks/opencode-rtk-awareness.md b/hooks/opencode-rtk-awareness.md new file mode 100644 index 0000000..d999db0 --- /dev/null +++ b/hooks/opencode-rtk-awareness.md @@ -0,0 +1,25 @@ +# RTK - Rust Token Killer + +**Usage**: Token-optimized CLI proxy (60-90% savings on dev operations) + +## Meta Commands (always use rtk directly) + +```bash +rtk gain # Show token savings analytics +rtk gain --history # Show command usage history with savings +rtk discover # Analyze OpenCode history for missed opportunities +rtk proxy # Execute raw command without filtering (for debugging) +``` + +## Installation Verification + +```bash +rtk --version # Should show: rtk X.Y.Z +rtk gain # Should work (not "command not found") +which rtk # Verify correct binary +``` + +## Plugin-Based Usage + +All other commands are automatically rewritten by the OpenCode RTK plugin. +Example: `git status` → `rtk git status` (transparent, 0 tokens overhead) diff --git a/hooks/opencode-rtk-plugin.ts b/hooks/opencode-rtk-plugin.ts new file mode 100644 index 0000000..5eab66c --- /dev/null +++ b/hooks/opencode-rtk-plugin.ts @@ -0,0 +1,273 @@ +// RTK (Rust Token Killer) plugin for OpenCode +// Transparently rewrites bash commands to their rtk equivalents, +// reducing LLM token consumption by 60-90%. +// +// Install globally: ~/.config/opencode/plugins/rtk.ts +// Install per-project: .opencode/plugins/rtk.ts +// +// Requires: rtk binary in PATH (https://github.com/rtk-ai/rtk) + +import type { Plugin } from "@opencode-ai/plugin" + +/** Check if a command is already prefixed with rtk */ +function isAlreadyRtk(cmd: string): boolean { + return /^(rtk\s|.*\/rtk\s)/.test(cmd) +} + +/** Check if a command contains heredocs */ +function hasHeredoc(cmd: string): boolean { + return cmd.includes("<<") +} + +/** + * Strip leading environment variable assignments for pattern matching. + * e.g., "TEST_SESSION_ID=2 npx playwright test" → { prefix: "TEST_SESSION_ID=2 ", body: "npx playwright test" } + */ +function stripEnvPrefix(cmd: string): { prefix: string; body: string } { + const match = cmd.match(/^([A-Za-z_][A-Za-z0-9_]*=[^ ]* +)+/) + if (match) { + const prefix = match[0] + return { prefix, body: cmd.slice(prefix.length) } + } + return { prefix: "", body: cmd } +} + +/** + * Strip git global flags for subcommand matching. + * Removes -C , -c , --no-pager, --no-optional-locks, --bare, --literal-pathspecs + */ +function stripGitFlags(subcmd: string): string { + return subcmd + .replace(/(-C|-c)\s+\S+\s*/g, "") + .replace(/--[a-z-]+=\S+\s*/g, "") + .replace(/--(no-pager|no-optional-locks|bare|literal-pathspecs)\s*/g, "") + .trim() +} + +/** + * Port of rtk-rewrite.sh rewrite rules to TypeScript. + * Returns the rewritten command string, or null if no rewrite applies. + */ +function rewriteCommand(cmd: string): string | null { + const trimmed = cmd.trim() + + // Skip if already using rtk or contains heredocs + if (isAlreadyRtk(trimmed) || hasHeredoc(trimmed)) { + return null + } + + const { prefix, body } = stripEnvPrefix(trimmed) + const matchCmd = body + + // --- Git commands --- + if (/^git\s/.test(matchCmd)) { + const gitRest = matchCmd.replace(/^git\s+/, "") + const subcmd = stripGitFlags(gitRest) + if ( + /^(status|diff|log|add|commit|push|pull|branch|fetch|stash|show)(\s|$)/.test( + subcmd, + ) + ) { + return `${prefix}rtk ${body}` + } + return null + } + + // --- GitHub CLI --- + if (/^gh\s+(pr|issue|run|api|release)(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^gh /, "rtk gh ")}` + } + + // --- Cargo --- + if (/^cargo\s/.test(matchCmd)) { + const cargoRest = matchCmd.replace(/^cargo\s+(\+\S+\s+)?/, "") + if (/^(test|build|clippy|check|install|fmt)(\s|$)/.test(cargoRest)) { + return `${prefix}rtk ${body}` + } + return null + } + + // --- File operations --- + if (/^cat\s+/.test(matchCmd)) { + return `${prefix}${body.replace(/^cat /, "rtk read ")}` + } + if (/^(rg|grep)\s+/.test(matchCmd)) { + return `${prefix}${body.replace(/^(rg|grep) /, "rtk grep ")}` + } + if (/^ls(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^ls/, "rtk ls")}` + } + if (/^tree(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^tree/, "rtk tree")}` + } + if (/^find\s+/.test(matchCmd)) { + return `${prefix}${body.replace(/^find /, "rtk find ")}` + } + if (/^diff\s+/.test(matchCmd)) { + return `${prefix}${body.replace(/^diff /, "rtk diff ")}` + } + + // --- head → rtk read with --max-lines --- + { + let headMatch = matchCmd.match(/^head\s+-(\d+)\s+(.+)$/) + if (headMatch) { + return `${prefix}rtk read ${headMatch[2]} --max-lines ${headMatch[1]}` + } + headMatch = matchCmd.match(/^head\s+--lines=(\d+)\s+(.+)$/) + if (headMatch) { + return `${prefix}rtk read ${headMatch[2]} --max-lines ${headMatch[1]}` + } + } + + // --- JS/TS tooling --- + if (/^(pnpm\s+)?(npx\s+)?vitest(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^(pnpm )?(npx )?vitest( run)?/, "rtk vitest run")}` + } + if (/^pnpm\s+test(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^pnpm test/, "rtk vitest run")}` + } + if (/^npm\s+test(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^npm test/, "rtk npm test")}` + } + if (/^npm\s+run\s+/.test(matchCmd)) { + return `${prefix}${body.replace(/^npm run /, "rtk npm ")}` + } + if (/^(npx\s+)?vue-tsc(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^(npx )?vue-tsc/, "rtk tsc")}` + } + if (/^pnpm\s+tsc(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^pnpm tsc/, "rtk tsc")}` + } + if (/^(npx\s+)?tsc(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^(npx )?tsc/, "rtk tsc")}` + } + if (/^pnpm\s+lint(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^pnpm lint/, "rtk lint")}` + } + if (/^(npx\s+)?eslint(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^(npx )?eslint/, "rtk lint")}` + } + if (/^(npx\s+)?prettier(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^(npx )?prettier/, "rtk prettier")}` + } + if (/^(npx\s+)?playwright(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^(npx )?playwright/, "rtk playwright")}` + } + if (/^pnpm\s+playwright(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^pnpm playwright/, "rtk playwright")}` + } + if (/^(npx\s+)?prisma(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^(npx )?prisma/, "rtk prisma")}` + } + + // --- Containers --- + if (/^docker\s/.test(matchCmd)) { + if (/^docker\s+compose(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^docker /, "rtk docker ")}` + } + const dockerRest = matchCmd + .replace(/^docker\s+/, "") + .replace(/(-H|--context|--config)\s+\S+\s*/g, "") + .replace(/--[a-z-]+=\S+\s*/g, "") + .trim() + if ( + /^(ps|images|logs|run|build|exec)(\s|$)/.test(dockerRest) + ) { + return `${prefix}${body.replace(/^docker /, "rtk docker ")}` + } + return null + } + if (/^kubectl\s/.test(matchCmd)) { + const kubeRest = matchCmd + .replace(/^kubectl\s+/, "") + .replace(/(--context|--kubeconfig|--namespace|-n)\s+\S+\s*/g, "") + .replace(/--[a-z-]+=\S+\s*/g, "") + .trim() + if (/^(get|logs|describe|apply)(\s|$)/.test(kubeRest)) { + return `${prefix}${body.replace(/^kubectl /, "rtk kubectl ")}` + } + return null + } + + // --- Network --- + if (/^curl\s+/.test(matchCmd)) { + return `${prefix}${body.replace(/^curl /, "rtk curl ")}` + } + if (/^wget\s+/.test(matchCmd)) { + return `${prefix}${body.replace(/^wget /, "rtk wget ")}` + } + + // --- pnpm package management --- + if (/^pnpm\s+(list|ls|outdated)(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^pnpm /, "rtk pnpm ")}` + } + + // --- Python tooling --- + if (/^pytest(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^pytest/, "rtk pytest")}` + } + if (/^python\s+-m\s+pytest(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^python -m pytest/, "rtk pytest")}` + } + if (/^ruff\s+(check|format)(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^ruff /, "rtk ruff ")}` + } + if (/^pip\s+(list|outdated|install|show)(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^pip /, "rtk pip ")}` + } + if (/^uv\s+pip\s+(list|outdated|install|show)(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^uv pip /, "rtk pip ")}` + } + + // --- Go tooling --- + if (/^go\s+test(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^go test/, "rtk go test")}` + } + if (/^go\s+build(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^go build/, "rtk go build")}` + } + if (/^go\s+vet(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^go vet/, "rtk go vet")}` + } + if (/^golangci-lint(\s|$)/.test(matchCmd)) { + return `${prefix}${body.replace(/^golangci-lint/, "rtk golangci-lint")}` + } + + // No rewrite matched + return null +} + +/** + * RTK Plugin for OpenCode. + * + * Intercepts bash commands via the tool.execute.before hook and transparently + * rewrites them to their rtk equivalents for token-optimized output. + * + * This is the OpenCode equivalent of Claude Code's PreToolUse hook + * (hooks/rtk-rewrite.sh). + */ +export const RTKPlugin: Plugin = async ({ $ }) => { + // Guard: skip if rtk binary is not installed + const check = await $`which rtk`.quiet().nothrow() + if (check.exitCode !== 0) { + return {} + } + + return { + "tool.execute.before": async (input, output) => { + // Only intercept bash tool calls + if (input.tool !== "bash") return + + const cmd = output.args.command as string + if (!cmd) return + + const rewritten = rewriteCommand(cmd) + if (rewritten) { + output.args.command = rewritten + } + }, + } +} + +// Also export as default for single-file plugin usage +export default RTKPlugin diff --git a/hooks/opencode-rtk-rules.md b/hooks/opencode-rtk-rules.md new file mode 100644 index 0000000..01b3f7f --- /dev/null +++ b/hooks/opencode-rtk-rules.md @@ -0,0 +1,9 @@ +RTK (Rust Token Killer) is active. All bash commands are automatically +rewritten to their token-optimized rtk equivalents by the RTK plugin. +No special prefixes are needed — the plugin handles rewriting transparently. + +For meta commands, use rtk directly: +- `rtk gain` — view token savings analytics +- `rtk gain --history` — view command history with savings +- `rtk discover` — find missed optimization opportunities +- `rtk proxy ` — bypass filtering for debugging diff --git a/src/discover/mod.rs b/src/discover/mod.rs index a8cee12..56b1894 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -5,7 +5,7 @@ mod report; use anyhow::Result; use std::collections::HashMap; -use provider::{ClaudeProvider, SessionProvider}; +use provider::{ClaudeProvider, OpenCodeProvider, SessionProvider}; use registry::{category_avg_tokens, classify_command, split_command_chain, Classification}; use report::{DiscoverReport, SupportedEntry, UnsupportedEntry}; @@ -33,8 +33,14 @@ pub fn run( limit: usize, format: &str, verbose: u8, + opencode: bool, ) -> Result<()> { - let provider = ClaudeProvider; + // Select provider based on --opencode flag + let provider: Box = if opencode { + Box::new(OpenCodeProvider) + } else { + Box::new(ClaudeProvider) + }; // Determine project filter let project_filter = if all { @@ -45,7 +51,11 @@ 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); + let encoded = if opencode { + OpenCodeProvider::encode_project_path(&cwd_str) + } else { + ClaudeProvider::encode_project_path(&cwd_str) + }; Some(encoded) }; diff --git a/src/discover/provider.rs b/src/discover/provider.rs index e9218b2..0609642 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -234,6 +234,260 @@ impl SessionProvider for ClaudeProvider { } } +/// OpenCode session provider. +/// +/// OpenCode stores sessions in a SQLite database under its data directory. +/// The database location follows XDG conventions: +/// - $XDG_DATA_HOME/opencode/ (if XDG_DATA_HOME is set) +/// - ~/.local/share/opencode/ (default) +/// +/// Session data is stored in an SQLite database with messages containing +/// tool calls and results. The schema uses JSON columns for message parts. +pub struct OpenCodeProvider; + +impl OpenCodeProvider { + /// Get the base data directory for OpenCode. + fn data_dir() -> Result { + let data_dir = if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + PathBuf::from(xdg).join("opencode") + } else { + let home = dirs::home_dir().context("could not determine home directory")?; + home.join(".local").join("share").join("opencode") + }; + + if !data_dir.exists() { + anyhow::bail!( + "OpenCode data directory not found: {}\nMake sure OpenCode has been used at least once.", + data_dir.display() + ); + } + Ok(data_dir) + } + + /// Encode a filesystem path to OpenCode's directory name format. + /// OpenCode uses the full path as a project identifier. + pub fn encode_project_path(path: &str) -> String { + // OpenCode may use the path directly or encode it + path.replace('/', "-") + } +} + +impl SessionProvider for OpenCodeProvider { + fn discover_sessions( + &self, + project_filter: Option<&str>, + since_days: Option, + ) -> Result> { + let data_dir = Self::data_dir()?; + + let cutoff = since_days.map(|days| { + SystemTime::now() + .checked_sub(Duration::from_secs(days * 86400)) + .unwrap_or(SystemTime::UNIX_EPOCH) + }); + + let mut sessions = Vec::new(); + + // Walk the data directory looking for session files (JSONL or SQLite) + for walk_entry in WalkDir::new(&data_dir) + .follow_links(false) + .max_depth(4) + .into_iter() + .filter_map(|e| e.ok()) + { + let file_path = walk_entry.path(); + let ext = file_path.extension().and_then(|e| e.to_str()); + + // Look for JSONL session files (OpenCode may export in this format) + // or SQLite databases + match ext { + Some("jsonl") => {} + Some("db") | Some("sqlite") | Some("sqlite3") => {} + _ => continue, + } + + // Apply project filter + if let Some(filter) = project_filter { + let path_str = file_path.to_string_lossy(); + if !path_str.contains(filter) { + continue; + } + } + + // Apply mtime filter + if let Some(cutoff_time) = cutoff { + if let Ok(meta) = fs::metadata(file_path) { + if let Ok(mtime) = meta.modified() { + if mtime < cutoff_time { + continue; + } + } + } + } + + sessions.push(file_path.to_path_buf()); + } + + Ok(sessions) + } + + fn extract_commands(&self, path: &Path) -> Result> { + let ext = path.extension().and_then(|e| e.to_str()); + + match ext { + Some("jsonl") => { + // Parse JSONL format (similar to Claude but with OpenCode's schema) + self.extract_from_jsonl(path) + } + _ => { + // For SQLite databases, we'd need rusqlite to read them. + // For now, skip non-JSONL files with a warning. + Ok(Vec::new()) + } + } + } +} + +impl OpenCodeProvider { + /// Extract commands from an OpenCode JSONL session file. + /// + /// OpenCode's message format stores tool calls as "parts" within messages. + /// We look for bash tool invocations and their results. + fn extract_from_jsonl(&self, path: &Path) -> Result> { + let file = + fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let reader = BufReader::new(file); + + let session_id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let mut pending_tool_uses: Vec<(String, String, usize)> = Vec::new(); + let mut tool_results: HashMap = HashMap::new(); + let mut commands = Vec::new(); + let mut sequence_counter = 0; + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + + // Pre-filter for performance + if !line.contains("bash") && !line.contains("Bash") && !line.contains("tool") { + continue; + } + + let entry: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + // OpenCode stores parts in message.parts array + // Tool calls have type "tool-invocation" with toolName "bash" + // Tool results have type "tool-result" + let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match entry_type { + "assistant" | "message" => { + // Check parts array for tool invocations + let parts = entry + .pointer("/parts") + .or_else(|| entry.pointer("/message/parts")) + .or_else(|| entry.pointer("/message/content")) + .and_then(|p| p.as_array()); + + if let Some(parts) = parts { + for part in parts { + let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + // Handle OpenCode's tool-invocation format + if part_type == "tool-invocation" || part_type == "tool_use" { + let tool_name = part + .get("toolName") + .or_else(|| part.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or(""); + + if tool_name == "bash" || tool_name == "Bash" { + let id = part + .get("toolInvocationId") + .or_else(|| part.get("id")) + .and_then(|i| i.as_str()); + let cmd = part + .pointer("/args/command") + .or_else(|| part.pointer("/input/command")) + .and_then(|c| c.as_str()); + + if let (Some(id), Some(cmd)) = (id, cmd) { + pending_tool_uses.push(( + id.to_string(), + cmd.to_string(), + sequence_counter, + )); + sequence_counter += 1; + } + } + } + + // Handle tool results + if part_type == "tool-result" || part_type == "tool_result" { + let id = part + .get("toolInvocationId") + .or_else(|| part.get("tool_use_id")) + .and_then(|i| i.as_str()); + + if let Some(id) = id { + let content = part + .get("result") + .or_else(|| part.get("content")) + .and_then(|c| c.as_str()) + .unwrap_or(""); + let output_len = content.len(); + let is_error = part + .get("isError") + .or_else(|| part.get("is_error")) + .and_then(|e| e.as_bool()) + .unwrap_or(false); + let content_preview: String = + content.chars().take(1000).collect(); + + tool_results.insert( + id.to_string(), + (output_len, content_preview, is_error), + ); + } + } + } + } + } + _ => {} + } + } + + // Match tool_uses with their results + for (tool_id, command, sequence_index) in pending_tool_uses { + let (output_len, output_content, is_error) = tool_results + .get(&tool_id) + .map(|(len, content, err)| (Some(*len), Some(content.clone()), *err)) + .unwrap_or((None, None, false)); + + commands.push(ExtractedCommand { + command, + output_len, + session_id: session_id.clone(), + output_content, + is_error, + sequence_index, + }); + } + + Ok(commands) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/init.rs b/src/init.rs index 961e4ac..5cad753 100644 --- a/src/init.rs +++ b/src/init.rs @@ -10,6 +10,15 @@ const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md"); +// Embedded OpenCode plugin (TypeScript) +const OPENCODE_PLUGIN: &str = include_str!("../hooks/opencode-rtk-plugin.ts"); + +// Embedded OpenCode awareness instructions +const OPENCODE_AWARENESS: &str = include_str!("../hooks/opencode-rtk-awareness.md"); + +// Embedded OpenCode rules file +const OPENCODE_RULES: &str = include_str!("../hooks/opencode-rtk-rules.md"); + /// Control flow for settings.json patching #[derive(Debug, Clone, Copy, PartialEq)] pub enum PatchMode { @@ -980,6 +989,216 @@ fn resolve_claude_dir() -> Result { .context("Cannot determine home directory. Is $HOME set?") } +// ============================================================================ +// OpenCode integration +// ============================================================================ + +/// Resolve OpenCode config directory (~/.config/opencode or XDG_CONFIG_HOME/opencode) +fn resolve_opencode_config_dir() -> Result { + // Respect XDG_CONFIG_HOME if set, otherwise default to ~/.config + let config_dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(xdg) + } else { + dirs::home_dir() + .context("Cannot determine home directory. Is $HOME set?")? + .join(".config") + }; + Ok(config_dir.join("opencode")) +} + +/// Resolve OpenCode project-local config directory (.opencode/) +fn resolve_opencode_project_dir() -> Result { + Ok(PathBuf::from(".opencode")) +} + +/// Install RTK for OpenCode +/// +/// Global mode (--global --opencode): +/// - Writes plugin to ~/.config/opencode/plugins/rtk.ts +/// - Writes rules to ~/.config/opencode/rules/rtk.md +/// +/// Local mode (--opencode): +/// - Writes plugin to .opencode/plugins/rtk.ts +/// - Writes rules to .opencode/rules/rtk.md +pub fn run_opencode(global: bool, verbose: u8) -> Result<()> { + let base_dir = if global { + resolve_opencode_config_dir()? + } else { + resolve_opencode_project_dir()? + }; + + let plugins_dir = base_dir.join("plugins"); + let rules_dir = base_dir.join("rules"); + + // Create directories + fs::create_dir_all(&plugins_dir).with_context(|| { + format!( + "Failed to create plugins directory: {}", + plugins_dir.display() + ) + })?; + fs::create_dir_all(&rules_dir) + .with_context(|| format!("Failed to create rules directory: {}", rules_dir.display()))?; + + // Write plugin + let plugin_path = plugins_dir.join("rtk.ts"); + let plugin_changed = write_if_changed(&plugin_path, OPENCODE_PLUGIN, "RTK plugin", verbose)?; + + // Write rules + let rules_path = rules_dir.join("rtk.md"); + let rules_changed = write_if_changed(&rules_path, OPENCODE_RULES, "RTK rules", verbose)?; + + // Write awareness doc (RTK.md for reference) + let awareness_path = base_dir.join("RTK.md"); + let awareness_changed = + write_if_changed(&awareness_path, OPENCODE_AWARENESS, "RTK.md", verbose)?; + + // Report + let scope = if global { "global" } else { "local" }; + println!("\nRTK installed for OpenCode ({scope}).\n"); + println!(" Plugin: {}", plugin_path.display()); + if plugin_changed { + println!(" (written)"); + } else { + println!(" (already up to date)"); + } + println!(" Rules: {}", rules_path.display()); + if rules_changed { + println!(" (written)"); + } else { + println!(" (already up to date)"); + } + println!(" RTK.md: {}", awareness_path.display()); + if awareness_changed { + println!(" (written)"); + } else { + println!(" (already up to date)"); + } + + println!("\n Restart OpenCode to activate. Test with: git status"); + println!(); + + Ok(()) +} + +/// Uninstall RTK from OpenCode +pub fn uninstall_opencode(global: bool, verbose: u8) -> Result<()> { + let base_dir = if global { + resolve_opencode_config_dir()? + } else { + resolve_opencode_project_dir()? + }; + + let mut removed = Vec::new(); + + // Remove plugin + let plugin_path = base_dir.join("plugins").join("rtk.ts"); + if plugin_path.exists() { + fs::remove_file(&plugin_path) + .with_context(|| format!("Failed to remove plugin: {}", plugin_path.display()))?; + removed.push(format!("Plugin: {}", plugin_path.display())); + } + + // Remove rules + let rules_path = base_dir.join("rules").join("rtk.md"); + if rules_path.exists() { + fs::remove_file(&rules_path) + .with_context(|| format!("Failed to remove rules: {}", rules_path.display()))?; + removed.push(format!("Rules: {}", rules_path.display())); + } + + // Remove awareness doc + let awareness_path = base_dir.join("RTK.md"); + if awareness_path.exists() { + fs::remove_file(&awareness_path) + .with_context(|| format!("Failed to remove RTK.md: {}", awareness_path.display()))?; + removed.push(format!("RTK.md: {}", awareness_path.display())); + } + + if removed.is_empty() { + println!("RTK was not installed for OpenCode (nothing to remove)"); + } else { + println!("RTK uninstalled from OpenCode:"); + for item in &removed { + println!(" - {}", item); + } + println!("\nRestart OpenCode to apply changes."); + } + + if verbose > 0 { + eprintln!("Checked base dir: {}", base_dir.display()); + } + + Ok(()) +} + +/// Show current OpenCode RTK configuration +pub fn show_opencode_config() -> Result<()> { + println!("RTK Configuration (OpenCode):\n"); + + // Check global plugin + let global_dir = resolve_opencode_config_dir()?; + let global_plugin = global_dir.join("plugins").join("rtk.ts"); + if global_plugin.exists() { + let content = fs::read_to_string(&global_plugin)?; + if content.contains("RTKPlugin") { + println!(" Global plugin: {} (installed)", global_plugin.display()); + } else { + println!( + " Global plugin: {} (exists but may be outdated)", + global_plugin.display() + ); + } + } else { + println!(" Global plugin: not found"); + } + + // Check global rules + let global_rules = global_dir.join("rules").join("rtk.md"); + if global_rules.exists() { + println!(" Global rules: {} (installed)", global_rules.display()); + } else { + println!(" Global rules: not found"); + } + + // Check global awareness doc + let global_awareness = global_dir.join("RTK.md"); + if global_awareness.exists() { + println!( + " Global RTK.md: {} (installed)", + global_awareness.display() + ); + } else { + println!(" Global RTK.md: not found"); + } + + // Check local plugin + let local_dir = resolve_opencode_project_dir()?; + let local_plugin = local_dir.join("plugins").join("rtk.ts"); + if local_plugin.exists() { + println!(" Local plugin: {} (installed)", local_plugin.display()); + } else { + println!(" Local plugin: not found"); + } + + // Check local rules + let local_rules = local_dir.join("rules").join("rtk.md"); + if local_rules.exists() { + println!(" Local rules: {} (installed)", local_rules.display()); + } else { + println!(" Local rules: not found"); + } + + println!("\nUsage:"); + println!(" rtk init --opencode # Install for current project"); + println!(" rtk init --opencode -g # Install globally for all projects"); + println!(" rtk init --opencode --show # Show this status"); + println!(" rtk init --opencode --uninstall # Remove local RTK artifacts"); + println!(" rtk init --opencode --uninstall -g # Remove global RTK artifacts"); + + Ok(()) +} + /// Show current rtk configuration pub fn show_config() -> Result<()> { let claude_dir = resolve_claude_dir()?; @@ -1511,4 +1730,106 @@ More notes let removed = remove_hook_from_json(&mut json_content); assert!(!removed); } + + // ========================================== + // OpenCode integration tests + // ========================================== + + #[test] + fn test_opencode_plugin_embedded() { + // Verify the OpenCode plugin is properly embedded + assert!(OPENCODE_PLUGIN.contains("RTKPlugin")); + assert!(OPENCODE_PLUGIN.contains("tool.execute.before")); + assert!(OPENCODE_PLUGIN.contains("rewriteCommand")); + } + + #[test] + fn test_opencode_plugin_has_all_rewrite_rules() { + // Verify the plugin covers the same command categories as the bash hook + for keyword in [ + "git", + "cargo", + "cat", + "grep", + "pytest", + "docker", + "kubectl", + "curl", + "go test", + "golangci-lint", + "vitest", + "eslint", + "prettier", + "playwright", + "prisma", + "ruff", + "pip", + ] { + assert!( + OPENCODE_PLUGIN.contains(keyword), + "Missing rewrite rule for '{}' in OpenCode plugin", + keyword + ); + } + } + + #[test] + fn test_opencode_rules_embedded() { + assert!(OPENCODE_RULES.contains("RTK")); + assert!(OPENCODE_RULES.contains("rtk gain")); + } + + #[test] + fn test_opencode_awareness_embedded() { + assert!(OPENCODE_AWARENESS.contains("RTK")); + assert!(OPENCODE_AWARENESS.contains("rtk gain")); + assert!(OPENCODE_AWARENESS.contains("OpenCode")); + } + + #[test] + fn test_opencode_install_creates_files() { + let temp = TempDir::new().unwrap(); + let plugins_dir = temp.path().join("plugins"); + let rules_dir = temp.path().join("rules"); + + fs::create_dir_all(&plugins_dir).unwrap(); + fs::create_dir_all(&rules_dir).unwrap(); + + let plugin_path = plugins_dir.join("rtk.ts"); + let rules_path = rules_dir.join("rtk.md"); + let awareness_path = temp.path().join("RTK.md"); + + // Write files (simulating what run_opencode does) + write_if_changed(&plugin_path, OPENCODE_PLUGIN, "RTK plugin", 0).unwrap(); + write_if_changed(&rules_path, OPENCODE_RULES, "RTK rules", 0).unwrap(); + write_if_changed(&awareness_path, OPENCODE_AWARENESS, "RTK.md", 0).unwrap(); + + assert!(plugin_path.exists()); + assert!(rules_path.exists()); + assert!(awareness_path.exists()); + + // Verify content + let plugin_content = fs::read_to_string(&plugin_path).unwrap(); + assert!(plugin_content.contains("RTKPlugin")); + + let rules_content = fs::read_to_string(&rules_path).unwrap(); + assert!(rules_content.contains("rtk gain")); + } + + #[test] + fn test_opencode_install_is_idempotent() { + let temp = TempDir::new().unwrap(); + let plugins_dir = temp.path().join("plugins"); + fs::create_dir_all(&plugins_dir).unwrap(); + + let plugin_path = plugins_dir.join("rtk.ts"); + + // First write + let changed1 = write_if_changed(&plugin_path, OPENCODE_PLUGIN, "RTK plugin", 0).unwrap(); + assert!(changed1); + + // Second write (same content) + let changed2 = write_if_changed(&plugin_path, OPENCODE_PLUGIN, "RTK plugin", 0).unwrap(); + assert!(!changed2); // Should not write again + } } diff --git a/src/main.rs b/src/main.rs index bc46fdd..25d442d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -271,6 +271,10 @@ enum Commands { #[arg(long = "hook-only", group = "mode")] hook_only: bool, + /// Install RTK plugin for OpenCode instead of Claude Code + #[arg(long, group = "mode")] + opencode: bool, + /// Auto-patch settings.json without prompting #[arg(long = "auto-patch", group = "patch")] auto_patch: bool, @@ -441,7 +445,7 @@ enum Commands { args: Vec, }, - /// Discover missed RTK savings from Claude Code history + /// Discover missed RTK savings from Claude Code or OpenCode history Discover { /// Filter by project path (substring match) #[arg(short, long)] @@ -458,6 +462,9 @@ enum Commands { /// Output format: text, json #[arg(short, long, default_value = "text")] format: String, + /// Scan OpenCode sessions instead of Claude Code + #[arg(long)] + opencode: bool, }, /// Learn CLI corrections from Claude Code error history @@ -1108,14 +1115,25 @@ fn main() -> Result<()> { show, claude_md, hook_only, + opencode, auto_patch, no_patch, uninstall, } => { if show { - init::show_config()?; + if opencode { + init::show_opencode_config()?; + } else { + init::show_config()?; + } } else if uninstall { - init::uninstall(global, cli.verbose)?; + if opencode { + init::uninstall_opencode(global, cli.verbose)?; + } else { + init::uninstall(global, cli.verbose)?; + } + } else if opencode { + init::run_opencode(global, cli.verbose)?; } else { let patch_mode = if auto_patch { init::PatchMode::Auto @@ -1290,8 +1308,17 @@ fn main() -> Result<()> { all, since, format, + opencode, } => { - discover::run(project.as_deref(), all, since, limit, &format, cli.verbose)?; + discover::run( + project.as_deref(), + all, + since, + limit, + &format, + cli.verbose, + opencode, + )?; } Commands::Learn { From 153425d178ec5cdfffac0c39883f65727ddf400b Mon Sep 17 00:00:00 2001 From: Alec McQuarrie Date: Thu, 19 Feb 2026 17:51:05 -0500 Subject: [PATCH 2/2] Fix shell issue --- hooks/opencode-rtk-plugin.ts | 44 ++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/hooks/opencode-rtk-plugin.ts b/hooks/opencode-rtk-plugin.ts index 5eab66c..6a585e0 100644 --- a/hooks/opencode-rtk-plugin.ts +++ b/hooks/opencode-rtk-plugin.ts @@ -247,13 +247,49 @@ function rewriteCommand(cmd: string): string | null { * (hooks/rtk-rewrite.sh). */ export const RTKPlugin: Plugin = async ({ $ }) => { - // Guard: skip if rtk binary is not installed - const check = await $`which rtk`.quiet().nothrow() - if (check.exitCode !== 0) { - return {} + // Find rtk binary. Check common install paths since Bun's shell + // and OpenCode's bash tool may not have ~/.cargo/bin in PATH. + const home = process.env.HOME ?? process.env.USERPROFILE ?? "" + const candidatePaths = [ + `${home}/.cargo/bin/rtk`, + `${home}/.local/bin/rtk`, + "/usr/local/bin/rtk", + "/usr/bin/rtk", + ] + + let rtkDir = "" + for (const p of candidatePaths) { + try { + const exists = await Bun.file(p).exists() + if (exists) { + rtkDir = p.replace(/\/rtk$/, "") + break + } + } catch { + // ignore + } + } + + // Fallback: try command -v in case rtk is on a non-standard PATH + if (!rtkDir) { + const check = await $`command -v rtk`.quiet().nothrow() + if (check.exitCode === 0) { + const rtkPath = check.text().trim() + rtkDir = rtkPath.replace(/\/rtk$/, "") + } else { + return {} + } } return { + // Ensure rtk's directory is in PATH for all shell executions + "shell.env": async (_input, output) => { + const currentPath = output.env.PATH ?? process.env.PATH ?? "" + if (!currentPath.split(":").includes(rtkDir)) { + output.env.PATH = `${rtkDir}:${currentPath}` + } + }, + "tool.execute.before": async (input, output) => { // Only intercept bash tool calls if (input.tool !== "bash") return