diff --git a/README.md b/README.md index 760bcdb..85f8fb0 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ The plugin uses the `tool.execute.before` hook to prefix all commands with `snip ## Development +```bash +npm ci # install dependencies (matches CI) +npm run typecheck # TypeScript type checking (CI step 1) +npm test # run tests with vitest (CI step 2) +``` + This package uses [semantic-release](https://semantic-release.gitbook.io/) for automated releases. Commit messages should follow the [Conventional Commits](https://www.conventionalcommits.org/) format: - `fix:` → patch release diff --git a/src/index.test.ts b/src/index.test.ts index 7401699..4706687 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest" -import { toolExecuteBefore } from "./index" +import { createToolExecuteBefore, hasSnipSubcommands } from "./index" + +const mockedWrap = async () => true describe("toolExecuteBefore", () => { let mockInput: { tool: string; sessionID: string; callID: string } @@ -10,190 +12,269 @@ describe("toolExecuteBefore", () => { mockOutput = { args: { command: "" } } }) - it("should prefix simple command with snip", async () => { + it("should prefix simple command with snip run --", async () => { mockOutput.args.command = "go test ./..." - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip go test ./...") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- go test ./...") }) it("should handle command with one env var prefix", async () => { mockOutput.args.command = "CGO_ENABLED=0 go test ./..." - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("CGO_ENABLED=0 snip go test ./...") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("CGO_ENABLED=0 snip run -- go test ./...") }) it("should handle command with multiple env var prefixes", async () => { mockOutput.args.command = "CGO_ENABLED=0 GOOS=linux go test ./..." - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("CGO_ENABLED=0 GOOS=linux snip go test ./...") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("CGO_ENABLED=0 GOOS=linux snip run -- go test ./...") }) it("should handle command with &&", async () => { mockOutput.args.command = "go test && go build" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip go test && snip go build") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- go test && snip run -- go build") }) it("should handle command with |", async () => { mockOutput.args.command = "git log | head" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip git log | head") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- git log | snip run -- head") }) it("should handle command with ;", async () => { mockOutput.args.command = "go test; go build" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip go test; snip go build") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- go test; snip run -- go build") }) it("should handle command with ||", async () => { mockOutput.args.command = "test -f foo.txt || echo missing" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip test -f foo.txt || snip echo missing") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- test -f foo.txt || snip run -- echo missing") }) it("should handle command with &", async () => { mockOutput.args.command = "sleep 1 & sleep 2 &" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip sleep 1 & snip sleep 2 &") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- sleep 1 & snip run -- sleep 2 &") }) it("should handle mixed operators", async () => { mockOutput.args.command = "go test && go build; go run" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip go test && snip go build; snip go run") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- go test && snip run -- go build; snip run -- go run") }) it("should handle env vars with operators", async () => { mockOutput.args.command = "FOO=bar go test && go build" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("FOO=bar snip go test && snip go build") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("FOO=bar snip run -- go test && snip run -- go build") }) it("should not double prefix already prefixed command", async () => { - mockOutput.args.command = "snip go test" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip go test") + mockOutput.args.command = "snip run -- go test" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- go test") }) it("should not modify non-bash tool calls", async () => { mockInput.tool = "read" mockOutput.args.command = "go test" - await toolExecuteBefore(mockInput, mockOutput) + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) expect(mockOutput.args.command).toBe("go test") }) - describe("unproxyable shell builtins", () => { - it("should skip cd", async () => { - mockOutput.args.command = "cd /tmp" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cd /tmp") - }) - - it("should skip source", async () => { - mockOutput.args.command = "source ~/.bashrc" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("source ~/.bashrc") - }) - - it("should skip . (dot)", async () => { - mockOutput.args.command = ". ./env.sh" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe(". ./env.sh") - }) - - it("should skip export", async () => { - mockOutput.args.command = "export FOO=bar" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("export FOO=bar") + describe("subcommand passthrough", () => { + it("should pass each pipe segment to shouldWrap", async () => { + const called: string[] = [] + const spy = async (c: string) => { called.push(c); return true } + mockOutput.args.command = "git log | head" + await createToolExecuteBefore(spy)(mockInput, mockOutput) + expect(called).toEqual(["git log", "head"]) }) - it("should skip alias", async () => { - mockOutput.args.command = 'alias ll="ls -la"' - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe('alias ll="ls -la"') + it("should pass each && segment to shouldWrap", async () => { + const called: string[] = [] + const spy = async (c: string) => { called.push(c); return true } + mockOutput.args.command = "cd /tmp && go test" + await createToolExecuteBefore(spy)(mockInput, mockOutput) + expect(called).toEqual(["cd /tmp", "go test"]) }) - it("should skip unset", async () => { - mockOutput.args.command = "unset VAR" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("unset VAR") + it("should pass mixed operator segments to shouldWrap", async () => { + const called: string[] = [] + const spy = async (c: string) => { called.push(c); return true } + mockOutput.args.command = "go test && go build; go run" + await createToolExecuteBefore(spy)(mockInput, mockOutput) + expect(called).toEqual(["go test", "go build", "go run"]) }) - it("should skip export with env var prefix", async () => { - mockOutput.args.command = "CGO_ENABLED=0 export FOO=bar" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("CGO_ENABLED=0 export FOO=bar") + it("should not call shouldWrap for already prefixed command", async () => { + const called: string[] = [] + const spy = async (c: string) => { called.push(c); return true } + mockOutput.args.command = "snip run -- go test" + await createToolExecuteBefore(spy)(mockInput, mockOutput) + expect(called).toEqual([]) }) - it("should skip cd but snip chained command", async () => { - mockOutput.args.command = "cd /tmp && ls" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cd /tmp && snip ls") + it("should not call shouldWrap for already prefixed segments in compound command", async () => { + const called: string[] = [] + const spy = async (c: string) => { called.push(c); return true } + mockOutput.args.command = "cd /tmp && snip run -- go test" + await createToolExecuteBefore(spy)(mockInput, mockOutput) + expect(called).toEqual(["cd /tmp"]) }) }) describe("redirections with &", () => { it("should not break 2>&1 redirection", async () => { mockOutput.args.command = "find / -name \"*.log\" 2>&1" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip find / -name \"*.log\" 2>&1") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- find / -name \"*.log\" 2>&1") }) it("should not break 1>&2 redirection", async () => { mockOutput.args.command = "cmd 1>&2" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip cmd 1>&2") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cmd 1>&2") }) it("should handle 2>&1 with pipe", async () => { mockOutput.args.command = "find / -name \"*.log\" 2>&1 | grep error" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip find / -name \"*.log\" 2>&1 | grep error") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- find / -name \"*.log\" 2>&1 | snip run -- grep error") }) it("should handle 2>&1 with chained commands", async () => { mockOutput.args.command = "cmd1 2>&1 && cmd2" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip cmd1 2>&1 && snip cmd2") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cmd1 2>&1 && snip run -- cmd2") }) }) describe("pipe expressions with quotes", () => { it("should not split pipes inside single quotes", async () => { mockOutput.args.command = "cat file.json | jq '.content | .text'" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip cat file.json | jq '.content | .text'") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cat file.json | snip run -- jq '.content | .text'") }) it("should not split pipes inside double quotes", async () => { mockOutput.args.command = 'cat file.json | jq ".content | .text"' - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe('snip cat file.json | jq ".content | .text"') + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe('snip run -- cat file.json | snip run -- jq ".content | .text"') }) it("should handle jq with fromjson", async () => { mockOutput.args.command = "cat file.json | jq '.content[0].text | fromjson'" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip cat file.json | jq '.content[0].text | fromjson'") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cat file.json | snip run -- jq '.content[0].text | fromjson'") }) it("should handle multiple pipes in jq", async () => { mockOutput.args.command = "cat file.json | jq '.a | .b | .c'" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip cat file.json | jq '.a | .b | .c'") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cat file.json | snip run -- jq '.a | .b | .c'") }) it("should handle pipe with || operator", async () => { mockOutput.args.command = "cmd1 || cmd2" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip cmd1 || snip cmd2") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cmd1 || snip run -- cmd2") }) it("should handle mixed quotes and pipes", async () => { mockOutput.args.command = 'echo "hello | world" | cat' - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe('snip echo "hello | world" | cat') + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe('snip run -- echo "hello | world" | snip run -- cat') + }) + }) + + describe("error guard", () => { + it("should leave command unmodified when shouldWrap throws", async () => { + const throwWrap = async () => { throw new Error("boom") } + mockOutput.args.command = "go test ./..." + await createToolExecuteBefore(throwWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("go test ./...") + }) + + it("should leave command unmodified when shouldWrap throws for compound commands", async () => { + const throwWrap = async () => { throw new Error("boom") } + mockOutput.args.command = "go test && go build" + await createToolExecuteBefore(throwWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("go test && go build") }) }) -}) \ No newline at end of file + + describe("mixed wrapping in compound commands", () => { + it("should wrap only segments that shouldWrap approves", async () => { + const selectiveWrap = async (cmd: string) => !cmd.startsWith("cd ") + mockOutput.args.command = "cd /tmp && go test" + await createToolExecuteBefore(selectiveWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("cd /tmp && snip run -- go test") + }) + + it("should skip all segments when shouldWrap always returns false", async () => { + const neverWrap = async () => false + mockOutput.args.command = "go test && go build" + await createToolExecuteBefore(neverWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("go test && go build") + }) + }) + + describe("already-prefixed segments in compound commands", () => { + it("should not double-prefix snip run -- in && chain", async () => { + mockOutput.args.command = "cd /tmp && snip run -- go test" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cd /tmp && snip run -- go test") + }) + + it("should not double-prefix snip run -- with env vars", async () => { + mockOutput.args.command = "FOO=bar snip run -- go test" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("FOO=bar snip run -- go test") + }) + + it("should not double-prefix snip run -- in pipe chain", async () => { + mockOutput.args.command = "cd /tmp && snip run -- go test | ls" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cd /tmp && snip run -- go test | snip run -- ls") + }) + + it("should not double-prefix snip run -- in complex chain with 2>&1", async () => { + mockOutput.args.command = "cd /tmp && snip run -- go test 2>&1 | ls" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cd /tmp && snip run -- go test 2>&1 | snip run -- ls") + }) + }) +}) + +describe("hasSnipSubcommands", () => { + it("should return true when snip check succeeds", async () => { + const mock$ = ((...args: any[]) => ({ + nothrow: () => ({ + quiet: async () => ({ exitCode: 0 }) + }) + })) as any + expect(await hasSnipSubcommands(mock$)).toBe(true) + }) + + it("should return true even when snip check exits non-zero (filter exists, cmd has no filter)", async () => { + const mock$ = ((...args: any[]) => ({ + nothrow: () => ({ + quiet: async () => ({ exitCode: 1 }) + }) + })) as any + expect(await hasSnipSubcommands(mock$)).toBe(true) + }) + + it("should return false when snip check subcommand is missing", async () => { + const mock$ = ((...args: any[]) => ({ + nothrow: () => ({ + quiet: async () => { throw new Error("not found") } + }) + })) as any + expect(await hasSnipSubcommands(mock$)).toBe(false) + }) +}) diff --git a/src/index.ts b/src/index.ts index e032713..8129412 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,79 +1,139 @@ import type { Hooks, Plugin } from "@opencode-ai/plugin" const ENV_VAR_RE = /^([A-Za-z_][A-Za-z0-9_]*=[^\s]* +)*/ -const UNPROXYABLE_COMMANDS = new Set([ - "cd", "source", ".", "export", "alias", "unset", "set", "shopt", "eval", "exec", -]) const OPERATOR_RE = /(\s*(?:&&|\|\||;)\s*|\s&\s?)/ -function findFirstPipe(command: string): number { - let inSingleQuote = false - let inDoubleQuote = false - - for (let i = 0; i < command.length; i++) { - const char = command[i] - - if (char === "'" && !inDoubleQuote) { - inSingleQuote = !inSingleQuote - } else if (char === '"' && !inSingleQuote) { - inDoubleQuote = !inDoubleQuote - } else if (char === '|' && !inSingleQuote && !inDoubleQuote) { - if (command[i + 1] === '|' || (i > 0 && command[i - 1] === '|')) { - i++ - continue - } - return i - } - } - - return -1 -} - -function snipCommand(command: string): string { +async function snipCommand(command: string, shouldWrap: (cmd: string) => Promise): Promise { const envPrefix = (command.match(ENV_VAR_RE) ?? [""])[0] const bareCmd = command.slice(envPrefix.length).trim() if (!bareCmd) return command - if (UNPROXYABLE_COMMANDS.has(bareCmd.split(/\s+/)[0])) return command - return `${envPrefix}snip ${bareCmd}` + if (bareCmd.startsWith("snip ")) return command + if (await shouldWrap(bareCmd)) { + return `${envPrefix}snip run -- ${bareCmd}` + } + return command } -export const toolExecuteBefore: NonNullable = async (input, output) => { - if (input.tool !== "bash") return +async function processSegment(segment: string, shouldWrap: (cmd: string) => Promise): Promise { + const parts = segment.split(OPERATOR_RE) + if (parts.length === 1) { + return await snipCommand(segment, shouldWrap) + } + const results = await Promise.all( + parts.map((part) => + OPERATOR_RE.test(part) ? part : snipCommand(part, shouldWrap) + ) + ) + return results.join("") +} - const command = output.args.command - if (!command || typeof command !== "string") return - if (command.startsWith("snip ")) return +export function createToolExecuteBefore(shouldWrap: (cmd: string) => Promise) { + return async (input: Parameters>[0], output: Parameters>[1]) => { + try { + if (input.tool !== "bash") return - if (findFirstPipe(command) !== -1) { - const pipeIdx = findFirstPipe(command) - const firstCmd = command.slice(0, pipeIdx).trimEnd() - const rest = command.slice(pipeIdx) - output.args.command = snipCommand(firstCmd) + ' ' + rest - return - } + const command = output.args.command + if (!command || typeof command !== "string") return + if (command.startsWith("snip ")) return + + // Split by pipes outside quotes (single |, not ||) + const pipeSegments: string[] = [] + let current = '' + let inSingleQuote = false + let inDoubleQuote = false + + for (let i = 0; i < command.length; i++) { + const char = command[i] + + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote + current += char + } else if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote + current += char + } else if (char === '|' && !inSingleQuote && !inDoubleQuote) { + if (command[i + 1] === '|') { + current += '||' + i++ + continue + } + pipeSegments.push(current) + pipeSegments.push('|') + current = '' + } else { + current += char + } + } + pipeSegments.push(current) - const segments = command.split(OPERATOR_RE) + if (pipeSegments.length <= 1) { + const segment = command.trim() + if (!segment) return + output.args.command = await processSegment(segment, shouldWrap) + return + } - if (segments.length === 1) { - output.args.command = snipCommand(command) - return + const commands: string[] = [] + for (const part of pipeSegments) { + if (part === '|') continue + const trimmed = part.trim() + if (!trimmed) continue + commands.push(await processSegment(trimmed, shouldWrap)) + } + output.args.command = commands.join(" | ") + } catch { + // leave command unmodified on any unexpected error + } } +} - output.args.command = segments - .map((segment) => OPERATOR_RE.test(segment) ? segment : snipCommand(segment)) - .join("") +export async function hasSnipSubcommands($: any): Promise { + try { + await $`snip check -- ls`.nothrow().quiet() + return true + } catch { + return false + } } -export const SnipPlugin: Plugin = async ({ $ }) => { +export const SnipPlugin: Plugin = async ({ $, client }) => { try { await $`which snip`.quiet() } catch { - console.warn("[snip] snip binary not found in PATH — plugin disabled") + await client.app.log({ body: { service: "snip", level: "warn", message: "[snip] snip binary not found in PATH — plugin disabled" } }).catch(() => {}) return {} } + if (!(await hasSnipSubcommands($))) { + await client.app.log({ body: { service: "snip", level: "warn", + message: "[snip] snip >= 0.16.0 required (snip check/run subcommands missing) — plugin disabled" } + }).catch(() => {}) + return {} + } + + const shouldWrap = async (cmd: string): Promise => { + try { + const words = cmd.split(/\s+/) + const w0 = {raw: words[0]} + const w1 = words.length > 1 ? {raw: words[1]} : undefined + const result = w1 !== undefined + ? await $`snip check -- ${w0} ${w1}`.nothrow().quiet() + : await $`snip check -- ${w0}`.nothrow().quiet() + return result.exitCode === 0 + } catch (err) { + await client.app.log({ body: { service: "snip", level: "warn", message: `[snip] snip check failed for ${cmd}`, extra: { error: String(err) } } }).catch(() => {}) + return false + } + } + return { - "tool.execute.before": toolExecuteBefore, + "tool.execute.before": createToolExecuteBefore(shouldWrap), + "experimental.chat.system.transform": async (_input, output) => { + output.system.push( + "The snip plugin automatically prefixes eligible commands with `snip run --`. " + + "Do NOT manually add `snip run --` to commands." + ) + }, } }