diff --git a/src/index.test.ts b/src/index.test.ts index 7401699..a2244ef 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,486 @@ 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 = "cmd1 |& cmd2" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cmd1 |& snip run -- cmd2") }) 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 not treat &> as background operator", async () => { + mockOutput.args.command = "cmd &> out.log" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cmd &> out.log") }) 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") + 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 . (dot)", async () => { - mockOutput.args.command = ". ./env.sh" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe(". ./env.sh") + 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 export", async () => { - mockOutput.args.command = "export FOO=bar" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("export FOO=bar") + 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 alias", async () => { - mockOutput.args.command = 'alias ll="ls -la"' - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe('alias ll="ls -la"') + 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 unset", async () => { - mockOutput.args.command = "unset VAR" - await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("unset VAR") - }) - - 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 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") + mockOutput.args.command = '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") + mockOutput.args.command = '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") + }) + + it("should not treat &> as background operator", async () => { + mockOutput.args.command = "cmd &> out.log" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cmd &> out.log") + }) + + it("should not treat &> in compound command as background", async () => { + mockOutput.args.command = "cmd1 &> out.log && cmd2" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cmd1 &> out.log && 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') + }) + + it("should preserve |& operator between pipe segments", async () => { + mockOutput.args.command = "cmd1 |& cmd2" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cmd1 |& snip run -- cmd2") + }) + }) + + 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") + }) + }) + + describe("snip prefix deduplication (PR #17)", () => { + it("should strip single snip prefix and re-add snip run --", async () => { + mockOutput.args.command = "snip go test" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- go test") + }) + + it("should strip multiple snip prefixes", async () => { + mockOutput.args.command = "snip snip snip go test" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- go test") + }) + + it("should deduplicate snip in chained commands", async () => { + mockOutput.args.command = "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 deduplicate snip with env var prefix", async () => { + mockOutput.args.command = "FOO=bar snip go test" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("FOO=bar snip run -- go test") + }) + + it("should deduplicate snip in pipe chain", async () => { + mockOutput.args.command = "snip git log | head" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- git log | snip run -- head") + }) + }) + + describe("PowerShell support (PR #21)", () => { + it("should skip PowerShell env var assignment", async () => { + mockOutput.args.command = "$env:CI='true'" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("$env:CI='true'") + }) + + it("should skip PowerShell variable assignment", async () => { + mockOutput.args.command = "$x = 1" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("$x = 1") + }) + + it("should skip Write-Output cmdlet", async () => { + mockOutput.args.command = "Write-Output 'hello'" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("Write-Output 'hello'") + }) + + it("should skip Get-ChildItem cmdlet", async () => { + mockOutput.args.command = "Get-ChildItem ." + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("Get-ChildItem .") + }) + + it("should skip Remove-Item cmdlet", async () => { + mockOutput.args.command = "Remove-Item -Recurse -Force dir" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("Remove-Item -Recurse -Force dir") + }) + + it("should skip ForEach-Object cmdlet (camelCase verb)", async () => { + mockOutput.args.command = "ForEach-Object { $_.Name }" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("ForEach-Object { $_.Name }") }) + + it("should skip ConvertTo-Json cmdlet (camelCase verb)", async () => { + mockOutput.args.command = "ConvertTo-Json -Depth 5" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("ConvertTo-Json -Depth 5") + }) + + it("should skip PowerShell call operator (&)", async () => { + mockOutput.args.command = "& 'C:\\Program Files\\tool.exe'" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("& 'C:\\Program Files\\tool.exe'") + }) + + it("should skip PowerShell splatting (@args)", async () => { + mockOutput.args.command = "@args" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("@args") + }) + + it("should skip PowerShell array literal (@())", async () => { + mockOutput.args.command = '@("a","b")' + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe('@("a","b")') + }) + + it("should skip env var but snip chained command", async () => { + const selectiveWrap = async (cmd: string) => !cmd.startsWith("$") + mockOutput.args.command = "$env:CI='true'; git log -1" + await createToolExecuteBefore(selectiveWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("$env:CI='true'; snip run -- git log -1") + }) + + it("should skip cmdlet but snip chained command", async () => { + const selectiveWrap = async (cmd: string) => !cmd.startsWith("Write") + mockOutput.args.command = "Write-Output 'test'; git log -1" + await createToolExecuteBefore(selectiveWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("Write-Output 'test'; snip run -- git log -1") + }) + + it("should handle mixed PowerShell env vars and commands", async () => { + const selectiveWrap = async (cmd: string) => + !cmd.startsWith("$") && !cmd.startsWith("cd") + mockOutput.args.command = "$env:CI='true'; $env:GIT_PAGER='cat'; cd 'C:\\Projects'; git log -1" + await createToolExecuteBefore(selectiveWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("$env:CI='true'; $env:GIT_PAGER='cat'; cd 'C:\\Projects'; snip run -- git log -1") + }) + }) + + describe("Unix commands not matching cmdlet regex", () => { + const isWin32 = process.platform === "win32" + + it.skipIf(isWin32)("should wrap apt-get (not matched as cmdlet on non-Windows)", async () => { + mockOutput.args.command = "apt-get install foo" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- apt-get install foo") + }) + + it.skipIf(isWin32)("should wrap node-gyp (not matched as cmdlet on non-Windows)", async () => { + mockOutput.args.command = "node-gyp rebuild" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- node-gyp rebuild") + }) + + it.skipIf(isWin32)("should wrap pkg-config (not matched as cmdlet on non-Windows)", async () => { + mockOutput.args.command = "pkg-config --libs openssl" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- pkg-config --libs openssl") + }) + }) + + describe("newline splitting (PR #21)", () => { + it("should split and snip commands separated by newlines", async () => { + mockOutput.args.command = "git log\ngit status" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- git log\nsnip run -- git status") + }) + + it("should handle newline after unproxyable command", async () => { + const selectiveWrap = async (cmd: string) => !cmd.startsWith("cd ") + mockOutput.args.command = "cd /tmp\ngit log" + await createToolExecuteBefore(selectiveWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("cd /tmp\nsnip run -- git log") + }) + + it("should handle mixed newlines and operators", async () => { + const selectiveWrap = async (cmd: string) => !cmd.startsWith("cd ") + mockOutput.args.command = "cd /tmp\ngit log && git status" + await createToolExecuteBefore(selectiveWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("cd /tmp\nsnip run -- git log && snip run -- git status") + }) + + it("should handle pipe within newline-separated commands", async () => { + const selectiveWrap = async (cmd: string) => !cmd.startsWith("cd ") + mockOutput.args.command = "cd /tmp\ngit log | head" + await createToolExecuteBefore(selectiveWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("cd /tmp\nsnip run -- git log | snip run -- head") + }) + + it("should handle PowerShell prelude with newlines", async () => { + const selectiveWrap = async (cmd: string) => + !cmd.startsWith("$") && !cmd.startsWith("cd") + mockOutput.args.command = "$env:CI='true'; cd 'C:\\Projects'\ngit show abc123" + await createToolExecuteBefore(selectiveWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("$env:CI='true'; cd 'C:\\Projects'\nsnip run -- git show abc123") + }) + }) + + describe("heredoc safety (PR #21)", () => { + it("should not split heredoc body on newlines", async () => { + mockOutput.args.command = "cat < { + mockOutput.args.command = "cat <<'EOF'\nhello world\nEOF" + await createToolExecuteBefore(mockedWrap)(mockInput, mockOutput) + expect(mockOutput.args.command).toBe("snip run -- cat <<'EOF'\nhello world\nEOF") + }) + + it("should not split pipes inside heredoc body", async () => { + mockOutput.args.command = "cat < { + mockOutput.args.command = "cat < { + mockOutput.args.command = "cat < { + mockOutput.args.command = "cat < { + 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", 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) }) -}) \ No newline at end of file +}) diff --git a/src/index.ts b/src/index.ts index e032713..980e1b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,80 +1,226 @@ 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?)/ +const OPERATOR_RE = /(\s*(?:&&|\|\||;)\s*|\s&(?![>])\s?|\r?\n)/ +const OPERATOR_ONLY_RE = /(\s*(?:&&|\|\||;)\s*|\s&(?![>])\s?)/ +const HEREDOC_RE = /<<-?\s*['"]?\w/ +const HEREDOC_DELIM_RE = /<<-?\s*['"]?(\w[\w.-]*)/ +const POWERSHELL_SKIP_RE = /^[$@&{]/ +const POWERSHELL_CMDLET_RE = /^[A-Z][a-zA-Z]*-[A-Z]/i -function findFirstPipe(command: string): number { +function stripSnipPrefixes(cmd: string): string { + let s = cmd.trimStart() + while (s.startsWith("snip ")) { + s = s.slice(5).trimStart() + } + return s +} + +async function snipCommand( + command: string, + shouldWrap: (cmd: string) => Promise, +): Promise { + const envPrefix = (command.match(ENV_VAR_RE) ?? [""])[0] + const bareCmd = stripSnipPrefixes(command.slice(envPrefix.length).trim()) + if (!bareCmd) return command + if (bareCmd.startsWith("snip ") || bareCmd.startsWith("run -- ")) return command + + const firstWord = bareCmd.split(/\s+/)[0] + if (process.platform === "win32" && POWERSHELL_SKIP_RE.test(bareCmd)) return command + if (process.platform === "win32" && POWERSHELL_CMDLET_RE.test(firstWord)) return command + + if (await shouldWrap(bareCmd)) { + return `${envPrefix}snip run -- ${bareCmd}` + } + return command +} + +interface PipeSplit { + segments: string[] + operators: string[] +} + +function splitByPipe(command: string): PipeSplit { + const segments: string[] = [] + const operators: 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 - } else if (char === '|' && !inSingleQuote && !inDoubleQuote) { - if (command[i + 1] === '|' || (i > 0 && command[i - 1] === '|')) { + current += char + } else if (char === "|" && !inSingleQuote && !inDoubleQuote) { + if (command[i + 1] === "|") { i++ continue } - return i + segments.push(current) + current = "" + if (command[i + 1] === "&") { + operators.push("|&") + i++ + } else { + operators.push("|") + } + } else { + current += char } } - - return -1 + segments.push(current) + return { segments, operators } } -function snipCommand(command: string): string { - 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}` +async function snipSegment( + segment: string, + shouldWrap: (cmd: string) => Promise, +): Promise { + if (HEREDOC_RE.test(segment)) { + return snipCommand(segment, shouldWrap) + } + + const { segments, operators } = splitByPipe(segment) + if (segments.length === 1) { + return snipCommand(segment, shouldWrap) + } + + let result = await snipCommand(segments[0].trim(), shouldWrap) + for (let i = 1; i < segments.length; i++) { + result += ` ${operators[i - 1]} ` + result += await snipCommand(segments[i].trim(), shouldWrap) + } + return result } -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 run -- ")) 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 - } + if (HEREDOC_RE.test(command)) { + const heredocMatch = command.match(HEREDOC_DELIM_RE) + if (heredocMatch) { + const delimiter = heredocMatch[1] + const bodyStart = heredocMatch.index! + heredocMatch[0].length + const bodyEnd = command.indexOf(delimiter, bodyStart) + if (bodyEnd !== -1) { + const heredocPart = command.slice(0, bodyEnd + delimiter.length) + const afterPart = command.slice(bodyEnd + delimiter.length) + const afterSegments = afterPart.split(OPERATOR_RE) + const results: string[] = [await snipSegment(heredocPart, shouldWrap)] + for (const seg of afterSegments) { + if (OPERATOR_RE.test(seg)) { + results.push(seg) + } else if (seg) { + results.push(await snipSegment(seg, shouldWrap)) + } + } + output.args.command = results.join("") + return + } + } + output.args.command = await snipSegment(command, shouldWrap) + 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 snipSegment(command, shouldWrap) + return + } + + const results: string[] = [] + for (const segment of segments) { + if (OPERATOR_RE.test(segment)) { + results.push(segment) + } else { + results.push(await snipSegment(segment, shouldWrap)) + } + } + output.args.command = results.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() + if (process.platform === "win32") { + await $`where snip`.quiet() + } else { + 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 result = await $`snip check -- ${{ raw: cmd }}`.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.", + ) + }, } } -export default SnipPlugin \ No newline at end of file +export default SnipPlugin