Skip to content

Commit cd1ae35

Browse files
hmennen90claude
andauthored
Keep unknown binaries in Git Bash (fixes "jq: command not found") — v0.1.2 (#1)
* Keep unknown binaries in Git Bash instead of routing them to PowerShell A binary missing from POSIX_SAFE_BINS pulled the entire command chain out of the Git Bash pass-through path and into PowerShell. PowerShell only resolves the Windows PATH, not the MSYS /usr/bin tree, so an installed and working tool reported as "command not found" — `jq` being the common case. The effect cascaded across the chain: in `cat x.json | jq …` the `cat` segment also ended up in PowerShell, where it is an alias for Get-Content and pipes objects rather than bytes. An allowlist can never be complete, so the default is inverted: an unknown binary is assumed to be an ordinary executable and passes through to Git Bash. Only PowerShell-shaped names are still routed to PowerShell — Verb-Noun cmdlets and *.ps1 scripts, via the new looksLikePowerShell(). This matches what the README already documented for the passthrough class. POSIX_SAFE_BINS additionally gains the tools that show up in everyday chains (jq, yq, rg, fd, tar, openssl, ssh, make, sqlite3, aws, az, terraform, …) so the fast path stays explicit and self-documenting. The deny-list and WIN32_TRANSPILE_FORCE_PS keep precedence over the new default; both are covered by tests. 40 -> 47 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Add CI and release workflows The repository had no automation: tests only ever ran on a maintainer's machine, and a release was a manual build-tag-publish sequence with no guard against shipping a tag whose version disagreed with package.json. - ci.yml runs the suite on Node 20/22/24 across ubuntu-latest and windows-latest. Windows is the platform this plugin exists for, so it is part of the matrix rather than an afterthought. A second job checks that package.json, plugin.json and marketplace.json carry the same version, which is exactly the drift scripts/sync-version.js exists to prevent. - release.yml triggers on a v*.*.* tag: builds and tests on both platforms, refuses a tag that does not match package.json, creates the GitHub release with generated notes, and publishes to npm with provenance. The npm step is skipped rather than failed when no NPM_TOKEN secret is present, so a tag push still produces a GitHub release on a repo without registry credentials. - dependabot.yml keeps the pinned action SHAs and the dev dependencies current. Third-party actions are pinned to commit SHAs with a version comment, workflow permissions default to contents: read and are widened only in the publishing job, and every multi-line script runs under set -euo pipefail. actionlint is clean. The test script changes from a `dist/test/*.test.js` glob to the `dist/test/` directory: cmd.exe does not expand globs, so the glob form is unreliable on the Windows runners the new matrix adds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Resolve test files explicitly instead of relying on shell or Node globbing The CI matrix exposed that neither form of the test invocation is portable: `node --test dist/test/*.test.js` relies on the shell expanding the glob, which cmd.exe does not do, and `node --test dist/test` is accepted by Node 20 but not by 22 or 24, which try to load the directory as a module and fail with MODULE_NOT_FOUND. scripts/run-tests.js lists dist/test/*.test.js via readdir and passes the files to `node --test` directly, which behaves the same on every supported version and on both platforms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5061498 commit cd1ae35

12 files changed

Lines changed: 358 additions & 10 deletions

File tree

‎.claude-plugin/marketplace.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"name": "claude-code-win32",
3131
"source": "./",
3232
"description": "Makes Claude's Bash tool calls work cleanly on Windows: POSIX pass-through via Git Bash, PowerShell rewrites for common Unix idioms, MSYS drive-path translation, and deny-with-hint for Unix-only commands.",
33-
"version": "0.1.1",
33+
"version": "0.1.2",
3434
"author": {
3535
"name": "Hendrik Mennen"
3636
},

‎.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "claude-code-win32",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"description": "Hybrid bash→PowerShell transpiler for Claude Code on Windows. Passes POSIX-safe commands through to Git Bash, rewrites Unix idioms to PowerShell equivalents, translates MSYS drive paths, and denies Unix-only commands with per-command hints.",
55
"author": {
66
"name": "Hendrik Mennen",

‎.github/dependabot.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: github-actions
4+
directory: /
5+
schedule:
6+
interval: weekly
7+
- package-ecosystem: npm
8+
directory: /
9+
schedule:
10+
interval: weekly

‎.github/workflows/ci.yml‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.ref }}
10+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
test:
17+
name: Tests (node ${{ matrix.node }}, ${{ matrix.os }})
18+
runs-on: ${{ matrix.os }}
19+
timeout-minutes: 10
20+
strategy:
21+
fail-fast: false
22+
matrix:
23+
# Windows is the platform this plugin targets, so it is not optional here.
24+
os: [ubuntu-latest, windows-latest]
25+
node: ['20', '22', '24']
26+
steps:
27+
- name: Checkout
28+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29+
30+
- name: Setup Node
31+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
32+
with:
33+
node-version: ${{ matrix.node }}
34+
cache: npm
35+
cache-dependency-path: package-lock.json
36+
37+
- name: Install
38+
run: npm ci
39+
40+
- name: Build and test
41+
run: npm test
42+
43+
versions-in-sync:
44+
name: Version sync
45+
runs-on: ubuntu-latest
46+
timeout-minutes: 5
47+
steps:
48+
- name: Checkout
49+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
50+
51+
- name: package.json, plugin.json and marketplace.json agree
52+
shell: bash
53+
run: |
54+
set -euo pipefail
55+
PKG=$(node -p "require('./package.json').version")
56+
PLUGIN=$(node -p "require('./.claude-plugin/plugin.json').version")
57+
MARKET=$(node -p "require('./.claude-plugin/marketplace.json').plugins.map(p => p.version).join(',')")
58+
echo "package.json=$PKG plugin.json=$PLUGIN marketplace.json=$MARKET"
59+
if [ "$PKG" != "$PLUGIN" ] || [ "$MARKET" != "$PKG" ]; then
60+
echo "::error::Version mismatch — run 'node scripts/sync-version.js'"
61+
exit 1
62+
fi

