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:
parent
f8c74fa0ac
commit
dcd3eae534
20 changed files with 1523 additions and 542 deletions
210
scripts/bootstrap.py
Normal file
210
scripts/bootstrap.py
Normal 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:]))
|
||||
|
|
@ -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
94
scripts/build_stamp.py
Normal 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:]))
|
||||
|
|
@ -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"
|
||||
260
scripts/cowork_probe_check.py
Normal file
260
scripts/cowork_probe_check.py
Normal 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:]))
|
||||
|
|
@ -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
|
||||
257
scripts/jobbsok_tools_launch.py
Normal file
257
scripts/jobbsok_tools_launch.py
Normal 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:]))
|
||||
|
|
@ -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
160
scripts/package_plugin.py
Normal 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:]))
|
||||
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue