#!/usr/bin/env python3
"""
recipes — CLI for publishing and installing skills against recipes.wisechef.ai

Usage:
  recipes init <skill-name>
  recipes pack [--out=<file>]
  recipes publish [--api-key=<key>] [--private]
  recipes install <slug>[@<version>] [--client-mode] [--report-to=<url>] [--force]
  recipes update [<slug>]
  recipes sync [--channel=stable|latest] [--dry-run] [--quiet]
  recipes list

Python 3.11+ stdlib only (+ cryptography for ed25519).
"""

from __future__ import annotations

import argparse
import base64
import gzip
import hashlib
import io
import json
import os
import pathlib
import stat
import sys
import tarfile
import time
import tomllib
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone

# ─── Constants ──────────────────────────────────────────────────────────────

API_BASE = "https://recipes.wisechef.ai/api"
SKILLS_DIR = pathlib.Path(os.environ.get("RECIPES_INSTALL_DIR", pathlib.Path.home() / ".hermes" / "skills"))
KEYS_DIR = pathlib.Path.home() / ".recipes" / "keys"

EXCLUDE_DIRS_SET = {".git", "__pycache__", "venv", ".venv", "node_modules", ".eggs", "dist", "build"}
EXCLUDE_FILES_SET = {".recipes-meta.json"}


# ─── Install-dir resolution ───────────────────────────────────────────────────

def resolve_skills_dir(args: "argparse.Namespace") -> pathlib.Path:
    """Return the effective skills directory.

    Precedence: --install-dir flag > RECIPES_INSTALL_DIR env var > default.
    Validates: absolute path, no '..' components, must be (or can be) writable.
    """
    raw: str | None = getattr(args, "install_dir", None)
    if raw:
        p = pathlib.Path(raw)
        if not p.is_absolute():
            sys.exit(
                f"Error: --install-dir must be an absolute path, got: {raw}"
            )
        parts = p.parts
        if ".." in parts:
            sys.exit(
                f"Error: --install-dir must not contain '..' components, got: {raw}"
            )
        # Validate writable (create if missing first to allow fresh dirs)
        p.mkdir(parents=True, exist_ok=True)
        try:
            test_file = p / ".recipes_write_test"
            test_file.touch()
            test_file.unlink()
        except OSError:
            sys.exit(f"Error: --install-dir is not writable: {raw}")
        return p.resolve()

    env_val = os.environ.get("RECIPES_INSTALL_DIR", "")
    if env_val:
        return pathlib.Path(env_val)

    return SKILLS_DIR


# ─── Helpers ─────────────────────────────────────────────────────────────────

