Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 102 additions & 40 deletions plugins/magpie-setup/skills/setup/setup_preflight/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@

import argparse
import json
import os
import subprocess
import sys
from collections.abc import Mapping
from pathlib import Path

from .core import (
Expand All @@ -47,50 +49,110 @@
)
from .lockfile import MalformedLock

#: How to notice which harness is running, and that harness's own
#: plugin-listing command (`install.md` Step M2/M5). The markers mirror
#: the canonical self-detection order in `tools/adversarial-review`: a
#: harness started from inside another inherits the outer one's variables,
#: so the innermost candidates are checked first and Claude Code's widely
#: inherited `CLAUDECODE` comes last. Only the running harness's listing
#: runs — a machine may have several installed, and whichever comes first
#: in a fixed probe order is not the one whose versions this session runs.
#: Copilot installs from a repo URL or catalogue and has no listing CLI,
#: so its entry has none: the floor stays unknown there rather than
#: guessing what a listing would print.
HARNESS_LISTINGS: tuple[
tuple[str, tuple[tuple[str, str | None], ...], list[str] | None], ...
] = (
("codex", (("CODEX_SANDBOX", None), ("CODEX_THREAD_ID", None)), ["codex", "plugin", "list", "--json"]),
("copilot", (("COPILOT_CLI", None),), None),
("gemini", (("GEMINI_CLI", "1"),), ["gemini", "extensions", "list", "-o", "json"]),
("claude", (("CLAUDECODE", "1"),), ["claude", "plugin", "list", "--json"]),
)


def _running_harness(env: Mapping[str, str]) -> tuple[str | None, list[str] | None]:
"""The harness these environment variables are running, and its listing."""
for harness, markers, argv in HARNESS_LISTINGS:
for var, want in markers:
value = env.get(var)
if value and (want is None or value == want):
return harness, argv
return None, None

def read_installed(explicit: str | None) -> dict[str, str] | None:
"""The installed plugin map, or `None` for *unknown* — never `{}` for it.

`--plugin-list` lets the caller supply what it already read; otherwise
the harness CLI is asked directly. Every failure mode collapses to
`None`: the command missing, a non-zero exit, unparsable output, or a
sandbox that denies the plugin cache. The one case that is *not*
unknown is a listing that parsed and was genuinely empty, which only a
harness with no plugins installed produces.
def _parse_listing(text: str) -> dict[str, str] | None:
"""One harness listing, parsed into the shared `{name: version}` map.

Shared by every harness's listing command: `None` for unknown (the
output did not parse, or an empty parsed listing leaves the sandbox
read-denial ambiguity standing), never an empty map.
"""
if explicit is not None:
text = sys.stdin.read() if explicit == "-" else Path(explicit).read_text(encoding="utf-8")
else:
try:
completed = subprocess.run(
["claude", "plugin", "list", "--json"],
capture_output=True,
text=True,
timeout=20,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
if completed.returncode != 0:
return None
text = completed.stdout
try:
parsed = json.loads(text)
listing = json.loads(text)
except json.JSONDecodeError:
return None
if not isinstance(parsed, list):
if not isinstance(listing, list):
return None
installed: dict[str, str] = {}
for entry in parsed:
if not isinstance(entry, dict):
out: dict[str, str] = {}
for item in listing:
if not isinstance(item, dict):
return None
name, version = entry.get("name"), entry.get("version")
if isinstance(name, str) and isinstance(version, str):
installed[name] = version
# An empty *parsed* list inside a sandbox is the read-denied case, and
# is indistinguishable from a genuinely plugin-free harness. Unknown is
# the safe reading of the ambiguity: it proposes nothing.
return installed or None
version = item.get("version")
if isinstance(item.get("name"), str) and isinstance(version, str):
out[item["name"]] = version
# A listing that parses but is empty leaves the sandbox read-denial
# ambiguity standing, so we report unknown rather than an empty map.
return out or None


def read_installed(
explicit: str | None, env: Mapping[str, str] | None = None
) -> tuple[str | None, dict[str, str] | None]:
"""The running harness and its installed plugin map, or `None` for
*unknown* — never `{}` for it. The pair is a coherent answer: both
are `None` unless the running harness's own listing was read and
parsed.

