#!/usr/bin/env python3
"""
matrix — Pratimāna Matrix from the terminal.

One file, standard library only, so it runs wherever Python 3.8+ does.
Installed by https://matrix.pratimana.com/install.sh to ~/.local/bin/matrix;
`matrix update` fetches the newest copy of this file from the site.

    matrix login              approve this terminal from your browser
    matrix programs           the programs you can see
    matrix report submit …    file a report
    matrix queue              an organization's open reports
    matrix mcp                an MCP server for Claude, Cursor and friends
    matrix --help             everything else

Credentials live in ~/.config/matrix/credentials.json (mode 600): the same
short-lived access token and rotating refresh token the web app holds.
"""
import argparse
import hashlib
import json
import os
import socket
import stat
import sys
import tempfile
import textwrap
import time
import urllib.error
import urllib.parse
import urllib.request

__version__ = "0.3.0"

DEFAULT_SITE = "https://matrix.pratimana.com"
DEFAULT_API = DEFAULT_SITE + "/api"
# The API's former address. A credentials file written before the move
# still names it; api_base() rewrites it so an installed copy keeps
# working after the old host is gone.
RETIRED_API_HOSTS = ("api.matrix.pratimana.com",)
CLI_PATH = "/cli/matrix.py"
CHECKSUM_PATH = "/cli/matrix.sha256"
USER_AGENT = "matrix-cli/" + __version__

SEVERITIES = ("critical", "high", "medium", "low")
STATUSES = ("new", "triaging", "needs_more_info", "triaged", "retesting", "resolved",
            "duplicate", "informative", "not_applicable", "spam", "closed")
ROLE_LABELS = {
    "SECURITY_RESEARCHER": "Security Researcher",
    "ORGANIZATION": "Organization",
    "PRATIMANA_MEMBER": "Pratimāna staff",
    "PRATIMANA_ADMIN": "Pratimāna admin",
    "PRATIMANA_SUPERADMIN": "Pratimāna superadmin",
}


# ---------------------------------------------------------------- errors

class CliError(Exception):
    """Something to tell the person, then exit 1."""

    exit_code = 1


class AuthRequired(CliError):
    def __init__(self):
        super().__init__("You are not signed in. Run `matrix login`.")


class ApiError(CliError):
    def __init__(self, status, payload):
        self.status = status
        self.payload = payload
        super().__init__(describe_error(status, payload))


def describe_error(status, payload):
    if isinstance(payload, dict):
        if payload.get("detail"):
            return str(payload["detail"])
        if payload.get("error"):
            return str(payload.get("detail") or payload["error"])
        parts = []
        for field, messages in payload.items():
            if isinstance(messages, (list, tuple)):
                messages = "; ".join(str(m) for m in messages)
            parts.append("%s: %s" % (field, messages))
        if parts:
            return "\n".join(parts)
    if isinstance(payload, list) and payload:
        return "; ".join(str(p) for p in payload)
    if status == 401:
        return "Not signed in, or the session expired. Run `matrix login`."
    if status == 403:
        return "Your account is not allowed to do that."
    if status == 404:
        return "Not found."
    if status == 429:
        return "Too many requests. Wait a little and try again."
    return "The API answered %s." % status


# ---------------------------------------------------------------- config

def config_dir():
    override = os.environ.get("MATRIX_CONFIG_DIR")
    if override:
        return override
    base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
    return os.path.join(base, "matrix")


def credentials_path():
    return os.path.join(config_dir(), "credentials.json")


def load_credentials():
    try:
        with open(credentials_path(), "r", encoding="utf-8") as fh:
            data = json.load(fh)
        return data if isinstance(data, dict) else {}
    except (OSError, ValueError):
        return {}


def save_credentials(data):
    directory = config_dir()
    os.makedirs(directory, exist_ok=True)
    try:
        os.chmod(directory, stat.S_IRWXU)
    except OSError:
        pass
    path = credentials_path()
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR)
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        json.dump(data, fh, indent=2)
        fh.write("\n")
    try:
        os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
    except OSError:
        pass


def forget_credentials():
    try:
        os.remove(credentials_path())
        return True
    except OSError:
        return False


def retire_old_host(creds):
    """A saved API base on the retired host becomes the current one, and
    the file is rewritten so this happens once."""
    saved = creds.get("api") or ""
    host = urllib.parse.urlsplit(saved).hostname or ""
    if host in RETIRED_API_HOSTS:
        creds["api"] = DEFAULT_API
        creds["site"] = DEFAULT_SITE
        try:
            save_credentials(creds)
        except OSError:
            pass
    return creds


def api_base(args, creds):
    retire_old_host(creds)
    base = getattr(args, "api", None) or os.environ.get("MATRIX_API") or creds.get("api") or DEFAULT_API
    return base.rstrip("/")


def site_origin(api, creds):
    site = os.environ.get("MATRIX_SITE") or creds.get("site")
    if site:
        return site.rstrip("/")
    # The API lives under the site: strip its path and the origin is the site.
    parsed = urllib.parse.urlsplit(api)
    if parsed.scheme and parsed.netloc:
        return "%s://%s" % (parsed.scheme, parsed.netloc)
    return DEFAULT_SITE


def report_path(report):
    """A report's page: /<organization>/reports/<uuid> when the API named
    the organization, the flat /report?id=<uuid> otherwise (the page
    rewrites that to the canonical address once it loads)."""
    rid = urllib.parse.quote(str(report.get("id") or ""), safe="-")
    slug = report.get("organization_slug")
    if slug:
        return "/%s/reports/%s" % (urllib.parse.quote(slug, safe="-_"), rid)
    return "/report?id=%s" % rid


# ---------------------------------------------------------------- http

class Client:
    def __init__(self, api, creds):
        self.api = api
        self.creds = creds

    def _call(self, method, path, body=None, auth=True, query=None):
        url = self.api + path
        if query:
            clean = {k: v for k, v in query.items() if v not in (None, "", False)}
            if clean:
                url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean)
        data = None
        headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
        if body is not None:
            data = json.dumps(body).encode("utf-8")
            headers["Content-Type"] = "application/json"
        if auth:
            token = self.creds.get("access")
            if not token:
                raise AuthRequired()
            headers["Authorization"] = "Bearer " + token
        req = urllib.request.Request(url, data=data, method=method, headers=headers)
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                raw = resp.read()
                return resp.status, (json.loads(raw) if raw else None)
        except urllib.error.HTTPError as exc:
            raw = exc.read()
            try:
                payload = json.loads(raw) if raw else None
            except ValueError:
                payload = None
            return exc.code, payload
        except urllib.error.URLError as exc:
            raise CliError("Could not reach %s: %s" % (self.api, exc.reason))

    def request(self, method, path, body=None, auth=True, query=None):
        status, payload = self._call(method, path, body, auth, query)
        if status == 401 and auth and self.creds.get("refresh"):
            if self.refresh():
                status, payload = self._call(method, path, body, auth, query)
        if status == 401 and auth:
            raise AuthRequired()
        if status >= 400:
            raise ApiError(status, payload)
        return payload

    def organization_path(self, suffix=""):
        """The caller's organization routes, /organizations/{id}…, with the
        id from GET /me. Someone outside any organization has none."""
        me = self.get("/me")
        organization = me.get("organization") if isinstance(me, dict) else None
        if not organization or not organization.get("id"):
            raise ApiError(403, {"detail": "You don't belong to an organization."})
        return "/organizations/%s%s" % (organization["id"], suffix)

    def refresh(self):
        status, payload = self._call("POST", "/tokens", {"refresh": self.creds.get("refresh")}, auth=False)
        if status != 200 or not isinstance(payload, dict) or not payload.get("access"):
            return False
        self.creds["access"] = payload["access"]
        self.creds["refresh"] = payload.get("refresh") or self.creds.get("refresh")
        save_credentials(self.creds)
        return True

    def get(self, path, **query):
        return self.request("GET", path, query=query or None)

    def post(self, path, body=None, auth=True):
        return self.request("POST", path, body=body, auth=auth)

    def patch(self, path, body=None):
        return self.request("PATCH", path, body=body)


def rows_of(payload):
    """A DRF page ({count, results}), a {results} envelope, or a bare list."""
    if isinstance(payload, dict):
        if isinstance(payload.get("results"), list):
            return payload["results"]
        return []
    return payload if isinstance(payload, list) else []


# ---------------------------------------------------------------- output

# Flushed line by line: `matrix login` prints a code and then waits, and
# with stdout in a pipe (a test harness, `| tee`, an editor's terminal)
# Python would otherwise hold the code in its buffer until exit — which
# is after the person was supposed to have read it.
def out(text=""):
    sys.stdout.write(text + "\n")
    sys.stdout.flush()


