Skip to content

Add Windows install/uninstall scripts and venv support - #12

Open
ImperatorRuscal wants to merge 4 commits into
capitalone:mainfrom
ImperatorRuscal:feature/windows-installer
Open

Add Windows install/uninstall scripts and venv support#12
ImperatorRuscal wants to merge 4 commits into
capitalone:mainfrom
ImperatorRuscal:feature/windows-installer

Conversation

@ImperatorRuscal

Copy link
Copy Markdown

Summary

  • Adds install.cmd / uninstall.cmd so Windows users can install/remove
    the skills without WSL or a POSIX shell (mirrors the existing
    install.sh / uninstall.sh behavior).
  • Updates vulnhunter-fix/scripts/_skill_bootstrap.py to resolve the
    bundled venv's Windows layout (.venv\Scripts\python.exe and
    .venv\Lib\site-packages) instead of only checking the POSIX
    .venv/bin/python3 path, so the interpreter/site-packages bootstrap
    works correctly on Windows.
  • Documents the Windows install flow in the README.

Test plan

  • Ran install.cmd on Windows (cmd.exe), confirmed it copies the
    skills into %USERPROFILE%\.claude\skills\ and creates the
    vulnhunter-fix venv at .venv\Scripts\python.exe with
    .venv\Lib\site-packages populated.
  • Verified _skill_bootstrap.py's Windows path branch matches the
    venv layout actually produced by the install.
  • Ran uninstall.cmd on Windows; completed without errors and
    removed the installed skills as expected.

Adds install.cmd/uninstall.cmd for Windows users, updates
_skill_bootstrap.py to resolve the bundled venv's Windows paths
(.venv\Scripts\python.exe, .venv\Lib\site-packages), documents the
Windows install flow in the README, and ignores the local pip
download cache created by install.cmd.
@ImperatorRuscal
ImperatorRuscal requested a review from a team as a code owner July 20, 2026 15:18
@CLAassistant

CLAassistant commented Jul 20, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@schenksj schenksj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — Windows support is a real gap and the batch scripting here is unusually careful. I walked the setlocal/endlocal pairing, delayed-expansion inheritance into the subroutines, parse-time vs. runtime expansion inside every parenthesized block, caret escaping, robocopy's 0–7 success range, and subroutine fall-through, and found no defects in the cmd mechanics. The if not !ERRORLEVEL!==0 idiom and the comment explaining why if errorlevel N misreads negative CRT exit codes are both correct, and the USERPROFILE guard faithfully mirrors the HOME guard's rationale. Structure, naming, and error-message style track install.sh closely.

The problems aren't in the batch — they're in what the _skill_bootstrap.py change activates.

Blocking

os.execv does not block on Windows. Before this PR, _VENV_PY was .venv/bin/python3, which never exists on Windows, so the re-exec branch was dead there and Windows users got a loud ImportError with a nonzero exit — gates failed correctly. Pointing _VENV_PY at .venv\Scripts\python.exe turns that branch on. Windows has no real exec; CPython routes os.execv through the CRT's _wexecv (_wspawnv(_P_OVERLAY, …)), which CreateProcess-es the child and terminates the caller immediately — the caller's waiter sees the parent's exit status (0) while the child runs detached. This is long-standing documented behavior (bpo-19124, "os.exec* on Windows runs the new process in the background").

That matters here because script exit codes are workflow gates: preflight.py, validate-finding.py, validate-fix-plan.py, validate-result.py, validate-triage.py, build_graph.py, language-detect.py, and parse-tier-judgment.py all import _skill_bootstrap first and are consumed for their status, and run-gates.py:294 reads gate rc via subprocess. On Windows those gates would report success unconditionally.

It's also not an occasional-mismatch path. Windows venvs copy python.exe rather than symlinking it, so _same_interpreter() is false for every out-of-venv invocation — including the exact base interpreter that built the venv. Post-merge, the broken path becomes the default Windows path. See the inline notes on _skill_bootstrap.py and install.cmd:121; those two fixes are ordered dependencies and need to land together.

Should fix

preflight.py:382-383 wasn't updated (out of diff, so no inline). It still hardcodes .venv/bin/python3 and install.sh, so on Windows the first branch can never match and the hint falls through to "No installed skill at ~/.claude/skills/vulnhunter-fix/ … bash install.sh". Since the hint only prints when deps genuinely fail to import, the audience is a Windows user with a broken venv on an otherwise-installed skill — told nothing is installed, and told to run bash. That's exactly the dead-end the function's docstring says it exists to prevent. Worth mirroring the os.name == "nt" branch and emitting install.cmd.

While you're in there: the elif os.path.isfile(install_sh) branch at preflight.py:397 is dead on every platform — install.sh lives at repo root and is never copied into the installed skill dir by either installer. Pre-existing, but adjacent.

Minor

_same_interpreter() compares realpath results case-sensitively. On CPython ≥3.8 ntpath.realpath returns canonical on-disk case for existing files, and both operands are guaranteed to exist, so this only bites on the resolution fallback (permissions, exotic paths) — and on Windows the comparison is already false for the copied-exe reason above. os.path.normcase() is still correct hardening, just low priority.

One more class of bug the POSIX scripts don't have: with EnableDelayedExpansion active, a USERPROFILE containing ! corrupts paths during %-expansion inside blocks. Rare enough that I wouldn't block on it.

Testing

The repo has no CI, so nothing lints the batch files or exercises the bootstrap's Windows branch — and that branch is unreachable from CI regardless, since it's gated on os.name == "nt". A cheap improvement would be factoring the path construction into a helper that takes the OS name as a parameter, making both layouts unit-testable on any platform.

On the test plan: bullet 3 ("verified _skill_bootstrap.py's Windows path branch matches the venv layout") can't have exercised what it claims, for the reason in the install.cmd:121 comment — the smoke test cannot currently fail on Windows. Worth re-running the full plan once the exec fix lands.

Also flagging scope, not as a blocker: installing on Windows doesn't yet mean the skills run there. vulnhunter-fix/SKILL.md Step 0b's staleness check is a bash snippet, and harness/ and vulnhunter-agent/ still assume POSIX.

Security

Nothing concerning. Every rmdir /s /q target derives from %USERPROFILE%\.claude\skills\<literal-name> where the name comes from a hardcoded list — no argument, stdin, or other environment input reaches a delete path, and both scripts guard on USERPROFILE being set first. robocopy preserves the deliberate copy-not-symlink decision, and the pip specifiers are the same pinned, bounded set install.sh uses.

Happy to re-review once the exec/smoke-test pair is sorted.


Disclosure: this review was prepared with AI assistance (Claude Code). The Windows-specific runtime claims — os.execv/CRT exec emulation, venv interpreter copy-vs-symlink, PowerShell command discovery — were derived from the code plus documented platform behavior and cross-checked by a second model, but were not executed on Windows. Please verify them against a real Windows run before acting. Everything else was checked directly against this checkout.

Comment thread vulnhunter-fix/scripts/_skill_bootstrap.py
Comment thread install.cmd Outdated
Comment thread install.cmd Outdated
Comment thread install.cmd Outdated
Comment thread .gitignore Outdated
Comment thread README.md
os.execv doesn't block on Windows (CPython routes it through the CRT's
_wexecv/_wspawnv, which returns control to the caller with the parent's
exit status while the child runs detached). Pointing _skill_bootstrap's
re-exec at the bundled venv turned this dead-on-POSIX-only branch into
the default Windows path, silently making every gate/smoke-test that
depends on this script's exit code report success unconditionally.

- _skill_bootstrap.py: use a blocking subprocess on Windows instead of
  os.execv; also skip re-exec for `-c` invocations, which would lose
  the code argument.
- install.cmd: smoke-test the venv's own interpreter directly instead
  of routing through the (previously broken) re-exec.
- install.cmd: move the Python-3.11+ requirement into build_vulnfix_venv
  so vulnhunt/vulnhunt-fix-verify (which need no venv) aren't blocked by
  a missing interpreter, restoring parity with install.sh.
- install.cmd: dedupe the dep-pin string, check the pip self-upgrade's
  exit code, normcase the interpreter comparison.
- preflight.py: bootstrap hint now branches on os.name=="nt" and points
  Windows users at install.cmd; also fixes a dead branch (it checked
  for install.sh under the installed skill dir, which is never copied
  there by either installer) by checking for SKILL.md instead.
- preflight.py: check_memory/check_disk_space used os.sysconf/statvfs,
  which don't exist on Windows and crashed preflight.py unconditionally
  there, independent of anything above. Added GlobalMemoryStatusEx/
  GetDiskFreeSpaceExW-based implementations via ctypes.
- .gitignore, README.md: drop a stray pip/ ignore entry unrelated to
  anything install.cmd creates, and use .\install.cmd/.\uninstall.cmd
  so the documented commands work in PowerShell too.
@ImperatorRuscal

Copy link
Copy Markdown
Author

Addressed the listed concerns, thank you for the check and feedback.

While working through I also found part of preflight that should normally fail on Windows (my system has some existing workarounds that hid it from me initially). So I put a bit of branching in preflight when it checks disk space & RAM to call native MS Win handles in order to get good values. This would have been a block against full execution for most Windows environments (without special workarounds) -- but I have now tested in a vanilla sandbox and can confirm it works on both the install & uninstall path.

Asked Claude Code to do a Review based on the Requested Changes / comments from the PR and here are the results of that


PR #12 review items — status

# Location Ask Resolved in c9b4b7c
1 Blocking — review body + _skill_bootstrap.py:40 os.execv doesn't block on Windows → gates/smoke-tests silently report success ✅ Blocking subprocess.run on Windows, os.execv kept on POSIX
2 Same comment, secondary latent bug -c invocations lose the code arg on re-exec ✅ Guarded with sys.argv[0] != "-c"
3 Blockinginstall.cmd:122 Smoke test can't fail on Windows (rides the same broken re-exec) ✅ Now runs .venv\Scripts\python.exe directly, bypassing re-exec entirely
4 Should fixinstall.cmd:27 Requiring Python up front regresses install.sh parity (blocks vulnhunt/vulnhunt-fix-verify, breaks hotfix-branch skip path) ✅ Moved into build_vulnfix_venv
5 Should fix — review body, preflight.py:382-383 Hint hardcodes .venv/bin/python3 / install.sh, gives Windows users a "not installed" dead end ✅ Branches on os.name == "nt"
6 Same comment, "while you're in there" elif os.path.isfile(install_sh) is dead on every platform (never copied into skill dir) ✅ Switched to checking SKILL.md; also fixed the two sibling hint messages, which had the identical latent bug of pointing at a nonexistent path
7 Minor — review body _same_interpreter should normcase() ✅ Added
8 Minorinstall.cmd:114 Dep pins duplicated (echo vs. install); pip self-upgrade result unchecked ✅ Deduped into VULNFIX_DEPS; added errorlevel check
9 Minor.gitignore:60 pip/ ignore entry doesn't match anything install.cmd creates ✅ Removed
10 MinorREADME.md:87 Bare install.cmd/uninstall.cmd fail in PowerShell (cwd excluded from command search) ✅ Changed to .\install.cmd / .\uninstall.cmd

Additional fixes beyond the current PR discussion

