fix: use snip check/run subcommands, fix pipe handling, fix multi-word args for snip check#14
fix: use snip check/run subcommands, fix pipe handling, fix multi-word args for snip check#14lenucksi wants to merge 10 commits into
Conversation
… 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 VincentHardouin#6, VincentHardouin#7
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.
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 " | ".
| @@ -0,0 +1,44 @@ | |||
| # Pipe Handling | |||
There was a problem hiding this comment.
This file is unnecessary, and the behavior should be documented through testing. Please remove the commit (d27373f) that adds it 🙏
There was a problem hiding this comment.
Done — commit d27373f reverted, docs/PIPE-HANDLING.md removed. The pipe-splitting behavior is now covered by the updated tests (pipe expressions with quotes, 2>&1 with pipe, etc.).
There was a problem hiding this comment.
Can you push changes please ?
There was a problem hiding this comment.
Done — pushed commit 882fa4b with all the changes. Includes the {raw: w} fix from Greptile's P2 review too.
There was a problem hiding this comment.
The commit isn't included in this PR. Could you fix that?
There was a problem hiding this comment.
Sorry, pushed to the wrong branch. Should be visible now.
| async function defaultShouldWrap(cmd: string): Promise<boolean> { | ||
| const baseCmd = cmd.split(/\s+/)[0] | ||
| return WRAPPED_COMMANDS.has(cmd) || WRAPPED_COMMANDS.has(baseCmd) | ||
| } |
There was a problem hiding this comment.
I think in the tests, we could call it mockedWrap and just return true. This would allow us to remove the WRAPPED_COMMANDS variable, which lists all the commands in the tests and seems unnecessary to me.
There was a problem hiding this comment.
Done — WRAPPED_COMMANDS and defaultShouldWrap replaced with a simple mockedWrap = async () => true. The tests now focus on verifying the splitting/prefixing logic rather than simulating filter lists. All test expectations updated accordingly (every segment gets wrapped in tests).
| 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") | ||
| }) |
There was a problem hiding this comment.
I feel like these tests aren't useful anymore. snip check now checks to make sure it can't run cd, so what we want to verify is that every subcommand has been successfully passed to snip check. What do you think about updating the test to reflect that?
There was a problem hiding this comment.
Done — the old "commands that should not be wrapped" section is replaced with a "subcommand passthrough" describe block. It uses a spy that captures which subcommands are passed to shouldWrap, verifying that each &&/|/; segment is correctly isolated and forwarded. This is more useful — tests the parser, not the filter logic.
|
Also, as I mentioned in PR #13 (comment), we need to warn users who don't have a version of Snip that includes the |
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.
|
@VincentHardouin re: snip version check — done. Added |
Greptile SummaryThis PR replaces the static
|
| Filename | Overview |
|---|---|
| src/index.ts | Core rewrite replacing static UNPROXYABLE_COMMANDS with dynamic snip check calls, adding quote-aware full-pipeline splitting, and wrapping commands with snip run --. Quote-state tracker has known gaps for $() and " escapes. |
| src/index.test.ts | Comprehensive test update: replaces static WRAPPED_COMMANDS mock with shouldWrap spy, adds pipe-segment passthrough checks, error-guard tests, and hasSnipSubcommands unit tests. Coverage is thorough for the supported syntax. |
| README.md | Adds development quickstart (npm ci / typecheck / test) to the Development section. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[tool.execute.before hook] --> B{tool === bash?}
B -- No --> Z[return unchanged]
B -- Yes --> C{starts with snip?}
C -- Yes --> Z
C -- No --> D[Quote-aware pipe split]
D --> E{Pipe segments found?}
E -- No --> F[processSegment]
E -- Yes --> G[processSegment per segment]
F --> H[snipCommand per operator-split part]
G --> H
H --> I{bareCmd starts with snip?}
I -- Yes --> J[return unchanged]
I -- No --> K[shouldWrap: snip check w0 w1]
K -- exit 0 --> L[prefix with snip run --]
K -- non-zero or throw --> M[return unchanged]
L --> N[reassemble segments]
J --> N
M --> N
N --> O[output.args.command = result]
Reviews (6): Last reviewed commit: "feat: inject usage instructions into sys..." | Re-trigger Greptile
| 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") | ||
| }) |
There was a problem hiding this comment.
Stale pipe test expectations cause multiple failures
With the new multi-segment pipe processing, every pipe segment is independently evaluated by shouldWrap. Because WRAPPED_COMMANDS includes "head", "grep", "jq", "cat", and "echo" (all matched by baseCmd), every segment of a pipeline gets wrapped. However, the expected values throughout this section were copied from the old single-segment tests and still show only the first segment wrapped.
Concrete failures:
git log | head→ actualsnip run -- git log | snip run -- head, expectedsnip run -- git log | headcat file.json | jq '.content | .text'→ actualsnip run -- cat file.json | snip run -- jq '.content | .text', expectedsnip run -- cat file.json | jq '.content | .text'find / -name "*.log" 2>&1 | grep error→ bothfindandgrepget wrapped, expected onlyfindecho "hello | world" | cat→ bothechoandcatwrapped, expected onlyecho
The WRAPPED_COMMANDS set also contains full-pipeline strings like "cat file.json | jq '.content | .text'" that can never match after pipe splitting — those entries are dead weight. Either remove head, grep, cat, jq, echo from WRAPPED_COMMANDS so the mock simulates a realistic snip check (only git, go, etc. have filters), or update the expected values to reflect both segments being wrapped.
There was a problem hiding this comment.
Resolved — WRAPPED_COMMANDS/defaultShouldWrap removed in favor of mockedWrap = async () => true. All pipe expectations updated so every segment is wrapped. Both git log | head and cat file.json | jq ... expectations now correctly show both segments prefixed.
| const words = cmd.split(/\s+/) | ||
| const result = await $`snip check -- ${words.map(w => ({raw: w}))}`.nothrow().quiet() |
There was a problem hiding this comment.
Shell metacharacters in command words bypass escaping with
{raw: w}
cmd.split(/\s+/) can produce words containing shell-special characters — most notably > and & from redirections like 2>&1. Passing these via {raw: w} means they are interpreted by the shell rather than forwarded as literal arguments to snip check. For shouldWrap("cmd1 2>&1"), the shell ends up running snip check -- cmd1 2>&1 (with stderr redirected) instead of snip check -- cmd1 '2>&1'.
While .quiet().nothrow() means only exitCode is consumed (so the redirect usually doesn't change the result), any word containing ; would split the command at the shell level and could execute unintended sub-commands. Dropping {raw: ...} and passing words directly lets bun's $ template tag quote each element correctly.
| const words = cmd.split(/\s+/) | |
| const result = await $`snip check -- ${words.map(w => ({raw: w}))}`.nothrow().quiet() | |
| const words = cmd.split(/\s+/) | |
| const result = await $`snip check -- ${words}`.nothrow().quiet() |
There was a problem hiding this comment.
Fixed — dropped {raw: w}, now passes words directly so bun's $ template tag handles quoting. Words like 2>&1 or ; are now forwarded as literal arguments to snip check instead of being interpreted by the shell.
… 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
| export async function hasSnipSubcommands($: any): Promise<boolean> { | ||
| try { | ||
| await $`snip check -- ls`.quiet() | ||
| return true | ||
| } catch { | ||
| return false | ||
| } | ||
| } |
There was a problem hiding this comment.
hasSnipSubcommands conflates "subcommand not found" with "ls has no filter". Without .nothrow(), bun's $ throws on any non-zero exit — including exit code 1 from snip check -- ls when ls simply has no filter configured. The catch block then returns false, disabling the plugin entirely with the misleading message "snip >= 0.16.0 required". Any user whose snip config doesn't include an ls filter (but does have git, npm, docker, etc.) will silently lose all wrapping.
Using .nothrow() and treating any completed run (exit 0 or non-zero) as proof that the check subcommand exists fixes this: a real "command not found" / missing-subcommand failure will still throw and return false.
| export async function hasSnipSubcommands($: any): Promise<boolean> { | |
| try { | |
| await $`snip check -- ls`.quiet() | |
| return true | |
| } catch { | |
| return false | |
| } | |
| } | |
| export async function hasSnipSubcommands($: any): Promise<boolean> { | |
| try { | |
| await $`snip check -- ls`.nothrow().quiet() | |
| return true | |
| } catch { | |
| return false | |
| } | |
| } |
There was a problem hiding this comment.
Good catch — fixed. Added .nothrow() so exit code 1 (ls has no filter) is treated as "subcommand exists". Only a real throw (command not found) triggers the warning and plugin disable. Also added a test for the exit-code-1 case.
|
@lenucksi the CI fails can you correct this before I continue the review please? |
Should be addressed now and pass CI green. Does so at least locally. |
…rray-in-template issues)
|
Re: Greptile P2 — |
…hat.system.transform The plugin now tells the AI via the system prompt to not manually prefix commands with `snip run --` — the plugin handles that automatically.
Summary
Replaces the hardcoded
UNPROXYABLE_COMMANDSlist with a runtimesnip checkcall, fixes all pipe segments being checked (not just the first), and fixes multi-word command detection forsnip check.Fixes #6, #7.
Closes #13 (superseded by this PR).
Changes
1. Dynamic command filtering via
snip check/snip run(snip >= 0.16.0)UNPROXYABLE_COMMANDSsetsnip check -- <cmd>at runtime to determine filter statussnip run -- <cmd>for wrapping (safer than plainsnip <cmd>)git,go,ls,jq,npm, ...)cd,export,source, ...)2. Fixed: multi-word args for
snip checksnip check -- "git log -10"returnsno filter(one string arg), butsnip check -- git log -10correctly returnsfilter: git-log. The first two words of the command are now passed as separate{raw: w}arguments tosnip check.3. Fixed: all pipe segments are checked (not just the first)
Previously only the first command before
|was evaluated. Now:'...'/"..."are ignored)snip check+processSegment&&,||,;,&) work within each pipe segment||is treated as a compound operator, not a pipe4. Maintenance
console.warnwithclient.app.log(prevents UI mangling)findFirstPipe— now inline quote-aware pipe processingsnipprefix in compound commandsdocs/PIPE-HANDLING.mddocumenting pipe resolution behaviorAcknowledgements
This PR builds on the foundational work by @JoaoCostaIFG in PR #13, which introduced
the
snip check/snip runsubcommand approach and replacedconsole.warnwithclient.log. Thank you for laying that groundwork!