diff --git a/.agents/docs/2026-05-17-windows-llvm-support-design.md b/.agents/docs/2026-05-17-windows-llvm-support-design.md
new file mode 100644
index 0000000..a6b9221
--- /dev/null
+++ b/.agents/docs/2026-05-17-windows-llvm-support-design.md
@@ -0,0 +1,49 @@
+# Windows LLVM/Clang 支持设计方案
+
+Date: 2026-05-17
+
+## 目标
+
+mcpp 在 Windows x86_64 上通过 xmake bootstrap 达到可用水平，产出 mcpp.exe 作为后续自举依赖。
+
+## 平台特征
+
+### Windows LLVM 包（xlings-res 20.1.7）
+
+```
+bin/clang.exe, clang++.exe, clang-cl.exe, lld-link.exe
+bin/llvm-ar.exe, llvm-lib.exe, llvm-rc.exe
+lib/clang/20/lib/windows/clang_rt.*.lib
+没有 libc++（没有 include/c++/v1，没有 std.cppm）
+没有 clang-scan-deps.exe
+```
+
+Windows LLVM 包不含 libc++。Windows 上 clang 搭配 MSVC STL。
+
+### Bootstrap 策略
+
+用 xmake + MSVC（和 xlings 自身做法一致）：
+- GitHub Actions windows-latest 预装 Visual Studio
+- xmake 对 MSVC C++23 modules 支持成熟
+- 不需要额外安装 LLVM（MSVC 即可）
+
+## 代码适配清单
+
+### 必须修改
+
+| 文件 | 问题 | 方案 |
+|------|------|------|
+| ninja_backend.cppm | POSIX shell 命令 | #if _WIN32 cmd.exe 语法 |
+| ninja_backend.cppm | mcpp_exe_path() 缺 Windows | GetModuleFileNameA() |
+| config.cppm | MCPP_HOME 路径发现缺 Windows | 同上 |
+| probe.cppm | command -v Unix only | where.exe |
+| probe.cppm | LD_LIBRARY_PATH | Windows 用 PATH |
+| flags.cppm | 链接 flags 缺 Windows 分支 | 无 sysroot/rpath |
+| xlings.cppm | popen | _popen |
+
+## 执行顺序
+
+1. 创建 ci-windows.yml 用 xmake 构建，看编译错误
+2. 根据 CI 错误逐步修代码
+3. 产出 mcpp.exe bootstrap binary
+4. 上传到 xlings-res
diff --git a/.agents/docs/2026-05-19-pack-windows-design.md b/.agents/docs/2026-05-19-pack-windows-design.md
new file mode 100644
index 0000000..14544d0
--- /dev/null
+++ b/.agents/docs/2026-05-19-pack-windows-design.md
@@ -0,0 +1,97 @@
+# Windows Pack Design
+
+**Date:** 2026-05-19
+**Status:** Planned (stub guard in place, implementation not yet started)
+
+## Current state
+
+`mcpp pack` is fully functional on Linux and macOS. On Windows it exits early
+with a clear error message directing users to the CI workflow:
+
+```
+error: `mcpp pack` is not yet supported on Windows.
+       Use the CI workflow (ci-windows.yml) to produce Windows zip packages.
+       Windows PE packaging (DLL collection + zip) is planned.
+```
+
+The guard lives at the top of `mcpp::pack::run()` in `src/pack/pack.cppm`.
+
+## Why the current implementation cannot run on Windows
+
+The POSIX implementation relies on three Linux/macOS-only mechanisms:
+
+| Mechanism | POSIX usage | Windows equivalent |
+|---|---|---|
+| `LD_TRACE_LOADED_OBJECTS=1` | Tells the ELF dynamic linker to print deps without executing `main()` | No direct equivalent. Would need `dumpbin /dependents` (MSVC) or `ldd` emulation via `LoadLibraryEx` |
+| `patchelf` | Rewrites `RUNPATH` / `PT_INTERP` ELF headers in-place | Not applicable to PE/COFF. DLL search order is controlled by the OS loader and manifest, not embedded paths |
+| `tar -czf` | GNU tar — not universally present on Windows before Win11 22H2 | `Compress-Archive` (PowerShell), `7z`, or Win32 `CreateFile`/`MiniZip` |
+
+## Planned Windows pack implementation
+
+### Goal
+
+Produce a self-contained `.zip` archive (not `.tar.gz`) that users can
+extract and run with no additional setup:
+
+```
+<name>-<version>-x86_64-pc-windows-msvc.zip
+└── <name>-<version>-x86_64-pc-windows-msvc/
+    ├── <name>.exe
+    ├── *.dll         (bundled DLLs, if any)
+    └── README.md / LICENSE (if present)
+```
+
+### DLL discovery
+
+Replace `ldd_parse()` with a Win32 equivalent:
+
+1. **Primary: `dumpbin /dependents <binary>`** — available when MSVC tools are
+   on `PATH`. Produces a list of DLL names; resolve each against `PATH` /
+   `%SystemRoot%\System32` / side-by-side assemblies.
+
+2. **Fallback: `PE header walk`** — open the PE file, walk the Import Directory,
+   extract DLL names.  Can be implemented with `<windows.h>` + `ImageNtHeader`.
+
+3. **Skip-list**: mirror the manylinux skip-list concept for Windows:
+   `kernel32.dll`, `user32.dll`, `ntdll.dll`, `vcruntime*.dll` (Redist),
+   `api-ms-win-*.dll` (API sets), `ucrtbase.dll`.
+
+### Archive creation
+
+Use `std::filesystem` to copy files into a staging directory, then produce
+the zip with one of:
+
+- **PowerShell** `Compress-Archive` — available on all modern Windows.
+  Invoke via `run_capture("powershell -Command \"Compress-Archive ..."`)`.
+  Slow for large trees; fine for typical release packages.
+- **libzip / minizip** — statically linkable; avoid the PowerShell dependency.
+  Preferred long-term.
+
+### Format
+
+- Output file: `.zip` (not `.tar.gz`) on Windows.
+- `pack::Format` enum needs a new `Zip` variant (or auto-select by platform).
+- `make_plan()` should derive the output extension from the target platform.
+
+### Entry point
+
+No shell wrapper needed on Windows — users double-click `<name>.exe` or run
+it from `cmd.exe` / PowerShell directly.  If DLLs are bundled, they should be
+placed in the **same directory** as the executable (the Win32 loader checks
+`%EXE_DIR%` first, before `%PATH%`).
+
+### Implementation checklist (for the future PR)
+
+- [ ] Add `Format::Zip` (or `Format::ZipAuto`) to `pack::Format`
+- [ ] Implement `dumpbin_parse()` (or PE header walk fallback) in `pack.cppm`
+      under `#if defined(_WIN32)`
+- [ ] Implement `make_zip()` (PowerShell or libzip) in `pack.cppm`
+- [ ] Remove the `#if defined(_WIN32)` early-return guard from `pack::run()`
+      once the above are ready
+- [ ] Add a Windows-specific integration test to `ci-windows.yml`
+
+### CI workflow (current workaround)
+
+Until this is implemented, `ci-windows.yml` zips the raw build output with
+PowerShell `Compress-Archive`. This is good enough for CI artifacts but does
+not collect/bundle DLLs or apply the staging-directory layout.
diff --git a/.agents/docs/2026-05-19-windows-e2e-parity-plan.md b/.agents/docs/2026-05-19-windows-e2e-parity-plan.md
new file mode 100644
index 0000000..3c7a88c
--- /dev/null
+++ b/.agents/docs/2026-05-19-windows-e2e-parity-plan.md
@@ -0,0 +1,43 @@
+# Windows E2E 与 macOS 对齐方案
+
+> 目标：Windows E2E 从 20/49 提升到 ~32/49，与 macOS 33/49 对齐。
+
+## 根因分析
+
+| 类别 | 测试数 | 问题 | 修复方式 |
+|------|--------|------|----------|
+| mcpp run/test 单引号 | 1 (02) | `cli.cppm` 用 POSIX 单引号执行 binary | `_WIN32` 改双引号 |
+| clang-scan-deps 查找 | 1 (16) | `cli.cppm` 硬编码无 .exe | 调用已有 `find_scan_deps()` |
+| symlink 依赖 | 4 (10,24,27,32) | `_inherit_toolchain.sh` 用 `ln -sf` | 加 `cp -r` fallback |
+| bash-specific 语法 | 1 (19) | `compgen -G` 不在 Git Bash | 改用 `find` |
+| unix-shell 误标 | 1 (38_mirror) | 实际只需 symlink fallback | 改标签 |
+| import-std-libcxx 硬编码路径 | 4 (37,38,40,41) | 测试用 Linux 路径 | 加 Windows 路径 |
+
+## 修复计划
+
+### Fix 1: cli.cppm 单引号 → 双引号 (解锁 02)
+- `src/cli.cppm:2611` — `mcpp run` 执行 binary 用 `'{}'` → Windows 改 `"{}"`
+- `src/cli.cppm:2522` — fast-path ninja 同上
+- `src/cli.cppm:3159` — test PATH prefix 是 POSIX 语法，Windows 跳过
+
+### Fix 2: clang-scan-deps 查找 (解锁 16)
+- `src/cli.cppm:2162-2167` — 直接查找 `clang-scan-deps`，不走 `find_scan_deps()`
+- 改为调用 `mcpp::toolchain::clang::find_scan_deps(*tc)` 已正确处理 .exe
+
+### Fix 3: _inherit_toolchain.sh cp fallback (解锁 10,24,27,32)
+- 当 `ln -sf` 失败时用 `cp -r` 替代
+- 自动检测 symlink 可用性
+
+### Fix 4: 19_bmi_cache_reuse.sh bash 兼容 (解锁 19)
+- `compgen -G` → `find ... | grep -q .`
+
+### Fix 5: LLVM 测试 Windows 路径 (解锁 37,38,40,41)
+- 参照 36_llvm_toolchain.sh 的模式加 Windows 路径和 .exe 处理
+
+### Fix 6: 标签修正
+- `38_self_config_mirror.sh` 改标签
+- `run_all.sh` 移除已修复测试的标签限制
+
+## 预期结果
+
+修复后：**~32 passed, 0 failed, ~17 skipped** (与 macOS 33 passed 对齐)
diff --git a/.agents/docs/2026-05-19-windows-platform-maturity-plan.md b/.agents/docs/2026-05-19-windows-platform-maturity-plan.md
new file mode 100644
index 0000000..f00fae5
--- /dev/null
+++ b/.agents/docs/2026-05-19-windows-platform-maturity-plan.md
@@ -0,0 +1,240 @@
+# Windows 平台成熟度提升方案
+
+> 基于 PR #52 code review 反馈，针对 Windows 支持从"可自举"到"生产就绪"的优化路径。
+
+## 当前状态
+
+| 能力 | Linux | macOS | Windows | 差距 |
+|------|-------|-------|---------|------|
+| self-host | ✅ | ✅ | ✅ | — |
+| `mcpp test` (unit) | ✅ | ✅ | ❌ | 缺 clang-scan-deps |
+| E2E 覆盖 | 46/46 | 33/46 | 22/49 | 27 项 skip |
+| `mcpp pack` | ✅ (musl static) | ✅ (手动) | ❌ (CI 手写 zip) | pack 不支持 PE |
+| release workflow | ✅ | ✅ | ❌ | 无 build-windows job |
+| MSVC 工具链 | N/A | N/A | 模型预留 | detect 不支持 |
+| 默认工具链回退 | gcc@15.1.0-musl | llvm@20.1.7 | llvm@20.1.7 | ✅ 已修 |
+
+## 优化方案（按优先级）
+
+### P0: 补齐 release workflow + 减少 E2E skip
+
+**目标：** Windows 二进制进入正式 release 发布流程。
+
+#### 1. release.yml 加 build-windows job
+
+参照 `build-macos` 结构，在 `release.yml` 中增加 `build-windows` job：
+
+```yaml
+build-windows:
+  name: build (Windows / x64)
+  runs-on: windows-latest
+  needs: build-release
+  # xlings install mcpp → mcpp build → package zip → upload
+```
+
+产出 `mcpp-<version>-windows-x86_64.zip` + sha256，上传到 GitHub Release。
+
+#### 2. 修复高价值 E2E skip 项
+
+按投入产出排序：
+
+| 测试 | 修复方式 | 工作量 |
+|------|----------|--------|
+| `02_new_build_run.sh` | 检查 `bin/hello` 或 `bin/hello.exe` | 小 |
+| `16_test_failing.sh` | 调查 Windows 上 exit code 传递 | 小 |
+| `35_workspace.sh` | 同上，binary 名加 `.exe` 检查 | 小 |
+| `36_llvm_toolchain.sh` | 同上 | 小 |
+| `19_bmi_cache_reuse.sh` | 修复 `cp_bmi` rule 的混合路径 | 中 |
+| `24_git_dependency.sh` | CRLF + Windows 路径处理 | 中 |
+| `38_self_config_mirror.sh` | xlings mirror cmd.exe 路径 | 中 |
+
+**预计可把 E2E 从 22 passed 提升到 ~30 passed。**
+
+### P1: PlatformTraits 抽象
+
+**目标：** 减少散落的 `#if defined(_WIN32)` / `#if defined(__APPLE__)`。
+
+新建 `src/platform.cppm`，集中平台差异：
+
+```cpp
+export module mcpp.platform;
+import std;
+
+export namespace mcpp::platform {
+
+constexpr std::string_view exe_suffix =
+#if defined(_WIN32)
+    ".exe";
+#else
+    "";
+#endif
+
+constexpr std::string_view static_lib_ext =
+#if defined(_WIN32)
+    ".lib";
+#else
+    ".a";
+#endif
+
+constexpr std::string_view shared_lib_ext =
+#if defined(_WIN32)
+    ".dll";
+#elif defined(__APPLE__)
+    ".dylib";
+#else
+    ".so";
+#endif
+
+constexpr std::string_view null_redirect =
+#if defined(_WIN32)
+    "2>nul";
+#else
+    "2>/dev/null";
+#endif
+
+constexpr std::string_view lib_prefix =
+#if defined(_WIN32)
+    "";
+#else
+    "lib";
+#endif
+
+std::string shell_quote(std::string_view s);  // 取代散落的 shq
+
+} // namespace mcpp::platform
+```
+
+**受益文件：** `plan.cppm`、`flags.cppm`、`ninja_backend.cppm`、`probe.cppm`、`clang.cppm`、`config.cppm`
+
+### P2: ToolchainProvider 重构
+
+**目标：** 把工具链行为从散落的 `if (isClang)` / `if (isGcc)` 收敛到 provider 接口。
+
+当前工具链代码分散在：
+- `gcc.cppm` — GCC 行为
+- `clang.cppm` — Clang/libc++ 行为 + MSVC STL fallback
+- `llvm.cppm` — xlings 包映射
+- `detect.cppm` — 只处理 GCC/Clang
+- `flags.cppm` — 编译/链接 flags 按平台分支
+- `ninja_backend.cppm` — 构建规则按平台分支
+
+建议拆成明确的 provider：
+
+```
+ToolchainProvider (interface)
+  ├── GccProvider          — GCC + glibc/musl
+  ├── ClangLibcxxProvider  — Clang + libc++ (Linux/macOS)
+  ├── ClangMsvcProvider    — Clang + MSVC STL (Windows)
+  └── MsvcProvider         — cl.exe (未来)
+```
+
+每个 provider 声明：
+- `frontend()` → 编译器路径
+- `c_compiler()` → C 编译器
+- `archive_tool()` → ar/llvm-ar/lib.exe
+- `scanner()` → clang-scan-deps 路径
+- `stdlib_id()` → libc++/libstdc++/msvc-stl
+- `find_std_module()` → std.cppm/std.cc/std.ixx
+- `compile_flags()` → 平台相关编译 flags
+- `link_flags()` → 平台相关链接 flags
+- `bmi_traits()` → .gcm/.pcm/.ifc
+
+### P3: 跨平台 Process Runner
+
+**目标：** 消除 shell 字符串拼接，统一子进程执行。
+
+当前问题：
+- `popen` + cmd.exe 字符串拼接（路径空格、引号转义脆弱）
+- `shq()` 在 Windows 上有 cmd.exe 首 token 引号剥离问题
+- `_putenv_s` 污染全局进程环境
+
+建议新建 `src/process.cppm`：
+
+```cpp
+struct ProcessOptions {
+    std::vector<std::string> argv;
+    std::map<std::string, std::string> env;  // 进程级环境变量
+    std::filesystem::path cwd;
+    bool capture_stdout = true;
+    bool capture_stderr = false;
+};
+
+struct ProcessResult {
+    int exit_code;
+    std::string stdout_output;
+    std::string stderr_output;
+};
+
+ProcessResult run(const ProcessOptions& opts);
+```
+
+POSIX: `fork/exec` + `pipe`
+Windows: `CreateProcessW` + `STARTUPINFOW`
+
+**受益范围：** `probe.cppm`、`xlings.cppm`、`stdmod.cppm`、`ninja_backend.cppm`、`config.cppm`
+
+### P4: `mcpp pack` Windows 支持
+
+**目标：** `mcpp pack` 原生支持 Windows PE 打包。
+
+当前 `pack.cppm` 依赖：
+- `LD_TRACE_LOADED_OBJECTS` (Linux ELF)
+- `patchelf` (RPATH 修改)
+- `tar -czf` (打包格式)
+
+Windows 需要：
+- DLL 依赖收集（`dumpbin /dependents` 或 `llvm-objdump`）
+- 无需 RPATH（DLL 在 exe 同目录自动找到）
+- `.zip` 打包 + `.bat` wrapper
+
+建议 pack 做成平台策略：
+
+```
+PackStrategy (interface)
+  ├── LinuxElfPack   — ldd + patchelf + tar.gz
+  ├── MacosMachoPack — otool + install_name_tool + tar.gz
+  └── WindowsPePack  — dumpbin + zip + .bat
+```
+
+### P5: E2E 能力标签化
+
+**目标：** 从"平台 skip 列表"升级为"能力标签"。
+
+在每个 E2E 脚本头部声明需求：
+
+```bash
+# requires: elf          — 需要 ELF 工具链
+# requires: gcc          — 需要 GCC
+# requires: symlink      — 需要 ln -sf
+# requires: scan-deps    — 需要 clang-scan-deps
+# requires: import-std   — 需要 import std (std.cppm/std.ixx)
+# requires: pack         — 需要 mcpp pack
+```
+
+`run_all.sh` 读取标签，根据当前平台的能力集决定 skip，不再维护平台 skip 列表。
+
+## 实施顺序
+
+```
+P0 release + E2E 修复   ← 立即可做，产出最大
+  ↓
+P1 PlatformTraits       ← 减少 #if 散落，降低后续维护成本
+  ↓
+P2 ToolchainProvider    ← 为 MSVC 支持打基础
+  ↓
+P3 Process Runner       ← 消除 shell 拼接风险
+  ↓
+P4 mcpp pack Windows    ← 产品化打包
+  ↓
+P5 E2E 标签化           ← 测试治理
+```
+
+## 预期里程碑
+
+| 阶段 | 目标 | Windows E2E 通过率 |
+|------|------|-------------------|
+| 当前 | self-host + 基础 E2E | 22/49 (45%) |
+| P0 完成 | release + 高价值 E2E | ~30/49 (61%) |
+| P1+P2 完成 | 平台抽象 + provider | ~35/49 (71%) |
+| P3+P4 完成 | process runner + pack | ~40/49 (82%) |
+| P5 完成 | 能力标签 | 动态评估 |
diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml
new file mode 100644
index 0000000..83720f6
--- /dev/null
+++ b/.github/workflows/ci-windows.yml
@@ -0,0 +1,161 @@
+name: ci-windows
+
+# Windows CI for mcpp — same flow as Linux (ci.yml) and macOS (ci-macos.yml):
+#   xlings install mcpp → self-host build → E2E → smoke → package
+
+on:
+  push:
+    branches: [ main ]
+  pull_request:
+    branches: [ main ]
+  workflow_dispatch:
+
+concurrency:
+  group: ci-windows-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  build-and-test:
+    name: build + test (windows x64, self-host)
+    runs-on: windows-latest
+    timeout-minutes: 45
+    env:
+      MCPP_HOME: C:\Users\runneradmin\.mcpp
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Cache mcpp sandbox
+        uses: actions/cache@v4
+        with:
+          path: ~\.mcpp
+          key: mcpp-sandbox-${{ runner.os }}-${{ hashFiles('mcpp.toml', '.xlings.json') }}
+          restore-keys: |
+            mcpp-sandbox-${{ runner.os }}-
+
+      - name: Cache xlings
+        uses: actions/cache@v4
+        with:
+          path: ~\.xlings
+          key: xlings-${{ runner.os }}-v2-${{ hashFiles('.xlings.json') }}
+          restore-keys: |
+            xlings-${{ runner.os }}-v2-
+
+      - name: Bootstrap mcpp via xlings
+        shell: bash
+        env:
+          XLINGS_NON_INTERACTIVE: '1'
+          XLINGS_VERSION: '0.4.30'
+        run: |
+          WORK=$(mktemp -d)
+          zipfile="xlings-${XLINGS_VERSION}-windows-x86_64.zip"
+          curl -fsSL -o "${WORK}/${zipfile}" \
+            "https://github.com/d2learn/xlings/releases/download/v${XLINGS_VERSION}/${zipfile}"
+          cd "${WORK}"
+          unzip -q "${zipfile}"
+          "$WORK/xlings-${XLINGS_VERSION}-windows-x86_64/subos/default/bin/xlings.exe" self install
+          export PATH="$USERPROFILE/.xlings/subos/default/bin:$PATH"
+          echo "$USERPROFILE/.xlings/subos/default/bin" >> "$GITHUB_PATH"
+          xlings.exe --version
+          xlings.exe install mcpp -y || xlings.exe install mcpp@0.0.17 -y
+          echo "=== Searching for mcpp binary ==="
+          find "$USERPROFILE/.xlings" -name "mcpp.exe" -o -name "mcpp" 2>/dev/null | head -10
+          MCPP=$(find "$USERPROFILE/.xlings" -name "mcpp.exe" -path "*/bin/*" 2>/dev/null | head -1)
+          if [ -z "$MCPP" ]; then
+            MCPP=$(find "$USERPROFILE/.xlings" -name "mcpp" -path "*/bin/*" 2>/dev/null | head -1)
+          fi
+          test -n "$MCPP" || { echo "FAIL: mcpp not found after xlings install"; exit 1; }
+          echo "Found mcpp at: $MCPP"
+          "$MCPP" --version
+          echo "MCPP=$MCPP" >> "$GITHUB_ENV"
+          XLINGS_BIN=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe")
+          echo "XLINGS_BIN=$XLINGS_BIN" >> "$GITHUB_ENV"
+
+      - name: Build mcpp from source (self-host)
+        shell: bash
+        run: |
+          export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
+
+          # Pre-seed mcpp sandbox with xlings LLVM (avoids redundant download)
+          MCPP_XPKGS="$USERPROFILE/.mcpp/registry/data/xpkgs"
+          XLINGS_XPKGS="$USERPROFILE/.xlings/data/xpkgs"
+          if [ -d "$XLINGS_XPKGS/xim-x-llvm" ]; then
+            mkdir -p "$MCPP_XPKGS"
+            rm -rf "$MCPP_XPKGS/xim-x-llvm"
+            cp -r "$XLINGS_XPKGS/xim-x-llvm" "$MCPP_XPKGS/xim-x-llvm"
+            echo "Pre-seeded LLVM from global xlings"
+          fi
+
+          "$MCPP" build
+
+          MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" | head -1)
+          test -n "$MCPP_SELF" || { echo "FAIL: no mcpp.exe"; exit 1; }
+          MCPP_SELF=$(cd "$(dirname "$MCPP_SELF")" && pwd)/$(basename "$MCPP_SELF")
+          echo "Self-hosted binary: $MCPP_SELF"
+          "$MCPP_SELF" --version
+          echo "MCPP_SELF=$MCPP_SELF" >> "$GITHUB_ENV"
+
+      # NOTE: `mcpp test` requires clang-scan-deps for module dependency
+      # scanning. The xlings LLVM package does not yet ship it on Windows.
+      # Enable once available.
+
+      - name: E2E suite
+        shell: bash
+        run: |
+          export MCPP="$MCPP_SELF"
+          export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
+          export MCPP_E2E_TOOLCHAIN_MIRROR=GLOBAL
+          "$MCPP_SELF" self config --mirror GLOBAL 2>/dev/null || true
+          "$MCPP_SELF" toolchain default llvm@20.1.7 2>/dev/null || true
+          bash tests/e2e/run_all.sh
+
+      - name: Self-host smoke (freshly-built mcpp builds itself again)
+        shell: bash
+        run: |
+          export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
+          "$MCPP_SELF" build
+          "$MCPP_SELF" --version
+          echo ":: Self-host smoke PASS"
+
+      - name: Package Windows release zip
+        id: package
+        shell: bash
+        run: |
+          VERSION=$(awk -F '"' '/^version[[:space:]]*=/{print $2; exit}' mcpp.toml)
+          WRAPPER="mcpp-${VERSION}-windows-x86_64"
+          ZIPNAME="${WRAPPER}.zip"
+
+          STAGING=$(mktemp -d)
+          mkdir -p "$STAGING/$WRAPPER/bin" "$STAGING/$WRAPPER/registry/bin"
+          cp "$MCPP_SELF" "$STAGING/$WRAPPER/bin/mcpp.exe"
+          printf '@echo off\r\n"%%~dp0bin\\mcpp.exe" %%*\r\n' > "$STAGING/$WRAPPER/mcpp.bat"
+          cp README.md "$STAGING/$WRAPPER/" 2>/dev/null || true
+          cp LICENSE "$STAGING/$WRAPPER/" 2>/dev/null || true
+          XLINGS_EXE="$USERPROFILE/.xlings/subos/default/bin/xlings.exe"
+          [ -f "$XLINGS_EXE" ] && cp "$XLINGS_EXE" "$STAGING/$WRAPPER/registry/bin/xlings.exe"
+
+          mkdir -p dist
+          (cd "$STAGING" && 7z a -tzip "$ZIPNAME" "$WRAPPER")
+          cp "$STAGING/$ZIPNAME" "dist/$ZIPNAME"
+          (cd dist && sha256sum "$ZIPNAME" > "$ZIPNAME.sha256")
+          echo "zipname=$ZIPNAME" >> "$GITHUB_OUTPUT"
+          ls -la dist/
+
+      - name: Smoke-test the packaged zip
+        shell: bash
+        run: |
+          ZIPNAME="${{ steps.package.outputs.zipname }}"
+          WRAPPER="${ZIPNAME%.zip}"
+          SMOKE=$(mktemp -d)
+          (cd "$SMOKE" && unzip -q "$GITHUB_WORKSPACE/dist/$ZIPNAME")
+          "$SMOKE/$WRAPPER/bin/mcpp.exe" --version
+          test -f "$SMOKE/$WRAPPER/registry/bin/xlings.exe"
+          test -f "$SMOKE/$WRAPPER/mcpp.bat"
+          echo "Smoke-test passed"
+
+      - name: Upload artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: mcpp-windows-x86_64
+          path: |
+            dist/*.zip
+            dist/*.sha256
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9ef2d96..fe1534a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -394,3 +394,164 @@ jobs:
             dist/mcpp-${{ steps.resolve.outputs.version }}-macosx-arm64.tar.gz.sha256
             dist/mcpp-macosx-arm64.tar.gz
             dist/mcpp-macosx-arm64.tar.gz.sha256
+
+  build-windows:
+    name: build (Windows / x86_64)
+    runs-on: windows-latest
+    needs: build-release
+    permissions:
+      contents: write
+    timeout-minutes: 45
+    env:
+      MCPP_HOME: C:\Users\runneradmin\.mcpp
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Resolve tag
+        id: resolve
+        shell: bash
+        run: |
+          if [ "${{ github.event_name }}" = "push" ]; then
+            TAG="${{ github.ref_name }}"
+          elif [ -n "${{ github.event.inputs.tag }}" ]; then
+            TAG="${{ github.event.inputs.tag }}"
+          else
+            VER=$(awk -F '"' '/^version[[:space:]]*=/{print $2; exit}' mcpp.toml)
+            TAG="v$VER"
+          fi
+          echo "tag=$TAG"         >> "$GITHUB_OUTPUT"
+          echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
+          if [ "${{ github.event_name }}" = "workflow_dispatch" ] \
+             && git rev-parse --verify "refs/tags/$TAG" >/dev/null 2>&1; then
+            git checkout --detach "refs/tags/$TAG"
+          fi
+
+      - name: Cache mcpp sandbox
+        uses: actions/cache@v4
+        with:
+          path: ~\.mcpp
+          key: mcpp-sandbox-${{ runner.os }}-release-${{ hashFiles('mcpp.toml', '.xlings.json') }}
+          restore-keys: |
+            mcpp-sandbox-${{ runner.os }}-release-
+            mcpp-sandbox-${{ runner.os }}-
+
+      - name: Cache xlings
+        uses: actions/cache@v4
+        with:
+          path: ~\.xlings
+          key: xlings-${{ runner.os }}-release-${{ hashFiles('.xlings.json') }}
+          restore-keys: |
+            xlings-${{ runner.os }}-release-
+            xlings-${{ runner.os }}-
+
+      - name: Bootstrap mcpp via xlings
+        shell: bash
+        env:
+          XLINGS_NON_INTERACTIVE: '1'
+          XLINGS_VERSION: '0.4.30'
+        run: |
+          WORK=$(mktemp -d)
+          zipfile="xlings-${XLINGS_VERSION}-windows-x86_64.zip"
+          curl -fsSL -o "${WORK}/${zipfile}" \
+            "https://github.com/d2learn/xlings/releases/download/v${XLINGS_VERSION}/${zipfile}"
+          cd "${WORK}"
+          unzip -q "${zipfile}"
+          "$WORK/xlings-${XLINGS_VERSION}-windows-x86_64/subos/default/bin/xlings.exe" self install
+          export PATH="$USERPROFILE/.xlings/subos/default/bin:$PATH"
+          echo "$USERPROFILE/.xlings/subos/default/bin" >> "$GITHUB_PATH"
+          xlings.exe --version
+          xlings.exe install mcpp -y || xlings.exe install mcpp@0.0.17 -y
+          MCPP=$(find "$USERPROFILE/.xlings" -name "mcpp.exe" -path "*/bin/*" 2>/dev/null | head -1)
+          if [ -z "$MCPP" ]; then
+            MCPP=$(find "$USERPROFILE/.xlings" -name "mcpp" -path "*/bin/*" 2>/dev/null | head -1)
+          fi
+          test -n "$MCPP" || { echo "FAIL: mcpp not found after xlings install"; exit 1; }
+          "$MCPP" --version
+          echo "MCPP=$MCPP" >> "$GITHUB_ENV"
+          XLINGS_BIN=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe")
+          echo "XLINGS_BIN=$XLINGS_BIN" >> "$GITHUB_ENV"
+          echo "XLINGS_BIN_UNIX=$USERPROFILE/.xlings/subos/default/bin/xlings.exe" >> "$GITHUB_ENV"
+          echo "XLINGS_XPKGS=$USERPROFILE/.xlings/data/xpkgs" >> "$GITHUB_ENV"
+
+      - name: Build mcpp from source (self-host)
+        shell: bash
+        run: |
+          export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
+
+          # Pre-seed mcpp sandbox with xlings LLVM (avoids redundant download)
+          MCPP_XPKGS="$USERPROFILE/.mcpp/registry/data/xpkgs"
+          if [ -d "$XLINGS_XPKGS/xim-x-llvm" ]; then
+            mkdir -p "$MCPP_XPKGS"
+            rm -rf "$MCPP_XPKGS/xim-x-llvm"
+            cp -r "$XLINGS_XPKGS/xim-x-llvm" "$MCPP_XPKGS/xim-x-llvm"
+            echo "Pre-seeded LLVM from global xlings"
+          fi
+
+          "$MCPP" build
+
+          MCPP_BIN=$(find target -name "mcpp.exe" -path "*/bin/*" | head -1)
+          test -n "$MCPP_BIN" || { echo "FAIL: no mcpp.exe in target/"; exit 1; }
+          MCPP_BIN=$(cd "$(dirname "$MCPP_BIN")" && pwd)/$(basename "$MCPP_BIN")
+          echo "Self-hosted binary: $MCPP_BIN"
+          "$MCPP_BIN" --version
+          echo "MCPP_BIN=$MCPP_BIN" >> "$GITHUB_ENV"
+
+      - name: Package Windows release zip
+        id: stage
+        shell: bash
+        run: |
+          VERSION="${{ steps.resolve.outputs.version }}"
+          WRAPPER="mcpp-${VERSION}-windows-x86_64"
+          ZIPNAME="${WRAPPER}.zip"
+
+          STAGING=$(mktemp -d)
+          mkdir -p "$STAGING/$WRAPPER/bin" "$STAGING/$WRAPPER/registry/bin"
+          cp "$MCPP_BIN" "$STAGING/$WRAPPER/bin/mcpp.exe"
+
+          # Windows batch launcher
+          printf '@echo off\r\n"%%~dp0bin\\mcpp.exe" %%*\r\n' > "$STAGING/$WRAPPER/mcpp.bat"
+          cp README.md "$STAGING/$WRAPPER/" 2>/dev/null || true
+          cp LICENSE   "$STAGING/$WRAPPER/" 2>/dev/null || true
+
+          # Bundle xlings.exe for install consumers
+          if [ -f "$XLINGS_BIN_UNIX" ]; then
+            cp "$XLINGS_BIN_UNIX" "$STAGING/$WRAPPER/registry/bin/xlings.exe"
+          fi
+
+          # Pack with 7z (available on windows-latest)
+          mkdir -p dist
+          (cd "$STAGING" && 7z a -tzip "$ZIPNAME" "$WRAPPER")
+          cp "$STAGING/$ZIPNAME" "dist/$ZIPNAME"
+          # Versionless alias
+          cp "dist/$ZIPNAME" "dist/mcpp-windows-x86_64.zip"
+          # SHA256
+          (cd dist && sha256sum "$ZIPNAME" > "$ZIPNAME.sha256")
+          (cd dist && sha256sum "mcpp-windows-x86_64.zip" > "mcpp-windows-x86_64.zip.sha256")
+
+          echo "zipname=$ZIPNAME" >> "$GITHUB_OUTPUT"
+          ls -la dist/
+
+      - name: Smoke-test the packaged zip
+        shell: bash
+        run: |
+          ZIPNAME="${{ steps.stage.outputs.zipname }}"
+          WRAPPER="${ZIPNAME%.zip}"
+          SMOKE=$(mktemp -d)
+          (cd "$SMOKE" && unzip -q "$GITHUB_WORKSPACE/dist/$ZIPNAME")
+          "$SMOKE/$WRAPPER/bin/mcpp.exe" --version
+          "$SMOKE/$WRAPPER/bin/mcpp.exe" --help | head -5
+          test -f "$SMOKE/$WRAPPER/registry/bin/xlings.exe"
+          test -f "$SMOKE/$WRAPPER/mcpp.bat"
+          echo "Smoke-test passed"
+
+      - name: Upload Windows artifacts to release
+        uses: softprops/action-gh-release@v2
+        with:
+          tag_name: ${{ steps.resolve.outputs.tag }}
+          files: |
+            dist/mcpp-${{ steps.resolve.outputs.version }}-windows-x86_64.zip
+            dist/mcpp-${{ steps.resolve.outputs.version }}-windows-x86_64.zip.sha256
+            dist/mcpp-windows-x86_64.zip
+            dist/mcpp-windows-x86_64.zip.sha256
diff --git a/.xlings.json b/.xlings.json
index 7ce6add..2ef3138 100644
--- a/.xlings.json
+++ b/.xlings.json
@@ -1,6 +1,5 @@
 {
   "workspace": {
-    "mcpp": "0.0.9",
-    "xmake": "3.0.7"
+    "mcpp": "0.0.17"
   }
 }
diff --git a/mcpp.toml b/mcpp.toml
index 06c67ae..0a0da07 100644
--- a/mcpp.toml
+++ b/mcpp.toml
@@ -14,6 +14,7 @@ include_dirs = ["src/libs/json"]
 [toolchain]
 default = "gcc@16.1.0"
 macos   = "llvm@20.1.7"
+windows = "llvm@20.1.7"
 
 # Per-target overrides: `mcpp build --target x86_64-linux-musl` (or the
 # four-segment form `x86_64-unknown-linux-musl`) picks musl-gcc 15.1 + full
diff --git a/src/bmi_cache.cppm b/src/bmi_cache.cppm
index 9be6ce0..e2ab08e 100644
--- a/src/bmi_cache.cppm
+++ b/src/bmi_cache.cppm
@@ -15,9 +15,14 @@
 // dep cache without trashing manifest.txt (docs/26 §5.4 V2).
 
 module;
+#if defined(_WIN32)
+#include <io.h>
+#include <windows.h>
+#else
 #include <fcntl.h>
 #include <sys/file.h>
 #include <unistd.h>
+#endif
 
 export module mcpp.bmi_cache;
 
@@ -184,9 +189,35 @@ stage_into(const CacheKey& key,
 
 namespace {
 
-// Acquire an exclusive non-blocking flock on <dir>/.lock. Returns the fd on
-// success (caller closes it to release), or -1 if another mcpp is already
-// populating this entry — in which case the caller should skip writing.
+// Acquire an exclusive non-blocking lock on <dir>/.lock. Returns a handle
+// on success, or -1/INVALID_HANDLE if another mcpp is already populating.
+#if defined(_WIN32)
+// Windows: use LockFileEx on a file handle
+HANDLE try_lock_dir(const std::filesystem::path& dir) {
+    std::error_code ec;
+    std::filesystem::create_directories(dir, ec);
+    auto lockPath = dir / ".lock";
+    HANDLE h = CreateFileW(lockPath.wstring().c_str(),
+        GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE,
+        NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
+    if (h == INVALID_HANDLE_VALUE) return h;
+    OVERLAPPED ov = {};
+    if (!LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
+                    0, 1, 0, &ov)) {
+        CloseHandle(h);
+        return INVALID_HANDLE_VALUE;
+    }
+    return h;
+}
+
+void release_lock(HANDLE h) {
+    if (h == INVALID_HANDLE_VALUE) return;
+    OVERLAPPED ov = {};
+    UnlockFileEx(h, 0, 1, 0, &ov);
+    CloseHandle(h);
+}
+#else
+// POSIX: use flock(2)
 int try_lock_dir(const std::filesystem::path& dir) {
     std::error_code ec;
     std::filesystem::create_directories(dir, ec);
@@ -205,6 +236,7 @@ void release_lock(int fd) {
     ::flock(fd, LOCK_UN);
     ::close(fd);
 }
+#endif
 
 } // namespace
 
@@ -214,6 +246,16 @@ populate_from(const CacheKey& key,
               const DepArtifacts& arts)
 {
     auto cacheDir = key.dir();
+#if defined(_WIN32)
+    HANDLE lockHandle = try_lock_dir(cacheDir);
+    if (lockHandle == INVALID_HANDLE_VALUE) {
+        return {};
+    }
+    struct LockGuard {
+        HANDLE h;
+        ~LockGuard() { release_lock(h); }
+    } guard{ lockHandle };
+#else
     int lockFd = try_lock_dir(cacheDir);
     if (lockFd < 0) {
         // Another writer holds the lock; treat as success (they'll do it).
@@ -223,6 +265,7 @@ populate_from(const CacheKey& key,
         int fd;
         ~LockGuard() { release_lock(fd); }
     } guard{ lockFd };
+#endif
 
     auto cacheBmi = key.bmiDir();
     auto cacheObj = key.objDir();
diff --git a/src/build/flags.cppm b/src/build/flags.cppm
index 992f091..4dd0ab5 100644
--- a/src/build/flags.cppm
+++ b/src/build/flags.cppm
@@ -149,20 +149,32 @@ CompileFlags compute_flags(const BuildPlan& plan) {
     // Link flags
     f.staticStdlib = plan.manifest.buildConfig.staticStdlib;
     f.linkage = plan.manifest.buildConfig.linkage;
-#if defined(__APPLE__)
+#if defined(_WIN32)
+    // Windows: MSVC linker handles static/dynamic linking differently
+    std::string full_static;
+    std::string static_stdlib;
+#elif defined(__APPLE__)
     // macOS does not support full static linking (libSystem must be dynamic)
     std::string full_static;
+    std::string static_stdlib = (f.staticStdlib && !isClang) ? " -static-libstdc++" : "";
 #else
     std::string full_static = (f.linkage == "static") ? " -static" : "";
-#endif
     std::string static_stdlib = (f.staticStdlib && !isClang) ? " -static-libstdc++" : "";
+#endif
     std::string runtime_dirs;
+#if !defined(_WIN32)
+    // -L and -rpath are ELF/Mach-O linker flags; MSVC linker doesn't use them.
     for (auto& dir : plan.toolchain.linkRuntimeDirs) {
         runtime_dirs += " -L" + escape_path(dir);
         runtime_dirs += " -Wl,-rpath," + escape_path(dir);
     }
+#endif
 
-#if defined(__APPLE__)
+#if defined(_WIN32)
+    // Windows: Clang targeting MSVC links against MSVC runtime automatically.
+    // No -L/-rpath/-static flags needed.
+    f.ld = "";
+#elif defined(__APPLE__)
     // macOS linking strategy:
     // - No --sysroot: SDK .tbd stubs miss libc++abi exports.
     // - No -L<llvm>/lib: xlings LLVM's libc++.dylib doesn't pull in
diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm
index 5266fee..f2c7073 100644
--- a/src/build/ninja_backend.cppm
+++ b/src/build/ninja_backend.cppm
@@ -14,7 +14,11 @@
 module;
 #include <cstdio>
 #include <cstdlib>
-#if defined(__APPLE__)
+#if defined(_WIN32)
+#include <windows.h>
+#define popen  _popen
+#define pclose _pclose
+#elif defined(__APPLE__)
 #include <mach-o/dyld.h>  // _NSGetExecutablePath
 #endif
 
@@ -116,8 +120,14 @@ bool dyndep_mode_enabled() {
 
 std::filesystem::path mcpp_exe_path() {
     std::error_code ec;
-#if defined(__APPLE__)
-    // macOS: use _NSGetExecutablePath
+#if defined(_WIN32)
+    char buf[MAX_PATH];
+    DWORD len = GetModuleFileNameA(NULL, buf, MAX_PATH);
+    if (len > 0 && len < MAX_PATH) {
+        auto p = std::filesystem::canonical(buf, ec);
+        if (!ec) return p;
+    }
+#elif defined(__APPLE__)
     char buf[4096];
     uint32_t size = sizeof(buf);
     if (_NSGetExecutablePath(buf, &size) == 0) {
@@ -187,7 +197,13 @@ std::string emit_ninja_string(const BuildPlan& plan) {
     append("\n");
 
     append("rule cp_bmi\n");
+#if defined(_WIN32)
+    // Use PowerShell Copy-Item which handles both forward and back slashes.
+    // cmd.exe `copy` breaks on forward-slash paths from ninja.
+    append("  command = powershell -NoProfile -Command \"Copy-Item -Force '$in' -Destination '$out'\"\n");
+#else
     append("  command = mkdir -p $$(dirname $out) && cp -f $in $out\n");
+#endif
     append("  description = STAGE $out\n\n");
 
     // P1: per-file dyndep rule. Converts one .ddi → .dd independently.
@@ -210,6 +226,12 @@ std::string emit_ninja_string(const BuildPlan& plan) {
     std::string module_output_flag = traits.needsExplicitModuleOutput
         ? " -fmodule-output=$bmi_out" : "";
     append("rule cxx_module\n");
+#if defined(_WIN32)
+    // Windows: skip BMI restat optimization (requires POSIX shell).
+    // No $toolenv (empty on Windows; its leading space breaks CreateProcess).
+    append(std::format("  command = "
+           "$cxx $cxxflags{} -c $in -o $out\n", module_output_flag));
+#else
     append(std::format("  command = "
            "if [ -n \"$bmi_out\" ] && [ -f \"$bmi_out\" ]; then "
              "cp -p \"$bmi_out\" \"$bmi_out.bak\"; "
@@ -221,13 +243,18 @@ std::string emit_ninja_string(const BuildPlan& plan) {
            "else "
              "rm -f \"$bmi_out.bak\"; "
            "fi\n", module_output_flag));
+#endif
     append("  description = MOD $out\n");
     if (dyndep)
         append("  restat = 1\n");
     append("\n");
 
     append("rule cxx_object\n");
+#if defined(_WIN32)
+    append("  command = $cxx $cxxflags -c $in -o $out\n");
+#else
     append("  command = $toolenv $cxx $cxxflags -c $in -o $out\n");
+#endif
     append("  description = OBJ $out\n");
     if (dyndep)
         append("  restat = 1\n");
@@ -235,13 +262,30 @@ std::string emit_ninja_string(const BuildPlan& plan) {
 
     if (need_c_rule) {
         append("rule c_object\n");
+#if defined(_WIN32)
+        append("  command = $cc $cflags -c $in -o $out\n");
+#else
         append("  command = $toolenv $cc $cflags -c $in -o $out\n");
+#endif
         append("  description = CC $out\n");
         if (dyndep)
             append("  restat = 1\n");
         append("\n");
     }
 
+#if defined(_WIN32)
+    append("rule cxx_link\n");
+    append("  command = $cxx $in -o $out $ldflags\n");
+    append("  description = LINK $out\n\n");
+
+    append("rule cxx_archive\n");
+    append("  command = $ar rcs $out $in\n");
+    append("  description = AR $out\n\n");
+
+    append("rule cxx_shared\n");
+    append("  command = $cxx -shared $in -o $out $ldflags\n");
+    append("  description = SHARED $out\n\n");
+#else
     append("rule cxx_link\n");
     append("  command = $toolenv $cxx $in -o $out $ldflags\n");
     append("  description = LINK $out\n\n");
@@ -253,6 +297,7 @@ std::string emit_ninja_string(const BuildPlan& plan) {
     append("rule cxx_shared\n");
     append("  command = $toolenv $cxx -shared $in -o $out $ldflags\n");
     append("  description = SHARED $out\n\n");
+#endif
 
     if (dyndep) {
         // Scan rule: produce P1689 .ddi for one TU.
@@ -261,14 +306,23 @@ std::string emit_ninja_string(const BuildPlan& plan) {
         append("rule cxx_scan\n");
         if (plan.scanDepsPath.empty()) {
             // GCC path: compiler-integrated P1689 scanning.
+#if defined(_WIN32)
+            append("  command = $cxx $cxxflags -fmodules "
+#else
             append("  command = $toolenv $cxx $cxxflags -fmodules "
+#endif
                    "-fdeps-format=p1689r5 "
                    "-fdeps-file=$out -fdeps-target=$compile_target "
                    "-M -MM -MF $out.dep -E $in -o $compile_target\n");
         } else {
             // Clang path: clang-scan-deps produces P1689 JSON to stdout.
+#if defined(_WIN32)
+            append("  command = $scan_deps -format=p1689 -- "
+                   "$cxx $cxxflags -c $in -o $compile_target > $out\n");
+#else
             append("  command = $toolenv $scan_deps -format=p1689 -- "
                    "$cxx $cxxflags -c $in -o $compile_target > $out\n");
+#endif
         }
         append("  description = SCAN $out\n\n");
 
@@ -511,19 +565,33 @@ std::expected<BuildResult, BuildError> NinjaBackend::build(const BuildPlan& plan
     // -B<binutils-bin> flag we emit into cxxflags/ldflags (see
     // emit_ninja_string). No PATH injection needed here.
     std::filesystem::path ninjaBin;
+#if defined(_WIN32)
+    if (auto nb = mcpp::xlings::paths::find_sibling_binary(
+            plan.toolchain.binaryPath, "ninja", "ninja.exe")) {
+        ninjaBin = *nb;
+    }
+#else
     if (auto nb = mcpp::xlings::paths::find_sibling_binary(
             plan.toolchain.binaryPath, "ninja", "ninja")) {
         ninjaBin = *nb;
     }
+#endif
 
+#if defined(_WIN32)
+    // Windows: no quotes on first token (cmd.exe strips leading quotes),
+    // use shq only for the -C argument which may contain spaces.
+    std::string ninjaProgram =
+        !ninjaBin.empty() ? ninjaBin.string() : std::string{"ninja"};
+#else
     std::string ninjaProgram =
-        !ninjaBin.empty() ? std::format("'{}'", ninjaBin.string()) : std::string{"ninja"};
+        !ninjaBin.empty() ? mcpp::xlings::shq(ninjaBin.string()) : std::string{"ninja"};
+#endif
 
     // Record ninja binary for P0 fast-path cache.
     BuildResult r;
     r.ninjaProgram = ninjaProgram;
 
-    std::string cmd = std::format("{} -C '{}'", ninjaProgram, plan.outputDir.string());
+    std::string cmd = std::format("{} -C {}", ninjaProgram, mcpp::xlings::shq(plan.outputDir.string()));
     if (opts.verbose)
         cmd += " -v";
     if (opts.parallelJobs)
diff --git a/src/build/plan.cppm b/src/build/plan.cppm
index 2e83573..33cc341 100644
--- a/src/build/plan.cppm
+++ b/src/build/plan.cppm
@@ -10,6 +10,7 @@ import mcpp.manifest;
 import mcpp.modgraph.graph;
 import mcpp.toolchain.detect;
 import mcpp.toolchain.fingerprint;
+import mcpp.platform;
 
 export namespace mcpp::build {
 
@@ -172,17 +173,23 @@ BuildPlan make_plan(const mcpp::manifest::Manifest&         manifest,
         lu.targetName = t.name;
         if (t.kind == mcpp::manifest::Target::Library) {
             lu.kind   = LinkUnit::StaticLibrary;
-            lu.output = std::filesystem::path("bin") / std::format("lib{}.a", t.name);
+            lu.output = std::filesystem::path("bin") /
+                        std::format("{}{}{}", mcpp::platform::lib_prefix, t.name,
+                                    mcpp::platform::static_lib_ext);
         } else if (t.kind == mcpp::manifest::Target::SharedLibrary) {
             lu.kind   = LinkUnit::SharedLibrary;
-            lu.output = std::filesystem::path("bin") / std::format("lib{}.so", t.name);
+            lu.output = std::filesystem::path("bin") /
+                        std::format("{}{}{}", mcpp::platform::lib_prefix, t.name,
+                                    mcpp::platform::shared_lib_ext);
         } else if (t.kind == mcpp::manifest::Target::TestBinary) {
             lu.kind   = LinkUnit::TestBinary;
-            lu.output = std::filesystem::path("bin") / t.name;
+            lu.output = std::filesystem::path("bin") /
+                        std::format("{}{}", t.name, mcpp::platform::exe_suffix);
             if (!t.main.empty()) lu.entryMain = projectRoot / t.main;
         } else {
             lu.kind   = LinkUnit::Binary;
-            lu.output = std::filesystem::path("bin") / t.name;
+            lu.output = std::filesystem::path("bin") /
+                        std::format("{}{}", t.name, mcpp::platform::exe_suffix);
             if (!t.main.empty()) lu.entryMain = projectRoot / t.main;
         }
 
diff --git a/src/cli.cppm b/src/cli.cppm
index eb9efbf..4ca491f 100644
--- a/src/cli.cppm
+++ b/src/cli.cppm
@@ -11,6 +11,10 @@
 module;
 #include <cstdio>
 #include <cstdlib>
+#if defined(_WIN32)
+#define popen  _popen
+#define pclose _pclose
+#endif
 
 export module mcpp.cli;
 
@@ -19,6 +23,7 @@ import mcpp.manifest;
 import mcpp.modgraph.graph;
 import mcpp.modgraph.scanner;
 import mcpp.modgraph.validate;
+import mcpp.toolchain.clang;
 import mcpp.toolchain.detect;
 import mcpp.toolchain.fingerprint;
 import mcpp.toolchain.registry;
@@ -1131,7 +1136,7 @@ prepare_build(bool print_fingerprint,
         // macOS: LLVM/Clang — Apple doesn't ship GCC; upstream LLVM with
         //        bundled libc++ is the self-contained choice.
         // Linux: musl-gcc — produces portable static binaries.
-#if defined(__APPLE__)
+#if defined(__APPLE__) || defined(_WIN32)
         std::string defaultSpec = "llvm@20.1.7";
 #else
         std::string defaultSpec = "gcc@15.1.0-musl";
@@ -1139,7 +1144,7 @@ prepare_build(bool print_fingerprint,
         auto defaultParsed = mcpp::toolchain::parse_toolchain_spec(defaultSpec);
         auto defaultPkg = mcpp::toolchain::to_xim_package(*defaultParsed);
 
-#if defined(__APPLE__)
+#if defined(__APPLE__) || defined(_WIN32)
         mcpp::ui::info("First run",
             std::format("no toolchain configured — installing {} (LLVM/Clang) as default",
                         defaultSpec));
@@ -2156,9 +2161,8 @@ prepare_build(bool print_fingerprint,
 
     // Clang: discover clang-scan-deps for P1689 dyndep scanning.
     if (mcpp::toolchain::is_clang(*tc)) {
-        auto sd = tc->binaryPath.parent_path() / "clang-scan-deps";
-        if (std::filesystem::exists(sd)) {
-            ctx.plan.scanDepsPath = sd;
+        if (auto sd = mcpp::toolchain::clang::find_scan_deps(*tc)) {
+            ctx.plan.scanDepsPath = *sd;
         }
     }
 
@@ -2515,7 +2519,11 @@ std::optional<int> try_fast_build(const std::filesystem::path& projectRoot,
     }
 
     // All inputs are older than build.ninja → fast-path: just run ninja.
+#if defined(_WIN32)
+    std::string cmd = std::format("{} -C \"{}\"", ninjaProgram, outputDir.string());
+#else
     std::string cmd = std::format("{} -C '{}'", ninjaProgram, outputDir.string());
+#endif
     if (verbose) cmd += " -v";
     cmd += " 2>&1";
 
@@ -2604,8 +2612,13 @@ int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed,
         std::format("`{}`", mcpp::ui::shorten_path(exe, pathCtx)));
     std::println("");
     std::fflush(stdout);
+#if defined(_WIN32)
+    std::string cmd = std::format("\"{}\"", exe.string());
+    for (auto& a : passthrough) cmd += std::format(" \"{}\"", a);
+#else
     std::string cmd = std::format("'{}'", exe.string());
     for (auto& a : passthrough) cmd += std::format(" '{}'", a);
+#endif
     return std::system(cmd.c_str()) == 0 ? 0 : 1;
 }
 
@@ -3147,6 +3160,7 @@ int cmd_test(const mcpplibs::cmdline::ParsedArgs& /*parsed*/,
         // visible to test binaries that shell out to them. The
         // toolchain binary's path encodes the registry root — derive it.
         std::string pathPrefix;
