#!/usr/bin/env python3
"""Istari Digital agent setup and sample runner.

    pip install "istari-digital-client==13.2.1"
    python istari_quickstart_13_2_1.py --setup --agent claude
    python istari_quickstart_13_2_1.py --check-connection --env ./istari.env
    python istari_quickstart_13_2_1.py --list-samples
    python istari_quickstart_13_2_1.py --sample bracket --env ./istari.env

`--setup` verifies the SDK, installs the Istari Digital skills, and checks `istari.env`.
If that file is missing, it writes a safe placeholder with instructions for a Key.

The bracket sample creates a System whose `baseline` branch holds three files —

    00-README.html            a front door: what the part is, how it was made, links to the files
    10-Model/bracket.stl      the CAD model (generated, or the file you passed with --file)
    10-Model/make_bracket.py  the Python that generated it (omitted for --file unless --script)

The README links to the other two with the platform's own deep-link form, so it is a working
front door, not a description. Nothing is run against the model: getting a model onto the
platform, versioned, documented and linked, is the whole of "zero to hero". Running functions
is the next page.

Steps, printed as they happen:
  0. sdk       report the installed istari-digital-client and stop unless it is the version
               this script is written for (EXPECTED_SDK); --any-sdk-version runs anyway
  1. connect   --env is copied into the environment and Configuration() reads it from there,
               so the credential type is decided by the file, not by this script. Keys are
               current: ISTARI_DIGITAL_API_URL + ISTARI_CLIENT_IDENTITY_SERVICE_SECRET_FILE
               (the file downloaded from
               https://<instance>.istari.app/settings?tab=developer-settings) +
               ISTARI_DIGITAL_IDENTITY_SERVICE_ENABLED=true. A Personal Access Token
               (ISTARI_REGISTRY_URL + ISTARI_REGISTRY_AUTH_TOKEN) still works but is
               deprecated since July 2026.
  2. part      write make_bracket.py and run it to produce bracket.stl (80x40x6 mm plate with a
               12 mm hole, binary STL, pure Python) — or take --file (and --script)
  3. system    create the System
  4. upload    the model and the script, as MODEL resources (so they open from the tree)
  5. readme    render 00-README.html with deep links to the two files, upload it
  6. commit    put all three on the baseline branch, README at the root, the others in 10-Model/
  7. report    read everything back; print JSON with every id and the UI links
  Also:        --revise MODEL_ID --file f.stl [--version-name v2]   upload a new revision of a model
               --archive SYSTEM_ID                                 undo a run (reversible)
               --setup --agent claude                              prepare an agent workspace
               --init-env [PATH]                                   write a Key-only credentials placeholder
               --check-env --env PATH                              validate credential names and local key path
               --check-connection --env PATH                       authenticate; print user id and visible System ids
               --list-samples                                      list sample names without connecting
               --any-sdk-version                                   skip the exact client-version check
               --sample [bracket]                                  generate a sample System (bracket is the default)
               --file PATH                                         upload your own model instead of a sample
               --install-skills --agent claude|codex|cursor|DIR    install the default Istari agent skill set
               --install-skills --all-skills --agent claude       also install layout and HTML-craft skills

Design notes (each decision has a measured reason; see onramp/README.md):
  * SDK 13.x `Istari` facade for upload / system — one call each. The commit uses the legacy
    `Client` configuration recipe because 13.2.1's `branch.commit()` still takes only
    add/remove: it cannot set a folder, and it rebuilds every carried NewTrackedFile without
    folder_path, so folders are dropped (read in the 13.2.1 source). One Configuration
    serves both clients.
  * The branch tree is read with `branch.resources()`. 13.0.x called it `branch.files()`;
    the method was renamed in 13.1.x, which is why this script is pinned to one version.
  * Folder paths are `<system-hex>.<label>` with `_XX` escapes; UI links are
    https://<host>/systems/<id> and .../<branch>/tree/m~<model_id>?tab=file with the host
    being the service host minus its `fileservice[-v2].` prefix. Neither is documented; both
    are implemented here in a few lines you can copy.
  * Everything is read back from the server before it is reported. No credential is printed.
  * Every call checked against the istari-python-client v13.2.1 source on 2026-10-06. The
    last live run was on 13.0.x (demo, registry 11.1.0) — rerun `--sample bracket` against
    a 13.2.1 environment before treating this as exercised end to end.

Exit 0 with JSON on stdout; non-zero with a one-line reason on stderr.
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import re
import sys
import time
from pathlib import Path

# --------------------------------------------------------------------------- credentials

EXPECTED_SDK = "13.2.1"
PIP_INSTALL = f'pip install "istari-digital-client=={EXPECTED_SDK}"'
ENV_PLACEHOLDER = """# Istari Digital Key credentials
# 1. Open https://<your-instance>.istari.app/settings?tab=developer-settings
# 2. Click Generate Key, choose an expiration, then Download credentials.
# 3. Copy the API URL from Endpoints and replace both placeholders below.
# Keep this file and the downloaded key out of git and chat.
ISTARI_DIGITAL_API_URL=https://api.<your-instance>.istari.app
ISTARI_CLIENT_IDENTITY_SERVICE_SECRET_FILE=/absolute/path/to/downloaded-key.json
ISTARI_DIGITAL_IDENTITY_SERVICE_ENABLED=true
"""
# Configuration() reads these names from the environment, so no credential is passed in code.
# A Key uses the first three; a deprecated PAT uses the last two.
API_URL_VAR = "ISTARI_DIGITAL_API_URL"
SECRET_FILE_VAR = "ISTARI_CLIENT_IDENTITY_SERVICE_SECRET_FILE"
IDENTITY_ENABLED_VAR = "ISTARI_DIGITAL_IDENTITY_SERVICE_ENABLED"
REGISTRY_URL_VAR = "ISTARI_REGISTRY_URL"
REGISTRY_TOKEN_VAR = "ISTARI_REGISTRY_AUTH_TOKEN"
CREDENTIAL_VARS = (API_URL_VAR, SECRET_FILE_VAR, IDENTITY_ENABLED_VAR, REGISTRY_URL_VAR, REGISTRY_TOKEN_VAR)
# Older names accepted from an existing istari.env, mapped to the name the client reads.
ENV_ALIASES = {
    "IDENTITY_SERVICE_SECRET_FILE": SECRET_FILE_VAR,
    "ISTARI_DIGITAL_REGISTRY_URL": REGISTRY_URL_VAR,
    "ISTARI_DIGITAL_REGISTRY_AUTH_TOKEN": REGISTRY_TOKEN_VAR,
}
SAMPLES = {
    "bracket": {
        "description": "Create a documented mounting-bracket System with an STL and its pure-Python generator.",
        "command": "python istari_quickstart_13_2_1.py --sample bracket --env istari.env",
    },
}


def _missing_dep_message(name: str | None) -> str:
    """cryptography and PyJWT install with the client, so a missing one means a partial
    install or the wrong environment."""
    pkg = "PyJWT" if name == "jwt" else (name or "istari-digital-client")
    return (
        f"istari_quickstart: missing Python package {pkg!r}. "
        f"It installs with the client, so reinstall into the environment you are running: {PIP_INSTALL}"
    )


WHERE_TO_GET_CREDENTIALS = (
    "Open https://<your-instance>.istari.app/settings?tab=developer-settings (for example "
    "https://demo.istari.app/settings?tab=developer-settings). Click Generate Key, choose an "
    "expiration, and Download credentials (it is shown once). Put that file's path in "
    "ISTARI_CLIENT_IDENTITY_SERVICE_SECRET_FILE and the API URL shown under Endpoints on the same page in "
    "ISTARI_DIGITAL_API_URL. If your instance still issues Personal Access Tokens (deprecated), "
    "use ISTARI_REGISTRY_URL + ISTARI_REGISTRY_AUTH_TOKEN from that page instead."
)


def sdk_version(allow_mismatch: bool = False) -> str:
    """Return the installed client version, or exit unless it is the one this script targets.

    The calls here are written against EXPECTED_SDK, so another version is reported as an
    install problem rather than left to fail later inside an API call.
    """
    import importlib.metadata

    try:
        version = importlib.metadata.version("istari-digital-client")
    except importlib.metadata.PackageNotFoundError:
        sys.exit(f"istari_quickstart: the Python client is not installed. Run: {PIP_INSTALL}")
    if version != EXPECTED_SDK and not allow_mismatch:
        sys.exit(f"istari_quickstart: this script is written for istari-digital-client {EXPECTED_SDK}, "
                 f"but {version} is installed in {sys.prefix}.\n"
                 f"  Run: {PIP_INSTALL}\n"
                 f"  Or pass --any-sdk-version to run anyway.")
    return version


def sdk_label(version: str) -> str:
    """One progress line naming the client every later call goes through."""
    if version == EXPECTED_SDK:
        return f"istari-digital-client {version}"
    return f"istari-digital-client {version} — not the {EXPECTED_SDK} this script is written for"


def load_env_file(path: str | None) -> dict[str, str]:
    """Copy a KEY=VALUE credentials file into the environment for Configuration() to read.

    Variables already set in the shell win. Older variable names are mapped to the name the
    client reads, and a Key path implies Key auth — so an istari.env written before
    ISTARI_DIGITAL_IDENTITY_SERVICE_ENABLED existed still authenticates. A missing file is
    not an error: the credentials may already be in the environment.
    """
    if path and Path(path).is_file():
        for line in Path(path).read_text().splitlines():
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            name, _, value = line.partition("=")
            name = name.strip()
            os.environ.setdefault(ENV_ALIASES.get(name, name), value.strip().strip("'").strip('"'))
    if os.environ.get(SECRET_FILE_VAR):
        os.environ.setdefault(IDENTITY_ENABLED_VAR, "true")
    return {name: os.environ.get(name, "") for name in CREDENTIAL_VARS}


def credential_status(path: str | None) -> dict:
    """Describe whether credentials are ready without reading or printing secret contents."""
    vals = load_env_file(path)
    api_url = vals[API_URL_VAR]
    secret_file = vals[SECRET_FILE_VAR]
    placeholders = "<your-instance>" in api_url or "/absolute/path/to/" in secret_file
    if api_url and secret_file and not placeholders:
        key_exists = Path(secret_file).expanduser().is_file()
        return {
            "ready": key_exists,
            "mode": "key",
            "env_file": path,
            "api_url": api_url,
            "key_file": str(Path(secret_file).expanduser()),
            "message": "Key credentials are ready." if key_exists else "The Key file path does not exist.",
        }
    if vals[REGISTRY_URL_VAR] and vals[REGISTRY_TOKEN_VAR]:
        return {
            "ready": True,
            "mode": "pat",
            "env_file": path,
            "message": "Deprecated Personal Access Token credentials are present; generate a Key when possible.",
        }
    return {
        "ready": False,
        "mode": None,
        "env_file": path,
        "message": f"Fill in {API_URL_VAR} and {SECRET_FILE_VAR} with a downloaded Key.",
    }


def init_env(path: str, step) -> bool:
    """Write a Key-only placeholder without overwriting an existing credentials file."""
    target = Path(path).expanduser()
    if target.exists():
        step(f"kept existing {target}; credentials files are never overwritten")
        return False
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(ENV_PLACEHOLDER)
    step(f"created {target} — replace the two placeholders with the API URL and downloaded Key path")
    return True


def developer_settings_url(service_url: str) -> str:
    """The page where credentials are generated, for the instance behind a service URL."""
    return f"{ui_host(service_url)}/settings?tab=developer-settings"


def resolve_env_path(path: str | None) -> str | None:
    """--env wins; otherwise use ./istari.env when it exists, else the process environment."""
    if path:
        return path
    return "istari.env" if Path("istari.env").is_file() else None


def auth_label(mode: str) -> str:
    """How to describe the credential in progress output."""
    return "key" if mode == "key" else "personal access token — deprecated, still accepted"


def build_configuration(path: str | None):
    """Load the credentials file into the environment and let Configuration() read it.

    Returns (cfg, url, mode). Nothing is passed in code: the variables in CREDENTIAL_VARS
    decide whether this authenticates with a Key or a deprecated PAT, and the client
    validates them on construction. A Key wins when both are present.
    """
    if path and not Path(path).is_file():
        sys.exit(f"istari_quickstart: no credentials file at {path}.\n  "
                 f"Run `python istari_quickstart_13_2_1.py --init-env {path}` first.")
    load_env_file(path)
    try:
        from istari_digital_client import Configuration, ConfigurationError
    except ModuleNotFoundError as e:
        sys.exit(_missing_dep_message(e.name))

    try:
        cfg = Configuration(http_request_timeout_secs=120)
    except ConfigurationError as e:
        sys.exit(f"istari_quickstart: no usable credentials in {path or 'the environment'} — {e}\n"
                 f"  {WHERE_TO_GET_CREDENTIALS}")
    url = cfg.digital_api_url or cfg.registry_url
    return cfg, url.rstrip("/"), "key" if cfg.identity_service_enabled else "pat"


def ui_host(service_url: str) -> str:
    """The web-app origin: the service host without its API prefix.

    https://fileservice-v2.demo.istari.app -> https://demo.istari.app  (verified)
    https://api.<instance>.istari.app      -> https://<instance>.istari.app  (by the same rule; not verified)
    """
    return re.sub(r"^(https?://)(fileservice(-v\d+)?|api)\.", r"\1", service_url)


# --------------------------------------------------------------------------- connectivity probe


class _NetworkError(Exception):
    pass


class IstariAdminProbe:
    """One cheap authenticated call, so a bad URL or a dead credential fails on line one."""

    def __init__(self, cfg):
        self._cfg = cfg

    def current_user(self):
        import urllib.error

        from istari_digital_client.sdk import IstariAdmin

        try:
            return IstariAdmin(self._cfg).users.current()
        except Exception as e:  # noqa: BLE001
            root = e
            while root.__cause__ is not None:
                root = root.__cause__
            name = type(root).__name__
            if isinstance(root, (urllib.error.URLError, ConnectionError, TimeoutError, OSError)) or name in (
                "MaxRetryError", "NewConnectionError", "NameResolutionError", "ConnectTimeoutError", "SSLError"
            ):
                raise _NetworkError(f"{name}: {str(root)[:120]}") from None
            raise

    def whoami(self) -> str:
        u = self.current_user()
        return getattr(u, "email", None) or getattr(u, "user_name", None) or getattr(u, "id", "?")


def _silence_sdk_logs() -> None:
    logging.disable(logging.CRITICAL)
    import warnings
    warnings.filterwarnings("ignore")


def _identity_or_exit(cfg, url: str, env_label: str):
    from istari_digital_client.sdk import IstariError

    try:
        user = IstariAdminProbe(cfg).current_user()
    except ModuleNotFoundError as e:
        sys.exit(_missing_dep_message(e.name))
    except _NetworkError as e:
        sys.exit(
            f"istari_quickstart: cannot reach {url} ({e}). Check the URL in {env_label} "
            f"— it should be the Registry URL (PAT) or API URL (Key) from {developer_settings_url(url)} — and your network/VPN."
        )
    except IstariError as e:
        msg = str(e)
        if any(k in msg for k in ("401", "Unauthorized", "Not authenticated", "Invalid Personal Access Token", "invalid token")) or "expired" in msg.lower():
            sys.exit(
                f"istari_quickstart: {url} rejected the credential (expired, revoked, or for a different instance). "
                f"Generate a new Key at {developer_settings_url(url)} and update {env_label}."
            )
        sys.exit(f"istari_quickstart: connected to {url} but the identity check failed: {msg[:200]}")
    display = getattr(user, "email", None) or getattr(user, "user_name", None) or getattr(user, "id", "?")
    return user, display


def check_connection(env_path: str | None, step, version: str) -> int:
    """Authenticate and print a JSON identity report. Creates nothing."""
    _silence_sdk_logs()
    try:
        from istari_digital_client.sdk import Istari, IstariError
    except ModuleNotFoundError as e:
        sys.exit(_missing_dep_message(e.name))

    cfg, url, mode = build_configuration(env_path)
    user, display = _identity_or_exit(cfg, url, env_path or "the environment")
    try:
        systems = Istari(cfg).systems.list(size=5)
    except IstariError as e:
        sys.exit(f"istari_quickstart: authenticated as {display} but listing Systems failed: {str(e)[:200]}")
    items = list(getattr(systems, "items", []) or [])
    report = {
        "ok": True,
        "url": url,
        "sdk_version": version,
        "auth": mode,
        "user_id": str(getattr(user, "id", "")),
        "email": getattr(user, "email", None),
        "visible_systems": len(items),
        "first_system_ids": [str(system.id) for system in items],
    }
    print(json.dumps(report, indent=2))
    step(f"connected to {url} as {display} ({auth_label(mode)})")
    return 0


# --------------------------------------------------------------------------- the demo part
# The generator is written out as its own file (make_bracket.py) and RUN to produce the STL,
# then uploaded next to the model — so the script on the platform really is the one that made
# the part, and someone can change a number and regenerate.

MAKE_BRACKET_PY = r'''#!/usr/bin/env python3
"""make_bracket.py — a parametric mounting bracket as a binary STL, in pure Python.

    python make_bracket.py                       # bracket.stl, 80 x 40 x 6 mm, 12 mm hole
    python make_bracket.py --length 100 --hole 8 # change the numbers, regenerate

