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:]))

View file

@ -1,91 +0,0 @@
#!/bin/bash
# Build the jobbsok Python environment and pin the ingestion guard.
#
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2,
# and a multibyte character has crashed a `set -u` script here before.
#
# 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.
#
# Usage:
# bash scripts/bootstrap.sh # dev dependencies
# bash scripts/bootstrap.sh --med-xlsx # also the openpyxl extra
set -eu
MIN_MAJOR=3
MIN_MINOR=10
WITH_XLSX=0
for arg in "$@"; do
case "$arg" in
--med-xlsx) WITH_XLSX=1 ;;
-h|--help)
sed -n '2,20p' "$0"
exit 0 ;;
*)
echo "bootstrap: unknown argument: $arg" >&2
echo "bootstrap: usage: bash scripts/bootstrap.sh [--med-xlsx]" >&2
exit 2 ;;
esac
done
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
if [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then
VENV_DIR="${CLAUDE_PLUGIN_DATA}/venv"
VENV_KIND="plugin-data (survives a plugin update)"
else
VENV_DIR="${REPO_ROOT}/.venv"
VENV_KIND="repo root (development)"
fi
# Refuse an interpreter that is too old rather than building an environment
# that parses everything and fails at runtime. Under a GUI-spawned process with
# an empty PATH, python3 here can resolve to 3.9.6.
PYTHON_BIN="${JOBBSOK_PYTHON:-python3}"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
echo "bootstrap: no such interpreter: $PYTHON_BIN" >&2
echo "bootstrap: set JOBBSOK_PYTHON to a Python ${MIN_MAJOR}.${MIN_MINOR}+ executable." >&2
exit 1
fi
if ! "$PYTHON_BIN" -c "import sys; sys.exit(0 if sys.version_info >= ($MIN_MAJOR, $MIN_MINOR) else 1)"; then
FOUND=$("$PYTHON_BIN" -c "import sys; print('%d.%d.%d' % sys.version_info[:3])")
echo "bootstrap: $PYTHON_BIN is Python $FOUND; ${MIN_MAJOR}.${MIN_MINOR}+ is required." >&2
echo "bootstrap: refusing to build an environment on it. Set JOBBSOK_PYTHON instead." >&2
exit 1
fi
echo "bootstrap: interpreter $("$PYTHON_BIN" -c 'import sys; print(sys.executable)')"
echo "bootstrap: environment $VENV_DIR [$VENV_KIND]"
if [ ! -x "${VENV_DIR}/bin/python" ]; then
mkdir -p "$(dirname "$VENV_DIR")"
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
VENV_PY="${VENV_DIR}/bin/python"
"$VENV_PY" -m pip install --quiet --upgrade pip
# PEP 735 dependency groups need pip 25.1+; the upgrade above is what makes
# this line safe, and a pip too old to understand --group fails loudly here.
"$VENV_PY" -m pip install --quiet --group "${REPO_ROOT}/pyproject.toml:dev"
if [ "$WITH_XLSX" -eq 1 ]; then
"$VENV_PY" -m pip install --quiet --group "${REPO_ROOT}/pyproject.toml:xlsx"
fi
"$VENV_PY" - <<'PYEOF'
import sys
import llm_ingestion_guard
print("bootstrap: sys.executable %s" % sys.executable)
print("bootstrap: python %s" % sys.version.split()[0])
print("bootstrap: guard %s" % llm_ingestion_guard.__version__)
PYEOF
echo "bootstrap: ok"

94
scripts/build_stamp.py Normal file
View file

@ -0,0 +1,94 @@
"""Write the short hash of the commit this build came from to BUILD_STAMP.
Python rather than bash, and standard library only. Stock Windows has no bash
and none of the coreutils the shell version reached for, so a shell entry point
made the plugin uninstallable there -- for adopters who are not on this Mac,
that is the whole install, not a rough edge.
Why a stamp and not the manifest version: Cowork caches an uploaded plugin, and
plugin.json's version does not change between milestones, so a stale build
would echo the right number and every answer after it would be about some other
build (risk H11). The short hash moves on every commit, which is exactly the
property the check needs.
The stamp is a build artefact and is gitignored. It is regenerated by
scripts/package_plugin.py before every archive; a committed stamp would be
false the instant the next commit moved HEAD.
Usage:
python3 scripts/build_stamp.py # write <plugin root>/BUILD_STAMP
python3 scripts/build_stamp.py --ut <sti> # write somewhere else
On Windows the interpreter is normally called `python`, not `python3`.
"""
import os
import subprocess
import sys
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
def parse_args(argv):
"""Return the output path, or exit the way the shell version exited.
Kept as a function so the argument handling is reachable from a test
without starting a subprocess for every case.
"""
ut = os.path.join(REPO_ROOT, "BUILD_STAMP")
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--ut":
if i + 1 >= len(argv):
sys.stderr.write("build_stamp: --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("build_stamp: unknown argument: %s\n" % arg)
raise SystemExit(2)
return ut
def git(repo, *args):
"""Run git in ``repo`` and return (returncode, stdout)."""
kjort = subprocess.run(
["git", "-C", repo] + list(args),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
return kjort.returncode, kjort.stdout.strip()
def main(argv):
ut = parse_args(argv)
kode, _ = git(REPO_ROOT, "rev-parse", "--git-dir")
if kode != 0:
sys.stderr.write("build_stamp: %s is not a git working tree.\n" % REPO_ROOT)
sys.stderr.write(
"build_stamp: the stamp is the commit this build came from; "
"there is nothing to stamp.\n"
)
return 1
kode, stempel = git(REPO_ROOT, "rev-parse", "--short", "HEAD")
if kode != 0:
sys.stderr.write("build_stamp: git rev-parse --short HEAD failed.\n")
return 1
# Newline and nothing else: the skills read this file and echo it.
with open(ut, "w", encoding="utf-8", newline="\n") as handle:
handle.write(stempel + "\n")
sys.stdout.write("build_stamp: %s -> %s\n" % (stempel, ut))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -1,52 +0,0 @@
#!/bin/bash
# Write the short hash of the commit this build came from to BUILD_STAMP.
#
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2.
#
# Why a stamp and not the manifest version: Cowork caches an uploaded plugin,
# and plugin.json's version does not change between milestones, so a stale
# build would echo the right number and every answer after it would be about
# some other build (risk H11). The short hash moves on every commit, which is
# exactly the property the check needs.
#
# The stamp is a build artefact and is gitignored. It is regenerated by
# scripts/package_plugin.sh before every archive; a committed stamp would be
# false the instant the next commit moved HEAD.
#
# Usage:
# bash scripts/build_stamp.sh # write <plugin root>/BUILD_STAMP
# bash scripts/build_stamp.sh --ut <sti> # write somewhere else
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
OUT="${REPO_ROOT}/BUILD_STAMP"
while [ $# -gt 0 ]; do
case "$1" in
--ut)
if [ $# -lt 2 ]; then
echo "build_stamp: --ut needs a path" >&2
exit 2
fi
OUT="$2"
shift 2 ;;
-h|--help)
sed -n '2,20p' "$0"
exit 0 ;;
*)
echo "build_stamp: unknown argument: $1" >&2
exit 2 ;;
esac
done
if ! git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
echo "build_stamp: $REPO_ROOT is not a git working tree." >&2
echo "build_stamp: the stamp is the commit this build came from; there is nothing to stamp." >&2
exit 1
fi
STAMP=$(git -C "$REPO_ROOT" rev-parse --short HEAD)
printf '%s\n' "$STAMP" > "$OUT"
echo "build_stamp: $STAMP -> $OUT"

View file

@ -0,0 +1,260 @@
"""Report, from the host, how far the Cowork probe has actually got.
The probe asks four questions that only the operator can answer in a Cowork
session. But the host-MCP one leaves hard traces on this Mac, and a trace is a
measurement where "what did you see in the UI" is a recollection. This script
reads those traces so each step is verified before the next begins.
Python rather than bash: the shell version reached for find, grep, sed, awk and
unzip, and this repository no longer has a shell entry point anywhere. What it
INSPECTS is still macOS-only -- Claude's own log and session directories live
under ~/Library -- so this remains a tool for measuring this Mac. That is a
property of the question, not of the language it is written in.
Two earlier queries in this script were WRONG and the measurement caught both;
the corrections are written down here so they are not re-derived:
1. It searched for a directory named after the plugin. Cowork installs into
rpm/plugin_<opaque id>/, so the name never appears in a path. The manifest
at rpm/manifest.json is the index; read that instead.
2. It expected ~/Library/Logs/Claude/mcp-server-<name>.log. That naming is
for Claude Desktop's own connectors. A plugin's MCP server is logged by
LocalMcpServerManager into main.log instead. No file at the guessed path
meant the guess was wrong, not that nothing had happened.
It reports; it does not gate. Exit 0 when every check has landed, 1 while any
is pending. Read-only.
"""
import json
import os
import re
import subprocess
import sys
import zipfile
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
PROBE_DOC = os.path.join(REPO_ROOT, "docs", "cowork-probe.md")
FIXTURE = os.path.join(REPO_ROOT, "tests", "fixtures", "cowork-probe")
MAIN_LOG = os.path.expanduser("~/Library/Logs/Claude/main.log")
SESSIONS = os.path.expanduser(
"~/Library/Application Support/Claude/local-agent-mode-sessions"
)
PLUGIN_NAME = "jobbsok-probe"
SERVER_KEY = "plugin:jobbsok-probe:probe-tools"
#: An archive entry that would mean the probe vehicle leaked something local.
LEKKASJE = re.compile(r"^\.git|STATE\.md|\.venv|^\.claude/")
#: One of the four answers, written down in the form the probe doc asks for.
SVAR = re.compile(
r"^- (Sesjonsmodus|Host-MCP|python3|CLAUDE_PLUGIN_ROOT): +(lokal VM|sky|ja|nei)\b",
re.MULTILINE,
)
def lekkasje(navn):
"""True when an archive entry name is one the probe vehicle must not ship."""
return LEKKASJE.search(navn) is not None
def antall_svar(tekst):
"""How many of the four probe questions are answered in ``tekst``."""
return len(SVAR.findall(tekst))
def antall_filer(katalog):
n = 0
for _dirpath, _dirnames, filenames in os.walk(katalog):
n += len(filenames)
return n
def les_tekst(sti):
"""The file's text, or None when it is not there. Never raises on encoding."""
try:
with open(sti, "r", encoding="utf-8", errors="replace") as handle:
return handle.read()
except IOError:
return None
def installert_kopi(sessions, navn):
"""Find ``navn`` in any rpm/manifest.json under ``sessions``.
Returns (directory, id, marketplace, updatedAt) or None. The manifest is
the index -- the install directory is named after an opaque id, so the
plugin's own name never appears in a path.
"""
for dirpath, _dirnames, filenames in os.walk(sessions):
if os.path.basename(dirpath) != "rpm" or "manifest.json" not in filenames:
continue
try:
with open(os.path.join(dirpath, "manifest.json"), "r", encoding="utf-8") as h:
data = json.load(h)
except (IOError, ValueError):
continue
for p in data.get("plugins", []):
if p.get("name") == navn:
return (
os.path.join(dirpath, p["id"]),
p["id"],
p.get("marketplaceName", "?"),
p.get("updatedAt", "?"),
)
return None
class Rapport(object):
def __init__(self):
self.pending = 0
def done(self, tekst):
sys.stdout.write(" [DONE] %s\n" % tekst)
def pend(self, tekst):
sys.stdout.write(" [PENDING] %s\n" % tekst)
self.pending += 1
def info(self, tekst):
sys.stdout.write(" %s\n" % tekst)
def main(argv):
arkiv = os.environ.get("JOBBSOK_PROBE_ARCHIVE", "/tmp/jobbsok-probe.plugin")
r = Rapport()
sys.stdout.write(
"\n=== 1. Probe vehicle and archive ============================================\n"
)
n = antall_filer(FIXTURE)
if n == 4:
r.done("probe vehicle: 4 files under tests/fixtures/cowork-probe")
else:
r.pend("probe vehicle: expected 4 files, found %d" % n)
if os.path.isfile(arkiv):
try:
with zipfile.ZipFile(arkiv) as pakke:
oppforinger = pakke.namelist()
except zipfile.BadZipFile:
oppforinger = None
if oppforinger is None:
r.pend("archive: %s is not a readable zip - rebuild it" % arkiv)
else:
lekk = [o for o in oppforinger if lekkasje(o)]
if len(oppforinger) == 4 and not lekk:
r.done("archive: %s (4 files, no leaked entries)" % arkiv)
else:
r.pend(
"archive: %s has %d entries and %d leaked ones - rebuild it"
% (arkiv, len(oppforinger), len(lekk))
)
else:
r.pend("archive not built: %s" % arkiv)
sys.stdout.write(
"\n=== 2. Is jobbsok-probe installed in Cowork? =================================\n"
)
funnet = installert_kopi(SESSIONS, PLUGIN_NAME)
if funnet:
katalog, plugin_id, marked, oppdatert = funnet
r.done(
"listed in rpm/manifest.json as %s (marketplace: %s, updated %s)"
% (plugin_id, marked, oppdatert)
)
f = antall_filer(katalog)
if f == 4:
r.done("installed copy holds 4 files, matching the archive")
else:
r.pend("installed copy holds %d files, expected 4" % f)
manifest = os.path.join(katalog, ".claude-plugin", "plugin.json")
v = None
tekst = les_tekst(manifest)
if tekst:
try:
v = json.loads(tekst)["version"]
except (ValueError, KeyError):
v = None
if v == "0.0.1":
r.done("installed manifest version is 0.0.1 - not a stale cached build")
else:
r.pend(
"installed manifest version is %r, expected 0.0.1 - Cowork served "
"a cached build" % (v,)
)
else:
r.pend("no plugin named %s in any rpm/manifest.json" % PLUGIN_NAME)
sys.stdout.write(
"\n=== 3. Did Cowork spawn probe-tools on THIS Mac? =============================\n"
)
sys.stdout.write(" (the -Host-MCP: question, measured rather than recalled)\n")
logg = les_tekst(MAIN_LOG)
if logg is not None:
linjer = logg.splitlines()
conn = [l for l in linjer if ("Connected to " + SERVER_KEY) in l]
neg = [l for l in linjer if (SERVER_KEY + " negotiated protocol version") in l]
if conn:
r.done("connected: %s" % conn[-1].split("[LocalMcpServerManager] ")[-1])
else:
r.pend("main.log has no 'Connected to %s' line" % SERVER_KEY)
if neg:
r.done("handshake: negotiated%s" % neg[-1].split("negotiated", 1)[-1])
else:
r.pend("no protocol negotiation recorded")
if [l for l in linjer if "probe_ping" in l]:
r.done("probe_ping seen in the log")
else:
r.info("no probe_ping line in main.log yet - the connection lifecycle is")
r.info("logged there but an individual tool call may not be. Confirm the")
r.info("call by its answer in the Cowork chat, not by this line's absence.")
else:
r.pend("no main.log at %s" % MAIN_LOG)
try:
proc = subprocess.run(
["pgrep", "-fl", "probe_tools.py"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
treff = proc.stdout.splitlines()
except OSError:
treff = []
r.info("no pgrep on this platform - the log above is the record.")
if treff:
r.done("process alive: pid %s" % treff[0].split()[0])
tolk = re.search(r"/[^ ]*python3?", treff[0])
if tolk:
r.info("interpreter: %s (host, not sandbox)" % tolk.group(0))
else:
r.info("no probe_tools.py process at this instant - servers may be started on")
r.info("demand, so absence here is not a nei. The log above is the record.")
sys.stdout.write(
"\n=== 4. Are the four answers written down? ===================================\n"
)
doc = les_tekst(PROBE_DOC) or ""
c = antall_svar(doc)
if c == 4:
r.done("all four answered - plan Step 1 Verify passes")
else:
r.pend("%d of 4 answered in docs/cowork-probe.md" % c)
for linje in doc.splitlines():
if re.match(r"^- (Sesjonsmodus|Host-MCP|python3|CLAUDE_PLUGIN_ROOT):", linje):
sys.stdout.write(" %s\n" % linje)
sys.stdout.write(
"\n============================================================================\n"
)
if r.pending == 0:
sys.stdout.write("ALL CHECKS LANDED.\n")
return 0
sys.stdout.write("%d check(s) still pending.\n" % r.pending)
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -1,155 +0,0 @@
#!/bin/bash
# Report, from the host, how far the Cowork probe has actually got.
#
# The probe asks four questions that only the operator can answer in a Cowork
# session. But the host-MCP one leaves hard traces on this Mac, and a trace is
# a measurement where "what did you see in the UI" is a recollection. This
# script reads those traces so each step is verified before the next begins.
#
# Two earlier queries in this script were WRONG and the measurement caught
# both; the corrections are written down here so they are not re-derived:
#
# 1. It searched for a directory named after the plugin. Cowork installs into
# rpm/plugin_<opaque id>/, so the name never appears in a path. The
# manifest at rpm/manifest.json is the index; read that instead.
# 2. It expected ~/Library/Logs/Claude/mcp-server-<name>.log. That naming is
# for Claude Desktop's own connectors. A plugin's MCP server is logged by
# LocalMcpServerManager into main.log instead. No file at the guessed path
# meant the guess was wrong, not that nothing had happened.
#
# It reports; it does not gate. Exit 0 when every check has landed, 1 while any
# is pending. bash 3.2-clean, ASCII-only, read-only.
set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
PROBE_DOC="$REPO_ROOT/docs/cowork-probe.md"
FIXTURE="$REPO_ROOT/tests/fixtures/cowork-probe"
ARCHIVE="${JOBBSOK_PROBE_ARCHIVE:-/tmp/jobbsok-probe.plugin}"
MAIN_LOG="$HOME/Library/Logs/Claude/main.log"
SESSIONS="$HOME/Library/Application Support/Claude/local-agent-mode-sessions"
PLUGIN_NAME="jobbsok-probe"
SERVER_KEY="plugin:jobbsok-probe:probe-tools"
pending=0
done_() { printf ' [DONE] %s\n' "$1"; }
pend() { printf ' [PENDING] %s\n' "$1"; pending=$((pending+1)); }
info() { printf ' %s\n' "$1"; }
printf '\n=== 1. Probe vehicle and archive ============================================\n'
n=$(find "$FIXTURE" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "$n" = "4" ]; then
done_ "probe vehicle: 4 files under tests/fixtures/cowork-probe"
else
pend "probe vehicle: expected 4 files, found $n"
fi
if [ -f "$ARCHIVE" ]; then
a=$(unzip -Z1 "$ARCHIVE" 2>/dev/null | wc -l | tr -d ' ')
leak=$(unzip -Z1 "$ARCHIVE" 2>/dev/null | grep -cE '^\.git|STATE\.md|\.venv|^\.claude/')
if [ "$a" = "4" ] && [ "$leak" = "0" ]; then
done_ "archive: $ARCHIVE ($a files, no leaked entries)"
else
pend "archive: $ARCHIVE has $a entries and $leak leaked ones - rebuild it"
fi
else
pend "archive not built: $ARCHIVE"
fi
printf '\n=== 2. Is jobbsok-probe installed in Cowork? =================================\n'
# python3 does the walking: the path contains "Application Support", and an
# unquoted $(find ...) in a for-loop splits on that space. That exact bug is
# what made this check report "not installed" while the plugin was running.
INSTALL_DIR=$(python3 - "$SESSIONS" "$PLUGIN_NAME" <<'PYEOF'
import json, os, sys
sessions, name = sys.argv[1], sys.argv[2]
for dirpath, dirnames, filenames in os.walk(sessions):
if os.path.basename(dirpath) != "rpm" or "manifest.json" not in filenames:
continue
try:
data = json.load(open(os.path.join(dirpath, "manifest.json")))
except Exception:
continue
for p in data.get("plugins", []):
if p.get("name") == name:
print("%s\t%s\t%s\t%s" % (
os.path.join(dirpath, p["id"]), p["id"],
p.get("marketplaceName", "?"), p.get("updatedAt", "?")))
sys.exit(0)
PYEOF
)
if [ -n "$INSTALL_DIR" ]; then
D=$(printf '%s' "$INSTALL_DIR" | cut -f1)
ID=$(printf '%s' "$INSTALL_DIR" | cut -f2)
MP=$(printf '%s' "$INSTALL_DIR" | cut -f3)
AT=$(printf '%s' "$INSTALL_DIR" | cut -f4)
done_ "listed in rpm/manifest.json as $ID (marketplace: $MP, updated $AT)"
f=$(find "$D" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "$f" = "4" ]; then
done_ "installed copy holds 4 files, matching the archive"
else
pend "installed copy holds $f files, expected 4"
fi
v=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" "$D/.claude-plugin/plugin.json" 2>/dev/null)
if [ "$v" = "0.0.1" ]; then
done_ "installed manifest version is 0.0.1 - not a stale cached build"
else
pend "installed manifest version is '$v', expected 0.0.1 - Cowork served a cached build"
fi
else
pend "no plugin named $PLUGIN_NAME in any rpm/manifest.json"
fi
printf '\n=== 3. Did Cowork spawn probe-tools on THIS Mac? =============================\n'
printf ' (the -Host-MCP: question, measured rather than recalled)\n'
if [ -f "$MAIN_LOG" ]; then
conn=$(grep -a "Connected to $SERVER_KEY" "$MAIN_LOG" 2>/dev/null | tail -1)
neg=$(grep -a "$SERVER_KEY negotiated protocol version" "$MAIN_LOG" 2>/dev/null | tail -1)
if [ -n "$conn" ]; then
done_ "connected: $(printf '%s' "$conn" | sed 's/.*\[LocalMcpServerManager\] //')"
else
pend "main.log has no 'Connected to $SERVER_KEY' line"
fi
if [ -n "$neg" ]; then
done_ "handshake: $(printf '%s' "$neg" | sed 's/.*negotiated/negotiated/')"
else
pend "no protocol negotiation recorded"
fi
call=$(grep -a 'probe_ping' "$MAIN_LOG" 2>/dev/null | tail -1)
if [ -n "$call" ]; then
done_ "probe_ping seen in the log"
else
info "no probe_ping line in main.log yet - the connection lifecycle is"
info "logged there but an individual tool call may not be. Confirm the"
info "call by its answer in the Cowork chat, not by this line's absence."
fi
else
pend "no main.log at $MAIN_LOG"
fi
proc=$(pgrep -fl probe_tools.py 2>/dev/null | head -1)
if [ -n "$proc" ]; then
done_ "process alive: pid $(printf '%s' "$proc" | awk '{print $1}')"
interp=$(printf '%s' "$proc" | grep -oE '/[^ ]*python3?' | head -1)
[ -n "$interp" ] && info "interpreter: $interp (host, not sandbox)"
else
info "no probe_tools.py process at this instant - servers may be started on"
info "demand, so absence here is not a nei. The log above is the record."
fi
printf '\n=== 4. Are the four answers written down? ===================================\n'
RE='^- (Sesjonsmodus|Host-MCP|python3|CLAUDE_PLUGIN_ROOT): +(lokal VM|sky|ja|nei)\b'
c=$(grep -cE "$RE" "$PROBE_DOC" 2>/dev/null)
if [ "$c" = "4" ]; then
done_ "all four answered - plan Step 1 Verify passes"
else
pend "$c of 4 answered in docs/cowork-probe.md"
fi
grep -E '^- (Sesjonsmodus|Host-MCP|python3|CLAUDE_PLUGIN_ROOT):' "$PROBE_DOC" 2>/dev/null | sed 's|^| |'
printf '\n============================================================================\n'
if [ "$pending" -eq 0 ]; then
printf 'ALL CHECKS LANDED.\n'
exit 0
fi
printf '%s check(s) still pending.\n' "$pending"
exit 1

View file

@ -0,0 +1,257 @@
"""Start the jobbsok-tools stdio MCP server on an interpreter that is new enough.
Python rather than bash, and standard library only. Stock Windows has no bash,
so a shell launcher meant `.mcp.json` could not start the server there at all.
This file is deliberately written to parse on an OLD interpreter -- no syntax
newer than 3.7 -- because the interpreter that starts it is exactly the one it
exists to distrust: refusing 3.9.6 is worth nothing if the refusal is a
SyntaxError.
Why this file exists at all, rather than .mcp.json naming an interpreter
directly: a GUI-spawned process on this Mac can resolve python3 to 3.9.6 under
an empty launchd PATH (risk H2). A server running on 3.9.6 is worse than no
server, because the degradation branch already covers an absent server and
nothing covers a silently wrong one. So every candidate below is gated on the
version, and when none passes the launcher refuses instead of falling through
to whatever interpreter the PATH happens to offer.
Resolution order, first candidate that passes the gate wins:
1. $JOBBSOK_PYTHON -- the manual override. Named explicitly by
the operator, so a version failure here
is refused outright rather than skipped:
falling back from an interpreter someone
asked for would hide their mistake.
2. $CLAUDE_PLUGIN_DATA/venv -- what scripts/bootstrap.py builds against
an installed plugin. This is the
supported route in an installed copy.
3. $CLAUDE_PLUGIN_ROOT/.venv -- the development environment. The packaged
archive excludes the virtualenv, so this
branch never fires in an installed copy.
4. python on PATH -- last resort, and only at 3.10 or newer.
Two environment variables, and they are NOT the same one:
JOBBSOK_LAUNCH_PYTHON names the interpreter that STARTS this file, and is
read by .mcp.json, never here. It exists because the
plugin MCP schema has no platform-conditional command
(measured 2026-09-05: an invented per-OS key is
silently discarded) and no literal name exists on both
macOS and Windows. It defaults to python3, so nothing
changes on macOS or Linux.
JOBBSOK_PYTHON names the interpreter to SERVE on, and is candidate 1
below. Setting it wins over the bootstrapped
virtualenv, so it must not be borrowed for the
spelling problem above: a Windows adopter setting it
merely to say `python` would serve without the guard.
The gate is on the VERSION, not on the guard being importable. That is the
shell version's semantics carried over unchanged, and it is a known weakness
(O4 in docs/cowork-probe.md): `selvsjekk` can report `guard_versjon: null`
while the server runs happily. It is harmless while nothing writes and becomes
a defect in M2. Fixing it is an M2 decision, not a porting one.
"""
import os
import shutil
import subprocess
import sys
MIN_MAJOR = 3
MIN_MINOR = 10
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
class Avvist(Exception):
"""An interpreter the operator named explicitly and that cannot be used.
Separate from "no candidate found" on purpose: skipping past a broken
interpreter someone asked for by name would hide their mistake.
"""
def __init__(self, linjer):
Exception.__init__(self, linjer[0])
self.linjer = linjer
def path_candidates(os_name=None):
"""What the interpreter is called on PATH, on ``os_name``.
POSIX keeps exactly what the shell version tried, so nothing changes
there. Windows has no `python3` unless Python came from the Microsoft
Store -- the python.org installer ships `python` and `py`, and a bare
`python3` there resolves to the Store stub, which opens a shop rather than
running anything.
"""
if os_name is None:
os_name = os.name
if os_name == "nt":
return ("python", "py")
return ("python3",)
def venv_python(venv_dir, os_name=None):
"""The interpreter inside a virtualenv, in the layout this platform uses.
POSIX venvs carry both `bin/python` and `bin/python3`; Windows venvs carry
`Scripts/python.exe` and no `python3` at all, so `python` is the one name
that is right everywhere.
"""
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. Runs it; does not parse a name."""
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 usable(kandidat, which=None, gate=None):
"""Return ``kandidat`` resolved as a program if it passes the gate, else None.
``which`` stands in for the shell version's `command -v`: given a path it
checks that path, given a bare name it searches PATH, and on Windows it
applies PATHEXT, which is how `python.exe` is found from `python`.
"""
if which is None:
which = shutil.which
if gate is None:
gate = version_ok
if not kandidat:
return None
funnet = which(kandidat)
if not funnet:
return None
if not gate(funnet):
return None
return funnet
def resolve_interpreter(env, plugin_root, which=None, gate=None):
"""Return the first candidate that passes the gate, or None.
Raises ``Avvist`` for the one case that must not fall through: an
interpreter the operator named in JOBBSOK_PYTHON that cannot be used.
"""
if which is None:
which = shutil.which
if gate is None:
gate = version_ok
navngitt = env.get("JOBBSOK_PYTHON")
if navngitt:
funnet = which(navngitt)
if not funnet:
raise Avvist(
[
"jobbsok-tools: JOBBSOK_PYTHON=%s is not executable." % navngitt,
"jobbsok-tools: refusing to start. Point it at a Python "
"%d.%d+ interpreter." % (MIN_MAJOR, MIN_MINOR),
]
)
if not gate(funnet):
raise Avvist(
[
"jobbsok-tools: JOBBSOK_PYTHON=%s is Python %s."
% (navngitt, version_of(funnet)),
"jobbsok-tools: %d.%d+ is required; refusing to start on it."
% (MIN_MAJOR, MIN_MINOR),
]
)
return funnet
plugin_data = env.get("CLAUDE_PLUGIN_DATA")
if plugin_data:
funnet = usable(venv_python(os.path.join(plugin_data, "venv")), which, gate)
if funnet:
return funnet
funnet = usable(venv_python(os.path.join(plugin_root, ".venv")), which, gate)
if funnet:
return funnet
for navn in path_candidates():
funnet = usable(navn, which, gate)
if funnet:
return funnet
return None
def main(argv, env=None):
if env is None:
env = os.environ
plugin_root = env.get("CLAUDE_PLUGIN_ROOT") or os.path.dirname(SCRIPT_DIR)
server = os.path.join(plugin_root, "scripts", "jobbsok_tools.py")
try:
tolk = resolve_interpreter(env, plugin_root)
except Avvist as avvist:
for linje in avvist.linjer:
sys.stderr.write(linje + "\n")
return 1
if not tolk:
sys.stderr.write(
"jobbsok-tools: found no Python %d.%d+ interpreter.\n"
% (MIN_MAJOR, MIN_MINOR)
)
sys.stderr.write(
"jobbsok-tools: tried JOBBSOK_PYTHON, $CLAUDE_PLUGIN_DATA/venv, "
"$CLAUDE_PLUGIN_ROOT/.venv and %s on PATH.\n"
% " or ".join(path_candidates())
)
sys.stderr.write(
"jobbsok-tools: run 'python3 scripts/bootstrap.py' against the "
"installed plugin, or set JOBBSOK_PYTHON.\n"
)
sys.stderr.write(
"jobbsok-tools: refusing to start rather than serving on an "
"interpreter below %d.%d.\n" % (MIN_MAJOR, MIN_MINOR)
)
return 1
if not os.path.isfile(server):
sys.stderr.write("jobbsok-tools: server not found at %s\n" % server)
return 1
# POSIX replaces this process, as the shell version's `exec` did: the MCP
# client's child stays the process it spawned, and killing it kills the
# server. Windows has no such replacement -- os.execv there starts a new
# process and lets this one exit, which would leave the client holding a
# dead pid and a live pipe -- so there the launcher stays as the parent.
if os.name != "nt":
os.execv(tolk, [tolk, server] + list(argv))
return subprocess.call([tolk, server] + list(argv))
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -1,107 +0,0 @@
#!/bin/bash
# Start the jobbsok-tools stdio MCP server on an interpreter that is new enough.
#
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2,
# and a multibyte character has crashed a `set -u` script here before.
#
# Why this file exists at all, rather than .mcp.json naming python3 directly:
# a GUI-spawned process on this Mac can resolve python3 to 3.9.6 under an
# empty launchd PATH (risk H2). A server running on 3.9.6 is worse than no
# server, because the degradation branch already covers an absent server and
# nothing covers a silently wrong one. So every candidate below is gated on
# the version, and when none passes the launcher refuses instead of falling
# through to whatever python3 the PATH happens to offer.
#
# Resolution order, first candidate that passes the gate wins:
# 1. $JOBBSOK_PYTHON -- the manual override. Named explicitly by
# the operator, so a version failure here
# is refused outright rather than skipped:
# falling back from an interpreter someone
# asked for would hide their mistake.
# 2. $CLAUDE_PLUGIN_DATA/venv -- what scripts/bootstrap.sh builds against
# an installed plugin. This is the
# supported route in an installed copy.
# 3. $CLAUDE_PLUGIN_ROOT/.venv -- the development environment. The packaged
# archive excludes the virtualenv, so this
# branch never fires in an installed copy.
# 4. python3 on PATH -- last resort, and only at 3.10 or newer.
set -eu
MIN_MAJOR=3
MIN_MINOR=10
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
PLUGIN_ROOT="$CLAUDE_PLUGIN_ROOT"
else
PLUGIN_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
fi
SERVER="${PLUGIN_ROOT}/scripts/jobbsok_tools.py"
version_ok() {
"$1" -c "import sys; sys.exit(0 if sys.version_info >= ($MIN_MAJOR, $MIN_MINOR) else 1)" \
>/dev/null 2>&1
}
version_of() {
"$1" -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>/dev/null || echo "ukjent"
}
usable() {
[ -n "$1" ] || return 1
command -v "$1" >/dev/null 2>&1 || return 1
version_ok "$1"
}
PYTHON_BIN=""
if [ -n "${JOBBSOK_PYTHON:-}" ]; then
if ! command -v "$JOBBSOK_PYTHON" >/dev/null 2>&1; then
echo "jobbsok-tools: JOBBSOK_PYTHON=$JOBBSOK_PYTHON is not executable." >&2
echo "jobbsok-tools: refusing to start. Point it at a Python ${MIN_MAJOR}.${MIN_MINOR}+ interpreter." >&2
exit 1
fi
if ! version_ok "$JOBBSOK_PYTHON"; then
echo "jobbsok-tools: JOBBSOK_PYTHON=$JOBBSOK_PYTHON is Python $(version_of "$JOBBSOK_PYTHON")." >&2
echo "jobbsok-tools: ${MIN_MAJOR}.${MIN_MINOR}+ is required; refusing to start on it." >&2
exit 1
fi
PYTHON_BIN="$JOBBSOK_PYTHON"
fi
if [ -z "$PYTHON_BIN" ] && [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then
CANDIDATE="${CLAUDE_PLUGIN_DATA}/venv/bin/python"
if usable "$CANDIDATE"; then
PYTHON_BIN="$CANDIDATE"
fi
fi
if [ -z "$PYTHON_BIN" ]; then
CANDIDATE="${PLUGIN_ROOT}/.venv/bin/python3"
if usable "$CANDIDATE"; then
PYTHON_BIN="$CANDIDATE"
fi
fi
if [ -z "$PYTHON_BIN" ]; then
CANDIDATE=$(command -v python3 2>/dev/null || true)
if usable "$CANDIDATE"; then
PYTHON_BIN="$CANDIDATE"
fi
fi
if [ -z "$PYTHON_BIN" ]; then
echo "jobbsok-tools: found no Python ${MIN_MAJOR}.${MIN_MINOR}+ interpreter." >&2
echo "jobbsok-tools: tried JOBBSOK_PYTHON, \$CLAUDE_PLUGIN_DATA/venv, \$CLAUDE_PLUGIN_ROOT/.venv and python3 on PATH." >&2
echo "jobbsok-tools: run 'bash scripts/bootstrap.sh' against the installed plugin, or set JOBBSOK_PYTHON." >&2
echo "jobbsok-tools: refusing to start rather than serving on an interpreter below ${MIN_MAJOR}.${MIN_MINOR}." >&2
exit 1
fi
if [ ! -f "$SERVER" ]; then
echo "jobbsok-tools: server not found at $SERVER" >&2
exit 1
fi
exec "$PYTHON_BIN" "$SERVER" "$@"

160
scripts/package_plugin.py Normal file
View file

@ -0,0 +1,160 @@
"""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:]))

View file

@ -1,98 +0,0 @@
#!/bin/bash
# Build jobbsok.plugin from an explicit include list.
#
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2.
#
# The include list is the whole point, and it is not a convenience. zip 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.sh once against it, which builds the environment under
# $CLAUDE_PLUGIN_DATA. README.md says so.
#
# Usage:
# bash scripts/package_plugin.sh # -> <repo root>/jobbsok.plugin
# bash scripts/package_plugin.sh --ut <sti> # -> somewhere else
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
OUT="${REPO_ROOT}/jobbsok.plugin"
while [ $# -gt 0 ]; do
case "$1" in
--ut)
if [ $# -lt 2 ]; then
echo "package_plugin: --ut needs a path" >&2
exit 2
fi
OUT="$2"
shift 2 ;;
-h|--help)
sed -n '2,23p' "$0"
exit 0 ;;
*)
echo "package_plugin: unknown argument: $1" >&2
exit 2 ;;
esac
done
case "$OUT" in
/*) ;;
*) OUT="$(pwd)/$OUT" ;;
esac
# 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.
bash "${SCRIPT_DIR}/build_stamp.sh"
# The include list. Directories are archived whole, minus the exclusions below.
# Entries that do not exist yet are reported and skipped -- hooks/, templates/
# and SECURITY.md land in later steps -- but the two the archive is worthless
# without are required outright.
INCLUDE=".claude-plugin skills scripts hooks templates .mcp.json README.md SECURITY.md LICENSE BUILD_STAMP"
REQUIRED=".claude-plugin/plugin.json BUILD_STAMP"
for path in $REQUIRED; do
if [ ! -e "${REPO_ROOT}/${path}" ]; then
echo "package_plugin: required entry missing: $path" >&2
exit 1
fi
done
PRESENT=""
SKIPPED=""
for path in $INCLUDE; do
if [ -e "${REPO_ROOT}/${path}" ]; then
PRESENT="$PRESENT $path"
else
SKIPPED="$SKIPPED $path"
fi
done
if [ -n "$SKIPPED" ]; then
echo "package_plugin: not present yet, skipped:$SKIPPED"
fi
rm -f "$OUT"
# -X drops the extra file attributes, so the archive is reproducible enough
# that two builds of the same commit differ only in timestamps. The exclusions
# are 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.
cd "$REPO_ROOT"
# shellcheck disable=SC2086 -- PRESENT is a deliberate word-split list
zip -q -r -X "$OUT" $PRESENT \
-x '*/__pycache__/*' '*.pyc' '*/.pytest_cache/*' '*/.venv/*' '*.local.md' \
'*/.DS_Store'
echo "package_plugin: $(basename "$OUT") built from $(cat "${REPO_ROOT}/BUILD_STAMP")"
echo "package_plugin: $OUT"