pyrtan / sourcev0.19.0 · main

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
37bearer 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
42from __future__ import annotations
43
44import argparse
45import json
46import os
47import sys
48import threading
49import time
50from pathlib import Path
51from typing import Any
52
53from . import __version__
54from .catalog import Catalog, CatalogError, build_bundle, build_index, catalog_url
55from .federation import bootstrap_candidates
56from .grow import Proposal, propose
57from .identity import ABILITIES, KINDS, IdentityError
58from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER
59from .registry import Registry, default_paths
60from .runtime import Outcome, Runner
61from .service import RemoteResolver, http_json, outcome_from_dict, serve
62from .adapters import LocalAdapters
63from .transport import EGRESS_POLICIES, RecordedTransport, RecordingTransport, UrllibTransport
64from .tree import TreeError, check, load_tree
65
66
67
68def 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
392def _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
402def _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
408def _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
427def _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
435def _paths(given: list[str]) -> list[Path]:
436 return [Path(p) for p in given] if given else default_paths()
437
438
439def _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
451def 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
476def 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
491def _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
504def 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
528def 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
574def _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
586def 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
609def 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
718def _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
734def 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
752def 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
786def 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
818def 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
913def _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
935def 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
961def _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
1050def _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
1059def 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
1103def 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
1129def 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
1157def 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
1170def 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
1185def 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
1216def 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
1225def 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
1249def 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
1278def 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
1327def 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
1331def _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
1352def 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
1379def _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
1397def 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
1418def 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
1435def 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
1461def 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
1485def 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
1499def 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
1533def _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
1543def 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
1560def 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
1575def _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
1592def _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
1599def _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
1616def _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
1634def _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
1665def _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
1679def _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
1688def _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
1698def _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
1731def _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
1744def _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
1768def _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
1785def _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
1795def _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
1808def _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
1823def _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
1853def _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
1871def 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
1899def 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
1910def 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
1914def 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
1939if __name__ == "__main__":
1940 sys.exit(main())