def err(text):
    sys.stderr.write(text + "\n")
    sys.stderr.flush()


def emit_json(data):
    out(json.dumps(data, indent=2, ensure_ascii=False, default=str))


def table(rows, columns):
    """columns: list of (header, key or callable, max width or None)."""
    if not rows:
        out("(nothing)")
        return
    cells = []
    for row in rows:
        line = []
        for header, key, width in columns:
            value = key(row) if callable(key) else row.get(key, "")
            text = "" if value is None else str(value)
            text = text.replace("\n", " ")
            if width and len(text) > width:
                text = text[: width - 1] + "…"
            line.append(text)
        cells.append(line)
    widths = [len(h) for h, _, _ in columns]
    for line in cells:
        for i, text in enumerate(line):
            widths[i] = max(widths[i], len(text))
    out("  ".join(h.ljust(widths[i]) for i, (h, _, _) in enumerate(columns)))
    out("  ".join("-" * widths[i] for i in range(len(columns))))
    for line in cells:
        out("  ".join(text.ljust(widths[i]) for i, text in enumerate(line)).rstrip())


def facts(pairs):
    width = max(len(label) for label, _ in pairs)
    for label, value in pairs:
        if value is None or value == "":
            continue
        out("%s  %s" % (label.ljust(width), value))


def money(cents, currency="USD"):
    if cents is None:
        return "—"
    return "%s %s" % (currency, "{:,.2f}".format(cents / 100.0))


def bounty_range(program):
    lo, hi = program.get("reward_min"), program.get("reward_max")
    if not program.get("offers_bounties") or (lo is None and hi is None):
        return "—"
    if lo == hi or hi is None:
        return "$%s" % lo
    if lo is None:
        return "up to $%s" % hi
    return "$%s–$%s" % (lo, hi)


def when(iso):
    return (iso or "")[:16].replace("T", " ")


def short_id(value):
    return str(value or "")[:8]


def read_text_arg(inline, path, what):
    if inline and path:
        raise CliError("Give %s inline or as a file, not both." % what)
    if path:
        if path == "-":
            return sys.stdin.read()
        try:
            with open(path, "r", encoding="utf-8") as fh:
                return fh.read()
        except OSError as exc:
            raise CliError("Could not read %s: %s" % (path, exc))
    return inline


# ---------------------------------------------------------------- login

def may_open_browser():
    if os.environ.get("MATRIX_NO_BROWSER"):
        return False
    if sys.platform == "darwin" or sys.platform.startswith("win"):
        return True
    return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))


def cmd_login(args, creds):
    api = api_base(args, creds)
    client = Client(api, {})
    started = client.post("/device-codes", {"client_name": socket.gethostname()[:80]}, auth=False)
    code = started["user_code"]
    site = site_origin(api, creds)
    link = "%s/cli-auth?code=%s" % (site, code)
    out("Sign in to Matrix")
    out("")
    out("  Open   " + link)
    out("  Code   " + code)
    out("")
    out("Approve this terminal in the browser. Waiting… (Ctrl-C to stop)")
    if not args.no_browser and may_open_browser():
        try:
            import webbrowser
            webbrowser.open(link, new=2)
        except Exception:
            pass
    interval = max(1, int(started.get("interval") or 5))
    deadline = time.time() + int(started.get("expires_in") or 900)
    while time.time() < deadline:
        time.sleep(interval)
        status, payload = client._call("POST", "/tokens", {"device_code": started["device_code"]}, auth=False)
        if status == 200 and isinstance(payload, dict) and payload.get("access"):
            user = payload.get("user") or {}
            new = {
                "api": api,
                "site": site,
                "access": payload["access"],
                "refresh": payload.get("refresh"),
                "email": user.get("email"),
                "role": user.get("role"),
                "handle": user.get("handle"),
            }
            save_credentials(new)
            out("")
            out("Signed in as %s (%s)." % (user.get("email"), ROLE_LABELS.get(user.get("role"), user.get("role"))))
            return 0
        error = (payload or {}).get("error") if isinstance(payload, dict) else None
        if error == "authorization_pending":
            continue
        if error == "slow_down":
            interval += 5
            continue
        if error == "access_denied":
            raise CliError("The request was denied in the browser. Nothing was signed in.")
        if error == "expired_token":
            raise CliError("The code expired before it was approved. Run `matrix login` again.")
        raise CliError(describe_error(status, payload))
    raise CliError("The code expired before it was approved. Run `matrix login` again.")


def cmd_logout(args, creds):
    if creds.get("refresh"):
        try:
            Client(api_base(args, creds), creds)._call("DELETE", "/sessions/current", {"refresh": creds["refresh"]}, auth=False)
        except CliError:
            pass
    if forget_credentials():
        out("Signed out. The credentials in %s are gone." % credentials_path())
    else:
        out("You were not signed in.")
    return 0


def cmd_whoami(args, creds):
    client = Client(api_base(args, creds), creds)
    me = client.get("/me")
    if args.json:
        emit_json(me)
        return 0
    user = me.get("user") or {}
    facts([
        ("Email", user.get("email")),
        ("Name", user.get("display_name") or user.get("full_name")),
        ("Handle", user.get("handle")),
        ("Account", ROLE_LABELS.get(user.get("role"), user.get("role"))),
        ("Organization", (me.get("organization") or {}).get("name") if isinstance(me.get("organization"), dict) else None),
        ("Staff tier", me.get("staff_tier")),
        ("API", client.api),
    ])
    if me.get("impersonating"):
        out("Viewing as this account from %s." % ((me.get("impersonator") or {}).get("email")))
    return 0


# ---------------------------------------------------------------- programs

def cmd_programs(args, creds):
    client = Client(api_base(args, creds), creds)
    if args.mine:
        payload = client.get("/me/programs")
    else:
        payload = client.request("GET", "/programs", auth=bool(creds.get("access")), query={"limit": 200})
    rows = rows_of(payload)
    if args.json:
        emit_json(rows)
        return 0
    table(rows, [
        ("SLUG", "slug", 28),
        ("NAME", "name", 32),
        ("TYPE", "program_type", 8),
        ("STATUS", "status", 10),
        ("BOUNTY", bounty_range, 16),
        ("ASSETS", lambda p: ", ".join(p.get("assets") or []), 30),
    ])
    return 0


def fetch_program(client, slug):
    return client.request("GET", "/programs/" + urllib.parse.quote(slug), auth=bool(client.creds.get("access")))


def cmd_program(args, creds):
    client = Client(api_base(args, creds), creds)
    program = fetch_program(client, args.slug)
    if args.json:
        emit_json(program)
        return 0
    out(program.get("name", args.slug))
    out("=" * len(program.get("name", args.slug)))
    facts([
        ("Slug", program.get("slug")),
        ("Type", program.get("program_type")),
        ("Status", program.get("status")),
        ("Bounties", bounty_range(program)),
        ("Response", ("%s h" % program["response_hours"]) if program.get("response_hours") else None),
        ("Launched", when(program.get("launched_at"))),
        ("Safe harbor", ("version %s" % program["safe_harbor_version"]) if program.get("safe_harbor_version") else None),
    ])
    if program.get("description"):
        out("")
        out(textwrap.fill(program["description"], 88))
    tiers = program.get("reward_tiers") or []
    if tiers:
        out("")
        out("Rewards")
        table(tiers, [("SEVERITY", "severity", 10), ("MIN", "min_amount", 10), ("MAX", "max_amount", 10), ("RESPONSE H", "response_target_hours", 10)])
    print_scope(program)
    if program.get("policy"):
        out("")
        out("Policy")
        out(textwrap.indent(textwrap.fill(program["policy"], 86), "  "))
    return 0


def print_scope(program):
    assets = program.get("scope_assets") or []
    inside = [a for a in assets if a.get("in_scope")]
    outside = [a for a in assets if not a.get("in_scope")]
    out("")
    out("In scope")
    table(inside, [("KIND", "kind", 12), ("TARGET", "identifier", 48), ("NOTE", "notes", 40)])
    if outside:
        out("")
        out("Out of scope")
        table(outside, [("KIND", "kind", 12), ("TARGET", "identifier", 48), ("NOTE", "notes", 40)])
    rules = program.get("scope_rules") or []
    if rules:
        out("")
        out("Scope rules")
        print_rules(rules)


def print_rules(rules):
    table(rules, [
        ("KIND", "kind", 12),
        ("APPLIES TO", lambda r: ", ".join(a.get("identifier", "") for a in (r.get("assets") or [])) or "every target", 36),
        ("WEAKNESSES", lambda r: ", ".join((v.get("name") if isinstance(v, dict) else str(v)) for v in (r.get("vrt") or r.get("vrt_ids") or [])), 44),
        ("NOTE", "note", 30),
    ])


