Skip to content

feat(windows): native Windows support — install.ps1, .cmd shims, 3-OS CI matrix#122

Merged
George-iam merged 12 commits intomainfrom
feat/native-windows-support-20260429
Apr 29, 2026
Merged

feat(windows): native Windows support — install.ps1, .cmd shims, 3-OS CI matrix#122
George-iam merged 12 commits intomainfrom
feat/native-windows-support-20260429

Conversation

@George-iam
Copy link
Copy Markdown
Contributor

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)

  1. atomicWrite fsync fix — opened a read-only fd for fsync, which on Windows returns EPERM (FlushFileBuffers needs write access). Switched to write-through-fd pattern matching appendLine. Fixes 12 test failures in engine.test.ts.
  2. findClaudePath / whichwhere.exe on Windowswhich is not a Windows command; setup leaked 'which' is not recognized stderr at every run.
  3. execSync(\"sleep 0.05\")Atomics.wait — POSIX sleep isn't on cmd.exe. SharedArrayBuffer + Atomics.wait is a real cross-platform sync sleep with no subprocess spawn.
  4. 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

  1. scripts/run-tests.mjs + package.json — replaces the POSIX shell glob test/*.test.ts with a cross-platform Node runner.
  2. test/audit-dedup.test.tsspawn(\"npx\") needs .cmd + shell:true on Windows; generated worker script used backslash-bearing paths unescaped (interpreted as escape sequences); hardcoded /tmp.
  3. test/agent-sdk-paths.test.tsnew URL(...).pathname produces /C:/... on Windows; switched to fileURLToPath.
  4. test/telemetry.test.ts — skip sets file mode 0600 on Windows (POSIX modes are a no-op there).
  5. test/auth-config.test.ts — mocked only \$HOME; Node's os.homedir() reads %USERPROFILE% on Windows. Mock both.

Phase 2 — Hooks cross-platform

  1. configureHooks() in src/cli.ts — generated hook commands now use absolute node.exe + absolute axme-code.js path, all segments quoted. Removes PATH dependency that was breaking on Windows (shebang-only entry can't be executed by cmd.exe).
  2. build.mjs — emits dist/axme-code.cmd and dist/plugin/bin/axme-code.cmd shims for direct-invoke on Windows.
  3. Plugin SessionStart — moved POSIX shell fragment into check-init Node code.

Phase 3 — Distribution

  1. install.ps1 — downloads latest Node bundle from GitHub Releases, saves as axme-code.js under %LOCALAPPDATA%\Programs\axme-code\, generates the .cmd wrapper, adds to User PATH.
  2. release-binary.yml matrix — adds windows-x64 and windows-arm64 targets (built on ubuntu-latest since esbuild output is platform-agnostic).
  3. README Quick Start — Linux/macOS / Windows native / Windows WSL2 sections (preserved Option 1 plugin / Option 2 standalone structure from main).

Phase 4 — CI

  1. .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)

  1. 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.url defined), but crashed in the standalone CJS bundle that install.ps1 actually deploys (esbuild stubs import_meta = {} so SDK fallback fileURLToPath(undefined) blows up). Fix: derive the real claude.exe from npm's claude.cmd shim 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 test 511/511 pass, lint + tsc clean
  • node dist/axme-code.js setup --force real OAuth: 8 LLM + 13 presets, $0.44, 107s
  • claude --print with hooks: SessionStart → SessionEnd → detached audit worker → audit_complete ($0.13)
  • claude --plugin-dir dist/plugin --print: SessionStart check-init creates CLAUDE.md, plugin loads cleanly

Windows 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 --force real 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-permissions with Write tool: notes.md created, PreToolUse + PostToolUse + SessionEnd all fired
  • Detached audit worker: session_end → check_result PASS → audit_complete ($0.13735) — survives parent claude exit on Windows

Not in this PR

  • v0.5.0 release-prep (version bump + CHANGELOG) — separate PR after merge.
  • Bun/scoop/winget Claude install support — current fix is npm-only (the standard documented install). Other install methods fall through to deterministic path; follow-up patches if jobs come in.

🤖 Generated with Claude Code

George-iam and others added 12 commits April 29, 2026 09:26
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>
@George-iam George-iam merged commit 20e3929 into main Apr 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant