Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/setup/marketplace-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Add further families the same way.
Verify with `/plugins` inside Codex, or `codex plugin list` from the shell.

Codex has no auto-update setting for marketplaces, so refreshing is a manual
step — `codex plugin marketplace upgrade` re-pulls the configured Git snapshots.
step — `codex plugin marketplace upgrade apache-magpie` re-pulls the Magpie Git snapshot.
Run it when you want the newer skills; nothing moves on its own.

Once the baseline is in, ask for the rest in plain language — *install the
Expand Down
2 changes: 1 addition & 1 deletion plugins/magpie-setup/skills/setup/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ Tell the user, in this order:
4. **How it updates** — Claude Code:
`/plugin marketplace update apache-magpie` then
`/plugin update <plugin>@apache-magpie`; Codex:
`codex plugin update magpie`; Gemini:
`codex plugin marketplace upgrade apache-magpie`; Gemini:
`gemini extensions update magpie`. The bundled `SessionStart`
hook prompts on a version change where the client runs hooks.
5. **Configure it for yourself when a skill asks.** A marketplace
Expand Down
153 changes: 113 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 @@ -50,50 +52,121 @@
from .delegation import findings as delegation_findings
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"]),
Comment thread
potiuk marked this conversation as resolved.
("copilot", (("COPILOT_CLI", None),), None),
("gemini", (("GEMINI_CLI", "1"),), ["gemini", "extensions", "list", "-o", "json"]),
Comment thread
potiuk marked this conversation as resolved.
("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.
def _parse_listing(text: str, harness: str | None = None) -> dict[str, str] | None:
"""One harness listing, parsed into the shared `{name: version}` map.

`--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.
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):
# Codex 0.149.1 separates installed plugins from available catalogue
# entries. Only the former describe what this session can run.
if harness == "codex" and isinstance(listing, dict):
listing = listing.get("installed")
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 not isinstance(item.get("name"), str) or not isinstance(version, str):
return None
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 still detected from the environment, so its own parsing
and floor rules apply, and stays `None` only when no harness with a
listing command is running — the map then 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. An empty listing also stays unknown because it
cannot be distinguished from a denied cache read.
"""
if explicit is not None:
text = sys.stdin.read() if explicit == "-" else Path(explicit).read_text(encoding="utf-8")
# A supplied listing is still the running harness's own: without
# its id, a Gemini `magpie` extension reads as every floor plugin
# missing and a Codex envelope as unparsable.
harness, argv = _running_harness(os.environ if env is None else env)
if argv is None:
harness = None
parsed = _parse_listing(text, harness)
return (harness if parsed is not None else None), parsed
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, harness)
if parsed is None:
return None, None
return harness, parsed


def _delegation(root: Path, skill: str) -> list[Finding]:
Expand Down Expand Up @@ -127,7 +200,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 @@ -165,13 +238,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 = [
*([] if args.no_welcome else welcome_findings(root, args.skill)),
*project,
Expand Down
53 changes: 38 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,13 +152,24 @@ 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:
# Unknown, never absent. Say nothing and let the run continue: this
# is the ordinary state of a sandboxed session, not a fault.
return []
if harness == "gemini":
# Gemini ships all families in one extension. Its version carries
# every floor entry; an unrecognised extension name is unknown.
version = installed.get("magpie")
if version is None:
return []
installed = dict.fromkeys(lock.plugins, version)
elif harness == "codex" and not any(name in installed for name in lock.plugins):
# Until a listing identifies an exact floor name, qualified names
# or an unfamiliar schema are not evidence that plugins are absent.
return []
missing = [name for name in lock.plugins if name not in installed]
outdated = []
for name in lock.plugins:
Expand All @@ -177,7 +188,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 +234,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 +254,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 @@ -483,7 +502,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 @@ -496,6 +515,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 @@ -512,26 +534,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 @@ -548,7 +571,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 @@ -21,7 +21,7 @@ On a pinned-snapshot install the commands are shorter: `/magpie-setup config`, `
- ✋ **By hand:** `claude plugin marketplace update apache-magpie`, then `claude plugin update <plugin>@apache-magpie`
- 🤖 **Automatically (Claude Code):** `/plugin` → **Marketplaces** → **apache-magpie** → **Enable auto-update** —
it is off by default for third-party marketplaces like this one
- 🧩 Gemini CLI takes `--auto-update` at install time; Codex has no auto-update, so run `codex plugin marketplace upgrade`
- 🧩 Gemini CLI takes `--auto-update` at install time; Codex has no auto-update, so run `codex plugin marketplace upgrade apache-magpie`

After an update, the next skill you run checks your configuration against it and proposes any fixes;
`/magpie-setup:setup reconcile` checks everything at once
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,37 @@ 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.**
For Codex, read only the `installed` list in its JSON envelope; available
catalogue entries are not installed versions. An unfamiliar name form or
schema stays unknown. Require an exact floor plugin name before trusting
a Codex listing, and the exact `magpie` extension name on Gemini.

- a floor plugin absent → `claude plugin install <plugin>@apache-magpie`;
- a floor plugin below `min_version` → `claude plugin update
<plugin>@apache-magpie`.
**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:

| 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 marketplace upgrade apache-magpie` |
| 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.

`facts.harness` is `null` when the caller supplied the listing and no
harness with a listing command is running. Use the row of the harness this
session runs in; if that is not known, print every row rather than guess.

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
Loading
Loading