def cmd_scope(args, creds):
    client = Client(api_base(args, creds), creds)
    program = fetch_program(client, args.slug)
    if args.json:
        emit_json({"scope_assets": program.get("scope_assets"), "scope_rules": program.get("scope_rules")})
        return 0
    out(program.get("name", args.slug))
    print_scope(program)
    return 0


def cmd_scope_rules(args, creds):
    client = Client(api_base(args, creds), creds)
    try:
        rules = rows_of(client.get("/programs/%s/scope-rules" % urllib.parse.quote(args.slug)))
    except ApiError as exc:
        if exc.status not in (403, 404):
            raise
        # Not the program's own team: the rules in force, as the brief shows them.
        rules = fetch_program(client, args.slug).get("scope_rules") or []
    if args.json:
        emit_json(rules)
        return 0
    print_rules(rules)
    return 0


# ---------------------------------------------------------------- reports

REPORT_COLUMNS = [
    ("ID", lambda r: short_id(r.get("id")), 8),
    ("TITLE", "title", 44),
    ("PROGRAM", "program_name", 22),
    ("SEVERITY", "severity", 8),
    ("STATUS", "status", 15),
    ("BOUNTY", lambda r: (r.get("payout") or {}).get("amount") or r.get("bounty_amount") or "", 8),
    ("SUBMITTED", lambda r: when(r.get("submitted_at")), 16),
]


def list_reports(args, creds, default_open=False):
    client = Client(api_base(args, creds), creds)
    query = {
        "program": getattr(args, "program", None),
        "status": getattr(args, "status", None),
        "assigned": getattr(args, "assigned", None),
        "limit": getattr(args, "limit", None) or 100,
    }
    if getattr(args, "open", False) or (default_open and not query["status"]):
        query["open"] = "true"
    else:
        query["bucket"] = "all"
    rows = rows_of(client.get("/reports", **query))
    if args.json:
        emit_json(rows)
        return 0
    table(rows, REPORT_COLUMNS)
    return 0


def cmd_reports(args, creds):
    return list_reports(args, creds)


def cmd_queue(args, creds):
    return list_reports(args, creds, default_open=True)


def resolve_report_id(client, prefix):
    """A full id, or the first eight characters as the tables print them."""
    prefix = prefix.strip()
    if len(prefix) >= 32:
        return prefix
    rows = rows_of(client.get("/reports", bucket="all", limit=200))
    matches = [r for r in rows if str(r.get("id", "")).startswith(prefix)]
    if len(matches) == 1:
        return matches[0]["id"]
    if not matches:
        raise CliError("No report of yours starts with %s." % prefix)
    raise CliError("More than one report starts with %s; give more of the id." % prefix)


def cmd_report_show(args, creds):
    client = Client(api_base(args, creds), creds)
    report = client.get("/reports/" + resolve_report_id(client, args.id))
    if args.json:
        emit_json(report)
        return 0
    out(report.get("title", ""))
    out("=" * len(report.get("title", "")))
    payout = report.get("payout") or {}
    facts([
        ("Id", report.get("id")),
        ("Program", "%s (%s)" % (report.get("program_name"), report.get("program_slug"))),
        ("Researcher", report.get("researcher_handle")),
        ("Asset", report.get("asset")),
        ("Type", report.get("vulnerability_type")),
        ("Weakness", report.get("weakness")),
        ("Severity", report.get("severity")),
        ("CVSS", ("%s  %s" % (report.get("cvss_score"), report.get("cvss_vector"))) if report.get("cvss_vector") else None),
        ("Status", report.get("status")),
        ("Assignee", report.get("assignee_display")),
        ("Bounty", ("$%s (%s)" % (payout.get("amount"), payout.get("status"))) if payout else (("$%s" % report["bounty_amount"]) if report.get("bounty_amount") else None)),
        ("Submitted", when(report.get("submitted_at"))),
        ("Triaged", when(report.get("triaged_at"))),
        ("Resolved", when(report.get("resolved_at"))),
        ("Disclosure", report.get("disclosure_state") if report.get("disclosed") else None),
    ])
    hits = report.get("scope_rule_hits") or []
    if hits:
        out("")
        out("Scope rules that apply")
        for hit in hits:
            out("  - %s" % (hit.get("summary") or hit.get("kind") or hit))
    for label, key in (("Description", "description"), ("Steps to reproduce", "steps_to_reproduce"), ("Impact", "impact"), ("References", "references"), ("Remediation", "remediation")):
        if report.get(key):
            out("")
            out(label)
            out(textwrap.indent(str(report[key]).rstrip(), "  "))
    events = report.get("events") or []
    if events:
        out("")
        out("Timeline")
        for event in events[-12:]:
            who = event.get("actor_display") or ""
            line = "  %s  %-22s %s" % (when(event.get("created_at")), event.get("event_type") or "", who)
            if event.get("note"):
                line += "  — " + str(event["note"]).replace("\n", " ")
            out(line.rstrip())
    return 0


def cmd_report_submit(args, creds):
    client = Client(api_base(args, creds), creds)
    description = read_text_arg(args.description, args.file, "the description")
    if not description or not description.strip():
        raise CliError("A description is required: --description '…' or --file report.md (or --file - for stdin).")
    steps = read_text_arg(args.steps, args.steps_file, "the steps")
    if not steps or not steps.strip():
        raise CliError("Steps to reproduce are required: --steps '…' or --steps-file steps.md.")
    program = fetch_program(client, args.program)
    body = {
        "program": program["id"],
        "title": args.title,
        "asset": args.asset,
        "vulnerability_type": args.type,
        "description": description,
        "accept_safe_harbor": bool(args.accept_safe_harbor),
    }
    if steps:
        body["steps_to_reproduce"] = steps
    for key in ("severity", "cvss_vector", "weakness", "impact", "references", "remediation"):
        value = getattr(args, key)
        if value:
            body[key] = value
    report = client.post("/reports", body)
    if args.json:
        emit_json(report)
        return 0
    out("Submitted %s to %s." % (short_id(report.get("id")), program.get("name")))
    facts([
        ("Id", report.get("id")),
        ("Severity", report.get("severity") or "(from triage)"),
        ("CVSS", report.get("cvss_score")),
        ("Status", report.get("status")),
        ("Open", site_origin(client.api, creds) + report_path(report)),
    ])
    hits = report.get("scope_rule_hits") or []
    for hit in hits:
        out("Note: %s" % (hit.get("summary") or hit.get("kind") or hit))
    return 0


def cmd_report_comment(args, creds):
    client = Client(api_base(args, creds), creds)
    report_id = resolve_report_id(client, args.id)
    text = read_text_arg(args.text, args.file, "the comment")
    if not text or not text.strip():
        raise CliError("Say something: matrix report comment <id> 'text' (or --file note.md).")
    body = {"body": text}
    if args.internal:
        body["visibility"] = "internal"
    comment = client.post("/reports/%s/comments" % report_id, body)
    if args.json:
        emit_json(comment)
        return 0
    out("Posted on %s at %s." % (short_id(report_id), when(comment.get("created_at"))))
    return 0


def cmd_report_comments(args, creds):
    client = Client(api_base(args, creds), creds)
    rows = rows_of(client.get("/reports/%s/comments" % resolve_report_id(client, args.id)))
    if args.json:
        emit_json(rows)
        return 0
    if not rows:
        out("(no comments yet)")
        return 0
    for c in rows:
        out("%s  %s%s" % (when(c.get("created_at")), c.get("author_display") or "", "  [internal]" if c.get("visibility") == "internal" else ""))
        out(textwrap.indent(str(c.get("body") or "").rstrip(), "  "))
        out("")
    return 0


def cmd_triage(args, creds):
    client = Client(api_base(args, creds), creds)
    report_id = resolve_report_id(client, args.id)
    body = {"status": args.status, "disclose": bool(args.disclose)}
    if args.bounty is not None:
        body["bounty_amount"] = args.bounty
    if args.note:
        body["note"] = args.note
    if args.duplicate_of:
        body["duplicate_of"] = args.duplicate_of
    report = client.patch("/reports/%s/triage" % report_id, body)
    if args.json:
        emit_json(report)
        return 0
    amount = (report.get("payout") or {}).get("amount") or report.get("bounty_amount")
    out("%s is now %s%s." % (short_id(report_id), report.get("status"), (", bounty $%s" % amount) if amount else ""))
    return 0


# ---------------------------------------------------------------- invitations, standings, money

