#!/usr/bin/env python3
"""jm — JM Tech Power command-line client (like git / gh).

Manage your projects, releases, JMDrive files and chat from the terminal.
Zero dependencies: standard-library Python 3.7+ only.

Quick start:
    jm auth login          # log in with your JM Tech Power account
    jm whoami
    jm proj list --mine
"""
import argparse
import base64
import getpass
import hashlib
import json
import mimetypes
import os
import subprocess
import sys
import time
import zlib
import urllib.error
import urllib.parse
import urllib.request

DEFAULT_HOST = "https://www.jmtechpower.com"
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "jm")
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
VERSION = "1.29.0"

# Full documentation, embedded so `jm readme` / `jm help` works even after the
# one-line install (which ships only this binary). This is the single source of
# truth — seed_jm_app.py regenerates README.md from it for the download zip.
README = r"""# jm — JM Tech Power CLI

Control your JM Tech Power account from the terminal, like `git` / `gh`.
Pure Python 3.7+, **no dependencies**.

## Install

Requires **Python 3** (the installers check for it and tell you how to get it).

**Linux / macOS** — one line (installs to `/usr/local/bin`, cleans up after itself):

    curl -fsSL https://www.jmtechpower.com/jm/install.sh | sudo bash

**Windows** — in PowerShell:

    irm https://www.jmtechpower.com/jm/install.ps1 | iex

Or from the downloaded zip: `sudo ./install.sh` (Linux/macOS) or `.\install.ps1` (Windows).

## Log in

    jm auth login          # opens your browser — click "Authorize" and you're in (like gh)

Works with any sign-in method, including **Google / GitHub** (no password needed).
Alternatives:

    jm auth login --with-token   # paste a token from https://www.jmtechpower.com/settings/tokens
    jm auth login --password     # username + password (accounts that have one)

Config lives in `~/.config/jm/config.json`. Env overrides: `JM_HOST`, `JM_TOKEN`.

## Commands

    jm whoami
    jm account [--nickname N] [--bio B]       # show / update account settings
    jm readme | help                         # show this document
    jm update [--check]                      # update jm to the latest version

    jm search <query>                            # JM Search: projects, articles & people

    # Projects — one namespace for the whole lifecycle (GitHub-style)
    jm proj list [--mine] [--search QUERY]                        # browse projects
    jm proj search <name>                                         # find projects by name
    jm proj view <project> [--json]                              # a one-screen summary
    jm proj install <owner/slug | name | id> [-y] [--dir DIR] [--download-only]
    jm proj installed                                            # projects you've installed
    jm proj update [-y] [--check]                                # upgrade installed projects
    jm proj uninstall <project>                                  # remove + untrack

    jm proj init [--name N --version V --category C --os OS --license L --description D] [--public|--private]
    jm proj push [--version V] [--notes N] [--highlight H]... [--installer FILE] [--public|--private]
    jm proj clone <owner/slug | name | id> [--dir D] [--version V]
    jm proj ls <project> [--version V] [--json]
    jm proj cat <project> <path> [--version V]
    jm proj releases <project> [--json]
    jm proj visibility <project> <public|private>                # change who can see it
    jm proj delete <project>                                     # delete a project you own

    # Social (also usable at top level: jm star <project>, jm issues <project>, …)
    jm proj star <project> | unstar <project>
    jm proj watch <project> [--level releases|all] | unwatch <project>
    jm proj issues <project> [--state open|closed|all] [--json]
    jm proj issue <project> <number> [--json]
    jm proj issue-create <project> --title T [--body BODY|-]
    jm proj issue-comment <project> <number> [--body BODY|-]
    jm proj issue-close <project> <number> | issue-reopen <project> <number>
    jm notifications [--unread] [--json]  (alias: jm inbox)
    jm notifications read [--all]                 # mark notifications read

    jm articles list [--mine] [--search QUERY] [--category C]
    jm article view <id> [--raw]
    jm article create --title T --category C (--content "..." | --file body.html | stdin) [--summary ...] [--tags a,b] [--draft]
    jm article edit <id> [--title ...] [--content ... | --file ...] [--tags ...] [--publish | --unpublish]
    jm article delete <id>

    jm drive ls [folder_id]
    jm drive upload PATH... [--folder ID] [--public]
    jm drive mkdir NAME [--parent ID] [--public]
    jm drive download <id> [-o out]          # a folder downloads as a zip
    jm drive share <id> [--password P] [--limit N]
    jm drive rm <id>

    jm chat groups
    jm chat read (--with USER_ID | --group ID) [--limit N]
    jm chat send "message" (--to USERNAME | --group ID)

    jm token list | create [--name N] | revoke <id>

    jm link [--name N]                           # run as a device for website "Direct Install"
    jm devices | unlink <id>
    jm service install | uninstall | status      # run jm link at login/boot

    jm admin stats | apps | users                # official account only
    jm admin approve <id> | revoke <id> | delete <id>

    jm project init [--name N --version V --category C --os OS --license L --description D]
    jm project push [--version V] [--notes N] [--highlight H]... [--installer FILE]
    jm project clone <owner/name | name | id> [--dir D] [--version V]
    jm project ls <project> [--version V] [--json]
    jm project cat <project> <path> [--version V]
    jm project releases <project> [--json]
    jm project view <project> [--json]

    jm star <project> | unstar <project>          # star / unstar a project
    jm watch <project> [--level releases|all] | unwatch <project>
    jm issues <project> [--state open|closed|all] [--json]
    jm issue <project> <number> [--json]
    jm issue-create <project> --title T [--body BODY|-]
    jm issue-comment <project> <number> [--body BODY|-]
    jm issue-close <project> <number> | issue-reopen <project> <number>
    jm notifications [--unread] [--json]  (alias: jm inbox)
    jm notifications read [--all]                 # mark notifications read

## Projects

`jm proj` is the one command for everything project-related — publishing,
browsing source, installing, and the social layer — GitHub-style, from the
terminal. `jm project` is a long alias for `jm proj`.

A **project** has browsable source you can `push`, `clone`, `ls` and `cat`,
plus releases, stars, watchers and issues.

Work on a project locally, then publish it:

    jm proj init --name "My Tool" --category Tools --os Linux --license MIT
    # ... write your code ...
    jm proj push --highlight "First release"

`init` scaffolds two files in the current directory (no network):

  - **jm.toml** — the project manifest. A tiny flat TOML of `key = "value"`
    lines: `name`, `version` (default 0.1.0), `category`, `supported_os`,
    `license`, `description`, `visibility` (public|private, default public),
    and — after the first push — `id`, `owner`, `slug`. `init` refuses to
    clobber an existing jm.toml (use `--force`). Pass `--private`/`--public`
    to set the initial visibility.
  - **.jmignore** — gitignore-lite patterns for files to keep out of the source
    zip. Each non-empty, non-`#` line is an fnmatch pattern; a trailing `/`
    means a directory. Defaults: `.git/`, `__pycache__/`, `*.pyc`, `.DS_Store`,
    `node_modules/`, `.venv/`, `venv/`, `dist/`, `build/`, `*.zip`.

`push` zips the current directory (respecting .jmignore) and uploads it as the
project's source. The first push (no `id` in jm.toml) creates the project and
writes `id`/`owner`/`slug` back into the manifest; later pushes publish a new
release. Pass `--installer FILE` to also attach a downloadable installer, and
`--version` to set the release version (it updates jm.toml too). `--private` /
`--public` set visibility on the created project (and change it on an existing
one when pushing a new release).

`clone` downloads a project's source zip and unpacks it, writing a jm.toml so
you can `push` again. Accepts a project as an `id`, a `name`, or `owner/slug`:

    jm proj clone jm-tech-power-official/quill
    jm proj clone 42 --dir ./mytool --version 1.2.0

Browse a published project's source without cloning:

    jm proj ls my-tool                 # one file path per line, like git ls-files
    jm proj cat my-tool src/main.py     # print a file to stdout
    jm proj releases my-tool            # versions, sizes, downloads, source?
    jm proj view my-tool                # a one-screen summary

All read commands (`ls`, `cat`, `releases`, `view`) also take `--json`.
Every project argument resolves as an `id`, a `name`, or `owner/slug`
(`owner/slug` is looked up via the projects API).

Install a project's latest release for your OS, or change its visibility:

    jm proj install jm-tech-power-official/quill      # by owner/slug
    jm proj install 30 --download-only                # just download project #30
    jm proj visibility my-tool private                # make it private
    jm proj visibility my-tool public                 # …or public again

## Social

Star projects, watch them for updates, file issues and read your inbox —
GitHub-style, from the terminal. Everything lives under `jm proj` (and the
same verbs also work at the top level: `jm star`, `jm issues`, …). Every
command takes a project as an `id`, a `name`, or `owner/slug`.

    jm proj star my-tool                 # star a project (prints the new star count)
    jm proj unstar my-tool               # remove your star

    jm proj watch my-tool                # get notified about new releases
    jm proj watch my-tool --level all    # …about releases, issues and comments
    jm proj unwatch my-tool              # stop watching

Issues:

    jm proj issues my-tool                        # open issues (a table)
    jm proj issues my-tool --state all --json     # every issue, as JSON
    jm proj issue my-tool 7                        # view issue #7 with its comments
    jm proj issue-create my-tool --title "Bug: crash on start" --body "Steps: …"
    echo "long body" | jm proj issue-create my-tool --title "…" --body -   # body from stdin
    jm proj issue-comment my-tool 7 --body "I can repro this."
    jm proj issue-comment my-tool 7 --body -       # comment body from stdin
    jm proj issue-close my-tool 7
    jm proj issue-reopen my-tool 7

`--body -` (or piping on stdin) reads the body from standard input, so you can
compose long text in a file:  `jm proj issue-create my-tool --title Foo --body - < body.md`.

Notifications are your event bus — agents can poll them as clean, stable JSON:

    jm notifications                 # recent notifications (● = unread)
    jm inbox                         # alias for `jm notifications`
    jm notifications --unread        # only the unread ones
    jm notifications --unread --json # machine-readable; stable for scripting/agents
    jm notifications read            # mark all as read
    jm notifications read --all      # (same) mark everything read

## Examples

    jm proj init --name "My Tool" --category Utilities --os "Windows,Linux" --license MIT
    jm proj push --highlight "First release"

    echo "<p>Hello world</p>" | jm article create --title "Hi" --category News --draft

    jm drive upload report.pdf --public          # prints a https://.../d/<token> link

    jm proj install "Quickfire"                  # download the project and run its installer
    jm proj install 12 --download-only           # just download project #12

## Installing projects

`jm proj install <owner/slug | name | id>` downloads a project's latest release
and installs it for your OS: .deb/.rpm via the package manager, .AppImage into
~/.local/bin, .pkg/.dmg on macOS, .exe/.msi on Windows; archives (.zip/.tar.gz)
are extracted. It asks before running an installer (skip with `-y`);
`--download-only` just saves the file.

If the developer marked an installer inside the package (or you pass `--run`),
jm runs it after extracting:

    jm proj install MyApp --run setup.exe       # run setup.exe from inside the zip

Installed projects are remembered on your account (synced with the website, where
the same projects show "✓ Installed" / update badges). Manage them:

    jm proj update            # install newer versions of everything you installed
    jm proj update --check    # just list what has updates
    jm proj installed         # show what's tracked
    jm proj uninstall <project>   # remove it from disk (apt/dnf remove, delete files + install path) + untrack
    jm proj uninstall <project> --keep-files   # just untrack, leave files

Developers can declare an **uninstaller**; `jm proj uninstall` runs it (then
also removes the package + install path). An install location can be set two ways:
  - the developer declares it on the project (web form, or on push)
  - the installer/user reports the chosen path at install time:
        jm proj install <project> --install-path "/opt/myapp"
    or POST it to the API: POST /api/v1/installed {app_id, version, path}
`jm proj uninstall` then deletes that path too (with a confirmation + a safety guard).

## Deprecated: `jm apps`

`jm apps ...` still works as a thin alias for the matching `jm proj ...`
commands, but it is deprecated and prints a one-line notice. Use `jm proj`.

## Updating

    jm update            # download + replace the installed binary in place (jm itself)
    jm update --check    # just report whether a newer version exists

jm also checks once a day and prints a one-line notice when an update is
available (disable with the `JM_NO_UPDATE_CHECK` env var). If `jm` lives in a
system folder, run `sudo jm update` (Linux/macOS).
"""


# --------------------------------------------------------------------------- #
# config
# --------------------------------------------------------------------------- #
def load_config():
    try:
        with open(CONFIG_PATH) as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return {}


def save_config(cfg):
    os.makedirs(CONFIG_DIR, exist_ok=True)
    with open(CONFIG_PATH, "w") as fh:
        json.dump(cfg, fh, indent=2)
    try:
        os.chmod(CONFIG_PATH, 0o600)
    except OSError:
        pass


def get_host():
    return os.environ.get("JM_HOST") or load_config().get("host") or DEFAULT_HOST


def get_token():
    return os.environ.get("JM_TOKEN") or load_config().get("token")


def die(msg, code=1):
    sys.stderr.write("jm: %s\n" % msg)
    sys.exit(code)


def need_token():
    tok = get_token()
    if not tok:
        die("not logged in. Run: jm auth login")
    return tok


# --------------------------------------------------------------------------- #
# http
# --------------------------------------------------------------------------- #
def _encode_multipart(fields, files):
    boundary = "----jmcli" + os.urandom(16).hex()
    body = bytearray()

    def add(line=b""):
        body.extend(line)
        body.extend(b"\r\n")

    for name, value in (fields or {}).items():
        if value is None:
            continue
        add(("--%s" % boundary).encode())
        add(('Content-Disposition: form-data; name="%s"' % name).encode())
        add()
        add(str(value).encode())

    for name, path in files:
        fname = os.path.basename(path)
        ctype = mimetypes.guess_type(fname)[0] or "application/octet-stream"
        with open(path, "rb") as fh:
            content = fh.read()
        add(("--%s" % boundary).encode())
        add(('Content-Disposition: form-data; name="%s"; filename="%s"' % (name, fname)).encode())
        add(("Content-Type: %s" % ctype).encode())
        add()
        body.extend(content)
        body.extend(b"\r\n")

    add(("--%s--" % boundary).encode())
    return "multipart/form-data; boundary=%s" % boundary, bytes(body)


def request(method, path, auth=True, json_body=None, params=None, files=None, fields=None):
    url = get_host().rstrip("/") + path
    if params:
        clean = {k: v for k, v in params.items() if v is not None}
        if clean:
            url += "?" + urllib.parse.urlencode(clean)
    headers = {"Accept": "application/json", "User-Agent": "jm-cli/%s" % VERSION}
    data = None
    if auth:
        headers["Authorization"] = "Bearer " + need_token()
    if files is not None:
        ctype, data = _encode_multipart(fields or {}, files)
        headers["Content-Type"] = ctype
    elif json_body is not None:
        data = json.dumps(json_body).encode()
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        resp = urllib.request.urlopen(req)
    except urllib.error.HTTPError as exc:
        raw = exc.read()
        try:
            err = json.loads(raw)
            msg = err.get("message") or err.get("error") or ("HTTP %d" % exc.code)
            if err.get("fields"):
                msg += " (%s)" % ", ".join(err["fields"])
        except ValueError:
            msg = "HTTP %d" % exc.code
        die(msg)
    except urllib.error.URLError as exc:
        die("cannot reach %s (%s)" % (get_host(), exc.reason))
    return resp


def api(method, path, **kw):
    resp = request(method, path, **kw)
    raw = resp.read()
    if not raw:
        return {}
    return json.loads(raw)


def download(path, out=None, params=None):
    """GET a file endpoint. Writes to disk, or prints a download_url if the server returns one."""
    resp = request("GET", path, params=params)
    ctype = resp.headers.get("Content-Type", "")
    if "application/json" in ctype:
        data = json.loads(resp.read() or b"{}")
        if data.get("download_url"):
            print(data["download_url"])
            return
        die(data.get("message") or data.get("error") or "no file")
    name = out
    if not name:
        disp = resp.headers.get("Content-Disposition", "")
        if "filename=" in disp:
            name = disp.split("filename=", 1)[1].strip().strip('"')
        name = name or "download.bin"
    with open(name, "wb") as fh:
        while True:
            chunk = resp.read(65536)
            if not chunk:
                break
            fh.write(chunk)
    print("saved %s" % name)


# --------------------------------------------------------------------------- #
# output helpers
# --------------------------------------------------------------------------- #
def table(rows, headers):
    if not rows:
        print("(none)")
        return
    cols = list(zip(*([headers] + [[str(c) for c in r] for r in rows])))
    widths = [max(len(c) for c in col) for col in cols]
    line = "  ".join(h.ljust(w) for h, w in zip(headers, widths))
    print(line)
    print("  ".join("-" * w for w in widths))
    for r in rows:
        print("  ".join(str(c).ljust(w) for c, w in zip(r, widths)))


# --------------------------------------------------------------------------- #
# commands: auth
# --------------------------------------------------------------------------- #
def _login_browser(args, cfg):
    """Open the browser, wait for the user to click Authorize, receive the token (device flow)."""
    import socket
    import time
    import webbrowser

    name = args.name or (socket.gethostname() or "jm cli")
    res = api("POST", "/api/v1/cli/auth/start", auth=False, json_body={"device_name": name})
    device_code = res["device_code"]
    url = res["authorize_url"]
    interval = res.get("interval", 3)
    deadline = time.time() + res.get("expires_in", 600)

    print("Opening your browser to authorize jm…")
    print("  %s" % url)
    opened = False
    try:
        opened = webbrowser.open(url)
    except Exception:
        opened = False
    if not opened:
        print("(couldn't open a browser automatically — open the URL above)")
    print("Waiting for you to click Authorize…  (Ctrl-C to cancel)")

    while time.time() < deadline:
        time.sleep(interval)
        poll = api("POST", "/api/v1/cli/auth/poll", auth=False, json_body={"device_code": device_code})
        status = poll.get("status")
        if status == "approved":
            cfg["token"] = poll["token"]
            save_config(cfg)
            print("Authorized! Logged in as %s on %s" % (poll["user"]["username"], get_host()))
            return
        if status == "denied":
            die("authorization denied in the browser")
        if status in ("expired", "invalid"):
            die("authorization %s — run `jm auth login` again" % status)
    die("timed out waiting for authorization")


def cmd_auth_login(args):
    cfg = load_config()
    if args.host:
        cfg["host"] = args.host
        save_config(cfg)

    # Paste an existing token (from /settings/tokens).
    if args.with_token:
        token = getpass.getpass("Paste your token: ").strip()
        if not token:
            die("no token entered")
        cfg["token"] = token
        save_config(cfg)
        me = api("GET", "/api/v1/user")
        print("Logged in as %s on %s" % (me["username"], get_host()))
        return

    # Username + password (for accounts that have a password).
    if args.password or args.username:
        username = args.username or input("Username or email: ").strip()
        password = getpass.getpass("Password: ")
        res = api("POST", "/api/v1/auth/login", auth=False,
                  json_body={"username": username, "password": password, "name": args.name or "jm cli"})
        cfg["token"] = res["token"]
        save_config(cfg)
        print("Logged in as %s on %s" % (res["user"]["username"], get_host()))
        return

    # Default: browser "Authorize" flow — works with Google/GitHub or any sign-in.
    _login_browser(args, cfg)


def cmd_auth_logout(args):
    cfg = load_config()
    cfg.pop("token", None)
    save_config(cfg)
    print("Logged out (local token cleared).")


def cmd_readme(args):
    print(README)


def _version_tuple(v):
    out = []
    for part in str(v or "0").split("."):
        digits = "".join(ch for ch in part if ch.isdigit())
        out.append(int(digits) if digits else 0)
    return tuple(out)


def _fetch_latest(timeout=10):
    """Return the server's version info dict, or None on any failure."""
    try:
        url = get_host().rstrip("/") + "/jm/version"
        req = urllib.request.Request(url, headers={"User-Agent": "jm-cli/%s" % VERSION})
        return json.loads(urllib.request.urlopen(req, timeout=timeout).read())
    except Exception:
        return None