These were not raised anywhere in the PR #12 review thread — found while verifying the fixes above by actually running preflight.py live on Windows, and they'd have blocked the exact goal this PR is going for (working Windows execution) even after every review comment was addressed:

  1. check_memory() crashed unconditionally on Windows. It called os.sysconf, which doesn't exist in CPython's Windows build (no sysconf() in the CRT for it to wrap) — not a runtime failure, the attribute is simply absent. The existing except (ValueError, OSError) doesn't catch AttributeError, so it wasn't a mishandled error case, it was an unguarded crash that took down all of main() before any results could be reported. Fixed with a GlobalMemoryStatusEx-via-ctypes implementation; verified live it returns the exact correct RAM figure (matched Get-CimInstance Win32_ComputerSystem byte-for-byte).
  2. check_disk_space() had the identical bug, one function below it — os.statvfs, also POSIX-only, with no try/except guarding it at all. Fixed the same way with GetDiskFreeSpaceExW; verified against Get-PSDrive, exact match.
  3. Ran preflight.py's full main() on Windows before and after: before, it crashed with an unhandled AttributeError traceback on the first Windows run regardless of any PR fix; after, it completes normally and reports a real pass/fail count.

@ImperatorRuscal
ImperatorRuscal requested a review from schenksj July 21, 2026 16:59

@schenksj schenksj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at c9b4b7c. All ten items from the previous round are addressed, and both blockers are fixed correctly and in the right order. The two extra bugs you found while verifying — os.sysconf / os.statvfs being absent on Windows — are real and were genuinely fatal: check_memory's except (ValueError, OSError) doesn't catch AttributeError, and check_disk_space had no guard at all, so main() died before printing anything. Good catches, and exactly the class of thing that only surfaces by running it.

Verified

  • 1/2 — _skill_bootstrap.py. Blocking subprocess.run on Windows, os.execv retained on POSIX, and the exit code propagated via sys.exit(...returncode). This also makes run-gates.py:294 correct on Windows: the child's sys.executable is now the venv interpreter, so the per-gate subprocess invocations inherit it as the comment at run-gates.py:290-292 assumes. -c guard is in place.
  • 3 — install.cmd:131. Smoke test runs "%venv%\Scripts\python.exe" directly. Bypasses the re-exec entirely, so it can now actually fail.
  • 4 — install.cmd:91-97. find_python moved into :build_vulnfix_venv with a correct endlocal & exit /b 1. install.sh parity restored: a Windows box without Python 3.11+ can install the two prompt-only skills again, and the hotfix-branch skip path at line 50 is reachable.
  • 5/6 — preflight.py:430-470. Branches on os.name; the dead install.sh branch is replaced with a SKILL.md check, and all three hint messages now point at the source checkout rather than a path that was never there.
  • 7 normcase added. 8 VULNFIX_DEPS deduped, pip self-upgrade errorlevel checked. 9 .gitignore entry dropped. 10 .\install.cmd.

Re-walked the batch mechanics on the new code. set VULNFIX_DEPS="jsonschema>=4.18" "graphifyy>=0.8.14,<0.9.0" is safe: the > and < sit inside double quotes in the raw line so redirection parsing never sees them, and because the quotes are part of the value, they survive %-expansion and protect the same characters again at line 121 and line 122. setlocal EnableDelayedExpansion at line 87 is a no-op change (the mode was already inherited from line 2) but it's clearer to state it.

Ran preflight.py on macOS / CPython 3.13 as a POSIX regression check for the ctypes refactor — 9 passed, 2 failed (expected: no graphifyy/jsonschema in that env), memory and disk both report real figures, and the bootstrap hint renders the POSIX branch correctly. No regression from the extraction.

Should fix

1. preflight.py:440 hands PowerShell users the bare form that item #10 just removed from the README.

run_install_hint = "    install.cmd"

.\install.cmd is the form that works in both shells — same reasoning as the README fix, same audience.

2. check() throws away detail on the success path, so the disk-space fallback is invisible.

check() only appends detail inside the else branch (preflight.py:31-42). So the new check("Disk space", True, "cannot determine — skipping check") at line 137 prints a bare [ok] Disk space — a Windows user whose GetDiskFreeSpaceExW call fails is told the check passed, with no hint it was skipped. Fold the note into the name, the way check_memory's success path already does:

