#!/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 getpass
import json
import mimetypes
import os
import subprocess
import sys
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.26.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 _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))


# 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),
    "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}")
    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)

        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()
