From eed7f17a84113ce4b61a8c385eb9e6efc11c6992 Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Mon, 18 May 2026 12:41:24 +0200 Subject: [PATCH 01/10] feat: use snip check/run subcommands instead of hardcoded unproxyable list Replaces the static UNPROXYABLE_COMMANDS set with runtime snip check calls to determine whether a command should be wrapped by snip. Only commands with existing snip filters (snip check exit 0) get prefixed with snip run --. Changes: - Introduce DI pattern createToolExecuteBefore(shouldWrap) for testability - Replace hardcoded unproxyable commands with snip check invocation - Use snip run -- prefix instead of bare snip - Deduplicate findFirstPipe call - Replace console.warn with client.log - Add try/catch guard for resilience Fixes #6, #7 --- src/index.test.ts | 210 ++++++++++++++++++++++++++++++++-------------- src/index.ts | 82 +++++++++++------- 2 files changed, 196 insertions(+), 96 deletions(-) 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), } } From 2aa80865a7855b90c27cc75372324af98cc9299d Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Mon, 18 May 2026 13:03:54 +0200 Subject: [PATCH 02/10] fix: split cmd into separate args for snip check snip check expected each word as a separate argument to match multi-word filters (e.g. "git log" matches filter git-log). Previously the whole cmd string was passed as one raw arg. --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 44accd6..3af004c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,7 +82,8 @@ export const SnipPlugin: Plugin = async ({ $, client }) => { const shouldWrap = async (cmd: string): Promise => { try { - const result = await $`snip check -- ${{raw: cmd}}`.nothrow().quiet() + const words = cmd.split(/\s+/) + const result = await $`snip check -- ${words.map(w => ({raw: w}))}`.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(() => {}) From 2ea4f7a27d5e043ce5ba32e4d8eef9b5c2f3b9d9 Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Mon, 18 May 2026 13:12:00 +0200 Subject: [PATCH 03/10] fix: handle all pipe segments, not just the first Previously only the first command before the first | was checked. Now the command is split by all pipes (outside quotes, ignoring ||) and each segment is individually processed (with compound operator handling within each segment). Non-wrapped segments preserve output, wrapped segments get snip run --. Segments joined with " | ". --- src/index.ts | 92 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 39 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3af004c..4bad947 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,29 +3,6 @@ import type { Hooks, Plugin } from "@opencode-ai/plugin" const ENV_VAR_RE = /^([A-Za-z_][A-Za-z0-9_]*=[^\s]* +)*/ 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 -} - 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() @@ -36,6 +13,19 @@ async function snipCommand(command: string, shouldWrap: (cmd: string) => Promise return command } +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("") +} + export function createToolExecuteBefore(shouldWrap: (cmd: string) => Promise) { return async (input: Parameters>[0], output: Parameters>[1]) => { try { @@ -45,27 +35,51 @@ export function createToolExecuteBefore(shouldWrap: (cmd: string) => Promise - OPERATOR_RE.test(segment) ? Promise.resolve(segment) : snipCommand(segment, shouldWrap) - ) - ) - output.args.command = processed.join("") + 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 } From d27373f34ef1160d4a420968019a948bd9d1702b Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Mon, 18 May 2026 13:26:58 +0200 Subject: [PATCH 04/10] docs: add pipe handling documentation --- docs/PIPE-HANDLING.md | 44 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/PIPE-HANDLING.md diff --git a/docs/PIPE-HANDLING.md b/docs/PIPE-HANDLING.md new file mode 100644 index 0000000..ade118a --- /dev/null +++ b/docs/PIPE-HANDLING.md @@ -0,0 +1,44 @@ +# Pipe Handling + +When a bash command contains a pipe (`|`), the plugin must determine which parts of +the pipeline to prefix with `snip run --`. + +## Pipeline Segment Resolution + +Every segment separated by `|` is independently evaluated via `snip check`. Only +segments whose first command has a snip filter are wrapped. + +### Behavior + +| Command | Result | Explanation | +|---------|--------|-------------| +| `git log \| head` | `snip run -- git log \| head` | `git` has a filter, `head` does not | +| `cat file.json \| jq '.a \| .b'` | `cat file.json \| snip run -- jq '.a \| .b'` | `jq` has a filter, `cat` does not; pipe in quotes is preserved | +| `cd /tmp && git log \| head` | `cd /tmp && snip run -- git log \| head` | compound `&&` within first pipe segment | +| `cmd1 \|\| cmd2 \| cmd3 && cmd4` | `cmd1 \|\| cmd2 \| cmd3 && cmd4` | `\|\|` treated as compound, not pipe; nothing wrapped (no filters) | + +## Quote Awareness + +The plugin tracks single quotes (`'`) and double quotes (`"`) while scanning for +pipe characters. A `|` inside quotes is **never** treated as a pipe separator. +This is essential for commands like `jq` that use `|` inside expression strings: + +``` +cat file.json | jq '.content | fromjson' # | inside '' is NOT a pipe +cat file.json | jq ".content | fromjson" # | inside "" is NOT a pipe +``` + +## Compound Operators Within Segments + +Each pipe segment is further split on compound operators (`&&`, `||`, `;`, `&`) +before being checked. This ensures that `cd /tmp && git log` only wraps `git log`, +not `cd /tmp`. + +Shell redirections like `2>&1` or `1>&2` are not mistaken for the `&` compound +operator. + +## Implementation + +The pipe splitting logic lives in `createToolExecuteBefore()` in `src/index.ts`. +It replaces the original `findFirstPipe()` helper which only checked the first +pipe segment. From 9642c595d10090aab7f0adeae0f1002484a38497 Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Mon, 18 May 2026 15:18:41 +0200 Subject: [PATCH 05/10] feat: prevent double snip prefix in &&/| compound commands Add snip-prefix guard in snipCommand() to avoid double-wrapping when a segment in a compound command (connected via && or |) already contains snip run --. The existing top-level guard in createToolExecuteBefore only covered the full command, not individual segments after splitting. Add 4 test cases for already-prefixed segments in compound commands. --- src/index.test.ts | 68 ++++++++++++++++++++++++++--------------------- src/index.ts | 1 + 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index ff03b29..6e12449 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -4,34 +4,14 @@ 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", ]) @@ -88,7 +68,7 @@ describe("toolExecuteBefore", () => { it("should handle command with ||", async () => { mockOutput.args.command = "test -f foo.txt || echo missing" await createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip run -- test -f foo.txt || snip run -- echo missing") + expect(mockOutput.args.command).toBe("snip run -- test -f foo.txt || echo missing") }) it("should handle command with &", async () => { @@ -187,25 +167,25 @@ describe("toolExecuteBefore", () => { it("should not break 2>&1 redirection", async () => { mockOutput.args.command = "find / -name \"*.log\" 2>&1" await createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip run -- find / -name \"*.log\" 2>&1") + expect(mockOutput.args.command).toBe("find / -name \"*.log\" 2>&1") }) it("should not break 1>&2 redirection", async () => { mockOutput.args.command = "cmd 1>&2" await createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip run -- cmd 1>&2") + expect(mockOutput.args.command).toBe("cmd 1>&2") }) it("should handle 2>&1 with pipe", async () => { mockOutput.args.command = "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") + expect(mockOutput.args.command).toBe("find / -name \"*.log\" 2>&1 | grep error") }) it("should handle 2>&1 with chained commands", async () => { mockOutput.args.command = "cmd1 2>&1 && cmd2" await createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip run -- cmd1 2>&1 && snip run -- cmd2") + expect(mockOutput.args.command).toBe("cmd1 2>&1 && cmd2") }) }) @@ -213,37 +193,37 @@ describe("toolExecuteBefore", () => { it("should not split pipes inside single quotes", async () => { mockOutput.args.command = "cat file.json | jq '.content | .text'" await createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip run -- cat file.json | jq '.content | .text'") + expect(mockOutput.args.command).toBe("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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe('snip run -- cat file.json | jq ".content | .text"') + expect(mockOutput.args.command).toBe('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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip run -- cat file.json | jq '.content[0].text | fromjson'") + expect(mockOutput.args.command).toBe("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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip run -- cat file.json | jq '.a | .b | .c'") + expect(mockOutput.args.command).toBe("cat file.json | snip run -- jq '.a | .b | .c'") }) it("should handle pipe with || operator", async () => { mockOutput.args.command = "cmd1 || cmd2" await createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip run -- cmd1 || snip run -- cmd2") + expect(mockOutput.args.command).toBe("cmd1 || cmd2") }) it("should handle mixed quotes and pipes", async () => { mockOutput.args.command = 'echo "hello | world" | cat' await createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe('snip run -- echo "hello | world" | cat') + expect(mockOutput.args.command).toBe('echo "hello | world" | cat') }) }) @@ -278,4 +258,30 @@ describe("toolExecuteBefore", () => { 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(defaultShouldWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("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(defaultShouldWrap)(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(defaultShouldWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("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(defaultShouldWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("cd /tmp && snip run -- go test 2>&1 | snip run -- ls") + }) + }) }) \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 4bad947..11bb7b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ async function snipCommand(command: string, shouldWrap: (cmd: string) => Promise const envPrefix = (command.match(ENV_VAR_RE) ?? [""])[0] const bareCmd = command.slice(envPrefix.length).trim() if (!bareCmd) return command + if (bareCmd.startsWith("snip ")) return command if (await shouldWrap(bareCmd)) { return `${envPrefix}snip run -- ${bareCmd}` } From 882fa4bba6394b32ab6278d47b37cf5c3be8bdd9 Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Mon, 18 May 2026 21:52:42 +0200 Subject: [PATCH 06/10] refactor: apply PR feedback - simplify tests, add snip version check, fix shell escaping - Remove docs/PIPE-HANDLING.md (owner feedback: test > docs) - Replace WRAPPED_COMMANDS/defaultShouldWrap with mockedWrap - Replace cd/skip tests with subcommand-passthrough spy tests - Add hasSnipSubcommands() to warn when snip < 0.16.0 - Drop {raw: w} to prevent shell metacharacter injection in snip check --- docs/PIPE-HANDLING.md | 44 --------- src/index.test.ts | 204 +++++++++++++++++++----------------------- src/index.ts | 18 +++- 3 files changed, 109 insertions(+), 157 deletions(-) delete mode 100644 docs/PIPE-HANDLING.md diff --git a/docs/PIPE-HANDLING.md b/docs/PIPE-HANDLING.md deleted file mode 100644 index ade118a..0000000 --- a/docs/PIPE-HANDLING.md +++ /dev/null @@ -1,44 +0,0 @@ -# Pipe Handling - -When a bash command contains a pipe (`|`), the plugin must determine which parts of -the pipeline to prefix with `snip run --`. - -## Pipeline Segment Resolution - -Every segment separated by `|` is independently evaluated via `snip check`. Only -segments whose first command has a snip filter are wrapped. - -### Behavior - -| Command | Result | Explanation | -|---------|--------|-------------| -| `git log \| head` | `snip run -- git log \| head` | `git` has a filter, `head` does not | -| `cat file.json \| jq '.a \| .b'` | `cat file.json \| snip run -- jq '.a \| .b'` | `jq` has a filter, `cat` does not; pipe in quotes is preserved | -| `cd /tmp && git log \| head` | `cd /tmp && snip run -- git log \| head` | compound `&&` within first pipe segment | -| `cmd1 \|\| cmd2 \| cmd3 && cmd4` | `cmd1 \|\| cmd2 \| cmd3 && cmd4` | `\|\|` treated as compound, not pipe; nothing wrapped (no filters) | - -## Quote Awareness - -The plugin tracks single quotes (`'`) and double quotes (`"`) while scanning for -pipe characters. A `|` inside quotes is **never** treated as a pipe separator. -This is essential for commands like `jq` that use `|` inside expression strings: - -``` -cat file.json | jq '.content | fromjson' # | inside '' is NOT a pipe -cat file.json | jq ".content | fromjson" # | inside "" is NOT a pipe -``` - -## Compound Operators Within Segments - -Each pipe segment is further split on compound operators (`&&`, `||`, `;`, `&`) -before being checked. This ensures that `cd /tmp && git log` only wraps `git log`, -not `cd /tmp`. - -Shell redirections like `2>&1` or `1>&2` are not mistaken for the `&` compound -operator. - -## Implementation - -The pipe splitting logic lives in `createToolExecuteBefore()` in `src/index.ts`. -It replaces the original `findFirstPipe()` helper which only checked the first -pipe segment. diff --git a/src/index.test.ts b/src/index.test.ts index 6e12449..f2b3205 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,24 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest" -import { createToolExecuteBefore } from "./index" - -const WRAPPED_COMMANDS = new Set([ - "go test ./...", - "git log", - "git log -10", - "go build", - "go run", - "ls", - "sleep 1", - "sleep 2", - "test -f foo.txt", - "go test", - "jq", -]) - -async function defaultShouldWrap(cmd: string): Promise { - const baseCmd = cmd.split(/\s+/)[0] - return WRAPPED_COMMANDS.has(cmd) || WRAPPED_COMMANDS.has(baseCmd) -} +import { createToolExecuteBefore, hasSnipSubcommands } from "./index" + +const mockedWrap = async () => true describe("toolExecuteBefore", () => { let mockInput: { tool: string; sessionID: string; callID: string } @@ -31,199 +14,180 @@ describe("toolExecuteBefore", () => { it("should prefix simple command with snip run --", async () => { mockOutput.args.command = "go test ./..." - await createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) + 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) + 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) + 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) + 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip run -- 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) + 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip run -- test -f foo.txt || 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) + 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) + 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) + 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 run -- go test" - await createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) + 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) expect(mockOutput.args.command).toBe("go test") }) - 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 createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cd /tmp") - }) - - it("should skip source when shouldWrap returns false", async () => { - const shouldSkip = async (cmd: string) => !cmd.startsWith("source ") - mockOutput.args.command = "source ~/.bashrc" - await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("source ~/.bashrc") - }) - - it("should skip . (dot) when shouldWrap returns false", async () => { - const shouldSkip = async (cmd: string) => !cmd.startsWith(". ") - mockOutput.args.command = ". ./env.sh" - await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe(". ./env.sh") - }) - - it("should skip export when shouldWrap returns false", async () => { - const shouldSkip = async (cmd: string) => !cmd.startsWith("export ") - mockOutput.args.command = "export FOO=bar" - await createToolExecuteBefore(shouldSkip)(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 when shouldWrap returns false", async () => { - const shouldSkip = async (cmd: string) => !cmd.startsWith("alias ") - mockOutput.args.command = 'alias ll="ls -la"' - await createToolExecuteBefore(shouldSkip)(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 when shouldWrap returns false", async () => { - const shouldSkip = async (cmd: string) => !cmd.startsWith("unset ") - mockOutput.args.command = "unset VAR" - await createToolExecuteBefore(shouldSkip)(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 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 createToolExecuteBefore(shouldSkip)(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 wrap chained command via shouldWrap", async () => { - const shouldSkip = async (cmd: string) => !cmd.startsWith("cd ") - mockOutput.args.command = "cd /tmp && ls" - await createToolExecuteBefore(shouldSkip)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cd /tmp && snip run -- 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cmd1 2>&1 && 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cat file.json | snip run -- 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe('cat file.json | snip run -- 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cat file.json | snip run -- 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cat file.json | snip run -- 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cmd1 || 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 createToolExecuteBefore(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe('echo "hello | world" | cat') + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe('snip run -- echo "hello | world" | snip run -- cat') }) }) @@ -262,26 +226,42 @@ describe("toolExecuteBefore", () => { 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(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("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(defaultShouldWrap)(mockInput, mockOutput) + 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(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cd /tmp && snip run -- go test | snip run -- 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(defaultShouldWrap)(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("cd /tmp && snip run -- go test 2>&1 | snip run -- ls") + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cd /tmp && snip run -- go test 2>&1 | snip run -- ls") }) }) -}) \ No newline at end of file +}) + +describe("hasSnipSubcommands", () => { + it("should return true when snip check succeeds", async () => { + const mock$ = ((...args: any[]) => ({ + quiet: async () => ({ exitCode: 0 }) + })) as any + expect(await hasSnipSubcommands(mock$)).toBe(true) + }) + + it("should return false when snip check fails", async () => { + const mock$ = ((...args: any[]) => ({ + 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 11bb7b5..8d1bce3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,6 +87,15 @@ export function createToolExecuteBefore(shouldWrap: (cmd: string) => Promise { + try { + await $`snip check -- ls`.quiet() + return true + } catch { + return false + } +} + export const SnipPlugin: Plugin = async ({ $, client }) => { try { await $`which snip`.quiet() @@ -95,10 +104,17 @@ export const SnipPlugin: Plugin = async ({ $, client }) => { return {} } + if (!(await hasSnipSubcommands($))) { + await client.log({ 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 result = await $`snip check -- ${words.map(w => ({raw: w}))}`.nothrow().quiet() + const result = await $`snip check -- ${words}`.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(() => {}) From dad0801f17adc6d6d0a0090444cbde5a7f1ce42d Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Mon, 18 May 2026 22:36:01 +0200 Subject: [PATCH 07/10] fix: add .nothrow() to hasSnipSubcommands to avoid false-positive on exit-1 --- src/index.test.ts | 19 ++++++++++++++++--- src/index.ts | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index f2b3205..4706687 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -253,14 +253,27 @@ describe("toolExecuteBefore", () => { describe("hasSnipSubcommands", () => { it("should return true when snip check succeeds", async () => { const mock$ = ((...args: any[]) => ({ - quiet: async () => ({ exitCode: 0 }) + nothrow: () => ({ + quiet: async () => ({ exitCode: 0 }) + }) })) as any expect(await hasSnipSubcommands(mock$)).toBe(true) }) - it("should return false when snip check fails", async () => { + it("should return true even when snip check exits non-zero (filter exists, cmd has no filter)", async () => { const mock$ = ((...args: any[]) => ({ - quiet: async () => { throw new Error("not found") } + 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 8d1bce3..41a7acf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,7 +89,7 @@ export function createToolExecuteBefore(shouldWrap: (cmd: string) => Promise { try { - await $`snip check -- ls`.quiet() + await $`snip check -- ls`.nothrow().quiet() return true } catch { return false From a421df489db7d9d4b6b18e895a19c16f6defcc3e Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Tue, 19 May 2026 17:46:00 +0200 Subject: [PATCH 08/10] fix: use client.app.log for type-safe logging, add dev commands to README --- README.md | 6 ++++++ src/index.ts | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) 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.ts b/src/index.ts index 41a7acf..c2dcce7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,13 +100,13 @@ export const SnipPlugin: Plugin = async ({ $, client }) => { try { await $`which snip`.quiet() } catch { - await client.log({ level: "warn", message: "[snip] snip binary not found in PATH — plugin disabled" }).catch(() => {}) + 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.log({ level: "warn", - message: "[snip] snip >= 0.16.0 required (snip check/run subcommands missing) — plugin disabled" + 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 {} } @@ -117,7 +117,7 @@ export const SnipPlugin: Plugin = async ({ $, client }) => { const result = await $`snip check -- ${words}`.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(() => {}) + await client.app.log({ body: { service: "snip", level: "warn", message: `[snip] snip check failed for ${cmd}`, extra: { error: String(err) } } }).catch(() => {}) return false } } From 952faf47fff1e51f4ff6e06221a3ba2543c5dac0 Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Wed, 20 May 2026 12:29:49 +0200 Subject: [PATCH 09/10] fix: pass first two words as separate raw args to snip check (avoid array-in-template issues) --- src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index c2dcce7..0418927 100644 --- a/src/index.ts +++ b/src/index.ts @@ -114,7 +114,11 @@ export const SnipPlugin: Plugin = async ({ $, client }) => { const shouldWrap = async (cmd: string): Promise => { try { const words = cmd.split(/\s+/) - const result = await $`snip check -- ${words}`.nothrow().quiet() + 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(() => {}) From bdb44108b2c1b26c27f9101c3dc56a2bcbdbf719 Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Wed, 20 May 2026 19:18:19 +0200 Subject: [PATCH 10/10] feat: inject usage instructions into system prompt via experimental.chat.system.transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin now tells the AI via the system prompt to not manually prefix commands with `snip run --` — the plugin handles that automatically. --- src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/index.ts b/src/index.ts index 0418927..8129412 100644 --- a/src/index.ts +++ b/src/index.ts @@ -128,6 +128,12 @@ export const SnipPlugin: Plugin = async ({ $, client }) => { return { "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." + ) + }, } }