#!/usr/bin/env python3
"""collector: gather env vars + structured files and POST them to a sink.

It builds a single JSON envelope:

    {
      "collected_at": "...iso...",
      "env":     { ...all environment variables... },   # unless --no-env
      "payload": { ...inline payload from --payload... }, # optional
      "data":    { ...every parsed file merged into one map... }
      "stdin":   { ...the hook payload, when --stdin is set... }, # optional
    }

Only YAML (.yaml/.yml), JSON (.json) and HCL (.hcl/.tf) files are read. Given
folders, it scans the top level only — or recursively with -r. All parsed file
contents are merged into the single `data` map (mappings deep-merge; anything
with a non-mapping root is filed under its relative path).

Optional post-processors (--post-process NAME) run after the envelope is built
and add derived metadata under a top-level `enrichment` map, one key per
processor, leaving the raw `stdin` payload untouched. The first is `git`: when
the hook payload's cwd is a git work tree it records branch/sha/dirty/etc under
envelope.enrichment.git. Post-processors are best-effort and never fail a hook.

If the serialised envelope exceeds --max-bytes, it is split into byte-chunks
and POSTed as several requests sharing one batch id, each tagged with its part
number so the sink can reassemble them later. A single-call send still carries
batch headers (parts=1) so the server treats every send uniformly.
"""

from __future__ import annotations

import argparse
import base64
import binascii
import hashlib
import json
import os
import re
import socket
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from urllib import request as urlrequest
from urllib.parse import urlsplit

# Must mirror server.py.
H_BATCH = "X-Collector-Batch-Id"
H_PART = "X-Collector-Part"
H_PARTS = "X-Collector-Parts"
H_TOTAL = "X-Collector-Total-Bytes"
H_CTYPE = "X-Collector-Content-Type"
H_AGENT = "X-Collector-Agent"
H_SECRET = "X-Collector-Secret"
H_HOST = "X-Collector-Host"
H_HOST_ID = "X-Collector-Host-Id"
H_SESSION = "X-Collector-Session-Id"
H_EVENT = "X-Collector-Event"
H_CWD = "X-Collector-Cwd"
H_MODEL = "X-Collector-Model"
H_GROUP_KIND = "X-Collector-Group-Kind"
H_GROUP_ID = "X-Collector-Group-Id"
H_GROUP_NAME = "X-Collector-Group-Name"
H_KEY_ID = "X-Collector-Key-Id"
H_SIGNATURE = "X-Collector-Signature"
H_SIGNED_AT = "X-Collector-Signed-At"
H_NONCE = "X-Collector-Nonce"

# Orchestrators (e.g. scripts/auto-*.sh loops) export these before spawning
# sessions; hook processes inherit them, so they identify the group every
# event belongs to. Read from os.environ, not the envelope, so they work
# even with --no-env.
_GROUP_ENV_HEADERS = (
    ("COLLECTOR_GROUP_KIND", H_GROUP_KIND),
    ("COLLECTOR_GROUP_ID", H_GROUP_ID),
    ("COLLECTOR_GROUP_NAME", H_GROUP_NAME),
)

SSH_NAMESPACE = "cacp-collector-v1"
DEFAULT_PRIVATE_KEYS = (
    "~/.collector/id_ed25519",
    "~/.ssh/id_ed25519",
    "~/.ssh/id_ecdsa",
    "~/.ssh/id_rsa",
)

YAML_EXT = {".yaml", ".yml"}
JSON_EXT = {".json"}
HCL_EXT = {".hcl", ".tf"}
ALLOWED_EXT = YAML_EXT | JSON_EXT | HCL_EXT


def parse_file(path: Path):
    """Parse one structured file into a Python object. Lazy-imports the YAML
    and HCL parsers so a JSON-only run needs zero third-party packages."""
    ext = path.suffix.lower()
    text = path.read_text()
    if ext in JSON_EXT:
        return json.loads(text)
    if ext in YAML_EXT:
        try:
            import yaml
        except ImportError:
            sys.exit("PyYAML is required to read YAML files: pip install pyyaml")
        return yaml.safe_load(text)
    if ext in HCL_EXT:
        try:
            import hcl2
        except ImportError:
            sys.exit("python-hcl2 is required to read HCL files: pip install python-hcl2")
        return hcl2.loads(text)
    raise ValueError(f"unsupported extension: {path}")