No CAD library: the plate outline is resampled to N points and stitched to an N-point circle
with quads, giving a watertight solid (top and bottom annulus, outer wall, hole wall).
"""
import argparse, math, struct


def bracket_stl(path, length=80.0, width=40.0, thickness=6.0, hole_d=12.0, segments=48):
    def rect_points(n):
        per = 2 * (length + width); pts = []
        for i in range(n):
            d = (i / n) * per
            if d < width / 2:                    pts.append((length / 2, d))
            elif d < width / 2 + length:         pts.append((length / 2 - (d - width / 2), width / 2))
            elif d < 1.5 * width + length:       pts.append((-length / 2, width / 2 - (d - width / 2 - length)))
            elif d < 1.5 * width + 2 * length:   pts.append((-length / 2 + (d - 1.5 * width - length), -width / 2))
            else:                                pts.append((length / 2, -width / 2 + (d - 1.5 * width - 2 * length)))
        return pts
    outer = rect_points(segments); r = hole_d / 2
    inner = [(r * math.cos(2 * math.pi * i / segments), r * math.sin(2 * math.pi * i / segments)) for i in range(segments)]
    z0, z1 = 0.0, thickness; tris = []
    def quad(a, b, c, d): tris.append((a, b, c)); tris.append((a, c, d))
    for i in range(segments):
        j = (i + 1) % segments; o0, o1, i0, i1 = outer[i], outer[j], inner[i], inner[j]
        quad((*o0, z1), (*o1, z1), (*i1, z1), (*i0, z1))   # top
        quad((*o1, z0), (*o0, z0), (*i0, z0), (*i1, z0))   # bottom
        quad((*o0, z0), (*o1, z0), (*o1, z1), (*o0, z1))   # outer wall
        quad((*i1, z0), (*i0, z0), (*i0, z1), (*i1, z1))   # hole wall
    def normal(a, b, c):
        ux, uy, uz = b[0]-a[0], b[1]-a[1], b[2]-a[2]; vx, vy, vz = c[0]-a[0], c[1]-a[1], c[2]-a[2]
        nx, ny, nz = uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx; n = math.sqrt(nx*nx+ny*ny+nz*nz) or 1.0
        return nx/n, ny/n, nz/n
    with open(path, "wb") as f:
        f.write(f"bracket {length:g}x{width:g}x{thickness:g} mm hole d={hole_d:g}".encode()[:80].ljust(80, b"\0"))
        f.write(struct.pack("<I", len(tris)))
        for a, b, c in tris:
            f.write(struct.pack("<3f", *normal(a, b, c)))
            for v in (a, b, c): f.write(struct.pack("<3f", *v))
            f.write(b"\0\0")
    return len(tris)


if __name__ == "__main__":
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--out", default="bracket.stl"); ap.add_argument("--length", type=float, default=80.0)
    ap.add_argument("--width", type=float, default=40.0); ap.add_argument("--thickness", type=float, default=6.0)
    ap.add_argument("--hole", type=float, default=12.0); ap.add_argument("--segments", type=int, default=48)
    a = ap.parse_args()
    n = bracket_stl(a.out, a.length, a.width, a.thickness, a.hole, a.segments)
    print(f"wrote {a.out}: {n} triangles, {a.length:g}x{a.width:g}x{a.thickness:g} mm, hole d={a.hole:g}")
'''


# --------------------------------------------------------------------------- folders


def folder_path(system_id: str, *names: str) -> str:
    """Registry write-form: <32-hex system id>.<label>... ; labels escape non-alphanumerics as _XX."""
    def label(n: str) -> str:
        if not n.isascii():
            raise ValueError(f"folder names must be ASCII: {n!r}")
        return re.sub(r"[^A-Za-z0-9]", lambda m: f"_{ord(m.group()):02X}", n)
    return ".".join([system_id.replace("-", ""), *(label(n) for n in names)])


def folders_of(path: str | None) -> list[str]:
    """Registry read-form -> the encoded folder segments only (drops the file-id tail)."""
    hexre = re.compile(r"^[0-9a-f]{32}$")
    return [] if not path else [s for s in path.split(".")[1:] if not hexre.match(s)]


def decode_label(seg: str) -> str:
    """Encoded segment -> the friendly folder name the UI shows (`10_2DAnalysis` -> `10-Analysis`)."""
    return re.sub(r"_([0-9A-F]{2})", lambda m: chr(int(m.group(1), 16)), seg)


# --------------------------------------------------------------------------- the README


def render_readme(*, system_name: str, system_link: str, files: list[dict], part_desc: str,
                  regenerate: str, generated_by: str, made: str) -> str:
    """A self-contained HTML front door: what this is, the files (deep-linked), how to regenerate.

    Self-contained on purpose — Istari renders HTML under a strict content policy, so no
    external scripts, styles or fonts. Links use the platform's tree form so each opens the
    file in place on the baseline branch.
    """
    import html as h
    rows = "".join(
        f'<tr><td><a href="{h.escape(f["link"])}">{h.escape(f["path"])}</a></td>'
        f'<td>{h.escape(f["what"])}</td><td class="mono">{h.escape(f["model_id"])}</td></tr>'
        for f in files
    )
    return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{h.escape(system_name)}</title>
<style>
 body{{font:15px/1.5 -apple-system,Segoe UI,Helvetica,Arial,sans-serif;color:#1c2430;background:#f6f8fa;margin:0}}
 main{{max-width:860px;margin:0 auto;padding:32px 24px 48px}}
 h1{{font-size:26px;margin:0 0 4px}} .sub{{color:#5b6775;margin:0 0 24px}}
 section{{background:#fff;border:1px solid #e3e8ee;border-radius:8px;padding:18px 20px;margin:0 0 16px}}
 h2{{font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:#5b6775;margin:0 0 10px}}
 table{{width:100%;border-collapse:collapse}} td,th{{text-align:left;padding:8px 6px;border-top:1px solid #eef1f4;vertical-align:top}}
 th{{border-top:0;color:#5b6775;font-weight:600;font-size:13px}} .mono{{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:#5b6775}}
 pre{{background:#0f172a;color:#e2e8f0;padding:12px 14px;border-radius:6px;overflow:auto;font-size:13px}}
 a{{color:#0b57d0;text-decoration:none}} a:hover{{text-decoration:underline}} .foot{{color:#5b6775;font-size:13px}}
</style></head><body><main>
<h1>{h.escape(system_name)}</h1>
<p class="sub">{h.escape(part_desc)} &middot; <a href="{h.escape(system_link)}">open this System on Istari</a></p>
<section><h2>What is here</h2>
<table><tr><th>File (opens in the tree)</th><th>What it is</th><th>Model id</th></tr>{rows}</table></section>
<section><h2>How the model was made</h2>
<p>{h.escape(made)}</p>
<pre>{h.escape(regenerate)}</pre></section>
<section><h2>Provenance</h2>
<p class="foot">{h.escape(generated_by)}</p></section>
</main></body></html>
"""


