diff --git a/src/index.test.ts b/src/index.test.ts index 7401699..ff03b29 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,44 @@ import { describe, it, expect, beforeEach } from "vitest" -import { toolExecuteBefore } from "./index" +import { createToolExecuteBefore } from "./index" + +const WRAPPED_COMMANDS = new Set([ + "go test ./...", + "git log", + "docker build -t app .", + "git log -10", + "find / -name \"*.log\" 2>&1", + "cmd 1>&2", + "find / -name \"*.log\" 2>&1 | grep error", + "cmd1 2>&1 && cmd2", + "cat file.json | jq '.content | .text'", + "cat file.json | jq \".content | .text\"", + "cat file.json | jq '.content[0].text | fromjson'", + "cat file.json | jq '.a | .b | .c'", + "echo \"hello | world\" | cat", + "go build", + "go run", + "ls", + "sleep 1", + "sleep 2", + "test -f foo.txt", + "echo missing", + "cmd", + "cmd1", + "cmd2", + "go test", + "cat", + "cat file.json", + "echo", + "head", + "grep", + "find", + "jq", +]) + +async function defaultShouldWrap(cmd: string): Promise { + const baseCmd = cmd.split(/\s+/)[0] + return WRAPPED_COMMANDS.has(cmd) || WRAPPED_COMMANDS.has(baseCmd) +} describe("toolExecuteBefore", () => { let mockInput: { tool: string; sessionID: string; callID: string } @@ -10,190 +49,233 @@ 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(defaultShouldWrap)(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(defaultShouldWrap)(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(defaultShouldWrap)(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(defaultShouldWrap)(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(defaultShouldWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- git log | 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(defaultShouldWrap)(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(defaultShouldWrap)(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(defaultShouldWrap)(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(defaultShouldWrap)(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(defaultShouldWrap)(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(defaultShouldWrap)(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(defaultShouldWrap)(mockInput, mockOutput) expect(mockOutput.args.command).toBe("go test") }) - describe("unproxyable shell builtins", () => { - it("should skip cd", async () => { + describe("commands that should not be wrapped", () => { + it("should skip cd when shouldWrap returns false", async () => { + const shouldSkip = async (cmd: string) => !cmd.startsWith("cd ") mockOutput.args.command = "cd /tmp" - await toolExecuteBefore(mockInput, mockOutput) + await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) expect(mockOutput.args.command).toBe("cd /tmp") }) - it("should skip source", async () => { + it("should skip source when shouldWrap returns false", async () => { + const shouldSkip = async (cmd: string) => !cmd.startsWith("source ") mockOutput.args.command = "source ~/.bashrc" - await toolExecuteBefore(mockInput, mockOutput) + await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) expect(mockOutput.args.command).toBe("source ~/.bashrc") }) - it("should skip . (dot)", async () => { + it("should skip . (dot) when shouldWrap returns false", async () => { + const shouldSkip = async (cmd: string) => !cmd.startsWith(". ") mockOutput.args.command = ". ./env.sh" - await toolExecuteBefore(mockInput, mockOutput) + await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) expect(mockOutput.args.command).toBe(". ./env.sh") }) - it("should skip export", async () => { + it("should skip export when shouldWrap returns false", async () => { + const shouldSkip = async (cmd: string) => !cmd.startsWith("export ") mockOutput.args.command = "export FOO=bar" - await toolExecuteBefore(mockInput, mockOutput) + await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) expect(mockOutput.args.command).toBe("export FOO=bar") }) - it("should skip alias", async () => { + it("should skip alias when shouldWrap returns false", async () => { + const shouldSkip = async (cmd: string) => !cmd.startsWith("alias ") mockOutput.args.command = 'alias ll="ls -la"' - await toolExecuteBefore(mockInput, mockOutput) + await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) expect(mockOutput.args.command).toBe('alias ll="ls -la"') }) - it("should skip unset", async () => { + it("should skip unset when shouldWrap returns false", async () => { + const shouldSkip = async (cmd: string) => !cmd.startsWith("unset ") mockOutput.args.command = "unset VAR" - await toolExecuteBefore(mockInput, mockOutput) + await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) expect(mockOutput.args.command).toBe("unset VAR") }) - it("should skip export with env var prefix", async () => { + it("should skip export with env var prefix when shouldWrap returns false", async () => { + const shouldSkip = async (cmd: string) => { + const bare = cmd.replace(/^[A-Za-z_][A-Za-z0-9_]*=[^\s]* +/, "") + return !bare.startsWith("export ") + } mockOutput.args.command = "CGO_ENABLED=0 export FOO=bar" - await toolExecuteBefore(mockInput, mockOutput) + await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) expect(mockOutput.args.command).toBe("CGO_ENABLED=0 export FOO=bar") }) - it("should skip cd but snip chained command", async () => { + it("should skip cd but wrap chained command via shouldWrap", async () => { + const shouldSkip = async (cmd: string) => !cmd.startsWith("cd ") mockOutput.args.command = "cd /tmp && ls" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cd /tmp && snip ls") + await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("cd /tmp && snip run -- ls") }) }) 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(defaultShouldWrap)(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(defaultShouldWrap)(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(defaultShouldWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- find / -name \"*.log\" 2>&1 | 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(defaultShouldWrap)(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(defaultShouldWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cat file.json | 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(defaultShouldWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe('snip run -- cat file.json | 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(defaultShouldWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cat file.json | 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(defaultShouldWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cat file.json | 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(defaultShouldWrap)(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(defaultShouldWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe('snip run -- echo "hello | world" | 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") + }) + }) + + 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") }) }) }) \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e032713..44accd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,15 @@ 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) { @@ -25,55 +22,76 @@ function findFirstPipe(command: string): number { 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 (await shouldWrap(bareCmd)) { + return `${envPrefix}snip run -- ${bareCmd}` + } + return command } -export const toolExecuteBefore: NonNullable = async (input, output) => { - if (input.tool !== "bash") return +export function createToolExecuteBefore(shouldWrap: (cmd: string) => Promise) { + return async (input: Parameters>[0], output: Parameters>[1]) => { + try { + if (input.tool !== "bash") return - const command = output.args.command - if (!command || typeof command !== "string") return - if (command.startsWith("snip ")) return + const command = output.args.command + if (!command || typeof command !== "string") return + if (command.startsWith("snip ")) 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 pipeIdx = findFirstPipe(command) + if (pipeIdx !== -1) { + const firstCmd = command.slice(0, pipeIdx).trimEnd() + const rest = command.slice(pipeIdx) + output.args.command = (await snipCommand(firstCmd, shouldWrap)) + ' ' + rest + return + } - const segments = command.split(OPERATOR_RE) + const segments = command.split(OPERATOR_RE) - if (segments.length === 1) { - output.args.command = snipCommand(command) - return - } + if (segments.length === 1) { + output.args.command = await snipCommand(command, shouldWrap) + return + } - output.args.command = segments - .map((segment) => OPERATOR_RE.test(segment) ? segment : snipCommand(segment)) - .join("") + const processed = await Promise.all( + segments.map((segment) => + OPERATOR_RE.test(segment) ? Promise.resolve(segment) : snipCommand(segment, shouldWrap) + ) + ) + output.args.command = processed.join("") + } catch { + // leave command unmodified on any unexpected error + } + } } -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.log({ level: "warn", message: "[snip] snip binary not found in PATH — plugin disabled" }).catch(() => {}) return {} } + const shouldWrap = async (cmd: string): Promise => { + try { + const result = await $`snip check -- ${{raw: cmd}}`.nothrow().quiet() + return result.exitCode === 0 + } catch (err) { + await client.log({ 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), } }