def find_files(targets, recursive: bool):
    """Expand the given file/folder targets into a concrete list of allowed
    files. Folders are scanned top-level only unless recursive is set."""
    found = []
    for target in targets:
        p = Path(target)
        if not p.exists():
            sys.exit(f"no such file or folder: {target}")
        if p.is_file():
            if p.suffix.lower() in ALLOWED_EXT:
                found.append(p)
            else:
                sys.exit(f"unsupported file type (need YAML/JSON/HCL): {target}")
        else:
            it = p.rglob("*") if recursive else p.iterdir()
            for child in sorted(it):
                if child.is_file() and child.suffix.lower() in ALLOWED_EXT:
                    found.append(child)
    return found


def deep_merge(dst: dict, src: dict, path: str, collisions: list):
    """Recursively merge src into dst. On a scalar/list conflict, src wins and
    the clobbered key path is recorded so nothing is lost silently."""
    for key, value in src.items():
        if key in dst and isinstance(dst[key], dict) and isinstance(value, dict):
            deep_merge(dst[key], value, f"{path}.{key}", collisions)
        else:
            if key in dst:
                collisions.append(f"{path}.{key}")
            dst[key] = value


def build_data(files, base_dir: Path):
    """Merge every file's parsed contents into one map. Mapping roots deep-merge
    into the shared map; non-mapping roots (lists/scalars) are filed under the
    file's path relative to the scan root, so they remain addressable."""
    data: dict = {}
    collisions: list = []
    for f in files:
        parsed = parse_file(f)
        try:
            rel = str(f.relative_to(base_dir))
        except ValueError:
            rel = str(f)
        if isinstance(parsed, dict):
            deep_merge(data, parsed, rel, collisions)
        else:
            data[rel] = parsed
    return data, collisions


def chunk_bytes(blob: bytes, max_bytes: int):
    """Split blob into <=max_bytes pieces, preserving order."""
    if max_bytes <= 0:
        return [blob]
    return [blob[i:i + max_bytes] for i in range(0, len(blob), max_bytes)] or [b""]


def post(url: str, body: bytes, headers: dict, timeout: float = 10.0):
    req = urlrequest.Request(url, data=body, headers=headers, method="POST")
    with urlrequest.urlopen(req, timeout=timeout) as resp:
        return resp.status, resp.read()


def _b64decode(text: str) -> bytes:
    return base64.b64decode(text.encode("ascii"), validate=True)


def normalize_public_key(public_key: str) -> str:
    text = " ".join((public_key or "").strip().split())
    parts = text.split()
    if len(parts) < 2:
        raise ValueError("expected an OpenSSH public key")
    try:
        _b64decode(parts[1])
    except (binascii.Error, UnicodeEncodeError) as exc:
        raise ValueError("public key is not valid base64") from exc
    return f"{parts[0]} {parts[1]}" + (
        f" {' '.join(parts[2:])}" if len(parts) > 2 else "")


def public_key_fingerprint(public_key: str) -> str:
    normalized = normalize_public_key(public_key)
    digest = hashlib.sha256(_b64decode(normalized.split()[1])).digest()
    return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")


def _ssh_keygen(args, input_bytes: bytes | None = None,
                timeout: float = 5.0) -> bytes:
    proc = subprocess.run(
        ["ssh-keygen", *args],
        input=input_bytes,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        timeout=timeout,
        check=False,
    )
    if proc.returncode != 0:
        detail = proc.stderr.decode("utf-8", "replace").strip()
        raise RuntimeError(detail or f"ssh-keygen exited {proc.returncode}")
    return proc.stdout


def _default_private_key() -> Path | None:
    for candidate in DEFAULT_PRIVATE_KEYS:
        path = Path(candidate).expanduser()
        if path.is_file():
            return path
    return None


def resolve_private_key(value: str | None, no_pki: bool = False) -> Path | None:
    if no_pki:
        return None
    if value:
        path = Path(value).expanduser()
        if not path.is_file():
            raise SystemExit(f"private key not found: {value}")
        return path
    return _default_private_key()


def public_key_from_private(private_key: Path, timeout: float = 5.0) -> str:
    out = _ssh_keygen(["-y", "-f", str(private_key)], timeout=timeout)
    return normalize_public_key(out.decode("utf-8", "replace"))


