Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary>Install from source instead</summary>

```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
```

</details>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
```
Expand Down
5 changes: 3 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
50 changes: 37 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,41 @@
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<unknown> }
}

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(
`[smartsnip] '${config.snipPath}' not found in PATH — plugin disabled. ` +
"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.
Expand All @@ -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)
Expand All @@ -72,9 +81,24 @@ const SmartSnipPlugin: Plugin = async ({ client, directory }) => {
}
}

const plugin: PluginModule = {
async function setup(ctx: PluginContextV2): Promise<void> {
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
32 changes: 27 additions & 5 deletions src/snip-cli.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -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
}
Expand Down
38 changes: 23 additions & 15 deletions src/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Savings | null> {
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
}
Expand Down
73 changes: 73 additions & 0 deletions test/node-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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 })
}
3 changes: 2 additions & 1 deletion test/plugin-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
})
})
Loading
Loading