# Cross-platform port: the shell layer, and the one decision it forced Measured 2026-09-05, on the M1 codebase. This file records what was measured, the single design decision the measurements forced, and what remains unmeasured so that nobody has to re-derive any of it. ## The blocker `.mcp.json` started the tool server with `"command": "bash"`. Stock Windows has no bash, so the connector could not start at all. Five shell entry points behind it — `bootstrap.sh`, `build_stamp.sh`, `package_plugin.sh`, `jobbsok_tools_launch.sh`, `cowork_probe_check.sh` — reached for `grep`, `sed`, `find`, `awk`, `zip` and `unzip`, none of which are on a stock Windows machine either. `bootstrap.sh` built the virtualenv, so a Windows adopter could not reach an interpreter at all. The Python underneath was already clean: a scan of `scripts/*.py` and `scripts/jobbsok_lib/` found no `/tmp`, no `/usr`, no `os.uname` and no home directory assumption. The portability boundary was the shell layer alone, which is why this was a packaging job rather than a rewrite. All five are now Python and standard library only. The behaviour is carried over unchanged; the deliberate exceptions are listed under *Port decisions* below. ## The decision: how `.mcp.json` names an interpreter **Question.** How does `.mcp.json` start a Python interpreter without a POSIX shell, when the interpreter is called `python3` on macOS and Linux and `python` or `py` on Windows? **What was measured, and how.** 1. **The plugin `mcpServers` schema has no platform-conditional form.** The stdio entry is exactly `{type?, command, args, env?, timeout?, alwaysLoad?, role?}`. This was read out of the installed CLI (v2.1.261) and then confirmed behaviourally: a config carrying invented `windows`, `darwin` and `platform` keys was accepted and the extra keys were **silently discarded** — no error, no warning, no selection. An invented per-OS key therefore fails quietly rather than loudly, which is the worse of the two failure modes. 2. **`${VAR:-default}` is expanded**, in `command`, in every `args` element and in every `env` value. Measured: `${JOBBSOK_PY:-python3}` spawned the real `python3`; `${JOBBSOK_UNSET:-fallback-used}` arrived as `fallback-used`. 3. **`${VAR}` without a default is NOT safe.** With the variable unset the value is passed through unexpanded and merely warned about, so the spawn would try to run a program literally named `${VAR}`. 4. **Windows spawns the server directly, with no shell** (`shell: false`). A `.py` path as `command` is therefore not an option there. The known `cmd /c` requirement (claude-code issue 58510) applies to `.cmd`/`.bat` shims such as `npx`, not to a real executable like `python.exe`. 5. **No single literal name works.** Measured on the development Mac: `python` and `py` are not on `PATH`; only `python3` is. On stock Windows the reverse holds, and a bare `python3` there resolves to the Microsoft Store alias, which opens a shop instead of running anything. **Decision.** `"command": "${JOBBSOK_LAUNCH_PYTHON:-python3}"`. The default form is the only lever the schema offers, so macOS and Linux keep working with nothing set — no regression on the platform that works — and Windows needs one documented environment variable, no POSIX shell of any kind. **Why a second variable rather than reusing `JOBBSOK_PYTHON`.** They are different questions and conflating them would have created a Windows-only defect. `JOBBSOK_PYTHON` names the interpreter to **serve** on: the launcher treats it as an explicit operator choice, so it wins over the virtualenv the bootstrap built and a failure there is refused outright rather than skipped. A Windows adopter who set it merely to spell `python` would silently bypass that virtualenv and serve on a bare interpreter **without the ingestion guard**. `JOBBSOK_LAUNCH_PYTHON` only says how to **start** the launcher; the launcher then resolves the serving interpreter exactly as before. `README.md` states the distinction, and `tests/test_cross_platform.py` asserts the two never collapse into one. ## Port decisions, stated rather than smuggled * **Both venv layouts.** A POSIX venv carries `bin/python`; a Windows venv carries `Scripts\python.exe` and no `python3` at all. The shell version reached for `bin/python` and `bin/python3` in different branches; the port uses `python` uniformly, which is the same file on POSIX and the only correct one on Windows. * **PATH names.** The last-resort PATH lookup keeps exactly `python3` on POSIX — no widening — and uses `python`, then `py`, on Windows. * **The bootstrap's default interpreter** is now the one that started it rather than `python3` on `PATH`, because Windows has no such name. The version gate is unchanged, so an install attempted on 3.9.6 is still refused. * **POSIX still `exec`s.** The launcher replaces its own process on POSIX, as the shell version's `exec` did, so the client's child stays the process it spawned. Windows has no such replacement — `os.execv` there starts a new process and lets this one exit, leaving the client with a dead pid and a live pipe — so on Windows the launcher stays as the parent. * **`BUILD_STAMP` is written with an explicit `\n`.** Without that, Windows text mode would write CRLF and the stamp would no longer be the byte string the skills echo. * **The archive carries no execute bits.** Nothing needs one now that every entry point is started by naming an interpreter, and Windows has no such bit. ## What is measured here, and what is not **Measured on this Mac.** Every platform-dependent decision is a pure function that takes the platform as an argument — `venv_python`, `path_candidates`, `venv_location` — so the Windows branch is exercised from macOS by passing `"nt"`. The suite is 124 tests, offline. The server was also started end to end exactly as `.mcp.json` expands it, and answered `initialize` and `tools/list`. **Not measured, and not to be assumed in either direction:** * That Claude Code on Windows spawns this server successfully. Nothing in this repository has ever run on Windows. * That `${CLAUDE_PLUGIN_ROOT}` expands to a native Windows path. The expansion is a raw string substitution with no separator normalisation; the value it substitutes on Windows was not observable from a macOS build. * **That Cowork on Windows bridges to a host-side stdio MCP server the way it does on this Mac.** `docs/cowork-probe.md` measured macOS only, and operator decision 7 rests on that macOS measurement. Worse, the one piece of documentation that describes a plugin's `.mcp.json` on a Cowork surface lists `http` and `sse` only, with no `command`/`args` — which the macOS measurement in this repository contradicts. Whether that documentation is simply scoped to a different product or describes a real Windows restriction is **unknown**. A Windows probe would have to measure, in this order: whether the connector appears at all with `JOBBSOK_LAUNCH_PYTHON` set; whether it appears without it (and if so, whether the Store alias was what answered); what `${CLAUDE_PLUGIN_ROOT}` expanded to, read back through the `selvsjekk` tool; and whether `python scripts\bootstrap.py` builds a virtualenv the launcher then finds under `$CLAUDE_PLUGIN_DATA`. ## Findings left standing, deliberately 1. **The launcher still gates on the interpreter's version, not on the guard** (O4 in `docs/cowork-probe.md`). Carried over unchanged on purpose: it is harmless while nothing writes, and it becomes a defect in M2 when the decision log and the case folder start writing. Whether the launcher should refuse without the guard is an M2 decision, not a porting one. 2. **`skills/kandidatprofil/SKILL.md` tells the reader to run `python3`.** That is the same class of bug as the one this port closed, one layer up: the skill layer, not the entry points. It was outside this order's fence and is left for a decision about how skills should spell an interpreter at all. 3. **`tests/plan-gates/*.sh` are still shell scripts.** They gate a planning document, are never shipped in the archive and are not needed to install, package or start anything, so they are not entry points in the sense this port was about.