def canonical_request(method: str, url: str, headers: dict, body: bytes) -> bytes:
    split = urlsplit(url)
    signed_headers = (
        H_KEY_ID,
        H_SIGNED_AT,
        H_NONCE,
        H_BATCH,
        H_PART,
        H_PARTS,
        H_TOTAL,
        H_CTYPE,
        H_AGENT,
        H_HOST,
        H_HOST_ID,
        H_SESSION,
        H_EVENT,
        H_CWD,
    )
    lines = [
        SSH_NAMESPACE,
        method.upper(),
        split.path or "/",
        split.query or "",
        hashlib.sha256(body).hexdigest(),
    ]
    lines.extend(f"{name.lower()}:{headers.get(name, '')}"
                 for name in signed_headers)
    return ("\n".join(lines) + "\n").encode("utf-8")


def sign_message(private_key: Path, message: bytes,
                 timeout: float = 5.0) -> bytes:
    return _ssh_keygen(
        ["-Y", "sign", "-f", str(private_key), "-n", SSH_NAMESPACE, "-"],
        input_bytes=message,
        timeout=timeout,
    )


def pki_identity(private_key: Path, timeout: float = 5.0) -> dict:
    public_key = public_key_from_private(private_key, timeout)
    return {
        "private_key": private_key,
        "public_key": public_key,
        "key_id": public_key_fingerprint(public_key),
    }


def add_signature_headers(url: str, headers: dict, body: bytes, identity: dict,
                          timeout: float = 5.0) -> None:
    headers[H_KEY_ID] = identity["key_id"]
    headers[H_SIGNED_AT] = datetime.now(timezone.utc).isoformat()
    headers[H_NONCE] = uuid.uuid4().hex
    signature = sign_message(
        identity["private_key"],
        canonical_request("POST", url, headers, body),
        timeout,
    )
    headers[H_SIGNATURE] = base64.b64encode(signature).decode("ascii")


# Where the stable machine id is cached. Shared with feature-tracer's
# notify.py so events from both tools join on the same host_id.
HOST_ID_FILE = "~/.collector/host-id"


def _stable_host_id() -> str:
    """A deterministic 32-hex id for THIS machine.

    Resolution order: the cached file, then a hash of the OS machine id
    (/etc/machine-id on Linux, IOPlatformUUID on macOS), then a random id.
    Whatever wins is cached, so every later call — and every other tool using
    the same cache file — reports the same id. The OS id is hashed, never sent
    raw, per machine-id(5) guidance. (Keep in sync with feature-tracer/notify.py.)
    """
    cache = Path(HOST_ID_FILE).expanduser()
    try:
        cached = cache.read_text().strip()
        if re.fullmatch(r"[0-9a-f]{32}", cached):
            return cached
    except OSError:
        pass
    raw = None
    for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
        try:
            raw = Path(path).read_text().strip() or None
        except OSError:
            raw = None
        if raw:
            break
    if not raw and sys.platform == "darwin":
        try:
            out = subprocess.run(
                ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
                capture_output=True, text=True, timeout=3).stdout
            match = re.search(r'"IOPlatformUUID"\s*=\s*"([^"]+)"', out)
            raw = match.group(1) if match else None
        except (OSError, subprocess.SubprocessError):
            raw = None
    host_id = (hashlib.sha256(f"collector:{raw}".encode()).hexdigest()[:32]
               if raw else uuid.uuid4().hex)
    try:
        cache.parent.mkdir(parents=True, exist_ok=True)
        cache.write_text(host_id + "\n")
    except OSError:
        pass
    return host_id


def host_identity() -> dict:
    """{hostname, host_id} for this machine — deterministic across calls."""
    try:
        hostname = socket.gethostname() or "unknown"
    except OSError:
        hostname = "unknown"
    return {"hostname": hostname, "host_id": _stable_host_id()}


def _header_value(value) -> str:
    """Make a value safe for an HTTP header: ASCII, no CR/LF, bounded."""
    return re.sub(r"[^\x20-\x7e]", "?", str(value))[:200]


def read_stdin():
    """Read all of stdin (the hook payload when used as a Claude Code hook).
    Returns parsed JSON if it parses, else the raw text, else None if empty."""
    raw = sys.stdin.buffer.read()
    if not raw:
        return None
    text = raw.decode("utf-8", "replace")
    try:
        return json.loads(text)
    except (ValueError, json.JSONDecodeError):
        return text


def load_payload(value: str):
    """--payload accepts an inline JSON string or @path to a JSON file."""
    if value.startswith("@"):
        return json.loads(Path(value[1:]).read_text())
    return json.loads(value)


