Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4.8 KiB
Windows: a real install story — shell selection, install.ps1, and a win-x64 release package
The audit found the dependency graph already clean for Windows (node:sqlite, pure-JS tar, no native modules) and npm install -g @prismshadow/penguin-cli already yields a working penguin — but every exec_command died with spawn bash ENOENT, because the command session hardcoded spawn("bash", ["-lc", cmd]). That one line was the product-level blocker; the rest was packaging and honesty.
The agent actually works: shell selection
Command sessions now resolve their shell once per process: POSIX stays bash -lc bit for bit; on Windows the resolver probes PATH for Git-Bash first (best compatibility with the POSIX-oriented skill ecosystem; a bash that resolves into the Windows system directory is rejected — that's the WSL launcher, a different filesystem view entirely), then pwsh, then powershell (-NoLogo -NoProfile -Command). PENGUIN_SHELL overrides everywhere, with the argument shape inferred from the basename. The chosen shell is announced to the model through a new Shell: line in the session environment, so it writes commands in the syntax it actually has. Existing Agents whose system_config.yaml predates the {{SHELL}} placeholder get the same line through a narrow assembly-time fallback (win32 only, in-memory, no migration; see the backward-compatibility entry) — without it their models would keep emitting bash into PowerShell forever. Termination follows the platform: POSIX keeps process-group signal escalation; Windows kills the whole tree via taskkill /pid <pid> /t /f (no console signal delivery to piped children, so input_command's Ctrl-C degrades to a hard kill — documented rather than pretended away).
A sandbox gap the new CI caught
Windows has no O_NOFOLLOW, and the workspace upload path's (O_NOFOLLOW ?? 0) silently erased the final-segment symlink guard there — a practical "preset a symlink, overwrite a file outside the Workspace by upload" escape. Uploads on win32 now refuse a final-segment symlink via lstat (best-effort against the race; POSIX keeps the atomic open-time guarantee).
Install and release
install.ps1 at the repo root mirrors install.sh: PENGUIN_VERSION / PENGUIN_INSTALL_DIR knobs, SHA256 verification, a staged rename-then-delete swap that never touches data\, generated penguin.cmd / penguin.ps1 shims (both CRLF), and a user-Path update that reads and writes the registry value raw with its kind preserved — the naive [Environment] API expands REG_EXPAND_SZ on read and writes back REG_SZ, irreversibly hard-coding a user's %USERPROFILE%-style entries. The stable URL https://penguin.ooo/install.ps1 is a forwarder that downloads fully before executing — a truncated stream can't half-install. The release workflow gains penguin-win32-x64.zip (official Windows Node bundled, node.exe at the archive root, CRLF launchers) plus its checksum, and uploads install.ps1 alongside install.sh. Upgrading is re-running the installer; in-place penguin update still refuses on Windows, and the docs say so. The one-liner is irm https://penguin.ooo/install.ps1 | iex, with npm install -g (Node ≥ 24) as the script-free alternative; the landing page shows both install rows and the README roadmap checks off Windows support.
Keeping it true: CI and the long tail
A ci-windows job (windows-latest, full build/typecheck/test plus a PowerShell parse gate) now runs beside the required Ubuntu job; getting it green surfaced four genuine Windows findings (file-lock EBUSY on cleanup, a wrong timeout-test premise, the symlink gap above, path-separator test assumptions), all fixed. .gitattributes pins LF (with CRLF for .ps1) so a Windows checkout can't fail the Prettier gate or corrupt shell scripts. Cross-platform env handling replaces the POSIX-only VAR=x npm-script prefixes with a small runner script, penguin config lang refuses cleanly on win32 (pointing at setx PENGUIN_LANG), and the installer was functionally exercised with real PowerShell against a local fake release: fresh install, upgrade preserving data\, checksum-mismatch abort, and the full irm | iex chain. Known and documented limits: no 0600 semantics for config/vault files (NTFS ACLs apply), x64 package only (ARM64 runs via emulation), execution-policy notes for the .ps1 shim, no SIGTERM-driven graceful shutdown, and — now stated in the install and tools docs, not just in source — Ctrl-C in input_command kills the whole command-session tree on Windows instead of interrupting the foreground command. Keeping the Windows CI honest also meant giving win32 test runs a longer vitest timeout and platform-gating one spawn-timing assertion; each flake it caught was a real timing sensitivity, not a product bug.