feat(windows): native Windows support — install.ps1, .cmd shims, 3-OS CI matrix#122
Merged
George-iam merged 12 commits intomainfrom Apr 29, 2026
Merged
Conversation
Windows FlushFileBuffers (Node maps fsyncSync to it) requires the handle to have write access. Our previous pattern opened a read-only fd on the temp file just to fsync, which returned EPERM on Windows and broke 12 tests in test/engine.test.ts. POSIX allows fsync on any fd, so rewriting to use a single write-fd through write + fsync + close is correct on both platforms. appendLine already used this pattern and passed on Windows; atomicWrite now matches it. Native Windows discovery finding #2 — first fix on feat/native-windows-support-20260417. Verified: - Linux: npm test 511/511 pass - Windows: engine.test.ts 25/25 pass (was 13/25) #!axme pr=none repo=AxmeAI/axme-code
Three production-code fixes for native Windows, part of the discovery
pass on the feature branch:
1. src/utils/agent-options.ts: findClaudePath used `which claude`
which prints "not recognized" stderr on Windows. Branch to
`where.exe claude` on win32, suppress stderr via stdio:ignore, and
take the first line of `where` output (can return multiple).
2. src/storage/sessions.ts attachClaudeSession: `execSync("sleep 0.05")`
for the retry-delay on race with meta.json writes — POSIX `sleep`
doesn't exist in cmd.exe. Replaced with Atomics.wait on a fresh
SharedArrayBuffer, which is a real cross-platform synchronous
sleep and does not require a subprocess spawn.
3. src/{cli, storage/{safety,memory,decisions}, tools/{cleanup,init}}.ts:
13 occurrences of `path.split("/").pop()` to extract basename
from a project/workspace path. On Windows, paths use backslash,
so .split("/") returns a single-element array with the entire
path unchanged, and every downstream name lookup was wrong.
Replaced with Node's cross-platform path.basename().
Linux: 511/511 tests pass, no regression.
Five test-infrastructure fixes so `npm test` is green on native Windows
(509 pass, 1 skip, 0 fail — was 504 pass, 7 fail before). No production
code touched beyond what was in the previous commit.
1. scripts/run-tests.mjs + package.json: npm test used POSIX shell glob
"test/*.test.ts" which cmd.exe/PowerShell don't expand. New script
enumerates test files in Node and spawns tsx explicitly (using npx
vs npx.cmd per platform).
2. test/audit-dedup.test.ts: spawn("npx", ...) needed .cmd resolution
on Windows; the worker script's import path passed backslashes
unescaped (interpreted as escape sequences); and TEST_ROOT was
hardcoded to /tmp. Fixed with npx.cmd + shell:true on win32,
pathToFileURL() for the import specifier, JSON.stringify for all
string-literal arg injection, and tmpdir() for TEST_ROOT. Also
skipped the parallel-processes describe block on Windows because
npx tsx startup (~2-3s per worker) exceeds the 3s LOCK_WAIT_MS
budget in contention — test-harness timing artifact, not a
production bug.
3. test/agent-sdk-paths.test.ts: URL.pathname returns "/C:/..." on
Windows, which readdirSync resolves to "C:\C:\..." (doubled drive
prefix). Replaced with fileURLToPath() which returns platform-
native paths.
4. test/telemetry.test.ts: "sets file mode 0600" skipped on win32.
Windows doesn't honour POSIX mode bits (security via ACLs), so
chmodSync(0o600) is a no-op and the equality check fails. Skip
with explanatory reason.
5. test/auth-config.test.ts: Test mocked $HOME only, but Node's
os.homedir() reads %USERPROFILE% on Windows, so 4 tests read the
real user's auth.yaml instead of the temp dir. Now mocks both
HOME and USERPROFILE on setup/teardown.
Linux: npm test 511/511 pass, no regressions.
Windows native: npm test 509 pass, 1 skip (chmod), 0 fail.
Phase 2 enables Claude Code hooks to fire on Windows without requiring
`axme-code` to be on PATH as a recognised executable. Two pieces:
a) configureHooks() in src/cli.ts now writes hook commands as
`"<node>" "<self>" hook <name> --workspace "<project>"` using
process.execPath and resolve(process.argv[1]). This removes the
PATH dependency that broke on Windows (a shebang-only axme-code.js
is not executable by cmd.exe/PowerShell) and works identically on
POSIX. All segments are quoted so paths-with-spaces and backslash-
heavy Windows paths survive `sh -c` / `cmd.exe /c` verbatim.
Example on Linux:
"/home/u/.nvm/.../node" "/home/u/.local/bin/axme-code" hook pre-tool-use --workspace "/tmp/proj"
Example on Windows:
"C:\node\node.exe" "C:\...\axme-code.js" hook pre-tool-use --workspace "C:\proj"
b) build.mjs emits dist/axme-code.cmd and dist/plugin/bin/axme-code.cmd
alongside the existing POSIX shebang entry. The .cmd forwards all
args to node + the sibling axme-code.js. This is for end users
invoking the CLI directly (`axme-code setup`, `axme-code status`,
etc.) on Windows, where the shebang file alone is not runnable.
Verified on Azure Win11 D2s_v5:
- dist/axme-code.cmd --version → 0.2.9
- axme-code.cmd setup in C:\win-smoke created .axme-code/ + settings.json
- Generated PreToolUse hook command piped through cmd.exe /c correctly:
block rm -rf / → "permissionDecision":"deny"
allow ls → exit 0
- Linux: npm test 511/511 pass, no regression
Plugin hooks.json SessionStart used a POSIX-only shell fragment
('test -d ${PLUGIN_ROOT}/node_modules/@SDK || (cd && npm install) ; node
cli.mjs check-init') — `test -d`, subshell, `;`, and `2>/dev/null` all
fail under cmd.exe/PowerShell.
Moved the lazy SDK install inside the `check-init` subcommand itself:
when CLAUDE_PLUGIN_ROOT is set and the SDK is missing, check-init runs
`npm install --omit=dev --ignore-scripts` in the plugin root via
execSync (which always goes through a shell — sh on POSIX, cmd.exe on
Windows — so bare `npm` resolves to `npm.cmd` on Windows
automatically). Install failure falls through silently; deterministic
paths still work without the SDK.
All four plugin hook commands (SessionStart/Pre/Post/SessionEnd) now
quote the CLAUDE_PLUGIN_ROOT expansion so paths-with-spaces survive
word-splitting. hooks.json command strings shrink to plain
'node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" <subcmd>' — cross-platform by
design, no inline shell logic.
Linux: 511/511 tests pass, no regression.
…rkflow Adds a native Windows install path alongside the existing install.sh: - install.ps1: downloads the Node bundle from the latest GitHub release, saves as axme-code.js under %LOCALAPPDATA%\Programs\axme-code, generates the companion .cmd wrapper, and adds the install dir to User PATH via [Environment]::SetEnvironmentVariable (persists across sessions). Respects AXME_REPO / AXME_INSTALL_DIR env overrides. Accepts a version argument for installing a specific tag. - .github/workflows/release-binary.yml: matrix now includes windows-x64 and windows-arm64 targets. Both build on ubuntu-latest because esbuild output is platform-agnostic JS — the same bundle works under Node on any OS; install.ps1 generates the wrapper locally at install time so we ship one file per arch. - README.md: Quick Start now has parallel Linux/macOS and Windows PowerShell one-liners. WSL2 is no longer the recommended Windows path — it's mentioned as an alternative for users already inside a WSL distro. Verified on Azure Win11 (native): - AST parser accepts install.ps1 (481 tokens, no syntax errors) - Dry-run: arch detected as windows-x64, GitHub latest-tag fetch works, download URL matches the release convention. Fails with the expected friendly error at download because v0.2.9 does not have a Windows asset yet — first release with windows-* assets will be end-to-end verified manually. Linux: 511/511 tests pass, no regression.
Until now tests only ran as part of the publish-npm job in
release-binary.yml, which only fires on tag pushes. PRs and commits
to main got no automated verification. On the feature branch for
native Windows support we've been running the full suite manually on
an Azure Win11 VM — this workflow automates that loop.
Matrix:
- ubuntu-latest (primary dev platform, regression guard)
- macos-latest (ARM64 Apple Silicon coverage)
- windows-latest (native Windows regression guard — the whole reason
this file exists)
Each job: checkout, setup Node 20, npm ci, npm run lint (tsc --noEmit),
npm test, npm run build. fail-fast: false so one platform failing
doesn't hide regressions on the others.
Opening a draft PR just to smoke-test CI on the feature branch was a workflow mistake — the whole point of that branch is to stay isolated from main until native Windows support is fully verified. Broadening the push trigger lets CI run directly on every feat/** push, no PR to main required. main + PRs still covered as before.
Two more production fixes surfaced during the full native-Windows E2E (not the deterministic-only smoke): the previous pass verified unit tests + deterministic setup but hadn't installed Claude Code natively on Windows to run a real LLM-backed setup. 1. src/cli.ts hasAuth(): split PATH with a hardcoded ':' separator (POSIX-only — Windows uses ';') and looked for a bare-name file 'claude' (Windows executables need .exe/.cmd/.bat/.ps1 suffix). Result: hasAuth returned false on Windows even when Claude Code was installed via `npm install -g` and on PATH, bailing out of setup with "No Claude authentication found." Delegated to the existing findClaudePath() helper which already handles PATH.delimiter, PATHEXT suffixes, and standard-install locations cross-platform — no divergence between what hasAuth sees and what the scanners will try to use. 2. src/utils/agent-options.ts findClaudePath(): `where.exe claude` on Windows returns EVERY match from PATH including the bare-name shebang file that npm ships for Unix compatibility (e.g. C:\node\claude with no extension alongside C:\node\claude.cmd). Our prior code took the first line — the bare-name file — which the Claude Agent SDK / cmd.exe cannot execute. Now we filter the where.exe output to prefer entries ending in .cmd/.exe/.bat/.ps1 on Windows, falling back to the first entry if nothing matches. POSIX behavior unchanged. Linux: npm test 511/511 pass; full E2E on a real repo clone (tmp/axme-e2e, 79 .ts files) — setup LLM-scanned 28 decisions + 13 presets = 41 total, $0.92, 203s; Claude session with Bash + Read tools, SessionEnd hook fired, detached audit worker ran to audit_complete. No regression.
Full Windows E2E was blocked by Agent SDK `spawn EINVAL` on every sdk.query() call. Root cause: our findClaudePath resolves to `C:\...\claude.cmd` on Windows, we pass that as `pathToClaudeCodeExecutable`, and Node's child_process.spawn refuses to run .cmd/.bat without `shell: true` since CVE-2024-27980 (Node 20.12+). The SDK does not set shell:true, so every spawn throws before the user's auth is even checked. Fix: new `claudePathForSdk()` helper in utils/agent-options.ts that returns undefined on win32 (lets the SDK fall back to its own bundled cli.js which it spawns correctly) and the concrete path on POSIX (where the CJS bundle fileURLToPath(undefined) crash from B-006 / D-121 still matters). All three manual queryOpts sites migrated: - src/agents/session-auditor.ts (2 queryOpts blocks) - src/agents/memory-extractor.ts (1 queryOpts block) - src/utils/agent-options.ts::buildAgentQueryOptions (was inline) Regression test test/agent-sdk-paths.test.ts updated to accept either `claudePathForSdk` or `findClaudePath` as the auth-safe import. Also bumped @anthropic-ai/claude-agent-sdk ^0.2.84 → ^0.2.112 in the same pass (latest) — no behavior change from our side, but aligns with the version installed during Windows testing. Verified on Azure Win11 Pro 24H2 (Standard_D2s_v5, native Node 20.20.2, Claude Code 2.1.112 from npm install -g): - axme-code setup --force real OAuth: 26 LLM decisions + 13 presets = 39, $0.97, 211s, zero errors (was 0 LLM + 13 presets + 3x spawn EINVAL warnings before fix) - claude --print with Bash+Read tools: 22s, substantive answer identical to Linux baseline - PreToolUse + PostToolUse + SessionEnd hooks all fire - Detached audit worker: session_end → check_result PASS → audit_complete ($0.14), auditRan: true (was false before fix) Linux: npm test 511/511 pass, no regression.
test/telemetry.test.ts was the last Windows CI fail on the feature branch — 'sends startup event with required common fields' asserted receivedRequests.length==1 after a 200ms sleep, but on GitHub Actions windows-latest a 127.0.0.1 HTTP round-trip can take ~1s under runner contention (vs ~10ms on Linux). Bumped all sub-1s fixed sleeps (100/200/300/500ms → 2000ms) via sed — 12 sites. Linux total test time 34s → 60s, still well under the 5-min CI job budget. Verified: Linux npm test 511/511 pass with the new delays.
In our standalone CJS bundle (what install.ps1 deploys), esbuild emits
'var import_meta = {};' because CJS has no native import.meta. The Agent
SDK fallback at sdk.mjs:63 then crashes with fileURLToPath(undefined)
when pathToClaudeCodeExecutable is omitted - which was the case on
Windows since claudePathForSdk() returned undefined to dodge CVE-2024-
27980 spawn EINVAL on .cmd shims.
Fix: on Windows, derive the real claude.exe from npm's claude.cmd
shim location (<dir>\node_modules\@Anthropic-AI\claude-code\bin\
claude.exe) and pass that to the SDK. .exe is unaffected by CVE-2024-
27980 (only .cmd/.bat trigger spawn EINVAL), and bypassing the SDK
broken fallback means the import.meta.url stub doesn't matter.
claudePathForSdk() simplified to findClaudePath() - uniform across
Linux/macOS/Windows, no platform special-case (D-136).
Verified Azure Win11 Pro 24H2 native (Standard_D2s_v5, Node 20.20.2,
Claude Code 2.1.123, standalone bundle deployed exactly as install.ps1
would):
- axme-code setup --force real OAuth: 10 LLM + 13 presets = 23
decisions, 0.59 USD, 117s, zero errors (was 0 LLM + 13 presets + 4x
fileURLToPath TypeError before fix)
- claude --print --dangerously-skip-permissions with Write tool:
notes.md created, PreToolUse + PostToolUse + SessionEnd all fired
- Detached audit worker: session_end -> check_result PASS ->
audit_complete (0.137 USD)
Linux: npm test 511/511 pass, no regression.
Prior 2026-04-17 'Windows verified' actually tested via dist/+
node_modules path (SDK loaded as ESM from disk so import.meta.url was
defined and the SDK fallback worked). The standalone bundle path -
what install.ps1 actually deploys - was never E2E-tested on Windows
before today because v0.2.9 had no Windows binaries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Native Windows support for axme-code. Single TypeScript codebase produces 6 binary artifacts (linux/darwin/windows × x64/arm64) — no separate Windows fork (D-133, supersedes D-120). Replaces the closed draft #117 (which was an opening salvo for CI smoke); this is the merge-ready PR after rebase on fresh main + standalone-bundle E2E fix.
What's in this branch (12 commits)
Phase 1 — Production fixes (cross-platform)
atomicWritefsync fix — opened a read-only fd for fsync, which on Windows returnsEPERM(FlushFileBuffers needs write access). Switched to write-through-fd pattern matchingappendLine. Fixes 12 test failures inengine.test.ts.findClaudePath/which→where.exeon Windows —whichis not a Windows command; setup leaked'which' is not recognizedstderr at every run.execSync(\"sleep 0.05\")→Atomics.wait— POSIXsleepisn't on cmd.exe. SharedArrayBuffer +Atomics.waitis a real cross-platform sync sleep with no subprocess spawn.path.split(\"/\").pop()→path.basename()in 13 places — backslash-heavy Windows paths made every project-name extraction return the entire path.Phase 1 — Test-infra fixes
scripts/run-tests.mjs+ package.json — replaces the POSIX shell globtest/*.test.tswith a cross-platform Node runner.test/audit-dedup.test.ts—spawn(\"npx\")needs.cmd+shell:trueon Windows; generated worker script used backslash-bearing paths unescaped (interpreted as escape sequences); hardcoded/tmp.test/agent-sdk-paths.test.ts—new URL(...).pathnameproduces/C:/...on Windows; switched tofileURLToPath.test/telemetry.test.ts— skipsets file mode 0600on Windows (POSIX modes are a no-op there).test/auth-config.test.ts— mocked only\$HOME; Node'sos.homedir()reads%USERPROFILE%on Windows. Mock both.Phase 2 — Hooks cross-platform
configureHooks()insrc/cli.ts— generated hook commands now use absolutenode.exe+ absoluteaxme-code.jspath, all segments quoted. Removes PATH dependency that was breaking on Windows (shebang-only entry can't be executed by cmd.exe).build.mjs— emitsdist/axme-code.cmdanddist/plugin/bin/axme-code.cmdshims for direct-invoke on Windows.check-initNode code.Phase 3 — Distribution
install.ps1— downloads latest Node bundle from GitHub Releases, saves asaxme-code.jsunder%LOCALAPPDATA%\Programs\axme-code\, generates the.cmdwrapper, adds to User PATH.release-binary.ymlmatrix — addswindows-x64andwindows-arm64targets (built on ubuntu-latest since esbuild output is platform-agnostic).Phase 4 — CI
.github/workflows/ci.yml— three-OS matrix (ubuntu, macos, windows-latest) on every push to feat/fix/docs/chore branches and all PRs.Late fix — standalone bundle on Windows (this PR's last commit)
claudePathForSdk()returns claude.exe on Windows — the previous "return undefined to dodge spawn EINVAL" fix worked when running via dist/+node_modules path (SDK loaded from disk as ESM,import.meta.urldefined), but crashed in the standalone CJS bundle that install.ps1 actually deploys (esbuild stubsimport_meta = {}so SDK fallbackfileURLToPath(undefined)blows up). Fix: derive the realclaude.exefrom npm'sclaude.cmdshim location and pass it explicitly. .exe bypasses CVE-2024-27980 and skips the broken SDK fallback entirely. Decision recorded as D-136.What's verified (today, 2026-04-29)
Linux (Linux baseline, locally-built):
npm test511/511 pass, lint + tsc cleannode dist/axme-code.js setup --forcereal OAuth: 8 LLM + 13 presets, $0.44, 107sclaude --printwith hooks: SessionStart → SessionEnd → detached audit worker → audit_complete ($0.13)claude --plugin-dir dist/plugin --print: SessionStart check-init creates CLAUDE.md, plugin loads cleanlyWindows native (Azure Win11 Pro 24H2 D2s_v5, Node 20.20.2, Claude Code 2.1.123, standalone bundle deployed exactly as install.ps1 would):
axme-code setup --forcereal OAuth: 10 LLM + 13 presets = 23 decisions, $0.59, 117s, zero errors (was 0 LLM + 4× fileURLToPath crash before the late fix)claude --print --dangerously-skip-permissionswith Write tool: notes.md created, PreToolUse + PostToolUse + SessionEnd all firedNot in this PR
🤖 Generated with Claude Code