# --- Post-processors -------------------------------------------------------
#
# A post-processor is a best-effort enricher that runs after the envelope is
# built. It reads the envelope (usually the hook payload under `stdin`) and
# returns a JSON-able dict, which is filed under envelope["enrichment"][<name>].
# The raw payload is never mutated, so `stdin` stays a faithful record of what
# the hook handed us. Anything that goes wrong is swallowed — a post-processor
# must never raise or slow a hook to the point of mattering.

# stdin keys that may carry the working directory, in priority order. Mirrors
# webui's _CWD_KEYS so both ends agree on where to find the cwd.
_CWD_KEYS = ("cwd", "working_directory", "workdir")
_MODEL_KEYS = ("model", "model_id", "model_name")
_MODEL_ENV_KEYS = (
    "CODEX_MODEL",
    "CLAUDE_MODEL",
    "ANTHROPIC_MODEL",
    "OPENAI_MODEL",
)
# How much of the commit hash to keep in the short `sha` field. 12 is short
# enough to read yet long enough to stay unambiguous in large repos.
SHORT_SHA_LEN = 12


def _first_text(mapping: dict, keys) -> str | None:
    """First non-blank string value among `keys`, else None."""
    for key in keys:
        value = mapping.get(key)
        if isinstance(value, str) and value.strip():
            return value
    return None


def _model_from_payload(stdin_payload, env: dict[str, str]) -> str | None:
    """Best-effort model identifier available at collection time.

    Current hook payloads may carry a model directly. If they do not, keep a
    bounded fallback to explicit model env vars rather than scraping config
    files later, which would not be a collection-time snapshot.
    """
    if isinstance(stdin_payload, dict):
        model = _first_text(stdin_payload, _MODEL_KEYS)
        if model:
            return model
    return _first_text(env, _MODEL_ENV_KEYS)


def _safe_int(text: str):
    try:
        return int(text)
    except (TypeError, ValueError):
        return None