check("Disk space (could not determine — skipped)", True)

Line 183's check("Memory", True, "cannot determine — ...") has the identical problem and predates this PR.

3. GetDiskFreeSpaceExW is reading the wrong output parameter.

free_bytes is passed in the 4th slot — lpTotalNumberOfFreeBytes, total free on the volume. The POSIX side uses f_bavail, which is space available to an unprivileged caller. The direct analogue is the 2nd parameter, lpFreeBytesAvailableToCaller. Under a disk quota the two diverge and the check gets more optimistic than its POSIX twin. One-argument change:

if not ctypes.windll.kernel32.GetDiskFreeSpaceExW(
    ctypes.c_wchar_p(os.path.abspath(path)), ctypes.byref(free_bytes), None, None
):

Minor

  • preflight.py:165 — the last MEMORYSTATUSEX field is spelled sullAvailExtendedVirtual; the Win32 name is ullAvailExtendedVirtual. Purely cosmetic: the layout is unaffected (4 + 4 + 7×8 = 64, which is the dwLength the API expects), so the call is correct as written.

  • install.sh:68's comment still reads "re-execs under venv python if needed". With the new sys.argv[0] != "-c" guard that smoke test can no longer re-exec by construction, so the comment is now false.

  • The Windows smoke test covers strictly less than the POSIX one: it proves the deps landed in the venv, but no longer that _skill_bootstrap imports at all (a syntax error there would sail through). You can have both — run the venv interpreter and import the bootstrap. Under the venv Python _same_interpreter() is true, so no re-exec fires and there's no detach risk:

    "%venv%\Scripts\python.exe" -c "import sys; sys.path.insert(0, r'%skill_dir%\scripts'); import _skill_bootstrap; import jsonschema, graphify" >nul 2>&1
  • The dep echo now prints the pins with their quotes ("jsonschema>=4.18" ...), where install.sh prints them bare. Cosmetic drift.

Follow-ups, not blockers

  • vulnhunt/SKILL.md:331, vulnhunt-fix-verify/SKILL.md:89, and vulnhunter-fix/SKILL.md:123/137/138 still instruct users to run install.sh / ./install.sh — including the Step 0b staleness-check remediation, which is the one a Windows user is most likely to hit. preflight.py got its Windows branch; these didn't. Docs-only, fine as a separate PR.
  • Still no CI, and this commit adds two more Windows-only ctypes blocks that nothing on Linux/macOS can exercise. Same suggestion as last round: parameterizing the OS-dependent bits would at least make them unit-testable everywhere.
  • ! in %USERPROFILE% under EnableDelayedExpansion. Still rare enough not to block on.

Security

Unchanged from the last pass and still clean. The new ctypes calls are read-only system queries with no caller-controlled input reaching them; os.path.abspath(".") is the only argument. Every delete target still derives from %USERPROFILE%\.claude\skills\<literal>.

Approving — the blockers are resolved and everything above is small. Thanks for the thorough turnaround, and for going and running it rather than just patching to the review.


Disclosure: prepared with AI assistance (Claude Code). I have no Windows host here, so the batch-parsing and Win32/ctypes claims above are from static reading plus documented platform behavior, not execution. The POSIX regression run of preflight.py, the line references, and the check() behavior were verified directly against this checkout.

@schenksj schenksj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching to Changes Requested — narrowly, for should-fix item 2 from my previous review. Everything else in that review stands: both original blockers are correctly fixed and I'm satisfied with the rest. This is the one item I'd like to see land before merge, because it's the difference between a Windows user learning a check was skipped and being told it passed.

The fix belongs in check() itself, not at the call sites

preflight.py:31-42 only appends detail inside the failure branch:

if passed:
    CHECKS_PASSED += 1
    print(f"  [ok] {name}")
else:
    if not optional:
        CHECKS_FAILED += 1
    msg = f"  [WARN] {name}" if optional else f"  [FAIL] {name}"
    if detail:
        msg += f" — {detail}"
    print(msg)