# --------------------------------------------------------------------------- skills


SKILL_DIRS = {
    "claude": Path.home() / ".claude" / "skills",
    "codex": Path.home() / ".codex" / "skills",
    "cursor": Path.home() / ".cursor" / "skills",
}


def install_skills(bases: str, agent: str, step, include_optional: bool = False) -> int:
    """Fetch one or more skill sets (index.json + one SKILL.md per skill) into an agent's skills folder.

    Skills are plain markdown with frontmatter; every agent that reads skills reads this format.
    Each file is verified against the SHA-256 in its index before it is written, and the SDK
    generation each skill was verified for is printed so you know what you installed. Several
    sets — Istari's and your team's — install with one command: comma-separate the bases.
    """
    import hashlib
    import urllib.request

    # Honest UA — Cloudflare Bot Fight 403s the default Python-urllib/* header.
    ua = "istari-quickstart/1.0 (+https://docs.istaridigital.com/quickstart/istari_quickstart_13_2_1.py)"

    def get_bytes(url: str) -> bytes:
        req = urllib.request.Request(url, headers={"User-Agent": ua, "Accept": "*/*"})
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.read()

    dest = SKILL_DIRS.get(agent) or Path(agent).expanduser()
    dest.mkdir(parents=True, exist_ok=True)
    installed, sets = [], []
    for base in [b.strip().rstrip("/") for b in bases.split(",") if b.strip()]:
        def fetch(rel: str, base=base) -> bytes:
            return get_bytes(f"{base}/{rel}")

        try:
            index = json.loads(fetch("index.json"))
        except Exception as e:  # noqa: BLE001
            sys.exit(f"istari_quickstart: could not fetch the skill index from {base}/index.json ({type(e).__name__}: {str(e)[:100]}).")
        sets.append({"base": base, "set": index.get("set"), "generated": index.get("generated")})
        for sk in index["skills"]:
            # accept our index ("path", relative to base) or the docs site's .well-known catalog
            # ("url", site-relative or absolute); skip entries without a checksum rather than trust them blindly
            if sk.get("install") == "optional" and not include_optional:
                step(f"skipped {sk.get('name')}: optional (pass --all-skills to install layout and HTML-craft skills)")
                continue
            if "sha256" not in sk:
                step(f"skipped {sk.get('name')}: no checksum in the index")
                continue
            loc = sk.get("path") or sk.get("url") or ""
            if loc.startswith("http"):
                data = get_bytes(loc)
            elif loc.startswith("/"):
                origin = "/".join(base.split("/")[:3])
                data = get_bytes(origin + loc)
            else:
                data = fetch(loc)
            if hashlib.sha256(data).hexdigest() != sk["sha256"]:
                sys.exit(f"istari_quickstart: checksum mismatch for {sk['name']} from {base} — refusing to install.")
            target = dest / sk["name"] / "SKILL.md"
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_bytes(data)
            installed.append({"name": sk["name"], "set": index.get("set"), "sdk_generation": sk.get("sdk_generation"),
                              "skill_version": sk.get("skill_version"), "path": str(target)})
            step(f"installed {sk['name']}  (SDK {sk.get('sdk_generation')}, skill {sk.get('skill_version')}, from {index.get('set')})")
    print(json.dumps({"agent": agent, "skills_dir": str(dest), "sets": sets, "installed": installed}, indent=2))
    step(f"{len(installed)} skills installed into {dest} — start a new session so the agent picks them up")
    return 0


# --------------------------------------------------------------------------- main


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--env", default=None, help="KEY=VALUE file with Istari Digital credentials (setup default: istari.env)")
    ap.add_argument("--file", help="your own CAD file to upload instead of the generated bracket")
    ap.add_argument("--script", help="with --file: the script that generated it, uploaded beside it")
    ap.add_argument("--system-name", default=f"Quickstart {time.strftime('%Y-%m-%d %H:%M:%S')}")
    ap.add_argument("--description", default="Created by istari_quickstart_13_2_1.py")
    ap.add_argument("--folder", default="10-Model", help="folder for the model and its script")
    ap.add_argument("--quiet", action="store_true", help="only the final JSON on stdout")
    ap.add_argument("--archive", metavar="SYSTEM_ID",
                    help="undo a previous run: archive that System and every model on its baseline branch")
    ap.add_argument("--install-skills", action="store_true",
                    help="install the Istari agent skill set into an AI coding agent (see --agent)")
    ap.add_argument("--setup", action="store_true",
                    help=f"verify istari-digital-client {EXPECTED_SDK}, install skills, and check or create istari.env")
    ap.add_argument("--any-sdk-version", action="store_true",
                    help=f"run against an installed client other than {EXPECTED_SDK} (calls may not match)")
    ap.add_argument("--init-env", nargs="?", const="istari.env", metavar="PATH",
                    help="write a Key-only credentials placeholder (default: istari.env; never overwrites)")
    ap.add_argument("--check-env", action="store_true",
                    help="validate --env (default: istari.env) without connecting")
    ap.add_argument("--check-connection", action="store_true",
                    help="authenticate with --env (default: istari.env) and print user id / visible System ids")
    ap.add_argument("--list-samples", action="store_true",
                    help="list available sample names and commands without connecting")
    ap.add_argument("--sample", nargs="?", const="bracket", choices=sorted(SAMPLES),
                    help="run a named sample; omit the name to run bracket")
    ap.add_argument("--all-skills", action="store_true",
                    help="with --install-skills: also install optional skills (layout conventions, HTML craft)")
    ap.add_argument("--agent", default="claude",
                    help="with --install-skills: claude | codex | cursor | a directory path (default claude)")
    ap.add_argument("--skills-base", default=os.environ.get("ISTARI_SKILLS_BASE", "https://docs.istaridigital.com/skills/istari"),
                    help="where skill sets are served from: one or more comma-separated URLs, each the folder holding an index.json "
                         "(Istari's set first, then your team's)")
    ap.add_argument("--revise", metavar="MODEL_ID",
                    help="upload --file as a NEW REVISION of an existing model (keeps its id and history)")
    ap.add_argument("--version-name", help="with --revise: a label for the new revision, e.g. v2")
    a = ap.parse_args()

    def step(msg: str) -> None:
        if not a.quiet:
            print(f"· {msg}", file=sys.stderr, flush=True)

    env_path = a.env or "istari.env"

    if a.list_samples:
        print(json.dumps({"samples": [{"name": name, **details} for name, details in SAMPLES.items()]}, indent=2))
        return 0

    if a.init_env:
        init_env(a.init_env, step)
        print(json.dumps(credential_status(a.init_env), indent=2))
        return 0

    if a.check_env:
        status = credential_status(env_path)
        print(json.dumps(status, indent=2))
        return 0 if status["ready"] else 1

    if a.setup:
        version = sdk_version(a.any_sdk_version)
        step(sdk_label(version))
        if not Path(env_path).exists():
            init_env(env_path, step)
        status = credential_status(env_path)
        step(status["message"])
        install_skills(a.skills_base, a.agent, step, include_optional=a.all_skills)
        print("\nAfter you fill in istari.env, test the connection without an agent:\n"
              f"  python istari_quickstart_13_2_1.py --check-connection --env {env_path}")
        return 0

    if a.install_skills:
        return install_skills(a.skills_base, a.agent, step, include_optional=a.all_skills)

    if a.check_connection:
        version = sdk_version(a.any_sdk_version)
        step(sdk_label(version))
        return check_connection(resolve_env_path(a.env), step, version)

    if a.sample and a.file:
        sys.exit("istari_quickstart: --sample generates its own model; drop --sample to upload --file instead.")

    version = sdk_version(a.any_sdk_version)
    step(sdk_label(version))
    if a.sample:
        step(f"running sample {a.sample!r}")

    # The SDK logs a compatibility warning and a PAT-deprecation warning on every call, plus an
    # ERROR-level traceback for every non-2xx (its module loggers set their own levels, so a
    # parent setLevel is not enough). None of it is actionable in a CLI.
    _silence_sdk_logs()
    import subprocess

    try:
        from istari_digital_client import (Client, NewSystemConfiguration,
                                           NewTrackedFile, TrackedFileSpecifierType, UpdateTag)
        from istari_digital_client.sdk import Istari, IstariError
    except ModuleNotFoundError as e:
        sys.exit(_missing_dep_message(e.name))

    t_start = time.perf_counter()
    env_used = resolve_env_path(a.env)
    cfg, url, mode = build_configuration(env_used)
    try:
        client, legacy = Istari(cfg), Client(cfg)
    except ModuleNotFoundError as e:
        sys.exit(_missing_dep_message(e.name))

    _user, me = _identity_or_exit(cfg, url, env_used or "the environment")
    step(f"connected to {url} as {me} ({auth_label(mode)})")

    if a.revise:
        if not a.file or not Path(a.file).is_file():
            sys.exit("istari_quickstart: --revise needs --file <the regenerated file>")
        try:
            rev = client.resources.revisions.create(a.revise, a.file, version_name=a.version_name)
        except IstariError as e:
            sys.exit(f"istari_quickstart: could not add a revision to model {a.revise}: {str(e)[:160]}\n"
                     "  Use the model_id from the quickstart's JSON (not the file_id or the system_id).")
        print(json.dumps({"model_id": a.revise, "new_revision_id": rev.id, "version_name": a.version_name,
                          "file": Path(a.file).name, "model_link": f"{ui_host(url)}/files/{a.revise}"}, indent=2))
        step(f"uploaded {a.file} as revision {rev.id} of model {a.revise} — branches tracking it as LATEST pick it up at their next commit")
        return 0

    if a.archive:
        # Archive is reversible on Istari (restore exists); nothing is deleted.
        try:
            system = client.systems.get(a.archive)
            files = list(system.get_branch("baseline").resources())
        except IstariError as e:
            sys.exit(f"istari_quickstart: no system {a.archive} that you can see: {str(e)[:120]}\n"
                     "  Use the system_id from the quickstart's JSON.")
        for f in files:
            client.resources.archive(f.resource_id)
        client.systems.archive(system.id)
        print(json.dumps({"archived_system": system.id, "archived_models": [f.resource_id for f in files]}, indent=2))
        step(f"archived system {system.id} and {len(files)} model(s) — restorable from the web app")
        return 0

    # 2. part — write the generator, run it, keep both
    if a.file:
        part = Path(a.file)
        if not part.is_file():
            sys.exit(f"istari_quickstart: no such file {part}")
        script = Path(a.script) if a.script else None
        if script and not script.is_file():
            sys.exit(f"istari_quickstart: no such file {script}")
        part_desc = f"{part.name} ({part.stat().st_size:,} bytes), uploaded from {part.resolve().parent}"
        made = (f"Generated by {script.name}, uploaded beside it. To change the part, edit the script, regenerate, "
                f"and upload the result as a revision of the same model:" if script else
                f"Uploaded as-is from {part.name}; no generator script was provided. To change the part, regenerate it "
                f"with your own tooling and upload the result as a revision of the same model so the history stays on one id:")
        regenerate = f"python istari_quickstart_13_2_1.py --env istari.env --revise <model_id> --file {part.name} --version-name v2"
    else:
        script = Path("make_bracket.py"); script.write_text(MAKE_BRACKET_PY)
        part = Path("bracket.stl")
        r = subprocess.run([sys.executable, str(script), "--out", str(part)], capture_output=True, text=True)
        if r.returncode != 0:
            sys.exit(f"istari_quickstart: make_bracket.py failed: {r.stderr[-300:]}")
        step(f"generated {part} with {script} — {r.stdout.strip()}")
        part_desc = "Parametric mounting bracket, 80 × 40 × 6 mm plate with a 12 mm through-hole, binary STL"
        made = ("The STL was generated by the Python script beside it — no CAD package, one file, no dependencies. "
                "To change the part, edit the numbers and regenerate, then upload the new STL as a revision of the same "
                "model so the System's history stays on one id:")
        regenerate = ("python make_bracket.py --length 100 --hole 8 --out bracket.stl\n"
                      "python istari_quickstart_13_2_1.py --env istari.env --revise <model_id> --file bracket.stl --version-name v2")

    # 3. system
    system = client.systems.create(name=a.system_name, description=a.description)
    step(f"created system {system.id} ({system.name!r})")
    host = ui_host(url)
    system_link = f"{host}/systems/{system.id}"

    def tree_link(model_id: str) -> str:
        return f"{host}/systems/{system.id}/baseline/tree/m~{model_id}?tab=file"

    # 4. upload the model and the script (as MODEL resources: a FILE node cannot be opened from the tree)
    uploads = []  # (path-in-system, resource, what)
    r_model = client.resources.create(str(part), "model", description=f"{a.description} — the CAD model")
    uploads.append((f"{a.folder}/{part.name}", r_model, "the CAD model (binary STL)"))
    step(f"uploaded model {r_model.resource_id} ({part.name})")
    if script:
        r_script = client.resources.create(str(script), "model", description=f"{a.description} — the generator")
        uploads.append((f"{a.folder}/{script.name}", r_script, "the Python that generated the model"))
        step(f"uploaded script {r_script.resource_id} ({script.name})")

    # 5. README — rendered after the uploads so it can link to real ids
    readme_path = Path("00-README.html")
    readme_path.write_text(render_readme(
        system_name=system.name, system_link=system_link,
        files=[{"path": p_, "link": tree_link(r_.resource_id), "model_id": r_.resource_id, "what": w}
               for p_, r_, w in uploads],
        part_desc=part_desc, regenerate=regenerate, made=made,
        generated_by=f"Created {time.strftime('%Y-%m-%d %H:%M:%S %Z')} by istari_quickstart_13_2_1.py on {url}. "
                     f"README, model and script are tracked on the baseline branch of this System.",
    ))
    r_readme = client.resources.create(str(readme_path), "model", description=f"{a.description} — front door")
    step(f"rendered and uploaded {readme_path} ({r_readme.resource_id}) linking to {len(uploads)} file(s)")

    # 6. commit all three — legacy configuration recipe (facade commit() cannot set folders)
    baseline = system.get_branch("baseline")
    keep = [
        NewTrackedFile(specifier_type=TrackedFileSpecifierType(f.specifier_type), file_id=f.file_id,
                       pinned_file_revision_id=f.pinned_file_revision_id,
                       **({"folder_path": ".".join([system.id.replace("-", ""), *folders_of(f.path)])}
                          if folders_of(f.path) else {}))
        for f in baseline.resources()
    ]
    new = [NewTrackedFile(specifier_type=TrackedFileSpecifierType.LATEST, file_id=r_readme.file_id)]
    new += [NewTrackedFile(specifier_type=TrackedFileSpecifierType.LATEST, file_id=r_.file_id,
                           folder_path=folder_path(system.id, a.folder)) for _, r_, _ in uploads]
    config_ = legacy.create_configuration(
        system.id, NewSystemConfiguration(name=f"quickstart {int(time.time())}", tracked_files=keep + new))
    snapshot = next(s_ for s_ in legacy.list_snapshots(system.id, page=1, size=100).items
                    if s_.configuration_id == config_.id)
    tag = next(t for t in legacy.list_tags(system.id).items if t.tag == "baseline")
    legacy.update_tag(tag.id, UpdateTag(snapshot_id=snapshot.id))
    step(f"committed {len(new)} files to branch 'baseline' (README at the root, the rest in {a.folder!r})")

    # 7. read back and report
    branch = client.systems.get(system.id).get_branch("baseline")
    tracked = [{"name": f.name, "folders": [decode_label(x) for x in folders_of(f.path)], "model_id": f.resource_id}
               for f in branch.resources()]
    out = {
        "registry": url,
        "auth": mode,
        "sdk_version": version,
        "system_id": system.id,
        "system_name": system.name,
        "branch": branch.tag,
        "readme": {"model_id": r_readme.resource_id, "link": tree_link(r_readme.resource_id)},
        "model": {"model_id": r_model.resource_id, "file": part.name, "link": tree_link(r_model.resource_id)},
        "script": ({"model_id": uploads[1][1].resource_id, "file": script.name, "link": tree_link(uploads[1][1].resource_id)}
                   if script else None),
        "tracked_files": tracked,
        "ui_link": system_link,
        "developer_settings": developer_settings_url(url),
        "seconds": round(time.perf_counter() - t_start, 1),
    }
    print(json.dumps(out, indent=2))
    step(f"done in {out['seconds']}s — open {system_link}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