def cmd_update(args):
    info = _fetch_latest()
    if not info or not info.get("version"):
        die("could not check for updates (is %s reachable?)" % get_host())
    latest = info["version"]
    if _version_tuple(latest) <= _version_tuple(VERSION):
        print("jm is already up to date (v%s)." % VERSION)
        return
    if args.check:
        print("Update available: v%s (you have v%s). Run:  jm update" % (latest, VERSION))
        return

    print("Updating jm v%s -> v%s ..." % (VERSION, latest))
    import io
    import stat
    import tempfile
    import zipfile

    try:
        req = urllib.request.Request(info["download_url"], headers={"User-Agent": "jm-cli/%s" % VERSION})
        blob = urllib.request.urlopen(req, timeout=60).read()
        new_src = zipfile.ZipFile(io.BytesIO(blob)).read("jm")
    except Exception as exc:
        die("download failed: %s" % exc)

    target = os.path.realpath(sys.argv[0])
    folder = os.path.dirname(target) or "."
    tmp = None
    try:
        fd, tmp = tempfile.mkstemp(dir=folder, prefix=".jm-new-")
        with os.fdopen(fd, "wb") as fh:
            fh.write(new_src)
        os.chmod(tmp, os.stat(target).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
        os.replace(tmp, target)
    except PermissionError:
        if tmp and os.path.exists(tmp):
            try:
                os.remove(tmp)
            except OSError:
                pass
        msg = "no permission to update %s." % target
        if os.name != "nt":
            msg += "\nRe-run with elevated rights:  sudo jm update"
        else:
            msg += "\nRe-run the installer:  irm %s | iex" % info.get("install_ps1", "")
        die(msg)
    except Exception as exc:
        if tmp and os.path.exists(tmp):
            try:
                os.remove(tmp)
            except OSError:
                pass
        die("update failed: %s" % exc)
    print("Updated to v%s. ✔" % latest)


def _maybe_notify_update(command):
    """Best-effort once-a-day 'update available' nudge. Never breaks a command."""
    if command in ("update", "upgrade", None):
        return
    if os.environ.get("JM_NO_UPDATE_CHECK") or not sys.stderr.isatty():
        return
    try:
        import time
        cfg = load_config()
        now = int(time.time())
        if now - int(cfg.get("last_update_check", 0)) < 86400:
            return
        cfg["last_update_check"] = now
        save_config(cfg)
        info = _fetch_latest(timeout=3)
        if info and info.get("version") and _version_tuple(info["version"]) > _version_tuple(VERSION):
            sys.stderr.write("\njm v%s is available (you have v%s). Update:  jm update\n"
                             % (info["version"], VERSION))
    except Exception:
        pass


def cmd_admin(args):
    sub = getattr(args, "adminaction", None)
    if sub == "stats":
        d = api("GET", "/api/v1/admin/stats")
        print("users %(users)s | apps %(apps)s (pending %(pending)s, approved %(approved)s) | articles %(articles)s" % d)
    elif sub == "apps":
        rows = [[a["id"], a["name"], a["version"], "approved" if a["approved"] else "PENDING", a["developer"]]
                for a in api("GET", "/api/v1/admin/apps")["apps"]]
        table(rows, ["ID", "NAME", "VERSION", "STATUS", "DEVELOPER"])
    elif sub == "users":
        rows = [[u["id"], u["username"], u["email"], u["apps"]] for u in api("GET", "/api/v1/admin/users")["users"]]
        table(rows, ["ID", "USERNAME", "EMAIL", "APPS"])
    elif sub == "approve":
        api("POST", "/api/v1/admin/apps/%s/approve" % args.id)
        print("Approved app #%s" % args.id)
    elif sub == "revoke":
        api("POST", "/api/v1/admin/apps/%s/revoke" % args.id)
        print("Revoked app #%s (now pending)" % args.id)
    elif sub == "delete":
        if not args.yes and input("Delete app #%s? [y/N] " % args.id).strip().lower() != "y":
            die("aborted")
        api("DELETE", "/api/v1/admin/apps/%s" % args.id)
        print("Deleted app #%s" % args.id)
    else:
        die("usage: jm admin {stats | apps | users | approve <id> | revoke <id> | delete <id>}")


def cmd_account(args):
    if args.nickname is not None or args.bio is not None:
        body = {}
        if args.nickname is not None:
            body["nickname"] = args.nickname
        if args.bio is not None:
            body["bio"] = args.bio
        r = api("PATCH", "/api/v1/user", json_body=body)
        print("Updated. Display name: %s" % r.get("display_name"))
        return
    me = api("GET", "/api/v1/user")
    print("%s <%s>" % (me.get("display_name") or me["username"], me["email"]))
    if me.get("bio"):
        print(me["bio"])
    if me.get("admin"):
        print("(JM Tech Power official admin)")


def cmd_whoami(args):
    me = api("GET", "/api/v1/user")
    st = me.get("storage", {})
    print("%s <%s>  (id %s)" % (me.get("display_name") or me["username"], me["email"], me["id"]))
    print("host: %s" % get_host())
    if st:
        print("storage: %.2f / %d MB (%.1f%%)" % (st["used_mb"], st["total_mb"], st["usage_percentage"]))


# --------------------------------------------------------------------------- #
# commands: apps & releases
# --------------------------------------------------------------------------- #
def cmd_apps(args):
    """Deprecated alias for `jm proj ...`. Dispatches to the same handlers."""
    sys.stderr.write("jm: 'jm apps' is deprecated — use 'jm proj'\n")
    action = getattr(args, "action", "list")
    if action == "install":
        if not getattr(args, "target", None):
            die("usage: jm proj install <owner/slug | name | id>")
        return cmd_project_install(args)
    if action == "search":
        return cmd_apps_search(args)
    if action == "update":
        return cmd_apps_update(args)
    if action == "installed":
        return cmd_apps_installed(args)
    if action == "uninstall":
        return cmd_apps_uninstall(args)
    return cmd_apps_list(args)


def cmd_apps_search(args):
    if not getattr(args, "target", None) and not getattr(args, "search", None):
        die("usage: jm proj search <name>")
    if getattr(args, "target", None):
        args.search = args.target
    return cmd_apps_list(args)


def cmd_apps_list(args):
    res = api("GET", "/api/v1/apps", params={"mine": 1 if args.mine else None, "search": args.search})
    rows = [[a["id"], a["name"], "v" + a["version"], a["category"],
             "yes" if a["is_approved"] else "pending",
             "ok" if a["has_file"] or a["download_url"] else "no-file"]
            for a in res["apps"]]
    table(rows, ["ID", "NAME", "VERSION", "CATEGORY", "APPROVED", "DOWNLOAD"])


def _confirm(prompt):
    try:
        return input("%s [y/N] " % prompt).strip().lower() in ("y", "yes")
    except EOFError:
        return False


def _safe_name(name):
    out = "".join(ch if (ch.isalnum() or ch in "-_.") else "-" for ch in (name or "app")).strip("-")
    return out or "app"


def _resolve_app(query):
    """Find an app by id or name; returns its full detail (with releases)."""
    if str(query).isdigit():
        return api("GET", "/api/v1/apps/%s" % query)["app"]
    matches = api("GET", "/api/v1/apps", params={"search": query})["apps"]
    exact = [a for a in matches if a["name"].lower() == str(query).lower()]
    cands = exact or matches
    if not cands:
        die("no app matching %r  (try: jm apps list --search %s)" % (query, query))
    if len(cands) > 1:
        sys.stderr.write("Multiple apps match — install by id:\n")
        for a in cands:
            sys.stderr.write("  %-4s %s (v%s)\n" % (a["id"], a["name"], a["version"]))
        sys.exit(1)
    return api("GET", "/api/v1/apps/%s" % cands[0]["id"])["app"]


def _download_to_dir(api_path, dest_dir):
    """Download a file endpoint into dest_dir. Returns (saved_path, external_url)."""
    resp = request("GET", api_path)
    if "application/json" in resp.headers.get("Content-Type", ""):
        data = json.loads(resp.read() or b"{}")
        if data.get("download_url"):
            return None, data["download_url"]
        die(data.get("message") or data.get("error") or "no file")
    disp = resp.headers.get("Content-Disposition", "")
    fname = "download.bin"
    if "filename=" in disp:
        fname = disp.split("filename=", 1)[1].strip().strip('"')
    os.makedirs(dest_dir, exist_ok=True)
    out = os.path.join(dest_dir, os.path.basename(fname))
    with open(out, "wb") as fh:
        while True:
            chunk = resp.read(65536)
            if not chunk:
                break
            fh.write(chunk)
    return out, None


def _extract_archive(path):
    """Extract an archive next to itself. Returns the target dir, or None."""
    import tarfile
    import zipfile
    target = os.path.splitext(path)[0]
    low = path.lower()
    if low.endswith(".zip"):
        with zipfile.ZipFile(path) as z:
            z.extractall(target)
    elif low.endswith((".tar", ".tar.gz", ".tgz", ".gz")):
        try:
            with tarfile.open(path) as t:
                t.extractall(target)
        except Exception:
            print("Saved %s — couldn't extract automatically." % path)
            return None
    else:
        print("Saved %s — extract it with your archive tool (e.g. 7z, unrar)." % path)
        return None
    print("Extracted to %s" % target)
    return target


def _pkg_name(kind, path):
    import subprocess
    try:
        if kind == "deb":
            out = subprocess.check_output(["dpkg-deb", "-f", path, "Package"], stderr=subprocess.DEVNULL)
        else:
            out = subprocess.check_output(["rpm", "-qp", "--queryformat", "%{NAME}", path], stderr=subprocess.DEVNULL)
        return out.decode().strip() or None
    except Exception:
        return None


def _install_file(path, app_obj, assume_yes):
    """Install the downloaded file. Returns a record describing how, for later uninstall."""
    import platform
    import shutil
    import stat
    import subprocess
    osname = platform.system()
    ext = os.path.splitext(path)[1].lower().lstrip(".")

    def run(cmd, desc):
        print("Installer command:  %s" % desc)
        if not assume_yes and not _confirm("Run it?"):
            print("Skipped. The file is at: %s" % path)
            return False
        try:
            subprocess.call(cmd)
            return True
        except Exception as exc:
            die("install command failed: %s" % exc)

    if osname == "Linux":
        if ext == "deb":
            pkg = _pkg_name("deb", path)
            if run(["sudo", "apt-get", "install", "-y", path], "sudo apt-get install -y %s" % path) and pkg:
                return {"method": "apt", "package": pkg}
        elif ext == "rpm":
            pkg = _pkg_name("rpm", path)
            if run(["sudo", "dnf", "install", "-y", path], "sudo dnf install -y %s" % path) and pkg:
                return {"method": "dnf", "package": pkg}
        elif ext == "appimage":
            dest = os.path.join(os.path.expanduser("~/.local/bin"), _safe_name(app_obj["name"]))
            os.makedirs(os.path.dirname(dest), exist_ok=True)
            shutil.move(path, dest)
            os.chmod(dest, 0o755)
            print("Installed -> %s   (run:  %s)" % (dest, os.path.basename(dest)))
            return {"method": "file", "path": dest}
        elif ext == "sh":
            os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
            run(["bash", path], "bash %s" % path)
        elif ext in ("zip", "tar", "gz", "tgz", "7z", "rar"):
            t = _extract_archive(path)
            if t:
                return {"method": "archive", "path": t}
        else:
            print("Saved %s — not a Linux installer; install it manually." % path)
            return {"method": "file", "path": path}
    elif osname == "Darwin":
        if ext == "pkg":
            run(["sudo", "installer", "-pkg", path, "-target", "/"], "sudo installer -pkg %s -target /" % path)
        elif ext == "dmg":
            run(["open", path], "open %s" % path)
        elif ext in ("zip", "tar", "gz", "tgz"):
            t = _extract_archive(path)
            if t:
                return {"method": "archive", "path": t}
        else:
            print("Saved %s — open it to install." % path)
            return {"method": "file", "path": path}
    elif osname == "Windows":
        if ext == "msi":
            if run(["msiexec", "/i", path], "msiexec /i %s" % path):
                return {"method": "msi", "path": path}
        elif ext == "exe":
            print("Installer: %s" % path)
            if assume_yes or _confirm("Run it now?"):
                try:
                    os.startfile(path)  # Windows only
                except Exception as exc:
                    die("could not launch: %s" % exc)
        elif ext == "zip":
            t = _extract_archive(path)
            if t:
                return {"method": "archive", "path": t}
        else:
            print("Saved %s" % path)
            return {"method": "file", "path": path}
    else:
        print("Saved %s" % path)
        return {"method": "file", "path": path}
    return None


def cmd_app_install(args):
    import platform
    target = getattr(args, "app", None) or getattr(args, "target", None)
    if not target:
        die("usage: jm app install <name|id>")
    app_obj = _resolve_app(target)
    name = app_obj["name"]
    osname = platform.system()

    supported = (app_obj.get("supported_os") or "").lower()
    keys = {"Linux": ["linux"], "Darwin": ["mac", "osx", "darwin"], "Windows": ["win"]}
    if supported and not any(k in supported for k in keys.get(osname, [])):
        sys.stderr.write("Note: %s lists OS support '%s' — you're on %s.\n"
                         % (name, app_obj.get("supported_os"), osname))

    releases = app_obj.get("releases", [])
    rel = next((r for r in releases if r.get("has_file") or r.get("download_url")), None)
    dest_dir = args.dir or os.path.join(os.path.expanduser("~"), "Downloads")
    if not os.path.isdir(dest_dir):
        dest_dir = os.getcwd()

    if rel and rel.get("has_file"):
        print("Downloading %s v%s ..." % (name, rel["version"]))
        path, url = _download_to_dir("/api/v1/releases/%s/download" % rel["id"], dest_dir)
        if url:
            print("External download: %s" % url)
            return
    elif app_obj.get("download_url") or (rel and rel.get("download_url")):
        url = app_obj.get("download_url") or rel.get("download_url")
        print("%s is hosted externally — download it from:\n  %s" % (name, url))
        return
    else:
        die("no installable file for %s." % name)

    print("Saved to %s" % path)
    if args.download_only:
        return
    record = _install_file(path, app_obj, assume_yes=args.yes)
    version = rel["version"] if rel else app_obj.get("version")
    chosen_path = getattr(args, "install_path", None) or app_obj.get("install_path")
    _record_installed(app_obj, version, path=chosen_path)
    _record_local_install(app_obj["id"], record)
    if chosen_path:
        print("Install path noted for uninstall: %s" % chosen_path)

    # Run an installer found inside an extracted archive (dev-declared, or --run).
    inner = getattr(args, "run", None) or app_obj.get("installer_path")
    if inner and record and record.get("method") == "archive":
        _run_installer(os.path.join(record["path"], inner), assume_yes=args.yes)


def _run_installer(path, assume_yes, label="installer"):
    """Run an installer/uninstaller file the right way for this OS."""
    import platform
    import stat
    import subprocess
    path = os.path.expandvars(os.path.expanduser(path))
    osname = platform.system()
    # our packages ship install.sh + install.ps1 — pick the one for this OS
    base, e = os.path.splitext(path)
    if osname == "Windows" and e.lower() == ".sh" and os.path.exists(base + ".ps1"):
        path = base + ".ps1"
    elif osname != "Windows" and e.lower() == ".ps1" and os.path.exists(base + ".sh"):
        path = base + ".sh"
    if not os.path.exists(path):
        print("Declared %s not found: %s" % (label, path))
        return
    ext = os.path.splitext(path)[1].lower().lstrip(".")
    print("Running the app's %s: %s" % (label, path))
    if not assume_yes and not _confirm("Run it now?"):
        print("Skipped. It's at: %s" % path)
        return
    try:
        if osname == "Windows":
            if ext == "msi":
                subprocess.call(["msiexec", "/i", path])
            elif ext == "ps1":
                subprocess.call(["powershell", "-ExecutionPolicy", "Bypass", "-File", path])
            else:
                os.startfile(path)  # .exe and friends
            return
        if ext == "exe":
            print("That installer is a Windows .exe — run it on Windows.")
            return
        if ext == "deb":
            subprocess.call(["sudo", "apt-get", "install", "-y", path]); return
        if ext == "rpm":
            subprocess.call(["sudo", "dnf", "install", "-y", path]); return
        try:
            os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
        except OSError:
            pass
        if ext == "sh":
            subprocess.call(["bash", path])
        else:
            try:
                subprocess.call([path])           # AppImage, .run, .bin, plain executable
            except OSError:
                subprocess.call(["open" if osname == "Darwin" else "xdg-open", path])
    except Exception as exc:
        die("could not run installer: %s" % exc)


def _record_local_install(app_id, record):
    """Remember how an app was installed on THIS machine, so uninstall can undo it."""
    if not record:
        return
    try:
        cfg = load_config()
        recs = cfg.get("install_records", {})
        recs[str(app_id)] = record
        cfg["install_records"] = recs
        save_config(cfg)
    except Exception:
        pass


def _uninstall_record(rec, assume_yes):
    """Reverse a local install record (apt/dnf remove, delete file/folder, msiexec /x)."""
    import os
    import shutil
    import subprocess
    method = rec.get("method")

    def run(cmd, desc):
        print("Uninstall command:  %s" % desc)
        if not assume_yes and not _confirm("Run it?"):
            print("Skipped.")
            return
        try:
            subprocess.call(cmd)
        except Exception as exc:
            die("uninstall failed: %s" % exc)

    if method == "apt" and rec.get("package"):
        run(["sudo", "apt-get", "remove", "-y", rec["package"]], "sudo apt-get remove -y %s" % rec["package"])
    elif method == "dnf" and rec.get("package"):
        run(["sudo", "dnf", "remove", "-y", rec["package"]], "sudo dnf remove -y %s" % rec["package"])
    elif method == "msi" and rec.get("path"):
        run(["msiexec", "/x", rec["path"]], "msiexec /x %s" % rec["path"])
    elif method == "file" and rec.get("path"):
        p = rec["path"]
        if os.path.isfile(p):
            if assume_yes or _confirm("Delete %s?" % p):
                try:
                    os.remove(p)
                    print("Deleted %s" % p)
                except OSError as exc:
                    die("could not delete %s: %s" % (p, exc))
        else:
            print("(already gone: %s)" % p)
    elif method == "archive" and rec.get("path"):
        p = rec["path"]
        if os.path.isdir(p):
            if assume_yes or _confirm("Delete the extracted folder %s?" % p):
                shutil.rmtree(p, ignore_errors=True)
                print("Deleted %s" % p)
        else:
            print("(already gone: %s)" % p)
    else:
        print("No automatic uninstaller for this app — remove it manually if needed.")


def _record_installed(app_obj, version, path=None):
    """Mark an app installed on your account (synced with the website)."""
    try:
        body = {"app_id": app_obj["id"], "version": version}
        if path:
            body["path"] = path
        api("POST", "/api/v1/installed", json_body=body)
    except SystemExit:
        pass


def cmd_apps_installed(args):
    data = api("GET", "/api/v1/installed")["installed"]
    if getattr(args, "json", False):
        print(json.dumps(data))
        return
    rows = [[i["app_id"], i["name"], i["version"] or "?", "↑ update" if i.get("update_available") else "ok"]
            for i in data]
    table(rows, ["ID", "NAME", "INSTALLED", "STATUS"])


def _delete_install_path(path, assume_yes):
    """Delete a declared/recorded install path, with a safety guard against broad paths."""
    import os
    import shutil
    p = os.path.abspath(os.path.expanduser(os.path.expandvars(path)))
    home = os.path.abspath(os.path.expanduser("~"))
    parts = [x for x in p.replace("\\", "/").split("/") if x]
    if p in ("/", home) or len(parts) < 2:
        print("Refusing to delete unsafe path: %s" % p)
        return
    if not os.path.exists(p):
        print("(install path already gone: %s)" % p)
        return
    print("Install path: %s" % p)
    if not assume_yes and not _confirm("Delete this path and everything in it?"):
        print("Left %s in place." % p)
        return
    try:
        if os.path.isdir(p):
            shutil.rmtree(p)
        else:
            os.remove(p)
        print("Deleted %s" % p)
    except PermissionError:
        print("Permission denied for %s — re-run with elevated rights:  sudo jm apps uninstall ..." % p)
    except OSError as exc:
        print("Could not delete %s: %s" % (p, exc))


def cmd_apps_uninstall(args):
    target = getattr(args, "target", None)
    if not target:
        die("usage: jm apps uninstall <name|id>")
    installed = api("GET", "/api/v1/installed")["installed"]
    if str(target).isdigit():
        app_id = int(target)
        entry = next((i for i in installed if i["app_id"] == app_id), None)
    else:
        entry = next((i for i in installed if (i["name"] or "").lower() == str(target).lower()), None)
        if not entry:
            die("%r is not in your installed list (see: jm apps installed)" % target)
        app_id = entry["app_id"]

    if not getattr(args, "keep_files", False):
        cfg = load_config()
        recs = cfg.get("install_records", {})
        rec = recs.get(str(app_id))
        uninstaller = entry and entry.get("uninstaller")
        if uninstaller:
            u = uninstaller
            is_abs = os.path.isabs(u) or (len(u) > 1 and u[1] == ":")
            if not is_abs and rec and rec.get("method") == "archive" and rec.get("path"):
                u = os.path.join(rec["path"], u)   # uninstaller shipped inside the package
            _run_installer(u, assume_yes=args.yes, label="uninstaller")
        if rec:
            _uninstall_record(rec, assume_yes=args.yes)          # apt/dnf remove, delete file/folder
        path = entry and (entry.get("install_path") or entry.get("declared_path"))
        if path:
            _delete_install_path(path, assume_yes=args.yes)      # delete the app's install directory
        if not uninstaller and not rec and not path:
            print("Nothing recorded to remove from disk (was it installed elsewhere?). Untracking only.")
        recs.pop(str(app_id), None)
        cfg["install_records"] = recs
        save_config(cfg)

    api("DELETE", "/api/v1/installed/%d" % app_id)
    print("Removed app #%d from your installed list." % app_id)


def cmd_apps_update(args):
    import argparse as _argparse
    data = api("GET", "/api/v1/installed")["installed"]
    if not data:
        print("No apps are tracked yet. Install one with:  jm apps install <name|id>")
        return
    pending = [i for i in data if i.get("update_available")]
    if not pending:
        print("All %d installed app(s) are up to date." % len(data))
        return
    print("%d update(s) available:" % len(pending))
    for i in pending:
        print("  %-24s %s -> %s" % (i["name"], i["version"], i["latest_version"]))
    if args.check:
        print("Run `jm apps update` to install them.")
        return
    for i in pending:
        print("\n== %s ==" % i["name"])
        ns = _argparse.Namespace(app=str(i["app_id"]), yes=args.yes, dir=args.dir, download_only=False)
        try:
            cmd_app_install(ns)
        except SystemExit:
            sys.stderr.write("  (skipped %s)\n" % i["name"])


def cmd_app_view(args):
    a = api("GET", "/api/v1/apps/%d" % args.id)["app"]
    print("%s  v%s   [%s]" % (a["name"], a["version"], "approved" if a["is_approved"] else "pending"))
    print(a["url"])
    print("%s · %s · %s" % (a["category"], a["supported_os"], a["license_type"]))
    print("by %s" % a["developer_name"])
    print()
    print(a["description"])
    print()
    print("Releases:")
    rows = [[r["id"], "v" + r["version"], r.get("file_size") or "-",
             r["download_count"], "missing" if (not r["has_file"] and not r["download_url"]) else "ok",
             r["created_at"][:10]] for r in a.get("releases", [])]
    table(rows, ["REL_ID", "VERSION", "SIZE", "DOWNLOADS", "FILE", "DATE"])


def _file_or_url(args):
    if args.file:
        if not os.path.isfile(args.file):
            die("file not found: %s" % args.file)
    return


def cmd_app_create(args):
    _file_or_url(args)
    fields = {"name": args.name, "description": args.description, "version": args.version,
              "category": args.category, "supported_os": args.os, "license_type": args.license,
              "developer_name": args.developer_name, "developer_email": args.developer_email,
              "download_url": args.url, "notes": args.notes, "install_path": args.install_path,
              "installer_path": args.installer_path, "uninstaller_path": args.uninstaller_path}
    if args.file:
        res = api("POST", "/api/v1/apps", files=[("file", args.file)], fields=fields)
    else:
        res = api("POST", "/api/v1/apps", json_body={k: v for k, v in fields.items() if v is not None})
    a = res["app"]
    print("Created app #%d \"%s\". %s" % (a["id"], a["name"], res.get("message", "")))


def cmd_app_delete(args):
    if not args.yes:
        if input("Delete app #%d and all its releases/files? [y/N] " % args.id).strip().lower() != "y":
            die("aborted")
    api("DELETE", "/api/v1/apps/%d" % args.id)
    print("Deleted app #%d" % args.id)


def cmd_release_create(args):
    _file_or_url(args)
    fields = {"version": args.version, "notes": args.notes,
              "highlights": ("\n".join(args.highlight) if args.highlight else None),
              "download_url": args.url}
    if args.file:
        res = api("POST", "/api/v1/apps/%d/releases" % args.app_id, files=[("file", args.file)], fields=fields)
    else:
        res = api("POST", "/api/v1/apps/%d/releases" % args.app_id,
                  json_body={k: v for k, v in fields.items() if v is not None})
    print("Published release v%s (app now v%s)" % (res["release"]["version"], res.get("app_version")))


def cmd_release_download(args):
    download("/api/v1/releases/%d/download" % args.id, out=args.out)


# --------------------------------------------------------------------------- #
# commands: drive
# --------------------------------------------------------------------------- #
def cmd_drive_ls(args):
    res = api("GET", "/api/v1/drive", params={"folder": args.folder})
    rows = []
    for d in res["files"]:
        icon = "DIR " if d["kind"] == "folder" else "file"
        rows.append([d["id"], icon, d["name"], "" if d["kind"] == "folder" else "%.2f MB" % d["size_mb"],
                     d["visibility"], d.get("share_url", "")])
    table(rows, ["ID", "TYPE", "NAME", "SIZE", "VIS", "SHARE_URL"])


def cmd_drive_upload(args):
    for p in args.paths:
        if not os.path.isfile(p):
            die("file not found: %s" % p)
    res = api("POST", "/api/v1/drive/upload",
              files=[("file", p) for p in args.paths],
              fields={"folder": args.folder, "visibility": "public" if args.public else "private"})
    for d in res.get("uploaded", []):
        line = "uploaded #%d %s" % (d["id"], d["name"])
        if d.get("share_url"):
            line += "  " + d["share_url"]
        print(line)
    for e in res.get("errors", []):
        sys.stderr.write("skipped %s (%s)\n" % (e["name"], e["error"]))


def cmd_drive_mkdir(args):
    d = api("POST", "/api/v1/drive/folder",
            json_body={"name": args.name, "parent": args.parent,
                       "visibility": "public" if args.public else "private"})["folder"]
    print("created folder #%d %s" % (d["id"], d["name"]))


def cmd_drive_download(args):
    download("/api/v1/drive/%d/download" % args.id, out=args.out)


def cmd_drive_share(args):
    res = api("POST", "/api/v1/drive/%d/share" % args.id,
              json_body={"password": args.password, "limit": args.limit})
    print(res["share_url"])


def cmd_drive_rm(args):
    if not args.yes:
        if input("Delete drive item #%d (folders delete everything inside)? [y/N] " % args.id).strip().lower() != "y":
            die("aborted")
    api("DELETE", "/api/v1/drive/%d" % args.id)
    print("deleted #%d" % args.id)


# --------------------------------------------------------------------------- #
# commands: chat
# --------------------------------------------------------------------------- #
def cmd_chat_groups(args):
    res = api("GET", "/api/v1/chat/groups")
    table([[g["id"], g["name"]] for g in res["groups"]], ["ID", "NAME"])


def cmd_chat_read(args):
    res = api("GET", "/api/v1/chat/messages",
              params={"group": args.group, "with": args.with_user, "limit": args.limit})
    for m in res["messages"]:
        att = ("  [%s]" % m["attachment"]) if m.get("attachment") else ""
        print("%s  %-12s %s%s" % (m["timestamp"][:16].replace("T", " "), m.get("from_name") or m["from"], m["message"], att))


def cmd_chat_send(args):
    if not args.to and not args.group:
        die("specify --to USER or --group ID")
    body = {"message": args.message}
    if args.group:
        body["group"] = args.group
    else:
        body["to"] = args.to
    api("POST", "/api/v1/chat/send", json_body=body)
    print("sent")


# --------------------------------------------------------------------------- #
# commands: articles
# --------------------------------------------------------------------------- #
def cmd_articles_list(args):
    res = api("GET", "/api/v1/articles",
              params={"mine": 1 if args.mine else None, "search": args.search, "category": args.category})
    rows = [[a["id"], a["title"][:42], a["category"],
             "published" if a["is_published"] else "draft",
             a["view_count"], a["like_count"], a["created_at"][:10]]
            for a in res["articles"]]
    table(rows, ["ID", "TITLE", "CATEGORY", "STATUS", "VIEWS", "LIKES", "DATE"])


def cmd_article_view(args):
    a = api("GET", "/api/v1/articles/%d" % args.id)["article"]
    print("%s   [%s]" % (a["title"], "published" if a["is_published"] else "draft"))
    print(a["url"])
    print("%s · tags: %s · %d views · %d likes"
          % (a["category"], ", ".join(a["tags"]) or "-", a["view_count"], a["like_count"]))
    print()
    content = a.get("content", "")
    if not args.raw:
        import re as _re
        content = _re.sub(r"<[^>]+>", "", content)
    print(content.strip())


def _read_article_content(args, allow_stdin_pipe=False):
    f = getattr(args, "file", None)
    if f == "-":                          # explicit stdin
        return sys.stdin.read()
    if f:
        if not os.path.isfile(f):
            die("file not found: %s" % f)
        with open(f, encoding="utf-8") as fh:
            return fh.read()
    if getattr(args, "content", None) is not None:
        return args.content
    if allow_stdin_pipe and not sys.stdin.isatty():   # body piped into `create`
        data = sys.stdin.read()
        return data if data.strip() else None
    return None


def cmd_article_create(args):
    content = _read_article_content(args, allow_stdin_pipe=True)
    if not content:
        die("provide --content, --file PATH, or pipe the body on stdin")
    body = {"title": args.title, "category": args.category, "content": content,
            "summary": args.summary, "tags": args.tags, "published": (not args.draft)}
    a = api("POST", "/api/v1/articles", json_body={k: v for k, v in body.items() if v is not None})["article"]
    print("Created article #%d \"%s\" [%s]" % (a["id"], a["title"], "published" if a["is_published"] else "draft"))
    print(a["url"])


def cmd_article_edit(args):
    content = _read_article_content(args)
    body = {}
    for k in ("title", "category", "tags", "summary"):
        v = getattr(args, k)
        if v is not None:
            body[k] = v
    if content is not None:
        body["content"] = content
    if args.publish:
        body["published"] = True
    if args.unpublish:
        body["published"] = False
    if not body:
        die("nothing to change — pass --title/--content/--file/--publish/etc.")
    a = api("PATCH", "/api/v1/articles/%d" % args.id, json_body=body)["article"]
    print("Updated article #%d [%s]" % (a["id"], "published" if a["is_published"] else "draft"))


def cmd_article_delete(args):
    if not args.yes:
        if input("Delete article #%d and its comments? [y/N] " % args.id).strip().lower() != "y":
            die("aborted")
    api("DELETE", "/api/v1/articles/%d" % args.id)
    print("Deleted article #%d" % args.id)


# --------------------------------------------------------------------------- #
# commands: token
# --------------------------------------------------------------------------- #
def cmd_token_list(args):
    res = api("GET", "/api/v1/tokens")
    rows = [[t["id"], t["name"], t["prefix"] + "…", t["created_at"][:10], (t["last_used_at"] or "never")[:16].replace("T", " ")]
            for t in res["tokens"]]
    table(rows, ["ID", "NAME", "PREFIX", "CREATED", "LAST_USED"])


def cmd_token_create(args):
    res = api("POST", "/api/v1/tokens", json_body={"name": args.name or "jm cli"})
    print(res["token"])
    sys.stderr.write("Created token #%d (%s). Copy it now — it won't be shown again.\n" % (res["id"], res["name"]))


def cmd_token_revoke(args):
    api("DELETE", "/api/v1/tokens/%d" % args.id)
    print("revoked token #%d" % args.id)


# --------------------------------------------------------------------------- #
# commands: project (GitHub-style apps with browsable source)
# --------------------------------------------------------------------------- #
JM_TOML = "jm.toml"
JMIGNORE = ".jmignore"

# The manifest keys we round-trip through our tiny flat-TOML reader/writer.
_MANIFEST_KEYS = ["name", "version", "category", "supported_os", "license",
                  "description", "visibility", "id", "owner", "slug"]

_DEFAULT_JMIGNORE = [
    ".git/", "__pycache__/", "*.pyc", ".DS_Store",
    "node_modules/", ".venv/", "venv/", "dist/", "build/", "*.zip",
]


def _toml_escape(v):
    return str(v).replace("\\", "\\\\").replace('"', '\\"')


def _toml_unescape(v):
    return v.replace('\\"', '"').replace("\\\\", "\\")


def read_manifest(path=JM_TOML):
    """Read our tiny flat TOML: only `key = "value"` string lines. Skip the rest."""
    data = {}
    try:
        with open(path, encoding="utf-8") as fh:
            for line in fh:
                s = line.strip()
                if not s or s.startswith("#") or s.startswith("[") or "=" not in s:
                    continue
                key, _, val = s.partition("=")
                key = key.strip()
                val = val.strip()
                if len(val) >= 2 and val[0] == '"' and val[-1] == '"':
                    data[key] = _toml_unescape(val[1:-1])
                # non-string values are ignored (we only write strings)
    except OSError:
        return None
    return data


def write_manifest(data, path=JM_TOML):
    """Write our tiny flat TOML — one `key = "value"` line per known key present."""
    lines = ["# jm project manifest — https://www.jmtechpower.com", ""]
    written = set()
    for key in _MANIFEST_KEYS:
        if data.get(key) is not None and str(data.get(key)) != "":
            lines.append('%s = "%s"' % (key, _toml_escape(data[key])))
            written.add(key)
    # preserve any extra known-string keys not in our ordered list
    for key in sorted(data):
        if key not in written and data.get(key) is not None and str(data.get(key)) != "":
            lines.append('%s = "%s"' % (key, _toml_escape(data[key])))
    with open(path, "w", encoding="utf-8") as fh:
        fh.write("\n".join(lines) + "\n")


def _load_jmignore(root):
    """Return the list of gitignore-lite patterns from .jmignore in root (or defaults)."""
    path = os.path.join(root, JMIGNORE)
    pats = []
    try:
        with open(path, encoding="utf-8") as fh:
            for line in fh:
                s = line.strip()
                if s and not s.startswith("#"):
                    pats.append(s)
    except OSError:
        pats = list(_DEFAULT_JMIGNORE)
    return pats


def _jmignore_match(rel, is_dir, patterns):
    """gitignore-lite: match a repo-relative POSIX path against .jmignore patterns."""
    import fnmatch
    rel = rel.replace(os.sep, "/")
    name = rel.rsplit("/", 1)[-1]
    parts = rel.split("/")
    for pat in patterns:
        dir_only = pat.endswith("/")
        p = pat[:-1] if dir_only else pat
        p = p.strip("/") if "/" in p else p
        if dir_only:
            # match a directory component anywhere in the path
            if is_dir and (name == p or fnmatch.fnmatch(name, p)):
                return True
            if any(seg == p or fnmatch.fnmatch(seg, p) for seg in parts[:-1]):
                return True
            if any(seg == p or fnmatch.fnmatch(seg, p) for seg in parts):
                return True
        else:
            if fnmatch.fnmatch(name, p) or fnmatch.fnmatch(rel, p):
                return True
            if any(fnmatch.fnmatch(seg, p) for seg in parts):
                return True
    return False


def build_source_zip(root, zip_path, patterns=None):
    """Zip `root` into `zip_path`, honoring .jmignore. Returns the list of archived paths."""
    import zipfile
    if patterns is None:
        patterns = _load_jmignore(root)
    root = os.path.abspath(root)
    zip_abs = os.path.abspath(zip_path)
    added = []
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
        for dirpath, dirnames, filenames in os.walk(root):
            rel_dir = os.path.relpath(dirpath, root)
            # prune ignored directories (always skip .git and __pycache__)
            keep = []
            for d in dirnames:
                rel = d if rel_dir == "." else (rel_dir + "/" + d)
                if d in (".git", "__pycache__"):
                    continue
                if _jmignore_match(rel, True, patterns):
                    continue
                keep.append(d)
            dirnames[:] = keep
            for f in filenames:
                rel = f if rel_dir == "." else (rel_dir + "/" + f)
                full = os.path.join(dirpath, f)
                # never archive the zip we're building, the manifest stays, jmignore too
                if os.path.abspath(full) == zip_abs:
                    continue
                if _jmignore_match(rel, False, patterns):
                    continue
                arc = rel.replace(os.sep, "/")
                zf.write(full, arc)
                added.append(arc)
    return added


def _quiet_get_json(path):
    """GET a JSON endpoint, returning the parsed dict or None on any error.
    Unlike api(), this never prints or exits — used for optional lookups."""
    url = get_host().rstrip("/") + path
    headers = {"Accept": "application/json", "User-Agent": "jm-cli/%s" % VERSION}
    tok = get_token()
    if tok:
        headers["Authorization"] = "Bearer " + tok
    try:
        req = urllib.request.Request(url, headers=headers, method="GET")
        raw = urllib.request.urlopen(req).read()
        return json.loads(raw) if raw else {}
    except Exception:
        return None


def _resolve_owner_slug(query):
    """If query looks like 'owner/slug', resolve it via the projects API to a full
    app detail dict. Returns None if it isn't owner/slug or the lookup fails
    (callers fall back to the name/id resolver)."""
    q = str(query)
    if "/" not in q or q.isdigit():
        return None
    owner, slug = q.split("/", 1)
    if not owner or not slug or "/" in slug:
        return None
    res = _quiet_get_json("/api/v1/projects/%s/%s"
                          % (urllib.parse.quote(owner), urllib.parse.quote(slug)))
    if not res:
        return None
    app = res.get("app") or res.get("project")
    if not app or app.get("id") is None:
        return None
    # The projects endpoint may return a slim record — fetch full detail (with releases).
    full = _quiet_get_json("/api/v1/apps/%s" % app["id"])
    if full and full.get("app"):
        return full["app"]
    return app


def _resolve_project(query):
    """Resolve a project by id, name, or 'owner/slug'."""
    hit = _resolve_owner_slug(query)
    if hit is not None:
        return hit
    q = str(query)
    if "/" in q and not q.isdigit():
        q = q.split("/", 1)[1]  # fall back: resolve on the name/slug part
    return _resolve_app(q)


def cmd_project_fork(args):
    """Fork a project into your account (copies its latest source)."""
    app = _resolve_project(args.target)
    body = {}
    if getattr(args, "name", None):
        body["name"] = args.name
    res = api("POST", "/api/v1/projects/%s/fork" % app["id"], json_body=body)
    fork = res.get("app", {})
    owner = fork.get("owner")
    slug = fork.get("slug")
    where = ("%s/%s" % (owner, slug)) if owner and slug else ("#%s" % fork.get("id"))
    print("Forked %s -> %s (v%s)" % (res.get("forked_from") or app.get("name"), where, fork.get("version")))


def cmd_project_forks(args):
    """List a project's forks."""
    app = _resolve_project(args.target)
    res = api("GET", "/api/v1/projects/%s/forks" % app["id"])
    forks = res.get("forks", [])
    if getattr(args, "json", False):
        print(json.dumps(forks))
        return
    if not forks:
        print("no forks yet")
        return
    rows = [["%s/%s" % (f.get("owner"), f.get("slug")), "v" + (f.get("version") or ""),
             (f.get("created_at") or "")[:10]] for f in forks]
    table(rows, ["FORK", "VERSION", "CREATED"])


def cmd_project_stars(args):
    """List a project's stargazers."""
    app = _resolve_project(args.target)
    res = api("GET", "/api/v1/projects/%s/stargazers" % app["id"])
    if getattr(args, "json", False):
        print(json.dumps(res))
        return
    sg = res.get("stargazers", [])
    print("%d stargazer(s)" % res.get("count", len(sg)))
    for u in sg:
        print("  @%s  %s" % (u.get("handle"), u.get("display_name")))


def _emit_diff(old, new, name_only=False, stat=False):
    """Print a git-style diff between two {path: bytes} maps. Returns files changed."""
    import difflib
    changed = adds_t = dels_t = 0
    for p in sorted(set(old) | set(new)):
        o, nw = old.get(p), new.get(p)
        if o == nw:
            continue
        changed += 1
        status = "A" if o is None else ("D" if nw is None else "M")
        if name_only:
            print("%s\t%s" % (status, p))
            continue
        if not (_is_text(o) and _is_text(nw)):
            if not stat:
                print("diff --jm a/%s b/%s\nBinary files differ" % (p, p))
            continue
        if (o and len(o) > 1024 * 1024) or (nw and len(nw) > 1024 * 1024):
            if not stat:
                print("diff --jm a/%s b/%s\n(large file, not shown)" % (p, p))
            continue
        ol = o.decode("utf-8", "replace").splitlines(keepends=True) if o is not None else []
        nl = nw.decode("utf-8", "replace").splitlines(keepends=True) if nw is not None else []
        ud = list(difflib.unified_diff(ol, nl, fromfile="a/%s" % p, tofile="b/%s" % p))
        a = sum(1 for l in ud if l.startswith("+") and not l.startswith("+++"))
        d = sum(1 for l in ud if l.startswith("-") and not l.startswith("---"))
        adds_t += a
        dels_t += d
        if stat:
            tag = "  (new)" if status == "A" else ("  (deleted)" if status == "D" else "")
            print(" %-44s | +%d -%d%s" % (p, a, d, tag))
        else:
            hdr = "diff --jm a/%s b/%s" % (p, p)
            hdr += "\nnew file" if status == "A" else ("\ndeleted file" if status == "D" else "")
            print(hdr)
            sys.stdout.write("".join(ud))
            if ud and not ud[-1].endswith("\n"):
                print()
    if stat and changed:
        print(" %d file(s) changed, +%d -%d" % (changed, adds_t, dels_t))
    return changed


def cmd_project_pulls(args):
    """List a project's pull requests."""
    base = _resolve_project(args.target)
    res = api("GET", "/api/v1/projects/%s/pulls" % base["id"], params={"state": getattr(args, "state", None)})
    pulls = res.get("pulls", [])
    if getattr(args, "json", False):
        print(json.dumps(pulls))
        return
    if not pulls:
        print("no pull requests")
        return
    rows = [["#%d" % p["number"], p["state"], p["title"],
             "%s@%s" % (p.get("head"), p.get("head_version"))] for p in pulls]
    table(rows, ["PR", "STATE", "TITLE", "HEAD"])


def cmd_project_pr(args):
    """View a pull request, including its source diff (base -> proposed)."""
    base = _resolve_project(args.target)
    res = api("GET", "/api/v1/projects/%s/pulls/%s" % (base["id"], args.number))
    pr = res.get("pull", {})
    if getattr(args, "json", False):
        print(json.dumps(pr))
        return
    print("#%d  %s   [%s]" % (pr["number"], pr["title"], pr["state"]))
    print("%s wants to merge %s@%s into %s"
          % (pr.get("author"), pr.get("head"), pr.get("head_version"), pr.get("base")))
    if pr.get("body"):
        print("\n%s" % pr["body"])
    print("\n--- source diff (base -> proposed) ---")
    old = _source_map(pr["base_id"], None)
    new = _source_map(pr["head_id"], pr["head_version"])
    if _emit_diff(old, new) == 0:
        print("(no source changes)")


def cmd_project_pr_create(args):
    """Open a pull request from a fork into a base project."""
    base = _resolve_project(args.target)
    frm, version = args.frm, None
    if "@" in frm:
        frm, version = frm.rsplit("@", 1)
    body = {"head": frm, "title": args.title}
    if version:
        body["version"] = version
    bd = _read_body(args)
    if bd is not None:
        body["body"] = bd
    res = api("POST", "/api/v1/projects/%s/pulls" % base["id"], json_body=body)
    pr = res.get("pull", {})
    print("Opened pull request #%d on %s (%s@%s)"
          % (pr["number"], pr.get("base"), pr.get("head"), pr.get("head_version")))


def cmd_project_pr_merge(args):
    """Merge a pull request (base owner) — publishes the proposed source as a new release."""
    base = _resolve_project(args.target)
    res = api("POST", "/api/v1/projects/%s/pulls/%s/merge" % (base["id"], args.number))
    rel = res.get("release", {})
    print("Merged PR #%s -> %s v%s" % (args.number, base.get("name"), rel.get("version")))


def cmd_project_pr_close(args):
    """Close a pull request."""
    base = _resolve_project(args.target)
    api("POST", "/api/v1/projects/%s/pulls/%s/close" % (base["id"], args.number))
    print("Closed PR #%s" % args.number)


def _download_extract_source(app_id, version, dest):
    """Download a version's source zip and extract into dest. Returns file list."""
    import tempfile
    import zipfile
    params = {"version": version} if version else None
    resp = request("GET", "/api/v1/projects/%s/source" % app_id, params=params)
    if "application/json" in resp.headers.get("Content-Type", ""):
        data = json.loads(resp.read() or b"{}")
        die(data.get("message") or data.get("error") or "this project has no source")
    fd, tmpzip = tempfile.mkstemp(prefix="jm-src-", suffix=".zip")
    try:
        with os.fdopen(fd, "wb") as fh:
            while True:
                chunk = resp.read(65536)
                if not chunk:
                    break
                fh.write(chunk)
        os.makedirs(dest, exist_ok=True)
        with zipfile.ZipFile(tmpzip) as zf:
            names = [n for n in zf.namelist() if not n.endswith("/")]
            _safe_extract_zip(zf, dest)
        return names
    finally:
        try:
            os.remove(tmpzip)
        except OSError:
            pass


def cmd_project_checkout(args):
    """Fetch a specific version's source into a directory (like `git checkout <tag>`)."""
    app = _resolve_project(args.target)
    dest = args.dir or "."
    names = _download_extract_source(app["id"], args.version, dest)
    # Write a fresh jm.toml with the project id so `jm proj pull` works afterwards
    # (an early version's embedded manifest may predate the id).
    man = {
        "name": app.get("name"), "version": args.version or app.get("version") or "0.1.0",
        "category": app.get("category") or "", "supported_os": app.get("supported_os") or "",
        "license": app.get("license_type") or "", "description": app.get("description") or "",
        "id": str(app["id"]),
    }
    if app.get("owner"):
        man["owner"] = app["owner"]
    if app.get("slug"):
        man["slug"] = app["slug"]
    write_manifest(man, os.path.join(dest, JM_TOML))
    print("checked out %s@%s into %s (%d file(s))"
          % (app.get("slug") or app.get("name"), args.version, dest, len(names)))


def cmd_project_pull(args):
    """Update a local checkout to the project's latest release source (like `git pull`)."""
    dest = args.dir or "."
    man = read_manifest(os.path.join(dest, JM_TOML))
    if not man or not man.get("id"):
        die("no %s with an id here — run inside a cloned project (or use --dir)" % JM_TOML)
    app = _resolve_project(man["id"])
    latest = app.get("version")
    names = _download_extract_source(app["id"], None, dest)
    man["version"] = latest or man.get("version")
    write_manifest(man, os.path.join(dest, JM_TOML))
    print("pulled %s @ v%s (%d file(s) updated)"
          % (app.get("slug") or app.get("name"), latest, len(names)))


def cmd_project_readme(args):
    """Print a project's README (rendered from its source)."""
    app = _resolve_project(args.target)
    ver = getattr(args, "version", None)
    res = api("GET", "/api/v1/projects/%s/tree" % app["id"], params={"version": ver})
    readme = None
    for f in res.get("files", []):
        base = f["path"].rsplit("/", 1)[-1].lower()
        if base in ("readme.md", "readme.markdown", "readme.txt", "readme"):
            if readme is None or "/" not in f["path"]:
                readme = f["path"]
                if "/" not in f["path"]:
                    break
    if not readme:
        die("no README found in this project's source")
    resp = request("GET", "/api/v1/projects/%s/raw" % app["id"],
                   params={"version": ver, "path": readme})
    sys.stdout.write(resp.read().decode("utf-8", "replace"))


def _release_by_version(app, version):
    for r in app.get("releases", []):
        if r["version"] == version:
            return r
    return None


def _source_map(app_id, version):
    """Download a version's source zip -> {path: bytes} (files only)."""
    import io
    import zipfile
    resp = request("GET", "/api/v1/projects/%s/source" % app_id, params={"version": version})
    zf = zipfile.ZipFile(io.BytesIO(resp.read()))
    return {zi.filename: zf.read(zi.filename) for zi in zf.infolist() if not zi.is_dir()}


def cmd_project_log(args):
    """Release history, git-log style (short source hash · version tag · date · notes)."""
    app = _resolve_project(args.target)
    releases = sorted(app.get("releases", []),
                      key=lambda r: (r.get("created_at") or "", r["version"]), reverse=True)
    if getattr(args, "n", None):
        releases = releases[:args.n]
    if getattr(args, "json", False):
        print(json.dumps(releases))
        return
    if not releases:
        print("(no releases yet)")
        return
    for r in releases:
        sha = ((r.get("commit") or "")[:7]) or "-------"
        date = (r.get("created_at") or "")[:10]
        note = (r.get("notes") or "").splitlines()[0] if r.get("notes") else ""
        print("%s  v%-10s %s  %s" % (sha, r["version"], date, note))


def _is_text(b):
    return b is None or b"\x00" not in b[:4096]


def cmd_project_diff(args):
    """Diff browsable source between two releases (defaults to the latest two)."""
    import difflib
    app = _resolve_project(args.target)
    releases = sorted(app.get("releases", []), key=lambda r: (r.get("created_at") or "", r["version"]))
    vers = [r["version"] for r in releases]
    v1, v2 = getattr(args, "v1", None), getattr(args, "v2", None)
    if v1 and v2:
        a_ver, b_ver = v1, v2
    elif v1:
        if not vers:
            die("this project has no releases")
        a_ver, b_ver = v1, vers[-1]
    else:
        if len(vers) < 2:
            die("need at least two releases to diff (or pass a version)")
        a_ver, b_ver = vers[-2], vers[-1]
    for v in (a_ver, b_ver):
        r = _release_by_version(app, v)
        if r is None:
            die("no release %s (have: %s)" % (v, ", ".join(vers) or "none"))
        if not r.get("has_source"):
            die("%s has no browsable source to diff" % v)
    old, new = _source_map(app["id"], a_ver), _source_map(app["id"], b_ver)
    paths = sorted(set(old) | set(new))
    name_only, stat = getattr(args, "name_only", False), getattr(args, "stat", False)
    changed = adds_t = dels_t = 0
    for p in paths:
        o, nw = old.get(p), new.get(p)
        if o == nw:
            continue
        changed += 1
        status = "A" if o is None else ("D" if nw is None else "M")
        if name_only:
            print("%s\t%s" % (status, p))
            continue
        if not (_is_text(o) and _is_text(nw)):
            if not stat:
                print("diff --jm a/%s b/%s\nBinary files differ" % (p, p))
            continue
        if (o and len(o) > 1024 * 1024) or (nw and len(nw) > 1024 * 1024):
            if not stat:
                print("diff --jm a/%s b/%s\n(large file, not shown)" % (p, p))
            continue
        ol = o.decode("utf-8", "replace").splitlines(keepends=True) if o is not None else []
        nl = nw.decode("utf-8", "replace").splitlines(keepends=True) if nw is not None else []
        ud = list(difflib.unified_diff(ol, nl, fromfile="a/%s" % p, tofile="b/%s" % p))
        a = sum(1 for l in ud if l.startswith("+") and not l.startswith("+++"))
        d = sum(1 for l in ud if l.startswith("-") and not l.startswith("---"))
        adds_t += a
        dels_t += d
        if stat:
            tag = "  (new)" if status == "A" else ("  (deleted)" if status == "D" else "")
            print(" %-44s | +%d -%d%s" % (p, a, d, tag))
        else:
            hdr = "diff --jm a/%s b/%s" % (p, p)
            hdr += "\nnew file" if status == "A" else ("\ndeleted file" if status == "D" else "")
            print(hdr)
            sys.stdout.write("".join(ud))
            if ud and not ud[-1].endswith("\n"):
                print()
    if changed == 0:
        print("no source changes between %s and %s" % (a_ver, b_ver))
    elif stat:
        print(" %d file(s) changed, +%d -%d" % (changed, adds_t, dels_t))


# =========================================================================== #
# jm git — a real content-addressable object store (blobs/trees/commits),
# branches, 3-way merge, blame, and remote sync against the project's git store.
# Namespaced under `jm proj git <cmd>` so it never collides with the
# release-based verbs (clone/push/pull/log/diff/checkout).
# =========================================================================== #
def _git_root(start=None):
    d = start or os.getcwd()
    while True:
        if os.path.isdir(os.path.join(d, ".jm")):
            return d
        parent = os.path.dirname(d)
        if parent == d:
            return None
        d = parent


def _need_repo():
    root = _git_root()
    if not root:
        die("not a jm git repository (run: jm proj git init)")
    return root


def _obj_path(root, sha):
    return os.path.join(root, ".jm", "objects", sha)


def _write_object(root, otype, content):
    header = ("%s %d\0" % (otype, len(content))).encode()
    store = header + content
    sha = hashlib.sha256(store).hexdigest()
    p = _obj_path(root, sha)
    if not os.path.exists(p):
        os.makedirs(os.path.dirname(p), exist_ok=True)
        with open(p, "wb") as fh:
            fh.write(zlib.compress(store))
    return sha


def _read_object(root, sha):
    with open(_obj_path(root, sha), "rb") as fh:
        store = zlib.decompress(fh.read())
    nul = store.index(b"\0")
    otype = store[:nul].decode().split(" ", 1)[0]
    return otype, store[nul + 1:]


def _build_tree(root, dirpath, patterns):
    entries = []
    for name in sorted(os.listdir(dirpath)):
        if name == ".jm":
            continue
        full = os.path.join(dirpath, name)
        rel = os.path.relpath(full, root).replace(os.sep, "/")
        is_dir = os.path.isdir(full)
        if _jmignore_match(rel, is_dir, patterns):
            continue
        if is_dir:
            sub = _build_tree(root, full, patterns)
            entries.append(("40000", sub, name))
        elif os.path.isfile(full):
            with open(full, "rb") as fh:
                blob = _write_object(root, "blob", fh.read())
            entries.append(("100644", blob, name))
    content = "".join("%s %s %s\n" % (m, s, n) for m, s, n in entries).encode()
    return _write_object(root, "tree", content)


def _read_tree(root, sha):
    _t, content = _read_object(root, sha)
    out = []
    for line in content.decode().splitlines():
        if line:
            m, s, n = line.split(" ", 2)
            out.append((m, s, n))
    return out


def _flatten_tree(root, sha, prefix=""):
    out = {}
    for mode, s, name in _read_tree(root, sha):
        if mode == "40000":
            out.update(_flatten_tree(root, s, prefix + name + "/"))
        else:
            out[prefix + name] = s
    return out


def _tree_bytes(root, commit_sha):
    if not commit_sha:
        return {}
    tree = _read_commit(root, commit_sha)["tree"]
    return {p: _read_object(root, s)[1] for p, s in _flatten_tree(root, tree).items()}


def _worktree_bytes(root):
    patterns = _load_jmignore(root)
    out = {}
    for cur, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d != ".jm"
                   and not _jmignore_match(os.path.relpath(os.path.join(cur, d), root).replace(os.sep, "/"),
                                           True, patterns)]
        for f in files:
            full = os.path.join(cur, f)
            rel = os.path.relpath(full, root).replace(os.sep, "/")
            if _jmignore_match(rel, False, patterns):
                continue
            try:
                with open(full, "rb") as fh:
                    out[rel] = fh.read()
            except OSError:
                pass
    return out