def cmd_invitations(args, creds):
    client = Client(api_base(args, creds), creds)
    rows = rows_of(client.get("/me/invitations", status=args.status, limit=100))
    if args.json:
        emit_json(rows)
        return 0
    table(rows, [
        ("ID", lambda r: short_id(r.get("id")), 8),
        ("PROGRAM", "program_name", 30),
        ("SLUG", "program_slug", 24),
        ("STATUS", "status", 10),
        ("EXPIRES", lambda r: when(r.get("expires_at")), 16),
        ("MESSAGE", "message", 40),
    ])
    return 0


def cmd_invitation_respond(args, creds):
    client = Client(api_base(args, creds), creds)
    prefix = args.id.strip()
    invitation_id = prefix
    if len(prefix) < 32:
        rows = rows_of(client.get("/me/invitations", limit=100))
        matches = [r for r in rows if str(r.get("id", "")).startswith(prefix)]
        if len(matches) != 1:
            raise CliError("No single invitation starts with %s." % prefix)
        invitation_id = matches[0]["id"]
    answer = {"accept": "accepted", "decline": "declined"}[args.answer]
    result = client.patch("/me/invitations/%s" % invitation_id, {"status": answer})
    if args.json:
        emit_json(result)
        return 0
    out("Invitation %s: %s." % (short_id(invitation_id), answer))
    return 0


def _trend(row):
    trend = row.get("trend")
    if trend is None:
        return "new" if row.get("previous_rank") is None else "-"
    return ("+%d" % trend) if trend > 0 else str(trend)


def cmd_leaderboard(args, creds):
    client = Client(api_base(args, creds), creds)
    rows = rows_of(client.request("GET", "/leaderboard", auth=False, query={
        "limit": args.limit, "country": args.country,
        "window": args.window or args.season, "category": args.category,
        "program_type": args.program_type,
    }))
    if args.json:
        emit_json(rows)
        return 0
    table(rows, [
        ("#", "rank", 4),
        ("TREND", _trend, 5),
        ("HANDLE", "handle", 24),
        ("CC", "country_code", 2),
        ("POINTS", lambda r: r.get("points") if r.get("points") is not None else r.get("reputation_points"), 8),
        ("SIGNAL", lambda r: ("%.2f" % r["signal_score"]) if r.get("signal_score") is not None else "", 6),
        ("IMPACT", lambda r: ("%.1f" % r["impact"]) if r.get("impact") is not None else "", 6),
        ("C/H/M/L", lambda r: "/".join(str((r.get("severity") or {}).get(k, 0)) for k in ("critical", "high", "medium", "low")), 11),
        ("RESOLVED", "resolved_count", 8),
        ("WRITEUPS", "writeups", 8),
    ])
    return 0


def cmd_earnings(args, creds):
    client = Client(api_base(args, creds), creds)
    if creds.get("role") == "ORGANIZATION":
        return cmd_wallet(args, creds)
    data = client.get("/me/balance")
    if args.json:
        emit_json(data)
        return 0
    facts([
        ("Balance", money(data.get("balance_cents"), data.get("currency", "USD"))),
        ("Minimum withdrawal", money(data.get("min_withdrawal_cents"), data.get("currency", "USD"))),
    ])
    rows = data.get("transactions") or []
    if rows:
        out("")
        table(rows[:25], [
            ("WHEN", lambda r: when(r.get("created_at")), 16),
            ("TYPE", "type", 14),
            ("AMOUNT", lambda r: money(r.get("amount_cents"), data.get("currency", "USD")), 14),
            ("MEMO", "memo", 48),
        ])
    return 0


def cmd_wallet(args, creds):
    client = Client(api_base(args, creds), creds)
    data = client.get(client.organization_path("/wallet"))
    if args.json:
        emit_json(data)
        return 0
    facts([
        ("Organization", (data.get("organization") or {}).get("name")),
        ("Balance", money(data.get("balance_cents"), data.get("currency", "USD"))),
        ("Held for open reports", money(data.get("held_cents"), data.get("currency", "USD"))),
    ])
    rows = data.get("transactions") or []
    if rows:
        out("")
        table(rows[:25], [
            ("WHEN", lambda r: when(r.get("created_at")), 16),
            ("TYPE", "type", 14),
            ("AMOUNT", lambda r: money(r.get("amount_cents"), data.get("currency", "USD")), 14),
            ("MEMO", "memo", 48),
        ])
    return 0


# ---------------------------------------------------------------- housekeeping

def fetch_url(url):
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return resp.read()
    except urllib.error.HTTPError as exc:
        raise CliError("%s answered %s." % (url, exc.code))
    except urllib.error.URLError as exc:
        raise CliError("Could not reach %s: %s" % (url, exc.reason))


def version_in(source):
    for line in source.splitlines():
        if line.startswith("__version__"):
            return line.split("=", 1)[1].strip().strip("\"'")
    return None


def cmd_update(args, creds):
    site = site_origin(api_base(args, creds), creds)
    source = fetch_url(site + CLI_PATH)
    newest = version_in(source.decode("utf-8", "replace"))
    if not newest:
        raise CliError("The download from %s does not look like this program." % site)
    try:
        expected = fetch_url(site + CHECKSUM_PATH).decode("utf-8").split()[0].strip()
    except (CliError, IndexError):
        expected = None
    if expected and hashlib.sha256(source).hexdigest() != expected:
        raise CliError("The download's checksum does not match %s. Not installed." % (site + CHECKSUM_PATH))
    out("Installed %s, available %s." % (__version__, newest))
    if args.check:
        return 0
    if newest == __version__:
        out("Already current.")
        return 0
    target = os.path.realpath(sys.argv[0])
    directory = os.path.dirname(target)
    fd, temp = tempfile.mkstemp(prefix=".matrix-", dir=directory)
    with os.fdopen(fd, "wb") as fh:
        fh.write(source)
    os.chmod(temp, os.stat(target).st_mode | stat.S_IXUSR)
    os.replace(temp, target)
    out("Updated %s to %s." % (target, newest))
    return 0


def cmd_version(args, creds):
    out("matrix %s" % __version__)
    return 0


def cmd_config(args, creds):
    api = api_base(args, creds)
    facts([
        ("Credentials", credentials_path()),
        ("API", api),
        ("Site", site_origin(api, creds)),
        ("Signed in as", creds.get("email")),
        ("Account", ROLE_LABELS.get(creds.get("role"), creds.get("role"))),
    ])
    return 0


# ---------------------------------------------------------------- mcp
#
# `matrix mcp`: a Model Context Protocol server over stdio, so an AI
# assistant (Claude Desktop, Claude Code, Cursor, VS Code) can use Matrix
# as the person signed in here. JSON-RPC 2.0, one message per line, the
# protocol's own words for errors; stdout is the wire, so nothing in this
# section may print to it. Tools are the CLI's commands with structured
# answers; resources are the programs and the account; prompts set an
# assistant up to draft a report or work a triage queue.

MCP_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
MCP_INSTRUCTIONS = (
    "Pratimāna Matrix is a bug bounty platform: organizations run programs "
    "with a scope and reward tiers, security researchers submit reports, "
    "the program's team triages them. This server acts as the person who ran "
    "`matrix login` on this machine, a researcher or an organization member; "
    "the API enforces what they may do. Reading is free: whoami, "
    "list_programs, get_program, get_scope_rules, search_weaknesses, "
    "list_reports, get_report, list_invitations, get_leaderboard, "
    "get_balance. submit_report, comment_on_report, triage_report and "
    "respond_to_invitation change things in that person's name: show exactly "
    "what you are about to send and get their go-ahead first. A report needs "
    "title, asset (a target from the program's scope), vulnerability_type, "
    "description and steps_to_reproduce; prefer a CVSS 3.1 vector to a "
    "severity label; weakness is a CWE id from search_weaknesses; the first "
    "report against a program needs accept_safe_harbor once the person has "
    "read its terms (get_program shows the policy and safe-harbor version)."
)


class McpMethodNotFound(Exception):
    pass


class McpInvalidParams(Exception):
    pass


class McpResourceNotFound(Exception):
    pass


def _prop(kind, description, **extra):
    d = {"type": kind, "description": description}
    d.update(extra)
    return d


def _schema(properties, required=()):
    return {"type": "object", "properties": properties, "required": list(required), "additionalProperties": False}