`--plugin-list` lets the caller supply what it already read; the
harness is then not known, and the map stands alone. Otherwise the
running harness is detected from its own environment markers and only
its listing command runs — no other harness's listing may answer for
it. Every failure mode collapses to `(None, None)`: the harness
undetectable, a listing CLI the running harness does not have
(Copilot), the command missing, a non-zero exit, a timeout or a
refusal from the sandbox, unparsable output, or a sandbox that denies
the plugin cache. The one case that is *not* unknown is a listing
that parsed and was genuinely empty, which only a harness with no
plugins installed produces.
"""
if explicit is not None:
text = sys.stdin.read() if explicit == "-" else Path(explicit).read_text(encoding="utf-8")
return None, _parse_listing(text)
harness, argv = _running_harness(os.environ if env is None else env)
if argv is None:
# No harness is running here, or the one running has no listing
# CLI (Copilot installs from a repo URL): unknown, and no other
# harness's listing may be probed instead.
return None, None
try:
completed = subprocess.run(
argv,
check=False,
capture_output=True,
text=True,
timeout=20,
)
except (OSError, subprocess.SubprocessError):
# The listing cannot be produced: the binary is missing from the
# harness it belongs to, it times out, or the sandbox refuses to
# execute it. Unknown — never another harness's answer.
return None, None
if completed.returncode != 0:
return None, None
parsed = _parse_listing(completed.stdout)
if parsed is None:
return None, None
return harness, parsed


def build_parser() -> argparse.ArgumentParser:
Expand All @@ -111,7 +173,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--plugin-list",
metavar="PATH",
help="`claude plugin list --json` output to use instead of running it ('-' for stdin)",
help="harness plugin listing JSON to use instead of running it ('-' for stdin)",
)
parser.add_argument(
"--verify-interval-days",
Expand Down Expand Up @@ -144,13 +206,13 @@ def main(argv: list[str] | None = None) -> int:
root: Path = args.project_root

try:
installed = read_installed(args.plugin_list)
harness, installed = read_installed(args.plugin_list)
if args.no_cache:
from .core import project_findings

project, cached = project_findings(root, installed), False
project, cached = project_findings(root, installed, harness), False
else:
project, cached = cached_project_findings(root, installed)
project, cached = cached_project_findings(root, installed, harness=harness)
findings = [
*project,
*skill_findings(root, args.skill, args.surface_hash, args.requires),
Expand Down
44 changes: 29 additions & 15 deletions plugins/magpie-setup/skills/setup/setup_preflight/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ def _snapshot_findings(lock: Lock, root: Path) -> list[Finding]:
return []


def _floor_findings(lock: Lock, installed: dict[str, str] | None) -> list[Finding]:
def _floor_findings(
lock: Lock, installed: dict[str, str] | None, harness: str | None
) -> list[Finding]:
if lock.url and lock.url != TRUSTED_MARKETPLACE:
return [Finding("project", "untrusted-marketplace", "step-3", {"url": lock.url})]
if installed is None:
Expand All @@ -177,7 +179,7 @@ def _floor_findings(lock: Lock, installed: dict[str, str] | None) -> list[Findin
"project",
"below-floor",
"step-3",
{"missing": missing, "outdated": outdated, "restart_required": True},
{"missing": missing, "outdated": outdated, "restart_required": True, "harness": harness},
)
]

Expand Down Expand Up @@ -223,8 +225,16 @@ def legacy_layout_findings(root: Path) -> list[Finding]:
]


def project_findings(root: Path, installed: dict[str, str] | None) -> list[Finding]:
"""Everything true of the checkout rather than of one skill."""
def project_findings(
root: Path, installed: dict[str, str] | None, harness: str | None = None
) -> list[Finding]:
"""Everything true of the checkout rather than of one skill.

`harness` is the agent the session is running under, as the floor
check measured it — `None` when the listing came from `--plugin-list`
or could not be read. It travels with the findings so the rules can
name that harness's own install and update commands.
"""
lock = load(root / LOCK_NAME)
if lock is None:
# A supported end state, not a fault: the marketplace install
Expand All @@ -235,7 +245,7 @@ def project_findings(root: Path, installed: dict[str, str] | None) -> list[Findi
if lock.method in SNAPSHOT_METHODS:
return _snapshot_findings(lock, root)
if lock.method == "marketplace":
return _floor_findings(lock, installed)
return _floor_findings(lock, installed, harness)
if lock.method == LOCAL_METHOD:
return []
return [Finding("project", "unknown-method", "step-2", {"method": lock.method})]
Expand Down Expand Up @@ -445,7 +455,7 @@ def isolated_setup_findings(
# --- memoising the project scope ----------------------------------------------------


def _cache_key(root: Path, installed: dict[str, str] | None) -> str:
def _cache_key(root: Path, installed: dict[str, str] | None, harness: str | None) -> str:
# Linked worktrees of an adopted repository can share their main
# checkout's personal layer, and so this cache file: key it on the
# checkout as well, so one worktree never serves another's verdict.
Expand All @@ -458,6 +468,9 @@ def _cache_key(root: Path, installed: dict[str, str] | None) -> str:
# The legacy-layout finding depends on the in-tree directory existing.
parts.append("legacy:" + ("1" if legacy_local_dir(root) is not None else "-"))
parts.append("installed:" + ("?" if installed is None else json.dumps(installed, sort_keys=True)))
# The below-floor finding names the harness the listing came from, so a
# verdict computed under one harness must not be served to another.
parts.append("harness:" + (harness or "-"))
# The verdict is also a function of this code: an upgrade that refreshes
# the checker must not keep serving the previous checker's answer.
here = Path(__file__).parent
Expand All @@ -474,26 +487,27 @@ def _cache_key(root: Path, installed: dict[str, str] | None) -> str:
def cached_project_findings(
root: Path,
installed: dict[str, str] | None,
harness: str | None = None,
*,
now: float | None = None,
) -> tuple[list[Finding], bool]:
"""Project findings, reusing a recent verdict computed from the same inputs.

