jobbsok could not start on stock Windows. `.mcp.json` named `bash` as the
command, and the five entry points behind it reached for grep, sed, find, awk,
zip and unzip. `bootstrap.sh` built the virtualenv, so a Windows adopter could
not even reach an interpreter. Two adopters are waiting and neither is
guaranteed to be on macOS, so this is the install, not a rough edge.
The Python layer was already clean -- no /tmp, no /usr, no os.uname, no home
directory assumption -- so only the shell layer moved. Behaviour is carried
over unchanged; the deliberate exceptions are listed in docs/.
THE ONE OPEN DECISION, AND WHY IT WAS FORCED
How does .mcp.json start an interpreter without a POSIX shell, when it is
called python3 on macOS and python or py on Windows? Measured against the
installed CLI, not assumed:
- The plugin mcpServers stdio schema has NO platform-conditional form. A
config carrying invented windows/darwin/platform keys was accepted and the
keys were silently discarded -- it fails quietly, not loudly.
- ${VAR:-default} IS expanded, in command, args and env.
- ${VAR} without a default is not safe: unset, it is passed through
unexpanded, so the spawn would try to run a program named ${VAR}.
- Windows spawns with shell:false, so a .py path as command is out.
- No single literal works. On this Mac, python and py are not on PATH.
So the default form is the only lever the schema offers:
"${JOBBSOK_LAUNCH_PYTHON:-python3}". macOS and Linux keep working with nothing
set; Windows sets one variable and needs no shell.
A SECOND VARIABLE, NOT A REUSE OF JOBBSOK_PYTHON
JOBBSOK_PYTHON names the interpreter to SERVE on: the launcher treats it as an
explicit operator choice, so it wins over the bootstrapped virtualenv. A
Windows adopter setting it merely to spell `python` would silently bypass that
virtualenv and serve WITHOUT the ingestion guard. JOBBSOK_LAUNCH_PYTHON only
says how to start the launcher. A test asserts the two never collapse into one.
O4 IS LEFT STANDING
The launcher still gates on the interpreter's version rather than on the guard
being importable. That is the shell version's semantics carried over on
purpose: harmless while nothing writes, a defect from M2, and an M2 decision.
VERIFY
- pytest tests/ -> 124 passed, exit 0 (was 112)
- grep -c '"command": "bash"' .mcp.json -> 0
- git ls-files 'scripts/*.sh' -> 0
- README install block names Windows, and neither WSL nor Git Bash
- server started end to end exactly as .mcp.json expands, and answered
initialize and tools/list
- the ported probe checker reproduces the shell version's output and exits 0
NOT MEASURED, AND NOT ASSUMED
Nothing here has ever run on Windows. Whether Cowork on Windows bridges to a
host-side stdio MCP as it does on this Mac is unmeasured -- docs/cowork-probe.md
covered macOS only. docs/cross-platform-port.md says what a Windows probe would
have to measure, and records two findings left deliberately untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.1 KiB
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.
- The plugin
mcpServersschema 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 inventedwindows,darwinandplatformkeys 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. ${VAR:-default}is expanded, incommand, in everyargselement and in everyenvvalue. Measured:${JOBBSOK_PY:-python3}spawned the realpython3;${JOBBSOK_UNSET:-fallback-used}arrived asfallback-used.${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}.- Windows spawns the server directly, with no shell (
shell: false). A.pypath ascommandis therefore not an option there. The knowncmd /crequirement (claude-code issue 58510) applies to.cmd/.batshims such asnpx, not to a real executable likepython.exe. - No single literal name works. Measured on the development Mac:
pythonandpyare not onPATH; onlypython3is. On stock Windows the reverse holds, and a barepython3there 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 carriesScripts\python.exeand nopython3at all. The shell version reached forbin/pythonandbin/python3in different branches; the port usespythonuniformly, which is the same file on POSIX and the only correct one on Windows. - PATH names. The last-resort PATH lookup keeps exactly
python3on POSIX — no widening — and usespython, thenpy, on Windows. - The bootstrap's default interpreter is now the one that started it rather
than
python3onPATH, because Windows has no such name. The version gate is unchanged, so an install attempted on 3.9.6 is still refused. - POSIX still
execs. The launcher replaces its own process on POSIX, as the shell version'sexecdid, so the client's child stays the process it spawned. Windows has no such replacement —os.execvthere 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_STAMPis 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.mdmeasured macOS only, and operator decision 7 rests on that macOS measurement. Worse, the one piece of documentation that describes a plugin's.mcp.jsonon a Cowork surface listshttpandsseonly, with nocommand/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
- 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. skills/kandidatprofil/SKILL.mdtells the reader to runpython3. 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.tests/plan-gates/*.share 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.