‎.github/workflows/release.yml‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags: ['v*.*.*']
6+
7+
concurrency:
8+
group: release-${{ github.ref }}
9+
cancel-in-progress: false
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
verify:
16+
name: Build and verify
17+
runs-on: ${{ matrix.os }}
18+
timeout-minutes: 10
19+
strategy:
20+
fail-fast: true
21+
matrix:
22+
os: [ubuntu-latest, windows-latest]
23+
steps:
24+
- name: Checkout
25+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
26+
27+
- name: Setup Node
28+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
29+
with:
30+
node-version: '22'
31+
cache: npm
32+
cache-dependency-path: package-lock.json
33+
34+
- name: Install
35+
run: npm ci
36+
37+
- name: Build and test
38+
run: npm test
39+
40+
- name: Tag matches package.json version
41+
shell: bash
42+
env:
43+
TAG_REF: ${{ github.ref_name }}
44+
run: |
45+
set -euo pipefail
46+
PKG=$(node -p "require('./package.json').version")
47+
if [ "$TAG_REF" != "v${PKG}" ]; then
48+
echo "::error::Tag $TAG_REF does not match package.json version $PKG"
49+
exit 1
50+
fi
51+
52+
publish:
53+
name: Publish
54+
needs: verify
55+
runs-on: ubuntu-latest
56+
timeout-minutes: 10
57+
permissions:
58+
contents: write # create the GitHub release
59+
id-token: write # npm provenance
60+
env:
61+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
62+
steps:
63+
- name: Checkout
64+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
65+
with:
66+
fetch-depth: 0 # release notes are generated from the commit history
67+
68+
- name: Setup Node
69+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
70+
with:
71+
node-version: '22'
72+
cache: npm
73+
cache-dependency-path: package-lock.json
74+
registry-url: https://registry.npmjs.org
75+
76+
- name: Install
77+
run: npm ci
78+
79+
- name: Build
80+
run: npm run build
81+
82+
- name: GitHub release
83+
shell: bash
84+
env:
85+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
86+
TAG_REF: ${{ github.ref_name }}
87+
run: |
88+
set -euo pipefail
89+
gh release create "$TAG_REF" --title "$TAG_REF" --generate-notes --verify-tag
90+
91+
# Publishing is skipped rather than failed when no npm token is configured,
92+
# so a tag push still produces a GitHub release on its own.
93+
- name: npm publish
94+
if: env.NPM_TOKEN != ''
95+
shell: bash
96+
env:
97+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
98+
run: |
99+
set -euo pipefail
100+
npm publish --provenance --access public
101+
102+
- name: Note skipped npm publish
103+
if: env.NPM_TOKEN == ''
104+
shell: bash
105+
run: |
106+
set -euo pipefail
107+
{
108+
echo "### npm publish skipped"
109+
echo ""
110+
echo "No \`NPM_TOKEN\` secret configured — the GitHub release was created, the npm package was not published."
111+
} >> "$GITHUB_STEP_SUMMARY"

‎README.md‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ On macOS and Linux the hook checks `process.platform` first and no-ops immediate
1010
## What You Get
1111

