From 254ad4cd238183fc343eabdae22a6cf75151f1d1 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Fri, 10 Apr 2026 19:18:26 +0200 Subject: [PATCH] fix: prefix only first command in pipelines (fixes jq expressions) --- src/index.test.ts | 44 +++++++++++++++++++++++++++++++++++++++++--- src/index.ts | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 83bd77e..7401699 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -37,7 +37,7 @@ describe("toolExecuteBefore", () => { it("should handle command with |", async () => { mockOutput.args.command = "git log | head" await toolExecuteBefore(mockInput, mockOutput) - expect(mockOutput.args.command).toBe("snip git log | snip head") + expect(mockOutput.args.command).toBe("snip git log | head") }) it("should handle command with ;", async () => { @@ -149,7 +149,7 @@ describe("toolExecuteBefore", () => { 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 | snip grep error") + expect(mockOutput.args.command).toBe("snip find / -name \"*.log\" 2>&1 | grep error") }) it("should handle 2>&1 with chained commands", async () => { @@ -158,4 +158,42 @@ describe("toolExecuteBefore", () => { expect(mockOutput.args.command).toBe("snip cmd1 2>&1 && snip 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'") + }) + + 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"') + }) + + 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'") + }) + + 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'") + }) + + 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") + }) + + 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') + }) + }) +}) \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 7462c8a..e032713 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,30 @@ 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?)/ + +function findFirstPipe(command: string): number { + let inSingleQuote = false + let inDoubleQuote = false + + for (let i = 0; i < command.length; i++) { + const char = command[i] + + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote + } else if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote + } else if (char === '|' && !inSingleQuote && !inDoubleQuote) { + if (command[i + 1] === '|' || (i > 0 && command[i - 1] === '|')) { + i++ + continue + } + return i + } + } + + return -1 +} function snipCommand(command: string): string { const envPrefix = (command.match(ENV_VAR_RE) ?? [""])[0] @@ -21,6 +44,14 @@ export const toolExecuteBefore: NonNullable = asyn 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 segments = command.split(OPERATOR_RE) if (segments.length === 1) { @@ -46,4 +77,4 @@ export const SnipPlugin: Plugin = async ({ $ }) => { } } -export default SnipPlugin +export default SnipPlugin \ No newline at end of file