def _write_commit(root, tree_sha, parents, message, author):
    lines = ["tree %s" % tree_sha]
    lines += ["parent %s" % p for p in parents]
    lines.append("author %s %d" % (author, int(time.time())))
    content = ("\n".join(lines) + "\n\n" + (message or "") + "\n").encode()
    return _write_object(root, "commit", content)


def _read_commit(root, sha):
    _t, content = _read_object(root, sha)
    head, _, message = content.decode().partition("\n\n")
    d = {"parents": [], "message": message.rstrip("\n"), "author": "", "ts": 0, "tree": None}
    for line in head.splitlines():
        if line.startswith("tree "):
            d["tree"] = line[5:].strip()
        elif line.startswith("parent "):
            d["parents"].append(line[7:].strip())
        elif line.startswith("author "):
            rest = line[7:].rsplit(" ", 1)
            d["author"] = rest[0]
            try:
                d["ts"] = int(rest[1])
            except (ValueError, IndexError):
                d["ts"] = 0
    return d


def _read_head(root):
    try:
        s = open(os.path.join(root, ".jm", "HEAD")).read().strip()
    except OSError:
        return (None, None)
    if s.startswith("ref: "):
        return ("branch", s[5:].strip())
    return ("sha", s) if s else (None, None)


def _ref_sha(root, refname):
    try:
        return open(os.path.join(root, ".jm", refname)).read().strip() or None
    except OSError:
        return None