def _read(title):
    return {"title": title, "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False}


def _write(title):
    return {"title": title, "readOnlyHint": False, "destructiveHint": False, "idempotentHint": False, "openWorldHint": False}


def _mcp_whoami(client, creds, a):
    return client.get("/me")


def _mcp_list_programs(client, creds, a):
    if a.get("mine"):
        rows = rows_of(client.get("/me/programs"))
    else:
        rows = rows_of(client.request("GET", "/programs", auth=bool(creds.get("access")), query={"limit": 200}))
    return {"programs": rows, "count": len(rows)}


def _mcp_get_program(client, creds, a):
    return fetch_program(client, a["slug"])


def _mcp_scope_rules(client, creds, a):
    try:
        rules = rows_of(client.get("/programs/%s/scope-rules" % urllib.parse.quote(a["slug"])))
        every = True
    except ApiError as exc:
        if exc.status not in (401, 403, 404):
            raise
        rules = fetch_program(client, a["slug"]).get("scope_rules") or []
        every = False
    return {"program": a["slug"], "rules": rules, "count": len(rules), "includes_inactive": every}


def _mcp_search_weaknesses(client, creds, a):
    taxonomy = client.request("GET", "/vrt", auth=False)
    words = [w for w in str(a.get("query") or "").lower().replace("-", " ").split() if w]
    by_vrt = {}
    for cwe, vrt_id in (taxonomy.get("weakness_map") or {}).items():
        by_vrt.setdefault(vrt_id, []).append(cwe)
    matches = []
    for category in taxonomy.get("categories") or []:
        for leaf in category.get("children") or []:
            cwes = sorted(set(by_vrt.get(leaf["id"], [])))
            haystack = " ".join([leaf["id"], leaf["name"], category["name"], category["id"]] + cwes).lower().replace("-", " ")
            if words and not all(w in haystack for w in words):
                continue
            matches.append({
                "vrt_id": leaf["id"],
                "name": leaf["name"],
                "category": category["name"],
                "priority": leaf.get("priority"),
                "weakness_ids": cwes,
                "category_weakness_ids": sorted(set(by_vrt.get(category["id"], []))),
            })
    return {
        "query": a.get("query"),
        "matches": matches[:25],
        "count": len(matches),
        "note": "submit_report's weakness takes one of weakness_ids (or category_weakness_ids for the whole category), a CWE id.",
    }


def _mcp_list_reports(client, creds, a):
    query = {
        "program": a.get("program"),
        "status": a.get("status"),
        "assigned": a.get("assigned"),
        "limit": a.get("limit") or 100,
    }
    if a.get("open"):
        query["open"] = "true"
    else:
        query["bucket"] = "all"
    rows = rows_of(client.get("/reports", **query))
    return {"reports": rows, "count": len(rows)}


def _mcp_get_report(client, creds, a):
    report_id = resolve_report_id(client, a["id"])
    report = client.get("/reports/" + report_id)
    try:
        comments = rows_of(client.get("/reports/%s/comments" % report_id))
    except ApiError:
        comments = []
    return {"report": report, "comments": comments}


def _mcp_submit_report(client, creds, a):
    program = fetch_program(client, a["program"])
    body = {
        "program": program["id"],
        "title": a["title"],
        "asset": a["asset"],
        "vulnerability_type": a["vulnerability_type"],
        "description": a["description"],
        "steps_to_reproduce": a["steps_to_reproduce"],
        "accept_safe_harbor": bool(a.get("accept_safe_harbor")),
    }
    for key in ("severity", "cvss_vector", "weakness", "impact", "references", "remediation"):
        if a.get(key):
            body[key] = a[key]
    report = client.post("/reports", body)
    return {"report": report, "url": site_origin(client.api, creds) + report_path(report)}


def _mcp_comment(client, creds, a):
    report_id = resolve_report_id(client, a["id"])
    body = {"body": a["body"]}
    if a.get("internal"):
        body["visibility"] = "internal"
    return {"comment": client.post("/reports/%s/comments" % report_id, body), "report_id": report_id}


def _mcp_triage(client, creds, a):
    report_id = resolve_report_id(client, a["id"])
    body = {"status": a["status"], "disclose": bool(a.get("disclose"))}
    if a.get("bounty_amount") is not None:
        body["bounty_amount"] = a["bounty_amount"]
    if a.get("note"):
        body["note"] = a["note"]
    if a.get("duplicate_of"):
        body["duplicate_of"] = a["duplicate_of"]
    return {"report": client.patch("/reports/%s/triage" % report_id, body)}


def _mcp_list_invitations(client, creds, a):
    status = a.get("status")
    rows = rows_of(client.get("/me/invitations", status=None if status == "all" else (status or "invited"), limit=100))
    return {"invitations": rows, "count": len(rows)}


def _mcp_respond_invitation(client, creds, a):
    prefix = str(a["id"]).strip()
    invitation_id = prefix
    if len(prefix) < 32:
        rows = rows_of(client.get("/me/invitations", limit=100))
        found = [r for r in rows if str(r.get("id", "")).startswith(prefix)]
        if len(found) != 1:
            raise CliError("No single invitation starts with %s." % prefix)
        invitation_id = found[0]["id"]
    return {"invitation": client.patch("/me/invitations/%s" % invitation_id, {"status": a["status"]})}


def _mcp_leaderboard(client, creds, a):
    rows = rows_of(client.request("GET", "/leaderboard", auth=False, query={
        "limit": a.get("limit") or 25, "country": a.get("country"),
        "window": a.get("window") or a.get("season"), "category": a.get("category"),
        "program_type": a.get("program_type"),
    }))
    return {"entries": rows, "count": len(rows)}


def _mcp_balance(client, creds, a):
    if creds.get("role") == "ORGANIZATION":
        return {"wallet": client.get(client.organization_path("/wallet"))}
    try:
        return {"balance": client.get("/me/balance")}
    except ApiError as exc:
        if exc.status != 403:
            raise
        return {"wallet": client.get(client.organization_path("/wallet"))}


MCP_TOOLS = [
    ("whoami", "Who is signed in", "The account this server acts as: email, name, handle, side of the platform (researcher, organization, staff).",
     _schema({}), _read("Who am I"), _mcp_whoami),
    ("list_programs", "List programs", "Programs the signed-in person can see: public ones plus private ones they were invited to. Each row has slug, name, type, status, bounty range and asset kinds.",
     _schema({"mine": _prop("boolean", "Only the signed-in organization's own programs, including paused and draft ones.")}), _read("List programs"), _mcp_list_programs),
    ("get_program", "Get a program", "One program in full: description, policy, safe-harbor version, reward tiers by severity, scope assets in and out, and the scope rules in force. Read this before drafting a report against it.",
     _schema({"slug": _prop("string", "The program's slug, as list_programs shows it.")}, ["slug"]), _read("Get a program"), _mcp_get_program),
    ("get_scope_rules", "Get scope rules", "What a program has said in advance about a kind of finding on a target: out of scope, informative, accepted risk, and so on. The program's own team sees every rule; others see the ones in force.",
     _schema({"slug": _prop("string", "The program's slug.")}, ["slug"]), _read("Get scope rules"), _mcp_scope_rules),
    ("search_weaknesses", "Search weaknesses", "Find the vulnerability taxonomy entry for a finding. Returns VRT entries with their priority and the CWE ids (weakness_ids) that map to them; submit_report's weakness takes one of those ids. Search by words such as 'sql injection', 'idor', 'xss', or a CWE id.",
     _schema({"query": _prop("string", "Words or a CWE id. Empty lists everything.")}), _read("Search weaknesses"), _mcp_search_weaknesses),
    ("list_reports", "List reports", "A researcher's own reports, or every report across an organization's programs. Rows carry id, title, program, severity, status, bounty and submitted_at.",
     _schema({
         "program": _prop("string", "Only this program's slug."),
         "status": _prop("string", "One status.", enum=list(STATUSES)),
         "open": _prop("boolean", "Only reports still moving (new, triaging, needs_more_info, triaged, retesting)."),
         "assigned": _prop("string", "Organization only: 'me' or 'none'.", enum=["me", "none"]),
         "limit": _prop("integer", "At most this many, default 100.", minimum=1, maximum=200),
     }), _read("List reports"), _mcp_list_reports),
    ("get_report", "Get a report", "The whole report — fields, CVSS, the scope rules that applied, its timeline — and the comment thread.",
     _schema({"id": _prop("string", "The report id, or its first eight characters.")}, ["id"]), _read("Get a report"), _mcp_get_report),
    ("submit_report", "Submit a report", "File a vulnerability report against a program as the signed-in researcher. Changes state: show the person the exact arguments and get their go-ahead first. Needs the program's safe-harbor terms accepted (accept_safe_harbor) the first time.",
     _schema({
         "program": _prop("string", "The program's slug."),
         "title": _prop("string", "A precise title, up to 200 characters."),
         "asset": _prop("string", "The target, exactly as it appears in the program's scope (get_program)."),
         "vulnerability_type": _prop("string", "The vulnerability type in a few words, e.g. 'Insecure direct object reference'."),
         "description": _prop("string", "The report body, Markdown."),
         "steps_to_reproduce": _prop("string", "Numbered steps, Markdown."),
         "cvss_vector": _prop("string", "A CVSS 3.1 vector, e.g. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N. The score and severity are computed from it."),
         "severity": _prop("string", "Only when no vector is given; a label that disagrees with the vector is refused.", enum=list(SEVERITIES)),
         "weakness": _prop("string", "A CWE id from search_weaknesses, e.g. CWE-639. Decides which scope rules apply."),
         "impact": _prop("string", "What an attacker gains. Optional."),
         "references": _prop("string", "Links and CVEs. Optional."),
         "remediation": _prop("string", "How to fix it. Optional."),
         "accept_safe_harbor": _prop("boolean", "The person has read the program's terms and accepts them."),
     }, ["program", "title", "asset", "vulnerability_type", "description", "steps_to_reproduce"]), _write("Submit a report"), _mcp_submit_report),
    ("comment_on_report", "Comment on a report", "Post on a report's thread in the signed-in person's name. Changes state: confirm with them first. An organization member may post an internal note the researcher does not see.",
     _schema({
         "id": _prop("string", "The report id, or its first eight characters."),
         "body": _prop("string", "The comment, Markdown."),
         "internal": _prop("boolean", "Organization only: a note for the team, hidden from the researcher."),
     }, ["id", "body"]), _write("Comment on a report"), _mcp_comment),
    ("triage_report", "Triage a report", "Organization only: move a report to a new status and optionally set its bounty, leave a note the researcher sees, mark it a duplicate, or disclose it. The status machine is enforced (new → triaging → triaged → resolved …). Changes state and money: confirm with the person first.",
     _schema({
         "id": _prop("string", "The report id, or its first eight characters."),
         "status": _prop("string", "The new status.", enum=list(STATUSES)),
         "bounty_amount": _prop("integer", "Whole currency units.", minimum=0),
         "note": _prop("string", "Up to 280 characters, shown to the researcher."),
         "disclose": _prop("boolean", "Publish the report on OnGrid."),
         "duplicate_of": _prop("string", "The original report's full id, when status is duplicate."),
     }, ["id", "status"]), _write("Triage a report"), _mcp_triage),
    ("list_invitations", "List invitations", "Private programs that invited the signed-in researcher.",
     _schema({"status": _prop("string", "Default 'invited'.", enum=["invited", "accepted", "declined", "expired", "revoked", "all"])}), _read("List invitations"), _mcp_list_invitations),
    ("respond_to_invitation", "Answer an invitation", "Accept or decline a private program's invitation. Changes state: confirm with the person first.",
     _schema({
         "id": _prop("string", "The invitation id, or its first eight characters."),
         "status": _prop("string", "accepted or declined.", enum=["accepted", "declined"]),
     }, ["id", "response"]), _write("Answer an invitation"), _mcp_respond_invitation),
    ("get_leaderboard", "Get the leaderboard", "The global standings: rank and trend, handle, country, points, signal, impact, severity breakdown, writeups, badges. Public.",
     _schema({
         "limit": _prop("integer", "Default 25, at most 200.", minimum=1, maximum=200),
         "country": _prop("string", "Two-letter country code."),
         "window": _prop("string", "all (default), 90d, month, year, a month like 2026-08, a year like 2026, or a quarter like 2026-Q3."),
         "season": _prop("string", "A quarter, e.g. 2026-Q3; the older spelling of window."),
         "category": _prop("string", "web_api, mobile, cloud_infra or binary_hw: the kind of target the reports were filed against."),
         "program_type": _prop("string", "public or private: points earned on that kind of programme."),
     }), _read("Get the leaderboard"), _mcp_leaderboard),
    ("get_balance", "Get the balance", "A researcher's balance and recent movements, or an organization's wallet: balance, funds held for open reports, movements.",
     _schema({}), _read("Get the balance"), _mcp_balance),
]


def _lines(items):
    return "\n".join("- " + item for item in items) if items else "- (none)"


def _mcp_prompt_draft_report(client, creds, a):
    program = fetch_program(client, a["program"])
    assets = program.get("scope_assets") or []
    inside = ["%s %s%s" % (x.get("kind"), x.get("identifier"), (" — " + x["notes"]) if x.get("notes") else "") for x in assets if x.get("in_scope")]
    outside = ["%s %s" % (x.get("kind"), x.get("identifier")) for x in assets if not x.get("in_scope")]
    rules = ["%s: %s on %s%s" % (r.get("kind"), ", ".join((v.get("name") if isinstance(v, dict) else str(v)) for v in (r.get("vrt") or r.get("vrt_ids") or [])),
                                 ", ".join(x.get("identifier", "") for x in (r.get("assets") or [])) or "every target",
                                 (" — " + r["note"]) if r.get("note") else "") for r in (program.get("scope_rules") or [])]
    tiers = ["%s: %s–%s (response target %s h)" % (t.get("severity"), t.get("min_amount"), t.get("max_amount"), t.get("response_target_hours")) for t in (program.get("reward_tiers") or [])]
    text = "\n".join([
        "You are helping a security researcher file a report on Pratimāna Matrix against the program \"%s\" (slug %s)." % (program.get("name"), program.get("slug")),
        "",
        "In scope:", _lines(inside),
        "Out of scope:", _lines(outside),
        "Scope rules the program has declared:", _lines(rules),
        "Reward tiers:", _lines(tiers),
        "Safe harbor: %s" % ("version %s; the researcher must have read the program's terms, and the submission needs accept_safe_harbor=true" % program["safe_harbor_version"] if program.get("safe_harbor_version") else "no terms to accept"),
        "",
        "What the researcher found, in their words:",
        str(a.get("finding") or "").strip(),
        "",
        "Write the report as submit_report arguments: a precise title; asset copied exactly from the scope above (if the finding is on a target that is out of scope, or a scope rule excludes it, say so instead of drafting); vulnerability_type in a few words; a CVSS 3.1 vector with a sentence of reasoning per metric; weakness as a CWE id found with search_weaknesses; description in Markdown; numbered steps_to_reproduce that a triager can follow; impact; remediation. Show the full arguments, ask the researcher to confirm, and only then call submit_report.",
    ])
    return {
        "description": "Draft a report against %s from a finding, ready for submit_report." % program.get("name"),
        "messages": [{"role": "user", "content": {"type": "text", "text": text}}],
    }


def _mcp_prompt_triage_queue(client, creds, a):
    query = {"open": "true", "limit": 50}
    if a.get("program"):
        query["program"] = a["program"]
    reports = rows_of(client.get("/reports", **query))
    rows = ["%s  %-9s %-16s %s  (%s)" % (short_id(r.get("id")), r.get("severity") or "-", r.get("status"), r.get("title"), r.get("program_name")) for r in reports]
    tiers = []
    if a.get("program"):
        program = fetch_program(client, a["program"])
        tiers = ["%s: %s–%s" % (t.get("severity"), t.get("min_amount"), t.get("max_amount")) for t in (program.get("reward_tiers") or [])]
    text = "\n".join([
        "You are helping a program team on Pratimāna Matrix work their triage queue. The open reports%s:" % ((" on " + a["program"]) if a.get("program") else ""),
        _lines(rows),
        "",
        ("Reward tiers for this program:\n" + _lines(tiers)) if tiers else "Read each program's reward tiers with get_program before proposing a bounty.",
        "",
        "For each report: read it with get_report, check its asset and weakness against the program's scope and scope rules, judge whether the severity claimed matches the CVSS vector and the evidence, and propose the next status (new → triaging; triaging → needs_more_info, triaged, duplicate, informative, not_applicable or spam; triaged → resolved) with a bounty from the tier where one is due and a note to the researcher. Present the proposals as a list. Call triage_report only for decisions the team confirms, one at a time.",
    ])
    return {
        "description": "Work the open reports: propose a status, bounty and note for each.",
        "messages": [{"role": "user", "content": {"type": "text", "text": text}}],
    }


MCP_PROMPTS = [
    ("draft_report", "Draft a report", "Turn a finding into a report against a program, with the program's scope, rules and reward tiers in front of the assistant.",
     [{"name": "program", "description": "The program's slug.", "required": True},
      {"name": "finding", "description": "What was found, in the researcher's own words.", "required": True}], _mcp_prompt_draft_report),
    ("triage_queue", "Triage the queue", "Propose a status, bounty and note for each open report, against the program's scope and tiers.",
     [{"name": "program", "description": "Only this program's slug. Optional.", "required": False}], _mcp_prompt_triage_queue),
]


class McpServer:
    def __init__(self, api_override=None):
        self.api_override = api_override
        self.tools = {t[0]: t for t in MCP_TOOLS}
        self.prompts = {p[0]: p for p in MCP_PROMPTS}
        self.debug = bool(os.environ.get("MATRIX_MCP_DEBUG"))

    # Credentials are re-read on every call, so a `matrix login` run while
    # an assistant already has this server open takes effect at once.
    def client(self):
        creds = retire_old_host(load_credentials())
        api = (self.api_override or os.environ.get("MATRIX_API") or creds.get("api") or DEFAULT_API).rstrip("/")
        return Client(api, creds), creds

    def send(self, obj):
        sys.stdout.buffer.write((json.dumps(obj, ensure_ascii=False, default=str) + "\n").encode("utf-8"))
        sys.stdout.buffer.flush()

    def serve(self):
        for raw in sys.stdin.buffer:
            line = raw.decode("utf-8", "replace").strip()
            if not line:
                continue
            try:
                message = json.loads(line)
            except ValueError:
                self.send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}})
                continue
            if isinstance(message, list):
                replies = [r for r in (self.handle(m) for m in message) if r is not None]
                if replies:
                    self.send(replies)
            else:
                reply = self.handle(message)
                if reply is not None:
                    self.send(reply)
        return 0

    def handle(self, message):
        if not isinstance(message, dict) or "method" not in message:
            return {"jsonrpc": "2.0", "id": message.get("id") if isinstance(message, dict) else None,
                    "error": {"code": -32600, "message": "Invalid Request"}}
        method = message["method"]
        params = message.get("params") or {}
        is_notification = "id" not in message
        if self.debug:
            err("mcp <- %s" % method)
        try:
            result = self.dispatch(method, params if isinstance(params, dict) else {})
        except McpMethodNotFound:
            if is_notification:
                return None
            return {"jsonrpc": "2.0", "id": message["id"], "error": {"code": -32601, "message": "Method not found: %s" % method}}
        except McpInvalidParams as exc:
            if is_notification:
                return None
            return {"jsonrpc": "2.0", "id": message["id"], "error": {"code": -32602, "message": str(exc)}}
        except McpResourceNotFound as exc:
            if is_notification:
                return None
            return {"jsonrpc": "2.0", "id": message["id"], "error": {"code": -32002, "message": str(exc)}}
        except (CliError, Exception) as exc:  # noqa: BLE001 — the wire must stay up
            if is_notification:
                return None
            return {"jsonrpc": "2.0", "id": message["id"], "error": {"code": -32603, "message": str(exc) or exc.__class__.__name__}}
        if is_notification:
            return None
        return {"jsonrpc": "2.0", "id": message["id"], "result": result}

    def dispatch(self, method, params):
        if method == "initialize":
            asked = str(params.get("protocolVersion") or "")
            return {
                "protocolVersion": asked if asked in MCP_VERSIONS else MCP_VERSIONS[0],
                "capabilities": {
                    "tools": {"listChanged": False},
                    "resources": {"subscribe": False, "listChanged": False},
                    "prompts": {"listChanged": False},
                },
                "serverInfo": {"name": "matrix", "title": "Pratimāna Matrix", "version": __version__},
                "instructions": MCP_INSTRUCTIONS,
            }
        if method == "ping":
            return {}
        if method.startswith("notifications/"):
            return None
        if method == "logging/setLevel":
            return {}
        if method == "tools/list":
            return {"tools": [
                {"name": name, "title": title, "description": description, "inputSchema": schema, "annotations": annotations}
                for name, title, description, schema, annotations, _ in MCP_TOOLS
            ]}
        if method == "tools/call":
            return self.call_tool(params)
        if method == "resources/list":
            return {"resources": [
                {"uri": "matrix://me", "name": "me", "title": "Who is signed in", "description": "The account this server acts as.", "mimeType": "application/json"},
                {"uri": "matrix://programs", "name": "programs", "title": "Programs", "description": "Every program the signed-in person can see.", "mimeType": "application/json"},
            ]}
        if method == "resources/templates/list":
            return {"resourceTemplates": [
                {"uriTemplate": "matrix://programs/{slug}", "name": "program", "title": "A program", "description": "One program in full: scope, rules, rewards, policy.", "mimeType": "application/json"},
                {"uriTemplate": "matrix://reports/{id}", "name": "report", "title": "A report", "description": "One report with its comment thread.", "mimeType": "application/json"},
            ]}
        if method == "resources/read":
            return self.read_resource(str(params.get("uri") or ""))
        if method == "prompts/list":
            return {"prompts": [
                {"name": name, "title": title, "description": description, "arguments": arguments}
                for name, title, description, arguments, _ in MCP_PROMPTS
            ]}
        if method == "prompts/get":
            return self.get_prompt(params)
        raise McpMethodNotFound(method)

    def call_tool(self, params):
        name = str(params.get("name") or "")
        tool = self.tools.get(name)
        if tool is None:
            raise McpInvalidParams("Unknown tool: %s" % name)
        arguments = params.get("arguments") or {}
        if not isinstance(arguments, dict):
            raise McpInvalidParams("arguments must be an object")
        _, _, _, schema, _, handler = tool
        missing = [key for key in schema["required"] if arguments.get(key) in (None, "")]
        if missing:
            raise McpInvalidParams("Missing required argument(s): %s" % ", ".join(missing))
        unknown = [key for key in arguments if key not in schema["properties"]]
        if unknown:
            raise McpInvalidParams("Unknown argument(s): %s" % ", ".join(unknown))
        for key, value in arguments.items():
            spec = schema["properties"][key]
            if spec["type"] == "string" and not isinstance(value, str):
                raise McpInvalidParams("%s must be a string" % key)
            if spec["type"] == "boolean" and not isinstance(value, bool):
                raise McpInvalidParams("%s must be true or false" % key)
            if spec["type"] == "integer" and (isinstance(value, bool) or not isinstance(value, int)):
                raise McpInvalidParams("%s must be an integer" % key)
            if "enum" in spec and value not in spec["enum"]:
                raise McpInvalidParams("%s must be one of %s" % (key, ", ".join(spec["enum"])))
        client, creds = self.client()
        try:
            data = handler(client, creds, arguments)
        except AuthRequired:
            return self.tool_error("This machine is not signed in to Matrix. Ask the person to run `matrix login` in a terminal, then try again.")
        except ApiError as exc:
            return self.tool_error("Matrix answered %s: %s" % (exc.status, exc))
        except CliError as exc:
            return self.tool_error(str(exc))
        text = json.dumps(data, indent=2, ensure_ascii=False, default=str)
        return {"content": [{"type": "text", "text": text}], "structuredContent": data, "isError": False}

    @staticmethod
    def tool_error(text):
        return {"content": [{"type": "text", "text": text}], "isError": True}

    def read_resource(self, uri):
        client, creds = self.client()
        try:
            if uri == "matrix://me":
                data = _mcp_whoami(client, creds, {})
            elif uri == "matrix://programs":
                data = _mcp_list_programs(client, creds, {})
            elif uri.startswith("matrix://programs/"):
                data = _mcp_get_program(client, creds, {"slug": uri[len("matrix://programs/"):]})
            elif uri.startswith("matrix://reports/"):
                data = _mcp_get_report(client, creds, {"id": uri[len("matrix://reports/"):]})
            else:
                raise McpResourceNotFound("No such resource: %s" % uri)
        except ApiError as exc:
            if exc.status == 404:
                raise McpResourceNotFound("No such resource: %s" % uri)
            raise
        return {"contents": [{"uri": uri, "mimeType": "application/json", "text": json.dumps(data, indent=2, ensure_ascii=False, default=str)}]}

    def get_prompt(self, params):
        name = str(params.get("name") or "")
        prompt = self.prompts.get(name)
        if prompt is None:
            raise McpInvalidParams("Unknown prompt: %s" % name)
        arguments = params.get("arguments") or {}
        if not isinstance(arguments, dict):
            raise McpInvalidParams("arguments must be an object")
        for spec in prompt[3]:
            if spec["required"] and not str(arguments.get(spec["name"]) or "").strip():
                raise McpInvalidParams("Missing required argument: %s" % spec["name"])
        client, creds = self.client()
        return prompt[4](client, creds, arguments)


