jobbsok/docs/cross-platform-port.md
Kjell Tore Guttormsen dcd3eae534 feat(port): move five bash entry points to Python and drop bash from .mcp.json
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>
2026-09-05 21:02:03 +02:00

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.

  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 execs. 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.