+#if !defined(_WIN32)
         if (auto xpkgs = mcpp::xlings::paths::xpkgs_from_compiler(ctx->tc.binaryPath)) {
             // xpkgs is <registry>/data/xpkgs → registry = xpkgs/../..
             auto registryDir = xpkgs->parent_path().parent_path();
@@ -3154,12 +3168,22 @@ int cmd_test(const mcpplibs::cmdline::ParsedArgs& /*parsed*/,
             if (std::filesystem::exists(sandboxBin))
                 pathPrefix = std::format("PATH='{}':\"$PATH\" ", sandboxBin.string());
         }
+#endif
 
+#if defined(_WIN32)
+        std::string cmd = std::format("\"{}\"", exe.string());
+        for (auto& a : passthrough) cmd += std::format(" \"{}\"", a);
+#else
         std::string cmd = std::format("{}'{}'", pathPrefix, exe.string());
         for (auto& a : passthrough) cmd += std::format(" '{}'", a);
+#endif
         int rc = std::system(cmd.c_str());
-        // std::system returns wait status — extract exit code.
+        // std::system returns wait status on POSIX, exit code on Windows.
+#if defined(_WIN32)
+        int exitCode = rc;
+#else
         int exitCode = WIFEXITED(rc) ? WEXITSTATUS(rc) : 127;
+#endif
 
         if (exitCode == 0) {
             std::println("{} ... ok", lu.targetName);
diff --git a/src/config.cppm b/src/config.cppm
index d53d531..6c843f7 100644
--- a/src/config.cppm
+++ b/src/config.cppm
@@ -16,7 +16,11 @@
 module;
 #include <cstdio>
 #include <cstdlib>
-#if defined(__APPLE__)
+#if defined(_WIN32)
+#include <windows.h>
+#define popen  _popen
+#define pclose _pclose
+#elif defined(__APPLE__)
 #include <mach-o/dyld.h>  // _NSGetExecutablePath
 #endif
 
@@ -164,7 +168,13 @@ std::filesystem::path home_dir() {
         return std::filesystem::path(e);
 
     std::error_code ec;
-#if defined(__APPLE__)
+#if defined(_WIN32)
+    char _exe_buf[MAX_PATH];
+    DWORD _exe_len = GetModuleFileNameA(NULL, _exe_buf, MAX_PATH);
+    std::filesystem::path exe;
+    if (_exe_len > 0 && _exe_len < MAX_PATH)
+        exe = std::filesystem::canonical(_exe_buf, ec);
+#elif defined(__APPLE__)
     char _exe_buf[4096];
     uint32_t _exe_size = sizeof(_exe_buf);
     std::filesystem::path exe;
@@ -345,7 +355,11 @@ acquire_xlings_binary(const std::filesystem::path& destBin, bool quiet)
     }
 
     // 2. Copy from system (`which xlings`)
+#if defined(_WIN32)
+    auto sys = run_capture("where xlings.exe 2>nul");
+#else
     auto sys = run_capture("command -v xlings 2>/dev/null");
+#endif
     if (sys) {
         std::string p = *sys;
         while (!p.empty() && (p.back() == '\n' || p.back() == '\r')) p.pop_back();
@@ -427,7 +441,11 @@ std::expected<GlobalConfig, ConfigError> load_or_init(
     // <XLINGS_HOME>/bin/xlings, which satisfies xlings's own shim-
     // creation guard (`if fs::exists(homeDir/"bin"/"xlings")`),
     // making ensure_sandbox_xlings_binary() a no-op.
+#if defined(_WIN32)
+    cfg.xlingsBinary  = cfg.registryDir / "bin" / "xlings.exe";
+#else
     cfg.xlingsBinary  = cfg.registryDir / "bin" / "xlings";
+#endif
     cfg.bmiCacheDir   = cfg.mcppHome / "bmi";
     cfg.metaCacheDir  = cfg.mcppHome / "cache";
     cfg.logDir        = cfg.mcppHome / "log";
@@ -522,7 +540,11 @@ std::expected<GlobalConfig, ConfigError> load_or_init(
         auto xbin = acquire_xlings_binary(cfg.xlingsBinary, quiet);
         if (!xbin) return std::unexpected(ConfigError{xbin.error()});
     } else if (cfg.xlingsBinaryMode == "system") {
+#if defined(_WIN32)
+        auto sys = run_capture("where xlings.exe 2>nul");
+#else
         auto sys = run_capture("command -v xlings 2>/dev/null");
+#endif
         if (!sys || sys->empty())
             return std::unexpected(ConfigError{"system xlings not found in PATH"});
         std::string p = *sys;
@@ -546,8 +568,8 @@ std::expected<GlobalConfig, ConfigError> load_or_init(
     //    upstream (see docs/short-term-vs-long-track plan).
     ensure_sandbox_xlings_binary(cfg, quiet);
     ensure_sandbox_init(cfg, quiet);
-#if !defined(__APPLE__)
-    // patchelf is ELF-only; macOS uses Mach-O and does not need it.
+#if !defined(__APPLE__) && !defined(_WIN32)
+    // patchelf is ELF-only; macOS uses Mach-O and Windows uses PE.
     ensure_sandbox_patchelf(cfg, quiet, onBootstrapProgress);
 #endif
     ensure_sandbox_ninja(cfg, quiet, onBootstrapProgress);
diff --git a/src/modgraph/p1689.cppm b/src/modgraph/p1689.cppm
index 95bfb82..e42ae31 100644
--- a/src/modgraph/p1689.cppm
+++ b/src/modgraph/p1689.cppm
@@ -19,6 +19,10 @@
 module;
 #include <cstdio>
 #include <cstdlib>
+#if defined(_WIN32)
+#define popen  _popen
+#define pclose _pclose
+#endif
 
 export module mcpp.modgraph.p1689;
 
diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm
index f84cdf0..5f60770 100644
--- a/src/pack/pack.cppm
+++ b/src/pack/pack.cppm
@@ -17,6 +17,10 @@
 
 module;
 #include <cstdio>      // popen, pclose
+#if defined(_WIN32)
+#define popen  _popen
+#define pclose _pclose
+#endif
 
 export module mcpp.pack;
 
@@ -532,6 +536,28 @@ make_tarball(const std::filesystem::path& stagingRoot,
 std::expected<void, Error>
 run(const Plan& plan, const mcpp::config::GlobalConfig& cfg)
 {
+#if defined(_WIN32)
+    // `mcpp pack` is not yet supported on Windows.
+    //
+    // The current implementation relies on POSIX-only tools:
+    //   - LD_TRACE_LOADED_OBJECTS=1  (ELF dynamic linker trick; no equivalent
+    //                                 on Windows PE/COFF)
+    //   - ldd / patchelf             (Linux ELF tools; not available on Windows)
+    //   - tar -czf                   (GNU tar; not universally present on Windows)
+    //
+    // For CI-produced Windows zip packages, use the ci-windows.yml workflow
+    // which zips the MSVC/Clang build output directly.
+    //
+    // Windows PE packaging (DLL collection + zip) is planned.
+    // See .agents/docs/2026-05-19-pack-windows-design.md for the design.
+    (void)plan;
+    (void)cfg;
+    return std::unexpected(Error{
+        "error: `mcpp pack` is not yet supported on Windows.\n"
+        "       Use the CI workflow (ci-windows.yml) to produce Windows zip packages.\n"
+        "       Windows PE packaging (DLL collection + zip) is planned."
+    });
+#else
     using namespace detail;
     std::error_code ec;
 
@@ -645,6 +671,7 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg)
             return r;
     }
     return {};
+#endif // !_WIN32
 }
 
 } // namespace mcpp::pack
diff --git a/src/platform.cppm b/src/platform.cppm
new file mode 100644
index 0000000..fee9587
--- /dev/null
+++ b/src/platform.cppm
@@ -0,0 +1,65 @@
+// mcpp.platform — centralized platform-specific constants.
+//
+// Consumers import this module instead of scattering #if/_WIN32 / __APPLE__
+// blocks throughout the codebase.  All compile-time branching lives here.
+
+module;
+
+// Nothing to #include for compile-time constants; the module fragment is kept
+// for future OS headers if needed.
+
+export module mcpp.platform;
+
+import std;
+
+export namespace mcpp::platform {
+
+// ── Binary / library name conventions ─────────────────────────────────────
+
+#if defined(_WIN32)
+constexpr std::string_view exe_suffix    = ".exe";
+constexpr std::string_view static_lib_ext = ".lib";
+constexpr std::string_view shared_lib_ext = ".dll";
+constexpr std::string_view lib_prefix    = "";
+#elif defined(__APPLE__)
+constexpr std::string_view exe_suffix    = "";
+constexpr std::string_view static_lib_ext = ".a";
+constexpr std::string_view shared_lib_ext = ".dylib";
+constexpr std::string_view lib_prefix    = "lib";
+#else
+// Linux and other POSIX
+constexpr std::string_view exe_suffix    = "";
+constexpr std::string_view static_lib_ext = ".a";
+constexpr std::string_view shared_lib_ext = ".so";
+constexpr std::string_view lib_prefix    = "lib";
+#endif
+
+// ── Shell / process helpers ────────────────────────────────────────────────
+
+#if defined(_WIN32)
+constexpr std::string_view null_redirect = "2>nul";
+#else
+constexpr std::string_view null_redirect = "2>/dev/null";
+#endif
+
+// ── Platform identification ────────────────────────────────────────────────
+
+#if defined(_WIN32)
+constexpr bool is_windows = true;
+constexpr bool is_macos   = false;
+constexpr bool is_linux   = false;
+#elif defined(__APPLE__)
+constexpr bool is_windows = false;
+constexpr bool is_macos   = true;
+constexpr bool is_linux   = false;
+#elif defined(__linux__)
+constexpr bool is_windows = false;
+constexpr bool is_macos   = false;
+constexpr bool is_linux   = true;
+#else
+constexpr bool is_windows = false;
+constexpr bool is_macos   = false;
+constexpr bool is_linux   = false;
+#endif
+
+} // namespace mcpp::platform
diff --git a/src/pm/publisher.cppm b/src/pm/publisher.cppm
index 42209bc..56b4217 100644
--- a/src/pm/publisher.cppm
+++ b/src/pm/publisher.cppm
@@ -4,6 +4,10 @@
 
 module;
 #include <cstdio>     // popen / pclose / fgets
+#if defined(_WIN32)
+#define popen  _popen
+#define pclose _pclose
+#endif
 
 export module mcpp.pm.publisher;
 
diff --git a/src/process.cppm b/src/process.cppm
new file mode 100644
index 0000000..43f8834
--- /dev/null
+++ b/src/process.cppm
@@ -0,0 +1,145 @@
+// mcpp.process — platform-aware process runner.
+//
+// Centralises all popen/system usage into a single module so callers do
+// not need to scatter #if _WIN32 guards or duplicate the popen-read loop.
+//
+// Three entry points:
+//   run_capture   — run a command, capture stdout (replaces the many inline
+//                   popen loops in probe.cppm, xlings.cppm, pack.cppm, …)
+//   run_with_env  — run a command with extra env vars (replaces scattered
+//                   _putenv_s() calls on Windows)
+//   shell_quote   — platform-aware shell quoting (delegates to mcpp.xlings::shq;
+//                   kept here so new code imports mcpp.process, not mcpp.xlings)
+//
+// NOTE on Windows shell_quote:
+//   Do NOT use shell_quote() for the FIRST token in a popen/system command
+//   string on Windows — cmd.exe strips the leading double-quote pair and the
+//   binary name becomes unrecognised.  Use the raw path string as the first
+//   token and shell_quote() only for arguments.  See xlings.cppm::shq() for
+//   the full rationale.
+
+module;
+#include <cstdio>
+#include <cstdlib>
+#if defined(_WIN32)
+#include <stdlib.h>    // _putenv_s
+#define popen  _popen
+#define pclose _pclose
+#endif
+
+export module mcpp.process;
+
+import std;
+import mcpp.xlings;   // shq() — the authoritative shell-quoting implementation
+
+export namespace mcpp::process {
+
+// ─── Result type ─────────────────────────────────────────────────────────────
+
+struct RunResult {
+    int         exit_code = 0;
+    std::string output;
+};
+
+// ─── run_capture ─────────────────────────────────────────────────────────────
+//
+// Run `command` via the platform shell (popen on both POSIX and Windows).
+// Captures stdout.  stderr is NOT captured unless the caller redirects it
+// in the command string (e.g. "cmd 2>&1" on POSIX, "cmd 2>&1" on Windows).
+//
+// Returns RunResult with exit_code set and output containing all captured
+// text.  On popen failure, exit_code is -1 and output is empty.
+RunResult run_capture(std::string_view command);
+
+// ─── run_with_env ────────────────────────────────────────────────────────────
+//
+// Run `command` with extra environment variables (additive — existing vars
+// not in `env` are preserved).
+//
+// On Windows: uses _putenv_s() to inject each var into the current process
+// environment before spawning the child via popen().  _putenv_s() changes
+// are inherited by child processes.  IMPORTANT: this mutates the calling
+// process's environment; callers should restore vars if needed.
+//
+// On POSIX: prefixes the command with "VAR=val " tokens so the vars are
+// scoped to the child (the calling process's environment is unchanged).
+//
+// Returns the same RunResult as run_capture().
+RunResult run_with_env(std::string_view command,
+                       const std::vector<std::pair<std::string, std::string>>& env);
+
+// ─── shell_quote ─────────────────────────────────────────────────────────────
+//
+// Quote `s` for safe embedding in a shell command string.
+//   POSIX:   wraps in single quotes, escaping embedded single quotes.
+//   Windows: wraps in double quotes, escaping embedded double quotes.
+//
+// See the module-level NOTE about cmd.exe's first-token behaviour on Windows.
+std::string shell_quote(std::string_view s);
+
+} // namespace mcpp::process
+
+// ─── Implementation ──────────────────────────────────────────────────────────
+
+namespace mcpp::process {
+
+RunResult run_capture(std::string_view command) {
+    std::string cmd_str(command);
+    RunResult result;
+
+    std::FILE* fp = ::popen(cmd_str.c_str(), "r");
+    if (!fp) {
+        result.exit_code = -1;
+        return result;
+    }
+
+    std::array<char, 4096> buf{};
+    while (std::fgets(buf.data(), static_cast<int>(buf.size()), fp) != nullptr)
+        result.output += buf.data();
+
+    int rc = ::pclose(fp);
+#if defined(_WIN32)
+    // On Windows, pclose() returns the raw exit code from WaitForSingleObject /
+    // GetExitCodeProcess — it is already the process exit code, not a wait
+    // status word, so no WIFEXITED/WEXITSTATUS unwrapping needed.
+    result.exit_code = rc;
+#else
+    // On POSIX, pclose() returns a wait-status word; extract the real exit code.
+    if (WIFEXITED(rc))
+        result.exit_code = WEXITSTATUS(rc);
+    else
+        result.exit_code = rc;   // signal / abnormal — surface raw value
+#endif
+    return result;
+}
+
+RunResult run_with_env(std::string_view command,
+                       const std::vector<std::pair<std::string, std::string>>& env)
+{
+#if defined(_WIN32)
+    // Inject vars into the current process environment.  popen() inherits them.
+    for (auto& [k, v] : env)
+        _putenv_s(k.c_str(), v.c_str());
+    return run_capture(command);
+#else
+    // Build "KEY=val KEY2=val2 <original command>" prefix.
+    std::string prefixed;
+    for (auto& [k, v] : env) {
+        prefixed += k;
+        prefixed += '=';
+        prefixed += shell_quote(v);
+        prefixed += ' ';
+    }
+    prefixed += command;
+    return run_capture(prefixed);
+#endif
+}
+
+std::string shell_quote(std::string_view s) {
+    // Delegate to the canonical implementation in mcpp.xlings so the two
+    // stay in sync.  If xlings.cppm's shq() is ever updated for edge-cases
+    // (e.g. NUL bytes, Unicode), this function inherits the fix automatically.
+    return mcpp::xlings::shq(s);
+}
+
+} // namespace mcpp::process
diff --git a/src/toolchain/clang.cppm b/src/toolchain/clang.cppm
index 23cf617..9cea70b 100644
--- a/src/toolchain/clang.cppm
+++ b/src/toolchain/clang.cppm
@@ -6,6 +6,7 @@ import std;
 import mcpp.toolchain.model;
 import mcpp.toolchain.probe;
 import mcpp.xlings;
+import mcpp.platform;
 
 export namespace mcpp::toolchain::clang {
 
@@ -89,9 +90,10 @@ std::optional<std::filesystem::path> find_libcxx_std_module_source(
     const std::string& envPrefix)
 {
     auto manifest_r = mcpp::toolchain::run_capture(std::format(
-        "{}{} -print-library-module-manifest-path 2>/dev/null",
+        "{}{} -print-library-module-manifest-path {}",
         envPrefix,
-        mcpp::xlings::shq(cxx_binary.string())));
+        mcpp::xlings::shq(cxx_binary.string()),
+        mcpp::platform::null_redirect));
     if (manifest_r) {
         auto manifestPath = std::filesystem::path(
             mcpp::toolchain::trim_line(*manifest_r));
@@ -130,14 +132,44 @@ std::optional<std::filesystem::path> find_libcxx_std_module_source(
 }
 
 void enrich_toolchain(Toolchain& tc, const std::string& envPrefix) {
-    tc.stdlibId      = "libc++";
+    // Clang targeting MSVC uses MSVC STL, not libc++.
+    bool msvTarget = tc.targetTriple.find("msvc") != std::string::npos;
+    tc.stdlibId      = msvTarget ? "msvc-stl" : "libc++";
     tc.stdlibVersion = tc.version.empty() ? "unknown" : tc.version;
     tc.linkRuntimeDirs = mcpp::toolchain::discover_link_runtime_dirs(
         tc.binaryPath, tc.targetTriple);
+
     if (auto p = find_libcxx_std_module_source(tc.binaryPath, envPrefix)) {
         tc.stdModuleSource = *p;
         tc.hasImportStd    = true;
     }
+
+#if defined(_WIN32)
+    // Fallback: if libc++ std.cppm not found, look for MSVC STL's std.ixx.
+    // This happens when Clang targets x86_64-pc-windows-msvc.
+    if (!tc.hasImportStd && msvTarget) {
+        // Search Visual Studio installations for std.ixx
+        // Typical path: C:\Program Files\Microsoft Visual Studio\2022\*\VC\Tools\MSVC\*\modules\std.ixx
+        std::error_code ec;
+        std::filesystem::path vsBase = "C:\\Program Files\\Microsoft Visual Studio\\2022";
+        if (std::filesystem::exists(vsBase, ec)) {
+            for (auto& edition : std::filesystem::directory_iterator(vsBase, ec)) {
+                auto vcTools = edition.path() / "VC" / "Tools" / "MSVC";
+                if (!std::filesystem::exists(vcTools, ec)) continue;
+                for (auto& ver : std::filesystem::directory_iterator(vcTools, ec)) {
+                    auto stdIxx = ver.path() / "modules" / "std.ixx";
+                    if (std::filesystem::exists(stdIxx, ec)) {
+                        tc.stdModuleSource = stdIxx;
+                        tc.hasImportStd    = true;
+                        break;
+                    }
+                }
+                if (tc.hasImportStd) break;
+            }
+        }
+    }
+#endif
+
     if (tc.hasImportStd) {
         if (auto p = find_libcxx_std_compat_source(tc.binaryPath, envPrefix)) {
             tc.stdCompatSource = *p;
@@ -158,6 +190,37 @@ std::vector<std::string> std_module_build_commands(const Toolchain& tc,
                                                    const std::filesystem::path& bmiPath,
                                                    std::string_view sysrootFlag) {
     auto relBmi = std::filesystem::relative(bmiPath, cacheDir).string();
+#if defined(_WIN32)
+    // Windows: use absolute paths, raw binary path as first token
+    // (cmd.exe strips leading quotes), shq for args with spaces.
+    // -x c++-module is needed for MSVC STL's .ixx files (Clang doesn't
+    // recognize the .ixx extension as a module source by default).
+    auto absBmi = (cacheDir / relBmi).string();
+    auto ext = tc.stdModuleSource.extension().string();
+    // MSVC STL's std.ixx needs -x c++-module (Clang doesn't recognize .ixx)
+    // and generates harmless warnings about #include in module purview and
+    // the reserved 'std' module name — suppress both.
+    std::string ixxFlags = (ext == ".ixx")
+        ? " -x c++-module -Wno-include-angled-in-module-purview -Wno-reserved-module-identifier"
+        : "";
+    return {
+        std::format(
+            "{} -std=c++23{}{} "
+            "--precompile {} -o {}",
+            tc.binaryPath.string(),
+            ixxFlags,
+            sysrootFlag,
+            mcpp::xlings::shq(tc.stdModuleSource.string()),
+            mcpp::xlings::shq(absBmi)),
+        std::format(
+            "{} -std=c++23{} "
+            "{} -c -o {}",
+            tc.binaryPath.string(),
+            sysrootFlag,
+            mcpp::xlings::shq(absBmi),
+            mcpp::xlings::shq((cacheDir / "std.o").string()))
+    };
+#else
     return {
         std::format(
             "cd {} && {}{} -std=c++23 -Wno-reserved-module-identifier{} "
@@ -177,16 +240,25 @@ std::vector<std::string> std_module_build_commands(const Toolchain& tc,
             sysrootFlag,
             mcpp::xlings::shq(relBmi))
     };
+#endif
 }
 
 std::filesystem::path archive_tool(const Toolchain& tc) {
+#if defined(_WIN32)
+    auto llvmAr = tc.binaryPath.parent_path() / "llvm-ar.exe";
+#else
     auto llvmAr = tc.binaryPath.parent_path() / "llvm-ar";
+#endif
     if (std::filesystem::exists(llvmAr)) return llvmAr;
     return {};
 }
 
 std::optional<std::filesystem::path> find_scan_deps(const Toolchain& tc) {
+#if defined(_WIN32)
+    auto p = tc.binaryPath.parent_path() / "clang-scan-deps.exe";
+#else
     auto p = tc.binaryPath.parent_path() / "clang-scan-deps";
+#endif
     if (std::filesystem::exists(p)) return p;
     return std::nullopt;
 }
diff --git a/src/toolchain/llvm.cppm b/src/toolchain/llvm.cppm
index 8072c58..e56744b 100644
--- a/src/toolchain/llvm.cppm
+++ b/src/toolchain/llvm.cppm
@@ -3,6 +3,7 @@
 export module mcpp.toolchain.llvm;
 
 import std;
+import mcpp.platform;
 
 export namespace mcpp::toolchain::llvm {
 
@@ -24,7 +25,7 @@ std::string package_name() {
 }
 
 std::vector<std::string> frontend_candidates() {
-    return {"clang++"};
+    return {std::format("clang++{}", mcpp::platform::exe_suffix)};
 }
 
 std::vector<std::string> list_aliases() {
diff --git a/src/toolchain/probe.cppm b/src/toolchain/probe.cppm
index 03c0fb9..5db0203 100644
--- a/src/toolchain/probe.cppm
+++ b/src/toolchain/probe.cppm
@@ -3,12 +3,17 @@
 module;
 #include <cstdio>      // popen, pclose, fgets, FILE
 #include <cstdlib>     // getenv
+#if defined(_WIN32)
+#define popen  _popen
+#define pclose _pclose
+#endif
 
 export module mcpp.toolchain.probe;
 
 import std;
 import mcpp.toolchain.model;
 import mcpp.xlings;
+import mcpp.platform;
 
 export namespace mcpp::toolchain {
 
@@ -66,10 +71,15 @@ std::string join_colon_paths(const std::vector<std::filesystem::path>& dirs) {
 }
 
 std::string env_prefix_for_dirs(const std::vector<std::filesystem::path>& dirs) {
+#if defined(_WIN32)
+    (void)dirs;
+    return "";
+#else
     if (dirs.empty()) return "";
     auto joined = join_colon_paths(dirs);
     return std::format("env LD_LIBRARY_PATH={}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}} ",
                        mcpp::xlings::shq(joined));
+#endif
 }
 
 } // namespace
@@ -240,11 +250,18 @@ probe_compiler_binary(const std::filesystem::path& explicit_compiler) {
         cxx = "g++";
     }
 
-    auto bin_path_r = run_capture(std::format("command -v '{}' 2>/dev/null", cxx));
+#if defined(_WIN32)
+    auto bin_path_r = run_capture(std::format("where {} {}", cxx,
+                                               mcpp::platform::null_redirect));
+#else
+    auto bin_path_r = run_capture(std::format("command -v '{}' {}", cxx,
+                                               mcpp::platform::null_redirect));
+#endif
     if (!bin_path_r) {
         return std::unexpected(DetectError{std::format("compiler '{}' not found in PATH", cxx)});
     }
-    auto bin = trim_line(*bin_path_r);
+    // `where` on Windows may return multiple lines; take only the first.
+    auto bin = trim_line(first_line_of(*bin_path_r));
     if (bin.empty()) {
         return std::unexpected(DetectError{std::format("compiler '{}' not found", cxx)});
     }
@@ -254,9 +271,10 @@ probe_compiler_binary(const std::filesystem::path& explicit_compiler) {
 std::expected<std::string, DetectError>
 probe_target_triple(const std::filesystem::path& compilerBin,
                     const std::string& envPrefix) {
-    auto triple_r = run_capture(std::format("{}{} -dumpmachine 2>/dev/null",
+    auto triple_r = run_capture(std::format("{}{} -dumpmachine {}",
                                             envPrefix,
-                                            mcpp::xlings::shq(compilerBin.string())));
+                                            mcpp::xlings::shq(compilerBin.string()),
+                                            mcpp::platform::null_redirect));
     if (!triple_r) return std::unexpected(triple_r.error());
     return trim_line(*triple_r);
 }
@@ -264,16 +282,18 @@ probe_target_triple(const std::filesystem::path& compilerBin,
 std::filesystem::path
 probe_sysroot(const std::filesystem::path& compilerBin,
               const std::string& envPrefix) {
-    auto r = run_capture(std::format("{}{} -print-sysroot 2>/dev/null",
+    auto r = run_capture(std::format("{}{} -print-sysroot {}",
                                      envPrefix,
-                                     mcpp::xlings::shq(compilerBin.string())));
+                                     mcpp::xlings::shq(compilerBin.string()),
+                                     mcpp::platform::null_redirect));
     if (r) {
         auto s = trim_line(*r);
         if (!s.empty() && std::filesystem::exists(s)) return s;
     }
 #if defined(__APPLE__)
     // macOS fallback: use xcrun to discover the SDK path
-    auto xcrun_r = run_capture("xcrun --show-sdk-path 2>/dev/null");
+    auto xcrun_r = run_capture(std::format("xcrun --show-sdk-path {}",
+                                            mcpp::platform::null_redirect));
     if (xcrun_r) {
         auto sdk = trim_line(*xcrun_r);
         if (!sdk.empty() && std::filesystem::exists(sdk)) return sdk;
diff --git a/src/toolchain/provider.cppm b/src/toolchain/provider.cppm
new file mode 100644
index 0000000..985de48
--- /dev/null
+++ b/src/toolchain/provider.cppm
@@ -0,0 +1,111 @@
+// mcpp.toolchain.provider — provider capabilities dispatch.
+//
+// Documents the "provider concept": each toolchain variant (GCC/libstdc++,
+// Clang/libc++, Clang/MSVC-STL) has a distinct set of capabilities.
+// Previously these decisions were scattered as ad-hoc is_clang(tc) /
+// is_gcc(tc) / targetTriple.find("msvc") checks. This module centralises
+// them into a single query point.
+//
+// Usage:
+//   auto caps = mcpp::toolchain::capabilities_for(tc);
+//   if (caps.has_import_std) { ... }
+
+export module mcpp.toolchain.provider;
+
+import std;
+import mcpp.toolchain.model;
+
+export namespace mcpp::toolchain {
+
+// ─── ProviderCapabilities ────────────────────────────────────────────────────
+//
+// Describes what a particular toolchain instance can do.  All fields have
+// safe defaults (false / empty) so callers that only care about one flag
+// do not need to guard the rest.
+
+struct ProviderCapabilities {
+    // True when the toolchain ships a prebuilt `std` module source
+    // (bits/std.cc for GCC, std.cppm / std.ixx for Clang variants) and
+    // Toolchain::stdModuleSource has been populated by enrich_toolchain().
+    bool has_import_std = false;
+
+    // True when clang-scan-deps (or an equivalent dep-scanner) is available
+    // alongside the compiler binary.  Currently only Clang provides this.
+    bool has_scan_deps = false;
+
+    // True when the compiler supports C++ named modules at all.
+    // All three supported compilers do; kept for future use when we add
+    // compilers that don't (e.g. old MSVC versions, ICC).
+    bool has_modules = true;
+
+    // Canonical stdlib identifier:
+    //   "libstdc++"  — GCC, or Clang targeting a non-MSVC triple on Linux/macOS
+    //   "libc++"     — Clang with libc++ (xim:llvm toolchain, or Apple Clang)
+    //   "msvc-stl"   — Clang targeting x86_64-pc-windows-msvc
+    //   ""           — Unknown / not yet detected
+    std::string stdlib_id;
+
+    // Archive tool name used for static libraries:
+    //   "ar"       — GCC / system binutils
+    //   "llvm-ar"  — Clang (llvm-ar is preferred; falls back to system ar)
+    //   "lib.exe"  — MSVC (future)
+    //   ""         — Unknown
+    std::string archive_format;
+};
+
+// Determine provider capabilities from an already-detected toolchain.
+// All fields are derived from tc.compiler + tc.targetTriple + tc.hasImportStd
+// so the result is deterministic and has no side-effects.
+ProviderCapabilities capabilities_for(const Toolchain& tc);
+
+} // namespace mcpp::toolchain
+
+// ─── Implementation ──────────────────────────────────────────────────────────
+
+namespace mcpp::toolchain {
+
+ProviderCapabilities capabilities_for(const Toolchain& tc) {
+    ProviderCapabilities caps;
+
+    caps.has_import_std = tc.hasImportStd;
+    caps.has_modules    = true;   // all supported compilers handle modules
+
+    switch (tc.compiler) {
+        case CompilerId::GCC: {
+            caps.has_scan_deps   = false;   // GCC has no clang-scan-deps equivalent
+            caps.stdlib_id       = "libstdc++";
+            caps.archive_format  = "ar";
+            break;
+        }
+
+        case CompilerId::Clang: {
+            // Clang targeting MSVC uses MSVC STL, not libc++.
+            // We detect this the same way clang.cppm's enrich_toolchain does:
+            // by checking the target triple for "msvc".
+            bool msvc_target = tc.targetTriple.find("msvc") != std::string::npos;
+
+            caps.has_scan_deps  = true;     // clang-scan-deps lives beside clang++
+            caps.stdlib_id      = msvc_target ? "msvc-stl" : "libc++";
+            caps.archive_format = "llvm-ar";
+            break;
+        }
+
+        case CompilerId::MSVC: {
+            // Pure MSVC (cl.exe) — not yet fully supported, but stubs are here
+            // so callers can branch on it without another unknown-compiler guard.
+            caps.has_scan_deps  = false;
+            caps.stdlib_id      = "msvc-stl";
+            caps.archive_format = "lib.exe";
+            break;
+        }
+
+        case CompilerId::Unknown:
+        default:
+            // Leave all caps at their safe defaults (false / "").
+            break;
+    }
+
+    return caps;
+}
+
+} // namespace mcpp::toolchain
diff --git a/src/toolchain/stdmod.cppm b/src/toolchain/stdmod.cppm
index 042f4d0..8701fed 100644
--- a/src/toolchain/stdmod.cppm
+++ b/src/toolchain/stdmod.cppm
@@ -1,6 +1,10 @@
 module;
 #include <cstdio>     // popen, pclose, fgets, FILE
 #include <cstdlib>    // getenv
+#if defined(_WIN32)
+#define popen  _popen
+#define pclose _pclose
+#endif
 
 // mcpp.toolchain.stdmod — pre-build the `import std` BMI and cache it.
 //
diff --git a/src/xlings.cppm b/src/xlings.cppm
index 4cfe950..2e01efd 100644
--- a/src/xlings.cppm
+++ b/src/xlings.cppm
@@ -10,6 +10,11 @@
 module;
 #include <cstdio>
 #include <cstdlib>
+#if defined(_WIN32)
+#include <stdlib.h>    // _putenv_s
+#define popen  _popen
+#define pclose _pclose
+#endif
 
 export module mcpp.xlings;
 
@@ -315,12 +320,25 @@ std::expected<std::string, std::string> run_capture(const std::string& cmd) {
 std::string shq(std::string_view s) {
     std::string out;
     out.reserve(s.size() + 2);
+#if defined(_WIN32)
+    // Windows: wrap in double quotes, escape inner " as \".
+    // IMPORTANT: avoid placing a shq'd token as the FIRST token in a
+    // popen/system command — cmd.exe strips a leading " pair.  For
+    // binary paths, use the raw string; shq is safe for arguments.
+    out.push_back('"');
+    for (char c : s) {
+        if (c == '"') out += "\\\"";
+        else out.push_back(c);
+    }
+    out.push_back('"');
+#else
     out.push_back('\'');
     for (char c : s) {
         if (c == '\'') out += "'\\''";
         else out.push_back(c);
     }
     out.push_back('\'');
+#endif
     return out;
 }
 
@@ -409,6 +427,21 @@ std::filesystem::path sandbox_init_marker(const Env& env) {
 
 std::string build_command_prefix(const Env& env) {
     auto xvmBin = paths::sandbox_bin(env).string();
+#if defined(_WIN32)
+    // Windows: set environment variables via the process environment
+    // (cmd.exe `set` in compound &&-chains is unreliable) then invoke
+    // xlings directly. _putenv_s is inherited by popen/system child.
+    _putenv_s("XLINGS_HOME", env.home.string().c_str());
+    _putenv_s("XLINGS_PROJECT_DIR",
+              env.projectDir.empty() ? "" : env.projectDir.string().c_str());
+    // Prepend sandbox bin to PATH
+    {
+        std::string newPath = xvmBin + ";" + (std::getenv("PATH") ? std::getenv("PATH") : "");
+        _putenv_s("PATH", newPath.c_str());
+    }
+    // Return raw path — no quoting to avoid cmd.exe double-quote parsing issues
+    return env.binary.string();
+#else
     if (env.projectDir.empty()) {
         // Global mode: unset XLINGS_PROJECT_DIR (existing behavior).
         return std::format(
@@ -427,13 +460,19 @@ std::string build_command_prefix(const Env& env) {
         shq(env.home.string()),
         shq(env.projectDir.string()),
         shq(env.binary.string()));
+#endif
 }
 
 std::string build_interface_command(const Env& env,
                                     std::string_view capability,
                                     std::string_view argsJson) {
+#if defined(_WIN32)
+    return std::format("{} interface {} --args {}",
+        build_command_prefix(env), capability, shq(argsJson));
+#else
     return std::format("{} interface {} --args {} 2>/dev/null",
         build_command_prefix(env), capability, shq(argsJson));
+#endif
 }
 
 // ─── JSON extraction helpers ────────────────────────────────────────
@@ -624,12 +663,22 @@ int install_with_progress(const Env& env, std::string_view target,
     auto argsJson = std::format(
         R"({{"targets":["{}"],"yes":true}})", target);
 
+#if defined(_WIN32)
+    _putenv_s("XLINGS_HOME", env.home.string().c_str());
+    _putenv_s("XLINGS_PROJECT_DIR", "");
+    // Use raw path (no quoting) to avoid cmd.exe double-quote parsing issues.
+    // Wrap only the JSON arg in single-escaped quotes for the C runtime.
+    auto cmd = std::format("{} interface install_packages --args {}",
+        env.binary.string(),
+        shq(argsJson));
+#else
     auto cmd = std::format(
         "cd {} && env -u XLINGS_PROJECT_DIR XLINGS_HOME={} {} interface install_packages --args {} 2>/dev/null",
         shq(env.home.string()),
         shq(env.home.string()),
         shq(env.binary.string()),
         shq(argsJson));
+#endif
 
     std::FILE* fp = ::popen(cmd.c_str(), "r");
     if (!fp) return -1;
@@ -740,13 +789,23 @@ void ensure_init(const Env& env, bool quiet) {
     auto marker = paths::sandbox_init_marker(env);
     if (std::filesystem::exists(marker)) return;
 
+    // Ensure the home directory exists before cd'ing into it.
+    std::error_code ec;
+    std::filesystem::create_directories(env.home, ec);
+
     if (!quiet)
         print_status("Initialize", "mcpp sandbox layout (one-time)");
+#if defined(_WIN32)
+    _putenv_s("XLINGS_HOME", env.home.string().c_str());
+    _putenv_s("XLINGS_PROJECT_DIR", "");
+    auto cmd = env.binary.string() + " self init";
+#else
     auto cmd = std::format(
         "cd {} && env -u XLINGS_PROJECT_DIR XLINGS_HOME={} {} self init >/dev/null 2>&1",
         shq(env.home.string()),
         shq(env.home.string()),
         shq(env.binary.string()));
+#endif
     int rc = std::system(cmd.c_str());
     if (rc != 0 && !quiet) {
         std::println(stderr,
@@ -780,7 +839,11 @@ void ensure_ninja(const Env& env, bool quiet,
     if (std::filesystem::exists(root)) {
         std::error_code ec;
         for (auto& v : std::filesystem::directory_iterator(root, ec)) {
+#if defined(_WIN32)
+            if (std::filesystem::exists(v.path() / "ninja.exe")) return;
+#else
             if (std::filesystem::exists(v.path() / "ninja")) return;
+#endif
         }
     }
     if (!quiet)
diff --git a/tests/e2e/01_help_and_version.sh b/tests/e2e/01_help_and_version.sh
index 66d9aa7..2a17056 100755
--- a/tests/e2e/01_help_and_version.sh
+++ b/tests/e2e/01_help_and_version.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # Verify --help and --version
 set -e
 
diff --git a/tests/e2e/02_new_build_run.sh b/tests/e2e/02_new_build_run.sh
index f15626d..d0b9b61 100755
--- a/tests/e2e/02_new_build_run.sh
+++ b/tests/e2e/02_new_build_run.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: fresh-sandbox
 # Single-module hello world: mcpp new → build → run
 set -e
 
@@ -17,9 +18,15 @@ grep -q "import std"           src/main.cpp || { echo "main.cpp missing 'import
 grep -q "std::println"          src/main.cpp || { echo "main.cpp missing 'std::println'"; exit 1; }
 
 # Build
-"$MCPP" build > build.log 2>&1
+"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; }
 [[ -d target ]] || { cat build.log; echo "no target/ dir"; exit 1; }
-binary="$(find target -name hello -type f | head -1)"
+# On Windows (MINGW/MSYS) the binary has a .exe suffix
+OS="$(uname -s)"
+if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then
+    binary="$(find target -name hello.exe -type f | head -1)"
+else
+    binary="$(find target -name hello -type f | head -1)"
+fi
 [[ -n "$binary" ]] || { echo "binary not produced"; exit 1; }
 [[ -x "$binary" ]] || { echo "binary not executable"; exit 1; }
 
diff --git a/tests/e2e/03_multi_module.sh b/tests/e2e/03_multi_module.sh
index dd21023..42b8d95 100755
--- a/tests/e2e/03_multi_module.sh
+++ b/tests/e2e/03_multi_module.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: elf gcc
 # Multi-module: package with .cppm + main.cpp importing it
 set -e
 
diff --git a/tests/e2e/04_incremental.sh b/tests/e2e/04_incremental.sh
index c9214e9..199a5ea 100755
--- a/tests/e2e/04_incremental.sh
+++ b/tests/e2e/04_incremental.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # Incremental: a no-op rebuild does no work; touching main.cpp recompiles only it
 set -e
 
diff --git a/tests/e2e/05_errors.sh b/tests/e2e/05_errors.sh
index 9ffd970..b5ca5a2 100755
--- a/tests/e2e/05_errors.sh
+++ b/tests/e2e/05_errors.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # Error paths: missing manifest, missing version, conditional import, header unit, naming violation
 set -e
 
diff --git a/tests/e2e/06_emit_xpkg.sh b/tests/e2e/06_emit_xpkg.sh
index 9b3821d..e72c12f 100755
--- a/tests/e2e/06_emit_xpkg.sh
+++ b/tests/e2e/06_emit_xpkg.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # emit xpkg: produce valid Lua entry from mcpp.toml
 set -e
 
diff --git a/tests/e2e/07_static_library.sh b/tests/e2e/07_static_library.sh
index 551d1eb..98af0de 100755
--- a/tests/e2e/07_static_library.sh
+++ b/tests/e2e/07_static_library.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: elf
 # Static library: kind = "lib"  → libNAME.a via `ar rcs`
 set -e
 
diff --git a/tests/e2e/08_shared_library.sh b/tests/e2e/08_shared_library.sh
index 37b9460..2b91755 100755
--- a/tests/e2e/08_shared_library.sh
+++ b/tests/e2e/08_shared_library.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: elf
 # Shared library: kind = "shared" → libNAME.so via -shared -fPIC
 set -e
 
diff --git a/tests/e2e/09_path_dependency.sh b/tests/e2e/09_path_dependency.sh
index 059fe5f..3e67ae1 100755
--- a/tests/e2e/09_path_dependency.sh
+++ b/tests/e2e/09_path_dependency.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: elf gcc
 # Path-based dependency: package B imports modules from package A via
 #   [dependencies.A] path = "../A"
 # Verifies the multi-package scanner + linker pipeline.
diff --git a/tests/e2e/10_env_command.sh b/tests/e2e/10_env_command.sh
index ba36ac1..565165b 100755
--- a/tests/e2e/10_env_command.sh
+++ b/tests/e2e/10_env_command.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: symlink
 # `mcpp env` initializes $MCPP_HOME and prints expected layout.
 set -e
 
diff --git a/tests/e2e/11_index_list.sh b/tests/e2e/11_index_list.sh
index 1e54176..33a6395 100755
--- a/tests/e2e/11_index_list.sh
+++ b/tests/e2e/11_index_list.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # `mcpp index list` shows configured registries (after init).
 set -e
 
diff --git a/tests/e2e/12_add_command.sh b/tests/e2e/12_add_command.sh
index 579ceae..943cfc4 100755
--- a/tests/e2e/12_add_command.sh
+++ b/tests/e2e/12_add_command.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # `mcpp add` modifies mcpp.toml [dependencies], including the namespaced form
 # `mcpp add <ns>:<name>@<ver>` which lands under [dependencies.<ns>] without
 # any TOML key quoting.
diff --git a/tests/e2e/13_toolchain_pin.sh b/tests/e2e/13_toolchain_pin.sh
index baeab04..87af7ea 100755
--- a/tests/e2e/13_toolchain_pin.sh
+++ b/tests/e2e/13_toolchain_pin.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # Project pins [toolchain] → mcpp resolves to xpkg absolute path.
 #
 # This test verifies the resolve_xpkg_path branch is exercised when a
diff --git a/tests/e2e/14_toolchain_fallback.sh b/tests/e2e/14_toolchain_fallback.sh
index 51bd326..a6d2a0e 100755
--- a/tests/e2e/14_toolchain_fallback.sh
+++ b/tests/e2e/14_toolchain_fallback.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # 14_toolchain_fallback.sh — M5.5: when no toolchain is configured at all
 # (no project [toolchain], no global default), `mcpp build` hard-errors with
 # a helpful message instead of falling back to system PATH.
diff --git a/tests/e2e/15_test_passing.sh b/tests/e2e/15_test_passing.sh
index e2c429f..72c8a1d 100755
--- a/tests/e2e/15_test_passing.sh
+++ b/tests/e2e/15_test_passing.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # `mcpp test` discovers tests/**/*.cpp and runs each as a separate binary.
 # All passing → exit 0 + summary "ok. N passed".
 set -e
diff --git a/tests/e2e/16_test_failing.sh b/tests/e2e/16_test_failing.sh
index bd2ead8..9be4c7d 100755
--- a/tests/e2e/16_test_failing.sh
+++ b/tests/e2e/16_test_failing.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: scan-deps
 # 1 ok + 1 fail → mcpp test exits 1, summary lists failures.
 set -e
 
diff --git a/tests/e2e/17_test_no_tests.sh b/tests/e2e/17_test_no_tests.sh
index 3084b3d..e339044 100755
--- a/tests/e2e/17_test_no_tests.sh
+++ b/tests/e2e/17_test_no_tests.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # Project with no tests/ → `mcpp test` says "no tests found" and exits 0.
 set -e
 
diff --git a/tests/e2e/18_devdeps_isolation.sh b/tests/e2e/18_devdeps_isolation.sh
index b5faa06..550a23d 100755
--- a/tests/e2e/18_devdeps_isolation.sh
+++ b/tests/e2e/18_devdeps_isolation.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # Verify dev-deps are NOT pulled by `mcpp build` but ARE pulled by `mcpp test`.
 # We don't actually fetch a real dev-dep (would need network); we just verify
 # that the dev-deps section in mcpp.toml does not appear in the build path's
diff --git a/tests/e2e/19_bmi_cache_reuse.sh b/tests/e2e/19_bmi_cache_reuse.sh
index 3827efd..2f49572 100755
--- a/tests/e2e/19_bmi_cache_reuse.sh
+++ b/tests/e2e/19_bmi_cache_reuse.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # 19_bmi_cache_reuse.sh — verify M3.2 BMI persistent cache wiring.
 #
 #   1. Path deps don't populate the cache (correctness invariant from docs/26).
@@ -70,7 +71,7 @@ EOF
 
 # bmi/ should exist (env init creates it) but no deps/ entry for path deps.
 [[ -d "$MCPP_HOME/bmi" ]] || { echo "missing $MCPP_HOME/bmi"; exit 1; }
-if compgen -G "$MCPP_HOME/bmi/*/deps/*/mylibA*" > /dev/null; then
+if find "$MCPP_HOME/bmi" -path "*/deps/*/mylibA*" 2>/dev/null | grep -q .; then
     echo "FAIL: path dep mylibA was populated into BMI cache (must be skipped)"
     find "$MCPP_HOME/bmi" -maxdepth 5
     exit 1
diff --git a/tests/e2e/20_p1689_scanner.sh b/tests/e2e/20_p1689_scanner.sh
index a5da97a..47d79a1 100755
--- a/tests/e2e/20_p1689_scanner.sh
+++ b/tests/e2e/20_p1689_scanner.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: elf gcc
 # 20_p1689_scanner.sh — verify M3.3.a opt-in P1689 scanner end-to-end.
 #
 # Builds the same multi-module project twice — once under the default
diff --git a/tests/e2e/21_ninja_dyndep.sh b/tests/e2e/21_ninja_dyndep.sh
index 1c63584..3a290f5 100755
--- a/tests/e2e/21_ninja_dyndep.sh
+++ b/tests/e2e/21_ninja_dyndep.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: elf gcc
 # 21_ninja_dyndep.sh — verify M3.3.b: ninja dyndep build mode produces
 # byte-identical runtime output to the static-deps mode.
 #
diff --git a/tests/e2e/22_doctor_cache_publish.sh b/tests/e2e/22_doctor_cache_publish.sh
index 868e7f2..6ec661d 100755
--- a/tests/e2e/22_doctor_cache_publish.sh
+++ b/tests/e2e/22_doctor_cache_publish.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: elf gcc
 # 22_doctor_cache_publish.sh — M4 #1 #2 #4 smoke tests:
 #   - mcpp doctor    runs and reports
 #   - mcpp cache list / clean
diff --git a/tests/e2e/23_remove_update.sh b/tests/e2e/23_remove_update.sh
index 0a62002..8fd0bca 100755
--- a/tests/e2e/23_remove_update.sh
+++ b/tests/e2e/23_remove_update.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # 23_remove_update.sh — M4 #3: mcpp remove / mcpp update.
 set -e
 
diff --git a/tests/e2e/24_git_dependency.sh b/tests/e2e/24_git_dependency.sh
index 4b0f7eb..93e623f 100755
--- a/tests/e2e/24_git_dependency.sh
+++ b/tests/e2e/24_git_dependency.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: fresh-sandbox
 # 24_git_dependency.sh — M4 #5: git-based dep clones to ~/.mcpp/git/<hash>/
 # and is treated as a path dep.
 set -e
diff --git a/tests/e2e/25_convention_mode.sh b/tests/e2e/25_convention_mode.sh
index 1f9d724..8e45057 100755
--- a/tests/e2e/25_convention_mode.sh
+++ b/tests/e2e/25_convention_mode.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # 25_convention_mode.sh — verify M5.0 convention-first schema:
 #   - 3-line mcpp.toml builds + runs successfully
 #   - Inferred banner shown for sources / target
diff --git a/tests/e2e/26_c_language_support.sh b/tests/e2e/26_c_language_support.sh
index 6b5b833..eb20a02 100755
--- a/tests/e2e/26_c_language_support.sh
+++ b/tests/e2e/26_c_language_support.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: gcc
 # C-language compile rule: .c files routed to `c_object` with cc / cflags,
 # distinct from the .cppm/.cpp `cxx_object` rule. Verifies that a mixed
 # C + modular-C++23 project links and runs, and that build.ninja contains
diff --git a/tests/e2e/26_toolchain_management.sh b/tests/e2e/26_toolchain_management.sh
index 6dfe3cf..c6468b8 100755
--- a/tests/e2e/26_toolchain_management.sh
+++ b/tests/e2e/26_toolchain_management.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: gcc
 # 26_toolchain_management.sh — verify M5.5 toolchain CLI + isolation:
 #   - mcpp toolchain install / list / default / remove
 #   - hard error when no toolchain configured
diff --git a/tests/e2e/27_namespace_dependencies.sh b/tests/e2e/27_namespace_dependencies.sh
index 9a1e1ff..20fb170 100755
--- a/tests/e2e/27_namespace_dependencies.sh
+++ b/tests/e2e/27_namespace_dependencies.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: fresh-sandbox
 # Namespaced dependencies: `[dependencies.<ns>] name = { path = "..." }`
 # is parsed correctly and the dep is actually picked up by the build.
 # Also verifies that the legacy `"<ns>.<name>" = "..."` quoted form still
diff --git a/tests/e2e/27_self_contained_home.sh b/tests/e2e/27_self_contained_home.sh
index ff61d33..bd9c5e1 100755
--- a/tests/e2e/27_self_contained_home.sh
+++ b/tests/e2e/27_self_contained_home.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: elf
 # 27_self_contained_home.sh — verifies mcpp's self-contained home behaviour.
 #
 # Without MCPP_HOME set, mcpp resolves its home from the binary's location:
diff --git a/tests/e2e/28_target_static.sh b/tests/e2e/28_target_static.sh
index dca96d5..8111c37 100755
--- a/tests/e2e/28_target_static.sh
+++ b/tests/e2e/28_target_static.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: musl elf
 # 28_target_static.sh — `mcpp build --target <triple>` produces a binary
 # matching the requested target, and `--target *-linux-musl` yields a
 # fully-static ELF (no PT_INTERP, no RUNPATH).
diff --git a/tests/e2e/29_toolchain_partial_versions.sh b/tests/e2e/29_toolchain_partial_versions.sh
index e9fce6a..5a8b5d3 100755
--- a/tests/e2e/29_toolchain_partial_versions.sh
+++ b/tests/e2e/29_toolchain_partial_versions.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: gcc
 # 29_toolchain_partial_versions.sh — `mcpp toolchain default` accepts partial
 # versions in either positional or @-separated form, AND `mcpp build`
 # auto-installs the default toolchain on a first run with no toolchain
diff --git a/tests/e2e/30_dev_binary_home.sh b/tests/e2e/30_dev_binary_home.sh
index 98adc8f..4390f51 100644
--- a/tests/e2e/30_dev_binary_home.sh
+++ b/tests/e2e/30_dev_binary_home.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: gcc
 # Dev binaries are built under build/.../release/mcpp, not <root>/bin/mcpp.
 # With MCPP_HOME unset they should use the conventional ~/.mcpp sandbox;
 # only release-style <root>/bin/mcpp should self-locate to <root>.
diff --git a/tests/e2e/30_pack_modes.sh b/tests/e2e/30_pack_modes.sh
index 30870da..583a6e1 100755
--- a/tests/e2e/30_pack_modes.sh
+++ b/tests/e2e/30_pack_modes.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: pack patchelf elf
 # 30_pack_modes.sh — `mcpp pack` smoke tests for all three modes.
 #
 # Verifies the contract of each mode by extracting the produced tarball
diff --git a/tests/e2e/31_transitive_deps.sh b/tests/e2e/31_transitive_deps.sh
index 98d1d7d..f11a7e0 100755
--- a/tests/e2e/31_transitive_deps.sh
+++ b/tests/e2e/31_transitive_deps.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: musl
 # 31_transitive_deps.sh — transitive dependency walker:
 #   * a path-dep that itself declares a path-dep is fully resolved
 #     (consumer doesn't need to list the grandchild explicitly)
diff --git a/tests/e2e/32_semver_merge.sh b/tests/e2e/32_semver_merge.sh
index 377be5d..a7b7413 100755
--- a/tests/e2e/32_semver_merge.sh
+++ b/tests/e2e/32_semver_merge.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: fresh-sandbox
 # 32_semver_merge.sh — SemVer merge in the transitive walker:
 #   * Two consumers of the same package with overlapping constraints
 #     (one exact, one range) merge to a single satisfying version
@@ -18,13 +19,17 @@ mkdir -p "$MCPP_HOME/registry/data"
 for idx_name in mcpplibs mcpp-index; do
     if [[ -d "$HOME/.mcpp/registry/data/$idx_name" ]]; then
         ln -sf "$HOME/.mcpp/registry/data/$idx_name" \
-            "$MCPP_HOME/registry/data/$idx_name"
+            "$MCPP_HOME/registry/data/$idx_name" 2>/dev/null \
+            || cp -r "$HOME/.mcpp/registry/data/$idx_name" \
+                "$MCPP_HOME/registry/data/$idx_name"
     fi
 done
 # Pre-cached xpkg downloads so the test doesn't re-fetch the world.
 if [[ -d "$HOME/.mcpp/registry/data/xpkgs" ]]; then
     [[ -e "$MCPP_HOME/registry/data/xpkgs" ]] \
         || ln -sf "$HOME/.mcpp/registry/data/xpkgs" \
+            "$MCPP_HOME/registry/data/xpkgs" 2>/dev/null \
+        || cp -r "$HOME/.mcpp/registry/data/xpkgs" \
             "$MCPP_HOME/registry/data/xpkgs"
 fi
 
diff --git a/tests/e2e/33_multi_version_mangling.sh b/tests/e2e/33_multi_version_mangling.sh
index 5f20fae..e6c00da 100755
--- a/tests/e2e/33_multi_version_mangling.sh
+++ b/tests/e2e/33_multi_version_mangling.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: elf gcc
 # 33_multi_version_mangling.sh — Level 1 of dep resolution: when two
 # transitive consumers want incompatible (non-overlapping) versions of
 # the same package, the secondary copy is rewritten to use a mangled
diff --git a/tests/e2e/35_workspace.sh b/tests/e2e/35_workspace.sh
index 330a8c7..8bac59e 100755
--- a/tests/e2e/35_workspace.sh
+++ b/tests/e2e/35_workspace.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 set -euo pipefail
 
 # Test: workspace with two library members and one binary member.
@@ -94,7 +95,13 @@ echo "workspace build: ok"
 
 # ── Verify the binary runs correctly ────────────────────
 # target/ is created in the member dir (apps/hello/target/), not workspace root.
-BIN=$(find apps/hello/target -type f -name hello | head -1)
+# On Windows (MINGW/MSYS) the binary has a .exe suffix
+OS="$(uname -s)"
+if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then
+    BIN=$(find apps/hello/target -type f -name hello.exe | head -1)
+else
+    BIN=$(find apps/hello/target -type f -name hello | head -1)
+fi
 test -n "$BIN" || { echo "FAIL: hello binary not found"; exit 1; }
 OUT=$("$BIN" 2>&1)
 echo "output: $OUT"
diff --git a/tests/e2e/36_llvm_toolchain.sh b/tests/e2e/36_llvm_toolchain.sh
index 6e92240..bba2d13 100755
--- a/tests/e2e/36_llvm_toolchain.sh
+++ b/tests/e2e/36_llvm_toolchain.sh
@@ -1,9 +1,18 @@
 #!/usr/bin/env bash
+# requires:
 # 36_llvm_toolchain.sh — build a non-module C/C++ package with xlings LLVM.
 set -e
 
-LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7"
-if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then
+OS="$(uname -s)"
+# On Windows the clang++ binary has a .exe suffix
+if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then
+    LLVM_ROOT="${USERPROFILE}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7"
+    CLANGPP_BIN="$LLVM_ROOT/bin/clang++.exe"
+else
+    LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7"
+    CLANGPP_BIN="$LLVM_ROOT/bin/clang++"
+fi
+if [[ ! -x "$CLANGPP_BIN" ]]; then
     echo "SKIP: xlings llvm@20.1.7 is not installed"
     exit 0
 fi
@@ -16,7 +25,13 @@ source "$(dirname "$0")/_inherit_toolchain.sh"
 mkdir -p "$TMP/proj/src"
 cd "$TMP/proj"
 
-cat > mcpp.toml <<'EOF'
+if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then
+    TC_KEY="windows"
+else
+    TC_KEY="linux"
+fi
+
+cat > mcpp.toml <<EOF
 [package]
 name    = "hello_llvm"
 version = "0.1.0"
@@ -25,7 +40,7 @@ version = "0.1.0"
 import_std = false
 
 [toolchain]
-linux = "llvm@20.1.7"
+${TC_KEY} = "llvm@20.1.7"
 EOF
 
 cat > src/main.cpp <<'EOF'
@@ -63,7 +78,11 @@ grep -q 'Finished' "$TMP/build.log" || {
     exit 1
 }
 
-binary=$(find target -type f -path '*/bin/hello_llvm' | head -1)
+if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then
+    binary=$(find target -type f -path '*/bin/hello_llvm.exe' | head -1)
+else
+    binary=$(find target -type f -path '*/bin/hello_llvm' | head -1)
+fi
 [[ -n "$binary" && -x "$binary" ]] || {
     find target -maxdepth 5 -type f
     echo "FAIL: hello_llvm binary missing"
diff --git a/tests/e2e/37_llvm_import_std.sh b/tests/e2e/37_llvm_import_std.sh
index 6747b6a..51f5724 100755
--- a/tests/e2e/37_llvm_import_std.sh
+++ b/tests/e2e/37_llvm_import_std.sh
@@ -1,7 +1,17 @@
 #!/usr/bin/env bash
+# requires: import-std-libcxx
 # 37_llvm_import_std.sh — build an import-std package with xlings LLVM/libc++.
 set -e
 
+OS="$(uname -s)"
+# libc++ std.cppm is only available on Linux/macOS — on Windows there is no
+# libc++ module distribution. Exit gracefully; the import-std-libcxx capability
+# check in run_all.sh already gates this, but guard here too for direct runs.
+if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then
+    echo "SKIP: libc++ std.cppm not available on Windows"
+    exit 0
+fi
+
 LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7"
 if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then
     echo "SKIP: xlings llvm@20.1.7 is not installed"
diff --git a/tests/e2e/38_llvm_modules.sh b/tests/e2e/38_llvm_modules.sh
index 31016a4..2ce8849 100755
--- a/tests/e2e/38_llvm_modules.sh
+++ b/tests/e2e/38_llvm_modules.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: import-std-libcxx
 # 38_llvm_modules.sh — multi-module project with LLVM/Clang.
 #
 # Tests: module interface (.cppm) with `export module`, cross-module import,
@@ -6,6 +7,15 @@
 # -fmodule-output / -fprebuilt-module-path flags.
 set -e
 
+OS="$(uname -s)"
+# libc++ std.cppm is only available on Linux/macOS — on Windows there is no
+# libc++ module distribution. Exit gracefully; the import-std-libcxx capability
+# check in run_all.sh already gates this, but guard here too for direct runs.
+if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then
+    echo "SKIP: libc++ std.cppm not available on Windows"
+    exit 0
+fi
+
 LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7"
 if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then
     echo "SKIP: xlings llvm@20.1.7 is not installed"
diff --git a/tests/e2e/38_self_config_mirror.sh b/tests/e2e/38_self_config_mirror.sh
index 2ec9433..e8409dc 100755
--- a/tests/e2e/38_self_config_mirror.sh
+++ b/tests/e2e/38_self_config_mirror.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: unix-shell
 # 38_self_config_mirror.sh — configure xlings mirror through mcpp self config.
 set -e
 
diff --git a/tests/e2e/39_llvm_incremental.sh b/tests/e2e/39_llvm_incremental.sh
index d54a4e4..9842661 100755
--- a/tests/e2e/39_llvm_incremental.sh
+++ b/tests/e2e/39_llvm_incremental.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires: import-std-libcxx scan-deps
 # 39_llvm_incremental.sh — Clang per-file incremental rebuild via clang-scan-deps dyndep.
 set -e
 
diff --git a/tests/e2e/39_xlings_index_migration.sh b/tests/e2e/39_xlings_index_migration.sh
index 895381a..41962ad 100755
--- a/tests/e2e/39_xlings_index_migration.sh
+++ b/tests/e2e/39_xlings_index_migration.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # 39_xlings_index_migration.sh - legacy mcpp-index cache migrates to mcpplibs.
 set -e
 
diff --git a/tests/e2e/40_llvm_bmi_cache.sh b/tests/e2e/40_llvm_bmi_cache.sh
index 5aadb5f..16ce2f3 100755
--- a/tests/e2e/40_llvm_bmi_cache.sh
+++ b/tests/e2e/40_llvm_bmi_cache.sh
@@ -1,7 +1,17 @@
 #!/usr/bin/env bash
+# requires: import-std-libcxx
 # 40_llvm_bmi_cache.sh — Clang BMI cache reuse for dependency packages.
 set -e
 
+OS="$(uname -s)"
+# libc++ std.cppm is only available on Linux/macOS — on Windows there is no
+# libc++ module distribution. Exit gracefully; the import-std-libcxx capability
+# check in run_all.sh already gates this, but guard here too for direct runs.
+if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then
+    echo "SKIP: libc++ std.cppm not available on Windows"
+    exit 0
+fi
+
 LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7"
 if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then
     echo "SKIP: xlings llvm@20.1.7 is not installed"
@@ -23,7 +33,8 @@ USER_MCPP="${HOME}/.mcpp"
 if [[ -d "$USER_MCPP/registry/data/mcpplibs" ]]; then
     mkdir -p "$MCPP_HOME/registry/data"
     [[ -e "$MCPP_HOME/registry/data/mcpplibs" ]] \
-        || ln -sf "$USER_MCPP/registry/data/mcpplibs" "$MCPP_HOME/registry/data/mcpplibs"
+        || ln -sf "$USER_MCPP/registry/data/mcpplibs" "$MCPP_HOME/registry/data/mcpplibs" 2>/dev/null \
+        || cp -r "$USER_MCPP/registry/data/mcpplibs" "$MCPP_HOME/registry/data/mcpplibs"
 fi
 
 mkdir -p "$TMP/proj/src"
diff --git a/tests/e2e/41_llvm_std_compat.sh b/tests/e2e/41_llvm_std_compat.sh
index 9747c38..3f91d98 100755
--- a/tests/e2e/41_llvm_std_compat.sh
+++ b/tests/e2e/41_llvm_std_compat.sh
@@ -1,7 +1,17 @@
 #!/usr/bin/env bash
+# requires: import-std-libcxx
 # 41_llvm_std_compat.sh — build a project that uses import std.compat with Clang.
 set -e
 
+OS="$(uname -s)"
+# libc++ std.compat.cppm is only available on Linux/macOS — on Windows there
+# is no libc++ module distribution. Exit gracefully; the import-std-libcxx
+# capability check in run_all.sh already gates this, but guard here too.
+if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then
+    echo "SKIP: libc++ std.compat.cppm not available on Windows"
+    exit 0
+fi
+
 LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7"
 if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then
     echo "SKIP: xlings llvm@20.1.7 is not installed"
diff --git a/tests/e2e/42_custom_local_index.sh b/tests/e2e/42_custom_local_index.sh
index 8387afe..f8eeba5 100755
--- a/tests/e2e/42_custom_local_index.sh
+++ b/tests/e2e/42_custom_local_index.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # Custom [indices] parsing: a local path index is parsed from mcpp.toml
 # and visible in `mcpp index list`. Verifies the TOML parsing path for
 # short form, long form, and local path indices without requiring any
diff --git a/tests/e2e/43_indices_lockfile.sh b/tests/e2e/43_indices_lockfile.sh
index fe0b661..70dcb11 100755
--- a/tests/e2e/43_indices_lockfile.sh
+++ b/tests/e2e/43_indices_lockfile.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # Lockfile v2 + index pin/unpin: verify lockfile format, v1 migration,
 # and `mcpp index pin` / `mcpp index unpin` CLI commands.
 # No network access required — uses local path indices and synthetic lockfiles.
diff --git a/tests/e2e/44_indices_e2e_integration.sh b/tests/e2e/44_indices_e2e_integration.sh
index 1755961..f4b7e1e 100755
--- a/tests/e2e/44_indices_e2e_integration.sh
+++ b/tests/e2e/44_indices_e2e_integration.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+# requires:
 # E2E integration test for [indices] feature gaps:
 #   1. Local path index discovery via `mcpp index list`
 #   2. Workspace inherits [indices] from root
diff --git a/tests/e2e/_inherit_toolchain.sh b/tests/e2e/_inherit_toolchain.sh
index 2968963..e870433 100644
--- a/tests/e2e/_inherit_toolchain.sh
+++ b/tests/e2e/_inherit_toolchain.sh
@@ -13,26 +13,34 @@ if [[ -z "${MCPP_HOME:-}" ]]; then
 fi
 mkdir -p "$MCPP_HOME"
 
+# On Windows, HOME may differ from USERPROFILE; try both
 USER_MCPP="${HOME}/.mcpp"
+if [[ ! -d "$USER_MCPP" && -n "${USERPROFILE:-}" ]]; then
+    USER_MCPP="$USERPROFILE/.mcpp"
+fi
 if [[ -d "$USER_MCPP/registry/data/xpkgs" ]]; then
     mkdir -p "$MCPP_HOME/registry/data"
     [[ -e "$MCPP_HOME/registry/data/xpkgs" ]] \
-        || ln -sf "$USER_MCPP/registry/data/xpkgs" "$MCPP_HOME/registry/data/xpkgs"
+        || ln -sf "$USER_MCPP/registry/data/xpkgs" "$MCPP_HOME/registry/data/xpkgs" 2>/dev/null \
+        || cp -r "$USER_MCPP/registry/data/xpkgs" "$MCPP_HOME/registry/data/xpkgs"
 fi
 if [[ -d "$USER_MCPP/registry/data/xim-pkgindex" ]]; then
     mkdir -p "$MCPP_HOME/registry/data"
     [[ -e "$MCPP_HOME/registry/data/xim-pkgindex" ]] \
-        || ln -sf "$USER_MCPP/registry/data/xim-pkgindex" "$MCPP_HOME/registry/data/xim-pkgindex"
+        || ln -sf "$USER_MCPP/registry/data/xim-pkgindex" "$MCPP_HOME/registry/data/xim-pkgindex" 2>/dev/null \
+        || cp -r "$USER_MCPP/registry/data/xim-pkgindex" "$MCPP_HOME/registry/data/xim-pkgindex"
 fi
 if [[ -d "$USER_MCPP/registry/data/xim-index-repos" ]]; then
     mkdir -p "$MCPP_HOME/registry/data"
     [[ -e "$MCPP_HOME/registry/data/xim-index-repos" ]] \
-        || ln -sf "$USER_MCPP/registry/data/xim-index-repos" "$MCPP_HOME/registry/data/xim-index-repos"
+        || ln -sf "$USER_MCPP/registry/data/xim-index-repos" "$MCPP_HOME/registry/data/xim-index-repos" 2>/dev/null \
+        || cp -r "$USER_MCPP/registry/data/xim-index-repos" "$MCPP_HOME/registry/data/xim-index-repos"
 fi
 if [[ "${MCPP_INHERIT_SUBOS:-1}" != "0" && -d "$USER_MCPP/registry/subos" ]]; then
     mkdir -p "$MCPP_HOME/registry"
     [[ -e "$MCPP_HOME/registry/subos" ]] \
-        || ln -sf "$USER_MCPP/registry/subos" "$MCPP_HOME/registry/subos"
+        || ln -sf "$USER_MCPP/registry/subos" "$MCPP_HOME/registry/subos" 2>/dev/null \
+        || cp -r "$USER_MCPP/registry/subos" "$MCPP_HOME/registry/subos"
 fi
 if [[ "${MCPP_INHERIT_CONFIG:-1}" != "0" && -f "$USER_MCPP/config.toml" ]]; then
     cp -f "$USER_MCPP/config.toml" "$MCPP_HOME/config.toml" 2>/dev/null || true
@@ -40,5 +48,6 @@ fi
 if [[ -d "$USER_MCPP/bin" ]]; then
     mkdir -p "$MCPP_HOME"
     [[ -e "$MCPP_HOME/bin" ]] \
-        || ln -sf "$USER_MCPP/bin" "$MCPP_HOME/bin"
+        || ln -sf "$USER_MCPP/bin" "$MCPP_HOME/bin" 2>/dev/null \
+        || cp -r "$USER_MCPP/bin" "$MCPP_HOME/bin"
 fi
diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh
index ada5c7e..a09d851 100755
--- a/tests/e2e/run_all.sh
+++ b/tests/e2e/run_all.sh
@@ -31,43 +31,94 @@ if [[ -z "${MCPP_HOME:-}" ]]; then
 fi
 echo "MCPP_HOME: $MCPP_HOME"
 
-# Platform detection: some tests are Linux-only (ELF patchelf, musl-static,
-# GCC-specific BMI layout, etc.)
+# ---------------------------------------------------------------------------
+# Capability detection
+# ---------------------------------------------------------------------------
+# Build the set of capabilities available on this machine/platform.
+# Each test declares its needs via a `# requires: cap1 cap2 ...` comment
+# on line 2.  Tests with no requirements run everywhere.
+
+CAPS=()
 OS="$(uname -s)"
-MACOS_SKIP=(
-    # GCC-specific BMI assertions (gcm.cache/*.gcm)
-    03_multi_module.sh
-    # Static library test checks ELF ar output format
-    07_static_library.sh
-    # Shared library test hardcodes .so / ELF shared object
-    08_shared_library.sh
-    # Path dependency checks .gcm BMI format (GCC-specific)
-    09_path_dependency.sh
-    # Pack modes use patchelf (ELF-only)
-    30_pack_modes.sh
-    # Toolchain management tests assume GCC availability
-    26_toolchain_management.sh
-    29_toolchain_partial_versions.sh
-    # P1689 scanner test hardcodes GCC ddi format
-    20_p1689_scanner.sh
-    # Ninja dyndep test hardcodes GCC module format
-    21_ninja_dyndep.sh
-    # Doctor/cache/publish uses GCC fingerprint
-    22_doctor_cache_publish.sh
-    # Self-contained home test assumes Linux sandbox layout
-    27_self_contained_home.sh
-    # Multi-version mangling test uses GCC module format
-    33_multi_version_mangling.sh
-)
-
-should_skip() {
-    local name="$1"
-    if [[ "$OS" == "Darwin" ]]; then
-        for skip in "${MACOS_SKIP[@]}"; do
-            [[ "$name" == "$skip" ]] && return 0
-        done
-    fi
-    return 1
+
+case "$OS" in
+    Linux)
+        CAPS+=(elf unix-shell fresh-sandbox)
+        command -v g++      &>/dev/null && CAPS+=(gcc)
+        command -v patchelf &>/dev/null && CAPS+=(patchelf)
+        # musl-gcc: check both system PATH and xlings-managed locations
+        if command -v x86_64-linux-musl-g++ &>/dev/null \
+           || [[ -x "$HOME/.xlings/data/xpkgs/xim-x-musl-gcc/15.1.0/bin/x86_64-linux-musl-g++" ]] \
+           || [[ -x "${MCPP_HOME}/registry/data/xpkgs/xim-x-musl-gcc/15.1.0/bin/x86_64-linux-musl-g++" ]]; then
+            CAPS+=(musl)
+        fi
+        # pack capability: ELF + patchelf both required
+        if [[ " ${CAPS[*]} " == *" patchelf "* ]]; then
+            CAPS+=(pack)
+        fi
+        ;;
+    Darwin)
+        CAPS+=(unix-shell fresh-sandbox)
+        # macOS g++ is Apple Clang, not real GCC — don't add gcc capability.
+        # Tests requiring gcc need actual GNU GCC (modules, gcm.cache, etc.)
+        ;;
+    MINGW* | MSYS* | CYGWIN*)
+        # Git Bash / MSYS2 on Windows: symlinks need admin or Developer Mode
+        if [[ "${MSYS:-}" == *winsymlinks* ]] || cmd.exe /c "mklink /?" &>/dev/null 2>&1; then
+            CAPS+=(symlink)
+        fi
+        # NOTE: Windows runners may have g++.exe (MinGW/Strawberry) in PATH
+        # but it's not a proper mcpp-compatible GCC. Don't add gcc capability.
+        ;;
+esac
+
+# symlink: ln -sf works properly on all non-Windows platforms
+case "$OS" in
+    Linux|Darwin) CAPS+=(symlink) ;;
+esac
+
+# scan-deps: clang-scan-deps available (needed for P1689 / Clang dyndep flows)
+if command -v clang-scan-deps &>/dev/null \
+   || ls "${MCPP_HOME}/registry/data/xpkgs/xim-x-llvm"/*/bin/clang-scan-deps 2>/dev/null | head -1 | grep -q . \
+   || ls "${MCPP_HOME}/registry/data/xpkgs/xim-x-llvm"/*/bin/clang-scan-deps.exe 2>/dev/null | head -1 | grep -q .; then
+    CAPS+=(scan-deps)
+fi
+
+# import-std-libcxx: libc++ std.cppm available (LLVM with libc++ modules)
+if ls "${MCPP_HOME}/registry/data/xpkgs/xim-x-llvm"/*/share/libc++/v1/std.cppm 2>/dev/null | head -1 | grep -q .; then
+    CAPS+=(import-std-libcxx)
+fi
+
+echo "Detected capabilities: ${CAPS[*]:-<none>}"
+
+# ---------------------------------------------------------------------------
+# Helper: check if a test's requirements are satisfied
+# ---------------------------------------------------------------------------
+# Returns 0 (true) if the test should be skipped, prints reason.
+# Returns 1 (false) if all requirements are met.
+
+check_requires() {
+    local test_file="$1"
+    # Read the # requires: line (must be line 2 of the script)
+    local req_line
+    req_line="$(sed -n '2p' "$test_file")"
+
+    # If there's no requires comment at all, run the test
+    [[ "$req_line" =~ ^#\ requires: ]] || return 1
+
+    local caps_needed="${req_line#\# requires:}"
+    caps_needed="${caps_needed# }"   # strip leading space
+
+    # Empty requirements → runs everywhere
+    [[ -z "$caps_needed" ]] && return 1
+
+    for cap in $caps_needed; do
+        if [[ " ${CAPS[*]} " != *" $cap "* ]]; then
+            echo "$cap"   # return the missing capability name
+            return 0      # should skip
+        fi
+    done
+    return 1  # all satisfied → don't skip
 }
 
 PASS=0
@@ -78,8 +129,9 @@ FAILED_TESTS=()
 for test in "$HERE"/[0-9]*.sh; do
     name="$(basename "$test")"
     echo
-    if should_skip "$name"; then
-        echo "SKIP: $name (not applicable on $OS)"
+    missing_cap="$(check_requires "$test")"
+    if [[ -n "$missing_cap" ]]; then
+        echo "SKIP: $name (missing capability: $missing_cap)"
         ((SKIP++))
         continue
     fi
