pyrtan / src / pyrtan / cli.py
107,407 bytes · last changed 2026-10-28 in Merge main (agents beside the swarm, 0.18.0) into identity without a shell; this is release 0.19.0
| 1 | """The ``pyrtan`` command. |
| 2 | |
| 3 | pyrtan check [PATH ...] validate trees |
| 4 | pyrtan list [PATH ...] what is loaded and what it answers to |
| 5 | pyrtan run TEXT [--trees PATH] answer a sentence, in this process |
| 6 | pyrtan run --tree NAME --input k=v run a named tree |
| 7 | pyrtan match TEXT [--trees PATH] how a sentence reads: the rewrites and every trigger's verdict |
| 8 | pyrtan ask TEXT --at URL answer a sentence through a service or hub |
| 9 | pyrtan chat [--at HUB] [--bot|--people] talk to a random stranger or a bot, through a hub started --chat |
| 10 | pyrtan serve [PATH ...] [--port N] host trees as a service |
| 11 | pyrtan hub [--peer URL ...] route between services |
| 12 | pyrtan hub --trees DIR and hold trees itself: a swarm in one process |
| 13 | pyrtan hub --federate --advertise URL [--follow URL ...] [--listed] and between hubs |
| 14 | pyrtan hub --chat and keep a lobby: strangers and bots, Omegle-style |
| 15 | pyrtan hub --lan --join default and find the hubs on this LAN by multicast, with nobody to ask |
| 16 | pyrtan hub --chat-public --chat-reach 8 and link its lobby with other open lobbies, for chat only |
| 17 | pyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: join the default network |
| 18 | pyrtan unfollow URL --at HUB |
| 19 | pyrtan peers --at HUB services, and hubs followed and following |
| 20 | pyrtan directory [--at HUB] every hub that asked to be found, as the swarm knows it |
| 21 | pyrtan keep KEY [FILE] --at HUB give your hub a blob to keep, and have its keepers keep it |
| 22 | pyrtan kept --at HUB what your hub keeps, who holds it, and what it keeps for others |
| 23 | pyrtan fetch KEY --at HUB get a blob back, from your hub or from whoever holds it |
| 24 | pyrtan gaps --at URL branches waiting to be written |
| 25 | pyrtan unanswered --at URL [--scaffold DIR] trees waiting to be written |
| 26 | pyrtan new [NAME] [--from TEXT] [--dir PATH] scaffold a tree file |
| 27 | pyrtan search [WORDS] [--catalog URL] find trees in a catalog |
| 28 | pyrtan pull NAME[@VERSION] [--dir] fetch a tree from a catalog, verified |
| 29 | pyrtan index DIR [--out FILE] build a catalog index from a directory |
| 30 | pyrtan console --out FILE write the console as one static HTML file |
| 31 | pyrtan id new|show|check|device|revoke|route|name your identity: a key, any domain |
| 32 | pyrtan id resolve NAME@DOMAIN what identity a name is, pinned the first time |
| 33 | pyrtan allow WHO --may ask,run --at HUB grant an identity abilities on your hub; allowed, disallow |
| 34 | pyrtan names init DIR --domain D | gate a network's own names, in any git repository |
| 35 | |
| 36 | ``serve`` and ``hub`` take ``--token`` (or ``$PYRTAN_TOKEN``) to require a |
| 37 | bearer token, ``--cors ORIGIN`` to let a browser elsewhere call them, and |
| 38 | ``serve`` takes ``--gaps FILE`` to keep gaps across restarts and |
| 39 | ``--egress public`` to refuse private addresses. |
| 40 | """ |
| 41 | |
| 42 | from __future__ import annotations |
| 43 | |
| 44 | import argparse |
| 45 | import json |
| 46 | import os |
| 47 | import sys |
| 48 | import threading |
| 49 | import time |
| 50 | from pathlib import Path |
| 51 | from typing import Any |
| 52 | |
| 53 | from . import __version__ |
| 54 | from .catalog import Catalog, CatalogError, build_bundle, build_index, catalog_url |
| 55 | from .federation import bootstrap_candidates |
| 56 | from .grow import Proposal, propose |
| 57 | from .identity import ABILITIES, KINDS, IdentityError |
| 58 | from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER |
| 59 | from .registry import Registry, default_paths |
| 60 | from .runtime import Outcome, Runner |
| 61 | from .service import RemoteResolver, http_json, outcome_from_dict, serve |
| 62 | from .adapters import LocalAdapters |
| 63 | from .transport import EGRESS_POLICIES, RecordedTransport, RecordingTransport, UrllibTransport |
| 64 | from .tree import TreeError, check, load_tree |
| 65 | |
| 66 | |
| 67 | |
| 68 | def main(argv: list[str] | None = None) -> int: |
| 69 | parser = argparse.ArgumentParser(prog="pyrtan", description="A core set of expandable services that act on all forms of I/O through inspectable decision trees.") |
| 70 | parser.add_argument("--version", action="version", version=f"pyrtan {__version__}") |
| 71 | sub = parser.add_subparsers(dest="command", required=True) |
| 72 | |
| 73 | p = sub.add_parser("check", help="validate trees; exit 1 if any has an error") |
| 74 | p.add_argument("paths", nargs="*") |
| 75 | p.add_argument("--quiet", action="store_true", help="only print problems") |
| 76 | |
| 77 | p = sub.add_parser("list", help="what is loaded and what it answers to") |
| 78 | p.add_argument("paths", nargs="*") |
| 79 | p.add_argument("--json", action="store_true") |
| 80 | |
| 81 | p = sub.add_parser("run", help="answer a sentence or run a named tree in this process") |
| 82 | p.add_argument("text", nargs="?", help="the sentence to answer") |
| 83 | p.add_argument("--trees", action="append", default=[], help="tree file or directory (repeatable; default $PYRTAN_TREES or ./trees)") |
| 84 | p.add_argument("--tree", help="run this tree by name instead of matching text") |
| 85 | p.add_argument("--input", action="append", default=[], metavar="K=V", help="an input for --tree (repeatable; JSON values accepted)") |
| 86 | p.add_argument("--json", action="store_true", help="print the outcome as JSON") |
| 87 | p.add_argument("--no-trace", action="store_true", help="do not print the trace") |
| 88 | p.add_argument("--replay", help="answer HTTP from this fixture instead of the network") |
| 89 | p.add_argument("--record", help="save every response (HTTP and the other sources) to this fixture") |
| 90 | p.add_argument("--hub", help="forward trees not held here to this hub or service") |
| 91 | p.add_argument("--env", action="append", default=[], metavar="K=V", help="a value trees may read as {{ env.K }} (repeatable)") |
| 92 | p.add_argument("--egress", choices=EGRESS_POLICIES, default="any", help="'public' refuses private, loopback and link-local addresses") |
| 93 | _token_arg(p, "the token the hub (--hub) requires, if any", sign=False) |
| 94 | |
| 95 | p = sub.add_parser("match", help="how a sentence reads against the trees: the rewrites, every trigger's verdict, the winner") |
| 96 | p.add_argument("text") |
| 97 | p.add_argument("--trees", action="append", default=[], help="tree file or directory (repeatable; default $PYRTAN_TREES or ./trees)") |
| 98 | p.add_argument("--now", help="read dates against this day (YYYY-MM-DD) instead of today") |
| 99 | p.add_argument("--json", action="store_true") |
| 100 | |
| 101 | p = sub.add_parser("ask", help="answer a sentence through a running service or hub") |
| 102 | p.add_argument("text") |
| 103 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="service or hub URL (default $PYRTAN_AT or the local hub)") |
| 104 | p.add_argument("--json", action="store_true") |
| 105 | p.add_argument("--no-trace", action="store_true") |
| 106 | _token_arg(p, "the token the service or hub requires, if any") |
| 107 | |
| 108 | p = sub.add_parser("chat", help="talk to a random stranger or a bot, through a hub started with --chat") |
| 109 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="the hub (default $PYRTAN_AT or the local hub)") |
| 110 | who = p.add_mutually_exclusive_group() |
| 111 | who.add_argument("--bot", action="store_true", help="meet a bot: trees on the network, not a person") |
| 112 | who.add_argument("--people", action="store_true", help="meet people only; never a bot") |
| 113 | p.add_argument("--interests", default="", metavar="WORDS", help="up to five words, comma-separated; a stranger or bot that shares one is met first") |
| 114 | p.add_argument("--trace", action="store_true", help="print the path behind each bot line") |
| 115 | p.add_argument("--no-typing", action="store_true", help="turn the typing indicator off: no \"Stranger is typing…\" (a terminal reads whole lines, so it never says you are typing)") |
| 116 | _token_arg(p, "the token the hub requires, if any") |
| 117 | |
| 118 | p = sub.add_parser("serve", help="host trees as a service") |
| 119 | p.add_argument("paths", nargs="*") |
| 120 | p.add_argument("--host", default="127.0.0.1") |
| 121 | p.add_argument("--port", type=int, default=8701) |
| 122 | p.add_argument("--name", default=None, help="what this service calls itself (default: the first directory's name)") |
| 123 | p.add_argument("--hub", help="register with this hub, and forward unknown trees to it") |
| 124 | p.add_argument("--advertise", help="the URL the hub should reach this service at, if not host:port") |
| 125 | p.add_argument("--env", action="append", default=[], metavar="K=V") |
| 126 | p.add_argument("--gaps", metavar="FILE", help="keep gaps in this file (JSON lines) so they survive a restart") |
| 127 | p.add_argument("--unanswered", metavar="FILE", default=os.environ.get("PYRTAN_UNANSWERED"), help="keep unanswered sentences in this file (JSON lines) so they survive a restart (default $PYRTAN_UNANSWERED)") |
| 128 | p.add_argument("--suggestions", metavar="FILE", default=os.environ.get("PYRTAN_SUGGESTIONS"), help="keep what agents suggest in this file (JSON lines) across restarts (default $PYRTAN_SUGGESTIONS)") |
| 129 | p.add_argument("--watch", action="store_true", default=os.environ.get("PYRTAN_WATCH", "").lower() in ("1", "true", "yes"), help="run the trees' [[watches]]: a clock, a file, a topic or a serial line starts a run (default $PYRTAN_WATCH)") |
| 130 | p.add_argument("--egress", choices=EGRESS_POLICIES, default=os.environ.get("PYRTAN_EGRESS", "any"), help="'public' refuses private, loopback and link-local addresses (default $PYRTAN_EGRESS or any)") |
| 131 | _access_args(p) |
| 132 | p.add_argument("--quiet", action="store_true") |
| 133 | |
| 134 | p = sub.add_parser("watch", help="run the trees' [[watches]] in this process, printing each run; nothing is served") |
| 135 | p.add_argument("paths", nargs="*") |
| 136 | p.add_argument("--env", action="append", default=[], metavar="K=V") |
| 137 | p.add_argument("--gaps", metavar="FILE", help="keep gaps in this file") |
| 138 | p.add_argument("--egress", choices=EGRESS_POLICIES, default=os.environ.get("PYRTAN_EGRESS", "any")) |
| 139 | p.add_argument("--hub", help="delegate to this hub what the trees here do not hold") |
| 140 | p.add_argument("--json", action="store_true", help="one JSON outcome per line instead of prose") |
| 141 | _token_arg(p, "the hub's token, if any", sign=False) |
| 142 | |
| 143 | p = sub.add_parser("watches", help="what a running service or hub is watching, and what each watch last did") |
| 144 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="a running service or hub (default $PYRTAN_AT or the local hub)") |
| 145 | p.add_argument("--json", action="store_true") |
| 146 | _token_arg(p, "the token the service or hub requires, if any") |
| 147 | |
| 148 | p = sub.add_parser("grow", help="draft the branch a gap asks for, by rules, for you to accept") |
| 149 | p.add_argument("--trees", action="append", default=[], help="tree file or directory holding the trees the gaps name (repeatable; default $PYRTAN_TREES or ./trees)") |
| 150 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT"), help="read gaps from this running service or hub (default $PYRTAN_AT)") |
| 151 | p.add_argument("--from", dest="source", metavar="FILE", help="read gaps from a file: a --gaps JSON-lines file, a /gaps answer, or a reports.json") |
| 152 | p.add_argument("--reports", nargs="?", const="", metavar="HUB_OR_FILE", help="read the federation's gap reports for your catalog trees (from a hub's URL, a baked reports.json, or pyrtan.com)") |
| 153 | p.add_argument("--tree", action="append", default=[], help="only gaps of this tree (repeatable)") |
| 154 | p.add_argument("--suggestions", nargs="?", const="", metavar="HUB_OR_FILE", help="draft what agents suggested instead of gaps: from a hub's or service's URL (default --at), or a file the console saved") |
| 155 | p.add_argument("--dir", metavar="DIR", help="with --suggestions, where a suggested new tree is written (default: the first --trees directory)") |
| 156 | p.add_argument("--apply", action="store_true", help="write each draft into its tree file (the file must load clean afterwards)") |
| 157 | p.add_argument("--json", action="store_true") |
| 158 | _token_arg(p, "the token the service or hub requires, if any") |
| 159 | |
| 160 | p = sub.add_parser("hub", help="route between services") |
| 161 | p.add_argument("--host", default="127.0.0.1") |
| 162 | p.add_argument("--port", type=int, default=8700) |
| 163 | p.add_argument("--name", default="hub", help="what this hub calls itself, to the console and to hubs that follow it") |
| 164 | p.add_argument("--trees", action="append", default=[], metavar="PATH", help="trees this hub holds itself, in-process (repeatable); a swarm in one process") |
| 165 | p.add_argument("--handout", action="append", default=[t for t in os.environ.get("PYRTAN_HANDOUT", "").split(os.pathsep) if t], metavar="PATH", help="trees for this hub's trusted members (a phone or a browser joined with the token) to run, not this hub (repeatable; default $PYRTAN_HANDOUT); its own --trees are handed out too") |
| 166 | p.add_argument("--env", action="append", default=[], metavar="K=V", help="a value the hub's own trees may read as {{ env.K }}") |
| 167 | p.add_argument("--gaps", metavar="FILE", help="keep the hub's own trees' gaps in this file") |
| 168 | p.add_argument("--unanswered", metavar="FILE", default=os.environ.get("PYRTAN_UNANSWERED"), help="keep unanswered sentences in this file across restarts (default $PYRTAN_UNANSWERED)") |
| 169 | p.add_argument("--suggestions", metavar="FILE", default=os.environ.get("PYRTAN_SUGGESTIONS"), help="keep what agents suggest in this file across restarts (default $PYRTAN_SUGGESTIONS)") |
| 170 | p.add_argument("--watch", action="store_true", default=os.environ.get("PYRTAN_WATCH", "").lower() in ("1", "true", "yes"), help="run the hub's own trees' [[watches]] (default $PYRTAN_WATCH)") |
| 171 | p.add_argument("--egress", choices=EGRESS_POLICIES, default=os.environ.get("PYRTAN_EGRESS", "any"), help="for the hub's own trees: 'public' refuses private addresses") |
| 172 | p.add_argument("--peer", action="append", default=[], help="a service URL to start with (repeatable; services may also register themselves)") |
| 173 | p.add_argument("--poll", type=float, default=10.0, help="seconds between health polls") |
| 174 | _access_args(p) |
| 175 | p.add_argument("--federate", action="store_true", help="answer /actor and /inbox so other hubs can follow this one, and follow others") |
| 176 | p.add_argument("--advertise", default=os.environ.get("PYRTAN_ADVERTISE"), help="the URL other hubs reach this hub at (implies --federate; default $PYRTAN_ADVERTISE, else this host and port)") |
| 177 | p.add_argument("--follow", action="append", default=[h for h in os.environ.get("PYRTAN_FOLLOW", "").replace(",", " ").split() if h], metavar="URL", help="a hub to follow on start (repeatable; default $PYRTAN_FOLLOW; to join the default network, --join default)") |
| 178 | p.add_argument("--join", action="append", default=[h for h in os.environ.get("PYRTAN_JOIN", "").replace(",", " ").split() if h], metavar="INVITE", help="a network to be in on start: 'default' for the public one built into pyrtan, or an invite NAME:SECRET[@HUB] (repeatable; default $PYRTAN_JOIN)") |
| 179 | p.add_argument("--federation-secret", default=os.environ.get("PYRTAN_FEDERATION_SECRET"), help="only hubs that sign their Follow with this secret may follow (default $PYRTAN_FEDERATION_SECRET; without it, follows are open)") |
| 180 | p.add_argument("--closed", action="store_true", help="take no follows at all (this hub can still follow others)") |
| 181 | p.add_argument("--state", metavar="FILE", default=os.environ.get("PYRTAN_STATE"), help="keep who this hub follows and is followed by, and the directory, in this file across restarts (default $PYRTAN_STATE)") |
| 182 | p.add_argument("--listed", action="store_true", default=os.environ.get("PYRTAN_LISTED", "").lower() in ("1", "true", "yes"), help="put this hub in the directory it gossips, so others can find it (default $PYRTAN_LISTED)") |
| 183 | p.add_argument("--store", metavar="DIR", default=os.environ.get("PYRTAN_STORE"), help="where this hub keeps blobs, its own and others' (default $PYRTAN_STORE, else next to --state)") |
| 184 | p.add_argument("--replicas", type=int, default=int(os.environ.get("PYRTAN_REPLICAS", "2")), help="how many members or hubs should keep each of this hub's blobs (default $PYRTAN_REPLICAS or 2)") |
| 185 | p.add_argument("--chat", action="store_true", default=os.environ.get("PYRTAN_CHAT", "").lower() in ("1", "true", "yes"), help="keep a lobby at /chat where people meet a random stranger here or at a hub this one follows, or a bot made of the trees it routes to (default $PYRTAN_CHAT)") |
| 186 | p.add_argument("--allow", action="append", default=[a for a in os.environ.get("PYRTAN_ALLOW", "").split() if a], metavar="WHO=ABILITIES", help="grant an identity (pyr:… or name@domain, resolved and pinned now) abilities here: ask, chat, run, operate (repeatable; default $PYRTAN_ALLOW, space-separated)") |
| 187 | p.add_argument("--names", metavar="DIR", default=os.environ.get("PYRTAN_NAMES"), help="serve the name claims in DIR (a checkout of a names repository, `pyrtan names init`) at /.well-known/pyrtan/, as this network's name provider (default $PYRTAN_NAMES)") |
| 188 | p.add_argument("--names-domain", metavar="DOMAIN", default=os.environ.get("PYRTAN_NAMES_DOMAIN"), help="the domain the names are for (default $PYRTAN_NAMES_DOMAIN, else this hub's own host); the hub must be reachable at https://DOMAIN") |
| 189 | p.add_argument("--grants", metavar="FILE", default=os.environ.get("PYRTAN_GRANTS"), help="keep the grants in this file (default $PYRTAN_GRANTS, else grants.json beside --state, else in memory)") |
| 190 | p.add_argument("--lan", action="store_true", default=os.environ.get("PYRTAN_LAN", "").lower() in ("1", "true", "yes"), help="find hubs on this local network with no one to ask: announce this hub over UDP multicast (TTL 1) and try the hubs heard there when joining a network; needs --advertise with a LAN address (default $PYRTAN_LAN)") |
| 191 | p.add_argument("--chat-reach", type=int, metavar="N", default=int(os.environ.get("PYRTAN_CHAT_REACH", "0") or 0), help="keep up to N chat links with other open lobbies this hub hears of (LAN, directory), so its strangers can meet theirs: for chat only, never a follow; needs an open lobby (--chat-public, or no token) (default $PYRTAN_CHAT_REACH or 0)") |
| 192 | p.add_argument("--chat-public", action="store_true", default=os.environ.get("PYRTAN_CHAT_PUBLIC", "").lower() in ("1", "true", "yes"), help="open the lobby to callers without the token (implies --chat): they meet people only, never a bot, and everything else still needs the token (default $PYRTAN_CHAT_PUBLIC)") |
| 193 | p.add_argument("--chat-typing", action=argparse.BooleanOptionalAction, default=os.environ.get("PYRTAN_CHAT_TYPING", "").lower() not in ("0", "false", "no", "off"), help="the lobby's typing indicator: \"Stranger is typing…\" for anyone who keeps theirs on (default on; $PYRTAN_CHAT_TYPING=0 turns it off)") |
| 194 | p.add_argument("--report-gaps", action="store_true", default=os.environ.get("PYRTAN_REPORT_GAPS", "").lower() in ("1", "true", "yes"), help="put the gaps this swarm's catalog trees meet (tree id, node, value; never inputs) into its announces, so they reach the tree's author through the federation and pyrtan.com (default $PYRTAN_REPORT_GAPS)") |
| 195 | p.add_argument("--quiet", action="store_true") |
| 196 | |
| 197 | p = sub.add_parser("follow", help="follow another hub, through a running federated hub of yours") |
| 198 | p.add_argument("url", nargs="?", default=None, help="the other hub's URL (default: join the default network, as `pyrtan join`)") |
| 199 | p.add_argument("--network", help="follow it in this network of yours, so the directory heard over the link stays in it") |
| 200 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)") |
| 201 | p.add_argument("--listed", action="store_true", help="also put your hub in the directory it gossips, so others can find it") |
| 202 | _token_arg(p, "your hub's token, if any") |
| 203 | |
| 204 | p = sub.add_parser("join", help="put your hub in a network: the default one from pyrtan.com, or one an invite names") |
| 205 | p.add_argument("invite", nargs="?", default="default", help="NAME:SECRET[@HUB] from `pyrtan network new` on another hub, or 'default' (the default)") |
| 206 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)") |
| 207 | p.add_argument("--listed", action="store_true", help="also put your hub in the directory it gossips, so others can find it") |
| 208 | _token_arg(p, "your hub's token, if any") |
| 209 | |
| 210 | p = sub.add_parser("network", help="the networks your hub is in: start one, list them with their invites, leave one") |
| 211 | p.add_argument("action", choices=["new", "list", "leave"]) |
| 212 | p.add_argument("name", nargs="?", help="the network's name (new, leave)") |
| 213 | p.add_argument("--public", action="store_true", help="new: show this network's part of the directory to anyone, not only to you") |
| 214 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)") |
| 215 | p.add_argument("--json", action="store_true") |
| 216 | _token_arg(p, "your hub's token, if any") |
| 217 | |
| 218 | p = sub.add_parser("unfollow", help="stop following a hub") |
| 219 | p.add_argument("url", help="the other hub's URL") |
| 220 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)") |
| 221 | _token_arg(p, "your hub's token, if any") |
| 222 | |
| 223 | p = sub.add_parser("keep", help="give your hub a blob to keep; its keepers get a copy") |
| 224 | p.add_argument("key", help="a key: lowercase, digits, dot, dash, underscore, slash") |
| 225 | p.add_argument("file", nargs="?", help="a JSON file to keep (default: read JSON from stdin)") |
| 226 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)") |
| 227 | _token_arg(p, "your hub's token, if any") |
| 228 | |
| 229 | p = sub.add_parser("kept", help="what your hub keeps, who holds each blob, and what it keeps for others") |
| 230 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700")) |
| 231 | p.add_argument("--json", action="store_true") |
| 232 | _token_arg(p, "your hub's token, if any") |
| 233 | |
| 234 | p = sub.add_parser("fetch", help="get a blob back from your hub, or from whoever holds it") |
| 235 | p.add_argument("key") |
| 236 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700")) |
| 237 | p.add_argument("--out", help="write the data here (default: stdout)") |
| 238 | _token_arg(p, "your hub's token, if any") |
| 239 | |
| 240 | p = sub.add_parser("directory", help="every hub that asked to be found, as a hub knows it") |
| 241 | p.add_argument("--at", default=None, help="a federated hub to ask (default $PYRTAN_AT, else the local hub, else the first hub from pyrtan.com's hub list that answers)") |
| 242 | p.add_argument("--json", action="store_true") |
| 243 | |
| 244 | p = sub.add_parser("peers", help="a hub's services, and the hubs it follows and is followed by") |
| 245 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700")) |
| 246 | p.add_argument("--json", action="store_true") |
| 247 | _token_arg(p, "the hub's token, if any") |
| 248 | |
| 249 | p = sub.add_parser("gaps", help="decisions that met a value with no branch") |
| 250 | p.add_argument("--at", help="a running service or hub") |
| 251 | p.add_argument("--json", action="store_true") |
| 252 | _token_arg(p, "the token the service or hub requires, if any") |
| 253 | |
| 254 | p = sub.add_parser("reports", help="gaps reported across the federation for catalog trees, as a hub (or pyrtan.com) carries them") |
| 255 | p.add_argument("id", nargs="?", default=None, metavar="NAMESPACE/NAME", help="only this tree (any version)") |
| 256 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT"), help="a federated hub (default $PYRTAN_AT, else the snapshot at pyrtan.com)") |
| 257 | p.add_argument("--json", action="store_true") |
| 258 | |
| 259 | p = sub.add_parser("unanswered", help="sentences nobody answered, each with the tree that would") |
| 260 | p.add_argument("--at", help="a running service or hub") |
| 261 | p.add_argument("--json", action="store_true") |
| 262 | p.add_argument("--scaffold", metavar="DIR", help="write a tree file for each sentence into DIR (existing files are kept)") |
| 263 | _token_arg(p, "the token the service or hub requires, if any") |
| 264 | |
| 265 | p = sub.add_parser("suggestions", help="what agents beside the swarm suggested: a branch's meaning, a trigger, a tree") |
| 266 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="a running service or hub (default $PYRTAN_AT or the local hub)") |
| 267 | p.add_argument("--json", action="store_true") |
| 268 | _token_arg(p, "the token the service or hub requires, if any") |
| 269 | |
| 270 | p = sub.add_parser("new", help="scaffold a tree file") |
| 271 | p.add_argument("name", nargs="?", help="the tree's name (derived from --from when omitted)") |
| 272 | p.add_argument("--from", dest="sentence", metavar="TEXT", help="the sentence the tree should answer; numbers and hosts in it become slots") |
| 273 | p.add_argument("--dir", default=".") |
| 274 | |
| 275 | p = sub.add_parser("search", help="find trees in a catalog") |
| 276 | p.add_argument("words", nargs="*", help="words that must all appear in the name, description, tags, hosts or triggers") |
| 277 | p.add_argument("--catalog", default=None, help=f"catalog URL or directory (default $PYRTAN_CATALOG or {catalog_url()})") |
| 278 | p.add_argument("--json", action="store_true") |
| 279 | |
| 280 | p = sub.add_parser("pull", help="fetch a tree from a catalog, verified against its index") |
| 281 | p.add_argument("spec", nargs="+", metavar="[NAMESPACE/]NAME[@VERSION]") |
| 282 | p.add_argument("--dir", default=None, help="where to write (default: the first of $PYRTAN_TREES or ./trees)") |
| 283 | p.add_argument("--catalog", default=None, help=f"catalog URL or directory (default $PYRTAN_CATALOG or {catalog_url()})") |
| 284 | p.add_argument("--force", action="store_true", help="replace a file that exists and differs") |
| 285 | |
| 286 | p = sub.add_parser("index", help="build a catalog index.json from directories of trees") |
| 287 | p.add_argument("dir", nargs="+") |
| 288 | p.add_argument("--out", default=None, help="write the index here (default: DIR/index.json, or COPY/index.json with --copy; '-' for stdout)") |
| 289 | p.add_argument("--copy", metavar="DIR", default=None, help="copy every tree file into DIR as <namespace>/<name>.toml and describe that layout (needed with more than one source directory)") |
| 290 | p.add_argument("--allow-unlicensed", action="store_true", help="index trees with no 'license' too") |
| 291 | p.add_argument("--require-namespace", action="store_true", help="leave out trees with no 'namespace'") |
| 292 | |
| 293 | p = sub.add_parser("publish", help="put a tree into a checkout of the catalog repository, ready for a pull request") |
| 294 | p.add_argument("file", help="the tree file; it must carry 'namespace' and 'license'") |
| 295 | p.add_argument("--into", required=True, metavar="CHECKOUT", help="a checkout of github.com/b-i-t-8/pyrtan (or any repository with a catalog/ directory)") |
| 296 | p.add_argument("--owner", action="append", default=[], metavar="LOGIN", help="GitHub login(s) that own the namespace; needed when the namespace is new") |
| 297 | |
| 298 | p = sub.add_parser("names", help="a network's own names, in any git repository: make one, or run its gate") |
| 299 | nms = p.add_subparsers(dest="names_command", required=True) |
| 300 | q = nms.add_parser("init", help="write a names repository for DOMAIN: README, names/, and the gate and Pages for its git host") |
| 301 | q.add_argument("dir", type=Path) |
| 302 | q.add_argument("--domain", required=True, help="the domain the names are for: name@DOMAIN, served at https://DOMAIN/.well-known/pyrtan/") |
| 303 | q.add_argument("--host", choices=["github", "gitlab", "plain"], default="github", help="github: Actions (pull_request_target) and Pages; gitlab: .gitlab-ci.yml and Pages; plain: neither (default github)") |
| 304 | q.add_argument("--pyrtan-ref", default="main", metavar="REF", help="the pyrtan the gate installs (a branch, tag or commit; pin a commit you have read; default main)") |
| 305 | q = nms.add_parser("gate", help="judge a change to a directory of names owned by their keys: HEAD against BASE") |
| 306 | q.add_argument("head", type=Path, help="the names directory as the change leaves it") |
| 307 | q.add_argument("--base", type=Path, help="the names directory the change starts from (its merge base); omit for a first commit") |
| 308 | q.add_argument("--domain", required=True) |
| 309 | |
| 310 | p = sub.add_parser("allow", help="grant an identity abilities on your hub: ask, chat, run, operate") |
| 311 | p.add_argument("who", metavar="pyr:…|NAME@DOMAIN", help="an identity, or a name your hub resolves and pins now") |
| 312 | p.add_argument("--may", required=True, metavar="ABILITIES", help="comma-separated: ask (the routed paths), chat (the lobby), run (be a trusted member), operate (what the token allows)") |
| 313 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)") |
| 314 | _token_arg(p, "your hub's token, if any") |
| 315 | |
| 316 | p = sub.add_parser("allowed", help="the identities your hub grants anything, and what") |
| 317 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)") |
| 318 | p.add_argument("--json", action="store_true") |
| 319 | _token_arg(p, "your hub's token, if any") |
| 320 | |
| 321 | p = sub.add_parser("disallow", help="take back everything your hub grants an identity") |
| 322 | p.add_argument("who", metavar="pyr:…|NAME@DOMAIN") |
| 323 | p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)") |
| 324 | _token_arg(p, "your hub's token, if any") |
| 325 | |
| 326 | p = sub.add_parser("console", help="write the console as one static HTML file, to host anywhere") |
| 327 | p.add_argument("--out", required=True, help="the file to write ('-' for stdout)") |
| 328 | |
| 329 | p = sub.add_parser("id", help="your identity: a key, reachable at any domain, named by any domain (_meta/identity.md)") |
| 330 | ids = p.add_subparsers(dest="id_command", required=True) |
| 331 | q = ids.add_parser("new", help="make your root key and identity document") |
| 332 | q.add_argument("--kind", choices=KINDS, default="person", help="what the identity says it is (default person)") |
| 333 | q.add_argument("--route", action="append", default=[], metavar="HOST", help="a hub or .onion that takes messages for you (repeatable)") |
| 334 | q = ids.add_parser("show", help="your identity, its routes and devices") |
| 335 | q.add_argument("--json", action="store_true", help="print the signed identity document") |
| 336 | q = ids.add_parser("check", help="check an identity, address or name, or a document (a file, or - for stdin)") |
| 337 | q.add_argument("thing", help="pyr:…, pyr:…@host, name@domain, or a JSON file") |
| 338 | q = ids.add_parser("device", help="delegate to a device key: a phone, a browser, another machine") |
| 339 | q.add_argument("action", choices=["add"]) |
| 340 | q.add_argument("device", metavar="pyr:…", help="the device's identity") |
| 341 | q.add_argument("--may", default="ask,chat", help=f"what the device may ever be granted, comma-separated from {','.join(ABILITIES)} (default ask,chat)") |
| 342 | q.add_argument("--label", default="", help="what to call it: phone, laptop") |
| 343 | q.add_argument("--days", type=int, default=365, help="how long the note lasts, at most 365 (default 365)") |
| 344 | q = ids.add_parser("revoke", help="revoke a device; hubs drop it when they next read your document") |
| 345 | q.add_argument("device", metavar="pyr:…") |
| 346 | q = ids.add_parser("route", help="add or remove a place you can be reached") |
| 347 | q.add_argument("action", choices=["add", "remove"]) |
| 348 | q.add_argument("host", help="a host[:port] or <56>.onion") |
| 349 | q = ids.add_parser("name", help="sign a claim that you are name@domain, for the domain to serve") |
| 350 | q.add_argument("name", metavar="NAME@DOMAIN") |
| 351 | q.add_argument("--github", type=int, metavar="USER_ID", help="your numeric GitHub user id, for a name at pyrtan.com (with --into, looked up from the name when omitted)") |
| 352 | where = q.add_mutually_exclusive_group() |
| 353 | where.add_argument("--out", help="write the claim here (default: stdout)") |
| 354 | where.add_argument("--into", type=Path, metavar="CHECKOUT", help="put the claim and your identity document into a checkout of a network's names repository (pyrtan names init), or of this repository's site/names/, ready for a pull request") |
| 355 | q.add_argument("--handover-from", type=Path, metavar="KEY-FILE", help="the key that holds the name now (its root.key): it signs the handover that moves the name to you") |
| 356 | q = ids.add_parser("release", help="sign that you give up a name, to replace your claim with in the names repository") |
| 357 | q.add_argument("name", metavar="NAME@DOMAIN") |
| 358 | q.add_argument("--out", help="write the release here (default: stdout)") |
| 359 | q = ids.add_parser("resolve", help="what identity a name@domain is, pinned the first time") |
| 360 | q.add_argument("name", metavar="NAME@DOMAIN") |
| 361 | q.add_argument("--repin", action="store_true", help="accept a different identity than the one pinned") |
| 362 | q.add_argument("--json", action="store_true", help="print the claim, and the identity document if the domain serves one") |
| 363 | q = ids.add_parser("pins", help="the names you have resolved, and the identity each is pinned to") |
| 364 | q.add_argument("--forget", metavar="NAME@DOMAIN", help="drop a pin, so the next resolve takes what the domain says") |
| 365 | q = ids.add_parser("backup", help="write your key, encrypted with a passphrase, and your identity document, to keep somewhere only you can read") |
| 366 | q.add_argument("--out", required=True, help="the file to write (a private place: your disk, your own private repository; never a public one)") |
| 367 | q.add_argument("--passphrase-env", metavar="VAR", help="read the passphrase from this environment variable instead of asking") |
| 368 | q = ids.add_parser("restore", help="make this machine your identity again, from a backup (the console's or `pyrtan id backup`'s)") |
| 369 | q.add_argument("file", help="the backup file") |
| 370 | q.add_argument("--passphrase-env", metavar="VAR", help="read the passphrase from this environment variable instead of asking") |
| 371 | q = ids.add_parser("push", help="give a hub your newest identity document, so it knows your devices and revocations now") |
| 372 | q.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="the hub (default $PYRTAN_AT or the local hub)") |
| 373 | q = ids.add_parser("wellknown", help="build /.well-known/pyrtan/ from a directory of name claims, for any domain to serve") |
| 374 | q.add_argument("names", type=Path, metavar="DIR", help="<name>.json claims, and <name>.id.json identity documents") |
| 375 | q.add_argument("--domain", required=True, help="the domain that will serve them; every claim must be for it") |
| 376 | q.add_argument("--out", type=Path, required=True, help="the site's root; .well-known/pyrtan/ is written under it") |
| 377 | for q in ids.choices.values(): |
| 378 | q.add_argument("--dir", type=Path, default=None, help="where your key and document live (default $PYRTAN_IDENTITY or ~/.config/pyrtan/identity)") |
| 379 | |
| 380 | args = parser.parse_args(argv) |
| 381 | commands = { |
| 382 | "check": cmd_check, "list": cmd_list, "run": cmd_run, "match": cmd_match, "ask": cmd_ask, "chat": cmd_chat, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "unanswered": cmd_unanswered, "new": cmd_new, |
| 383 | "watch": cmd_watch, "watches": cmd_watches, "grow": cmd_grow, "suggestions": cmd_suggestions, |
| 384 | "search": cmd_search, "pull": cmd_pull, "index": cmd_index, "publish": cmd_publish, "console": cmd_console, |
| 385 | "follow": cmd_follow, "join": cmd_join, "network": cmd_network, "unfollow": cmd_unfollow, "peers": cmd_peers, "directory": cmd_directory, "reports": cmd_reports, |
| 386 | "keep": cmd_keep, "kept": cmd_kept, "fetch": cmd_fetch, "id": cmd_id, |
| 387 | "allow": cmd_allow, "allowed": cmd_allowed, "disallow": cmd_disallow, "names": cmd_names, |
| 388 | } |
| 389 | return commands[args.command](args) |
| 390 | |
| 391 | |
| 392 | def _token_arg(p: argparse.ArgumentParser, help_text: str, *, sign: bool = True) -> None: |
| 393 | p.add_argument("--token", default=os.environ.get("PYRTAN_TOKEN"), help=f"{help_text} (default $PYRTAN_TOKEN)") |
| 394 | if sign: |
| 395 | p.add_argument("--sign", action="store_true", default=os.environ.get("PYRTAN_SIGN", "").lower() in ("1", "true", "yes"), |
| 396 | help="sign requests with your identity's key (pyrtan id new), for a hub that grants it (default $PYRTAN_SIGN)") |
| 397 | |
| 398 | |
| 399 | _AUDIENCES: dict[str, str] = {} |
| 400 | |
| 401 | |
| 402 | def _call(args: argparse.Namespace, method: str, url: str, payload: Any = None, **kw: Any) -> tuple[int, Any]: |
| 403 | """``http_json`` with the command's token, and with its key's signature under --sign.""" |
| 404 | headers = _key_headers(args, method, url, payload) if getattr(args, "sign", False) else None |
| 405 | return http_json(method, url, payload, token=getattr(args, "token", None), headers=headers, **kw) |
| 406 | |
| 407 | |
| 408 | def _key_headers(args: argparse.Namespace, method: str, url: str, payload: Any) -> dict[str, str]: |
| 409 | """Sign as the root key in $PYRTAN_IDENTITY (or ~/.config/pyrtan/identity), for the |
| 410 | audience the hub names at /api.""" |
| 411 | from urllib.parse import urlsplit |
| 412 | from . import identity |
| 413 | parts = urlsplit(url) |
| 414 | base = f"{parts.scheme}://{parts.netloc}" |
| 415 | if base not in _AUDIENCES: |
| 416 | status, api = http_json("GET", f"{base}/api", timeout=10.0) |
| 417 | audience = api.get("audience") if status == 200 and isinstance(api, dict) else None |
| 418 | if not isinstance(audience, str): |
| 419 | raise SystemExit(f"pyrtan: {base} names no audience to sign for; is it a hub of pyrtan 0.14 or later?") |
| 420 | _AUDIENCES[base] = audience |
| 421 | key = identity.Key.load(identity.home() / "root.key") |
| 422 | if method == "POST" and payload is None: |
| 423 | payload = {} |
| 424 | return identity.sign_request(key, _AUDIENCES[base], method, parts.path or "/", payload) |
| 425 | |
| 426 | |
| 427 | def _access_args(p: argparse.ArgumentParser) -> None: |
| 428 | _token_arg(p, "require this bearer token on every request but /health and the console", sign=False) |
| 429 | p.add_argument("--cors", action="append", default=[], metavar="ORIGIN", help="let a browser at this origin call (repeatable; '*' for any; e.g. https://pyrtan.com)") |
| 430 | |
| 431 | |
| 432 | # ---------------------------------------------------------------- commands |
| 433 | |
| 434 | |
| 435 | def _paths(given: list[str]) -> list[Path]: |
| 436 | return [Path(p) for p in given] if given else default_paths() |
| 437 | |
| 438 | |
| 439 | def _files(paths: list[Path]) -> list[Path]: |
| 440 | out: list[Path] = [] |
| 441 | for p in paths: |
| 442 | if p.is_dir(): |
| 443 | out.extend(f for f in sorted(p.rglob("*.toml")) if f.name != "pyproject.toml") |
| 444 | elif p.exists(): |
| 445 | out.append(p) |
| 446 | else: |
| 447 | print(f"pyrtan: {p}: no such file or directory", file=sys.stderr) |
| 448 | return out |
| 449 | |
| 450 | |
| 451 | def cmd_check(args: argparse.Namespace) -> int: |
| 452 | worst = 0 |
| 453 | files = _files(_paths(args.paths)) |
| 454 | if not files: |
| 455 | print("pyrtan: nothing to check", file=sys.stderr) |
| 456 | return 2 |
| 457 | for path in files: |
| 458 | try: |
| 459 | tree = load_tree(path) |
| 460 | except Exception as e: # TreeError or TOML trouble: already explained |
| 461 | print(f"{path}: error: {e}") |
| 462 | worst = max(worst, 1) |
| 463 | continue |
| 464 | problems = check(tree) |
| 465 | errors = [p for p in problems if p.level == "error"] |
| 466 | if errors: |
| 467 | worst = max(worst, 1) |
| 468 | if not args.quiet or problems: |
| 469 | summary = f"{len(tree.nodes)} nodes, {len(tree.triggers)} triggers" |
| 470 | print(f"{path}: {tree.name}: {summary}" + ("" if not problems else f", {len(problems)} note(s)")) |
| 471 | for p in problems: |
| 472 | print(f" {p}") |
| 473 | return worst |
| 474 | |
| 475 | |
| 476 | def cmd_list(args: argparse.Namespace) -> int: |
| 477 | reg = Registry.from_paths(_paths(args.paths)) |
| 478 | if args.json: |
| 479 | print(json.dumps({"trees": reg.capabilities(), "errors": reg.errors}, indent=2, ensure_ascii=False)) |
| 480 | return 0 |
| 481 | for tree in reg.trees(): |
| 482 | via = ", ".join(s for s in tree.sources() if s != "http") |
| 483 | print(f"{tree.name} v{tree.version} {tree.description}" + (f" [{via}]" if via else "")) |
| 484 | for t in tree.triggers: |
| 485 | print(f" {t.pattern}") |
| 486 | for path, err in reg.errors.items(): |
| 487 | print(f"{path}: {err}", file=sys.stderr) |
| 488 | return 0 if not reg.errors else 1 |
| 489 | |
| 490 | |
| 491 | def _kv(items: list[str]) -> dict[str, Any]: |
| 492 | out: dict[str, Any] = {} |
| 493 | for item in items: |
| 494 | if "=" not in item: |
| 495 | raise SystemExit(f"pyrtan: expected K=V, got {item!r}") |
| 496 | k, v = item.split("=", 1) |
| 497 | try: |
| 498 | out[k] = json.loads(v) |
| 499 | except json.JSONDecodeError: |
| 500 | out[k] = v |
| 501 | return out |
| 502 | |
| 503 | |
| 504 | def cmd_run(args: argparse.Namespace) -> int: |
| 505 | if not args.text and not args.tree: |
| 506 | print("pyrtan run: give a sentence, or --tree NAME", file=sys.stderr) |
| 507 | return 2 |
| 508 | reg = Registry.from_paths(_paths(args.trees)) |
| 509 | for path, err in reg.errors.items(): |
| 510 | print(f"pyrtan: {path}: {err}", file=sys.stderr) |
| 511 | transport: Any = RecordedTransport.from_file(args.replay) if args.replay else UrllibTransport(egress=args.egress) |
| 512 | adapters: Any = transport if args.replay else LocalAdapters(egress=args.egress) |
| 513 | recorder = None |
| 514 | if args.record: |
| 515 | recorder = RecordingTransport(transport, adapters) |
| 516 | transport = adapters = recorder |
| 517 | runner = Runner(reg, transport, resolver=RemoteResolver(args.hub, args.token) if args.hub else None, env=_kv(args.env), adapters=adapters) |
| 518 | if args.tree: |
| 519 | outcome = runner.run_tree(args.tree, _kv(args.input)) |
| 520 | else: |
| 521 | outcome = runner.run_intent(args.text) |
| 522 | outcome = _answer_questions(outcome, runner.resume) |
| 523 | if recorder is not None: |
| 524 | recorder.save(args.record) |
| 525 | return _report(outcome, args.json, not args.no_trace) |
| 526 | |
| 527 | |
| 528 | def cmd_match(args: argparse.Namespace) -> int: |
| 529 | """Show how a sentence is read, without running anything: the rewrites |
| 530 | the language rules made, each trigger that matched or refused, and the |
| 531 | winner. The tool for someone writing a trigger.""" |
| 532 | import datetime as _dt |
| 533 | |
| 534 | reg = Registry.from_paths(_paths(args.trees)) |
| 535 | for path, err in reg.errors.items(): |
| 536 | print(f"pyrtan: {path}: {err}", file=sys.stderr) |
| 537 | now: Any = _dt.datetime.now(_dt.timezone.utc) |
| 538 | if args.now: |
| 539 | try: |
| 540 | now = _dt.date.fromisoformat(args.now) |
| 541 | except ValueError: |
| 542 | print(f"pyrtan match: --now must be YYYY-MM-DD, got {args.now!r}", file=sys.stderr) |
| 543 | return 2 |
| 544 | reading = reg.read(args.text, now) |
| 545 | if args.json: |
| 546 | print(json.dumps(reading.to_dict(), indent=2, ensure_ascii=False, default=str)) |
| 547 | return 0 if reading.winner else 1 |
| 548 | print(f"sentence: {reading.text!r}") |
| 549 | if reading.cleaned != reading.text: |
| 550 | print(f"read as: {reading.cleaned!r}") |
| 551 | for step in reading.steps: |
| 552 | print(f" {step}") |
| 553 | if not reading.considered: |
| 554 | print("no trigger matched" + (f" ({len(reg.candidates())} tried)" if reg.candidates() else " (no trees loaded)")) |
| 555 | return 1 |
| 556 | print("triggers:") |
| 557 | for m in sorted(reading.considered, key=lambda m: (-m.specificity, m.tree)): |
| 558 | verdict = "refused" if m.rejected else ("match" if m in reading.matches else "match, less specific") |
| 559 | print(f" {m.tree}: {m.trigger.pattern!r} {m.specificity} literal chars -> {verdict}") |
| 560 | if m.inputs and not m.rejected: |
| 561 | print(f" inputs: {json.dumps(m.inputs, ensure_ascii=False, default=str)}") |
| 562 | for note in m.notes: |
| 563 | print(f" {note}") |
| 564 | if reading.winner: |
| 565 | print(f"winner: {reading.winner.tree}") |
| 566 | return 0 |
| 567 | if reading.matches: |
| 568 | print(f"tie: {sorted({m.tree for m in reading.matches})}; make one trigger more specific") |
| 569 | else: |
| 570 | print("no match: every trigger that fit refused a slot") |
| 571 | return 1 |
| 572 | |
| 573 | |
| 574 | def _answer_questions(outcome: Outcome, resume) -> Outcome: |
| 575 | while outcome.status == "asked": |
| 576 | if not sys.stdin.isatty(): |
| 577 | return outcome |
| 578 | try: |
| 579 | answer = input(f"{outcome.question} ") |
| 580 | except EOFError: |
| 581 | return outcome |
| 582 | outcome = resume(outcome.snapshot, answer) |
| 583 | return outcome |
| 584 | |
| 585 | |
| 586 | def cmd_ask(args: argparse.Namespace) -> int: |
| 587 | at = args.at.rstrip("/") |
| 588 | try: |
| 589 | status, body = _call(args, "POST", f"{at}/intent", {"text": args.text, "trace": not args.no_trace}) |
| 590 | except Exception as e: |
| 591 | print(f"pyrtan: {at}: {e}", file=sys.stderr) |
| 592 | return 3 |
| 593 | if status == 401: |
| 594 | print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr) |
| 595 | return 3 |
| 596 | if status != 200 or not isinstance(body, dict): |
| 597 | print(f"pyrtan: {at} answered {status}: {body}", file=sys.stderr) |
| 598 | return 3 |
| 599 | outcome = outcome_from_dict(body) |
| 600 | |
| 601 | def remote_resume(snapshot: dict[str, Any], answer: Any) -> Outcome: |
| 602 | st, bd = _call(args, "POST", f"{at}/resume", {"snapshot": snapshot, "answer": answer, "trace": not args.no_trace}) |
| 603 | return outcome_from_dict(bd) if isinstance(bd, dict) else Outcome("error", "", reason="remote", say=f"{at} answered {st}") |
| 604 | |
| 605 | outcome = _answer_questions(outcome, remote_resume) |
| 606 | return _report(outcome, args.json, not args.no_trace) |
| 607 | |
| 608 | |
| 609 | def cmd_chat(args: argparse.Namespace) -> int: |
| 610 | at = args.at.rstrip("/") |
| 611 | want = "bot" if args.bot else "people" if args.people else "anyone" |
| 612 | |
| 613 | def post(path: str, body: dict[str, Any], timeout: float = 15.0) -> tuple[int, Any]: |
| 614 | return _call(args, "POST", f"{at}{path}", body, timeout=timeout) |
| 615 | |
| 616 | try: |
| 617 | status, body = post("/chat/start", {"want": want, "interests": args.interests, "typing_indicator": not args.no_typing}) |
| 618 | except Exception as e: |
| 619 | print(f"pyrtan: {at}: {e}", file=sys.stderr) |
| 620 | return 3 |
| 621 | if status == 401: |
| 622 | print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr) |
| 623 | return 3 |
| 624 | if status != 200 or not isinstance(body, dict) or "seat" not in body: |
| 625 | print(f"pyrtan: {at}: {body.get('say') if isinstance(body, dict) else body or status}", file=sys.stderr) |
| 626 | return 3 |
| 627 | seat = body["seat"] |
| 628 | state = {"after": 0, "owed": 0, "partner": body.get("partner"), "typing": False} # owed: lines said to a bot it has not answered yet |
| 629 | lock = threading.Condition() |
| 630 | stop = threading.Event() |
| 631 | print("Type to talk. /next for someone new, /quit or Ctrl-D to leave.", flush=True) |
| 632 | |
| 633 | def show(answer: dict[str, Any]) -> None: |
| 634 | with lock: |
| 635 | for e in answer.get("events", []): |
| 636 | if e["n"] <= state["after"]: |
| 637 | continue |
| 638 | state["after"] = e["n"] |
| 639 | if e["kind"] == "system": |
| 640 | print(f" {e['text']}", flush=True) |
| 641 | elif e["kind"] == "bot": |
| 642 | print(f"Bot: {e['text']}", flush=True) |
| 643 | if args.trace and isinstance(e.get("trace"), dict): |
| 644 | print(outcome_from_dict({**e, "say": e["text"]}).trace.render(), flush=True) |
| 645 | state["owed"] = max(0, state["owed"] - 1) |
| 646 | elif e["kind"] == "stranger": |
| 647 | print(f"Stranger: {e['text']}", flush=True) |
| 648 | state["partner"] = answer.get("partner") |
| 649 | typing = bool(answer.get("partner_typing")) |
| 650 | if typing and not state["typing"]: |
| 651 | print(" Bot is thinking…" if (state["partner"] or {}).get("kind") == "bot" else " Stranger is typing…", flush=True) |
| 652 | state["typing"] = typing |
| 653 | lock.notify_all() |
| 654 | |
| 655 | show(body) |
| 656 | |
| 657 | def pump() -> None: |
| 658 | while not stop.is_set(): |
| 659 | try: |
| 660 | ask = {"seat": seat, "after": state["after"], "wait": 20} |
| 661 | if body.get("typing_indicator"): |
| 662 | ask["partner_typing"] = state["typing"] # woken when it changes |
| 663 | st, answer = post("/chat/poll", ask, timeout=40.0) |
| 664 | except Exception as e: |
| 665 | if stop.is_set(): |
| 666 | return # leaving: the hub not answering any more is expected |
| 667 | print(f" ({at} did not answer: {e})", flush=True) |
| 668 | stop.wait(2) |
| 669 | continue |
| 670 | if st == 404: |
| 671 | print(" This seat is gone; start again.", flush=True) |
| 672 | stop.set() |
| 673 | with lock: |
| 674 | lock.notify_all() |
| 675 | return |
| 676 | if st == 200 and isinstance(answer, dict): |
| 677 | show(answer) |
| 678 | if answer.get("state") == "left": |
| 679 | return |
| 680 | |
| 681 | threading.Thread(target=pump, daemon=True).start() |
| 682 | try: |
| 683 | for line in sys.stdin: |
| 684 | line = line.strip() |
| 685 | if stop.is_set() or line in ("/quit", "/q"): |
| 686 | break |
| 687 | if line in ("/next", "/n"): |
| 688 | st, answer = post("/chat/next", {"seat": seat, "after": state["after"]}) |
| 689 | if st == 200 and isinstance(answer, dict): |
| 690 | show(answer) |
| 691 | continue |
| 692 | if not line: |
| 693 | continue |
| 694 | with lock: |
| 695 | to_bot = (state["partner"] or {}).get("kind") == "bot" |
| 696 | if to_bot: |
| 697 | state["owed"] += 1 |
| 698 | st, answer = post("/chat/say", {"seat": seat, "text": line}) |
| 699 | if st != 200: |
| 700 | with lock: |
| 701 | state["owed"] -= 1 if to_bot else 0 |
| 702 | print(f" {answer.get('say') if isinstance(answer, dict) else answer}", flush=True) |
| 703 | else: |
| 704 | # Input ran out (a pipe): wait for the bot to answer what it was told. |
| 705 | with lock: |
| 706 | lock.wait_for(lambda: state["owed"] <= 0 or stop.is_set(), timeout=60) |
| 707 | except KeyboardInterrupt: |
| 708 | pass |
| 709 | finally: |
| 710 | stop.set() |
| 711 | try: |
| 712 | post("/chat/leave", {"seat": seat}, timeout=5.0) |
| 713 | except Exception: |
| 714 | pass |
| 715 | return 0 |
| 716 | |
| 717 | |
| 718 | def _report(outcome: Outcome, as_json: bool, with_trace: bool) -> int: |
| 719 | if as_json: |
| 720 | print(json.dumps(outcome.to_dict(with_trace=with_trace), indent=2, ensure_ascii=False, default=str)) |
| 721 | else: |
| 722 | if with_trace and outcome.trace is not None: |
| 723 | print(outcome.trace.render()) |
| 724 | print() |
| 725 | if outcome.status == "done": |
| 726 | print(outcome.say if outcome.say is not None else json.dumps(outcome.output, ensure_ascii=False)) |
| 727 | elif outcome.status == "asked": |
| 728 | print(f"asked: {outcome.question}") |
| 729 | else: |
| 730 | print(f"{outcome.status} ({outcome.reason}): {outcome.say or ''}".rstrip(": ")) |
| 731 | return {"done": 0, "failed": 1, "asked": 4}.get(outcome.status, 3) |
| 732 | |
| 733 | |
| 734 | def cmd_serve(args: argparse.Namespace) -> int: |
| 735 | paths = _paths(args.paths) |
| 736 | name = args.name or (paths[0].resolve().name if paths else "pyrtan") |
| 737 | service, server = serve(paths, args.host, args.port, name=name, hub=args.hub, env=_kv(args.env), advertise=args.advertise, quiet=args.quiet, token=args.token, cors=args.cors, gaps=args.gaps, egress=args.egress, unanswered=args.unanswered, watch=args.watch, suggestions=args.suggestions) |
| 738 | watching = f", watching {len(service.watcher)}" if service.watcher else "" |
| 739 | print(f"pyrtan service {name!r}: {len(service.registry)} tree(s) at {server.url}{watching}" + (f", registered with {args.hub}" if args.hub else "") + _access_note(args), flush=True) |
| 740 | for path, err in service.registry.errors.items(): |
| 741 | print(f" not loaded: {path}: {err}", file=sys.stderr) |
| 742 | try: |
| 743 | server.wait() |
| 744 | except KeyboardInterrupt: |
| 745 | pass |
| 746 | finally: |
| 747 | service.stop() |
| 748 | server.stop() |
| 749 | return 0 |
| 750 | |
| 751 | |
| 752 | def cmd_watch(args: argparse.Namespace) -> int: |
| 753 | from .service import Service |
| 754 | |
| 755 | paths = _paths(args.paths) |
| 756 | reg = Registry.from_paths(paths) |
| 757 | for path, err in reg.errors.items(): |
| 758 | print(f"pyrtan: {path}: {err}", file=sys.stderr) |
| 759 | service = Service(reg, name="watch", hub=args.hub, env=_kv(args.env), token=args.token, gaps=args.gaps, egress=args.egress) |
| 760 | from .watch import Watcher |
| 761 | |
| 762 | def after(w, event, outcome): |
| 763 | if args.json: |
| 764 | print(json.dumps({"watch": w.name, "event": event, **outcome.to_dict(with_trace=False)}, ensure_ascii=False, default=str), flush=True) |
| 765 | else: |
| 766 | changed = " (the path changed)" if w.path_changed_at and w.last_at == w.path_changed_at else "" |
| 767 | print(f"{time.strftime('%H:%M:%S')} {w.name}: {outcome.status}" + (f" ({outcome.reason})" if outcome.reason else "") + (f": {outcome.say}" if outcome.say else "") + changed, flush=True) |
| 768 | |
| 769 | watcher = Watcher(reg, service.run_watched, env=_kv(args.env), egress=args.egress, after=after, note=lambda text: print(f"pyrtan: {text}", file=sys.stderr, flush=True)).start() |
| 770 | if not len(watcher): |
| 771 | print("pyrtan watch: no tree here has [[watches]]; nothing to do", file=sys.stderr) |
| 772 | watcher.stop() |
| 773 | return 2 |
| 774 | for w in watcher.status()["watches"]: |
| 775 | print(f"watching {w['name']}: {w['what']}", flush=True) |
| 776 | try: |
| 777 | while True: |
| 778 | time.sleep(3600) |
| 779 | except KeyboardInterrupt: |
| 780 | pass |
| 781 | finally: |
| 782 | watcher.stop() |
| 783 | return 0 |
| 784 | |
| 785 | |
| 786 | def cmd_watches(args: argparse.Namespace) -> int: |
| 787 | try: |
| 788 | status, body = _call(args, "GET", f"{args.at.rstrip('/')}/watches") |
| 789 | except Exception as e: |
| 790 | print(f"pyrtan: {args.at}: {e}", file=sys.stderr) |
| 791 | return 3 |
| 792 | if status == 401: |
| 793 | print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr) |
| 794 | return 3 |
| 795 | watches = (body or {}).get("watches", []) if isinstance(body, dict) else [] |
| 796 | if args.json: |
| 797 | print(json.dumps({"watches": watches}, indent=2, ensure_ascii=False)) |
| 798 | return 0 |
| 799 | if not watches: |
| 800 | print(f"{args.at} watches nothing (start it with --watch, and give a tree [[watches]])") |
| 801 | return 0 |
| 802 | for w in watches: |
| 803 | last = w.get("last") or {} |
| 804 | when = time.strftime("%H:%M:%S", time.localtime(w["last_at"])) if w.get("last_at") else "never" |
| 805 | line = f"{w['name']} {w['kind']} {w['what']} fired {w.get('fires', 0)}× (last {when})" |
| 806 | if last: |
| 807 | line += f": {last.get('status')}" + (f" ({last.get('reason')})" if last.get("reason") else "") + (f" {last.get('say')}" if last.get("say") else "") |
| 808 | if w.get("path_changed_at"): |
| 809 | line += f" [path changed at {time.strftime('%H:%M:%S', time.localtime(w['path_changed_at']))}: {' > '.join(w.get('previous_path') or [])} -> {' > '.join(w.get('path') or [])}]" |
| 810 | if w.get("last_error"): |
| 811 | line += f" error: {w['last_error']}" |
| 812 | if w.get("peer") and w["peer"] != "local": |
| 813 | line += f" ({w['peer']})" |
| 814 | print(line) |
| 815 | return 0 |
| 816 | |
| 817 | |
| 818 | def cmd_grow(args: argparse.Namespace) -> int: |
| 819 | from .grow import apply_draft, draft_branch |
| 820 | |
| 821 | if args.suggestions is not None: |
| 822 | return _grow_suggestions(args) |
| 823 | gaps: list[dict[str, Any]] = [] |
| 824 | if args.at: |
| 825 | try: |
| 826 | status, body = _call(args, "GET", f"{args.at.rstrip('/')}/gaps") |
| 827 | except Exception as e: |
| 828 | print(f"pyrtan: {args.at}: {e}", file=sys.stderr) |
| 829 | return 3 |
| 830 | if status != 200 or not isinstance(body, dict): |
| 831 | print(f"pyrtan: {args.at} answered {status}", file=sys.stderr) |
| 832 | return 3 |
| 833 | gaps.extend(g for g in body.get("gaps", []) if isinstance(g, dict)) |
| 834 | if args.source: |
| 835 | text = Path(args.source).read_text(encoding="utf-8") |
| 836 | try: |
| 837 | data = json.loads(text) |
| 838 | if isinstance(data, dict) and ("gaps" in data or "reports" in data): |
| 839 | rows = data.get("gaps") or data.get("reports") or [] # a /gaps answer, or a reports.json |
| 840 | elif isinstance(data, dict): |
| 841 | rows = [data] # one gap, one line |
| 842 | else: |
| 843 | rows = data |
| 844 | except json.JSONDecodeError: |
| 845 | rows = [json.loads(l) for l in text.splitlines() if l.strip()] # a --gaps file: JSON lines |
| 846 | gaps.extend(_from_report(r) for r in rows if isinstance(r, dict)) |
| 847 | if args.reports is not None: |
| 848 | given = args.reports |
| 849 | source = f"{given.rstrip('/')}/reports" if given.startswith(("http://", "https://")) else given if given else (os.environ.get("PYRTAN_REPORTS") or "https://pyrtan.com/rendezvous/reports.json") |
| 850 | try: |
| 851 | if source.startswith(("http://", "https://")): |
| 852 | status, body = http_json("GET", source) |
| 853 | else: |
| 854 | status, body = 200, json.loads(Path(source).read_text(encoding="utf-8")) |
| 855 | except Exception as e: |
| 856 | print(f"pyrtan: {source}: {e}", file=sys.stderr) |
| 857 | return 3 |
| 858 | gaps.extend(_from_report(r) for r in (body or {}).get("reports", []) if isinstance(r, dict)) |
| 859 | if not args.at and not args.source and args.reports is None: |
| 860 | print("pyrtan grow: say where the gaps are: --at URL, --from FILE or --reports", file=sys.stderr) |
| 861 | return 2 |
| 862 | if args.tree: |
| 863 | gaps = [g for g in gaps if g.get("tree") in args.tree] |
| 864 | reg = Registry.from_paths(_paths(args.trees)) |
| 865 | for path, err in reg.errors.items(): |
| 866 | print(f"pyrtan: {path}: {err}", file=sys.stderr) |
| 867 | drafts = [] |
| 868 | skipped: list[str] = [] |
| 869 | seen: set[str] = set() |
| 870 | for g in gaps: |
| 871 | tree = reg.get(str(g.get("tree", ""))) |
| 872 | if tree is None: |
| 873 | skipped.append(f"{g.get('tree')}: not among the trees here") |
| 874 | continue |
| 875 | key = json.dumps([tree.name, g.get("node"), g.get("on"), g.get("observed")], sort_keys=True, default=str) |
| 876 | if key in seen: |
| 877 | continue |
| 878 | seen.add(key) |
| 879 | draft = draft_branch(tree, g) |
| 880 | if draft is None: |
| 881 | skipped.append(f"{tree.name}.{g.get('node')}: {g.get('on')} = {json.dumps(g.get('observed'), default=str)} is covered already, or names no decide node") |
| 882 | continue |
| 883 | drafts.append((tree, draft)) |
| 884 | if args.json: |
| 885 | print(json.dumps({"drafts": [d.to_dict() | {"file": str(t.source)} for t, d in drafts], "skipped": skipped}, indent=2, ensure_ascii=False, default=str)) |
| 886 | return 0 if drafts else 1 |
| 887 | for note in skipped: |
| 888 | print(f"skipped {note}") |
| 889 | if not drafts: |
| 890 | print("nothing to draft" + (": every gap has its branch" if gaps else ": no gaps")) |
| 891 | return 1 |
| 892 | worst = 0 |
| 893 | for tree, draft in drafts: |
| 894 | print(f"\n{draft.render()}") |
| 895 | print(f"# why: {draft.reason}") |
| 896 | if args.apply: |
| 897 | if not tree.source: |
| 898 | print(f"# cannot apply: {tree.name} has no file", file=sys.stderr) |
| 899 | worst = 1 |
| 900 | continue |
| 901 | try: |
| 902 | apply_draft(tree.source, draft) |
| 903 | print(f"# written into {tree.source}; now edit [nodes.{draft.then}] to say what the value means, then: pyrtan check {tree.source}") |
| 904 | except TreeError as e: |
| 905 | print(f"# {e}", file=sys.stderr) |
| 906 | worst = 1 |
| 907 | reg.reload() |
| 908 | if not args.apply: |
| 909 | print("\n# nothing written; --apply puts each draft into its file for you to finish") |
| 910 | return worst |
| 911 | |
| 912 | |
| 913 | def _load_suggestions(args: argparse.Namespace, source: str) -> list[dict[str, Any]] | None: |
| 914 | """Suggestions from a running service or hub, or a file: a /suggestions |
| 915 | answer, a list, or JSON lines.""" |
| 916 | if source.startswith(("http://", "https://")): |
| 917 | try: |
| 918 | status, body = _call(args, "GET", f"{source.rstrip('/')}/suggestions") |
| 919 | except Exception as e: |
| 920 | print(f"pyrtan: {source}: {e}", file=sys.stderr) |
| 921 | return None |
| 922 | if status != 200 or not isinstance(body, dict): |
| 923 | print(f"pyrtan: {source} answered {status}" + (": pass --token, or --sign as an identity it grants operate" if status == 401 else ""), file=sys.stderr) |
| 924 | return None |
| 925 | return [s for s in body.get("suggestions", []) if isinstance(s, dict)] |
| 926 | text = Path(source).read_text(encoding="utf-8") |
| 927 | try: |
| 928 | data = json.loads(text) |
| 929 | rows = data.get("suggestions", [data]) if isinstance(data, dict) else data |
| 930 | except json.JSONDecodeError: |
| 931 | rows = [json.loads(l) for l in text.splitlines() if l.strip()] |
| 932 | return [s for s in rows if isinstance(s, dict)] |
| 933 | |
| 934 | |
| 935 | def cmd_suggestions(args: argparse.Namespace) -> int: |
| 936 | rows = _load_suggestions(args, args.at) |
| 937 | if rows is None: |
| 938 | return 3 |
| 939 | if args.json: |
| 940 | print(json.dumps({"suggestions": rows}, indent=2, ensure_ascii=False, default=str)) |
| 941 | return 0 |
| 942 | if not rows: |
| 943 | print("no suggestions: no agent has reported to this Pyrtan yet") |
| 944 | return 0 |
| 945 | for s in rows: |
| 946 | by = s.get("by") or {} |
| 947 | who = by.get("agent", "an agent") + (f" ({by['model']})" if by.get("model") else "") |
| 948 | count = f" x{s.get('count', 1)}" |
| 949 | if s.get("kind") == "branch": |
| 950 | m = s.get("meaning") or {} |
| 951 | print(f"branch {s.get('tree')}.{s.get('node')}: {s.get('on')} = {json.dumps(s.get('observed'), ensure_ascii=False, default=str)} means {m.get('kind')}: {m.get('say')!r}{count}") |
| 952 | elif s.get("kind") == "trigger": |
| 953 | print(f"trigger {s.get('tree')}: {s.get('pattern')!r}, covering {', '.join(repr(c) for c in s.get('covers', []))}{count}") |
| 954 | else: |
| 955 | print(f"tree {s.get('text')!r}: {s.get('description') or 'no description'}{count}") |
| 956 | print(f" by {who}" + (f": {s['why']}" if s.get("why") else "")) |
| 957 | print("\nnow: pyrtan grow --suggestions " + args.at + " --trees DIR (add --apply to write them)") |
| 958 | return 0 |
| 959 | |
| 960 | |
| 961 | def _grow_suggestions(args: argparse.Namespace) -> int: |
| 962 | """``pyrtan grow --suggestions``: each suggestion drafted by the rules |
| 963 | and checked, nothing trusted because an agent said it.""" |
| 964 | from .grow import apply_draft |
| 965 | from . import suggestions as sg |
| 966 | |
| 967 | say = (lambda *a, **k: None) if args.json else print |
| 968 | source = args.suggestions or args.at |
| 969 | if not source: |
| 970 | print("pyrtan grow --suggestions: say where they are: a hub's URL, a file, or --at URL", file=sys.stderr) |
| 971 | return 2 |
| 972 | rows = _load_suggestions(args, source) |
| 973 | if rows is None: |
| 974 | return 3 |
| 975 | if args.tree: |
| 976 | rows = [s for s in rows if s.get("tree") in args.tree] # a new tree has none, so --tree leaves it out |
| 977 | reg = Registry.from_paths(_paths(args.trees)) |
| 978 | for path, err in reg.errors.items(): |
| 979 | print(f"pyrtan: {path}: {err}", file=sys.stderr) |
| 980 | out_dir = Path(args.dir) if args.dir else next((Path(t) for t in _paths(args.trees) if Path(t).is_dir()), Path("trees")) |
| 981 | worst, done, report = 0, 0, [] |
| 982 | for s in rows: |
| 983 | why = sg.problem(s) |
| 984 | label = f"{s.get('kind')} {s.get('tree') or s.get('text')!r}" |
| 985 | if why: |
| 986 | say(f"refused {label}: {why}") |
| 987 | report.append({"suggestion": s, "refused": why}) |
| 988 | continue |
| 989 | kind = s["kind"] |
| 990 | if kind == "tree": |
| 991 | name, text = sg.tree_text(s) |
| 992 | if reg.get(name): |
| 993 | say(f"skipped {label}: a tree named {name} is here already") |
| 994 | continue |
| 995 | say(f"\n# a new tree, {out_dir / (name + '.toml')}:\n{text}") |
| 996 | if args.apply: |
| 997 | try: |
| 998 | path = sg.write_tree(out_dir, s) |
| 999 | say(f"# written: {path}; now say what it observes and how it decides, then: pyrtan check {path}") |
| 1000 | done += 1 |
| 1001 | except TreeError as e: |
| 1002 | print(f"# {e}", file=sys.stderr) |
| 1003 | worst = 1 |
| 1004 | report.append({"suggestion": s, "file": str(out_dir / (name + ".toml")), "text": text}) |
| 1005 | continue |
| 1006 | tree = reg.get(s["tree"]) |
| 1007 | if tree is None: |
| 1008 | say(f"skipped {label}: not among the trees here") |
| 1009 | continue |
| 1010 | if kind == "branch": |
| 1011 | d = sg.draft(tree, s) |
| 1012 | if d is None: |
| 1013 | say(f"skipped {label}.{s.get('node')}: {s.get('on')} = {json.dumps(s.get('observed'), default=str)} is covered already, or names no decide node") |
| 1014 | continue |
| 1015 | say(f"\n{d.render()}") |
| 1016 | say(f"# why: {d.reason}") |
| 1017 | report.append({"suggestion": s, "file": str(tree.source), "draft": d.to_dict()}) |
| 1018 | if args.apply: |
| 1019 | try: |
| 1020 | apply_draft(tree.source, d) |
| 1021 | say(f"# written into {tree.source}; read it, then: pyrtan check {tree.source}") |
| 1022 | done += 1 |
| 1023 | except TreeError as e: |
| 1024 | print(f"# {e}", file=sys.stderr) |
| 1025 | worst = 1 |
| 1026 | else: |
| 1027 | say(f"\n# {tree.name}: add after its last [[triggers]]:\n{sg.trigger_text(s)}") |
| 1028 | report.append({"suggestion": s, "file": str(tree.source), "text": sg.trigger_text(s)}) |
| 1029 | if args.apply: |
| 1030 | try: |
| 1031 | sg.apply_trigger(tree.source, tree, s, reg) |
| 1032 | say(f"# written into {tree.source}") |
| 1033 | done += 1 |
| 1034 | except TreeError as e: |
| 1035 | print(f"# {e}", file=sys.stderr) |
| 1036 | worst = 1 |
| 1037 | reg.reload() |
| 1038 | if args.json: |
| 1039 | print(json.dumps({"drafts": report}, indent=2, ensure_ascii=False, default=str)) |
| 1040 | if not rows: |
| 1041 | say("nothing to draft: no suggestions") |
| 1042 | return 1 |
| 1043 | if not args.apply: |
| 1044 | say("\n# nothing written; --apply puts each into its file, checked, for you to read") |
| 1045 | elif done: |
| 1046 | say(f"\n# {done} written; bump each tree's version when you keep it") |
| 1047 | return worst |
| 1048 | |
| 1049 | |
| 1050 | def _from_report(r: dict[str, Any]) -> dict[str, Any]: |
| 1051 | """A federation report (id namespace/name@version) as a gap (tree name).""" |
| 1052 | if "tree" in r: |
| 1053 | return r |
| 1054 | tree_id = str(r.get("id", "")) |
| 1055 | name = tree_id.split("/", 1)[-1].split("@", 1)[0] |
| 1056 | return {**r, "tree": name} |
| 1057 | |
| 1058 | |
| 1059 | def cmd_hub(args: argparse.Namespace) -> int: |
| 1060 | from .hub import serve_hub |
| 1061 | from .identity import IdentityError |
| 1062 | |
| 1063 | follow = list(args.follow) |
| 1064 | join = list(args.join) |
| 1065 | try: |
| 1066 | hub, server = serve_hub( |
| 1067 | args.host, args.port, name=args.name, peers=args.peer, poll_every=args.poll, quiet=args.quiet, token=args.token, cors=args.cors, |
| 1068 | federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed or args.lan or args.chat_reach > 0, advertise=args.advertise, federation_secret=args.federation_secret, |
| 1069 | state=args.state, open_follows=not args.closed, follow=follow, join=join, listed=args.listed, |
| 1070 | trees=[Path(t) for t in args.trees], handout=[Path(t) for t in args.handout], env=_kv(args.env), gaps=args.gaps, egress=args.egress, |
| 1071 | store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, suggestions=args.suggestions, watch=args.watch, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public, lan=args.lan, chat_reach=args.chat_reach, |
| 1072 | allow=args.allow, grants=args.grants, names=args.names, names_domain=args.names_domain, |
| 1073 | ) |
| 1074 | except IdentityError as e: |
| 1075 | print(f"pyrtan hub: {e}", file=sys.stderr) |
| 1076 | return 1 |
| 1077 | fed = f", federated as {hub.federation.id}" + (f" following {len(hub.federation.following)}" if hub.federation.following else "") + (", listed" if hub.federation.listed else "") if hub.federation else "" |
| 1078 | local = f", holding {len(hub.local.trees)} tree(s)" if hub.local else "" |
| 1079 | if hub.local is not None and hub.local.service.watcher is not None: |
| 1080 | local += f", watching {len(hub.local.service.watcher)}" |
| 1081 | if hub.handout_registry is not None: |
| 1082 | local += f", handing out {len(hub.handout_registry.trees())} tree(s) to trusted members" |
| 1083 | if hub.lobby is not None: |
| 1084 | local += ", chatting at /chat" + (" (open to callers without the token, people only)" if hub.chat_public and args.token else "") |
| 1085 | if hub.lobby.reach: |
| 1086 | local += f", linking with up to {hub.lobby.reach} other open lobbies" if hub.lobby.open() else ", but --chat-reach links only an open lobby (--chat-public, or no token)" |
| 1087 | if hub.lan is not None: |
| 1088 | lan = hub.lan.view() |
| 1089 | local += f", on the LAN ({lan['group']}): " + (f"cannot join it: {hub.lan.error}" if hub.lan.error else f"heard {len(lan['hubs'])} hub(s)" + ("" if lan["announcing"] else "; listening only, since this hub's id is not a LAN address (give --advertise http://<its LAN address>:PORT)")) |
| 1090 | if len(hub.grants): |
| 1091 | local += f", granting {len(hub.grants)} identit{'y' if len(hub.grants) == 1 else 'ies'}" |
| 1092 | print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s){local}{fed}" + _access_note(args), flush=True) |
| 1093 | try: |
| 1094 | server.wait() |
| 1095 | except KeyboardInterrupt: |
| 1096 | pass |
| 1097 | finally: |
| 1098 | hub.stop() |
| 1099 | server.stop() |
| 1100 | return 0 |
| 1101 | |
| 1102 | |
| 1103 | def cmd_gaps(args: argparse.Namespace) -> int: |
| 1104 | if not args.at: |
| 1105 | print("pyrtan gaps: give --at URL of a running service or hub", file=sys.stderr) |
| 1106 | return 2 |
| 1107 | try: |
| 1108 | status, body = _call(args, "GET", f"{args.at.rstrip('/')}/gaps") |
| 1109 | except Exception as e: |
| 1110 | print(f"pyrtan: {args.at}: {e}", file=sys.stderr) |
| 1111 | return 3 |
| 1112 | if status == 401: |
| 1113 | print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr) |
| 1114 | return 3 |
| 1115 | gaps = (body or {}).get("gaps", []) if isinstance(body, dict) else [] |
| 1116 | if args.json: |
| 1117 | print(json.dumps({"gaps": gaps}, indent=2, ensure_ascii=False)) |
| 1118 | return 0 |
| 1119 | if not gaps: |
| 1120 | print("no gaps: every decision so far had a branch") |
| 1121 | return 0 |
| 1122 | for g in gaps: |
| 1123 | peer = f" ({g['peer']})" if g.get("peer") else "" |
| 1124 | times = f" ×{g['count']}" if g.get("count", 1) > 1 else "" |
| 1125 | print(f"{g['tree']}.{g['node']}: {g['on']} = {json.dumps(g['observed'], ensure_ascii=False)}{times}{peer}") |
| 1126 | return 0 |
| 1127 | |
| 1128 | |
| 1129 | def cmd_reports(args: argparse.Namespace) -> int: |
| 1130 | source = f"{args.at.rstrip('/')}/reports" if args.at else (os.environ.get("PYRTAN_REPORTS") or "https://pyrtan.com/rendezvous/reports.json") |
| 1131 | try: |
| 1132 | if source.startswith(("http://", "https://")): |
| 1133 | status, body = http_json("GET", source) |
| 1134 | else: |
| 1135 | status, body = 200, json.loads(Path(source).read_text(encoding="utf-8")) # a baked reports.json on disk |
| 1136 | except Exception as e: |
| 1137 | print(f"pyrtan: {source}: {e}", file=sys.stderr) |
| 1138 | return 3 |
| 1139 | if status != 200 or not isinstance(body, dict): |
| 1140 | print(f"pyrtan: {source} answered {status}", file=sys.stderr) |
| 1141 | return 3 |
| 1142 | reports = [r for r in body.get("reports", []) if isinstance(r, dict)] |
| 1143 | if args.id: |
| 1144 | reports = [r for r in reports if str(r.get("id", "")).split("@")[0] == args.id] |
| 1145 | if args.json: |
| 1146 | print(json.dumps({"source": source, "reports": reports}, indent=2, ensure_ascii=False)) |
| 1147 | return 0 |
| 1148 | if not reports: |
| 1149 | print(f"no gap reports at {source}" + (f" for {args.id}" if args.id else "")) |
| 1150 | return 0 |
| 1151 | for r in reports: |
| 1152 | who = f", {r['reporters']} hub(s)" if r.get("reporters", 1) > 1 else "" |
| 1153 | print(f"{r['id']} {r['node']}: {r['on']} = {json.dumps(r.get('observed'), ensure_ascii=False)} ×{r.get('count', 1)}{who}") |
| 1154 | return 0 |
| 1155 | |
| 1156 | |
| 1157 | def cmd_follow(args: argparse.Namespace) -> int: |
| 1158 | if not args.url: |
| 1159 | if args.network: |
| 1160 | print("pyrtan follow: --network needs a hub URL to follow", file=sys.stderr) |
| 1161 | return 2 |
| 1162 | args.invite = "default" |
| 1163 | return cmd_join(args) |
| 1164 | payload = {"url": args.url, "listed": bool(args.listed)} |
| 1165 | if args.network: |
| 1166 | payload["network"] = args.network |
| 1167 | return _hub_post(args, "/follow", payload, lambda b: f"{args.at} now follows {b['following']['url']} ({b['following']['state']})" + (f" in {args.network}" if args.network else "") + "; it routes there once accepted" + (" and is listed in the directory" if b.get("listed") else "")) |
| 1168 | |
| 1169 | |
| 1170 | def cmd_join(args: argparse.Namespace) -> int: |
| 1171 | def said(b: dict[str, Any]) -> str: |
| 1172 | lines = [f"{args.at}: {b.get('say')}" + (" and listed in the directory" if args.listed else "")] |
| 1173 | if b.get("invite") and not b.get("public"): |
| 1174 | lines.append(f"invite others: pyrtan join {b['invite']}") |
| 1175 | return "\n".join(lines) |
| 1176 | |
| 1177 | code = _hub_post(args, "/join", {"invite": args.invite, "listed": bool(args.listed)}, said) |
| 1178 | if code == 1 and args.invite == "default": |
| 1179 | tried = bootstrap_candidates() |
| 1180 | print("pyrtan join: no listed or snapshot hub answers" + (f" (tried {', '.join(tried)})" if tried else " (none listed; set $PYRTAN_HUB_LIST or $PYRTAN_RENDEZVOUS)") + "; give an invite or a hub URL", file=sys.stderr) |
| 1181 | return 3 |
| 1182 | return code |
| 1183 | |
| 1184 | |
| 1185 | def cmd_network(args: argparse.Namespace) -> int: |
| 1186 | if args.action in ("new", "leave") and not args.name: |
| 1187 | print(f"pyrtan network {args.action}: give the network's name", file=sys.stderr) |
| 1188 | return 2 |
| 1189 | if args.action == "new": |
| 1190 | return _hub_post(args, "/networks", {"name": args.name, "public": bool(args.public)}, lambda b: f"{args.at} is in {b['network']} ({b['id']}), a network of one.\ninvite others: pyrtan join {b['invite']}\nthe invite holds the network's secret: share it only with the hubs you want in it") |
| 1191 | if args.action == "leave": |
| 1192 | return _hub_post(args, "/leave", {"network": args.name}, lambda b: f"{args.at} left {args.name}" if b.get("left") else f"{args.at} was not in {args.name}") |
| 1193 | at = args.at.rstrip("/") |
| 1194 | try: |
| 1195 | status, body = _call(args, "GET", f"{at}/networks") |
| 1196 | except Exception as e: |
| 1197 | print(f"pyrtan: {at}: {e}", file=sys.stderr) |
| 1198 | return 3 |
| 1199 | if status == 401: |
| 1200 | print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr) |
| 1201 | return 3 |
| 1202 | if status != 200 or not isinstance(body, dict): |
| 1203 | print(f"pyrtan: {at} is not a federated hub ({status})", file=sys.stderr) |
| 1204 | return 3 |
| 1205 | if args.json: |
| 1206 | print(json.dumps(body, indent=2, ensure_ascii=False)) |
| 1207 | return 0 |
| 1208 | nets = body.get("networks", []) |
| 1209 | print(f"{at} is in {len(nets)} network(s)") |
| 1210 | for n in nets: |
| 1211 | print(f"{n['name']:16} {n['id']} {'public' if n['public'] else 'private'} following {len(n['following'])}, followed by {len(n['followers'])}") |
| 1212 | print(f" invite: {n['invite']}") |
| 1213 | return 0 |
| 1214 | |
| 1215 | |
| 1216 | def cmd_keep(args: argparse.Namespace) -> int: |
| 1217 | try: |
| 1218 | data = json.loads(Path(args.file).read_text(encoding="utf-8") if args.file else sys.stdin.read()) |
| 1219 | except (OSError, json.JSONDecodeError) as e: |
| 1220 | print(f"pyrtan keep: {e}", file=sys.stderr) |
| 1221 | return 2 |
| 1222 | return _hub_post(args, "/keep", {"key": args.key, "data": data}, lambda b: f"{args.at} keeps {b['keeping']['key']} ({b['keeping']['size']} bytes); held by {len(b['keeping']['holders'])} of {b['keeping']['wanted']} wanted keeper(s)") |
| 1223 | |
| 1224 | |
| 1225 | def cmd_kept(args: argparse.Namespace) -> int: |
| 1226 | at = args.at.rstrip("/") |
| 1227 | try: |
| 1228 | status, body = _call(args, "GET", f"{at}/keep") |
| 1229 | except Exception as e: |
| 1230 | print(f"pyrtan: {at}: {e}", file=sys.stderr) |
| 1231 | return 3 |
| 1232 | if status == 401: |
| 1233 | print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr) |
| 1234 | return 3 |
| 1235 | if status != 200 or not isinstance(body, dict): |
| 1236 | print(f"pyrtan: {at} is not a federated hub ({status})", file=sys.stderr) |
| 1237 | return 3 |
| 1238 | if args.json: |
| 1239 | print(json.dumps(body, indent=2, ensure_ascii=False)) |
| 1240 | return 0 |
| 1241 | print(f"{at} keeps {len(body['keeping'])} blob(s) of its own, wants {body['replicas']} keeper(s) each; keeps {len(body['kept'])} for {body['store']['actors']} other(s)") |
| 1242 | for k in body["keeping"]: |
| 1243 | print(f" {k['key']:24} {k['size']:>8} B held by {len(k['holders'])}/{k['wanted']}" + (f" ({', '.join(k['holders'])})" if k["holders"] else " (nobody yet)")) |
| 1244 | for k in body["kept"]: |
| 1245 | print(f" for {k['actor']}: {k['key']} ({k['size']} B)") |
| 1246 | return 0 |
| 1247 | |
| 1248 | |
| 1249 | def cmd_fetch(args: argparse.Namespace) -> int: |
| 1250 | at = args.at.rstrip("/") |
| 1251 | try: |
| 1252 | status, body = _call(args, "POST", f"{at}/fetch", {"key": args.key}) |
| 1253 | except Exception as e: |
| 1254 | print(f"pyrtan: {at}: {e}", file=sys.stderr) |
| 1255 | return 3 |
| 1256 | if status == 401: |
| 1257 | print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr) |
| 1258 | return 3 |
| 1259 | if status != 200 or not isinstance(body, dict): |
| 1260 | print(f"pyrtan: {at} answered {status}", file=sys.stderr) |
| 1261 | return 3 |
| 1262 | if body.get("status") == "asked": |
| 1263 | print(f"pyrtan fetch: {body.get('say')}", file=sys.stderr) |
| 1264 | return 4 |
| 1265 | if body.get("status") != "ok": |
| 1266 | print(f"pyrtan fetch: {body.get('say') or body.get('reason')}", file=sys.stderr) |
| 1267 | return 1 |
| 1268 | blob = body["blob"] |
| 1269 | text = json.dumps(blob["data"], indent=2, ensure_ascii=False) |
| 1270 | if args.out: |
| 1271 | Path(args.out).write_text(text + "\n", encoding="utf-8") |
| 1272 | print(f"{blob['key']} ({blob['size']} bytes, from {body['source']}) -> {args.out}") |
| 1273 | else: |
| 1274 | print(text) |
| 1275 | return 0 |
| 1276 | |
| 1277 | |
| 1278 | def cmd_directory(args: argparse.Namespace) -> int: |
| 1279 | tried: list[str] = [] |
| 1280 | candidates = [args.at] if args.at else [os.environ.get("PYRTAN_AT") or "http://127.0.0.1:8700", *bootstrap_candidates()] |
| 1281 | body: Any = None |
| 1282 | at = "" |
| 1283 | for at in candidates: |
| 1284 | at = at.rstrip("/") |
| 1285 | tried.append(at) |
| 1286 | try: |
| 1287 | status, body = http_json("GET", f"{at}/directory", timeout=5.0) |
| 1288 | except Exception: |
| 1289 | body = None |
| 1290 | continue |
| 1291 | if status == 200 and isinstance(body, dict) and "hubs" in body: |
| 1292 | break |
| 1293 | body = None |
| 1294 | if body is None and not args.at: |
| 1295 | # Nobody live: the snapshot pyrtan.com bakes from the hub list, with its age. |
| 1296 | source = os.environ.get("PYRTAN_SNAPSHOT") or "https://pyrtan.com/rendezvous/directory.json" |
| 1297 | try: |
| 1298 | if source.startswith(("http://", "https://")): |
| 1299 | status, snap = http_json("GET", source, timeout=10.0) |
| 1300 | else: |
| 1301 | status, snap = 200, json.loads(Path(source).read_text(encoding="utf-8")) |
| 1302 | except Exception: |
| 1303 | status, snap = 0, None |
| 1304 | if status == 200 and isinstance(snap, dict) and isinstance(snap.get("hubs"), list): |
| 1305 | import time as _time |
| 1306 | |
| 1307 | now = _time.time() |
| 1308 | for h in snap["hubs"]: |
| 1309 | age = now - float(h.get("seen", 0) or 0) |
| 1310 | h["link"] = "up" if age < 600 else "stale" if age < 86400 else "down" |
| 1311 | body = {**snap, "name": f"snapshot as of {snap.get('as_of', '?')}", "hub": source} |
| 1312 | at = source |
| 1313 | if body is None: |
| 1314 | print(f"pyrtan directory: no federated hub answered (tried {', '.join(tried)})", file=sys.stderr) |
| 1315 | return 3 |
| 1316 | if args.json: |
| 1317 | print(json.dumps(body, indent=2, ensure_ascii=False)) |
| 1318 | return 0 |
| 1319 | hubs = body.get("hubs", []) |
| 1320 | print(f"directory as {body.get('name') or at} ({body.get('hub', at)}) knows it: {len(hubs)} hub(s)") |
| 1321 | for h in hubs: |
| 1322 | print(f"{h['link']:8} {h['name'] or '-':16} {h['id']} {', '.join(h.get('trees', []))}") |
| 1323 | print("to follow one: pyrtan follow URL --at YOUR-HUB; to be in it: pyrtan follow --listed, or pyrtan hub --listed") |
| 1324 | return 0 |
| 1325 | |
| 1326 | |
| 1327 | def cmd_unfollow(args: argparse.Namespace) -> int: |
| 1328 | return _hub_post(args, "/unfollow", {"url": args.url}, lambda b: f"{args.at} no longer follows {args.url}" if b.get("removed") else f"{args.at} was not following {args.url}") |
| 1329 | |
| 1330 | |
| 1331 | def _hub_post(args: argparse.Namespace, path: str, payload: dict[str, Any], said) -> int: |
| 1332 | at = args.at.rstrip("/") |
| 1333 | try: |
| 1334 | status, body = _call(args, "POST", f"{at}{path}", payload) |
| 1335 | except Exception as e: |
| 1336 | print(f"pyrtan: {at}: {e}", file=sys.stderr) |
| 1337 | return 3 |
| 1338 | if status == 401: |
| 1339 | said_401 = body.get("say") if isinstance(body, dict) and getattr(args, "sign", False) else None |
| 1340 | print(f"pyrtan: {at}: {said_401}" if said_401 else f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr) |
| 1341 | return 3 |
| 1342 | if status == 404: |
| 1343 | print(f"pyrtan: {at} is not a federated hub; start it with --federate", file=sys.stderr) |
| 1344 | return 3 |
| 1345 | if status != 200 or not isinstance(body, dict) or body.get("status") != "ok": |
| 1346 | print(f"pyrtan: {at} answered {status}: {(body or {}).get('say') if isinstance(body, dict) else body}", file=sys.stderr) |
| 1347 | return 1 |
| 1348 | print(said(body)) |
| 1349 | return 0 |
| 1350 | |
| 1351 | |
| 1352 | def cmd_peers(args: argparse.Namespace) -> int: |
| 1353 | at = args.at.rstrip("/") |
| 1354 | try: |
| 1355 | status, body = _call(args, "GET", f"{at}/peers") |
| 1356 | except Exception as e: |
| 1357 | print(f"pyrtan: {at}: {e}", file=sys.stderr) |
| 1358 | return 3 |
| 1359 | if status == 401: |
| 1360 | print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr) |
| 1361 | return 3 |
| 1362 | if status != 200 or not isinstance(body, dict): |
| 1363 | print(f"pyrtan: {at} answered {status}", file=sys.stderr) |
| 1364 | return 3 |
| 1365 | if args.json: |
| 1366 | print(json.dumps(body, indent=2, ensure_ascii=False)) |
| 1367 | return 0 |
| 1368 | for p in body.get("peers", []): |
| 1369 | print(f"{p['link']:8} service {p['name'] or '-':12} {p['url']} {', '.join(p.get('trees', []))}") |
| 1370 | for r in body.get("following", []): |
| 1371 | print(f"{r['link']:8} follows {r['name'] or '-':12} {r['url']} {', '.join(r.get('trees', []))}" + (f" ({r['last_error']})" if r.get("last_error") else "")) |
| 1372 | for r in body.get("followers", []): |
| 1373 | print(f"{r['link']:8} follower {r['name'] or '-':12} {r['url']}") |
| 1374 | if not any(body.get(k) for k in ("peers", "following", "followers")): |
| 1375 | print("no peers") |
| 1376 | return 0 |
| 1377 | |
| 1378 | |
| 1379 | def _access_note(args: argparse.Namespace) -> str: |
| 1380 | notes = [] |
| 1381 | if getattr(args, "token", None): |
| 1382 | notes.append("token required") |
| 1383 | if getattr(args, "cors", None): |
| 1384 | notes.append("cors: " + ", ".join(args.cors)) |
| 1385 | if getattr(args, "egress", "any") != "any": |
| 1386 | notes.append(f"egress: {args.egress}") |
| 1387 | if getattr(args, "federation_secret", None): |
| 1388 | notes.append("follows need the federation secret") |
| 1389 | elif getattr(args, "closed", False): |
| 1390 | notes.append("no follows taken") |
| 1391 | return f" ({'; '.join(notes)})" if notes else "" |
| 1392 | |
| 1393 | |
| 1394 | # ---------------------------------------------------------------- catalog |
| 1395 | |
| 1396 | |
| 1397 | def cmd_search(args: argparse.Namespace) -> int: |
| 1398 | cat = Catalog(args.catalog) |
| 1399 | try: |
| 1400 | found = cat.search(" ".join(args.words)) |
| 1401 | except CatalogError as e: |
| 1402 | print(f"pyrtan: {e}", file=sys.stderr) |
| 1403 | return 3 |
| 1404 | if args.json: |
| 1405 | print(json.dumps({"catalog": cat.base, "trees": found}, indent=2, ensure_ascii=False)) |
| 1406 | return 0 |
| 1407 | if not found: |
| 1408 | print(f"nothing in {cat.base} matches {' '.join(args.words)!r}") |
| 1409 | return 1 |
| 1410 | for e in found: |
| 1411 | reach = ", ".join(e.get("hosts", []) + [s for s in e.get("sources", []) if s != "http"]) |
| 1412 | print(f"{e.get('id', e['name'])}@{e.get('version', '?')} {e.get('description', '')}" + (f" [{reach}]" if reach else "")) |
| 1413 | for t in e.get("triggers", [])[:3]: |
| 1414 | print(f" {t}") |
| 1415 | return 0 |
| 1416 | |
| 1417 | |
| 1418 | def cmd_pull(args: argparse.Namespace) -> int: |
| 1419 | cat = Catalog(args.catalog) |
| 1420 | into = Path(args.dir) if args.dir else default_paths()[0] |
| 1421 | worst = 0 |
| 1422 | for spec in args.spec: |
| 1423 | try: |
| 1424 | target = cat.pull(spec, into, force=args.force) |
| 1425 | except CatalogError as e: |
| 1426 | print(f"pyrtan: {e}", file=sys.stderr) |
| 1427 | worst = 1 |
| 1428 | continue |
| 1429 | tree = load_tree(target) |
| 1430 | reach = ", ".join(tree.hosts + [s for s in tree.sources() if s != "http"]) |
| 1431 | print(f"{tree.id}@{tree.version} -> {target}" + (f" (reaches: {reach})" if reach else "")) |
| 1432 | return worst |
| 1433 | |
| 1434 | |
| 1435 | def cmd_index(args: argparse.Namespace) -> int: |
| 1436 | roots = [Path(d) for d in args.dir] |
| 1437 | for root in roots: |
| 1438 | if not root.is_dir(): |
| 1439 | print(f"pyrtan: {root}: not a directory", file=sys.stderr) |
| 1440 | return 2 |
| 1441 | if len(roots) > 1 and not args.copy: |
| 1442 | print("pyrtan index: more than one directory needs --copy DIR, so the index has one place to point at", file=sys.stderr) |
| 1443 | return 2 |
| 1444 | index, problems = build_index(roots, require_license=not args.allow_unlicensed, copy_to=args.copy, require_namespace=args.require_namespace) |
| 1445 | for p in problems: |
| 1446 | print(f"{p}", file=sys.stderr) |
| 1447 | text = json.dumps(index, indent=2, ensure_ascii=False) + "\n" |
| 1448 | if args.out == "-": |
| 1449 | sys.stdout.write(text) |
| 1450 | else: |
| 1451 | out = Path(args.out) if args.out else (Path(args.copy) if args.copy else roots[0]) / "index.json" |
| 1452 | out.parent.mkdir(parents=True, exist_ok=True) |
| 1453 | out.write_text(text, encoding="utf-8") |
| 1454 | # The same trees as JSON, for the console in a browser (it reads JSON, not TOML). |
| 1455 | bundle = build_bundle(index, Path(args.copy) if args.copy else roots[0]) |
| 1456 | (out.parent / "trees.json").write_text(json.dumps(bundle, ensure_ascii=False, separators=(",", ":"), default=str) + "\n", encoding="utf-8") |
| 1457 | print(f"wrote {out} and trees.json beside it: {index['count']} tree(s)" + (f", copied to {args.copy}" if args.copy else "") + (f", {len(problems)} left out" if problems else "")) |
| 1458 | return 1 if problems else 0 |
| 1459 | |
| 1460 | |
| 1461 | def cmd_publish(args: argparse.Namespace) -> int: |
| 1462 | """Copy a tree into a checkout's catalog/<namespace>/, write OWNERS for |
| 1463 | a new namespace, run the gate, and say what to do next. The pull |
| 1464 | request is the publishing; this prepares it.""" |
| 1465 | from .catalog import publish |
| 1466 | |
| 1467 | try: |
| 1468 | placed, notes = publish(args.file, args.into, owners=args.owner) |
| 1469 | except CatalogError as e: |
| 1470 | print(f"pyrtan publish: {e}", file=sys.stderr) |
| 1471 | return 1 |
| 1472 | for n in notes: |
| 1473 | print(n) |
| 1474 | print(f"placed {placed}") |
| 1475 | rel = placed.relative_to(Path(args.into).resolve()) |
| 1476 | print("next, from that checkout:") |
| 1477 | print(f" git checkout -b catalog/{rel.parent.name}-{rel.stem}") |
| 1478 | print(f" git add {rel.parent}") |
| 1479 | print(f" git commit -m \"catalog: {rel.parent.name}/{rel.stem}\"") |
| 1480 | print(" git push -u origin HEAD") |
| 1481 | print("then open a pull request; the catalog-gate workflow runs the same gate (main's copy) and a maintainer merges.") |
| 1482 | return 0 |
| 1483 | |
| 1484 | |
| 1485 | def cmd_console(args: argparse.Namespace) -> int: |
| 1486 | if args.out == "-": |
| 1487 | sys.stdout.write(PAGE) |
| 1488 | return 0 |
| 1489 | out = Path(args.out) |
| 1490 | out.parent.mkdir(parents=True, exist_ok=True) |
| 1491 | out.write_text(PAGE, encoding="utf-8") |
| 1492 | (out.parent / "manifest.webmanifest").write_text(MANIFEST, encoding="utf-8") |
| 1493 | (out.parent / "sw.js").write_text(SERVICE_WORKER, encoding="utf-8") |
| 1494 | (out.parent / "pyrtan.js").write_text(PYRTAN_JS, encoding="utf-8") |
| 1495 | print(f"wrote {out}, manifest.webmanifest, sw.js and pyrtan.js beside it; serve the folder from any static host: there the browser is a hub of its own, with a catalog's trees.json (../catalog/ beside it, else pyrtan.com's), until it joins a hub started with --cors for that host") |
| 1496 | return 0 |
| 1497 | |
| 1498 | |
| 1499 | def cmd_unanswered(args: argparse.Namespace) -> int: |
| 1500 | if not args.at: |
| 1501 | print("pyrtan unanswered: give --at URL of a running service or hub", file=sys.stderr) |
| 1502 | return 2 |
| 1503 | try: |
| 1504 | status, body = _call(args, "GET", f"{args.at.rstrip('/')}/unanswered") |
| 1505 | except Exception as e: |
| 1506 | print(f"pyrtan: {args.at}: {e}", file=sys.stderr) |
| 1507 | return 3 |
| 1508 | if status == 401: |
| 1509 | print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr) |
| 1510 | return 3 |
| 1511 | entries = (body or {}).get("unanswered", []) if isinstance(body, dict) else [] |
| 1512 | if args.json: |
| 1513 | print(json.dumps({"unanswered": entries}, indent=2, ensure_ascii=False)) |
| 1514 | return 0 |
| 1515 | if not entries: |
| 1516 | print("nothing unanswered: every sentence so far found a tree") |
| 1517 | return 0 |
| 1518 | for e in entries: |
| 1519 | prop = e.get("proposal") or propose(e["text"]).to_dict() |
| 1520 | peer = f" ({e['peer']})" if e.get("peer") else "" |
| 1521 | print(f"{e['text']!r} x{e.get('count', 1)}{peer}") |
| 1522 | print(f" would be: {prop['name']} answering {prop['trigger']}") |
| 1523 | if args.scaffold: |
| 1524 | path = _write_scaffold(Proposal(prop["text"], prop["name"], prop["trigger"], prop.get("inputs") or {}), Path(args.scaffold)) |
| 1525 | print(f" {'wrote' if path else 'kept'} {Path(args.scaffold) / (prop['name'] + '.toml')}") |
| 1526 | else: |
| 1527 | print(f" {prop['scaffold']}") |
| 1528 | if args.scaffold: |
| 1529 | print(f"now: pyrtan check {args.scaffold}") |
| 1530 | return 0 |
| 1531 | |
| 1532 | |
| 1533 | def _write_scaffold(proposal: Proposal, directory: Path) -> Path | None: |
| 1534 | """Write the proposal's tree file unless one is already there.""" |
| 1535 | path = directory / f"{proposal.name}.toml" |
| 1536 | if path.exists(): |
| 1537 | return None |
| 1538 | path.parent.mkdir(parents=True, exist_ok=True) |
| 1539 | path.write_text(proposal.scaffold(), encoding="utf-8") |
| 1540 | return path |
| 1541 | |
| 1542 | |
| 1543 | def cmd_new(args: argparse.Namespace) -> int: |
| 1544 | if not args.name and not args.sentence: |
| 1545 | print("pyrtan new: give a NAME, or --from \"the sentence it should answer\"", file=sys.stderr) |
| 1546 | return 2 |
| 1547 | proposal = propose(args.sentence or args.name.replace("-", " "), name=args.name) |
| 1548 | path = Path(args.dir) / f"{proposal.name}.toml" |
| 1549 | if path.exists(): |
| 1550 | print(f"pyrtan: {path} exists", file=sys.stderr) |
| 1551 | return 1 |
| 1552 | _write_scaffold(proposal, Path(args.dir)) |
| 1553 | print(f"wrote {path}: {proposal.name!r} answering {proposal.trigger!r}; edit it, then: pyrtan check {path}") |
| 1554 | return 0 |
| 1555 | |
| 1556 | |
| 1557 | # ---------------------------------------------------------------- pyrtan id |
| 1558 | |
| 1559 | |
| 1560 | def cmd_id(args: argparse.Namespace) -> int: |
| 1561 | from . import identity |
| 1562 | directory = args.dir or identity.home() |
| 1563 | try: |
| 1564 | return { |
| 1565 | "new": _id_new, "show": _id_show, "check": _id_check, "device": _id_device, |
| 1566 | "revoke": _id_revoke, "route": _id_route, "name": _id_name, |
| 1567 | "resolve": _id_resolve, "pins": _id_pins, "wellknown": _id_wellknown, "push": _id_push, |
| 1568 | "backup": _id_backup, "restore": _id_restore, "release": _id_release, |
| 1569 | }[args.id_command](args, directory) |
| 1570 | except IdentityError as e: |
| 1571 | print(f"pyrtan id: {e}", file=sys.stderr) |
| 1572 | return 1 |
| 1573 | |
| 1574 | |
| 1575 | def _id_load(directory: Path): |
| 1576 | """Your root key and your current identity document.""" |
| 1577 | from . import identity |
| 1578 | key_file, doc_file = directory / "root.key", directory / "identity.json" |
| 1579 | if not key_file.exists(): |
| 1580 | raise IdentityError(f"no identity in {directory}: make one with `pyrtan id new`") |
| 1581 | key = identity.Key.load(key_file) |
| 1582 | try: |
| 1583 | doc = json.loads(doc_file.read_text(encoding="utf-8")) |
| 1584 | except (OSError, json.JSONDecodeError) as e: |
| 1585 | raise IdentityError(f"{doc_file}: {e}") from None |
| 1586 | problem = identity.check_identity(doc, ident=key.id) |
| 1587 | if problem: |
| 1588 | raise IdentityError(f"{doc_file}: {problem}") |
| 1589 | return key, doc |
| 1590 | |
| 1591 | |
| 1592 | def _id_save(directory: Path, doc: dict[str, Any]) -> None: |
| 1593 | path = directory / "identity.json" |
| 1594 | temp = path.with_suffix(".json.tmp") |
| 1595 | temp.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") |
| 1596 | temp.replace(path) |
| 1597 | |
| 1598 | |
| 1599 | def _id_new(args: argparse.Namespace, directory: Path) -> int: |
| 1600 | from . import identity |
| 1601 | key_file = directory / "root.key" |
| 1602 | if key_file.exists(): |
| 1603 | print(f"pyrtan id: {key_file} exists; an identity is not replaced (move it away first, knowing that it is you)", file=sys.stderr) |
| 1604 | return 1 |
| 1605 | key = identity.Key.generate() |
| 1606 | doc = identity.make_identity(key, kind=args.kind, routes=args.route) |
| 1607 | key.save(key_file) |
| 1608 | _id_save(directory, doc) |
| 1609 | print(f"you are {key.id}") |
| 1610 | print(f"the key is {key_file} (readable only by you). It is the identity: lose it and nobody can give it back,") |
| 1611 | print("copy it and whoever has the copy is you. Back it up somewhere offline.") |
| 1612 | print(f"the document hubs and name providers hold is {directory / 'identity.json'}") |
| 1613 | return 0 |
| 1614 | |
| 1615 | |
| 1616 | def _id_show(args: argparse.Namespace, directory: Path) -> int: |
| 1617 | from . import identity |
| 1618 | _, doc = _id_load(directory) |
| 1619 | if args.json: |
| 1620 | print(json.dumps(doc, indent=2, ensure_ascii=False)) |
| 1621 | return 0 |
| 1622 | print(f"{doc['id']} ({doc['kind']}, updated {time.strftime('%Y-%m-%d %H:%M', time.localtime(doc['updated']))})") |
| 1623 | print("routes: " + (", ".join(doc["routes"]) if doc["routes"] else "none (add one with `pyrtan id route add HOST`)")) |
| 1624 | print(f"devices: {len(doc['devices'])}" + ("" if doc["devices"] else " (add one with `pyrtan id device add pyr:…`)")) |
| 1625 | for note in doc["devices"]: |
| 1626 | status = identity.device_status(doc, note["device"]) |
| 1627 | until = time.strftime("%Y-%m-%d", time.localtime(note["expires"])) |
| 1628 | print(f" {note['device']} {note['label'] or '-':10} may {','.join(note['may']) or 'nothing'} {status} until {until}") |
| 1629 | for device in doc["revoked"]: |
| 1630 | print(f" {device} revoked") |
| 1631 | return 0 |
| 1632 | |
| 1633 | |
| 1634 | def _id_check(args: argparse.Namespace, directory: Path) -> int: |
| 1635 | from . import identity |
| 1636 | thing = args.thing |
| 1637 | if thing != "-" and not Path(thing).is_file(): |
| 1638 | parsed = identity.parse(thing) |
| 1639 | if parsed.form == "name": |
| 1640 | print(f"{parsed}: a well-formed name; `pyrtan id resolve {parsed}` asks {parsed.domain} who it is") |
| 1641 | else: |
| 1642 | print(f"{parsed}: a well-formed {parsed.form}, key {identity.decode(parsed.id).hex()}") |
| 1643 | return 0 |
| 1644 | try: |
| 1645 | doc = json.loads(sys.stdin.read() if thing == "-" else Path(thing).read_text(encoding="utf-8")) |
| 1646 | except (OSError, json.JSONDecodeError) as e: |
| 1647 | raise IdentityError(f"{thing}: {e}") from None |
| 1648 | kind, problem = identity.check_any(doc) |
| 1649 | if problem: |
| 1650 | print(f"{thing}: {kind}: refused: {problem}") |
| 1651 | return 1 |
| 1652 | if kind == "identity": |
| 1653 | print(f"{thing}: the identity document of {doc['id']}, signed by it; {len(doc['devices'])} device(s), {len(doc['revoked'])} revoked") |
| 1654 | elif kind == "device": |
| 1655 | print(f"{thing}: {doc['device']} may {','.join(doc['may']) or 'nothing'} for {doc['root']}, until {time.strftime('%Y-%m-%d', time.localtime(doc['expires']))}") |
| 1656 | elif kind == "release": |
| 1657 | print(f"{thing}: {doc['id']} gives up {doc['name']}@{doc['domain']}, signed by it") |
| 1658 | elif kind == "backup": |
| 1659 | print(f"{thing}: a key backup of {doc['id']}, {doc['kdf']['iterations']} PBKDF2 rounds" + (", with its identity document" if doc.get("document") else "") + "; `pyrtan id restore` opens it") |
| 1660 | else: |
| 1661 | print(f"{thing}: {doc['id']} claims {doc['name']}@{doc['domain']}, signed by it") |
| 1662 | return 0 |
| 1663 | |
| 1664 | |
| 1665 | def _id_device(args: argparse.Namespace, directory: Path) -> int: |
| 1666 | from . import identity |
| 1667 | if not 0 < args.days <= 365: |
| 1668 | raise IdentityError("--days is from 1 to 365") |
| 1669 | key, doc = _id_load(directory) |
| 1670 | may = [a for a in args.may.replace(" ", "").split(",") if a] |
| 1671 | issued = int(time.time()) |
| 1672 | note = identity.make_device(key, args.device, may, label=args.label, issued=issued, expires=issued + args.days * 86400) |
| 1673 | _id_save(directory, identity.with_device(doc, key, note)) |
| 1674 | print(json.dumps(note, ensure_ascii=False)) |
| 1675 | print(f"{note['device']} may {','.join(note['may']) or 'nothing'} for you until {time.strftime('%Y-%m-%d', time.localtime(note['expires']))}; give the device the note above", file=sys.stderr) |
| 1676 | return 0 |
| 1677 | |
| 1678 | |
| 1679 | def _id_revoke(args: argparse.Namespace, directory: Path) -> int: |
| 1680 | from . import identity |
| 1681 | key, doc = _id_load(directory) |
| 1682 | device = identity.normal(args.device) |
| 1683 | _id_save(directory, identity.with_revoked(doc, key, device)) |
| 1684 | print(f"revoked {device}; hubs drop it when they next read your document") |
| 1685 | return 0 |
| 1686 | |
| 1687 | |
| 1688 | def _id_route(args: argparse.Namespace, directory: Path) -> int: |
| 1689 | from . import identity |
| 1690 | key, doc = _id_load(directory) |
| 1691 | host = identity.check_locator(args.host) |
| 1692 | routes = [r for r in doc["routes"] if r != host] + ([host] if args.action == "add" else []) |
| 1693 | _id_save(directory, identity.with_routes(doc, key, routes)) |
| 1694 | print("routes: " + (", ".join(routes) or "none")) |
| 1695 | return 0 |
| 1696 | |
| 1697 | |
| 1698 | def _id_name(args: argparse.Namespace, directory: Path) -> int: |
| 1699 | from . import identity, names |
| 1700 | key, doc = _id_load(directory) |
| 1701 | network = names.repo_domain(args.into) if args.into else None |
| 1702 | github = args.github |
| 1703 | if args.into and network is None and github is None: |
| 1704 | local = identity.parse(args.name).name |
| 1705 | github = names.github_id(local) |
| 1706 | print(f"GitHub says {local} is user {github}", file=sys.stderr) |
| 1707 | extra = {"github": github} if github is not None else {} |
| 1708 | old = identity.Key.load(args.handover_from) if args.handover_from else None |
| 1709 | claim = identity.make_name(key, args.name, handover_from=old, **extra) |
| 1710 | text = json.dumps(claim, indent=2, ensure_ascii=False) + "\n" |
| 1711 | if args.into: |
| 1712 | written = names.place(args.into, claim, doc) |
| 1713 | problems = names.gate_key(args.into / "names", None, network) if network else names.gate(args.into) |
| 1714 | for p in problems: |
| 1715 | print(f"gate: {p}", file=sys.stderr) |
| 1716 | if problems: |
| 1717 | return 1 |
| 1718 | print("wrote " + " and ".join(str(w) for w in written)) |
| 1719 | where = "names" if network else names.NAMES_DIR |
| 1720 | print(f"now: git checkout -b names/{claim['name']} && git add {where} && git commit && git push -u origin HEAD, and open a pull request" |
| 1721 | + ("" if network else f" from the GitHub account {claim['name']}")) |
| 1722 | return 0 |
| 1723 | if args.out: |
| 1724 | Path(args.out).write_text(text, encoding="utf-8") |
| 1725 | print(f"wrote {args.out}: {claim['id']} claims {claim['name']}@{claim['domain']}; the domain serves it at /.well-known/pyrtan/name/{claim['name']}.json") |
| 1726 | else: |
| 1727 | sys.stdout.write(text) |
| 1728 | return 0 |
| 1729 | |
| 1730 | |
| 1731 | def _id_release(args: argparse.Namespace, directory: Path) -> int: |
| 1732 | from . import identity |
| 1733 | key, _ = _id_load(directory) |
| 1734 | release = identity.make_release(key, args.name) |
| 1735 | text = json.dumps(release, indent=2, ensure_ascii=False) + "\n" |
| 1736 | if args.out: |
| 1737 | Path(args.out).write_text(text, encoding="utf-8") |
| 1738 | print(f"wrote {args.out}: {key.id} gives up {release['name']}@{release['domain']}; put it in place of names/{release['name']}.json") |
| 1739 | else: |
| 1740 | sys.stdout.write(text) |
| 1741 | return 0 |
| 1742 | |
| 1743 | |
| 1744 | def _id_resolve(args: argparse.Namespace, directory: Path) -> int: |
| 1745 | from . import identity, names |
| 1746 | claim = names.resolve(args.name) |
| 1747 | name, ident = f"{claim['name']}@{claim['domain']}", claim["id"] |
| 1748 | pins = names.Pins(directory / "pins.json") |
| 1749 | verdict, pinned = pins.compare(name, ident), pins.get(name) |
| 1750 | if verdict == "changed" and not args.repin: |
| 1751 | print(f"pyrtan id: {name} now says it is {ident}", file=sys.stderr) |
| 1752 | print(f" but it was pinned to {pinned['id']} on {time.strftime('%Y-%m-%d', time.localtime(pinned['first']))}", file=sys.stderr) |
| 1753 | print(" the domain changed its answer, or someone is answering for it. If you know why, pyrtan id resolve --repin", file=sys.stderr) |
| 1754 | return 1 |
| 1755 | pins.pin(name, ident) |
| 1756 | pins.save() |
| 1757 | doc = names.fetch_identity(ident, claim["domain"]) |
| 1758 | if args.json: |
| 1759 | print(json.dumps({"claim": claim, "identity": doc}, indent=2, ensure_ascii=False)) |
| 1760 | return 0 |
| 1761 | since = {"new": "pinned now", "same": f"as pinned since {time.strftime('%Y-%m-%d', time.localtime(pinned['first'])) if pinned else ''}", "changed": "re-pinned"}[verdict] |
| 1762 | print(f"{name} is {ident} ({since})") |
| 1763 | if doc: |
| 1764 | print("routes: " + (", ".join(doc["routes"]) or "none") + f"; {len(doc['devices'])} device(s), {len(doc['revoked'])} revoked") |
| 1765 | return 0 |
| 1766 | |
| 1767 | |
| 1768 | def _id_pins(args: argparse.Namespace, directory: Path) -> int: |
| 1769 | from . import names |
| 1770 | pins = names.Pins(directory / "pins.json") |
| 1771 | if args.forget: |
| 1772 | if not pins.forget(args.forget): |
| 1773 | print(f"pyrtan id: {args.forget} is not pinned", file=sys.stderr) |
| 1774 | return 1 |
| 1775 | pins.save() |
| 1776 | print(f"forgot {args.forget}; the next resolve takes what its domain says") |
| 1777 | return 0 |
| 1778 | if not pins.names: |
| 1779 | print("no pins yet: pyrtan id resolve NAME@DOMAIN") |
| 1780 | for name, pin in sorted(pins.names.items()): |
| 1781 | print(f"{name:32} {pin['id']} since {time.strftime('%Y-%m-%d', time.localtime(pin['first']))}") |
| 1782 | return 0 |
| 1783 | |
| 1784 | |
| 1785 | def _id_wellknown(args: argparse.Namespace, directory: Path) -> int: |
| 1786 | from . import identity, names |
| 1787 | domain = identity.check_host(args.domain.lower()) |
| 1788 | count, problems = names.build_wellknown(args.names, args.out, domain) |
| 1789 | for p in problems: |
| 1790 | print(f"wellknown: {p} (left out)", file=sys.stderr) |
| 1791 | print(f"wrote {count} name(s) for {domain} under {args.out / names.WELL_KNOWN}") |
| 1792 | return 1 if problems else 0 |
| 1793 | |
| 1794 | |
| 1795 | def _passphrase(args: argparse.Namespace, *, twice: bool) -> str: |
| 1796 | import getpass |
| 1797 | if args.passphrase_env: |
| 1798 | value = os.environ.get(args.passphrase_env) |
| 1799 | if not value: |
| 1800 | raise IdentityError(f"${args.passphrase_env} is empty") |
| 1801 | return value |
| 1802 | first = getpass.getpass("passphrase: ") |
| 1803 | if twice and getpass.getpass("again: ") != first: |
| 1804 | raise IdentityError("the two passphrases differ") |
| 1805 | return first |
| 1806 | |
| 1807 | |
| 1808 | def _id_backup(args: argparse.Namespace, directory: Path) -> int: |
| 1809 | from . import identity |
| 1810 | key, doc = _id_load(directory) |
| 1811 | out = Path(args.out) |
| 1812 | if out.exists(): |
| 1813 | raise IdentityError(f"{out} exists; a backup is not overwritten") |
| 1814 | backup = identity.make_backup(key, _passphrase(args, twice=True), document=doc) |
| 1815 | fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) |
| 1816 | with os.fdopen(fd, "w", encoding="utf-8") as f: |
| 1817 | f.write(json.dumps(backup, indent=2) + "\n") |
| 1818 | print(f"wrote {out}: {key.id}, encrypted with your passphrase, and your identity document") |
| 1819 | print("keep it where only you can read it (your disk, your own private repository), never in a public one: anyone with a copy can guess at the passphrase forever") |
| 1820 | return 0 |
| 1821 | |
| 1822 | |
| 1823 | def _id_restore(args: argparse.Namespace, directory: Path) -> int: |
| 1824 | from . import identity |
| 1825 | try: |
| 1826 | backup = json.loads(Path(args.file).read_text(encoding="utf-8")) |
| 1827 | except (OSError, json.JSONDecodeError) as e: |
| 1828 | raise IdentityError(f"{args.file}: {e}") from None |
| 1829 | problem = identity.backup_problem(backup) |
| 1830 | if problem: |
| 1831 | raise IdentityError(f"{args.file}: {problem}") |
| 1832 | key_file = directory / "root.key" |
| 1833 | if key_file.exists(): |
| 1834 | held = identity.Key.load(key_file) |
| 1835 | if held.id != backup["id"].lower(): |
| 1836 | raise IdentityError(f"{directory} already holds {held.id}; move it away first, knowing that it is an identity") |
| 1837 | key = identity.open_backup(backup, _passphrase(args, twice=False)) |
| 1838 | doc = backup.get("document") or identity.make_identity(key) |
| 1839 | current = None |
| 1840 | if (directory / "identity.json").exists(): |
| 1841 | try: |
| 1842 | current = json.loads((directory / "identity.json").read_text(encoding="utf-8")) |
| 1843 | except (OSError, json.JSONDecodeError): |
| 1844 | current = None |
| 1845 | doc = identity.newest([doc, current], ident=key.id) or doc |
| 1846 | if not key_file.exists(): |
| 1847 | key.save(key_file) |
| 1848 | _id_save(directory, doc) |
| 1849 | print(f"you are {key.id} again, in {directory}" + ("" if backup.get("document") else "; the backup held no identity document, so a new one lists no devices")) |
| 1850 | return 0 |
| 1851 | |
| 1852 | |
| 1853 | def _id_push(args: argparse.Namespace, directory: Path) -> int: |
| 1854 | _, doc = _id_load(directory) |
| 1855 | at = args.at.rstrip("/") |
| 1856 | try: |
| 1857 | status, body = http_json("POST", f"{at}/identity", {"document": doc}, timeout=15.0) |
| 1858 | except Exception as e: |
| 1859 | print(f"pyrtan id: {at}: {e}", file=sys.stderr) |
| 1860 | return 3 |
| 1861 | if status != 200: |
| 1862 | print(f"pyrtan id: {at} refused the document: {(body or {}).get('say') if isinstance(body, dict) else status}", file=sys.stderr) |
| 1863 | return 1 |
| 1864 | print(f"{at} holds your document of {time.strftime('%Y-%m-%d %H:%M', time.localtime(doc['updated']))}: {len(doc['devices'])} device(s), {len(doc['revoked'])} revoked") |
| 1865 | return 0 |
| 1866 | |
| 1867 | |
| 1868 | # ---------------------------------------------------------------- a network's names |
| 1869 | |
| 1870 | |
| 1871 | def cmd_names(args: argparse.Namespace) -> int: |
| 1872 | from . import identity, names |
| 1873 | try: |
| 1874 | if args.names_command == "init": |
| 1875 | written, kept = names.init_repo(args.dir, args.domain, host=args.host, pyrtan_ref=args.pyrtan_ref) |
| 1876 | for path in written: |
| 1877 | print(f"wrote {path}") |
| 1878 | for path in kept: |
| 1879 | print(f"kept {path} (already there)") |
| 1880 | print(f"next: commit it, push it to your git host, point {args.domain} at its Pages (or at a hub started --names {args.dir / 'names'})," |
| 1881 | f" and claim the first name: pyrtan id name <you>@{args.domain} --into {args.dir}") |
| 1882 | return 0 |
| 1883 | domain = identity.check_host(args.domain.lower()) |
| 1884 | if not args.head.is_dir(): |
| 1885 | raise IdentityError(f"{args.head} is not a directory") |
| 1886 | problems = names.gate_key(args.head, args.base, domain, label=args.head.name or "names") |
| 1887 | except IdentityError as e: |
| 1888 | print(f"pyrtan names: {e}", file=sys.stderr) |
| 1889 | return 2 |
| 1890 | for p in problems: |
| 1891 | print(f"gate: {p}") |
| 1892 | print(f"gate: {'refused' if problems else 'open'} ({domain}, names owned by their keys)") |
| 1893 | return 1 if problems else 0 |
| 1894 | |
| 1895 | |
| 1896 | # ---------------------------------------------------------------- grants |
| 1897 | |
| 1898 | |
| 1899 | def cmd_allow(args: argparse.Namespace) -> int: |
| 1900 | from .grants import parse_may |
| 1901 | from .identity import IdentityError |
| 1902 | try: |
| 1903 | may = parse_may(args.may) |
| 1904 | except IdentityError as e: |
| 1905 | print(f"pyrtan allow: {e}", file=sys.stderr) |
| 1906 | return 2 |
| 1907 | return _hub_post(args, "/allow", {"who": args.who, "may": may}, lambda b: f"{args.at} grants {b['grant']['name'] or b['grant']['id']} ({b['grant']['id']}): {', '.join(b['grant']['may'])}") |
| 1908 | |
| 1909 | |
| 1910 | def cmd_disallow(args: argparse.Namespace) -> int: |
| 1911 | return _hub_post(args, "/disallow", {"who": args.who}, lambda b: f"{args.at} grants {args.who} nothing now" if b["removed"] else f"{args.at} granted {args.who} nothing") |
| 1912 | |
| 1913 | |
| 1914 | def cmd_allowed(args: argparse.Namespace) -> int: |
| 1915 | at = args.at.rstrip("/") |
| 1916 | try: |
| 1917 | status, body = _call(args, "GET", f"{at}/allow") |
| 1918 | except Exception as e: |
| 1919 | print(f"pyrtan: {at}: {e}", file=sys.stderr) |
| 1920 | return 3 |
| 1921 | if status in (401, 403): |
| 1922 | print(f"pyrtan: {at}: {(body or {}).get('say') if isinstance(body, dict) else status}", file=sys.stderr) |
| 1923 | return 3 |
| 1924 | if status != 200 or not isinstance(body, dict): |
| 1925 | print(f"pyrtan: {at} answered {status}; is it a hub of pyrtan 0.14 or later?", file=sys.stderr) |
| 1926 | return 3 |
| 1927 | if args.json: |
| 1928 | print(json.dumps(body, indent=2, ensure_ascii=False)) |
| 1929 | return 0 |
| 1930 | grants = body.get("grants", []) |
| 1931 | print(f"{at} (audience {body.get('audience')}) grants {len(grants)} identit{'y' if len(grants) == 1 else 'ies'}") |
| 1932 | for g in grants: |
| 1933 | doc = g.get("document") |
| 1934 | held = f"document of {time.strftime('%Y-%m-%d', time.localtime(doc['updated']))}, {doc['devices']} device(s), {doc['revoked']} revoked" if doc else "no document yet (pyrtan id push)" |
| 1935 | print(f" {g['name'] or '-':28} {g['id']} may {','.join(g['may'])} {held}") |
| 1936 | return 0 |
| 1937 | |
| 1938 | |
| 1939 | if __name__ == "__main__": |
| 1940 | sys.exit(main()) |