def cmd_mcp(args, creds):
    return McpServer(api_override=getattr(args, "api", None)).serve()


# ---------------------------------------------------------------- argparse

class Formatter(argparse.RawDescriptionHelpFormatter):
    pass


def add_json(parser):
    parser.add_argument("--json", action="store_true", help="print the API's answer as JSON")


def build_parser():
    parser = argparse.ArgumentParser(
        prog="matrix",
        formatter_class=Formatter,
        description="Pratimāna Matrix from the terminal.",
        epilog=textwrap.dedent("""\
            examples:
              matrix login
              matrix programs
              matrix program abc-logistics
              matrix report submit --program abc-logistics --title "IDOR on /orders" \\
                  --asset api.abclogistics.com --type "Insecure direct object reference" \\
                  --severity high --file report.md --accept-safe-harbor
              matrix reports --open
              matrix queue                       # an organization's open reports
              matrix triage 1a2b3c4d --status triaged --bounty 500 --note "Confirmed"
              claude mcp add matrix -- matrix mcp   # Matrix as tools in Claude Code
            docs: https://matrix.pratimana.com/docs
        """),
    )
    parser.add_argument("--api", help="API base, default https://matrix.pratimana.com/api (or $MATRIX_API)")
    parser.add_argument("--version", action="version", version="matrix " + __version__)
    sub = parser.add_subparsers(dest="command", metavar="command")

    p = sub.add_parser("login", help="sign this terminal in from your browser")
    p.add_argument("--no-browser", action="store_true", help="print the link instead of opening it")
    p.set_defaults(run=cmd_login, auth=False)

    p = sub.add_parser("logout", help="forget the credentials on this machine")
    p.set_defaults(run=cmd_logout, auth=False)

    p = sub.add_parser("whoami", help="who this terminal is signed in as")
    add_json(p)
    p.set_defaults(run=cmd_whoami)

    p = sub.add_parser("programs", help="programs you can see (public, plus any you were invited to)")
    p.add_argument("--mine", action="store_true", help="only your organization's own programs")
    add_json(p)
    p.set_defaults(run=cmd_programs, auth=False)

    p = sub.add_parser("program", help="one program: rewards, scope, rules, policy")
    p.add_argument("slug")
    add_json(p)
    p.set_defaults(run=cmd_program, auth=False)

    p = sub.add_parser("scope", help="a program's scope and the rules in force")
    p.add_argument("slug")
    add_json(p)
    p.set_defaults(run=cmd_scope, auth=False)

    p = sub.add_parser("scope-rules", help="a program's scope rules")
    p.add_argument("slug")
    add_json(p)
    p.set_defaults(run=cmd_scope_rules, auth=False)

    p = sub.add_parser("reports", help="your reports (researcher) or your programs' reports (organization)")
    p.add_argument("--program", help="slug")
    p.add_argument("--status", choices=STATUSES)
    p.add_argument("--open", action="store_true", help="only reports still open")
    p.add_argument("--assigned", choices=["me", "none"], help="organization: by assignee")
    p.add_argument("--limit", type=int, default=100)
    add_json(p)
    p.set_defaults(run=cmd_reports)

    p = sub.add_parser("queue", help="organization: the open reports waiting on your team")
    p.add_argument("--program", help="slug")
    p.add_argument("--status", choices=STATUSES)
    p.add_argument("--assigned", choices=["me", "none"])
    p.add_argument("--limit", type=int, default=100)
    add_json(p)
    p.set_defaults(run=cmd_queue, open=True)

    report = sub.add_parser("report", help="one report: show, submit, comment, comments")
    rsub = report.add_subparsers(dest="action", metavar="action")

    p = rsub.add_parser("show", help="the whole report")
    p.add_argument("id", help="the id, or its first eight characters")
    add_json(p)
    p.set_defaults(run=cmd_report_show)

    p = rsub.add_parser("submit", help="file a report against a program")
    p.add_argument("--program", required=True, help="program slug")
    p.add_argument("--title", required=True)
    p.add_argument("--asset", required=True, help="the target, as listed in the program's scope")
    p.add_argument("--type", required=True, help="vulnerability type, e.g. 'Stored XSS'")
    p.add_argument("--severity", choices=SEVERITIES, help="leave out when you give a CVSS vector")
    p.add_argument("--cvss", dest="cvss_vector", help="CVSS 3.1 vector, e.g. CVSS:3.1/AV:N/AC:L/…")
    p.add_argument("--weakness", help="VRT id, e.g. server_side_injection.sql_injection")
    p.add_argument("--description", help="the report body, inline")
    p.add_argument("--file", help="the report body from a Markdown file, or - for stdin")
    p.add_argument("--steps", help="steps to reproduce, inline (this or --steps-file is required)")
    p.add_argument("--steps-file", help="steps to reproduce from a file")
    p.add_argument("--impact")
    p.add_argument("--references")
    p.add_argument("--remediation")
    p.add_argument("--accept-safe-harbor", action="store_true", help="accept the program's safe-harbor terms")
    add_json(p)
    p.set_defaults(run=cmd_report_submit)

    p = rsub.add_parser("comment", help="post on a report's thread")
    p.add_argument("id")
    p.add_argument("text", nargs="?")
    p.add_argument("--file", help="the comment from a file")
    p.add_argument("--internal", action="store_true", help="organization: a note your team sees, the researcher does not")
    add_json(p)
    p.set_defaults(run=cmd_report_comment)

    p = rsub.add_parser("comments", help="a report's thread")
    p.add_argument("id")
    add_json(p)
    p.set_defaults(run=cmd_report_comments)

    p = sub.add_parser("triage", help="organization: move a report and set its bounty")
    p.add_argument("id")
    p.add_argument("--status", required=True, choices=STATUSES)
    p.add_argument("--bounty", type=int, help="whole currency units")
    p.add_argument("--note", help="up to 280 characters, shown to the researcher")
    p.add_argument("--disclose", action="store_true")
    p.add_argument("--duplicate-of", help="the original report's id")
    add_json(p)
    p.set_defaults(run=cmd_triage)

    p = sub.add_parser("invitations", help="private programs that invited you")
    p.add_argument("--status", default="invited", choices=["invited", "accepted", "declined", "expired", "revoked", "all"])
    add_json(p)
    p.set_defaults(run=cmd_invitations)

    p = sub.add_parser("invitation", help="answer an invitation")
    p.add_argument("answer", choices=["accept", "decline"])
    p.add_argument("id")
    add_json(p)
    p.set_defaults(run=cmd_invitation_respond)

    p = sub.add_parser("leaderboard", help="the global standings")
    p.add_argument("--limit", type=int, default=25)
    p.add_argument("--country", help="two-letter code")
    p.add_argument("--window", help="all (default), 90d, month, year, 2026-08, 2026 or 2026-Q3")
    p.add_argument("--season", help="a quarter, e.g. 2026-Q3 (the older spelling of --window)")
    p.add_argument("--category", choices=["web_api", "mobile", "cloud_infra", "binary_hw"],
                   help="the kind of target the reports were filed against")
    p.add_argument("--program-type", dest="program_type", choices=["public", "private"],
                   help="points earned on public or on private programmes")
    add_json(p)
    p.set_defaults(run=cmd_leaderboard, auth=False)

    p = sub.add_parser("earnings", help="researcher: balance and payouts (organization: the wallet)")
    add_json(p)
    p.set_defaults(run=cmd_earnings)

    p = sub.add_parser("wallet", help="organization: balance, held funds, movements")
    add_json(p)
    p.set_defaults(run=cmd_wallet)

    p = sub.add_parser("update", help="fetch the newest matrix from the site")
    p.add_argument("--check", action="store_true", help="only say whether one is available")
    p.set_defaults(run=cmd_update, auth=False)

    p = sub.add_parser("version", help="print the version")
    p.set_defaults(run=cmd_version, auth=False)

    p = sub.add_parser("config", help="where things are and who is signed in")
    p.set_defaults(run=cmd_config, auth=False)

    p = sub.add_parser("mcp", help="run an MCP server over stdio for an AI assistant (see /docs#mcp)",
                       description="Speaks the Model Context Protocol on stdin/stdout, as the account signed in with `matrix login`. "
                                   "Add to Claude Code with: claude mcp add matrix -- matrix mcp")
    p.set_defaults(run=cmd_mcp, auth=False)

    return parser, report


def main(argv=None):
    parser, report_parser = build_parser()
    args = parser.parse_args(argv)
    if not getattr(args, "command", None):
        parser.print_help()
        return 0
    if args.command == "report" and not getattr(args, "action", None):
        report_parser.print_help()
        return 0
    if args.command == "invitations" and args.status == "all":
        args.status = None
    creds = load_credentials()
    if getattr(args, "auth", True) and not creds.get("access"):
        raise AuthRequired()
    return args.run(args, creds) or 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        err("")
        sys.exit(130)
    except CliError as exc:
        err(str(exc))
        sys.exit(exc.exit_code)