def _set_ref(root, refname, sha):
    p = os.path.join(root, ".jm", refname)
    os.makedirs(os.path.dirname(p), exist_ok=True)
    with open(p, "w") as fh:
        fh.write(sha + "\n")


def _write_head(root, value):
    with open(os.path.join(root, ".jm", "HEAD"), "w") as fh:
        fh.write(value + "\n")


def _head_commit(root):
    kind, val = _read_head(root)
    if kind == "branch":
        return _ref_sha(root, val)
    return val if kind == "sha" else None


def _current_branch(root):
    kind, val = _read_head(root)
    return val.split("/")[-1] if kind == "branch" else None


def _advance_head(root, commit):
    kind, val = _read_head(root)
    if kind == "branch":
        _set_ref(root, val, commit)
    else:
        _write_head(root, commit)


def _resolve_commitish(root, ref):
    if ref in (None, "HEAD"):
        return _head_commit(root)
    b = _ref_sha(root, "refs/heads/%s" % ref)
    if b:
        return b
    t = _ref_sha(root, "refs/tags/%s" % ref)
    if t:
        return t
    if ref.startswith("refs/"):
        r = _ref_sha(root, ref)
        if r:
            return r
    objdir = os.path.join(root, ".jm", "objects")
    if os.path.isdir(objdir):
        for s in os.listdir(objdir):
            if s.startswith(ref):
                try:
                    if _read_object(root, s)[0] == "commit":
                        return s
                except OSError:
                    pass
    return None


def _author_name():
    return os.environ.get("JM_AUTHOR") or "you"


def _checkout_worktree(root, old_commit, new_commit):
    old = _tree_bytes(root, old_commit)
    new = _tree_bytes(root, new_commit)
    for p in old:
        if p not in new:
            fp = os.path.join(root, p)
            if os.path.isfile(fp):
                try:
                    os.remove(fp)
                except OSError:
                    pass
    for p, data in new.items():
        fp = os.path.join(root, p)
        os.makedirs(os.path.dirname(fp) or ".", exist_ok=True)
        with open(fp, "wb") as fh:
            fh.write(data)


# ---- staging index + repo metadata (git-like) ---------------------------
def _read_index(root):
    """Return {path: blobsha} of the staging area, or None if never staged."""
    p = os.path.join(root, ".jm", "index")
    if not os.path.exists(p):
        return None
    m = {}
    for line in open(p):
        line = line.rstrip("\n")
        if line:
            sha, _, path = line.partition(" ")
            if path:
                m[path] = sha
    return m


def _write_index(root, mapping):
    with open(os.path.join(root, ".jm", "index"), "w") as fh:
        for path in sorted(mapping):
            fh.write("%s %s\n" % (mapping[path], path))


def _clear_index(root):
    p = os.path.join(root, ".jm", "index")
    if os.path.exists(p):
        try:
            os.remove(p)
        except OSError:
            pass


def _refresh_index(root):
    """After a commit/merge, keep the index in sync with the new tree (if staging is active)."""
    if _read_index(root) is not None:
        _write_index(root, _head_tree_map(root))


def _write_meta(root, name, val):
    with open(os.path.join(root, ".jm", name), "w") as fh:
        fh.write(val + "\n")


def _read_meta(root, name):
    try:
        return open(os.path.join(root, ".jm", name)).read().strip() or None
    except OSError:
        return None


def _clear_meta(root, name):
    p = os.path.join(root, ".jm", name)
    if os.path.exists(p):
        try:
            os.remove(p)
        except OSError:
            pass


def _head_tree_map(root):
    h = _head_commit(root)
    if not h:
        return {}
    return dict(_flatten_tree(root, _read_commit(root, h)["tree"]))


def _worktree_map(root):
    """Worktree as {path: blobsha}, writing blobs to the object store."""
    return {p: _write_object(root, "blob", d) for p, d in _worktree_bytes(root).items()}


def _build_tree_from_map(root, mapping):
    """Build nested tree objects from a flat {path: blobsha} map; return root tree sha."""
    def build(entries):
        files, dirs = {}, {}
        for path, sha in entries:
            if "/" in path:
                d, rest = path.split("/", 1)
                dirs.setdefault(d, []).append((rest, sha))
            else:
                files[path] = sha
        lines = []
        for name in sorted(set(files) | set(dirs)):
            if name in dirs:
                lines.append(("40000", build(dirs[name]), name))
            else:
                lines.append(("100644", files[name], name))
        content = "".join("%s %s %s\n" % (m, s, n) for m, s, n in lines).encode()
        return _write_object(root, "tree", content)
    return build(list(mapping.items()))


def cmd_git_init(args):
    root = os.getcwd()
    g = os.path.join(root, ".jm")
    if os.path.isdir(g) and not getattr(args, "force", False):
        die(".jm already exists here")
    os.makedirs(os.path.join(g, "objects"), exist_ok=True)
    os.makedirs(os.path.join(g, "refs", "heads"), exist_ok=True)
    _write_head(root, "ref: refs/heads/main")
    man = read_manifest(os.path.join(root, JM_TOML)) or {}
    if man.get("id"):
        with open(os.path.join(g, "config"), "w") as fh:
            fh.write("remote=%s\n" % man["id"])
    print("Initialized empty jm git repository in %s" % g)


def cmd_git_add(args):
    root = _need_repo()
    wtmap = _worktree_map(root)
    paths = getattr(args, "paths", None) or []
    stage_all = getattr(args, "all", False) or paths in ([], ["."], ["-A"])
    idx = _read_index(root)
    if idx is None:
        idx = _head_tree_map(root)          # stage relative to last commit
    staged = 0
    if stage_all:
        idx = dict(wtmap)                    # snapshot everything (adds, mods, deletions)
        staged = "all"
    else:
        for pth in paths:
            rel = os.path.relpath(os.path.abspath(pth), root).replace(os.sep, "/")
            matched = [p for p in wtmap if p == rel or p.startswith(rel + "/")]
            if matched:
                for p in matched:
                    idx[p] = wtmap[p]
                    staged += 1
            elif rel in idx:                 # tracked file removed from worktree -> stage deletion
                del idx[rel]
                staged += 1
            else:
                die("pathspec '%s' did not match any files" % pth)
    _write_index(root, idx)
    print("Staged %s change(s)" % staged)


def cmd_git_commit(args):
    root = _need_repo()
    if getattr(args, "all", False):
        _write_index(root, _worktree_map(root))     # -a: stage every change first
    idx = _read_index(root)
    if idx is None:
        tree = _build_tree(root, root, _load_jmignore(root))   # never staged -> snapshot worktree
    else:
        tree = _build_tree_from_map(root, idx)
    parent = _head_commit(root)
    merge_head = _read_meta(root, "MERGE_HEAD")
    parents = ([parent] if parent else []) + ([merge_head] if merge_head else [])
    if parent and not merge_head and _read_commit(root, parent).get("tree") == tree:
        print("nothing to commit, working tree clean")
        return
    commit = _write_commit(root, tree, parents, args.message, _author_name())
    _advance_head(root, commit)
    _clear_meta(root, "MERGE_HEAD")
    _refresh_index(root)
    print("[%s %s] %s" % (_current_branch(root) or "detached", commit[:7],
                          (args.message or "").splitlines()[0] if args.message else ""))


def cmd_git_log(args):
    import datetime
    root = _need_repo()
    sha = _head_commit(root)
    if not sha:
        print("no commits yet")
        return
    count, limit = 0, getattr(args, "n", None)
    while sha:
        c = _read_commit(root, sha)
        try:
            date = datetime.datetime.fromtimestamp(c["ts"], datetime.timezone.utc).strftime("%Y-%m-%d")
        except (ValueError, OSError):
            date = ""
        msg = (c["message"] or "").splitlines()[0] if c["message"] else ""
        print("%s  %s  %s  (%s)" % (sha[:7], date, msg, c["author"]))
        count += 1
        if limit and count >= limit:
            break
        sha = c["parents"][0] if c["parents"] else None


def cmd_git_status(args):
    root = _need_repo()
    head, wt = _tree_bytes(root, _head_commit(root)), _worktree_bytes(root)
    idx_map = _read_index(root)
    print("On branch %s" % (_current_branch(root) or "(detached)"))
    if _read_meta(root, "MERGE_HEAD"):
        print("You have unmerged changes (resolve, then commit).")

    def diff_pairs(a, b):
        return [("A" if p not in a else ("D" if p not in b else "M"), p)
                for p in sorted(set(a) | set(b)) if a.get(p) != b.get(p)]

    if idx_map is None:                     # staging never used
        ch = diff_pairs(head, wt)
        if ch:
            print("Changes not staged for commit:")
            for s, p in ch:
                print("  %s  %s" % (s, p))
        else:
            print("nothing to commit, working tree clean")
        return
    idx_bytes = {p: _read_object(root, s)[1] for p, s in idx_map.items()}
    staged, unstaged = diff_pairs(head, idx_bytes), diff_pairs(idx_bytes, wt)
    if staged:
        print("Changes to be committed:")
        for s, p in staged:
            print("  %s  %s" % (s, p))
    if unstaged:
        print("Changes not staged for commit:")
        for s, p in unstaged:
            print("  %s  %s" % (s, p))
    if not staged and not unstaged:
        print("nothing to commit, working tree clean")


def cmd_git_diff(args):
    root = _need_repo()
    a, b = getattr(args, "a", None), getattr(args, "b", None)
    if a and b:
        old, new = _tree_bytes(root, _resolve_commitish(root, a)), _tree_bytes(root, _resolve_commitish(root, b))
    elif a:
        old, new = _tree_bytes(root, _resolve_commitish(root, a)), _worktree_bytes(root)
    else:
        old, new = _tree_bytes(root, _head_commit(root)), _worktree_bytes(root)
    if _emit_diff(old, new, name_only=getattr(args, "name_only", False), stat=getattr(args, "stat", False)) == 0:
        print("no changes")


def cmd_git_branch(args):
    root = _need_repo()
    if getattr(args, "delete", None):
        p = os.path.join(root, ".jm", "refs", "heads", args.delete)
        if os.path.exists(p):
            os.remove(p)
            print("Deleted branch %s" % args.delete)
        else:
            die("no such branch: %s" % args.delete)
        return
    if not getattr(args, "name", None):
        cur = _current_branch(root)
        hd = os.path.join(root, ".jm", "refs", "heads")
        for b in (sorted(os.listdir(hd)) if os.path.isdir(hd) else []):
            print(("* " if b == cur else "  ") + b)
        return
    sha = _head_commit(root)
    if not sha:
        die("no commits yet — commit before branching")
    _set_ref(root, "refs/heads/%s" % args.name, sha)
    print("Created branch %s" % args.name)


def cmd_git_checkout(args):
    root = _need_repo()
    ref = args.ref
    is_branch = os.path.exists(os.path.join(root, ".jm", "refs", "heads", ref))
    target = _resolve_commitish(root, ref)
    if not target:
        die("no such branch or commit: %s" % ref)
    old, wt = _tree_bytes(root, _head_commit(root)), _worktree_bytes(root)
    if any(old.get(p) != wt.get(p) for p in set(old) | set(wt)) and not getattr(args, "force", False):
        die("you have uncommitted changes — commit them or use -f to discard")
    _checkout_worktree(root, _head_commit(root), target)
    _write_head(root, ("ref: refs/heads/%s" % ref) if is_branch else target)
    print("Switched to %s" % (("branch " + ref) if is_branch else ("commit " + target[:7])))


def _ancestors(root, sha):
    seen, stack = set(), [sha]
    while stack:
        s = stack.pop()
        if not s or s in seen:
            continue
        seen.add(s)
        stack.extend(_read_commit(root, s)["parents"])
    return seen


def _merge_base(root, a, b):
    aa = _ancestors(root, a)
    from collections import deque
    seen, q = set(), deque([b])
    while q:
        s = q.popleft()
        if not s or s in seen:
            continue
        seen.add(s)
        if s in aa:
            return s
        q.extend(_read_commit(root, s)["parents"])
    return None


def _merge3(base, ours, theirs):
    """diff3 line-level 3-way merge. Lists of lines in, (merged_lines, conflict) out."""
    import difflib

    def to_map(x, y):
        m = {}
        for oi, sj, size in difflib.SequenceMatcher(None, x, y).get_matching_blocks():
            for k in range(size):
                m[oi + k] = sj + k
        return m

    amap, bmap = to_map(base, ours), to_map(base, theirs)
    out, conflict, io, ia, ib, n = [], False, 0, 0, 0, len(base)

    def emit_region(a_reg, b_reg, base_reg):
        nonlocal conflict
        if a_reg == b_reg:
            out.extend(a_reg)
        elif a_reg == base_reg:
            out.extend(b_reg)
        elif b_reg == base_reg:
            out.extend(a_reg)
        else:
            out.append("<<<<<<< ours\n")
            out.extend(a_reg)
            out.append("=======\n")
            out.extend(b_reg)
            out.append(">>>>>>> theirs\n")
            conflict = True

    while io < n:
        if io in amap and io in bmap:
            aj, bj = amap[io], bmap[io]
            emit_region(ours[ia:aj], theirs[ib:bj], [])   # insertions before this synced line
            out.append(base[io])
            ia, ib, io = aj + 1, bj + 1, io + 1
        else:
            jo = io
            while jo < n and not (jo in amap and jo in bmap):
                jo += 1
            aj0 = amap[jo] if jo < n else len(ours)
            bj0 = bmap[jo] if jo < n else len(theirs)
            emit_region(ours[ia:aj0], theirs[ib:bj0], base[io:jo])
            io, ia, ib = jo, aj0, bj0
    emit_region(ours[ia:], theirs[ib:], base[n:])          # trailing insertions
    return out, conflict


def _three_way_file(base, ours, theirs):
    """Return (merged_bytes_or_None, conflict_bool)."""
    if ours == theirs:
        return (ours, False)
    if ours == base:
        return (theirs, False)
    if theirs == base:
        return (ours, False)
    if ours is None or theirs is None:      # add/delete vs modify -> conflict, keep the present side
        return ((ours if ours is not None else theirs), True)
    bl = base.decode("utf-8", "replace").splitlines(keepends=True) if base is not None else []
    ol = ours.decode("utf-8", "replace").splitlines(keepends=True)
    tl = theirs.decode("utf-8", "replace").splitlines(keepends=True)
    merged, conflict = _merge3(bl, ol, tl)
    return ("".join(merged).encode(), conflict)


def _merge_into(root, theirs, label):
    """Merge commit `theirs` into HEAD. Returns True on success, False on conflict."""
    ours = _head_commit(root)
    if not ours:
        _checkout_worktree(root, None, theirs)
        _advance_head(root, theirs)
        _refresh_index(root)
        print("Fast-forward to %s" % theirs[:7])
        return True
    base = _merge_base(root, ours, theirs)
    if base == theirs:
        print("Already up to date.")
        return True
    if base == ours:
        _checkout_worktree(root, ours, theirs)
        _advance_head(root, theirs)
        _refresh_index(root)
        print("Fast-forward to %s" % theirs[:7])
        return True
    bmap, omap, tmap = _tree_bytes(root, base), _tree_bytes(root, ours), _tree_bytes(root, theirs)
    result, conflicts = {}, []
    for p in set(bmap) | set(omap) | set(tmap):
        merged, conf = _three_way_file(bmap.get(p), omap.get(p), tmap.get(p))
        if merged is not None:
            result[p] = merged
        if conf:
            conflicts.append(p)
    for p in omap:
        if p not in result:
            fp = os.path.join(root, p)
            if os.path.isfile(fp):
                try:
                    os.remove(fp)
                except OSError:
                    pass
    for p, data in result.items():
        fp = os.path.join(root, p)
        os.makedirs(os.path.dirname(fp) or ".", exist_ok=True)
        with open(fp, "wb") as fh:
            fh.write(data)
    if conflicts:
        _write_meta(root, "MERGE_HEAD", theirs)     # next commit becomes a 2-parent merge
        _clear_index(root)                          # so a plain commit captures the resolution
        print("CONFLICT (%d file(s)) — resolve, then: jm proj git commit -m 'merge %s'"
              % (len(conflicts), label))
        for c in sorted(conflicts):
            print("  both modified: %s" % c)
        return False
    tree = _build_tree(root, root, _load_jmignore(root))
    commit = _write_commit(root, tree, [ours, theirs], "Merge %s" % label, _author_name())
    _advance_head(root, commit)
    _refresh_index(root)
    print("Merge made by 3-way (%s)" % commit[:7])
    return True


def cmd_git_merge(args):
    root = _need_repo()
    theirs = _ref_sha(root, "refs/heads/%s" % args.branch) or _resolve_commitish(root, args.branch)
    if not theirs:
        die("no such branch: %s" % args.branch)
    _merge_into(root, theirs, "branch '%s'" % args.branch)


def cmd_git_tag(args):
    root = _need_repo()
    if getattr(args, "delete", None):
        p = os.path.join(root, ".jm", "refs", "tags", args.delete)
        if os.path.exists(p):
            os.remove(p)
            print("Deleted tag %s" % args.delete)
        else:
            die("no such tag: %s" % args.delete)
        return
    if not getattr(args, "name", None):
        td = os.path.join(root, ".jm", "refs", "tags")
        for t in (sorted(os.listdir(td)) if os.path.isdir(td) else []):
            print("%s\t%s" % (t, (_ref_sha(root, "refs/tags/%s" % t) or "")[:7]))
        return
    target = _resolve_commitish(root, getattr(args, "ref", None) or "HEAD")
    if not target:
        die("no commit to tag")
    _set_ref(root, "refs/tags/%s" % args.name, target)
    print("Created tag %s -> %s" % (args.name, target[:7]))


def cmd_git_reset(args):
    root = _need_repo()
    mode = "hard" if getattr(args, "hard", False) else ("soft" if getattr(args, "soft", False) else "mixed")
    target = _resolve_commitish(root, args.ref) if getattr(args, "ref", None) else _head_commit(root)
    if not target:
        die("no such commit: %s" % (getattr(args, "ref", None) or "HEAD"))
    old = _head_commit(root)
    _advance_head(root, target)             # move the branch pointer
    if mode in ("mixed", "hard"):
        _write_index(root, _head_tree_map(root))
    if mode == "hard":
        _checkout_worktree(root, old, target)
    _clear_meta(root, "MERGE_HEAD")
    print("HEAD is now at %s (%s reset)" % (target[:7], mode))


def cmd_git_revert(args):
    root = _need_repo()
    target = _resolve_commitish(root, args.ref)
    if not target:
        die("no such commit: %s" % args.ref)
    c = _read_commit(root, target)
    parent = c["parents"][0] if c["parents"] else None
    after, before = _tree_bytes(root, target), _tree_bytes(root, parent)
    old_head = _head_commit(root)
    new = dict(_tree_bytes(root, old_head))
    for p in set(after) | set(before):      # invert this commit's changes onto HEAD
        if after.get(p) == before.get(p):
            continue
        if before.get(p) is None:
            new.pop(p, None)                # commit added it -> remove
        else:
            new[p] = before[p]              # restore the prior version
    mp = {p: _write_object(root, "blob", d) for p, d in new.items()}
    tree = _build_tree_from_map(root, mp)
    if old_head and _read_commit(root, old_head).get("tree") == tree:
        print("nothing to revert (commit makes no net change)")
        return
    subj = (c["message"] or "").splitlines()[0] if c["message"] else target[:7]
    commit = _write_commit(root, tree, [old_head] if old_head else [],
                           'Revert "%s"' % subj, _author_name())
    _advance_head(root, commit)
    _checkout_worktree(root, old_head, commit)
    _refresh_index(root)
    print("Reverted %s -> %s" % (target[:7], commit[:7]))


def cmd_git_pull(args):
    root = _need_repo()
    pid = _git_remote_id(root, getattr(args, "remote", None))
    branch = getattr(args, "branch", None) or _current_branch(root) or "main"
    refs = api("GET", "/api/v1/projects/%s/git/refs" % pid).get("refs", {})
    remote = refs.get("refs/heads/%s" % branch)
    if not remote:
        die("remote project #%s has no branch '%s'" % (pid, branch))
    _fetch_objects(root, pid, [remote])
    _set_ref(root, "refs/remotes/origin/%s" % branch, remote)
    _merge_into(root, remote, "remote branch '%s'" % branch)


def cmd_git_blame(args):
    import datetime
    import difflib
    root = _need_repo()
    sha = _head_commit(root)
    if not sha:
        die("no commits yet")
    if _tree_bytes(root, sha).get(args.path) is None:
        die("no such file in HEAD: %s" % args.path)
    chain = []
    s = sha
    while s:
        chain.append(s)
        ps = _read_commit(root, s)["parents"]
        s = ps[0] if ps else None
    chain.reverse()
    owners, prev = [], []
    for cs in chain:
        content = _tree_bytes(root, cs).get(args.path)
        if content is None:
            continue
        cur = content.decode("utf-8", "replace").splitlines()
        if not owners:
            owners, prev = [cs] * len(cur), cur
            continue
        new_owners = [cs] * len(cur)
        for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=prev, b=cur).get_opcodes():
            if tag == "equal":
                for k in range(j2 - j1):
                    new_owners[j1 + k] = owners[i1 + k]
        owners, prev = new_owners, cur
    for idx, line in enumerate(prev):
        cs = owners[idx] if idx < len(owners) and owners[idx] else sha
        c = _read_commit(root, cs)
        try:
            date = datetime.datetime.fromtimestamp(c["ts"], datetime.timezone.utc).strftime("%Y-%m-%d")
        except (ValueError, OSError):
            date = ""
        print("%s  (%s %s)  %s" % (cs[:7], c["author"], date, line))


def _git_remote_id(root, explicit=None):
    if explicit:
        return _resolve_project(explicit)["id"]
    man = read_manifest(os.path.join(root, JM_TOML)) or {}
    if man.get("id"):
        return int(man["id"])
    try:
        for line in open(os.path.join(root, ".jm", "config")):
            if line.startswith("remote="):
                return int(line.split("=", 1)[1].strip())
    except OSError:
        pass
    die("no remote project set (add an id to jm.toml, or pass a project)")


def _reachable(root, commit_sha):
    objs, stack = set(), [commit_sha]
    while stack:
        s = stack.pop()
        if s in objs:
            continue
        objs.add(s)
        t, _c = _read_object(root, s)
        if t == "commit":
            c = _read_commit(root, s)
            stack.append(c["tree"])
            stack.extend(c["parents"])
        elif t == "tree":
            for _m, es, _n in _read_tree(root, s):
                stack.append(es)
    return objs


def _fetch_objects(root, pid, want):
    visited, stack, got = set(), list(want), 0
    while stack:
        s = stack.pop()
        if s in visited:
            continue
        visited.add(s)
        p = _obj_path(root, s)
        if not os.path.exists(p):
            resp = request("GET", "/api/v1/projects/%s/git/objects/%s" % (pid, s))
            os.makedirs(os.path.dirname(p), exist_ok=True)
            with open(p, "wb") as fh:
                fh.write(resp.read())
            got += 1
        t, _c = _read_object(root, s)
        if t == "commit":
            c = _read_commit(root, s)
            stack.append(c["tree"])
            stack.extend(c["parents"])
        elif t == "tree":
            for _m, es, _n in _read_tree(root, s):
                stack.append(es)
    return got


def cmd_git_push(args):
    root = _need_repo()
    pid = _git_remote_id(root, getattr(args, "remote", None))
    branch = getattr(args, "branch", None) or _current_branch(root) or "main"
    head = _ref_sha(root, "refs/heads/%s" % branch)
    if not head:
        die("nothing to push on branch %s" % branch)
    refs = api("GET", "/api/v1/projects/%s/git/refs" % pid).get("refs", {})
    old = refs.get("refs/heads/%s" % branch, "")
    objs = list(_reachable(root, head))
    missing = api("POST", "/api/v1/projects/%s/git/have" % pid, json_body={"shas": objs}).get("missing", objs)
    for i in range(0, len(missing), 50):
        batch = []
        for s in missing[i:i + 50]:
            with open(_obj_path(root, s), "rb") as fh:
                batch.append({"sha": s, "data": base64.b64encode(fh.read()).decode()})
        api("POST", "/api/v1/projects/%s/git/objects" % pid, json_body={"objects": batch})
    api("POST", "/api/v1/projects/%s/git/refs" % pid,
        json_body={"name": "refs/heads/%s" % branch, "sha": head, "old_sha": old})
    print("Pushed %s -> project #%s (%d new object(s), %s)" % (branch, pid, len(missing), head[:7]))


def cmd_git_fetch(args):
    root = _need_repo()
    pid = _git_remote_id(root, getattr(args, "remote", None))
    refs = api("GET", "/api/v1/projects/%s/git/refs" % pid).get("refs", {})
    heads = [s for n, s in refs.items() if n.startswith("refs/heads/")]
    got = _fetch_objects(root, pid, heads)
    for n, s in refs.items():
        if n.startswith("refs/heads/"):
            _set_ref(root, "refs/remotes/origin/%s" % n.split("/")[-1], s)
    print("Fetched %d object(s); %d remote branch(es)" % (got, len(heads)))


def cmd_git_clone(args):
    app = _resolve_project(args.target)
    pid = app["id"]
    refs = api("GET", "/api/v1/projects/%s/git/refs" % pid).get("refs", {})
    if not refs:
        die("this project has no git repository (nothing pushed yet)")
    default = "refs/heads/main" if "refs/heads/main" in refs else \
        sorted(n for n in refs if n.startswith("refs/heads/"))[0]
    dest = args.dir or os.path.join(".", app.get("slug") or "project")
    os.makedirs(os.path.join(dest, ".jm", "objects"), exist_ok=True)
    os.makedirs(os.path.join(dest, ".jm", "refs", "heads"), exist_ok=True)
    _fetch_objects(dest, pid, [s for n, s in refs.items() if n.startswith("refs/heads/")])
    for n, s in refs.items():
        if n.startswith("refs/heads/"):
            _set_ref(dest, n, s)
    _write_head(dest, "ref: %s" % default)
    with open(os.path.join(dest, ".jm", "config"), "w") as fh:
        fh.write("remote=%d\n" % pid)
    _checkout_worktree(dest, None, refs[default])
    print("Cloned project #%s into %s (branch %s, %s)"
          % (pid, dest, default.split("/")[-1], refs[default][:7]))


_GIT_ACTIONS = {
    "init": cmd_git_init, "add": cmd_git_add, "commit": cmd_git_commit, "log": cmd_git_log,
    "status": cmd_git_status, "diff": cmd_git_diff, "branch": cmd_git_branch,
    "checkout": cmd_git_checkout, "merge": cmd_git_merge, "blame": cmd_git_blame,
    "tag": cmd_git_tag, "reset": cmd_git_reset, "revert": cmd_git_revert,
    "push": cmd_git_push, "fetch": cmd_git_fetch, "pull": cmd_git_pull, "clone": cmd_git_clone,
}


def cmd_git(args):
    handler = _GIT_ACTIONS.get(getattr(args, "gitaction", None))
    if handler is None:
        die("usage: jm proj git {init|add|commit|log|status|diff|branch|checkout|merge|"
            "tag|reset|revert|blame|push|fetch|pull|clone}")
    return handler(args)


# One unified namespace: `jm proj <action>` dispatches to the existing
# implementation functions. `apps` and the top-level social verbs reuse the
# same handlers — this is one surface, not new logic.
_PROJ_ACTIONS = {
    # lifecycle / source
    "init": lambda a: cmd_project_init(a),
    "push": lambda a: cmd_project_push(a),
    "clone": lambda a: cmd_project_clone(a),
    "checkout": lambda a: cmd_project_checkout(a),
    "pull": lambda a: cmd_project_pull(a),
    "readme": lambda a: cmd_project_readme(a),
    "ls": lambda a: cmd_project_ls(a),
    "cat": lambda a: cmd_project_cat(a),
    "releases": lambda a: cmd_project_releases(a),
    "log": lambda a: cmd_project_log(a),
    "diff": lambda a: cmd_project_diff(a),
    "fork": lambda a: cmd_project_fork(a),
    "forks": lambda a: cmd_project_forks(a),
    "stars": lambda a: cmd_project_stars(a),
    "stargazers": lambda a: cmd_project_stars(a),
    "pulls": lambda a: cmd_project_pulls(a),
    "pr": lambda a: cmd_project_pr(a),
    "pr-create": lambda a: cmd_project_pr_create(a),
    "pr-merge": lambda a: cmd_project_pr_merge(a),
    "pr-close": lambda a: cmd_project_pr_close(a),
    "git": lambda a: cmd_git(a),
    "view": lambda a: cmd_project_view(a),
    "visibility": lambda a: cmd_project_visibility(a),
    "delete": lambda a: cmd_project_delete(a),
    # discovery / install (reuse the apps handlers)
    "install": lambda a: cmd_project_install(a),
    "list": lambda a: cmd_apps_list(a),
    "search": lambda a: cmd_apps_search(a),
    "update": lambda a: cmd_apps_update(a),
    "installed": lambda a: cmd_apps_installed(a),
    "uninstall": lambda a: cmd_apps_uninstall(a),
    # social (reuse the social handlers)
    "star": lambda a: cmd_star(a),
    "unstar": lambda a: cmd_unstar(a),
    "watch": lambda a: cmd_watch(a),
    "unwatch": lambda a: cmd_unwatch(a),
    "issues": lambda a: cmd_issues(a),
    "issue": lambda a: cmd_issue_view(a),
    "issue-create": lambda a: cmd_issue_create(a),
    "issue-comment": lambda a: cmd_issue_comment(a),
    "issue-close": lambda a: cmd_issue_close(a),
    "issue-reopen": lambda a: cmd_issue_reopen(a),
}


def cmd_project(args):
    """Dispatcher for `jm proj <action>` (alias `jm project`)."""
    action = getattr(args, "projaction", None)
    handler = _PROJ_ACTIONS.get(action)
    if handler is None:
        die("usage: jm proj {init | push | clone | checkout | pull | readme | ls | cat | "
            "releases | log | diff | fork | forks | stars | view | visibility | delete | install | list | "
            "search | update | installed | "
            "uninstall | star | unstar | watch | unwatch | issues | issue | "
            "issue-create | issue-comment | issue-close | issue-reopen | "
            "pulls | pr | pr-create | pr-merge | pr-close}")
    return handler(args)


def cmd_project_install(args):
    """`jm proj install <owner/slug | name | id>` — resolve, then install."""
    target = getattr(args, "target", None)
    if not target:
        die("usage: jm proj install <owner/slug | name | id>")
    hit = _resolve_owner_slug(target)
    if hit is not None:
        args.app = str(hit["id"])
    else:
        args.app = target
    return cmd_app_install(args)


def cmd_project_visibility(args):
    """`jm proj visibility <project> <public|private>` — flip who can see it."""
    vis = getattr(args, "visibility", None)
    if vis not in ("public", "private"):
        die("usage: jm proj visibility <project> <public|private>")
    app = _resolve_project(args.target)
    res = api("POST", "/api/v1/projects/%s/visibility" % app["id"], json_body={"visibility": vis})
    new_vis = res.get("visibility", vis)
    print("%s is now %s." % (app.get("name") or app["id"], new_vis))


def cmd_project_delete(args):
    """`jm proj delete <project>` — delete a project you own."""
    app = _resolve_project(args.target)
    if not getattr(args, "yes", False):
        if input("Delete project %s (#%s) and all its releases/files? [y/N] "
                 % (app.get("name") or app["id"], app["id"])).strip().lower() != "y":
            die("aborted")
    api("DELETE", "/api/v1/apps/%s" % app["id"])
    print("Deleted project #%s" % app["id"])


def cmd_project_init(args):
    if os.path.exists(JM_TOML) and not args.force:
        die("%s already exists (use --force to overwrite)" % JM_TOML)
    data = {
        "name": args.name or os.path.basename(os.path.abspath(".")),
        "version": args.version or "0.1.0",
        "category": args.category or "",
        "supported_os": args.os or "",
        "license": args.license or "",
        "description": args.description or "",
        "visibility": "private" if getattr(args, "private", False) else "public",
    }
    write_manifest(data)
    if not os.path.exists(JMIGNORE):
        with open(JMIGNORE, "w", encoding="utf-8") as fh:
            fh.write("\n".join(_DEFAULT_JMIGNORE) + "\n")
    print("Initialized project %r (%s) in %s" % (data["name"], data["visibility"], os.path.abspath(".")))
    print("  wrote %s and %s" % (JM_TOML, JMIGNORE))
    print("Publish it with:  jm proj push")


def cmd_project_push(args):
    import tempfile
    manifest = read_manifest()
    if manifest is None:
        die("no %s here — run `jm project init` first" % JM_TOML)
    installer = getattr(args, "installer", None)
    if installer and not os.path.isfile(installer):
        die("installer file not found: %s" % installer)

    version = args.version or manifest.get("version") or "0.1.0"
    # Resolve requested visibility: --private / --public override the manifest;
    # otherwise None means "unspecified" (don't touch an existing project).
    if getattr(args, "private", False):
        want_vis = "private"
    elif getattr(args, "public", False):
        want_vis = "public"
    else:
        want_vis = None
    tmp = tempfile.mkstemp(prefix="jm-source-", suffix=".zip")
    os.close(tmp[0])
    zip_path = tmp[1]
    try:
        added = build_source_zip(".", zip_path)
        if not added:
            die("nothing to push — every file is ignored by %s" % JMIGNORE)
        files = [("source", zip_path)]
        if installer:
            files.append(("file", installer))

        if not manifest.get("id"):
            # On CREATE: prefer the explicit flag, else the manifest, else public.
            create_vis = want_vis or manifest.get("visibility") or "public"
            fields = {"name": manifest.get("name"), "description": manifest.get("description"),
                      "version": version, "category": manifest.get("category"),
                      "supported_os": manifest.get("supported_os"),
                      "license_type": manifest.get("license"),
                      "visibility": create_vis}
            res = api("POST", "/api/v1/apps", files=files, fields=fields)
            app = res["app"]
            manifest["id"] = str(app["id"])
            if app.get("owner"):
                manifest["owner"] = str(app["owner"])
            if app.get("slug"):
                manifest["slug"] = str(app["slug"])
            manifest["version"] = version
            manifest["visibility"] = create_vis
            write_manifest(manifest)
            where = "%s/%s" % (manifest.get("owner") or "?", manifest.get("slug") or app.get("name"))
            print("Created project #%s (%s) at v%s [%s]" % (app["id"], where, version, create_vis))
            print("  %d file(s) uploaded as source; manifest updated with id/owner/slug." % len(added))
        else:
            fields = {"version": version, "notes": args.notes,
                      "highlights": ("\n".join(args.highlight) if args.highlight else None)}
            res = api("POST", "/api/v1/apps/%s/releases" % manifest["id"], files=files, fields=fields)
            if args.version:
                manifest["version"] = version
                write_manifest(manifest)
            slug = manifest.get("slug") or manifest.get("name")
            print("Published %s release v%s (%d source file(s))." % (slug, version, len(added)))
            # On an existing project, an explicit --private/--public also flips visibility.
            if want_vis is not None:
                api("POST", "/api/v1/projects/%s/visibility" % manifest["id"],
                    json_body={"visibility": want_vis})
                manifest["visibility"] = want_vis
                write_manifest(manifest)
                print("  visibility set to %s." % want_vis)
    finally:
        try:
            os.remove(zip_path)
        except OSError:
            pass


def _safe_extract_zip(zf, dest):
    """Extract a zip guarding against zip-slip (skip .. and absolute paths)."""
    dest = os.path.abspath(dest)
    for member in zf.namelist():
        name = member.replace("\\", "/")
        if name.startswith("/") or (len(name) > 1 and name[1] == ":"):
            sys.stderr.write("skipped absolute path in zip: %s\n" % name)
            continue
        target = os.path.abspath(os.path.join(dest, name))
        if target != dest and not target.startswith(dest + os.sep):
            sys.stderr.write("skipped unsafe path in zip: %s\n" % name)
            continue
        if name.endswith("/"):
            os.makedirs(target, exist_ok=True)
            continue
        os.makedirs(os.path.dirname(target), exist_ok=True)
        with zf.open(member) as src, open(target, "wb") as out:
            while True:
                chunk = src.read(65536)
                if not chunk:
                    break
                out.write(chunk)


def cmd_project_clone(args):
    import tempfile
    import zipfile
    app = _resolve_project(args.target)
    slug = app.get("slug") or _safe_name(app.get("name"))
    dest = args.dir or os.path.join(".", slug)
    if os.path.exists(dest) and os.listdir(dest):
        die("destination %s already exists and is not empty" % dest)

    params = {"version": args.version} if args.version else None
    resp = request("GET", "/api/v1/projects/%s/source" % app["id"], params=params)
    if "application/json" in resp.headers.get("Content-Type", ""):
        data = json.loads(resp.read() or b"{}")
        die(data.get("message") or data.get("error") or "this project has no source to clone")
    fd, tmpzip = tempfile.mkstemp(prefix="jm-clone-", suffix=".zip")
    try:
        with os.fdopen(fd, "wb") as fh:
            while True:
                chunk = resp.read(65536)
                if not chunk:
                    break
                fh.write(chunk)
        os.makedirs(dest, exist_ok=True)
        with zipfile.ZipFile(tmpzip) as zf:
            _safe_extract_zip(zf, dest)
    finally:
        try:
            os.remove(tmpzip)
        except OSError:
            pass

    manifest = {
        "name": app.get("name"),
        "version": args.version or app.get("version") or "0.1.0",
        "category": app.get("category") or "",
        "supported_os": app.get("supported_os") or "",
        "license": app.get("license_type") or "",
        "description": app.get("description") or "",
        "id": str(app["id"]),
    }
    if app.get("owner"):
        manifest["owner"] = str(app["owner"])
    if app.get("slug"):
        manifest["slug"] = str(app["slug"])
    write_manifest(manifest, os.path.join(dest, JM_TOML))
    print("Cloned %s into %s" % (manifest["name"], os.path.abspath(dest)))


def cmd_project_ls(args):
    app = _resolve_project(args.target)
    params = {"version": args.version} if args.version else None
    res = api("GET", "/api/v1/projects/%s/tree" % app["id"], params=params)
    if getattr(args, "json", False):
        print(json.dumps(res))
        return
    for f in res.get("files", []):
        print(f["path"])


def cmd_project_cat(args):
    app = _resolve_project(args.target)
    params = {"path": args.path}
    if args.version:
        params["version"] = args.version
    resp = request("GET", "/api/v1/projects/%s/raw" % app["id"], params=params)
    if "application/json" in resp.headers.get("Content-Type", ""):
        data = json.loads(resp.read() or b"{}")
        die(data.get("message") or data.get("error") or "no such file")
    out = getattr(sys.stdout, "buffer", None)
    while True:
        chunk = resp.read(65536)
        if not chunk:
            break
        if out is not None:
            out.write(chunk)
        else:
            sys.stdout.write(chunk.decode("utf-8", "replace"))
    if out is not None:
        out.flush()


def cmd_project_releases(args):
    app = _resolve_project(args.target)
    releases = app.get("releases", [])
    if getattr(args, "json", False):
        print(json.dumps(releases))
        return
    rows = [["v" + r["version"], r.get("file_size") or "-", r.get("download_count", 0),
             "yes" if r.get("has_source") else "no"] for r in releases]
    table(rows, ["VERSION", "SIZE", "DOWNLOADS", "SOURCE"])


def cmd_project_view(args):
    app = _resolve_project(args.target)
    if getattr(args, "json", False):
        print(json.dumps(app))
        return
    owner = app.get("owner")
    slug = app.get("slug") or _safe_name(app.get("name"))
    where = ("%s/%s" % (owner, slug)) if owner else slug
    downloads = sum(r.get("download_count", 0) for r in app.get("releases", []))
    print("%s  (%s)" % (app.get("name"), where))
    print("  version:   v%s" % app.get("version"))
    print("  category:  %s" % (app.get("category") or "-"))
    print("  OS:        %s" % (app.get("supported_os") or "-"))
    print("  license:   %s" % (app.get("license_type") or "-"))
    print("  downloads: %d" % downloads)


# --------------------------------------------------------------------------- #
# commands: social (star / watch / issues / notifications)
# --------------------------------------------------------------------------- #
def cmd_star(args):
    app = _resolve_project(args.target)
    res = api("POST", "/api/v1/apps/%s/star" % app["id"])
    print("Starred %s — %d star(s)." % (app.get("name") or app["id"], res.get("star_count", 0)))


def cmd_unstar(args):
    app = _resolve_project(args.target)
    res = api("DELETE", "/api/v1/apps/%s/star" % app["id"])
    print("Unstarred %s — %d star(s)." % (app.get("name") or app["id"], res.get("star_count", 0)))


def cmd_watch(args):
    app = _resolve_project(args.target)
    res = api("POST", "/api/v1/apps/%s/watch" % app["id"], json_body={"level": args.level})
    print("Watching %s (level: %s)." % (app.get("name") or app["id"], res.get("level", args.level)))


def cmd_unwatch(args):
    app = _resolve_project(args.target)
    api("DELETE", "/api/v1/apps/%s/watch" % app["id"])
    print("No longer watching %s." % (app.get("name") or app["id"]))


def cmd_issues(args):
    app = _resolve_project(args.target)
    res = api("GET", "/api/v1/apps/%s/issues" % app["id"], params={"state": args.state})
    issues = res.get("issues", [])
    if getattr(args, "json", False):
        print(json.dumps(res))
        return
    rows = [["#%s" % i["number"], i["state"], (i["title"] or "")[:52], i.get("author") or "-"]
            for i in issues]
    table(rows, ["NUMBER", "STATE", "TITLE", "AUTHOR"])


def cmd_issue_view(args):
    app = _resolve_project(args.target)
    res = api("GET", "/api/v1/apps/%s/issues/%s" % (app["id"], args.number))
    if getattr(args, "json", False):
        print(json.dumps(res))
        return
    issue = res.get("issue", {})
    print("#%s  %s   [%s]" % (issue.get("number"), issue.get("title"), issue.get("state")))
    meta = "by %s · %s" % (issue.get("author") or "-", (issue.get("created_at") or "")[:16].replace("T", " "))
    if not issue.get("is_approved", True):
        meta += " · (pending approval)"
    print(meta)
    print()
    print((issue.get("body") or "").strip())
    comments = issue.get("comments", [])
    if comments:
        print()
        print("Comments (%d):" % len(comments))
        for c in comments:
            print()
            print("  %s · %s" % (c.get("author") or "-", (c.get("created_at") or "")[:16].replace("T", " ")))
            for ln in (c.get("body") or "").strip().splitlines():
                print("    %s" % ln)


def _read_body(args, allow_stdin_pipe=False):
    """Read an issue/comment body: --body - (or a piped stdin) reads from stdin."""
    b = getattr(args, "body", None)
    if b == "-":
        return sys.stdin.read()
    if b is not None:
        return b
    if allow_stdin_pipe and not sys.stdin.isatty():
        data = sys.stdin.read()
        return data if data.strip() else None
    return None


def cmd_issue_create(args):
    app = _resolve_project(args.target)
    body = _read_body(args, allow_stdin_pipe=True)
    payload = {"title": args.title}
    if body is not None:
        payload["body"] = body
    res = api("POST", "/api/v1/apps/%s/issues" % app["id"], json_body=payload)
    issue = res.get("issue", {})
    print("Opened issue #%s \"%s\". %s" % (issue.get("number"), issue.get("title"), res.get("message", "")))