1212
- **No more "bash: command not found" on Windows** — Claude's `Bash` calls just work.
13-
- **Zero-overhead fast path** — POSIX-safe commands (`git`, `npm`, `grep`, `sed`, …) stay in Git Bash; PowerShell startup is only paid when needed.
13+
- **Zero-overhead fast path** — POSIX-safe commands (`git`, `npm`, `grep`, `sed`, `jq`, …) stay in Git Bash; PowerShell startup is only paid when needed. Unknown binaries stay there too — Git Bash sees more `PATH` than PowerShell does.
1414
- **Targeted PS rewrites** — `xdg-open`, `pbcopy`, `uname`, `df`, `free`, and friends map to clean PowerShell equivalents.
1515
- **MSYS drive-path translation** — `/c/Users/foo` → `C:/Users/foo` when the segment is routed to PS. URL-safe, packed-flag-aware (`-I/c/include` works), quote-aware.
1616
- **Smart denial with hints** — `sudo`, `systemctl`, `apt`, `chmod`, etc. don't silently fail; Claude is told what to use instead (`Start-Process -Verb RunAs`, `Get-Service`, `winget`, `icacls`, …).
@@ -55,13 +55,13 @@ Registers a `PreToolUse` hook on the `Bash` tool. For each command:
5555
2. **Shell detection** — probes for `pwsh` (PS 7+), falls back to `powershell.exe` (5.1). Cached 24h under `~/.claude/plugins/claude-code-win32.state.json`.
5656
3. **Chain parse** — quote- and paren-aware split at `|`, `&&`, `||`, `;`, `&`. Redirect tokens (`2>&1`, `1>&2`, `&>file`, `&>>file`) are recognised and **not** misclassified as chain operators.
5757
4. **Per-segment classification**:
58-
- **passthrough** — external `.exe` / POSIX allowlist (git, node, npm, docker, grep, sed, awk, …)
58+
- **passthrough** — external `.exe` / POSIX allowlist (git, node, npm, docker, grep, sed, awk, jq, rg, …). **An unknown binary also passes through**: the allowlist can never be complete, and Git Bash resolves both the Windows `PATH` and the MSYS `/usr/bin` tree, while PowerShell only sees the former. Only PowerShell-shaped names (`Verb-Noun` cmdlets, `*.ps1`) are routed to PS.
5959
- **rewrite** — known Unix idiom with a clean PowerShell equivalent (`xdg-open`, `pbcopy`, `whoami`, `uname`, `df`, `free`, …)
6060
- **deny** — Unix-only with no Windows equivalent (package managers, `systemctl`, `sudo`, `chmod`, `dmesg`, `iptables`, …)
6161
5. **Deny gates**:
6262
- Any segment is Unix-only → deny with a per-command hint pointing Claude at the Windows-native alternative.
6363
- `&&` / `||` present AND detected shell is PS 5.1 → deny with `https://aka.ms/powershell` install link.
64-
6. **Pass fast-path** — all segments Git-Bash-safe, no rewrite needed, `preferPowerShell=false` → allow unchanged (avoids PS 100–500 ms startup).
64+
6. **Pass fast-path** — all segments Git-Bash-safe (allowlisted or unknown), no rewrite needed, `preferPowerShell=false` → allow unchanged (avoids PS 100–500 ms startup).
6565
7. **MSYS path translation** — for segments routed to PowerShell, leading `/c/`, `/d/`, … prefixes are rewritten to `C:/`, `D:/`, …. Boundary-aware: URLs (`https://…`), UNC paths (`//server/share`), `file:///c/…`, and single-quoted literals are left intact. Packed compiler flags like `-I/c/include`, `-Wl,/c/lib`, `-isystem/c/sys` are recognised and translated.
6666
8. **Strategy A rewrite** — rebuild the chain in PowerShell syntax (`&&`/`||` native on PS 7; trailing `&` → `Start-Job`), prepend `$ProgressPreference='SilentlyContinue';` to suppress CLIXML progress noise on PS 5.1, wrap in `<exe>.exe -NoProfile -NonInteractive -EncodedCommand <base64>`.
6767