def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def sha256_file(path: pathlib.Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def load_skill_toml(cwd: pathlib.Path) -> dict:
    toml_path = cwd / "skill.toml"
    if not toml_path.exists():
        sys.exit(f"Error: skill.toml not found in {cwd}")
    with open(toml_path, "rb") as f:
        return tomllib.load(f)


def get_skill_section(meta: dict) -> dict:
    """Support both flat toml and [skill] table."""
    return meta.get("skill", meta)


def api_get(url: str, headers: dict | None = None) -> dict:
    req = urllib.request.Request(url)
    req.add_header("User-Agent", "recipes-cli/0.1.0 (+https://recipes.wisechef.ai)")
    if headers:
        for k, v in headers.items():
            req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        sys.exit(f"HTTP {e.code} from {url}: {body}")
    except urllib.error.URLError as e:
        sys.exit(f"Network error reaching {url}: {e.reason}")


def api_download(url: str, headers: dict | None = None) -> bytes:
    req = urllib.request.Request(url)
    req.add_header("User-Agent", "recipes-cli/0.1.0 (+https://recipes.wisechef.ai)")
    if headers:
        for k, v in headers.items():
            req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return resp.read()
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        sys.exit(f"HTTP {e.code} downloading {url}: {body}")
    except urllib.error.URLError as e:
        sys.exit(f"Network error downloading {url}: {e.reason}")


def multipart_post(url: str, fields: dict, files: dict, headers: dict | None = None) -> dict:
    """Multipart/form-data POST — stdlib only."""
    boundary = "----RecipesBoundary" + hashlib.sha256(os.urandom(16)).hexdigest()[:16]
    parts: list[bytes] = []

    for key, value in fields.items():
        parts.append(
            f"--{boundary}\r\n"
            f'Content-Disposition: form-data; name="{key}"\r\n\r\n'
            f"{value}\r\n".encode()
        )

    for key, (filename, data, content_type) in files.items():
        header = (
            f"--{boundary}\r\n"
            f'Content-Disposition: form-data; name="{key}"; filename="{filename}"\r\n'
            f"Content-Type: {content_type}\r\n\r\n"
        ).encode()
        parts.append(header)
        parts.append(data if isinstance(data, bytes) else data.encode())
        parts.append(b"\r\n")

    parts.append(f"--{boundary}--\r\n".encode())
    body = b"".join(parts)

    req_headers = {
        "Content-Type": f"multipart/form-data; boundary={boundary}",
        "Content-Length": str(len(body)),
        "User-Agent": "recipes-cli/0.1.0 (+https://recipes.wisechef.ai)",
    }
    if headers:
        req_headers.update(headers)

    req = urllib.request.Request(url, data=body, method="POST")
    for k, v in req_headers.items():
        req.add_header(k, v)

    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        body_err = e.read().decode("utf-8", errors="replace")
        sys.exit(f"HTTP {e.code} from {url}: {body_err}")
    except urllib.error.URLError as e:
        sys.exit(f"Network error reaching {url}: {e.reason}")


# ─── Ed25519 Key Management ──────────────────────────────────────────────────

def get_or_create_keypair(slug: str):
    """Return (private_key, pub_bytes_raw) for slug. Creates if missing."""
    try:
        from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
        from cryptography.hazmat.primitives.serialization import (
            Encoding,
            NoEncryption,
            PrivateFormat,
            PublicFormat,
            load_pem_private_key,
        )
    except ImportError:
        sys.exit(
            "Error: 'cryptography' package required for signing.\n"
            "Install with: pip install cryptography>=42"
        )

    KEYS_DIR.mkdir(parents=True, exist_ok=True)
    priv_path = KEYS_DIR / f"{slug}.priv"

    if priv_path.exists():
        with open(priv_path, "rb") as f:
            priv_key = load_pem_private_key(f.read(), password=None)
        print(f"Reusing keypair from {priv_path}")
    else:
        priv_key = Ed25519PrivateKey.generate()
        pem = priv_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
        priv_path.write_bytes(pem)
        os.chmod(priv_path, 0o600)
        print(f"Generated new keypair → {priv_path}")

    pub_bytes = priv_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
    return priv_key, pub_bytes


def ed25519_sign(priv_key, data: bytes) -> bytes:
    return priv_key.sign(data)


# ─── Pack Helpers ────────────────────────────────────────────────────────────

def collect_files(cwd: pathlib.Path) -> list[pathlib.Path]:
    """Collect files for packing — deterministic sort, excludes noise."""
    result: list[pathlib.Path] = []
    for root, dirs, filenames in os.walk(cwd):
        # In-place prune so os.walk won't descend into excluded dirs
        dirs[:] = sorted(d for d in dirs if d not in EXCLUDE_DIRS_SET)
        root_path = pathlib.Path(root)
        for fname in sorted(filenames):
            if fname in EXCLUDE_FILES_SET:
                continue
            result.append(root_path / fname)
    return result


def pack_tarball(cwd: pathlib.Path, out_path: pathlib.Path) -> str:
    """Pack cwd into a deterministic tar.gz. Returns sha256 hex.

    Determinism strategy:
    - Files sorted lexicographically (collect_files guarantees this).
    - TarInfo mtime/uid/gid/uname/gname fixed to 0 / "".
    - Gzip mtime=0, compresslevel=6 — eliminates host-timestamp variation.
    """
    files = collect_files(cwd)

    # Step 1: build an uncompressed tar in memory
    raw = io.BytesIO()
    with tarfile.open(fileobj=raw, mode="w") as tar:
        for fpath in files:
            rel = str(fpath.relative_to(cwd))
            info = tarfile.TarInfo(name=rel)
            info.mtime = 0
            info.uid = 0
            info.gid = 0
            info.uname = ""
            info.gname = ""
            info.size = fpath.stat().st_size
            info.mode = 0o644
            with open(fpath, "rb") as fh:
                tar.addfile(info, fh)

    # Step 2: gzip-compress with mtime=0 for reproducibility
    raw_bytes = raw.getvalue()
    gz_buf = io.BytesIO()
    with gzip.GzipFile(fileobj=gz_buf, mode="wb", mtime=0, compresslevel=6) as gz:
        gz.write(raw_bytes)

    data = gz_buf.getvalue()
    digest = sha256_bytes(data)
    out_path.write_bytes(data)
    return digest


# ─── Subcommand: init ────────────────────────────────────────────────────────

def cmd_init(args: argparse.Namespace) -> None:
    cwd = pathlib.Path.cwd()
    skill_name: str = args.skill_name
    slug = skill_name.lower().replace(" ", "-").replace("_", "-")

    toml_path = cwd / "skill.toml"
    md_path = cwd / "SKILL.md"

    if toml_path.exists():
        sys.exit("Error: skill.toml already exists — refusing to overwrite.")
    if md_path.exists():
        sys.exit("Error: SKILL.md already exists — refusing to overwrite.")

    toml_content = (
        f'[skill]\n'
        f'name = "{slug}"\n'
        f'version = "0.1.0"\n'
        f'description = "A brief description of {skill_name}"\n'
        f'license = "MIT"\n'
        f'entrypoint = "SKILL.md"\n'
        f'tier = "cook"\n'
        f'is_public = false\n'
    )

    md_content = f"""# {skill_name}

> **Tier:** cook | **Version:** 0.1.0 | **License:** MIT

## Overview

Describe what this skill does and why it exists.

## Usage

```
# How to invoke this skill
```

## Parameters

| Parameter | Type     | Required | Description          |
|-----------|----------|----------|----------------------|
| example   | string   | yes      | An example parameter |

## Examples

```
# Example invocation
```

## Notes

Add any additional notes, caveats, or references here.

---
*Scaffolded by `recipes init {slug}`*
"""

    toml_path.write_text(toml_content, encoding="utf-8")
    md_path.write_text(md_content, encoding="utf-8")

    print(f"Initialized skill '{slug}' in {cwd}")
    print(f"  Created: skill.toml")
    print(f"  Created: SKILL.md")
    print(f"\nNext steps:")
    print(f"  1. Edit skill.toml — fill in description")
    print(f"  2. Edit SKILL.md   — describe what your skill does")
    print(f"  3. recipes pack    — create a distributable tarball")
    print(f"  4. recipes publish — ship it to recipes.wisechef.ai")


# ─── Subcommand: pack ────────────────────────────────────────────────────────

def cmd_pack(args: argparse.Namespace) -> None:
    cwd = pathlib.Path.cwd()
    meta = load_skill_toml(cwd)
    skill = get_skill_section(meta)

    name = skill["name"]
    version = skill["version"]

    if args.out:
        out_path = pathlib.Path(args.out)
    else:
        out_path = pathlib.Path(f"{name}-{version}.tar.gz")

    digest = pack_tarball(cwd, out_path)
    print(f"Packed: {out_path}")
    print(f"sha256: {digest}")


# ─── Subcommand: publish ─────────────────────────────────────────────────────

def cmd_publish(args: argparse.Namespace) -> None:
    import tempfile

    cwd = pathlib.Path.cwd()
    meta = load_skill_toml(cwd)
    skill = get_skill_section(meta)

    name = skill["name"]
    version = skill["version"]
    description = skill.get("description", "")
    license_ = skill.get("license", "MIT")
    tier = skill.get("tier", "cook")
    is_public_toml = skill.get("is_public", False)

    api_key = args.api_key or os.environ.get("RECIPES_API_KEY", "")
    if not api_key:
        sys.exit(
            "Error: API key required.\n"
            "  Use --api-key=<key> or export RECIPES_API_KEY=<key>"
        )

    # Determine visibility
    is_public = not args.private if args.private else is_public_toml

    # Pack into a temp file
    with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tf:
        tmp_path = pathlib.Path(tf.name)

    try:
        print(f"Packing {name}@{version} ...")
        digest = pack_tarball(cwd, tmp_path)
        tarball_bytes = tmp_path.read_bytes()
        print(f"sha256: {digest}")

        # Sign
        priv_key, pub_bytes = get_or_create_keypair(name)
        # Endpoint verifies signature over sha256(tarball).digest(), not
        # tarball bytes directly. Match the contract.
        tarball_sha256_digest = hashlib.sha256(tarball_bytes).digest()
        signature = ed25519_sign(priv_key, tarball_sha256_digest)

        # Endpoint expects skill_toml, tarball, signature, signing_pubkey ALL
        # as multipart files (UploadFile), plus is_public + changelog as form fields.
        toml_path = cwd / "skill.toml"
        toml_bytes = toml_path.read_bytes()
        fields = {
            "is_public": str(is_public).lower(),
        }
        files = {
            "skill_toml": ("skill.toml", toml_bytes, "text/plain"),
            "tarball": (f"{name}-{version}.tar.gz", tarball_bytes, "application/gzip"),
            "signature": ("signature.bin", signature, "application/octet-stream"),
            "signing_pubkey": ("pubkey.bin", pub_bytes, "application/octet-stream"),
        }
        headers = {"x-api-key": api_key}

        print(f"Publishing {name}@{version} to {API_BASE}/skills/_publish ...")
        resp = multipart_post(f"{API_BASE}/skills/_publish", fields, files, headers)

        print(f"\n✓ Published: {name}@{version}")
        print(f"  URL: {resp.get('url', 'N/A')}")
        if resp.get("slug"):
            print(f"  Slug: {resp['slug']}")
        print(f"\nFull response:\n{json.dumps(resp, indent=2)}")

    finally:
        tmp_path.unlink(missing_ok=True)


# ─── Subcommand: install ──────────────────────────────────────────────────────

def cmd_install(args: argparse.Namespace) -> None:
    slug_ver: str = args.slug
    if "@" in slug_ver:
        slug, version = slug_ver.rsplit("@", 1)
    else:
        slug, version = slug_ver, None

    api_key = os.environ.get("RECIPES_API_KEY", "")
    req_headers: dict[str, str] = {}
    if api_key:
        req_headers["x-api-key"] = api_key

    # Fetch install manifest
    url = f"{API_BASE}/skills/install?slug={urllib.parse.quote(slug)}"
    if version:
        url += f"&version={urllib.parse.quote(version)}"

    print(f"Fetching install info for {slug} ...")
    info = api_get(url, headers=req_headers)

    remote_slug: str = info["slug"]
    remote_version: str = info["version"]
    tarball_url: str = info["tarball_url"]
    # Endpoint field is checksum_sha256 (with sha256 as a legacy alias)
    remote_sha256: str | None = info.get("checksum_sha256") or info.get("sha256")
    if not remote_sha256:
        sys.exit(f"Install response missing checksum_sha256: {info}")
    manifest: dict = info.get("manifest", {})
    category: str = manifest.get("category", "")

    skills_dir = resolve_skills_dir(args)
    install_dir = skills_dir / (category or "general") / remote_slug
    meta_path = install_dir / ".recipes-meta.json"

    # Skip if already at this version (unless --force)
    force = getattr(args, "force", False)
    if not force and meta_path.exists():
        with open(meta_path, encoding="utf-8") as f:
            existing = json.load(f)
        if existing.get("version") == remote_version:
            print(f"Already installed {remote_slug}@{remote_version} — use --force to reinstall.")
            return

    # Download
    print(f"Downloading {tarball_url} ...")
    tarball_bytes = api_download(tarball_url, headers=req_headers)

    # Verify sha256
    actual_sha256 = sha256_bytes(tarball_bytes)
    if actual_sha256 != remote_sha256:
        sys.exit(
            f"sha256 mismatch!\n"
            f"  expected: {remote_sha256}\n"
            f"  got:      {actual_sha256}"
        )
    print(f"sha256 verified: {actual_sha256}")

    # If manifest.category was absent, try reading category from skill.toml inside tarball
    if not category:
        try:
            with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar:
                toml_member = next(
                    (m for m in tar.getmembers() if m.name.lstrip("./") == "skill.toml" or m.name == "skill.toml"),
                    None,
                )
                if toml_member:
                    raw_toml = tar.extractfile(toml_member)
                    if raw_toml:
                        parsed = tomllib.loads(raw_toml.read().decode("utf-8", errors="replace"))
                        category = parsed.get("skill", parsed).get("category", "")
        except Exception:
            pass  # best-effort; fall through to default

    if not category:
        category = "general"

    # Recalculate install_dir now that category is resolved
    install_dir = skills_dir / category / remote_slug
    meta_path = install_dir / ".recipes-meta.json"

    # Extract (safe path stripping)
    install_dir.mkdir(parents=True, exist_ok=True)
    with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar:
        for member in tar.getmembers():
            mname = member.name
            # Safety checks
            if mname.startswith("/") or ".." in mname.split("/"):
                print(f"  [skip] unsafe path: {mname}")
                continue
            tar.extract(member, install_dir, set_attrs=False)

    # Write .recipes-meta.json
    meta: dict = {
        "slug": remote_slug,
        "version": remote_version,
        "installed_at": datetime.now(timezone.utc).isoformat(),
        "sha256": remote_sha256,
        "source_url": tarball_url,
    }
    if getattr(args, "client_mode", False):
        meta["client_mode"] = True
    if getattr(args, "report_to", None):
        meta["report_to"] = args.report_to

    meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
    print(f"Installed {remote_slug}@{remote_version} at {install_dir}")


# ─── Subcommand: verify ───────────────────────────────────────────────────────

def cmd_verify(args: argparse.Namespace) -> None:
    """Fetch the manifest for `slug`, download the tarball, verify sha256, and
    print the source URL + signature status. Exit non-zero on mismatch."""
    slug: str = args.slug
    if "@" in slug:
        slug, version = slug.rsplit("@", 1)
    else:
        version = None

    api_key = os.environ.get("RECIPES_API_KEY", "")
    req_headers: dict[str, str] = {}
    if api_key:
        req_headers["x-api-key"] = api_key

    url = f"{API_BASE}/skills/install?slug={urllib.parse.quote(slug)}"
    if version:
        url += f"&version={urllib.parse.quote(version)}"

    print(f"Fetching manifest for {slug} ...")
    info = api_get(url, headers=req_headers)

    remote_slug: str = info.get("slug", slug)
    remote_version: str = info.get("version", "?")
    tarball_url: str = info.get("tarball_url", "")
    expected_sha: str | None = info.get("checksum_sha256") or info.get("sha256")
    source_url: str = info.get("source_url", "") or info.get("repo_url", "") or tarball_url
    signature: str | None = info.get("signature") or info.get("ed25519_signature")

    if not tarball_url:
        sys.exit(f"Manifest for {slug} is missing tarball_url")
    if not expected_sha:
        sys.exit(f"Manifest for {slug} is missing checksum_sha256")

    print(f"Downloading {tarball_url} ...")
    tarball_bytes = api_download(tarball_url, headers=req_headers)
    actual_sha = sha256_bytes(tarball_bytes)

    if actual_sha != expected_sha:
        sys.exit(
            f"sha256 mismatch for {remote_slug}@{remote_version}!\n"
            f"  expected: {expected_sha}\n"
            f"  got:      {actual_sha}"
        )

    print(f"✓ {remote_slug}@{remote_version} sha256 verified: {actual_sha}")
    print(f"  source: {source_url}")
    print(f"  signature: {'present (' + signature[:16] + '…)' if signature else 'unsigned'}")
    print("OK")


# ─── Subcommand: update ───────────────────────────────────────────────────────

def cmd_update(args: argparse.Namespace) -> None:
    target_slug: str | None = getattr(args, "slug", None)

    skills_dir = resolve_skills_dir(args)
    if not skills_dir.exists():
        print("No skills installed (skills dir not found).")
        return

    meta_files = sorted(skills_dir.rglob(".recipes-meta.json"))
    if not meta_files:
        print("No installed skills found.")
        return

    api_key = os.environ.get("RECIPES_API_KEY", "")
    req_headers: dict[str, str] = {}
    if api_key:
        req_headers["x-api-key"] = api_key

    updated = 0
    for meta_path in meta_files:
        with open(meta_path, encoding="utf-8") as f:
            meta = json.load(f)

        slug = meta.get("slug", "")
        if target_slug and slug != target_slug:
            continue

        current_version = meta.get("version", "")
        print(f"Checking {slug} (current: {current_version}) ...")

        url = f"{API_BASE}/skills/install?slug={urllib.parse.quote(slug)}"
        try:
            info = api_get(url, headers=req_headers)
        except SystemExit as e:
            print(f"  ✗ {slug}: failed to fetch — {e}")
            continue

        latest_version = info["version"]
        if latest_version == current_version:
            print(f"  ✓ {slug}: already at latest {current_version}")
            continue

        print(f"  ↑ {slug}: {current_version} → {latest_version}")

        # Re-install
        class _FakeArgs:
            pass

        fake = _FakeArgs()
        fake.slug = f"{slug}@{latest_version}"
        fake.force = True
        fake.client_mode = meta.get("client_mode", False)
        fake.report_to = meta.get("report_to", None)
        cmd_install(fake)
        updated += 1

    print(f"\nDone. {updated} skill(s) updated.")


# ─── Subcommand: sync ─────────────────────────────────────────────────────────

SYNC_LOG = pathlib.Path.home() / ".hermes" / "recipes-sync.log"
SYNC_STATE = pathlib.Path.home() / ".hermes" / "recipes-sync-state.json"


def _detect_local_edits(skill_dir: pathlib.Path) -> list[str]:
    """Check for files that differ from the original tarball install.

    We compare against the stored sha256 of the tarball — if any file has
    been modified since install, the directory hash won't match. For
    lightweight detection we just check if any file mtime is newer than
    the .recipes-meta.json creation time.
    """
    meta_path = skill_dir / ".recipes-meta.json"
    if not meta_path.exists():
        return []

    meta_stat = meta_path.stat()
    edited: list[str] = []
    for fpath in skill_dir.rglob("*"):
        if fpath.is_dir():
            continue
        if fpath.name == ".recipes-meta.json":
            continue
        try:
            if fpath.stat().st_mtime > meta_stat.st_mtime + 1:
                edited.append(str(fpath.relative_to(skill_dir)))
        except OSError:
            pass
    return edited


def _parse_version(ver: str) -> tuple[int, ...]:
    """Best-effort semantic version tuple for comparison."""
    try:
        parts = ver.lstrip("v").split(".")
        return tuple(int(p) for p in parts)
    except (ValueError, AttributeError):
        return (0,)


def _write_sync_log(entries: list[dict]) -> None:
    """Append sync results to the persistent changelog."""
    SYNC_LOG.parent.mkdir(parents=True, exist_ok=True)
    now = datetime.now(timezone.utc).isoformat()
    with open(SYNC_LOG, "a", encoding="utf-8") as f:
        f.write(f"\n--- sync {now} ---\n")
        for entry in entries:
            action = entry.get("action", "unknown")
            slug = entry.get("slug", "?")
            if action == "updated":
                old = entry.get("old_version", "?")
                new = entry.get("new_version", "?")
                f.write(f"  UPDATED {slug}: {old} -> {new}\n")
            elif action == "skipped_local_edits":
                f.write(f"  SKIPPED {slug}: local edits detected (drift)\n")
            elif action == "skipped_current":
                f.write(f"  OK      {slug}: already current\n")
            elif action == "failed":
                reason = entry.get("reason", "unknown error")
                f.write(f"  FAILED  {slug}: {reason}\n")
            else:
                f.write(f"  {action.upper():8} {slug}\n")


def cmd_sync(args: argparse.Namespace) -> None:
    """Pull all entitled skill bundles and install/update locally.

    Behaviours:
    - Scans ~/.hermes/skills for installed skills via .recipes-meta.json
    - Queries the API for the latest version of each
    - Installs updates for skills with newer remote versions
    - Detects local file modifications and skips update if drift detected
    - Writes a changelog to ~/.hermes/recipes-sync.log
    - If drift > 24h on local edits, emits a warning (caller can Discord ping)
    """
    dry_run: bool = getattr(args, "dry_run", False)
    quiet: bool = getattr(args, "quiet", False)
    channel: str = getattr(args, "channel", "stable")

    def _log(msg: str) -> None:
        if not quiet:
            print(msg)

    skills_dir = resolve_skills_dir(args)
    if not skills_dir.exists():
        _log("No skills installed (skills dir not found).")
        return

    meta_files = sorted(skills_dir.rglob(".recipes-meta.json"))
    if not meta_files:
        _log("No installed skills found.")
        return

    api_key = os.environ.get("RECIPES_API_KEY", "")
    req_headers: dict[str, str] = {}
    if api_key:
        req_headers["x-api-key"] = api_key

    log_entries: list[dict] = []
    updated = 0
    skipped_edits = 0
    failed = 0
    current_count = 0

    # Load previous sync state for drift detection
    prev_state: dict = {}
    if SYNC_STATE.exists():
        try:
            with open(SYNC_STATE, encoding="utf-8") as f:
                prev_state = json.load(f)
        except (json.JSONDecodeError, OSError):
            pass

    new_state: dict = {}

    for meta_path in meta_files:
        try:
            with open(meta_path, encoding="utf-8") as f:
                meta = json.load(f)
        except (json.JSONDecodeError, OSError) as e:
            log_entries.append({"action": "failed", "slug": "?", "reason": str(e)})
            failed += 1
            continue

        slug = meta.get("slug", "")
        current_version = meta.get("version", "")
        installed_at = meta.get("installed_at", "")
        skill_dir = meta_path.parent

        # Detect local edits
        local_edits = _detect_local_edits(skill_dir)

        # Check drift timing — if installed >24h ago and has local edits
        installed_time = None
        if installed_at:
            try:
                installed_time = datetime.fromisoformat(installed_at)
                if installed_time.tzinfo is None:
                    installed_time = installed_time.replace(tzinfo=timezone.utc)
            except (ValueError, TypeError):
                pass

        drift_hours = 0.0
        if installed_time and local_edits:
            drift_delta = datetime.now(timezone.utc) - installed_time
            drift_hours = drift_delta.total_seconds() / 3600

        # Fetch latest from API
        url = f"{API_BASE}/skills/install?slug={urllib.parse.quote(slug)}"
        try:
            info = api_get(url, headers=req_headers)
        except SystemExit as e:
            log_entries.append({"action": "failed", "slug": slug, "reason": str(e)})
            _log(f"  ✗ {slug}: failed to fetch — {e}")
            failed += 1
            continue

        latest_version = info.get("version", "")

        # Channel filter: 'stable' skips pre-release versions
        if channel == "stable" and "-" in latest_version:
            _log(f"  ⊘ {slug}: latest {latest_version} is pre-release, skipping (stable channel)")
            log_entries.append({
                "action": "skipped_channel",
                "slug": slug,
                "version": latest_version,
            })
            new_state[slug] = {"version": current_version, "latest_seen": latest_version}
            continue

        new_state[slug] = {"version": current_version, "latest_seen": latest_version}

        if latest_version == current_version:
            log_entries.append({
                "action": "skipped_current",
                "slug": slug,
                "version": current_version,
            })
            current_count += 1
            continue

        # Local edit conflict
        if local_edits:
            if drift_hours > 24:
                _log(
                    f"  ⚠ {slug}: LOCAL EDITS CONFLICT (drift: {drift_hours:.1f}h) — "
                    f"skipping update {current_version} → {latest_version}"
                )
                _log(f"    Edited files: {', '.join(local_edits[:5])}")
            else:
                _log(
                    f"  ⚠ {slug}: local edits detected ({len(local_edits)} files), "
                    f"skipping update {current_version} → {latest_version}"
                )
            log_entries.append({
                "action": "skipped_local_edits",
                "slug": slug,
                "old_version": current_version,
                "new_version": latest_version,
                "edited_files": local_edits[:5],
                "drift_hours": round(drift_hours, 1),
            })
            skipped_edits += 1
            continue

        # Ready to update
        _log(f"  ↑ {slug}: {current_version} → {latest_version}")

        if dry_run:
            log_entries.append({
                "action": "would_update",
                "slug": slug,
                "old_version": current_version,
                "new_version": latest_version,
            })
            updated += 1
            continue

        # Perform the install
        class _FakeArgs:
            pass

        fake = _FakeArgs()
        fake.slug = f"{slug}@{latest_version}"
        fake.force = True
        fake.client_mode = meta.get("client_mode", False)
        fake.report_to = meta.get("report_to", None)
        try:
            cmd_install(fake)
            log_entries.append({
                "action": "updated",
                "slug": slug,
                "old_version": current_version,
                "new_version": latest_version,
            })
            new_state[slug]["version"] = latest_version
            updated += 1
        except SystemExit as e:
            log_entries.append({
                "action": "failed",
                "slug": slug,
                "old_version": current_version,
                "new_version": latest_version,
                "reason": str(e),
            })
            failed += 1

    # Write sync log
    _write_sync_log(log_entries)

    # Persist sync state
    SYNC_STATE.parent.mkdir(parents=True, exist_ok=True)
    with open(SYNC_STATE, "w", encoding="utf-8") as f:
        json.dump({
            "last_sync": datetime.now(timezone.utc).isoformat(),
            "channel": channel,
            "skills": new_state,
        }, f, indent=2)

    # Summary
    mode_label = " (dry-run)" if dry_run else ""
    _log(
        f"\nSync complete{mode_label}: "
        f"{updated} updated, {skipped_edits} skipped (local edits), "
        f"{current_count} current, {failed} failed"
    )
    _log(f"Sync log: {SYNC_LOG}")

    # Exit non-zero if anything failed
    if failed > 0:
        sys.exit(1)


# ─── Subcommand: share ───────────────────────────────────────────────────────

_SECRET_FALLBACK = pathlib.Path.home() / ".hermes" / "secrets" / "tori-recipes-mcp-key.txt"
_SHARE_UA = "recipes-cli/0.1.0 (+https://recipes.wisechef.ai)"


def _share_get_api_key() -> str:
    key = os.environ.get("RECIPES_API_KEY", "").strip()
    if key:
        return key
    if _SECRET_FALLBACK.exists():
        return _SECRET_FALLBACK.read_text().strip()
    sys.exit(
        "Error: RECIPES_API_KEY not set and "
        f"{_SECRET_FALLBACK} not found.\n"
        "Set the env var or create the fallback file."
    )


def _share_api_post(path: str, body: dict, api_key: str) -> dict:
    base = API_BASE.removesuffix("/api") if API_BASE.endswith("/api") else API_BASE.rsplit("/api", 1)[0] if "/api" in API_BASE else API_BASE
    url = f"{base}{path}"
    data = json.dumps(body).encode()
    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("x-api-key", api_key)
    req.add_header("User-Agent", _SHARE_UA)
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        sys.exit(f"API error {exc.code}: {detail}")
    except urllib.error.URLError as exc:
        sys.exit(f"Network error: {exc.reason}")


def _share_api_get(path: str, api_key: str) -> dict:
    base = API_BASE.removesuffix("/api") if API_BASE.endswith("/api") else API_BASE.rsplit("/api", 1)[0] if "/api" in API_BASE else API_BASE
    url = f"{base}{path}"
    req = urllib.request.Request(url, method="GET")
    req.add_header("x-api-key", api_key)
    req.add_header("User-Agent", _SHARE_UA)
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        sys.exit(f"API error {exc.code}: {detail}")
    except urllib.error.URLError as exc:
        sys.exit(f"Network error: {exc.reason}")


def _share_api_delete(path: str, api_key: str) -> None:
    base = API_BASE.removesuffix("/api") if API_BASE.endswith("/api") else API_BASE.rsplit("/api", 1)[0] if "/api" in API_BASE else API_BASE
    url = f"{base}{path}"
    req = urllib.request.Request(url, method="DELETE")
    req.add_header("x-api-key", api_key)
    req.add_header("User-Agent", _SHARE_UA)
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            pass  # 204 expected
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        sys.exit(f"API error {exc.code}: {detail}")
    except urllib.error.URLError as exc:
        sys.exit(f"Network error: {exc.reason}")


def _share_print_config_blocks(token: str) -> None:
    """Print copy-paste MCP config blocks for Hermes and Claude Desktop."""
    # Derive MCP URL from API_BASE
    base = API_BASE.removesuffix("/api") if API_BASE.endswith("/api") else API_BASE.rsplit("/api", 1)[0] if "/api" in API_BASE else API_BASE
    mcp_url = f"{base}/api/mcp/http"
    divider = "=" * 60

    hermes_block = f"""\
# ── Hermes config.yaml ──
mcpServers:
  recipes-shared:
    transport: streamable-http
    url: {mcp_url}
    headers:
      x-api-key: {token}
"""

    claude_block = f"""\
// ── Claude Desktop  (claude_desktop_config.json) ──
{{
  "mcpServers": {{
    "recipes-shared": {{
      "type": "streamable-http",
      "url": "{mcp_url}",
      "headers": {{
        "x-api-key": "{token}"
      }}
    }}
  }}
}}
"""

    print(divider)
    print("Copy-paste the block that matches your client:")
    print(divider)
    print()
    print(hermes_block)
    print()
    print(claude_block)
    print(divider)


def cmd_share_create(args: argparse.Namespace) -> None:
    """Create a share token for a cookbook."""
    api_key = _share_get_api_key()
    scope = getattr(args, "scope", "edit")
    name = getattr(args, "name", "shared via CLI") or "shared via CLI"

    resp = _share_api_post(
        f"/api/cookbooks/{args.cookbook_id}/share-tokens",
        {"name": name, "scope": scope},
        api_key,
    )

    token = resp.get("token", "")
    print("✓ Share token created")
    print(f"  Token:   {token}")
    print(f"  Prefix:  {resp.get('prefix', '')}")
    print(f"  Scope:   {resp.get('scope', scope)}")
    print(f"  Name:    {resp.get('name', name)}")
    print(f"  Expires: never (revoke with: recipes share revoke {args.cookbook_id} {resp.get('id', '<token-id>')})")
    print()
    _share_print_config_blocks(token)


def cmd_share_list(args: argparse.Namespace) -> None:
    """List share tokens for a cookbook."""
    api_key = _share_get_api_key()
    resp = _share_api_get(
        f"/api/cookbooks/{args.cookbook_id}/share-tokens",
        api_key,
    )
    tokens = resp.get("tokens", [])
    if not tokens:
        print("No share tokens found.")
        return
    col_id = 24
    col_prefix = 16
    col_scope = 8
    header = f"{'ID':<{col_id}} {'Prefix':<{col_prefix}} {'Scope':<{col_scope}} Name"
    print(header)
    print("─" * (col_id + col_prefix + col_scope + 30))
    for t in tokens:
        print(
            f"{t.get('id', '?'):<{col_id}} "
            f"{t.get('prefix', '?'):<{col_prefix}} "
            f"{t.get('scope', '?'):<{col_scope}} "
            f"{t.get('name', '')}"
        )


def cmd_share_revoke(args: argparse.Namespace) -> None:
    """Revoke a share token."""
    api_key = _share_get_api_key()
    _share_api_delete(
        f"/api/cookbooks/{args.cookbook_id}/share-tokens/{args.token_id}",
        api_key,
    )
    print(f"✓ Token {args.token_id} revoked.")


def cmd_share_rotate(args: argparse.Namespace) -> None:
    """Rotate (replace) a share token — revoke old, create new with same settings."""
    api_key = _share_get_api_key()
    # Fetch existing token info
    resp = _share_api_get(
        f"/api/cookbooks/{args.cookbook_id}/share-tokens",
        api_key,
    )
    tokens = resp.get("tokens", [])
    old = next((t for t in tokens if t.get("id") == args.token_id), None)
    if old is None:
        sys.exit(f"Token {args.token_id} not found in cookbook {args.cookbook_id}")

    old_scope = old.get("scope", "edit")
    old_name = old.get("name", "rotated token")

    # Revoke old
    _share_api_delete(
        f"/api/cookbooks/{args.cookbook_id}/share-tokens/{args.token_id}",
        api_key,
    )
    print(f"✓ Old token {args.token_id} revoked.")

    # Create new
    new_resp = _share_api_post(
        f"/api/cookbooks/{args.cookbook_id}/share-tokens",
        {"name": f"{old_name} (rotated)", "scope": old_scope},
        api_key,
    )
    new_token = new_resp.get("token", "")
    print(f"✓ New token created: {new_resp.get('id', '?')}")
    print(f"  Token:  {new_token}")
    print()
    _share_print_config_blocks(new_token)


def cmd_share(args: argparse.Namespace) -> None:
    """Dispatcher for 'recipes share <subcommand>'."""
    sub = getattr(args, "share_command", None)
    dispatch = {
        "create": cmd_share_create,
        "list": cmd_share_list,
        "revoke": cmd_share_revoke,
        "rotate": cmd_share_rotate,
    }
    handler = dispatch.get(sub or "")
    if handler is None:
        print(f"Unknown share subcommand: {sub}", file=sys.stderr)
        sys.exit(1)
    handler(args)


# ─── Subcommand: telemetry emit ──────────────────────────────────────────────

_VALID_TELEMETRY_EVENTS = frozenset(
    {"install", "first_use", "task_completed", "task_failed", "replaced"}
)
_AGENT_HASH_RE = None  # lazy-compiled


def _agent_hash_re():
    global _AGENT_HASH_RE
    if _AGENT_HASH_RE is None:
        import re
        _AGENT_HASH_RE = re.compile(r"^[a-f0-9]{8,64}$")
    return _AGENT_HASH_RE


def cmd_telemetry_emit(args: argparse.Namespace) -> None:
    """POST a typed telemetry event to the recipes API."""
    # ── Env checks ──────────────────────────────────────────────────────────
    api_key = os.environ.get("RECIPES_API_KEY", "")
    if not api_key:
        print(
            "Error: RECIPES_API_KEY env var is required for telemetry emit.",
            file=sys.stderr,
        )
        sys.exit(1)

    base = os.environ.get("RECIPES_API_BASE", API_BASE)
    url = f"{base}/telemetry"

    # ── Client-side validation ───────────────────────────────────────────────
    if args.event not in _VALID_TELEMETRY_EVENTS:
        print(
            f"Error: invalid event type '{args.event}'. "
            f"Must be one of: {', '.join(sorted(_VALID_TELEMETRY_EVENTS))}",
            file=sys.stderr,
        )
        sys.exit(1)

    if args.duration is not None and not (0 <= args.duration <= 86400):
        print(
            f"Error: --duration must be between 0 and 86400 (got {args.duration}).",
            file=sys.stderr,
        )
        sys.exit(1)

    if args.agent_hash is not None and not _agent_hash_re().match(args.agent_hash):
        print(
            "Error: --agent-hash must match ^[a-f0-9]{8,64}$ (lowercase hex, 8-64 chars).",
            file=sys.stderr,
        )
        sys.exit(1)

    # ── Build payload ────────────────────────────────────────────────────────
    payload: dict = {
        "event_type": args.event,
        "skill_slug": args.skill,
        "retry_count": args.retries,
        "user_intervention": args.intervention,
    }
    if args.goal_class is not None:
        payload["goal_class"] = args.goal_class
    if args.duration is not None:
        payload["duration_seconds"] = args.duration
    if args.agent_hash is not None:
        payload["agent_class_hash"] = args.agent_hash

    # ── POST ─────────────────────────────────────────────────────────────────
    body_bytes = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(url, data=body_bytes, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("Content-Length", str(len(body_bytes)))
    req.add_header("User-Agent", "recipes-cli/0.1.0 (+https://recipes.wisechef.ai)")
    req.add_header("x-api-key", api_key)

    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            status = resp.status
            resp_body = json.loads(resp.read())
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")
        print(f"Error: HTTP {e.code} from {url}: {err_body}", file=sys.stderr)
        sys.exit(1)
    except urllib.error.URLError as e:
        print(f"Error: network error reaching {url}: {e.reason}", file=sys.stderr)
        sys.exit(1)

    if status != 201:
        print(
            f"Error: expected HTTP 201 but got {status}: {resp_body}",
            file=sys.stderr,
        )
        sys.exit(1)

    event_id = resp_body.get("event_id", "")
    print(event_id)


def cmd_telemetry(args: argparse.Namespace) -> None:
    """Dispatcher for 'recipes telemetry <subcommand>'."""
    sub = getattr(args, "telemetry_command", None)
    if sub == "emit":
        cmd_telemetry_emit(args)
    else:
        print(f"Unknown telemetry subcommand: {sub}", file=sys.stderr)
        sys.exit(1)


# ─── Subcommand: list ────────────────────────────────────────────────────────

def cmd_list(args: argparse.Namespace) -> None:
    skills_dir = resolve_skills_dir(args)
    if not skills_dir.exists():
        print("No skills installed (skills dir not found).")
        return

    meta_files = sorted(skills_dir.rglob(".recipes-meta.json"))
    if not meta_files:
        print("No installed skills found.")
        return

    col_slug = 30
    col_ver = 12
    col_date = 28
    header = (
        f"{'Slug':<{col_slug}} {'Version':<{col_ver}} {'Installed At':<{col_date}} Source URL"
    )
    print(header)
    print("─" * (col_slug + col_ver + col_date + 40))

    for meta_path in meta_files:
        try:
            with open(meta_path, encoding="utf-8") as f:
                meta = json.load(f)
        except (json.JSONDecodeError, OSError) as e:
            print(f"  [warn] could not read {meta_path}: {e}")
            continue

        slug = meta.get("slug", "?")
        version = meta.get("version", "?")
        installed_at = meta.get("installed_at", "?")
        source_url = meta.get("source_url", "?")
        print(f"{slug:<{col_slug}} {version:<{col_ver}} {installed_at:<{col_date}} {source_url}")


# ─── Subcommand: capabilities ─────────────────────────────────────────────────

def _detect_os() -> str:
    if sys.platform.startswith("linux"):
        return "linux"
    if sys.platform == "darwin":
        return "macos"
    return "unknown"


def _detect_pkg_mgr() -> str:
    import shutil as _sh
    for cand in ("brew", "apt-get", "dnf", "pacman"):
        if _sh.which(cand):
            return "apt" if cand == "apt-get" else cand
    return "unknown"


def _probe(tool: str) -> str:
    import shutil as _sh
    return "ok" if _sh.which(tool) else "missing"


_PROBE_TOOLS = ("git", "python3", "node", "sqlite3", "jq", "ssh", "tmux", "docker", "curl", "claude")
# Map normalized capability key → command name
_PROBE_KEYS = {
    "git": "git",
    "python": "python3",
    "node": "node",
    "sqlite": "sqlite3",
    "jq": "jq",
    "ssh": "ssh",
    "tmux": "tmux",
    "docker": "docker",
    "curl": "curl",
    "claude-cli": "claude",
}


def cmd_capabilities(args: argparse.Namespace) -> None:
    caps = {key: _probe(cmd) for key, cmd in _PROBE_KEYS.items()}
    payload = {
        "os": _detect_os(),
        "pkg_mgr": _detect_pkg_mgr(),
        "capabilities": caps,
    }
    if args.json:
        print(json.dumps(payload, indent=2))
        return
    print(f"os: {payload['os']}")
    print(f"pkg_mgr: {payload['pkg_mgr']}")
    print("capabilities:")
    for key in sorted(caps):
        mark = "✓" if caps[key] == "ok" else "✗"
        print(f"  {mark} {key}")


# ─── Subcommand: onboard ──────────────────────────────────────────────────────

# Bundle definitions live in repo bundles/*.yaml but for the in-tree CLI we
# keep a small mirror so the CLI works in a fresh install with no checkout.
_BUNDLES = {
    "starter-solo-operator": [
        "client-reporter",
        "cold-outreach",
        "content-calendar",
        "seo-audit",
        "invoice-automation",
    ],
    "starter-fleet-operator": [
        "recipes-skill",
        "client-reporter",
        "cold-outreach",
        "content-calendar",
        "seo-audit",
        "proposal-builder",
        "client-onboarding",
        "invoice-automation",
        "slack-standup",
        "gohighlevel-toolkit",
    ],
}

_GOAL_TO_BUNDLE = {
    "solo": "starter-solo-operator",
    "fleet": "starter-fleet-operator",
}


def _suggest_bundle_from_host() -> str:
    """If docker + many tools are present, suggest the fleet bundle.
    Otherwise fall back to the solo bundle."""
    docker_ok = _probe("docker") == "ok"
    tmux_ok = _probe("tmux") == "ok"
    if docker_ok and tmux_ok:
        return "starter-fleet-operator"
    return "starter-solo-operator"


def cmd_onboard(args: argparse.Namespace) -> None:
    if args.goal is not None:
        if args.goal not in _GOAL_TO_BUNDLE:
            print(
                f"recipes onboard: unknown goal '{args.goal}' (expected: solo, fleet)",
                file=sys.stderr,
            )
            sys.exit(2)
        bundle = _GOAL_TO_BUNDLE[args.goal]
    elif args.non_interactive:
        bundle = _suggest_bundle_from_host()
    else:
        # interactive: ask the question.
        print("What do you want to ship this week?")
        print("  1) solo work — client reports, outreach, invoicing")
        print("  2) fleet work — multi-skill agent fleet")
        choice = input("Choice [1]: ").strip() or "1"
        if choice == "1":
            bundle = _GOAL_TO_BUNDLE["solo"]
        elif choice == "2":
            bundle = _GOAL_TO_BUNDLE["fleet"]
        else:
            print("Invalid choice; defaulting to solo.")
            bundle = _GOAL_TO_BUNDLE["solo"]

    skills = _BUNDLES[bundle]
    if args.json:
        print(json.dumps({"bundle": bundle, "skills": skills}, indent=2))
        return
    print(f"Recommended bundle: {bundle}")
    print("Skills:")
    for s in skills:
        print(f"  - {s}")
    print()
    print(f"To install: recipes install {bundle}")


# ─── Argument Parser ──────────────────────────────────────────────────────────

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="recipes",
        description=(
            "CLI for publishing and installing skills against recipes.wisechef.ai\n\n"
            "Subcommands:\n"
            "  init      Scaffold a new skill (skill.toml + SKILL.md)\n"
            "  pack      Pack skill into a reproducible tar.gz\n"
            "  publish   Sign + upload skill to recipes.wisechef.ai\n"
            "  install   Download + verify + extract a skill\n"
            "  update    Re-install skills that have newer versions\n"
            "  sync      Pull all entitled bundles and install updates\n"
            "  list      Show all installed skills\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    sub = parser.add_subparsers(dest="command", metavar="<command>")
    sub.required = True

    # ── init ──
    p_init = sub.add_parser("init", help="Scaffold a new skill directory")
    p_init.add_argument("skill_name", metavar="<skill-name>", help="Human-readable skill name")

    # ── pack ──
    p_pack = sub.add_parser("pack", help="Pack skill into a reproducible tar.gz")
    p_pack.add_argument(
        "--out",
        metavar="<file>",
        default=None,
        help="Output file (default: {name}-{version}.tar.gz)",
    )

    # ── publish ──
    p_pub = sub.add_parser("publish", help="Publish skill to recipes.wisechef.ai")
    p_pub.add_argument("--api-key", metavar="<key>", default=None, dest="api_key")
    p_pub.add_argument(
        "--private",
        action="store_true",
        help="Override is_public=false regardless of skill.toml",
    )

    # ── install ──
    p_ins = sub.add_parser("install", help="Install a skill from recipes.wisechef.ai")
    p_ins.add_argument("slug", metavar="<slug[@version]>", help="Skill slug (optionally @version)")
    p_ins.add_argument(
        "--client-mode",
        action="store_true",
        dest="client_mode",
        help="Record skill as client-mode in meta",
    )
    p_ins.add_argument("--report-to", metavar="<url>", default=None, dest="report_to")
    p_ins.add_argument(
        "--force",
        action="store_true",
        help="Reinstall even if already at requested version",
    )
    p_ins.add_argument(
        "--install-dir",
        metavar="<path>",
        default=None,
        dest="install_dir",
        help="Override skills install directory (env: RECIPES_INSTALL_DIR)",
    )

    # ── verify ──
    p_ver = sub.add_parser("verify", help="Verify a skill's tarball matches its manifest sha256")
    p_ver.add_argument("slug", metavar="<slug[@version]>", help="Skill slug (optionally @version)")

    # ── update ──
    p_upd = sub.add_parser("update", help="Update installed skills to latest versions")
    p_upd.add_argument(
        "slug",
        metavar="<slug>",
        nargs="?",
        default=None,
        help="Specific skill to update (default: all)",
    )
    p_upd.add_argument(
        "--install-dir",
        metavar="<path>",
        default=None,
        dest="install_dir",
        help="Override skills install directory (env: RECIPES_INSTALL_DIR)",
    )

    # ── list ──
    p_lst = sub.add_parser("list", help="List all installed skills")
    p_lst.add_argument(
        "--install-dir",
        metavar="<path>",
        default=None,
        dest="install_dir",
        help="Override skills install directory (env: RECIPES_INSTALL_DIR)",
    )

    # ── telemetry ──
    p_tel = sub.add_parser("telemetry", help="Telemetry commands")
    tel_sub = p_tel.add_subparsers(
        dest="telemetry_command", metavar="<subcommand>"
    )
    tel_sub.required = True

    p_emit = tel_sub.add_parser("emit", help="Post a typed telemetry event to the recipes API")
    p_emit.add_argument(
        "--skill",
        required=True,
        metavar="<slug>",
        help="Skill slug (required)",
    )
    p_emit.add_argument(
        "--event",
        required=True,
        metavar="<event_type>",
        dest="event",
        help="Event type: install, first_use, task_completed, task_failed, replaced",
    )
    p_emit.add_argument(
        "--goal-class",
        metavar="<class>",
        default=None,
        dest="goal_class",
        help="Goal class (optional)",
    )
    p_emit.add_argument(
        "--duration",
        type=int,
        metavar="<seconds>",
        default=None,
        help="Duration in seconds (0-86400, optional)",
    )
    p_emit.add_argument(
        "--retries",
        type=int,
        default=0,
        metavar="<n>",
        help="Retry count (default 0)",
    )
    p_emit.add_argument(
        "--intervention",
        dest="intervention",
        action="store_true",
        default=False,
        help="Flag that a human intervention occurred",
    )
    p_emit.add_argument(
        "--no-intervention",
        dest="intervention",
        action="store_false",
        help="No human intervention (default)",
    )
    p_emit.add_argument(
        "--agent-hash",
        metavar="<hex>",
        default=None,
        dest="agent_hash",
        help="Agent class hash (hex, 8-64 chars, optional)",
    )

    # ── capabilities ──
    p_cap = sub.add_parser(
        "capabilities",
        help="Probe host for installed tooling (git, python, docker, ...)",
    )
    p_cap.add_argument("--json", action="store_true", help="Emit JSON")

    # ── sync ──
    p_sync = sub.add_parser(
        "sync",
        help="Pull all entitled skill bundles and install updates locally",
    )
    p_sync.add_argument(
        "--channel",
        choices=("stable", "latest"),
        default="stable",
        help="Release channel: stable skips pre-releases, latest includes all (default: stable)",
    )
    p_sync.add_argument(
        "--dry-run",
        action="store_true",
        dest="dry_run",
        help="Show what would be updated without making changes",
    )
    p_sync.add_argument(
        "--quiet",
        action="store_true",
        help="Suppress all output except errors",
    )
    p_sync.add_argument(
        "--install-dir",
        metavar="<path>",
        default=None,
        dest="install_dir",
        help="Override skills install directory (env: RECIPES_INSTALL_DIR)",
    )

    # ── share ──
    p_shr = sub.add_parser(
        "share",
        help="Manage cookbook share tokens (create / list / revoke / rotate)",
    )
    shr_sub = p_shr.add_subparsers(dest="share_command", metavar="<subcommand>")
    shr_sub.required = True

    # share create
    p_shr_create = shr_sub.add_parser("create", help="Create a share token for a cookbook")
    p_shr_create.add_argument("cookbook_id", help="UUID of the cookbook to share")
    p_shr_create.add_argument(
        "--scope",
        choices=("edit", "read"),
        default="edit",
        help="Token scope: edit (default) or read",
    )
    p_shr_create.add_argument(
        "--name",
        default="shared via CLI",
        help="Human-readable label for this token",
    )

    # share list
    p_shr_list = shr_sub.add_parser("list", help="List share tokens for a cookbook")
    p_shr_list.add_argument("cookbook_id", help="UUID of the cookbook")

    # share revoke
    p_shr_revoke = shr_sub.add_parser("revoke", help="Revoke a share token")
    p_shr_revoke.add_argument("cookbook_id", help="UUID of the cookbook")
    p_shr_revoke.add_argument("token_id", help="ID of the share token to revoke")

    # share rotate
    p_shr_rotate = shr_sub.add_parser(
        "rotate",
        help="Rotate a share token (revoke + re-create with same scope)",
    )
    p_shr_rotate.add_argument("cookbook_id", help="UUID of the cookbook")
    p_shr_rotate.add_argument("token_id", help="ID of the share token to rotate")

    # ── onboard ──
    p_on = sub.add_parser(
        "onboard",
        help="Detect host and suggest a starter bundle",
    )
    p_on.add_argument(
        "--goal",
        choices=None,  # validated in cmd_onboard for clearer error messages
        default=None,
        help="solo or fleet (skip the interactive prompt)",
    )
    p_on.add_argument(
        "--non-interactive",
        action="store_true",
        dest="non_interactive",
        help="Do not prompt; use heuristic + flags",
    )
    p_on.add_argument("--json", action="store_true", help="Emit JSON")

    return parser


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()

    dispatch = {
        "init": cmd_init,
        "pack": cmd_pack,
        "publish": cmd_publish,
        "install": cmd_install,
        "verify": cmd_verify,
        "update": cmd_update,
        "list": cmd_list,
        "telemetry": cmd_telemetry,
        "capabilities": cmd_capabilities,
        "onboard": cmd_onboard,
        "sync": cmd_sync,
        "share": cmd_share,
    }
    dispatch[args.command](args)


if __name__ == "__main__":
    main()
