Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions hooks/opencode/rtk.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import type { Plugin } from "@opencode-ai/plugin"
import { execFile, execFileSync } from "node:child_process"

// RTK OpenCode plugin — rewrites commands to use rtk for token savings.
// Requires: rtk >= 0.23.0 in PATH.
//
// This is a thin delegating plugin: all rewrite logic lives in `rtk rewrite`,
// which is the single source of truth (src/discover/registry.rs).
// To add or change rewrite rules, edit the Rust registry — not this file.
//
// Note: OpenCode Desktop runs its server in an Electron utility process (Node.js)
// where Bun.$ is unavailable. The plugin falls back to child_process in that case.

function execRtk($: any, args: string[]): Promise<{ stdout: string }> {
if ($) {
return $`rtk ${args}`.quiet().nothrow().then((r: any) => ({
stdout: String(r.stdout).trim(),
}))
}
return new Promise((resolve) => {
execFile("rtk", args, { encoding: "utf8", timeout: 5000 }, (_err, stdout) => {
resolve({ stdout: (stdout ?? "").trim() })
})
})
}

export const RtkOpenCodePlugin: Plugin = async ({ $ }) => {
try {
await $`which rtk`.quiet()
if ($) {
await $`which rtk`.quiet()
} else {
execFileSync("which", ["rtk"], { encoding: "utf8", timeout: 5000 })
}
} catch {
console.warn("[rtk] rtk binary not found in PATH — plugin disabled")
return {}
Expand All @@ -26,10 +47,9 @@ export const RtkOpenCodePlugin: Plugin = async ({ $ }) => {
if (typeof command !== "string" || !command) return

try {
const result = await $`rtk rewrite ${command}`.quiet().nothrow()
const rewritten = String(result.stdout).trim()
if (rewritten && rewritten !== command) {
;(args as Record<string, unknown>).command = rewritten
const result = await execRtk($, ["rewrite", command])
if (result.stdout && result.stdout !== command) {
;(args as Record<string, unknown>).command = result.stdout
}
} catch {
// rtk rewrite failed — pass through unchanged
Expand Down
205 changes: 205 additions & 0 deletions hooks/opencode/tests/rtk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { describe, it, beforeEach, afterEach } from "node:test"
import assert from "node:assert/strict"
import { writeFileSync, chmodSync, mkdtempSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"

// Minimal mock types matching the OpenCode plugin interface
type PluginInput = { tool: string; sessionID?: string; callID?: string }
type PluginOutput = { args: Record<string, unknown> }
type Hooks = { "tool.execute.before"?: (input: PluginInput, output: PluginOutput) => Promise<void> }

const { RtkOpenCodePlugin } = await import("../rtk.ts")

// Create a mock $ that simulates Bun.$ behavior.
// Bun.$ flattens arrays into space-separated args in template literals.
function mock$(rewrites: Record<string, string>) {
return (strings: TemplateStringsArray, ...values: any[]) => {
const parts: string[] = []
for (let i = 0; i < strings.length; i++) {
parts.push(strings[i])
if (i < values.length) {
const v = values[i]
parts.push(Array.isArray(v) ? v.join(" ") : String(v))
}
}
const cmd = parts.join("").trim()

const result = (stdout: string) => ({
stdout: Buffer.from(stdout),
exitCode: 0,
})

return {
quiet: () => {
if (cmd.includes("which rtk")) return Promise.resolve(result("/usr/bin/rtk"))
const prefix = "rtk rewrite "
if (cmd.startsWith(prefix)) {
const original = cmd.slice(prefix.length)
const rewritten = rewrites[original] ?? original
return { nothrow: () => Promise.resolve(result(rewritten)) }
}
return Promise.resolve(result(""))
},
}
}
}

// Create a fake rtk binary that simulates `rtk rewrite` behavior.
function createFakeRtk(rewrites: Record<string, string>): { dir: string; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), "rtk-test-"))
const cases = Object.entries(rewrites)
.map(([k, v]) => ` "${k}") echo "${v}" ;;`)
.join("\n")
const script = `#!/bin/sh
if [ "$1" = "rewrite" ]; then
shift
CMD="$*"
case "$CMD" in
${cases}
*) echo "$CMD" ;;
esac
exit 0
fi
exit 1
`
const bin = join(dir, "rtk")
writeFileSync(bin, script)
chmodSync(bin, 0o755)
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }
}

describe("RtkOpenCodePlugin", () => {
describe("initialization with $ (Bun path)", () => {
it("enables when $ finds rtk", async () => {
const $ = mock$({})
const hooks = (await RtkOpenCodePlugin({ $, project: {}, directory: "/tmp", worktree: "/tmp" })) as Hooks
assert.ok(hooks["tool.execute.before"])
})

it("disables when $ cannot find rtk", async () => {
const $ = (_strings: TemplateStringsArray, ..._args: any[]) => ({
quiet: () => Promise.reject(new Error("command not found: rtk")),
})
const hooks = (await RtkOpenCodePlugin({ $, project: {}, directory: "/tmp", worktree: "/tmp" })) as Hooks
assert.equal(hooks["tool.execute.before"], undefined)
})
})

describe("initialization with $ = undefined (Node.js/Desktop path)", () => {
it("enables when fake rtk is in PATH", async () => {
const fake = createFakeRtk({})
const origPath = process.env.PATH
try {
process.env.PATH = `${fake.dir}:${origPath}`
const hooks = (await RtkOpenCodePlugin({ $: undefined, project: {}, directory: "/tmp", worktree: "/tmp" })) as Hooks
assert.ok(hooks["tool.execute.before"])
} finally {
process.env.PATH = origPath
fake.cleanup()
}
})

it("disables when rtk is not in PATH", async () => {
const origPath = process.env.PATH
try {
process.env.PATH = "/nonexistent"
const hooks = (await RtkOpenCodePlugin({ $: undefined, project: {}, directory: "/tmp", worktree: "/tmp" })) as Hooks
assert.equal(hooks["tool.execute.before"], undefined)
} finally {
process.env.PATH = origPath
}
})
})

describe("tool.execute.before hook via $ (Bun path)", () => {
let hook: (input: PluginInput, output: PluginOutput) => Promise<void>

beforeEach(async () => {
const $ = mock$({
"git status": "rtk git status",
"git log -n 5": "rtk git log -n 5",
"ls .": "rtk ls .",
})
const hooks = (await RtkOpenCodePlugin({ $, project: {}, directory: "/tmp", worktree: "/tmp" })) as Hooks
hook = hooks["tool.execute.before"]!
})

it("rewrites bash tool commands", async () => {
const output = { args: { command: "git status" } }
await hook({ tool: "bash" }, output)
assert.equal(output.args.command, "rtk git status")
})

it("rewrites shell tool commands", async () => {
const output = { args: { command: "git log -n 5" } }
await hook({ tool: "shell" }, output)
assert.equal(output.args.command, "rtk git log -n 5")
})

it("is case-insensitive on tool name", async () => {
const output = { args: { command: "ls ." } }
await hook({ tool: "Bash" }, output)
assert.equal(output.args.command, "rtk ls .")
})

it("ignores non-bash tools", async () => {
const output = { args: { command: "git status" } }
await hook({ tool: "read" }, output)
assert.equal(output.args.command, "git status")
})

it("passes through commands with no rewrite", async () => {
const output = { args: { command: "echo hello" } }
await hook({ tool: "bash" }, output)
assert.equal(output.args.command, "echo hello")
})

it("handles missing command gracefully", async () => {
const output = { args: {} } as any
await hook({ tool: "bash" }, output)
})

it("handles undefined args gracefully", async () => {
const output = { args: undefined } as any
await hook({ tool: "bash" }, output)
})

it("handles non-string command gracefully", async () => {
const output = { args: { command: 123 } } as any
await hook({ tool: "bash" }, output)
assert.equal(output.args.command, 123)
})
})

describe("tool.execute.before hook via child_process (Desktop path)", () => {
let hook: (input: PluginInput, output: PluginOutput) => Promise<void>
let fake: { dir: string; cleanup: () => void }
let origPath: string | undefined

beforeEach(async () => {
fake = createFakeRtk({ "git status": "rtk git status", "cargo test": "rtk cargo test" })
origPath = process.env.PATH
process.env.PATH = `${fake.dir}:${origPath}`
const hooks = (await RtkOpenCodePlugin({ $: undefined, project: {}, directory: "/tmp", worktree: "/tmp" })) as Hooks
hook = hooks["tool.execute.before"]!
})

afterEach(() => {
process.env.PATH = origPath
fake.cleanup()
})

it("rewrites commands via child_process", async () => {
const output = { args: { command: "git status" } }
await hook({ tool: "bash" }, output)
assert.equal(output.args.command, "rtk git status")
})

it("passes through unknown commands", async () => {
const output = { args: { command: "echo hello" } }
await hook({ tool: "bash" }, output)
assert.equal(output.args.command, "echo hello")
})
})
})