Returns `(findings, was_cached)`. The cache lives in the personal
layer (`layers.personal_dir`, which in a linked worktree of an adopted
repository may be its main checkout's), is keyed on the checkout, the lock files' identity and
the plugin listing, and expires so that a plugin installed mid-session
is picked up by the next skill rather than at the end of the day. A
project whose personal layer does not exist is not cached at all —
creating it would create the very state whose absence the skill scope
reads as "never configured".
repository may be its main checkout's), is keyed on the checkout, the lock files' identity,
the plugin listing and the harness the listing came from, and expires
so that a plugin installed mid-session is picked up by the next skill
rather than at the end of the day. A project whose personal layer
does not exist is not cached at all — creating it would create the
very state whose absence the skill scope reads as "never configured".
"""
local_dir = personal_dir(root)
if local_dir is None or not local_dir.is_dir():
return project_findings(root, installed), False
return project_findings(root, installed, harness), False

stamp_now = now if now is not None else time.time()
key = _cache_key(root, installed)
key = _cache_key(root, installed, harness)
cache_path = local_dir / CACHE_NAME
if cache_path.exists():
try:
Expand All @@ -510,7 +524,7 @@ def cached_project_findings(
if isinstance(entries, list):
return [Finding(**entry) for entry in entries], True

findings = project_findings(root, installed)
findings = project_findings(root, installed, harness)
payload = {"key": key, "at": stamp_now, "findings": [asdict(f) for f in findings]}
tmp = cache_path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload), encoding="utf-8")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,28 @@ the release segment. The reconciliation check in step 4 is gated on the
fingerprint, never on this version delta.

**Why an unreadable result is *unknown* rather than *absent*.** Inside a
sandboxed session the plugin cache is read-denied and `claude plugin list
--json` returns `[]` there — that reads exactly like "nothing installed"
sandboxed session the plugin cache is read-denied and the running harness's
own listing returns `[]` there — that reads exactly like "nothing installed"
but is not. Acting on it would propose installing a project's entire floor
on every sandboxed run.

**The actions.**
**The actions.** The `below-floor` finding names the harness its listing
came from (`facts.harness`); the commands differ per client, and naming one
the operator cannot run in this session wastes their turn:

- a floor plugin absent → `claude plugin install <plugin>@apache-magpie`;
- a floor plugin below `min_version` → `claude plugin update
<plugin>@apache-magpie`.
| Harness | a floor plugin absent | a floor plugin below `min_version` |
|---|---|---|
| Claude Code | `claude plugin install <plugin>@apache-magpie` | `claude plugin update <plugin>@apache-magpie` |
| Codex | `codex plugin add <plugin>@apache-magpie` | `codex plugin update <plugin>` |
| Gemini CLI | `gemini extensions install https://github.com/apache/magpie` | `gemini extensions update magpie` |

Gemini installs the framework as one extension, so on it the floor is
carried by the `magpie` extension as a whole: update that one extension
rather than naming individual plugins.

Copilot CLI installs from a repo URL or catalogue and has no plugin CLI to
read a listing or run an action with. There the floor stays unknown: run
nothing, and leave it to the user.

Where there is no such CLI, run nothing and print the commands instead.

Expand Down
17 changes: 9 additions & 8 deletions tools/setup-preflight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ how to word it, and the prohibitions. Those live in each skill's
- **Runtime** — Python 3.11+. Standard library only, deliberately: the
module is copied into the user's personal configuration layer and run
with bare `python3`, where nothing else is available.
- **CLIs** — none required. The harness CLI (`claude plugin list --json`)
is consulted when present and its absence is a supported state, not an
error.
- **CLIs** — none required. The running harness's own listing command
(detected from its environment markers, e.g. `claude plugin list
--json` under Claude Code) is consulted when present; its absence or
the absence of any harness marker is a supported state, not an error.
- **Credentials / auth** — none.
- **Network** — none. Every input is a file in the project.
- **Optional** — `--plugin-list` accepts a listing the caller already
Expand Down Expand Up @@ -147,11 +148,11 @@ is settled when the run finishes rather than before it starts.

**Unknown is never absent.** A plugin listing that could not be read is
`None`, not `{}`. Inside a sandboxed session the plugin cache is
read-denied and `claude plugin list --json` prints `[]`, which reads
exactly like "nothing installed"; acting on it would propose installing a
project's entire floor on every sandboxed run. An empty *parsed* listing
is treated as unknown for the same reason — it is indistinguishable from
the denied case.
read-denied and the running harness's own listing prints `[]`, which
reads exactly like "nothing installed"; acting on it would propose
installing a project's entire floor on every sandboxed run. An empty
*parsed* listing is treated as unknown for the same reason — it is
indistinguishable from the denied case.

**A dev build is a version like any other.** Nothing strips or rounds a
`.devN` segment. `0.2.0.dev202609110041` is below `0.2.0`, and `0.10.0`
Expand Down
Loading