diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1f52d34..bd37b79 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,6 +13,11 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '24'
- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun test
+ - run: bun run build
+ - run: bun run smoke:node
diff --git a/README.md b/README.md
index 34a8094..fcdecba 100644
--- a/README.md
+++ b/README.md
@@ -44,15 +44,17 @@ brew install edouard-claude/tap/snip
That's it. No new tools, no prompt overhead. If snip isn't on PATH the plugin
disables itself with a warning, so it's safe in a shared repo config.
+Works in the opencode CLI, Desktop, and opencode 2.x.
Install from source instead
```bash
git clone https://github.com/carson2222/opencode-smartsnip.git
+cd opencode-smartsnip && bun run build
mkdir -p ~/.config/opencode/plugins
-printf 'export { default } from "%s/src/index"\n' "$PWD/opencode-smartsnip" \
- > ~/.config/opencode/plugins/smartsnip.ts
+printf 'export { default } from "%s/dist/index.js"\n' "$PWD" \
+ > ~/.config/opencode/plugins/smartsnip.js
```
@@ -125,7 +127,7 @@ Optional. `~/.config/opencode/smartsnip.json`, overridable per project in
- `deny` — never wrap these (`"cmd"` or `"cmd subcommand"`)
- `allow` — force wrap-eligibility, wins over deny
-- `toast` — once per session, a small TUI toast with tokens saved
+- `toast` — once per session, a small TUI toast with tokens saved (opencode 1.x only)
- `stripMimicry` — strip stray `snip` prefixes the agent picked up from history
before re-deciding (default on). Turn off only if you wrap commands via snip
filter dirs that smartsnip doesn't scan
@@ -226,6 +228,8 @@ the rare command too complex to parse, set `quiet_no_filter = true` under `[disp
bun install
bun test # includes a replay of 656 sanitized real-world commands
bun run typecheck
+bun run build
+bun run smoke:node
bun run generate:filters # re-sync allowlist from the pinned upstream snip release
bun run measure --days 7 # replay your real bash history through snip (the Numbers)
```
diff --git a/llms.txt b/llms.txt
index 261ff29..e5a534b 100644
--- a/llms.txt
+++ b/llms.txt
@@ -5,8 +5,9 @@
## What it does
-- Hooks `tool.execute.before` for the bash tool and rewrites wrap-eligible commands
- from `git status` to `snip git status` before execution.
+- Rewrites wrap-eligible shell commands from `git status` to `snip git status`
+ before execution. Works in the opencode CLI, Desktop, and 2.x (1.x hooks
+ `tool.execute.before` on `bash`, 2.x hooks `execute.before` on `shell`).
- snip (https://github.com/edouard-claude/snip) filters the output (measured -72% on git, -48% on pnpm) before it
enters the session.
- Anything uncertain (heredocs, control flow, `$( )`, pipes-downstream, unknown
diff --git a/package.json b/package.json
index 1f015b7..cf98667 100644
--- a/package.json
+++ b/package.json
@@ -3,9 +3,14 @@
"version": "0.2.0",
"description": "Cuts shell-output tokens in opencode. Wraps only what snip can filter — everything else runs byte-identical.",
"type": "module",
- "main": "src/index.ts",
- "types": "src/index.ts",
+ "main": "dist/index.js",
+ "exports": {
+ ".": "./dist/index.js",
+ "./server": "./dist/index.js",
+ "./package.json": "./package.json"
+ },
"files": [
+ "dist",
"src",
"bin",
"commands"
@@ -17,9 +22,11 @@
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
+ "build": "bun build src/index.ts --target=node --format=esm --external bun:sqlite --outfile=dist/index.js",
+ "smoke:node": "node test/node-smoke.mjs",
"generate:filters": "bun scripts/generate-filters.ts",
"measure": "bun scripts/measure-savings.ts",
- "prepublishOnly": "bun run typecheck && bun test"
+ "prepublishOnly": "bun run typecheck && bun test && bun run build && bun run smoke:node"
},
"engines": {
"bun": ">=1.0.0"
diff --git a/src/index.ts b/src/index.ts
index 26ff133..99e6cb7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,21 +1,25 @@
-import type { Plugin, PluginModule } from "@opencode-ai/plugin"
+import type { Plugin } from "@opencode-ai/plugin"
import { loadConfig } from "./config"
import { buildMatchTable } from "./filters"
import { rewrite } from "./router"
import { resolveSnip } from "./snip-cli"
import { formatTokens, nowUtcSnipFormat, savingsSince } from "./stats"
-// Use opencode's V1 plugin module shape. If the default export is not a
-// `{ id, server }` object, opencode falls back to its legacy loader, which walks
-// every runtime export and treats each one as a plugin. Keep this entry to a
-// single default export; import library helpers from their own modules.
+interface ShellEvent {
+ tool: string
+ input: unknown
+}
-const SmartSnipPlugin: Plugin = async ({ client, directory }) => {
- // POSIX parser — PowerShell/native Windows is a non-goal for now
- if (process.platform === "win32") return {}
+interface PluginContextV2 {
+ location: { directory: string }
+ tool: { hook: (name: "execute.before", callback: (event: ShellEvent) => void) => Promise }
+}
+function init(directory: string) {
+ // POSIX parser — PowerShell/native Windows is a non-goal for now
+ if (process.platform === "win32") return null
const config = loadConfig(directory)
- if (!config.enabled) return {}
+ if (!config.enabled) return null
if (!resolveSnip(config.snipPath)) {
console.warn(
@@ -23,10 +27,15 @@ const SmartSnipPlugin: Plugin = async ({ client, directory }) => {
"Install: brew install edouard-claude/tap/snip, " +
"or go install github.com/edouard-claude/snip@latest",
)
- return {}
+ return null
}
+ return { config, table: buildMatchTable(config) }
+}
- const table = buildMatchTable(config)
+const SmartSnipPlugin: Plugin = async ({ client, directory }) => {
+ const state = init(directory)
+ if (!state) return {}
+ const { config, table } = state
// Savings toast state: report once per session, only counting savings
// accrued after this plugin instance started.
@@ -51,7 +60,7 @@ const SmartSnipPlugin: Plugin = async ({ client, directory }) => {
const sessionID = (event as { properties?: { sessionID?: string } }).properties?.sessionID
if (!sessionID || toastedSessions.has(sessionID)) return
- const savings = savingsSince(startedAt)
+ const savings = await savingsSince(startedAt)
if (!savings || savings.savedTokens <= reportedSavedTokens) return
toastedSessions.add(sessionID)
@@ -72,9 +81,24 @@ const SmartSnipPlugin: Plugin = async ({ client, directory }) => {
}
}
-const plugin: PluginModule = {
+async function setup(ctx: PluginContextV2): Promise {
+ const state = init(ctx.location.directory)
+ if (!state) return
+ const { config, table } = state
+ await ctx.tool.hook("execute.before", (event) => {
+ if (event.tool !== "shell") return
+ const input = event.input
+ if (typeof input !== "object" || input === null || !("command" in input)) return
+ if (typeof input.command !== "string") return
+ const command = rewrite(input.command, table, config)
+ if (command !== input.command) event.input = { ...input, command }
+ })
+}
+
+const plugin = {
id: "opencode-smartsnip",
server: SmartSnipPlugin,
+ setup,
}
export default plugin
diff --git a/src/snip-cli.ts b/src/snip-cli.ts
index 86c942e..371f47a 100644
--- a/src/snip-cli.ts
+++ b/src/snip-cli.ts
@@ -1,3 +1,7 @@
+import { spawnSync } from "node:child_process"
+import { accessSync, constants, statSync } from "node:fs"
+import { delimiter, resolve } from "node:path"
+
export const PINNED_SNIP_VERSION = "0.25.2"
export const SNIP_NATIVE_SUBCOMMANDS = new Set([
@@ -6,16 +10,34 @@ export const SNIP_NATIVE_SUBCOMMANDS = new Set([
"inspect",
])
+function isExecutableFile(file: string): boolean {
+ try {
+ accessSync(file, constants.X_OK)
+ return statSync(file).isFile()
+ } catch {
+ return false
+ }
+}
+
export function resolveSnip(snipPath: string): string | null {
- // Explicit PATH reflects runtime changes; Bun.which otherwise uses its startup snapshot.
- return Bun.which(snipPath, { PATH: process.env["PATH"] ?? "" })
+ if (!snipPath) return null
+ if (snipPath.includes("/")) {
+ const file = resolve(snipPath)
+ return isExecutableFile(file) ? file : null
+ }
+ for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
+ if (!dir) continue
+ const file = resolve(dir, snipPath)
+ if (isExecutableFile(file)) return file
+ }
+ return null
}
export function snipVersion(resolved: string): string | null {
try {
- const r = Bun.spawnSync([resolved, "--version"], { stderr: "ignore" })
- if (r.exitCode !== 0) return null
- return r.stdout.toString().match(/\d+\.\d+\.\d+/)?.[0] ?? null
+ const r = spawnSync(resolved, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
+ if (r.status !== 0) return null
+ return r.stdout.match(/\d+\.\d+\.\d+/)?.[0] ?? null
} catch {
return null
}
diff --git a/src/stats.ts b/src/stats.ts
index 72c7b52..346e778 100644
--- a/src/stats.ts
+++ b/src/stats.ts
@@ -19,26 +19,34 @@ export function defaultTrackingDbPath(): string {
* Total snip savings recorded at or after `sinceUtcIso` (snip stores UTC
* `datetime('now')` strings, e.g. "2026-06-09 21:36:38").
*/
-export function savingsSince(
+export async function savingsSince(
sinceUtcIso: string,
dbPath = defaultTrackingDbPath(),
-): Savings | null {
+): Promise {
try {
if (!existsSync(dbPath)) return null
- // bun:sqlite is built into the Bun runtime opencode plugins run under
- const { Database } = require("bun:sqlite") as typeof import("bun:sqlite")
- const db = new Database(dbPath, { readonly: true })
- try {
- const row = db
- .query(
- "SELECT COUNT(*) AS commands, COALESCE(SUM(saved_tokens), 0) AS savedTokens FROM commands WHERE timestamp >= ?",
- )
- .get(sinceUtcIso) as { commands: number; savedTokens: number } | undefined
- if (!row) return null
- return { commands: row.commands, savedTokens: row.savedTokens }
- } finally {
- db.close()
+ const query = "SELECT COUNT(*) AS commands, COALESCE(SUM(saved_tokens), 0) AS savedTokens FROM commands WHERE timestamp >= ?"
+ let row: unknown
+ if ("Bun" in globalThis) {
+ const { Database } = await import("bun:sqlite")
+ const db = new Database(dbPath, { readonly: true })
+ try {
+ row = db.query(query).get(sinceUtcIso)
+ } finally {
+ db.close()
+ }
+ } else {
+ const { DatabaseSync } = await import("node:sqlite")
+ const db = new DatabaseSync(dbPath, { readOnly: true })
+ try {
+ row = db.prepare(query).get(sinceUtcIso)
+ } finally {
+ db.close()
+ }
}
+ if (typeof row !== "object" || row === null || !("commands" in row) || !("savedTokens" in row)) return null
+ if (typeof row.commands !== "number" || typeof row.savedTokens !== "number") return null
+ return { commands: row.commands, savedTokens: row.savedTokens }
} catch {
return null
}
diff --git a/test/node-smoke.mjs b/test/node-smoke.mjs
new file mode 100644
index 0000000..7b3fa02
--- /dev/null
+++ b/test/node-smoke.mjs
@@ -0,0 +1,73 @@
+import assert from "node:assert/strict"
+import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { dirname, join } from "node:path"
+import { DatabaseSync } from "node:sqlite"
+import { fileURLToPath, pathToFileURL } from "node:url"
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "..")
+const temp = mkdtempSync(join(tmpdir(), "smartsnip-node-"))
+const originalPath = process.env.PATH
+const originalHome = process.env.HOME
+
+try {
+ const pkg = join(temp, "node_modules", "opencode-smartsnip")
+ mkdirSync(join(pkg, "dist"), { recursive: true })
+ copyFileSync(join(root, "dist", "index.js"), join(pkg, "dist", "index.js"))
+ copyFileSync(join(root, "package.json"), join(pkg, "package.json"))
+
+ const bin = join(temp, "bin")
+ mkdirSync(bin)
+ const snip = join(bin, "snip")
+ writeFileSync(snip, "#!/bin/sh\nexit 0\n")
+ chmodSync(snip, 0o755)
+ process.env.PATH = bin
+ process.env.HOME = temp
+
+ const dbDir = join(temp, ".local", "share", "snip")
+ mkdirSync(dbDir, { recursive: true })
+ const db = new DatabaseSync(join(dbDir, "tracking.db"))
+ db.exec("CREATE TABLE commands (timestamp TEXT, saved_tokens INTEGER)")
+ db.prepare("INSERT INTO commands VALUES (?, ?)").run("9999-01-01 00:00:00", 1200)
+ db.close()
+
+ const project = join(temp, "project")
+ mkdirSync(join(project, ".opencode"), { recursive: true })
+ writeFileSync(join(project, ".opencode", "smartsnip.json"), JSON.stringify({ scanUserFilters: false }))
+
+ const { default: plugin } = await import(pathToFileURL(join(pkg, "dist", "index.js")).href)
+ assert.equal(plugin.id, "opencode-smartsnip")
+ const resolver = join(temp, "resolve.mjs")
+ writeFileSync(resolver, "import root from 'opencode-smartsnip'; import server from 'opencode-smartsnip/server'; export default [root, server]\n")
+ const { default: entries } = await import(pathToFileURL(resolver).href)
+ assert.deepEqual(entries, [plugin, plugin])
+ let toast
+ const hooks = await plugin.server({ directory: project, client: { tui: { showToast: async (value) => { toast = value } } } })
+ const output = { args: { command: "git status" } }
+ await hooks["tool.execute.before"]({ tool: "bash" }, output)
+ assert.equal(output.args.command, "snip git status")
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID: "test" } } })
+ assert.match(toast.body.message, /1\.2k tokens across 1 commands/)
+
+ let before
+ await plugin.setup({
+ location: { directory: project },
+ tool: {
+ hook: async (name, callback) => {
+ assert.equal(name, "execute.before")
+ before = callback
+ },
+ },
+ })
+ assert.equal(typeof before, "function")
+ const event = { tool: "shell", input: { command: "git status", workdir: project } }
+ before(event)
+ assert.deepEqual(event.input, { command: "snip git status", workdir: project })
+ console.log("Node desktop smoke passed (v1 bash + SQLite toast + v2 shell)")
+} finally {
+ if (originalPath === undefined) delete process.env.PATH
+ else process.env.PATH = originalPath
+ if (originalHome === undefined) delete process.env.HOME
+ else process.env.HOME = originalHome
+ rmSync(temp, { recursive: true, force: true })
+}
diff --git a/test/plugin-export.test.ts b/test/plugin-export.test.ts
index eafde14..b9bb596 100644
--- a/test/plugin-export.test.ts
+++ b/test/plugin-export.test.ts
@@ -10,10 +10,11 @@ describe("plugin entry — opencode load contract", () => {
expect(Object.keys(entry)).toEqual(["default"])
})
- test("default export has an id and server plugin", () => {
+ test("default export has an id and both plugin APIs", () => {
expect(entry.default).toEqual({
id: "opencode-smartsnip",
server: expect.any(Function),
+ setup: expect.any(Function),
})
})
})
diff --git a/test/snip-cli.test.ts b/test/snip-cli.test.ts
new file mode 100644
index 0000000..7bf5221
--- /dev/null
+++ b/test/snip-cli.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
+import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { delimiter, join, relative } from "node:path"
+import { resolveSnip, snipVersion } from "../src/snip-cli"
+
+describe("snip executable lookup", () => {
+ let dir: string
+ let originalPath: string | undefined
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "smartsnip-lookup-"))
+ originalPath = process.env["PATH"]
+ })
+
+ afterEach(() => {
+ if (originalPath === undefined) delete process.env["PATH"]
+ else process.env["PATH"] = originalPath
+ rmSync(dir, { recursive: true, force: true })
+ })
+
+ test("finds the first executable file on PATH and skips empty entries", () => {
+ const first = join(dir, "snip")
+ writeFileSync(first, "#!/bin/sh\nprintf 'snip 0.25.2\\n'\n")
+ chmodSync(first, 0o755)
+ process.env["PATH"] = ["", dir, ""].join(delimiter)
+ expect(resolveSnip("snip")).toBe(first)
+ expect(snipVersion(first)).toBe("0.25.2")
+ })
+
+ test("empty PATH entries do not search the current directory", () => {
+ const file = join(dir, "snip")
+ writeFileSync(file, "#!/bin/sh\nexit 0\n")
+ chmodSync(file, 0o755)
+ const cwd = process.cwd()
+ try {
+ process.chdir(dir)
+ process.env["PATH"] = delimiter
+ expect(resolveSnip("snip")).toBeNull()
+ } finally {
+ process.chdir(cwd)
+ }
+ })
+
+ test("rejects non-executable files and directories on PATH", () => {
+ const file = join(dir, "snip")
+ writeFileSync(file, "not executable")
+ chmodSync(file, 0o644)
+ process.env["PATH"] = dir
+ expect(resolveSnip("snip")).toBeNull()
+ expect(resolveSnip(".")).toBeNull()
+ })
+
+ test("accepts absolute and cwd-relative paths with a slash", () => {
+ const file = join(dir, "snip")
+ writeFileSync(file, "#!/bin/sh\nexit 0\n")
+ chmodSync(file, 0o755)
+ process.env["PATH"] = ""
+ expect(resolveSnip(file)).toBe(file)
+ expect(resolveSnip(relative(process.cwd(), file))).toBe(file)
+ chmodSync(file, 0o644)
+ expect(resolveSnip(file)).toBeNull()
+ })
+})
diff --git a/test/startup.test.ts b/test/startup.test.ts
index e008a51..27fe7ff 100644
--- a/test/startup.test.ts
+++ b/test/startup.test.ts
@@ -114,6 +114,43 @@ describe("startup probe", () => {
expect(notBash.args.command).toBe("git status")
})
+ test("v2 shell hook rewrites only string commands and keeps other input fields", async () => {
+ makeStub(stubDir, "snip")
+ process.env["PATH"] = stubDir
+ const dir = project()
+ dirs.push(dir)
+
+ let before: ((event: { tool: string; input: unknown }) => void) | undefined
+ await plugin.setup({
+ location: { directory: dir },
+ tool: {
+ hook: async (name, callback) => {
+ expect(name).toBe("execute.before")
+ before = callback
+ return { dispose: async () => {} }
+ },
+ },
+ })
+ if (!before) throw new Error("hook was not registered")
+
+ const shell = { tool: "shell", input: { command: "git status", workdir: dir, timeout: 123 } }
+ before(shell)
+ expect(shell.input).toEqual({ command: "snip git status", workdir: dir, timeout: 123 })
+
+ const other = { tool: "read", input: { command: "git status" } }
+ before(other)
+ expect(other.input.command).toBe("git status")
+
+ const invalid = { tool: "shell", input: { command: 42 } }
+ before(invalid)
+ expect(invalid.input.command).toBe(42)
+
+ const unchanged = { tool: "shell", input: { command: "npm view react version" } }
+ const original = unchanged.input
+ before(unchanged)
+ expect(unchanged.input).toBe(original)
+ })
+
test("a disabled plugin registers no hooks at all", async () => {
makeStub(stubDir, "snip")
process.env["PATH"] = stubDir
@@ -121,5 +158,11 @@ describe("startup probe", () => {
dirs.push(dir)
expect(await start(dir)).toEqual({})
+ let registered = false
+ await plugin.setup({
+ location: { directory: dir },
+ tool: { hook: async () => { registered = true } },
+ })
+ expect(registered).toBe(false)
})
})
diff --git a/test/stats.test.ts b/test/stats.test.ts
index 5b66297..395126e 100644
--- a/test/stats.test.ts
+++ b/test/stats.test.ts
@@ -26,15 +26,15 @@ function makeDb(): string {
}
describe("savingsSince", () => {
- test("sums rows at/after the cutoff", () => {
+ test("sums rows at/after the cutoff", async () => {
const db = makeDb()
- expect(savingsSince("2026-01-01 00:00:00", db)).toEqual({ commands: 2, savedTokens: 770 })
- expect(savingsSince("2026-01-02 00:00:00", db)).toEqual({ commands: 1, savedTokens: 680 })
- expect(savingsSince("2026-01-03 00:00:00", db)).toEqual({ commands: 0, savedTokens: 0 })
+ expect(await savingsSince("2026-01-01 00:00:00", db)).toEqual({ commands: 2, savedTokens: 770 })
+ expect(await savingsSince("2026-01-02 00:00:00", db)).toEqual({ commands: 1, savedTokens: 680 })
+ expect(await savingsSince("2026-01-03 00:00:00", db)).toEqual({ commands: 0, savedTokens: 0 })
})
- test("fails soft on missing db", () => {
- expect(savingsSince("2026-01-01 00:00:00", "/nonexistent/tracking.db")).toBeNull()
+ test("fails soft on missing db", async () => {
+ expect(await savingsSince("2026-01-01 00:00:00", "/nonexistent/tracking.db")).toBeNull()
})
})