@@ -86,8 +86,32 @@ npm run build
8686
npm test
8787
```
8888

89+
CI runs the suite on Node 20/22/24 across ubuntu-latest **and** windows-latest, and
90+
checks that `package.json`, `.claude-plugin/plugin.json` and
91+
`.claude-plugin/marketplace.json` carry the same version.
92+
93+
### Cutting a release
94+
95+
```bash
96+
npm version <patch|minor|major> # bumps package.json, syncs plugin/marketplace, tags, pushes
97+
```
98+
99+
The `v*.*.*` tag triggers `.github/workflows/release.yml`: it builds and tests on
100+
Linux and Windows, verifies the tag matches `package.json`, creates the GitHub
101+
release with generated notes, and publishes to npm with provenance. The npm step is
102+
skipped (not failed) when no `NPM_TOKEN` secret is configured.
103+
89104
## Status
90105

91106
**v0.1.0** — initial release. 40 unit tests, live verified on PS 5.1 (Windows 11).
92107

93108
Known limitation: nested commands under `xargs foo` are classified on the outer binary only. `sudo` is on the deny-list, so the most common case is already covered.
109+
110+
### Troubleshooting
111+
112+
**`jq: command not found` (or any tool that works in Git Bash but not through the hook).**
113+
Fixed in v0.1.2. Before that, any binary missing from the POSIX allowlist pulled the
114+
*entire* chain into PowerShell — including the `grep`/`sed`/`cat` segments around it.
115+
PowerShell does not see the MSYS `/usr/bin` tree, so a perfectly installed `jq` reported
116+
as missing. Unknown binaries now stay in Git Bash. If a tool is genuinely not installed,
117+
install it on the Windows `PATH` (`winget install jqlang.jq`, `scoop install jq`).

‎package-lock.json‎

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "claude-code-win32",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"description": "Claude Code Plugin: hybrid bash→PowerShell transpiler for Windows. POSIX pass-through, targeted rewrites, MSYS path translation, per-command deny hints.",
55
"type": "module",
66
"main": "./dist/transpile.js",
@@ -26,7 +26,7 @@
2626
"scripts": {
2727
"build": "tsc",
2828
"dev": "tsc --watch",
29-
"test": "tsc && node --test dist/test/*.test.js",
29+
"test": "tsc && node scripts/run-tests.js",
3030
"preversion": "npm run build && npm test",
3131
"version": "node scripts/sync-version.js && git add .claude-plugin/plugin.json .claude-plugin/marketplace.json",
3232
"postversion": "git push && git push --tags",

‎scripts/run-tests.js‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Runs the compiled test files under dist/test.
5+
*
6+
* Neither a shell glob (`dist/test/*.test.js`) nor a bare directory argument
7+
* works everywhere: cmd.exe does not expand globs, so the glob form breaks on
8+
* the Windows runners, and `node --test dist/test` is only accepted by Node 20 —
9+
* 22 and 24 try to load the directory as a module. Listing the files here works
10+
* on every supported version and platform.
11+
*/
12+
13+
import { readdirSync } from "node:fs";
14+
import { spawnSync } from "node:child_process";
15+
import { join, dirname } from "node:path";
16+
import { fileURLToPath } from "node:url";
17+
18+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
19+
const testDir = join(root, "dist", "test");
20+
21+
const files = readdirSync(testDir)
22+
.filter((f) => f.endsWith(".test.js"))
23+
.sort()
24+
.map((f) => join(testDir, f));
25+
26+
if (files.length === 0) {
27+
console.error(`No compiled tests found in ${testDir} — did 'tsc' run?`);
28+
process.exit(1);
29+
}
30+
31+
const result = spawnSync(process.execPath, ["--test", ...files], { stdio: "inherit" });
32+
process.exit(result.status ?? 1);

‎src/rewriters.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,68 @@ export const POSIX_SAFE_BINS = new Set<string>([
7070
"source",
7171
".",
7272
"whoami",
73+
// JSON/text tooling Claude reaches for constantly — all ship .exe builds and
74+
// resolve under Git Bash. Missing entries here used to drag the WHOLE chain
75+
// into PowerShell, where the MSYS PATH (and therefore the tool) is gone.
76+
"jq",
77+
"yq",
78+
"rg",
79+
"fd",
80+
"bat",
81+
"delta",
82+
"diff",
83+
"patch",
84+
"tar",
85+
"gzip",
86+
"gunzip",
87+
"zip",
88+
"unzip",
89+
"base64",
90+
"md5sum",
91+
"sha1sum",
92+
"sha256sum",
93+
"openssl",
94+
"ssh",
95+
"scp",
96+
"sftp",
97+
"rsync",
98+
"make",
99+
"cmake",
100+
"ninja",
101+
"sqlite3",
102+
"psql",
103+
"mysql",
104+
"redis-cli",
105+
"aws",
106+
"az",
107+
"gcloud",
108+
"terraform",
109+
"helm",
110+
"ruby",
111+
"perl",
112+
"pytest",
113+
"date",
114+
"sleep",
115+
"seq",
116+
"stat",
117+
"ln",
118+
"du",
119+
"df",
120+
"ps",
121+
"kill",
122+
"sh",
123+
"bash",
73124
]);
74125

126+
/**
127+
* A binary we don't know is assumed to be a normal executable and passed to
128+
* Git Bash — an allowlist can never be complete, and forcing the unknown case
129+
* into PowerShell silently changes PATH resolution. The exception is a name
130+
* that is clearly PowerShell: a Verb-Noun cmdlet or a .ps1 script.
131+
*/
132+
export const looksLikePowerShell = (bin: string): boolean =>
133+
/^[A-Z][A-Za-z0-9]*-[A-Z][A-Za-z0-9]*$/.test(bin) || /\.ps1$/i.test(bin);
134+
75135
/**
76136
* Unix-only commands with no meaningful Windows equivalent — translating them
77137
* would produce wrong behavior (different package catalogs, different service

0 commit comments

Comments
 (0)