"""``helix plugin`` — install / update / inspect the Claude Code plugin. The backend publishes the plugin as a zip of a whole Claude Code marketplace directory (``plugin-dist/`` on S3, mirrored from ``plugin/marketplace/`` in the Helix repo). This module: 1. reads the manifest (``GET /api/v1/plugin/manifest``, PAT-authed), 2. fetches a presigned URL (``GET /api/v1/plugin/download``) and downloads the zip WITHOUT the PAT (the URL is self-authenticating — never send a credential to S3), 3. verifies sha256 + size against the manifest, 4. unpacks atomically into ``~/.helix/claude-plugin/`` (stable path, so the Claude Code marketplace registration never has to change), and 5. registers/refreshes the marketplace with the ``claude`` binary when it is on PATH (skippable via ``--no-register``; prints the manual commands otherwise). Design notes: * The stable install dir means ``claude plugin marketplace add`` runs once per machine; afterwards ``helix plugin update`` + ``claude plugin marketplace update helix`` refresh in place. * Zip extraction guards against zip-slip (absolute paths / ``..`` members) and symlink members, and caps the artifact size — the zip comes from our own bucket, but presigned-URL fetches deserve the same hygiene as any remote archive. * All remote-error / network / auth failures reuse the CLI-wide exit contract via :class:`helix_cli.http_client.CliRestError`. """ from __future__ import annotations import hashlib import json import shutil import subprocess import tempfile import zipfile from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Any import httpx from helix_cli import http_client from helix_cli.config import CliConfig from helix_cli.http_client import CliRestError #: Root dir inside the zip — fixed by plugin-release.yml. ZIP_ROOT = "helix-claude-plugin" #: Hard ceiling on the artifact we're willing to download/unpack. The #: real zip is well under 1 MB; 50 MB means a corrupted/mispublished #: object can't fill the disk. MAX_ARTIFACT_BYTES = 50 * 1024 * 1024 #: Marketplace name (matches marketplace.json) → `helix@helix`. MARKETPLACE_NAME = "helix" PLUGIN_SPEC = "helix@helix" def default_install_base() -> Path: return Path.home() / ".helix" / "claude-plugin" def marketplace_dir(base: Path) -> Path: return base / ZIP_ROOT def receipt_path(base: Path) -> Path: return base / "installed.json" # ── remote metadata ───────────────────────────────────────────────── def fetch_manifest(config: CliConfig, *, timeout: float) -> dict: resp = http_client.get(config, "/api/v1/plugin/manifest", token=config.token, timeout=timeout) data = http_client.envelope_data(resp) if not isinstance(data, dict): raise CliRestError("unexpected plugin manifest shape", exit_code=5, code="BAD_RESPONSE") return data def _fetch_presigned(config: CliConfig, *, timeout: float) -> dict: resp = http_client.get(config, "/api/v1/plugin/download", token=config.token, timeout=timeout) data = http_client.envelope_data(resp) if not isinstance(data, dict) or not data.get("url"): raise CliRestError("plugin download unavailable", exit_code=4, code="UNAVAILABLE") return data # ── download + verify ─────────────────────────────────────────────── def _download_zip(url: str, dest: Path, *, timeout: float) -> tuple[str, int]: """Stream ``url`` → ``dest``; return (sha256, size). No auth header — the presigned URL is the credential.""" h = hashlib.sha256() size = 0 try: # trust_env=False / verify=True: same posture as http_client. with httpx.Client(timeout=httpx.Timeout(timeout), trust_env=False, verify=True) as client: with client.stream("GET", url) as r: if r.status_code != 200: raise CliRestError( f"artifact fetch failed (status {r.status_code})", exit_code=4, code="FETCH_FAILED", ) with dest.open("wb") as f: for chunk in r.iter_bytes(1 << 20): size += len(chunk) if size > MAX_ARTIFACT_BYTES: raise CliRestError( "artifact exceeds the size ceiling — refusing to continue", exit_code=5, code="TOO_LARGE", ) h.update(chunk) f.write(chunk) except httpx.TimeoutException as exc: raise CliRestError("artifact download timed out", exit_code=5, code="TIMEOUT") from exc except httpx.HTTPError as exc: raise CliRestError( f"network error downloading artifact: {type(exc).__name__}", exit_code=5, code="NETWORK", ) from exc return h.hexdigest(), size def _safe_extract(zip_path: Path, target: Path) -> None: """Extract with zip-slip / symlink / root-dir guards.""" with zipfile.ZipFile(zip_path) as zf: for info in zf.infolist(): name = info.filename p = Path(name) if p.is_absolute() or ".." in p.parts: raise CliRestError( f"unsafe path in archive: {name!r}", exit_code=5, code="UNSAFE_ARCHIVE" ) if not (name == ZIP_ROOT + "/" or name.startswith(ZIP_ROOT + "/")): raise CliRestError( f"unexpected top-level entry in archive: {name!r}", exit_code=5, code="UNSAFE_ARCHIVE", ) # Reject symlinks (external_attr high bits carry the unix mode). mode = (info.external_attr >> 16) & 0xF000 if mode == 0xA000: raise CliRestError( f"symlink in archive: {name!r}", exit_code=5, code="UNSAFE_ARCHIVE" ) zf.extractall(target) # ── claude registration ───────────────────────────────────────────── #: Injectable for tests; returns (ok, detail). Runner = Callable[[list[str]], tuple[bool, str]] def _run_claude(cmd: list[str]) -> tuple[bool, str]: try: proc = subprocess.run( # noqa: S603 — fixed binary, no shell cmd, capture_output=True, text=True, timeout=120, check=False ) except (OSError, subprocess.TimeoutExpired) as exc: return False, f"{type(exc).__name__}" out = (proc.stdout or "") + (proc.stderr or "") return proc.returncode == 0, out.strip()[-2000:] def manual_instructions(mp_dir: Path) -> list[str]: return [ f"claude plugin marketplace add {mp_dir}", f"claude plugin install {PLUGIN_SPEC}", ] def register_with_claude( mp_dir: Path, *, first_install: bool, runner: Runner = _run_claude ) -> dict[str, Any]: """Best-effort registration with the local Claude Code install. Never raises: plugin download/unpack already succeeded; a missing or unhappy ``claude`` binary must not fail the command. Returns a status dict the caller renders. """ claude = shutil.which("claude") if claude is None: return { "registered": False, "reason": "claude binary not found on PATH", "manual": manual_instructions(mp_dir), } steps: list[dict[str, Any]] = [] if first_install: cmds = [ [claude, "plugin", "marketplace", "add", str(mp_dir)], [claude, "plugin", "install", PLUGIN_SPEC], ] else: # BOTH steps are required: `marketplace update` only refreshes the # listing metadata; the installed plugin is a CACHED COPY that only # `plugin update ` refreshes (closeout review 2026-07-09). cmds = [ [claude, "plugin", "marketplace", "update", MARKETPLACE_NAME], [claude, "plugin", "update", PLUGIN_SPEC], ] ok_all = True for cmd in cmds: ok, detail = runner(cmd) steps.append({"cmd": " ".join(cmd[1:]), "ok": ok, "detail": detail if not ok else ""}) ok_all = ok_all and ok if not ok: break result: dict[str, Any] = {"registered": ok_all, "steps": steps} if not ok_all: result["manual"] = manual_instructions(mp_dir) return result # ── receipts ──────────────────────────────────────────────────────── def read_receipt(base: Path) -> dict | None: try: data = json.loads(receipt_path(base).read_text(encoding="utf-8")) except (OSError, ValueError): return None return data if isinstance(data, dict) else None def _write_receipt(base: Path, payload: dict) -> None: tmp = receipt_path(base).with_suffix(".json.tmp") tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") tmp.replace(receipt_path(base)) # ── high-level operations (called from main.py) ───────────────────── @dataclass(frozen=True) class InstallResult: version: str plugin_version: str | None path: str sha256: str size: int first_install: bool registration: dict[str, Any] def install( config: CliConfig, *, timeout: float, base: Path | None = None, register: bool = True, runner: Runner = _run_claude, ) -> InstallResult: base = base or default_install_base() presigned = _fetch_presigned(config, timeout=timeout) base.mkdir(parents=True, exist_ok=True) first_install = read_receipt(base) is None with tempfile.TemporaryDirectory(dir=str(base)) as tmp: tmp_dir = Path(tmp) zip_path = tmp_dir / "plugin.zip" sha256, size = _download_zip(presigned["url"], zip_path, timeout=timeout) want_sha = presigned.get("sha256") if want_sha and sha256 != want_sha: raise CliRestError( "sha256 mismatch — downloaded artifact does not match the " f"published manifest (got {sha256[:12]}…, want {str(want_sha)[:12]}…)", exit_code=5, code="INTEGRITY", ) extract_dir = tmp_dir / "extract" extract_dir.mkdir() _safe_extract(zip_path, extract_dir) unpacked = extract_dir / ZIP_ROOT if not (unpacked / ".claude-plugin" / "marketplace.json").is_file(): raise CliRestError( "archive does not contain a marketplace manifest — refusing to install", exit_code=5, code="UNSAFE_ARCHIVE", ) # Atomic-ish swap: replace the stable dir in one rename pair. final = marketplace_dir(base) backup = base / (ZIP_ROOT + ".prev") if backup.exists(): shutil.rmtree(backup) if final.exists(): final.rename(backup) try: unpacked.rename(final) except OSError: # Cross-device rename fallback (tmp lives under base, so this # is unexpected — but never leave the user with nothing). if backup.exists() and not final.exists(): backup.rename(final) raise if backup.exists(): shutil.rmtree(backup, ignore_errors=True) _write_receipt( base, { "version": presigned.get("version"), "plugin_version": presigned.get("plugin_version"), "sha256": sha256, "size": size, "installed_at": datetime.now(UTC).isoformat(), "path": str(marketplace_dir(base)), }, ) registration: dict[str, Any] if register: registration = register_with_claude( marketplace_dir(base), first_install=first_install, runner=runner ) else: registration = { "registered": False, "reason": "skipped (--no-register)", "manual": manual_instructions(marketplace_dir(base)), } return InstallResult( version=str(presigned.get("version")), plugin_version=presigned.get("plugin_version"), path=str(marketplace_dir(base)), sha256=sha256, size=size, first_install=first_install, registration=registration, ) def status(config: CliConfig, *, timeout: float, base: Path | None = None) -> dict: base = base or default_install_base() receipt = read_receipt(base) local = None if receipt: local = { "version": receipt.get("version"), "plugin_version": receipt.get("plugin_version"), "installed_at": receipt.get("installed_at"), "path": receipt.get("path"), "intact": (marketplace_dir(base) / ".claude-plugin" / "marketplace.json").is_file(), } remote = fetch_manifest(config, timeout=timeout) if not remote.get("available"): state = "not_published" elif local is None: state = "not_installed" elif local["version"] == remote.get("version"): state = "up_to_date" else: state = "update_available" return {"state": state, "installed": local, "latest": remote} __all__ = [ "InstallResult", "default_install_base", "fetch_manifest", "install", "manual_instructions", "marketplace_dir", "read_receipt", "register_with_claude", "status", ]