So the new check("Disk space", True, "cannot determine — skipping check") at line 137 renders as a bare [ok] Disk space. On a Windows box where GetDiskFreeSpaceExW fails, the operator is told the 5 GB requirement passed when it was never evaluated — and preflight exists precisely to stop the pipeline running into that.

I walked all 29 check() call sites in the file to confirm this is safe to fix centrally. Every one of them either passes False, passes no detail at all, or guards the detail with the file's existing ... if not passed else "" idiom. The only two that pass a non-empty detail alongside passed=True are the two "cannot determine" fallbacks — line 137 and line 183. So mirroring the failure branch changes exactly those two lines of output and nothing else:

def check(name: str, passed: bool, detail: str = "", optional: bool = False):
    global CHECKS_PASSED, CHECKS_FAILED
    if passed:
        CHECKS_PASSED += 1
        msg = f"  [ok] {name}"
    else:
        if not optional:
            CHECKS_FAILED += 1
        msg = f"  [WARN] {name}" if optional else f"  [FAIL] {name}"
    if detail:
        msg += f" — {detail}"
    print(msg)

Fixing it here rather than folding the note into the name argument (which is what I suggested last round) also repairs line 183's Memory fallback, which has had the same silent-skip behaviour since before this PR — and which your AttributeError addition at line 182 makes reachable on Windows for the first time. Two fallbacks, one place.

Worth a line in the test plan too: on a machine where both queries succeed there's no observable difference, so the way to check this is to force the exception path once and confirm the reason actually prints.

The other two should-fix items from the last round — the bare install.cmd hint at line 440, and the GetDiskFreeSpaceExW out-parameter (4th slot is total-free; the POSIX side's f_bavail maps to the 2nd, free-to-caller) — are still worth doing, but I'm not blocking on either. Ping me when this one's in and I'll re-approve.

…nd [ok]

check() only appended `detail` on the failure branch, so the disk-space
and memory "cannot determine" fallbacks (passed=True with an explanatory
detail) rendered as a bare [ok] with no indication the check was actually
skipped. Mirror the detail-append logic into the success branch so the
reason always reaches the operator.
- The bootstrap hint printed a bare `install.cmd`, which PowerShell
  can't resolve since it excludes cwd from command discovery (same
  issue already fixed in README.md). Use `.\install.cmd` to match.
- _free_disk_bytes read GetDiskFreeSpaceExW's lpTotalNumberOfFreeBytes
  (4th out-param, quota-blind) instead of lpFreeBytesAvailableToCaller
  (2nd out-param), overreporting how much space is actually usable and
  diverging from the POSIX side's statvfs f_bavail (quota-aware).
@ImperatorRuscal

Copy link
Copy Markdown
Author

@schenksj -- I have pushed the fix for the blocking item plus both non-blocking should-fix items from your latest review:

  • 484bf63 — check() now appends detail on the success path too, mirroring the failure branch exactly as you suggested. Verified live that the memory/disk-space "cannot determine" fallbacks now print their reason instead of a bare [ok].
  • 65eb516 — the two remaining should-fix items:
    • Bootstrap hint now prints .\install.cmd instead of the bare form, matching the README fix.
    • _free_disk_bytes now reads GetDiskFreeSpaceExW's lpFreeBytesAvailableToCaller (2nd out-param, quota-aware) instead of lpTotalNumberOfFreeBytes (4th, quota-blind), matching POSIX statvfs.f_bavail semantics.

Also added regression tests per your note about forcing the fallback path: TestCheckDetailReporting exercises check() directly and both real call sites with their helpers monkeypatched to raise, and TestDiskSpaceQuotaAware fakes GetDiskFreeSpaceExW to prove the correct out-parameter slot is read (skipped on non-Windows).

Ready for re-review whenever you get a chance.

@ImperatorRuscal
ImperatorRuscal requested a review from schenksj July 29, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants