#!/usr/bin/env python3
"""Istari quickstart — from a credentials file to a documented CAD model on Istari in one command.

    pip install "istari-digital-client>=13" cryptography PyJWT
    python istari_quickstart.py --env ./istari.env                 # demo bracket
    python istari_quickstart.py --env ./istari.env --file part.stl # your own model

What lands on the platform: a new 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:
  1. connect   credentials from --env (or the environment). Keys are the current credential
               (ISTARI_DIGITAL_API_URL + IDENTITY_SERVICE_SECRET_FILE, the file you downloaded
               from https://<instance>.istari.app/settings?tab=developer-settings). A Personal
               Access Token (ISTARI_DIGITAL_REGISTRY_URL + _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)
               --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.0.1's `branch.commit()` cannot set a folder and
    drops the folders of carried files (verified). One Configuration serves both clients.
  * 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.
  * Verified live on demo (registry 11.1.0, SDK 13.0.1) with a PAT on 2026-09-07; the Keys
    path follows the SDK Setup page exactly but was not exercised here.

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

PIP_INSTALL = 'pip install "istari-digital-client>=13" cryptography PyJWT'


def _missing_dep_message(name: str | None) -> str:
    """Keys sign a JWT with cryptography + PyJWT; a skipped or broken pip install omits them."""
    pkg = "PyJWT" if name == "jwt" else (name or "istari-digital-client")
    return (
        f"istari_quickstart: missing Python package {pkg!r}. "
        f"Key authentication needs istari-digital-client (>= 13), cryptography, and PyJWT. "
        f"Install with: {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 "
    "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_DIGITAL_REGISTRY_URL + ISTARI_DIGITAL_REGISTRY_AUTH_TOKEN from that page instead."
)


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 load_env(path: str | None) -> dict:
    """Resolve credentials from a KEY=VALUE file (or the environment).

    Returns {"mode": "key" | "pat", ...} with the fields the SDK Configuration needs.
    Precedence: a Key (current) over a PAT (deprecated) when both are present.
    """
    vals: dict[str, str] = {}
    if path:
        if not Path(path).is_file():
            sys.exit(f"istari_quickstart: no credentials file at {path}.\n  {WHERE_TO_GET_CREDENTIALS}")
        for line in Path(path).read_text().splitlines():
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            k, _, v = line.partition("=")
            vals[k.strip()] = v.strip().strip("'").strip('"')
    get = lambda *names: next((vals.get(n) or os.environ.get(n) for n in names if vals.get(n) or os.environ.get(n)), None)

    api_url = get("ISTARI_DIGITAL_API_URL")
    secret_file = get("IDENTITY_SERVICE_SECRET_FILE")
    if api_url and secret_file:
        if not Path(secret_file).is_file():
            sys.exit(f"istari_quickstart: IDENTITY_SERVICE_SECRET_FILE points at {secret_file}, which does not exist.")
        return {"mode": "key", "digital_api_url": api_url.rstrip("/"), "identity_service_secret_file": secret_file}

    reg_url = get("ISTARI_DIGITAL_REGISTRY_URL", "ISTARI_REGISTRY_URL")
    tok = get("ISTARI_DIGITAL_REGISTRY_AUTH_TOKEN", "ISTARI_REGISTRY_AUTH_TOKEN")
    if reg_url and tok:
        return {"mode": "pat", "registry_url": reg_url.rstrip("/"), "registry_auth_token": tok}

    sys.exit("istari_quickstart: no usable credentials in "
             f"{path or 'the environment'} (need ISTARI_DIGITAL_API_URL + IDENTITY_SERVICE_SECRET_FILE, "
             f"or ISTARI_DIGITAL_REGISTRY_URL + ISTARI_DIGITAL_REGISTRY_AUTH_TOKEN).\n  {WHERE_TO_GET_CREDENTIALS}")


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 whoami(self) -> str:
        import urllib.error

        from istari_digital_client.sdk import IstariAdmin

        try:
            u = 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
        return getattr(u, "email", None) or getattr(u, "user_name", None) or getattr(u, "id", "?")


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

    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:
            with urllib.request.urlopen(f"{base}/{rel}", timeout=30) as r:
                return r.read()

        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"):
                with urllib.request.urlopen(loc, timeout=30) as r:
                    data = r.read()
            elif loc.startswith("/"):
                origin = "/".join(base.split("/")[:3])
                with urllib.request.urlopen(origin + loc, timeout=30) as r:
                    data = r.read()
            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", help="KEY=VALUE file with the Istari credentials")
    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.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("--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)

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

    # 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.
    logging.disable(logging.CRITICAL)
    import subprocess
    import warnings
    warnings.filterwarnings("ignore")

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

    t_start = time.perf_counter()
    creds = load_env(a.env)
    try:
        if creds["mode"] == "key":
            cfg = Configuration(digital_api_url=creds["digital_api_url"],
                                identity_service_secret_file=creds["identity_service_secret_file"],
                                identity_service_enabled=True, http_request_timeout_secs=120)
            url = creds["digital_api_url"]
        else:
            cfg = Configuration(registry_url=creds["registry_url"], registry_auth_token=creds["registry_auth_token"],
                                http_request_timeout_secs=120)
            url = creds["registry_url"]
        client, legacy = Istari(cfg), Client(cfg)
    except ModuleNotFoundError as e:
        sys.exit(_missing_dep_message(e.name))

    # Fail early and plainly. Every message says what to do, not just what broke.
    from istari_digital_client.sdk import IstariError
    try:
        me = IstariAdminProbe(cfg).whoami()
    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 {a.env or 'the environment'} "
                 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 {a.env or 'the environment'}.")
        sys.exit(f"istari_quickstart: connected to {url} but the identity check failed: {msg[:200]}")
    step(f"connected to {url} as {me} ({'key' if creds['mode'] == 'key' else 'personal access token — deprecated, still accepted'})")

    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").files())
        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.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.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.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.files()
    ]
    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.files()]
    out = {
        "registry": url,
        "auth": creds["mode"],
        "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())