def _git_run(cwd: str, *gitargs: str, timeout: float) -> str | None:
    """Run `git -C cwd <gitargs>` and return stdout, or None on any failure
    (git missing, cwd not a repo, non-zero exit, timeout, OS error)."""
    try:
        proc = subprocess.run(
            ["git", "-C", cwd, *gitargs],
            stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
            timeout=timeout, check=False,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    if proc.returncode != 0:
        return None
    return proc.stdout.decode("utf-8", "replace")


def _parse_git_status_v2(text: str) -> dict:
    """Parse `git status --porcelain=v2 --branch` output into the fields we
    keep. One git call yields branch, sha, ahead/behind and the dirty/untracked
    flags: header lines start with '# branch.*', tracked changes with 1/2/u and
    untracked files with '?'."""
    branch = full_sha = None
    ahead = behind = None
    dirty = untracked = False
    for line in text.splitlines():
        if line.startswith("# branch.oid "):
            oid = line.removeprefix("# branch.oid ").strip()
            full_sha = None if oid == "(initial)" else oid
        elif line.startswith("# branch.head "):
            head = line.removeprefix("# branch.head ").strip()
            branch = None if head == "(detached)" else head
        elif line.startswith("# branch.ab "):
            for token in line.removeprefix("# branch.ab ").split():
                if token.startswith("+"):
                    ahead = _safe_int(token[1:])
                elif token.startswith("-"):
                    behind = _safe_int(token[1:])
        elif line.startswith("? "):
            untracked = True
        elif line[:2] in ("1 ", "2 ", "u "):
            dirty = True
    return {"branch": branch, "full_sha": full_sha, "ahead": ahead,
            "behind": behind, "dirty": dirty, "untracked": untracked}


def git_metadata(cwd: str, timeout: float = 3.0) -> dict | None:
    """A git snapshot for `cwd`, or None when it is not a git work tree (or git
    is unavailable). Two cheap local git calls: status for the branch/sha/flags,
    and rev-parse for the work-tree root."""
    status = _git_run(cwd, "status", "--porcelain=v2", "--branch",
                      timeout=timeout)
    if status is None:
        return None
    info = _parse_git_status_v2(status)
    root = _git_run(cwd, "rev-parse", "--show-toplevel", timeout=timeout)
    full = info["full_sha"]
    return {
        "branch": info["branch"],
        "sha": full[:SHORT_SHA_LEN] if full else None,
        "full_sha": full,
        "dirty": info["dirty"],
        "untracked": info["untracked"],
        "ahead": info["ahead"],
        "behind": info["behind"],
        "root": root.strip() if root else None,
    }


def enrich_git(envelope: dict, timeout: float):
    """Post-processor: if the hook payload's cwd is a git work tree, return a
    git metadata snapshot to file under envelope.enrichment.git."""
    stdin = envelope.get("stdin")
    if not isinstance(stdin, dict):
        return None
    cwd = _first_text(stdin, _CWD_KEYS)
    if not cwd:
        return None
    return git_metadata(cwd, timeout)


# Registry of available post-processors. Add new enrichers here; each writes
# under its own key in envelope["enrichment"], so they never collide.
POST_PROCESSORS = {
    "git": enrich_git,
}


def run_post_processors(envelope: dict, names, timeout: float) -> None:
    """Run each named post-processor and merge non-empty results into
    envelope["enrichment"][name], in place. Best-effort: a processor that is
    unknown, raises, or returns nothing is simply skipped."""
    enrichment = {}
    for name in names:
        proc = POST_PROCESSORS.get(name)
        if proc is None:
            continue
        try:
            result = proc(envelope, timeout)
        except Exception:  # noqa: BLE001 - best-effort, never break a hook
            result = None
        if result:
            enrichment[name] = result
    if not enrichment:
        return
    existing = envelope.get("enrichment")
    if isinstance(existing, dict):
        existing.update(enrichment)
    else:
        envelope["enrichment"] = enrichment


def normalize_agent(value: str | None) -> str:
    agent = (value or "").strip()
    return agent or "default"


def _send(args):
    # Read stdin first: as a hook this is the event payload, and it must be
    # drained before anything that might exit so the pipe never blocks.
    stdin_payload = read_stdin() if args.stdin else None

    base_dir = Path(args.base or ".").resolve()
    files = find_files(args.paths, args.recursive) if args.paths else []
    data, collisions = build_data(files, base_dir)

    agent = normalize_agent(args.agent)
    host = host_identity()
    env = dict(os.environ)
    model = _model_from_payload(stdin_payload, env)
    envelope = {
        "agent": agent,
        "collected_at": datetime.now(timezone.utc).isoformat(),
        "data": data,
        "host": host,
    }
    if model:
        envelope["model"] = model
    if not args.no_env:
        envelope["env"] = env
    if args.payload:
        envelope["payload"] = load_payload(args.payload)
    if stdin_payload is not None:
        envelope["stdin"] = stdin_payload
    if args.post_process:
        run_post_processors(envelope, args.post_process,
                            args.post_process_timeout)

    blob = json.dumps(envelope).encode()
    batch_id = args.batch_id or str(uuid.uuid4())
    chunks = chunk_bytes(blob, args.max_bytes)
    parts = len(chunks)

    print(f"collector: agent={agent}, {len(files)} file(s), "
          f"envelope {len(blob)} bytes, "
          f"{parts} part(s), batch {batch_id}", file=sys.stderr)
    if collisions:
        print(f"  note: {len(collisions)} merge collision(s) (src won): "
              f"{', '.join(collisions[:5])}"
              f"{' ...' if len(collisions) > 5 else ''}", file=sys.stderr)
    if args.dry_run:
        print(blob.decode())
        return

    secret = (args.secret or "").strip()
    private_key = resolve_private_key(args.private_key, args.no_pki)
    identity = None
    if private_key:
        try:
            identity = pki_identity(private_key, args.auth_timeout)
            print(f"collector: signing with {private_key} "
                  f"({identity['key_id']})", file=sys.stderr)
        except Exception as exc:  # noqa: BLE001 - legacy secret may still work
            if args.private_key:
                raise
            print(f"collector: default PKI key unusable, continuing without "
                  f"signature: {exc}", file=sys.stderr)
    # Extraction headers ride on EVERY part: a split envelope's body cannot be
    # inspected until reassembly, but the sink still gets host/session/event
    # columns for each stored part. Values come from the hook payload itself.
    extract_headers = {
        H_HOST: _header_value(host["hostname"]),
        H_HOST_ID: _header_value(host["host_id"]),
    }
    if isinstance(stdin_payload, dict):
        if stdin_payload.get("session_id"):
            extract_headers[H_SESSION] = _header_value(
                stdin_payload["session_id"])
        if stdin_payload.get("hook_event_name"):
            extract_headers[H_EVENT] = _header_value(
                stdin_payload["hook_event_name"])
        if model:
            extract_headers[H_MODEL] = _header_value(model)
        cwd = _first_text(stdin_payload, _CWD_KEYS)
        if cwd:
            extract_headers[H_CWD] = _header_value(cwd)
    elif model:
        extract_headers[H_MODEL] = _header_value(model)
    for env_key, header in _GROUP_ENV_HEADERS:
        value = os.environ.get(env_key, "").strip()
        if value:
            extract_headers[header] = _header_value(value)
    for idx, chunk in enumerate(chunks, start=1):
        headers = {
            "Content-Type": "application/octet-stream" if parts > 1
                            else "application/json",
            "Content-Length": str(len(chunk)),
            H_BATCH: batch_id,
            H_PART: str(idx),
            H_PARTS: str(parts),
            H_TOTAL: str(len(blob)),
            H_CTYPE: "application/json",
            H_AGENT: agent,
            **extract_headers,
        }
        if secret:
            headers[H_SECRET] = secret
        if identity:
            try:
                add_signature_headers(args.url, headers, chunk, identity,
                                      args.auth_timeout)
            except Exception as exc:  # noqa: BLE001 - legacy secret may still work
                if args.private_key:
                    raise
                print(f"collector: PKI signing failed, continuing without "
                      f"signature: {exc}", file=sys.stderr)
                identity = None
        status, resp = post(args.url, chunk, headers, timeout=args.timeout)
        print(f"  part {idx}/{parts}  {len(chunk)}B  -> {status}  {resp.decode()}",
              file=sys.stderr)


def cmd_send(args):
    # In best-effort mode (e.g. running as a Claude Code hook) we must never
    # raise or exit non-zero — that could block a tool call or surface noise.
    if not args.best_effort:
        _send(args)
        return
    try:
        _send(args)
    except SystemExit:
        pass
    except Exception as exc:  # noqa: BLE001 - deliberately swallow everything
        print(f"collector: best-effort, ignoring error: {exc}", file=sys.stderr)


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--url", required=True, help="sink URL to POST to")
    p.add_argument("paths", nargs="*",
                   help="files and/or folders to read (YAML/JSON/HCL)")
    p.add_argument("-r", "--recursive", action="store_true",
                   help="scan folders recursively (default: top level only)")
    p.add_argument("--base", help="path roots are made relative to "
                                  "(default: current dir)")
    p.add_argument("--payload", help="inline JSON, or @file.json, merged in as "
                                     "envelope.payload")
    p.add_argument("--agent", default="default",
                   help="agent label stored with this collection "
                        "(default: default)")
    p.add_argument("--secret", default=None,
                   help="legacy shared secret sent in the X-Collector-Secret "
                        "header when the sink still allows one")
    p.add_argument("--private-key", default=None,
                   help="OpenSSH private key used to sign requests. The "
                        "matching public key must be registered in the CACP "
                        "web UI. Defaults to ~/.collector/id_ed25519, then "
                        "standard ~/.ssh keys when present")
    p.add_argument("--no-pki", action="store_true",
                   help="do not auto-detect or use an OpenSSH private key")
    p.add_argument("--auth-timeout", type=float, default=5.0,
                   help="timeout for ssh-keygen public-key/signing operations "
                        "(default 5s)")
    p.add_argument("--no-env", action="store_true",
                   help="do not include environment variables")
    p.add_argument("--max-bytes", type=int, default=1_000_000,
                   help="split envelope into parts of at most this many bytes "
                        "(default 1,000,000)")
    p.add_argument("--batch-id", help="override the generated batch id")
    p.add_argument("--stdin", action="store_true",
                   help="read stdin (the hook payload) into envelope.stdin")
    p.add_argument("--best-effort", action="store_true",
                   help="never raise or exit non-zero (safe to use as a hook)")
    p.add_argument("--timeout", type=float, default=10.0,
                   help="per-request POST timeout in seconds (default 10)")
    p.add_argument("--dry-run", action="store_true",
                   help="print the envelope instead of POSTing")
    p.add_argument("--post-process", action="append", default=None,
                   choices=sorted(POST_PROCESSORS), metavar="NAME",
                   help="run an optional enricher after building the envelope "
                        "and add its result under envelope.enrichment.<name>; "
                        "repeatable (available: "
                        f"{', '.join(sorted(POST_PROCESSORS))})")
    p.add_argument("--post-process-timeout", type=float, default=3.0,
                   help="per-subprocess timeout for post-processors "
                        "(default 3s)")
    p.set_defaults(func=cmd_send)
    return p


def main(argv=None):
    args = build_parser().parse_args(argv)
    args.func(args)


if __name__ == "__main__":
    main()
