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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 21:02:03 +02:00
commit dcd3eae534
20 changed files with 1523 additions and 542 deletions

210
scripts/bootstrap.py Normal file
View file

@ -0,0 +1,210 @@
"""Build the jobbsok Python environment and pin the ingestion guard.
Python rather than bash, and standard library only. The shell version made the
first step of the install impossible on stock Windows, which is the one step
nobody can skip -- without it there is no interpreter for the host tool server
and no guard for M3 ingestion. Like the launcher, this file is written to parse
on Python 3.7: it may be started by exactly the old interpreter it refuses, and
a refusal that is a SyntaxError is not a refusal.
Where the environment lives is load-bearing, not incidental. When
CLAUDE_PLUGIN_DATA is set the environment is built there, because that
directory survives a plugin update -- the plugin's own cache does not. A
repo-root-only .venv would leave every non-development install without the
guard, which is risk H2. The repo-root .venv is the development case.
One port decision, stated rather than smuggled: the shell version defaulted to
`python3` on PATH, and Windows has no such name. The default here is the
interpreter that started this file, which is the same intent and is more
predictable than a PATH lookup -- and it is gated on the version exactly as
before, so an install started on 3.9.6 is still refused rather than built.
Usage:
python3 scripts/bootstrap.py # dev dependencies
python3 scripts/bootstrap.py --med-xlsx # also the openpyxl extra
On Windows the interpreter is normally called `python`, not `python3`.
"""
import os
import shutil
import subprocess
import sys
MIN_MAJOR = 3
MIN_MINOR = 10
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
class Avvist(Exception):
"""An interpreter that cannot be built on, and the words to say so."""
def __init__(self, linjer):
Exception.__init__(self, linjer[0])
self.linjer = linjer
def parse_args(argv):
"""Return True when the xlsx extra was asked for; exit as the shell did."""
med_xlsx = False
for arg in argv:
if arg == "--med-xlsx":
med_xlsx = True
elif arg in ("-h", "--help"):
sys.stdout.write(__doc__)
raise SystemExit(0)
else:
sys.stderr.write("bootstrap: unknown argument: %s\n" % arg)
sys.stderr.write(
"bootstrap: usage: python3 scripts/bootstrap.py [--med-xlsx]\n"
)
raise SystemExit(2)
return med_xlsx
def venv_location(env, repo_root):
"""Return (directory, what kind of directory it is) for the environment."""
plugin_data = env.get("CLAUDE_PLUGIN_DATA")
if plugin_data:
return (
os.path.join(plugin_data, "venv"),
"plugin-data (survives a plugin update)",
)
return os.path.join(repo_root, ".venv"), "repo root (development)"
def venv_python(venv_dir, os_name=None):
"""The interpreter inside a virtualenv, in the layout this platform uses."""
if os_name is None:
os_name = os.name
if os_name == "nt":
return os.path.join(venv_dir, "Scripts", "python.exe")
return os.path.join(venv_dir, "bin", "python")
def version_ok(tolk):
"""True when ``tolk`` reports at least the floor."""
return 0 == subprocess.call(
[
tolk,
"-c",
"import sys; sys.exit(0 if sys.version_info >= (%d, %d) else 1)"
% (MIN_MAJOR, MIN_MINOR),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def version_of(tolk):
"""The version ``tolk`` reports, or "ukjent" when it will not say."""
try:
ut = subprocess.run(
[tolk, "-c", "import sys; print('%d.%d.%d' % sys.version_info[:3])"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
except OSError:
return "ukjent"
if ut.returncode != 0 or not ut.stdout.strip():
return "ukjent"
return ut.stdout.strip()
def resolve_interpreter(env, which=None, gate=None):
"""The interpreter to build the environment on.
Refuses rather than building an environment that parses everything and
fails at runtime: under a GUI-spawned process with an empty PATH, the
interpreter here can be 3.9.6.
"""
if which is None:
which = shutil.which
if gate is None:
gate = version_ok
navngitt = env.get("JOBBSOK_PYTHON")
tolk = navngitt or sys.executable
funnet = which(tolk)
if not funnet:
raise Avvist(
[
"bootstrap: no such interpreter: %s" % tolk,
"bootstrap: set JOBBSOK_PYTHON to a Python %d.%d+ executable."
% (MIN_MAJOR, MIN_MINOR),
]
)
if not gate(funnet):
raise Avvist(
[
"bootstrap: %s is Python %s; %d.%d+ is required."
% (tolk, version_of(funnet), MIN_MAJOR, MIN_MINOR),
"bootstrap: refusing to build an environment on it. Set "
"JOBBSOK_PYTHON instead.",
]
)
return funnet
def main(argv, env=None):
if env is None:
env = os.environ
med_xlsx = parse_args(argv)
try:
tolk = resolve_interpreter(env)
except Avvist as avvist:
for linje in avvist.linjer:
sys.stderr.write(linje + "\n")
return 1
venv_dir, art = venv_location(env, REPO_ROOT)
sys.stdout.write("bootstrap: interpreter %s\n" % tolk)
sys.stdout.write("bootstrap: environment %s [%s]\n" % (venv_dir, art))
venv_py = venv_python(venv_dir)
if not os.path.isfile(venv_py):
forelder = os.path.dirname(venv_dir)
if forelder and not os.path.isdir(forelder):
os.makedirs(forelder)
kode = subprocess.call([tolk, "-m", "venv", venv_dir])
if kode != 0:
return kode
pyproject = os.path.join(REPO_ROOT, "pyproject.toml")
# PEP 735 dependency groups need pip 25.1+; the upgrade is what makes the
# --group line below safe, and a pip too old to understand it fails loudly.
trinn = [
[venv_py, "-m", "pip", "install", "--quiet", "--upgrade", "pip"],
[venv_py, "-m", "pip", "install", "--quiet", "--group", pyproject + ":dev"],
]
if med_xlsx:
trinn.append(
[venv_py, "-m", "pip", "install", "--quiet", "--group", pyproject + ":xlsx"]
)
for kommando in trinn:
kode = subprocess.call(kommando)
if kode != 0:
return kode
rapport = (
"import sys\n"
"import llm_ingestion_guard\n"
"print('bootstrap: sys.executable %s' % sys.executable)\n"
"print('bootstrap: python %s' % sys.version.split()[0])\n"
"print('bootstrap: guard %s' % llm_ingestion_guard.__version__)\n"
)
kode = subprocess.call([venv_py, "-c", rapport])
if kode != 0:
return kode
sys.stdout.write("bootstrap: ok\n")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))