From 5d9a6a28498dae910eafb3288a522bb02f156962 Mon Sep 17 00:00:00 2001 From: Carson Date: Sat, 26 Sep 2026 18:50:27 -0400 Subject: [PATCH] fix: support opencode desktop and 2.x --- .github/workflows/ci.yml | 5 +++ README.md | 10 ++++-- llms.txt | 5 +-- package.json | 13 +++++-- src/index.ts | 50 +++++++++++++++++++------- src/snip-cli.ts | 32 ++++++++++++++--- src/stats.ts | 38 ++++++++++++-------- test/node-smoke.mjs | 73 ++++++++++++++++++++++++++++++++++++++ test/plugin-export.test.ts | 3 +- test/snip-cli.test.ts | 64 +++++++++++++++++++++++++++++++++ test/startup.test.ts | 43 ++++++++++++++++++++++ test/stats.test.ts | 12 +++---- 12 files changed, 300 insertions(+), 48 deletions(-) create mode 100644 test/node-smoke.mjs create mode 100644 test/snip-cli.test.ts 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() }) })