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>
160 lines
5.7 KiB
Python
160 lines
5.7 KiB
Python
"""Build jobbsok.plugin from an explicit include list.
|
|
|
|
Python rather than bash, and standard library only: `zip` is not on stock
|
|
Windows any more than `bash` is, and `zipfile` does the same job everywhere.
|
|
|
|
The include list is the whole point, and it is not a convenience. An archiver
|
|
does not consult .gitignore, so archiving the repository root would ship .git,
|
|
the virtualenv from Step 2, the local-only STATE.md and everything under
|
|
.claude/ -- which holds the operator's decisions and absolute home paths. This
|
|
archive is uploaded to Cowork once per milestone and its command is printed in
|
|
a public README. An include list is the only form that is safe to publish.
|
|
|
|
The virtualenv is excluded for a second reason on top of that one: a venv's
|
|
absolute paths do not survive relocation, so an archived one would be broken
|
|
wherever it landed. The supported route in an installed copy is to run
|
|
scripts/bootstrap.py once against it, which builds the environment under
|
|
$CLAUDE_PLUGIN_DATA. README.md says so.
|
|
|
|
Entry names are written with forward slashes and no Unix permission bits,
|
|
which is what the shell version's `-X` was after: two builds of the same commit
|
|
differ only in timestamps, and nothing in the archive needs an execute bit now
|
|
that every entry point is started by naming an interpreter.
|
|
|
|
Usage:
|
|
python3 scripts/package_plugin.py # -> <repo root>/jobbsok.plugin
|
|
python3 scripts/package_plugin.py --ut <sti> # -> somewhere else
|
|
|
|
On Windows the interpreter is normally called `python`, not `python3`.
|
|
"""
|
|
|
|
import fnmatch
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
|
|
|
|
#: Directories are archived whole, minus the exclusions below. Entries that do
|
|
#: not exist yet are reported and skipped -- hooks/ and templates/ land in
|
|
#: later steps -- but the two the archive is worthless without are required.
|
|
INCLUDE = (
|
|
".claude-plugin",
|
|
"skills",
|
|
"scripts",
|
|
"hooks",
|
|
"templates",
|
|
".mcp.json",
|
|
"README.md",
|
|
"SECURITY.md",
|
|
"LICENSE",
|
|
"BUILD_STAMP",
|
|
)
|
|
REQUIRED = (".claude-plugin/plugin.json", "BUILD_STAMP")
|
|
|
|
#: Belt to the include list's braces: nothing under scripts/ or skills/ that is
|
|
#: a build artefact of a local test run may ride along. The patterns are the
|
|
#: ones the shell version passed to `zip -x`, matched against the archive entry
|
|
#: name, where `*` crosses directory separators exactly as it did there.
|
|
UTELAT = (
|
|
"*/__pycache__/*",
|
|
"*.pyc",
|
|
"*/.pytest_cache/*",
|
|
"*/.venv/*",
|
|
"*.local.md",
|
|
"*/.DS_Store",
|
|
)
|
|
|
|
|
|
def parse_args(argv):
|
|
"""Return the archive path, or exit the way the shell version exited."""
|
|
ut = os.path.join(REPO_ROOT, "jobbsok.plugin")
|
|
i = 0
|
|
while i < len(argv):
|
|
arg = argv[i]
|
|
if arg == "--ut":
|
|
if i + 1 >= len(argv):
|
|
sys.stderr.write("package_plugin: --ut needs a path\n")
|
|
raise SystemExit(2)
|
|
ut = argv[i + 1]
|
|
i += 2
|
|
elif arg in ("-h", "--help"):
|
|
sys.stdout.write(__doc__)
|
|
raise SystemExit(0)
|
|
else:
|
|
sys.stderr.write("package_plugin: unknown argument: %s\n" % arg)
|
|
raise SystemExit(2)
|
|
return os.path.abspath(ut)
|
|
|
|
|
|
def skal_utelates(navn):
|
|
"""True when ``navn`` -- an archive entry name -- matches an exclusion."""
|
|
for monster in UTELAT:
|
|
if fnmatch.fnmatch(navn, monster):
|
|
return True
|
|
return False
|
|
|
|
|
|
def oppforinger(rot, include):
|
|
"""Return [(absolute path, archive entry name)] for ``include`` under ``rot``.
|
|
|
|
Sorted, so two builds of the same tree lay the archive out identically.
|
|
"""
|
|
funnet = []
|
|
for sti in include:
|
|
full = os.path.join(rot, sti)
|
|
if os.path.isfile(full):
|
|
funnet.append((full, sti.replace(os.sep, "/")))
|
|
continue
|
|
for dirpath, dirnames, filenames in os.walk(full):
|
|
dirnames.sort()
|
|
for filnavn in sorted(filenames):
|
|
filsti = os.path.join(dirpath, filnavn)
|
|
navn = os.path.relpath(filsti, rot).replace(os.sep, "/")
|
|
funnet.append((filsti, navn))
|
|
return [(f, n) for f, n in funnet if not skal_utelates(n)]
|
|
|
|
|
|
def main(argv):
|
|
ut = parse_args(argv)
|
|
|
|
# Regenerate first: an archive carrying a stale stamp is the exact failure
|
|
# the stamp exists to catch, and building one would be worse than not
|
|
# stamping. Started with this interpreter, so the packaging run and the
|
|
# stamp it ships cannot end up on two different Pythons.
|
|
stempling = subprocess.run(
|
|
[sys.executable, os.path.join(SCRIPT_DIR, "build_stamp.py")]
|
|
)
|
|
if stempling.returncode != 0:
|
|
return stempling.returncode
|
|
|
|
for sti in REQUIRED:
|
|
if not os.path.exists(os.path.join(REPO_ROOT, sti.replace("/", os.sep))):
|
|
sys.stderr.write("package_plugin: required entry missing: %s\n" % sti)
|
|
return 1
|
|
|
|
present = [p for p in INCLUDE if os.path.exists(os.path.join(REPO_ROOT, p))]
|
|
skipped = [p for p in INCLUDE if not os.path.exists(os.path.join(REPO_ROOT, p))]
|
|
if skipped:
|
|
sys.stdout.write("package_plugin: not present yet, skipped: %s\n" % " ".join(skipped))
|
|
|
|
if os.path.exists(ut):
|
|
os.remove(ut)
|
|
|
|
with zipfile.ZipFile(ut, "w", zipfile.ZIP_DEFLATED) as pakke:
|
|
for filsti, navn in oppforinger(REPO_ROOT, present):
|
|
pakke.write(filsti, navn)
|
|
|
|
with open(os.path.join(REPO_ROOT, "BUILD_STAMP"), "r", encoding="utf-8") as handle:
|
|
stempel = handle.read().strip()
|
|
sys.stdout.write(
|
|
"package_plugin: %s built from %s\n" % (os.path.basename(ut), stempel)
|
|
)
|
|
sys.stdout.write("package_plugin: %s\n" % ut)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|