def cmd_issue_comment(args):
    app = _resolve_project(args.target)
    body = _read_body(args, allow_stdin_pipe=True)
    if not body or not body.strip():
        die("provide --body TEXT, or pipe the comment on stdin (--body -)")
    res = api("POST", "/api/v1/apps/%s/issues/%s/comments" % (app["id"], args.number),
              json_body={"body": body})
    if res.get("approved", True):
        print("Commented on issue #%s." % args.number)
    else:
        print("Comment on issue #%s submitted (pending approval)." % args.number)


def _issue_set_state(args, state):
    app = _resolve_project(args.target)
    api("POST", "/api/v1/apps/%s/issues/%s/state" % (app["id"], args.number), json_body={"state": state})
    print("Issue #%s %s." % (args.number, "reopened" if state == "open" else "closed"))


def cmd_issue_close(args):
    _issue_set_state(args, "closed")


def cmd_issue_reopen(args):
    _issue_set_state(args, "open")


def cmd_notifications(args):
    # `jm notifications read [--all]` marks notifications read.
    if getattr(args, "notifaction", None) == "read":
        api("POST", "/api/v1/notifications/read", json_body={})
        print("Marked all notifications read.")
        return
    params = {"limit": args.limit}
    if getattr(args, "unread", False):
        params["unread"] = 1
    res = api("GET", "/api/v1/notifications", params=params)
    if getattr(args, "json", False):
        print(json.dumps(res))
        return
    notes = res.get("notifications", [])
    if not notes:
        print("(no notifications)")
        return
    for n in notes:
        mark = " " if n.get("is_read") else "●"
        when = (n.get("created_at") or "")[:16].replace("T", " ")
        line = "%s %s  %s" % (mark, when, n.get("text") or "")
        if n.get("link"):
            line += "  %s" % n["link"]
        print(line)
    print("\n%d unread." % res.get("unread", 0))


def cmd_link(args):
    import socket
    import subprocess
    import time
    import re as _re
    cfg = load_config()
    name = args.name or cfg.get("device_name") or socket.gethostname() or "device"
    res = api("POST", "/api/v1/devices/register", json_body={"name": name, "platform": sys.platform})
    did = res["device_id"]
    cfg["device_id"] = did
    cfg["device_name"] = res["name"]
    save_config(cfg)
    print("Linked as device #%d (%s)." % (did, res["name"]))
    print("Waiting for install jobs from the website's Direct Install button… (Ctrl-C to stop)")
    self_cmd = [sys.executable, os.path.abspath(sys.argv[0])]
    while True:
        try:
            r = api("GET", "/api/v1/devices/poll", params={"device": did, "wait": 25})
        except SystemExit:
            time.sleep(3); continue
        except Exception:
            time.sleep(3); continue
        job = r.get("job") if isinstance(r, dict) else None
        if not job:
            continue
        action = job.get("action", "install")
        app_id = job["app_id"]
        jid = job["id"]
        sub = "uninstall" if action == "uninstall" else "install"
        print("→ %s %s (app #%s)" % (action, job.get("app_name") or "", app_id))
        proc = subprocess.run(self_cmd + ["apps", sub, str(app_id), "-y"], capture_output=True, text=True)
        log = (proc.stdout or "") + (proc.stderr or "")
        needs_admin = proc.returncode != 0 and bool(
            _re.search(r"sudo|permission|terminal is required|must be root|operation not permitted", log, _re.I))
        try:
            api("POST", "/api/v1/devices/jobs/%d" % jid,
                json_body={"result_code": proc.returncode, "log": log[-8000:], "needs_admin": needs_admin})
        except Exception:
            pass
        print("  %s (code %d)" % ("done" if proc.returncode == 0
                                  else ("needs admin — run locally" if needs_admin else "failed"), proc.returncode))


def cmd_devices(args):
    res = api("GET", "/api/v1/devices")
    rows = [[d["id"], d["name"], "online" if d["online"] else "offline",
             (d.get("last_seen_at") or "")[:16].replace("T", " ")] for d in res["devices"]]
    table(rows, ["ID", "NAME", "STATUS", "LAST SEEN"])


def cmd_unlink(args):
    api("DELETE", "/api/v1/devices/%d" % args.id)
    print("unlinked device #%d" % args.id)


def cmd_search(args):
    if getattr(args, "web", False):
        res = api("GET", "/api/v1/search", auth=False, params={"q": args.query, "limit": args.limit, "web": 1})
        rows = res.get("web", [])
        if not rows:
            print("No web results for %r" % args.query); return
        for r in rows:
            print("%s" % (r.get("title") or r["url"])[:90])
            print("  %s" % r["url"])
            if r.get("snippet"):
                print("  %s" % r["snippet"][:120])
            print("")
        return
    res = api("GET", "/api/v1/search", auth=False, params={"q": args.query, "limit": args.limit})
    apps = res.get("apps", []); arts = res.get("articles", []); users = res.get("users", [])
    if apps:
        print("Apps")
        table([[a["id"], a["name"], a["version"], a["category"]] for a in apps], ["ID", "NAME", "VERSION", "CATEGORY"])
    if arts:
        if apps:
            print("")
        print("Articles")
        table([[a["id"], a["title"][:48], a["category"], a["views"]] for a in arts], ["ID", "TITLE", "CATEGORY", "VIEWS"])
    if users:
        if apps or arts:
            print("")
        print("People")
        table([[u["id"], u["name"], "@" + u["username"]] for u in users], ["ID", "NAME", "USERNAME"])
    if not (apps or arts or users):
        print("No results for %r" % args.query)


# ---- run `jm link` as a background service (systemd / launchd / Task Scheduler) ----
def _jm_exec():
    return [sys.executable, os.path.abspath(sys.argv[0]), "link"]


def _try(cmd):
    try:
        return subprocess.run(cmd, capture_output=True, text=True).returncode == 0
    except FileNotFoundError:
        return False


_LINUX_UNIT = os.path.expanduser("~/.config/systemd/user/jm-link.service")
_MAC_PLIST = os.path.expanduser("~/Library/LaunchAgents/com.jmtechpower.jmlink.plist")


def cmd_service(args):
    action = getattr(args, "svcaction", None)
    if action not in ("install", "uninstall", "status"):
        die("usage: jm service {install | uninstall | status}")
    if sys.platform.startswith("linux"):
        _service_linux(action)
    elif sys.platform == "darwin":
        _service_macos(action)
    elif os.name == "nt":
        _service_windows(action)
    else:
        die("unsupported platform: %s" % sys.platform)


def _service_linux(action):
    if action == "status":
        active = _try(["systemctl", "--user", "is-active", "jm-link.service"])
        print("jm-link service: %s | unit: %s" % ("active" if active else "not active",
              "present" if os.path.exists(_LINUX_UNIT) else "missing"))
        return
    if action == "uninstall":
        _try(["systemctl", "--user", "disable", "--now", "jm-link.service"])
        if os.path.exists(_LINUX_UNIT):
            os.remove(_LINUX_UNIT)
        _try(["systemctl", "--user", "daemon-reload"])
        print("Removed jm-link service.")
        return
    unit = ("[Unit]\n"
            "Description=JM Tech Power device agent (jm link)\n"
            "After=network-online.target\n\n"
            "[Service]\n"
            "ExecStart=%s\n"
            "Restart=always\nRestartSec=5\n\n"
            "[Install]\nWantedBy=default.target\n" % " ".join(_jm_exec()))
    os.makedirs(os.path.dirname(_LINUX_UNIT), exist_ok=True)
    with open(_LINUX_UNIT, "w") as f:
        f.write(unit)
    print("Wrote %s" % _LINUX_UNIT)
    ok = _try(["systemctl", "--user", "daemon-reload"]) and \
        _try(["systemctl", "--user", "enable", "--now", "jm-link.service"])
    _try(["loginctl", "enable-linger", os.environ.get("USER", "")])  # keep running after logout/reboot
    if ok:
        print("Installed + started. Follow logs:  journalctl --user -u jm-link -f")
    else:
        print("Unit written, but couldn't enable it automatically. Run:")
        print("  systemctl --user daemon-reload && systemctl --user enable --now jm-link.service")
    print("Note: run `jm auth login` first so the agent has a token.")


def _service_macos(action):
    label = "com.jmtechpower.jmlink"
    if action == "status":
        loaded = _try(["launchctl", "list", label])
        print("jm-link service: %s | plist: %s" % ("loaded" if loaded else "not loaded",
              "present" if os.path.exists(_MAC_PLIST) else "missing"))
        return
    if action == "uninstall":
        _try(["launchctl", "unload", "-w", _MAC_PLIST])
        if os.path.exists(_MAC_PLIST):
            os.remove(_MAC_PLIST)
        print("Removed jm-link service.")
        return
    args_xml = "".join("    <string>%s</string>\n" % a for a in _jm_exec())
    plist = ('<?xml version="1.0" encoding="UTF-8"?>\n'
             '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
             '<plist version="1.0"><dict>\n'
             '  <key>Label</key><string>%s</string>\n'
             '  <key>ProgramArguments</key><array>\n%s  </array>\n'
             '  <key>RunAtLoad</key><true/>\n'
             '  <key>KeepAlive</key><true/>\n'
             '</dict></plist>\n' % (label, args_xml))
    os.makedirs(os.path.dirname(_MAC_PLIST), exist_ok=True)
    with open(_MAC_PLIST, "w") as f:
        f.write(plist)
    print("Wrote %s" % _MAC_PLIST)
    _try(["launchctl", "unload", _MAC_PLIST])
    if _try(["launchctl", "load", "-w", _MAC_PLIST]):
        print("Installed + started (launchd).")
    else:
        print("Plist written; load it with:  launchctl load -w %s" % _MAC_PLIST)
    print("Note: run `jm auth login` first so the agent has a token.")


def _service_windows(action):
    task = "JMLink"
    if action == "status":
        print("jm-link scheduled task: %s" % ("present" if _try(["schtasks", "/Query", "/TN", task]) else "missing"))
        return
    if action == "uninstall":
        _try(["schtasks", "/End", "/TN", task])
        _try(["schtasks", "/Delete", "/TN", task, "/F"])
        print("Removed scheduled task %s." % task)
        return
    tr = '"%s" "%s" link' % (sys.executable, os.path.abspath(sys.argv[0]))
    if _try(["schtasks", "/Create", "/TN", task, "/SC", "ONLOGON", "/TR", tr, "/F", "/RL", "LIMITED"]):
        _try(["schtasks", "/Run", "/TN", task])
        print("Installed scheduled task %s (runs at logon)." % task)
    else:
        print("Could not create the task. Run in PowerShell:")
        print('  schtasks /Create /TN %s /SC ONLOGON /TR "%s" /F' % (task, tr))
    print("Note: run `jm auth login` first so the agent has a token.")


# --------------------------------------------------------------------------- #
# parser
# --------------------------------------------------------------------------- #
def build_parser():
    p = argparse.ArgumentParser(prog="jm", description="JM Tech Power command-line client")
    p.add_argument("--version", action="version", version="jm %s" % VERSION)
    sub = p.add_subparsers(dest="command")

    # auth
    auth = sub.add_parser("auth", help="authenticate").add_subparsers(dest="sub")
    a_login = auth.add_parser("login", help="log in (opens browser to Authorize, like gh)")
    a_login.add_argument("-u", "--username", help="use password login with this username")
    a_login.add_argument("--password", action="store_true", help="use username + password instead of the browser")
    a_login.add_argument("--host", help="API host (default %s)" % DEFAULT_HOST)
    a_login.add_argument("--name", help="token / device name")
    a_login.add_argument("--web", action="store_true", help="force the browser Authorize flow (this is the default)")
    a_login.add_argument("--with-token", action="store_true", help="paste an existing token instead")
    a_login.set_defaults(func=cmd_auth_login)
    auth.add_parser("logout", help="clear the stored token").set_defaults(func=cmd_auth_logout)
    auth.add_parser("status", help="show current login").set_defaults(func=cmd_whoami)

    sub.add_parser("whoami", help="show the logged-in user").set_defaults(func=cmd_whoami)
    acc = sub.add_parser("account", help="show or update your account settings")
    acc.add_argument("--nickname")
    acc.add_argument("--bio")
    acc.set_defaults(func=cmd_account)
    sub.add_parser("readme", help="print the full documentation (README)").set_defaults(func=cmd_readme)
    sub.add_parser("help", help="print the full documentation (README)").set_defaults(func=cmd_readme)
    for _name in ("update", "upgrade"):
        _u = sub.add_parser(_name, help="update jm to the latest version")
        _u.add_argument("--check", action="store_true", help="only check, don't install")
        _u.set_defaults(func=cmd_update)

    # apps — deprecated alias for `jm proj`
    apps = sub.add_parser("apps", help="[deprecated] alias for `jm proj`")
    apps.add_argument("action", nargs="?", default="list",
                      choices=["list", "search", "install", "update", "installed", "uninstall"])
    apps.add_argument("target", nargs="?", help="app name or id (for: apps install / uninstall / search)")
    apps.add_argument("--mine", action="store_true", help="only your apps")
    apps.add_argument("--search", help="search query")
    apps.add_argument("-y", "--yes", action="store_true", help="don't prompt before running an installer")
    apps.add_argument("--dir", help="download directory (default ~/Downloads)")
    apps.add_argument("--download-only", action="store_true", help="download without installing")
    apps.add_argument("--check", action="store_true", help="for 'update': list updates without installing")
    apps.add_argument("--keep-files", action="store_true", help="for 'uninstall': only untrack, don't delete from disk")
    apps.add_argument("--install-path", help="for 'install': the path this app installs to (used by uninstall)")
    apps.add_argument("--run", help="for 'install': run this file inside the package after extracting")
    apps.add_argument("--json", action="store_true", help="for 'installed': output JSON")
    apps.set_defaults(func=cmd_apps)

    app_p = sub.add_parser("app", help="work with a single app").add_subparsers(dest="sub")
    v = app_p.add_parser("view", help="show an app + releases")
    v.add_argument("id", type=int)
    v.set_defaults(func=cmd_app_view)
    ins = app_p.add_parser("install", help="download + install an app by name or id")
    ins.add_argument("app", help="app name or id")
    ins.add_argument("-y", "--yes", action="store_true", help="don't prompt before running the installer")
    ins.add_argument("--dir", help="download directory (default ~/Downloads)")
    ins.add_argument("--download-only", action="store_true", help="download without installing")
    ins.add_argument("--install-path", help="path this app installs to (used by uninstall)")
    ins.add_argument("--run", help="run this file inside the package after extracting")
    ins.set_defaults(func=cmd_app_install)
    c = app_p.add_parser("create", help="publish a new app (pending approval)")
    for flag in ["name", "description", "version", "category"]:
        c.add_argument("--" + flag, required=True)
    c.add_argument("--os", required=True, help="supported OS")
    c.add_argument("--license", required=True)
    c.add_argument("--developer-name")
    c.add_argument("--developer-email")
    c.add_argument("--file", help="installer file to upload")
    c.add_argument("--url", help="external download URL (instead of --file)")
    c.add_argument("--notes", help="release notes for the first release")
    c.add_argument("--install-path", help="where this app installs (so users can fully uninstall it)")
    c.add_argument("--installer-path", help="installer file inside a zip/archive to run on install (e.g. setup.exe)")
    c.add_argument("--uninstaller-path", help="uninstaller to run on `jm apps uninstall` (e.g. /opt/app/uninstall.sh)")
    c.set_defaults(func=cmd_app_create)
    d = app_p.add_parser("delete", help="delete an app (with releases + files)")
    d.add_argument("id", type=int)
    d.add_argument("-y", "--yes", action="store_true")
    d.set_defaults(func=cmd_app_delete)

    rel = sub.add_parser("release", help="manage releases").add_subparsers(dest="sub")
    rc = rel.add_parser("create", help="publish a new release for an app")
    rc.add_argument("app_id", type=int)
    rc.add_argument("--version", required=True)
    rc.add_argument("--file", help="installer file to upload")
    rc.add_argument("--url", help="external download URL")
    rc.add_argument("--notes")
    rc.add_argument("--highlight", action="append", help="a 'What's Changed' bullet (repeatable)")
    rc.set_defaults(func=cmd_release_create)
    rd = rel.add_parser("download", help="download a release file")
    rd.add_argument("id", type=int)
    rd.add_argument("-o", "--out", help="output path")
    rd.set_defaults(func=cmd_release_download)

    # drive
    drv = sub.add_parser("drive", help="JMDrive storage").add_subparsers(dest="sub")
    ls = drv.add_parser("ls", help="list files in a folder (root by default)")
    ls.add_argument("folder", nargs="?", type=int)
    ls.set_defaults(func=cmd_drive_ls)
    up = drv.add_parser("upload", help="upload one or more files")
    up.add_argument("paths", nargs="+")
    up.add_argument("--folder", type=int, help="destination folder id")
    up.add_argument("--public", action="store_true", help="make public + create share link")
    up.set_defaults(func=cmd_drive_upload)
    mk = drv.add_parser("mkdir", help="create a folder")
    mk.add_argument("name")
    mk.add_argument("--parent", type=int)
    mk.add_argument("--public", action="store_true")
    mk.set_defaults(func=cmd_drive_mkdir)
    dd = drv.add_parser("download", help="download a file (or a folder as zip)")
    dd.add_argument("id", type=int)
    dd.add_argument("-o", "--out")
    dd.set_defaults(func=cmd_drive_download)
    sh = drv.add_parser("share", help="make a file/folder public and print its link")
    sh.add_argument("id", type=int)
    sh.add_argument("--password")
    sh.add_argument("--limit", type=int, help="max downloads (0 = unlimited)")
    sh.set_defaults(func=cmd_drive_share)
    rm = drv.add_parser("rm", help="delete a file or folder")
    rm.add_argument("id", type=int)
    rm.add_argument("-y", "--yes", action="store_true")
    rm.set_defaults(func=cmd_drive_rm)

    # chat
    chat = sub.add_parser("chat", help="messages").add_subparsers(dest="sub")
    chat.add_parser("groups", help="list your chat groups").set_defaults(func=cmd_chat_groups)
    cr = chat.add_parser("read", help="read a conversation")
    cr.add_argument("--with", dest="with_user", type=int, help="user id for a direct message")
    cr.add_argument("--group", type=int, help="group id")
    cr.add_argument("--limit", type=int, default=30)
    cr.set_defaults(func=cmd_chat_read)
    cs = chat.add_parser("send", help="send a message")
    cs.add_argument("message")
    cs.add_argument("--to", help="recipient username or id")
    cs.add_argument("--group", type=int, help="group id")
    cs.set_defaults(func=cmd_chat_send)

    # articles
    arts = sub.add_parser("articles", help="list articles")
    arts.add_argument("action", nargs="?", default="list", choices=["list"])
    arts.add_argument("--mine", action="store_true", help="include your drafts + unpublished")
    arts.add_argument("--search")
    arts.add_argument("--category")
    arts.set_defaults(func=cmd_articles_list)

    art = sub.add_parser("article", help="work with a single article").add_subparsers(dest="sub")
    av = art.add_parser("view", help="show an article")
    av.add_argument("id", type=int)
    av.add_argument("--raw", action="store_true", help="print raw HTML instead of stripped text")
    av.set_defaults(func=cmd_article_view)
    ac = art.add_parser("create", help="publish a new article")
    ac.add_argument("--title", required=True)
    ac.add_argument("--category", required=True)
    ac.add_argument("--content", help="article body (HTML/text); or use --file or stdin")
    ac.add_argument("--file", help="read the body from a file (use - for stdin)")
    ac.add_argument("--summary")
    ac.add_argument("--tags", help="comma-separated")
    ac.add_argument("--draft", action="store_true", help="save unpublished")
    ac.set_defaults(func=cmd_article_create)
    ae = art.add_parser("edit", help="update an article")
    ae.add_argument("id", type=int)
    ae.add_argument("--title")
    ae.add_argument("--category")
    ae.add_argument("--content")
    ae.add_argument("--file", help="read the new body from a file (use - for stdin)")
    ae.add_argument("--summary")
    ae.add_argument("--tags")
    ae.add_argument("--publish", action="store_true", help="mark as published")
    ae.add_argument("--unpublish", action="store_true", help="mark as draft")
    ae.set_defaults(func=cmd_article_edit)
    adl = art.add_parser("delete", help="delete an article")
    adl.add_argument("id", type=int)
    adl.add_argument("-y", "--yes", action="store_true")
    adl.set_defaults(func=cmd_article_delete)

    # admin (official account only)
    adm = sub.add_parser("admin", help="official-account admin: approve apps, stats, users").add_subparsers(dest="adminaction")
    adm.add_parser("stats", help="site stats").set_defaults(func=cmd_admin)
    adm.add_parser("apps", help="list all apps incl. pending").set_defaults(func=cmd_admin)
    adm.add_parser("users", help="list users").set_defaults(func=cmd_admin)
    aap = adm.add_parser("approve", help="approve an app"); aap.add_argument("id", type=int); aap.set_defaults(func=cmd_admin)
    arv = adm.add_parser("revoke", help="send an app back to pending"); arv.add_argument("id", type=int); arv.set_defaults(func=cmd_admin)
    adl = adm.add_parser("delete", help="delete an app"); adl.add_argument("id", type=int); adl.add_argument("-y", "--yes", action="store_true"); adl.set_defaults(func=cmd_admin)

    # token
    tok = sub.add_parser("token", help="manage API tokens").add_subparsers(dest="sub")
    tok.add_parser("list", help="list active tokens").set_defaults(func=cmd_token_list)
    tc = tok.add_parser("create", help="create a token")
    tc.add_argument("--name")
    tc.set_defaults(func=cmd_token_create)
    tr = tok.add_parser("revoke", help="revoke a token")
    tr.add_argument("id", type=int)
    tr.set_defaults(func=cmd_token_revoke)

    # project — THE unified namespace. `jm proj <action>`; `jm project` is an alias.
    _TGT_HELP = "project as owner/slug, name, or id"
    for _pname in ("proj", "project"):
        prj_p = sub.add_parser(
            _pname, help="projects: publish, browse, install, star/issues (one namespace)")
        prj_p.set_defaults(func=cmd_project)
        prj = prj_p.add_subparsers(dest="projaction")

        # ---- lifecycle / source ----
        pi = prj.add_parser("init", help="scaffold jm.toml + .jmignore (local, no network)")
        pi.add_argument("--name")
        pi.add_argument("--version")
        pi.add_argument("--category")
        pi.add_argument("--os", help="supported OS")
        pi.add_argument("--license")
        pi.add_argument("--description")
        pi.add_argument("--force", action="store_true", help="overwrite an existing jm.toml")
        pi.add_argument("--private", action="store_true", help="make the project private")
        pi.add_argument("--public", action="store_true", help="make the project public (default)")
        pi.set_defaults(func=cmd_project)

        pp = prj.add_parser("push", help="zip the source (respecting .jmignore) and publish")
        pp.add_argument("--version", help="release version (default: manifest version)")
        pp.add_argument("--notes", help="release notes")
        pp.add_argument("--highlight", action="append", help="a 'What's Changed' bullet (repeatable)")
        pp.add_argument("--installer", help="also attach a downloadable installer file")
        pp.add_argument("--private", action="store_true", help="create/set the project private")
        pp.add_argument("--public", action="store_true", help="create/set the project public (default)")
        pp.set_defaults(func=cmd_project)

        pc = prj.add_parser("clone", help="download a project's source and unpack it")
        pc.add_argument("target", help=_TGT_HELP)
        pc.add_argument("--dir", help="destination directory (default ./<slug>)")
        pc.add_argument("--version", help="source version to clone")
        pc.set_defaults(func=cmd_project)

        pl = prj.add_parser("ls", help="list a project's source files (like git ls-files)")
        pl.add_argument("target", help=_TGT_HELP)
        pl.add_argument("--version")
        pl.add_argument("--json", action="store_true")
        pl.set_defaults(func=cmd_project)

        pca = prj.add_parser("cat", help="print a project source file to stdout")
        pca.add_argument("target", help=_TGT_HELP)
        pca.add_argument("path", help="file path within the project")
        pca.add_argument("--version")
        pca.set_defaults(func=cmd_project)

        prl = prj.add_parser("releases", help="list a project's releases")
        prl.add_argument("target", help=_TGT_HELP)
        prl.add_argument("--json", action="store_true")
        prl.set_defaults(func=cmd_project)

        pv = prj.add_parser("view", help="show a project summary")
        pv.add_argument("target", help=_TGT_HELP)
        pv.add_argument("--json", action="store_true")
        pv.set_defaults(func=cmd_project)

        plog = prj.add_parser("log", help="release history, git-log style")
        plog.add_argument("target", help=_TGT_HELP)
        plog.add_argument("-n", "--num", type=int, dest="n", default=None)
        plog.add_argument("--json", action="store_true")
        plog.set_defaults(func=cmd_project)

        pdiff = prj.add_parser("diff", help="diff source between two releases")
        pdiff.add_argument("target", help=_TGT_HELP)
        pdiff.add_argument("v1", nargs="?", default=None)
        pdiff.add_argument("v2", nargs="?", default=None)
        pdiff.add_argument("--stat", action="store_true")
        pdiff.add_argument("--name-only", dest="name_only", action="store_true")
        pdiff.set_defaults(func=cmd_project)

        pfork = prj.add_parser("fork", help="fork a project into your account")
        pfork.add_argument("target", help=_TGT_HELP)
        pfork.add_argument("--name", help="name for the fork (default: same name)")
        pfork.set_defaults(func=cmd_project)

        pforks = prj.add_parser("forks", help="list a project's forks")
        pforks.add_argument("target", help=_TGT_HELP)
        pforks.add_argument("--json", action="store_true")
        pforks.set_defaults(func=cmd_project)

        pstars = prj.add_parser("stars", aliases=["stargazers"], help="list a project's stargazers")
        pstars.add_argument("target", help=_TGT_HELP)
        pstars.add_argument("--json", action="store_true")
        pstars.set_defaults(func=cmd_project)

        ppulls = prj.add_parser("pulls", help="list pull requests")
        ppulls.add_argument("target", help=_TGT_HELP)
        ppulls.add_argument("--state", choices=["open", "closed", "merged"])
        ppulls.add_argument("--json", action="store_true")
        ppulls.set_defaults(func=cmd_project)

        ppr = prj.add_parser("pr", help="view a pull request (with source diff)")
        ppr.add_argument("target", help=_TGT_HELP)
        ppr.add_argument("number", type=int)
        ppr.add_argument("--json", action="store_true")
        ppr.set_defaults(func=cmd_project)

        pprc = prj.add_parser("pr-create", help="open a pull request from a fork")
        pprc.add_argument("target", help="base project (owner/name|id)")
        pprc.add_argument("--from", dest="frm", required=True,
                          help="head fork: owner/slug[@version] or id[@version]")
        pprc.add_argument("--title", required=True)
        pprc.add_argument("--body", help="body text, or - for stdin")
        pprc.set_defaults(func=cmd_project)

        pprm = prj.add_parser("pr-merge", help="merge a pull request (base owner)")
        pprm.add_argument("target", help=_TGT_HELP)
        pprm.add_argument("number", type=int)
        pprm.set_defaults(func=cmd_project)

        pprx = prj.add_parser("pr-close", help="close a pull request")
        pprx.add_argument("target", help=_TGT_HELP)
        pprx.add_argument("number", type=int)
        pprx.set_defaults(func=cmd_project)

        # jm proj git <cmd> — real version control (object store, branches, merge, blame)
        pgit = prj.add_parser("git", help="version control: commits, branches, merge, blame")
        pgit.set_defaults(func=cmd_project)
        gsub = pgit.add_subparsers(dest="gitaction")
        gsub.add_parser("init", help="create a .jm repository here")
        gad = gsub.add_parser("add", help="stage files for the next commit")
        gad.add_argument("paths", nargs="*", help="paths to stage (default/'.' = all)")
        gad.add_argument("-A", "--all", action="store_true", help="stage every change")
        gci = gsub.add_parser("commit", help="record staged changes (-a to stage all first)")
        gci.add_argument("-m", "--message", required=True)
        gci.add_argument("-a", "--all", action="store_true", help="stage all tracked changes first")
        glg = gsub.add_parser("log", help="show commit history")
        glg.add_argument("-n", type=int, dest="n")
        gsub.add_parser("status", help="show staged/unstaged changes")
        gdf = gsub.add_parser("diff", help="diff commits / working tree")
        gdf.add_argument("a", nargs="?")
        gdf.add_argument("b", nargs="?")
        gdf.add_argument("--stat", action="store_true")
        gdf.add_argument("--name-only", dest="name_only", action="store_true")
        gbr = gsub.add_parser("branch", help="list/create/delete branches")
        gbr.add_argument("name", nargs="?")
        gbr.add_argument("-d", "--delete")
        gco = gsub.add_parser("checkout", help="switch branch or commit")
        gco.add_argument("ref")
        gco.add_argument("-f", "--force", action="store_true")
        gmg = gsub.add_parser("merge", help="merge a branch (line-level 3-way)")
        gmg.add_argument("branch")
        gtg = gsub.add_parser("tag", help="list/create/delete tags")
        gtg.add_argument("name", nargs="?")
        gtg.add_argument("ref", nargs="?", help="commit to tag (default: HEAD)")
        gtg.add_argument("-d", "--delete")
        grs = gsub.add_parser("reset", help="move HEAD (--soft/--mixed/--hard)")
        grs.add_argument("ref", nargs="?")
        grs.add_argument("--soft", action="store_true")
        grs.add_argument("--mixed", action="store_true")
        grs.add_argument("--hard", action="store_true")
        grv = gsub.add_parser("revert", help="create a commit that undoes another")
        grv.add_argument("ref")
        gbl = gsub.add_parser("blame", help="show who last changed each line")
        gbl.add_argument("path")
        gpu = gsub.add_parser("push", help="push commits to the project's git store")
        gpu.add_argument("remote", nargs="?", help="project (default: from jm.toml/.jm)")
        gpu.add_argument("--branch")
        gsub.add_parser("fetch", help="download remote objects/refs")
        gpl = gsub.add_parser("pull", help="fetch + merge the remote branch")
        gpl.add_argument("remote", nargs="?", help="project (default: from jm.toml/.jm)")
        gpl.add_argument("--branch")
        gcl = gsub.add_parser("clone", help="clone a project's git repository")
        gcl.add_argument("target", help=_TGT_HELP)
        gcl.add_argument("--dir")

        pco = prj.add_parser("checkout", help="fetch a version's source into a directory")
        pco.add_argument("target", help=_TGT_HELP)
        pco.add_argument("version")
        pco.add_argument("--dir", help="destination directory (default: current)")
        pco.set_defaults(func=cmd_project)

        ppull = prj.add_parser("pull", help="update a local checkout to the latest release source")
        ppull.add_argument("--dir", help="project directory (default: current)")
        ppull.set_defaults(func=cmd_project)

        prm = prj.add_parser("readme", help="print a project's README")
        prm.add_argument("target", help=_TGT_HELP)
        prm.add_argument("--version")
        prm.set_defaults(func=cmd_project)

        pvis = prj.add_parser("visibility", help="set a project public or private")
        pvis.add_argument("target", help=_TGT_HELP)
        pvis.add_argument("visibility", choices=["public", "private"])
        pvis.set_defaults(func=cmd_project)

        pdel = prj.add_parser("delete", help="delete a project you own (with releases + files)")
        pdel.add_argument("target", help=_TGT_HELP)
        pdel.add_argument("-y", "--yes", action="store_true")
        pdel.set_defaults(func=cmd_project)

        # ---- discovery / install ----
        pll = prj.add_parser("list", help="list projects")
        pll.add_argument("--mine", action="store_true", help="only your projects")
        pll.add_argument("--search", help="search query")
        pll.set_defaults(func=cmd_project)

        psr = prj.add_parser("search", help="find projects by name")
        psr.add_argument("target", nargs="?", help="name to search for")
        psr.add_argument("--mine", action="store_true", help="only your projects")
        psr.add_argument("--search", help="search query")
        psr.set_defaults(func=cmd_project)

        pinst = prj.add_parser("install", help="download + install a project")
        pinst.add_argument("target", help=_TGT_HELP)
        pinst.add_argument("-y", "--yes", action="store_true", help="don't prompt before running the installer")
        pinst.add_argument("--dir", help="download directory (default ~/Downloads)")
        pinst.add_argument("--download-only", action="store_true", help="download without installing")
        pinst.add_argument("--install-path", help="path this project installs to (used by uninstall)")
        pinst.add_argument("--run", help="run this file inside the package after extracting")
        pinst.set_defaults(func=cmd_project)

        pupd = prj.add_parser("update", help="upgrade installed projects")
        pupd.add_argument("-y", "--yes", action="store_true", help="don't prompt before running an installer")
        pupd.add_argument("--dir", help="download directory (default ~/Downloads)")
        pupd.add_argument("--check", action="store_true", help="list updates without installing")
        pupd.set_defaults(func=cmd_project)

        pinstd = prj.add_parser("installed", help="list projects you've installed")
        pinstd.add_argument("--json", action="store_true", help="output JSON")
        pinstd.set_defaults(func=cmd_project)

        punin = prj.add_parser("uninstall", help="remove a project from disk + untrack it")
        punin.add_argument("target", help=_TGT_HELP)
        punin.add_argument("-y", "--yes", action="store_true", help="don't prompt")
        punin.add_argument("--keep-files", action="store_true", help="only untrack, don't delete from disk")
        punin.set_defaults(func=cmd_project)

        # ---- social ----
        pst = prj.add_parser("star", help="star a project")
        pst.add_argument("target", help=_TGT_HELP)
        pst.set_defaults(func=cmd_project)
        pust = prj.add_parser("unstar", help="remove your star from a project")
        pust.add_argument("target", help=_TGT_HELP)
        pust.set_defaults(func=cmd_project)

        pw = prj.add_parser("watch", help="watch a project for updates")
        pw.add_argument("target", help=_TGT_HELP)
        pw.add_argument("--level", choices=["releases", "all"], default="releases",
                        help="notify about releases only (default) or everything")
        pw.set_defaults(func=cmd_project)
        puw = prj.add_parser("unwatch", help="stop watching a project")
        puw.add_argument("target", help=_TGT_HELP)
        puw.set_defaults(func=cmd_project)

        pis = prj.add_parser("issues", help="list a project's issues")
        pis.add_argument("target", help=_TGT_HELP)
        pis.add_argument("--state", choices=["open", "closed", "all"], default="open")
        pis.add_argument("--json", action="store_true")
        pis.set_defaults(func=cmd_project)

        pi1 = prj.add_parser("issue", help="view a single issue with its comments")
        pi1.add_argument("target", help=_TGT_HELP)
        pi1.add_argument("number", help="issue number")
        pi1.add_argument("--json", action="store_true")
        pi1.set_defaults(func=cmd_project)

        pic = prj.add_parser("issue-create", help="open a new issue on a project")
        pic.add_argument("target", help=_TGT_HELP)
        pic.add_argument("--title", required=True)
        pic.add_argument("--body", help="issue body; use - to read from stdin (or pipe it)")
        pic.set_defaults(func=cmd_project)

        picm = prj.add_parser("issue-comment", help="comment on an issue")
        picm.add_argument("target", help=_TGT_HELP)
        picm.add_argument("number", help="issue number")
        picm.add_argument("--body", help="comment body; use - to read from stdin (or pipe it)")
        picm.set_defaults(func=cmd_project)

        picl = prj.add_parser("issue-close", help="close an issue")
        picl.add_argument("target", help=_TGT_HELP)
        picl.add_argument("number", help="issue number")
        picl.set_defaults(func=cmd_project)

        piro = prj.add_parser("issue-reopen", help="reopen a closed issue")
        piro.add_argument("target", help=_TGT_HELP)
        piro.add_argument("number", help="issue number")
        piro.set_defaults(func=cmd_project)

    # direct install — run this machine as a device
    lk = sub.add_parser("link", help="run this machine as a device for the website's Direct Install")
    lk.add_argument("--name", help="device name (default: hostname)")
    lk.set_defaults(func=cmd_link)
    se = sub.add_parser("search", help="JM Search — search apps, articles and people (--web for the web index)")
    se.add_argument("query")
    se.add_argument("--web", action="store_true", help="search the crawled web index instead of JM content")
    se.add_argument("--limit", type=int, default=12)
    se.set_defaults(func=cmd_search)
    sub.add_parser("devices", help="list your linked devices").set_defaults(func=cmd_devices)
    ul = sub.add_parser("unlink", help="remove a linked device")
    ul.add_argument("id", type=int)
    ul.set_defaults(func=cmd_unlink)
    svc_p = sub.add_parser("service", help="run `jm link` at login/boot (systemd/launchd/Task Scheduler)")
    svc_p.set_defaults(func=cmd_service)
    svc = svc_p.add_subparsers(dest="svcaction")
    svc.add_parser("install", help="install + start the jm link service").set_defaults(func=cmd_service)
    svc.add_parser("uninstall", help="stop + remove the service").set_defaults(func=cmd_service)
    svc.add_parser("status", help="show service status").set_defaults(func=cmd_service)

    # social — star / watch / issues / notifications
    star = sub.add_parser("star", help="star a project")
    star.add_argument("target", help="project as owner/name, name, or id")
    star.set_defaults(func=cmd_star)
    unstar = sub.add_parser("unstar", help="remove your star from a project")
    unstar.add_argument("target", help="project as owner/name, name, or id")
    unstar.set_defaults(func=cmd_unstar)

    watch = sub.add_parser("watch", help="watch a project for updates")
    watch.add_argument("target", help="project as owner/name, name, or id")
    watch.add_argument("--level", choices=["releases", "all"], default="releases",
                       help="notify about releases only (default) or everything")
    watch.set_defaults(func=cmd_watch)
    unwatch = sub.add_parser("unwatch", help="stop watching a project")
    unwatch.add_argument("target", help="project as owner/name, name, or id")
    unwatch.set_defaults(func=cmd_unwatch)

    issues = sub.add_parser("issues", help="list a project's issues")
    issues.add_argument("target", help="project as owner/name, name, or id")
    issues.add_argument("--state", choices=["open", "closed", "all"], default="open")
    issues.add_argument("--json", action="store_true")
    issues.set_defaults(func=cmd_issues)

    issue = sub.add_parser("issue", help="view a single issue with its comments")
    issue.add_argument("target", help="project as owner/name, name, or id")
    issue.add_argument("number", help="issue number")
    issue.add_argument("--json", action="store_true")
    issue.set_defaults(func=cmd_issue_view)

    icr = sub.add_parser("issue-create", help="open a new issue on a project")
    icr.add_argument("target", help="project as owner/name, name, or id")
    icr.add_argument("--title", required=True)
    icr.add_argument("--body", help="issue body; use - to read from stdin (or pipe it)")
    icr.set_defaults(func=cmd_issue_create)

    icm = sub.add_parser("issue-comment", help="comment on an issue")
    icm.add_argument("target", help="project as owner/name, name, or id")
    icm.add_argument("number", help="issue number")
    icm.add_argument("--body", help="comment body; use - to read from stdin (or pipe it)")
    icm.set_defaults(func=cmd_issue_comment)

    icl = sub.add_parser("issue-close", help="close an issue")
    icl.add_argument("target", help="project as owner/name, name, or id")
    icl.add_argument("number", help="issue number")
    icl.set_defaults(func=cmd_issue_close)

    iro = sub.add_parser("issue-reopen", help="reopen a closed issue")
    iro.add_argument("target", help="project as owner/name, name, or id")
    iro.add_argument("number", help="issue number")
    iro.set_defaults(func=cmd_issue_reopen)

    for _nname in ("notifications", "inbox"):
        notif = sub.add_parser(_nname, help="your notifications (event inbox)")
        notif.add_argument("--unread", action="store_true", help="only unread notifications")
        notif.add_argument("--limit", type=int, default=30)
        notif.add_argument("--json", action="store_true", help="machine-readable output (stable for agents)")
        notif.set_defaults(func=cmd_notifications)
        nsub = notif.add_subparsers(dest="notifaction")
        nread = nsub.add_parser("read", help="mark notifications read (all, by default)")
        nread.add_argument("--all", action="store_true", help="mark everything read (default with no args)")
        nread.set_defaults(func=cmd_notifications)

    return p


def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)
    if not getattr(args, "func", None):
        parser.print_help()
        sys.exit(1)
    try:
        args.func(args)
    except KeyboardInterrupt:
        die("interrupted", 130)
    _maybe_notify_update(getattr(args, "command", None))


if __name__ == "__main__":
    main()
