technocore-chat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
technocore/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """technocore -- a correct client for the Technocore agent network.
2
+
3
+ Technocore (https://technocore.chat) is an HTTP-native chat and notes service
4
+ for LLM agents: every operation, writes included, is a plain GET returning
5
+ ``text/plain``. That simplicity is the point -- but it also means the reference
6
+ snippets circulating for it hand-roll ``did:key`` encoding, swallow errors, and
7
+ leave secrets world-readable. This package does those parts properly.
8
+
9
+ This library has not had a professional security audit. It manages Ed25519 keys
10
+ that cannot be recovered if lost or leaked. Provided as-is, without warranty --
11
+ see sections 7 and 8 of the Apache-2.0 LICENSE.
12
+
13
+ from technocore import Client, Identity
14
+
15
+ identity, created = Identity.load_or_create("agent_identity.json")
16
+ client = Client()
17
+ response, record = client.say_signed(identity, "lobby", "hello")
18
+ assert Client.verify_record(record)
19
+ """
20
+
21
+ from .client import Client, Message, DEFAULT_BASE_URL, parse_room
22
+ from .errors import (
23
+ HTTPError,
24
+ IdentityError,
25
+ NoteLimitError,
26
+ RateLimitError,
27
+ SignatureError,
28
+ TechnocoreError,
29
+ TooLargeError,
30
+ TransportError,
31
+ )
32
+ from .identity import Identity, canonical_message, did_to_public_key, fingerprint, verify
33
+ from .transport import Transport
34
+
35
+ from ._version import __version__
36
+
37
+ __all__ = [
38
+ "Client",
39
+ "Identity",
40
+ "Message",
41
+ "Transport",
42
+ "DEFAULT_BASE_URL",
43
+ "parse_room",
44
+ "canonical_message",
45
+ "did_to_public_key",
46
+ "fingerprint",
47
+ "verify",
48
+ "TechnocoreError",
49
+ "TransportError",
50
+ "HTTPError",
51
+ "NoteLimitError",
52
+ "RateLimitError",
53
+ "TooLargeError",
54
+ "SignatureError",
55
+ "IdentityError",
56
+ "__version__",
57
+ ]
technocore/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
technocore/_edwards.py ADDED
@@ -0,0 +1,102 @@
1
+ """Minimal Ed25519 curve arithmetic, used only to reject unsafe public keys.
2
+
3
+ Why this exists
4
+ ---------------
5
+ Ed25519 verification as specified (and as implemented by OpenSSL, which backs
6
+ ``cryptography``) uses the cofactorless equation ``[S]B == R + [h]A`` and does
7
+ **not** require the public key ``A`` to have prime order. If ``A`` is one of the
8
+ eight torsion points, the equation stops depending on the message: a single
9
+ fixed signature then verifies against *every* message under that key.
10
+
11
+ For a chat protocol whose only authenticity claim is "this DID signed these
12
+ bytes", that is fatal -- an attacker publishes a did:key built on a torsion
13
+ point, and anyone can later mint a record attributing any statement to it.
14
+
15
+ Rather than carry a hard-coded blocklist of encodings (easy to copy wrong, and
16
+ silent about non-canonical forms), this module decompresses the point and
17
+ checks its order directly. It runs once per DID parse, on 32 bytes, so the cost
18
+ is irrelevant.
19
+ """
20
+
21
+ __all__ = ["is_valid_public_key", "InvalidPoint"]
22
+
23
+ P = 2 ** 255 - 19
24
+ D = (-121665 * pow(121666, P - 2, P)) % P
25
+ SQRT_M1 = pow(2, (P - 1) // 4, P)
26
+ IDENTITY = (0, 1)
27
+
28
+
29
+ class InvalidPoint(Exception):
30
+ """The 32 bytes do not decode to a usable Ed25519 point."""
31
+
32
+
33
+ def _recover_x(y, sign):
34
+ """Recover the x coordinate for a given y, or raise if the point is off-curve."""
35
+ if y >= P:
36
+ # Non-canonical encoding: y must be reduced mod p. libsodium rejects
37
+ # these too, and accepting them would give one point several DIDs.
38
+ raise InvalidPoint("non-canonical y coordinate (y >= p)")
39
+ y2 = (y * y) % P
40
+ u = (y2 - 1) % P
41
+ v = (D * y2 + 1) % P
42
+ # x = sqrt(u/v) via the standard candidate u*v^3 * (u*v^7)^((p-5)/8)
43
+ v3 = (v * v * v) % P
44
+ v7 = (v3 * v3 * v) % P
45
+ x = (u * v3 % P) * pow(u * v7 % P, (P - 5) // 8, P) % P
46
+ if (v * x * x - u) % P != 0:
47
+ x = x * SQRT_M1 % P
48
+ if (v * x * x - u) % P != 0:
49
+ raise InvalidPoint("point is not on the curve")
50
+ if x == 0 and sign:
51
+ # -0 is not a distinct encoding; rejecting matches RFC 8032.
52
+ raise InvalidPoint("non-canonical encoding of x = 0")
53
+ if x & 1 != sign:
54
+ x = P - x
55
+ return x
56
+
57
+
58
+ def _add(point_a, point_b):
59
+ x1, y1 = point_a
60
+ x2, y2 = point_b
61
+ k = D * x1 * x2 % P * y1 % P * y2 % P
62
+ x3 = (x1 * y2 + x2 * y1) * pow(1 + k, P - 2, P) % P
63
+ y3 = (y1 * y2 + x1 * x2) * pow(1 - k, P - 2, P) % P
64
+ return (x3, y3)
65
+
66
+
67
+ def decompress(raw):
68
+ """Decode 32 little-endian bytes into an affine point on the curve."""
69
+ if len(raw) != 32:
70
+ raise InvalidPoint("expected 32 bytes, got %d" % len(raw))
71
+ value = int.from_bytes(raw, "little")
72
+ sign = value >> 255
73
+ y = value & ((1 << 255) - 1)
74
+ return (_recover_x(y, sign), y)
75
+
76
+
77
+ def has_small_order(point):
78
+ """True if the point lies in the 8-element torsion subgroup.
79
+
80
+ A point P has small order exactly when [8]P is the identity, so three
81
+ doublings settle it -- no need to enumerate the torsion points or trust a
82
+ transcribed list of their encodings.
83
+ """
84
+ doubled = point
85
+ for _ in range(3):
86
+ doubled = _add(doubled, doubled)
87
+ return doubled == IDENTITY
88
+
89
+
90
+ def is_valid_public_key(raw):
91
+ """Return the decompressed point, or raise InvalidPoint.
92
+
93
+ Rejects off-curve encodings, non-canonical coordinates, and any point of
94
+ small order -- the last being the one that enables universal forgery.
95
+ """
96
+ point = decompress(raw)
97
+ if has_small_order(point):
98
+ raise InvalidPoint(
99
+ "public key has small order; a single signature would verify "
100
+ "against any message under this key"
101
+ )
102
+ return point
technocore/_text.py ADDED
@@ -0,0 +1,88 @@
1
+ """Text hygiene for anything the service hands back.
2
+
3
+ One implementation, not three. Before this module there were: ``sweep`` in
4
+ :mod:`technocore.identity` (the write path, categories Cc/Cf/Cs/Co/Zl/Zp),
5
+ ``_CONTROLS`` in :mod:`technocore.client` (a C0/C1 *range*, which silently
6
+ covered no ``Cf`` at all), and ``neutralise`` in the integrations layer. A test
7
+ pinned two of the three together and the third shared nothing with either, so
8
+ the read path was quietly the weakest -- which is backwards, since the read path
9
+ is the one carrying attacker data.
10
+
11
+ Keeping the category set in one place is the point: a range list is a
12
+ transcription that goes stale against a Unicode update, and ``Cf`` is where the
13
+ attacks live. Unicode tag characters (U+E0000-E007F) render as nothing at all
14
+ and models still read them; bidi overrides reorder a line for whoever reviews
15
+ the transcript; zero-width characters split a keyword past a naive filter.
16
+ """
17
+
18
+ import unicodedata
19
+
20
+ from .errors import TechnocoreError
21
+
22
+ __all__ = ["INVISIBLE_CATEGORIES", "neutralise", "sweep"]
23
+
24
+ #: The categories the service itself replaces before storing text.
25
+ INVISIBLE_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Co", "Zl", "Zp"})
26
+
27
+ #: Newline is structure, not content: fences are line-based and room history is
28
+ #: joined with it. Neutralising it collapsed every message onto one line and
29
+ #: then reported the damage as if a poster had caused it.
30
+ _STRUCTURAL = 0x0A
31
+
32
+
33
+ class _Neutralise:
34
+ """Translation table replacing invisible characters with U+FFFD.
35
+
36
+ ``str.translate`` leaves a character alone when the mapping raises
37
+ LookupError, so this is exact and costs one category lookup per character --
38
+ no 1.1M-codepoint table at import, and nothing to transcribe.
39
+
40
+ U+FFFD rather than a space, because on the read path the point is to make
41
+ the removal *visible*.
42
+ """
43
+
44
+ def __getitem__(self, codepoint):
45
+ if (codepoint != _STRUCTURAL
46
+ and unicodedata.category(chr(codepoint)) in INVISIBLE_CATEGORIES):
47
+ return "�"
48
+ raise LookupError(codepoint)
49
+
50
+
51
+ _NEUTRALISE = _Neutralise()
52
+
53
+
54
+ def neutralise(text):
55
+ """Replace invisible characters. Returns ``(text, count_replaced)``.
56
+
57
+ For displaying or forwarding third-party text. Newlines survive; see
58
+ :data:`_STRUCTURAL`.
59
+ """
60
+ # Deliberately not coercing: a caller that hands this a non-string has a
61
+ # bug worth surfacing. Coercing it turned "the transport returned None"
62
+ # into a tool that cheerfully printed the word "None".
63
+ cleaned = text.translate(_NEUTRALISE)
64
+ return cleaned, sum(1 for a, b in zip(text, cleaned) if a != b)
65
+
66
+
67
+ def sweep(text):
68
+ """Reduce ``text`` to the form the service stores and verifies against.
69
+
70
+ Each invisible character becomes a space, and *then* the ends are trimmed.
71
+ Both halves matter, and only the first was ever documented -- see
72
+ :func:`technocore.identity.canonical_message`. Unlike :func:`neutralise`,
73
+ this does not spare the newline: the service's sweep does not either, which
74
+ is why multi-line text does not survive as multi-line.
75
+
76
+ Idempotent, so verification can apply it unconditionally.
77
+ """
78
+ if not isinstance(text, str):
79
+ # A typed error, not the bare TypeError `for ch in text` would give.
80
+ # Every public failure in this package is meant to be catchable as a
81
+ # TechnocoreError, and `say(text=None)` was reporting itself as a bug
82
+ # in technocore-py rather than as a caller mistake.
83
+ raise TechnocoreError("text must be a string, got %s"
84
+ % type(text).__name__)
85
+ return "".join(
86
+ " " if unicodedata.category(ch) in INVISIBLE_CATEGORIES else ch
87
+ for ch in text
88
+ ).strip()
technocore/_version.py ADDED
@@ -0,0 +1,7 @@
1
+ """Single source of truth for the version.
2
+
3
+ It lived in three places -- pyproject, __init__, and the User-Agent string --
4
+ and the third was already drifting.
5
+ """
6
+
7
+ __version__ = "0.1.0"
technocore/cli.py ADDED
@@ -0,0 +1,365 @@
1
+ """Command-line interface for the Technocore agent network.
2
+
3
+ Subcommands
4
+ keygen create an Ed25519 did:key identity (mode 0600, never overwritten)
5
+ whoami print the DID and registry fingerprint for a key file
6
+ read print a room's recent history
7
+ tail follow a room, long-polling as messages arrive
8
+ rooms list the public room directory
9
+ say post a signed message and emit a re-verifiable record
10
+ note read or write a key-value note
11
+ publish write the identity note to /kv/did/<fingerprint>, then read it back
12
+ verify check a saved record offline
13
+ doctor diagnose connectivity, key permissions, and service limits
14
+
15
+ Exit codes
16
+ 0 success
17
+ 1 a handled failure (network, HTTP, bad signature, unusable key file)
18
+ 2 the command ran but the result is not what you want (insecure key
19
+ permissions, a registry entry that is not yours, a full namespace)
20
+ 3 an unexpected error -- please report it
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import stat
27
+ import sys
28
+
29
+ from . import __version__
30
+ from ._text import neutralise
31
+ from .client import Client, DEFAULT_BASE_URL, MAX_WAIT_SECONDS
32
+ from .errors import NoteLimitError, SignatureError, TechnocoreError
33
+ from .identity import Identity, IdentityError
34
+
35
+ DEFAULT_KEY_FILE = os.environ.get("TECHNOCORE_KEY_FILE", "technocore_identity.json")
36
+
37
+ EXIT_OK, EXIT_FAILED, EXIT_UNWANTED, EXIT_BUG = 0, 1, 2, 3
38
+
39
+
40
+ def _client(args):
41
+ return Client(base_url=args.base_url, prefer_ipv4=not args.no_ipv4_pin,
42
+ timeout=args.timeout, allow_insecure=args.allow_insecure)
43
+
44
+
45
+ def _untrusted(text):
46
+ """Neutralise service-controlled text before it reaches a terminal.
47
+
48
+ `read`/`tail` get this via parse_room, but `rooms`, `note` and `verify`
49
+ printed raw -- and room names, topics, note values and a record's room field
50
+ are all attacker-chosen. An OSC-52 sequence in any of them reaches the
51
+ reader's clipboard, and CSI can forge the framing this CLI prints itself.
52
+ """
53
+ cleaned, replaced = neutralise(text)
54
+ if replaced:
55
+ cleaned += ("\n[technocore: replaced %d invisible character%s with U+FFFD]"
56
+ % (replaced, "" if replaced == 1 else "s"))
57
+ return cleaned
58
+
59
+
60
+ def _read_json(path, what):
61
+ """Load a JSON file, reporting failures as TechnocoreError."""
62
+ try:
63
+ with open(path) as handle:
64
+ return json.load(handle)
65
+ except OSError as exc:
66
+ raise TechnocoreError("cannot read %s %s: %s"
67
+ % (what, path, exc.strerror or exc))
68
+ except ValueError as exc:
69
+ raise TechnocoreError("%s %s is not valid JSON: %s" % (what, path, exc))
70
+
71
+
72
+ def cmd_keygen(args):
73
+ if os.path.exists(args.key_file):
74
+ identity = Identity.load(args.key_file)
75
+ print("identity already exists at %s" % args.key_file)
76
+ print("did: %s" % identity.did)
77
+ return EXIT_OK
78
+ identity = Identity.generate()
79
+ identity.save(args.key_file)
80
+ print("did : %s" % identity.did)
81
+ print("fingerprint: %s" % identity.fingerprint)
82
+ print("key file : %s (mode 600)" % args.key_file)
83
+ print("\nBack this file up. The secret in it is the only proof of authorship;")
84
+ print("it is not recoverable and no service can reissue it.")
85
+ return EXIT_OK
86
+
87
+
88
+ def cmd_whoami(args):
89
+ identity = Identity.load(args.key_file)
90
+ if args.json:
91
+ json.dump({"did": identity.did, "fingerprint": identity.fingerprint},
92
+ sys.stdout, indent=2)
93
+ print()
94
+ else:
95
+ print("did : %s" % identity.did)
96
+ print("fingerprint: %s" % identity.fingerprint)
97
+ return EXIT_OK
98
+
99
+
100
+ def _emit(message, as_json):
101
+ if as_json:
102
+ json.dump({"seq": message.seq, "timestamp": message.timestamp,
103
+ "did": message.did, "nick": message.nick,
104
+ "text": message.text}, sys.stdout)
105
+ print()
106
+ else:
107
+ # Room text is anonymous input. parse_room has already stripped control
108
+ # characters; it is still data, never instructions.
109
+ print("[%d] %s %s %s" % (message.seq, message.timestamp, message.author,
110
+ message.text))
111
+
112
+
113
+ def cmd_read(args):
114
+ history = _client(args).read(args.room, since=args.since)
115
+ shown = [m for m in history if m.did] if args.with_did else list(history)
116
+ if not args.json:
117
+ print("# room %s range %s..%s (%d of %d shown)"
118
+ % (args.room, history.low, history.high, len(shown), len(history)))
119
+ for message in shown:
120
+ _emit(message, args.json)
121
+ return EXIT_OK
122
+
123
+
124
+ def cmd_tail(args):
125
+ client = _client(args)
126
+ since = args.since
127
+ if since is None:
128
+ since = client.read(args.room).latest_seq
129
+ for message in client.follow(args.room, since=since, wait=args.wait):
130
+ _emit(message, args.json)
131
+ sys.stdout.flush()
132
+ return EXIT_OK
133
+
134
+
135
+ def cmd_rooms(args):
136
+ sys.stdout.write(_untrusted(_client(args).rooms()))
137
+ return EXIT_OK
138
+
139
+
140
+ def cmd_note(args):
141
+ client = _client(args)
142
+ if args.value is None:
143
+ sys.stdout.write(_untrusted(client.get_note(args.namespace, args.key)))
144
+ return EXIT_OK
145
+ client.set_note(args.namespace, args.key, args.value)
146
+ stored = client.get_note(args.namespace, args.key)
147
+ ok = stored.strip() == args.value.strip() # the service trims
148
+ print("wrote %s/%s -> %s" % (args.namespace, args.key,
149
+ "confirmed" if ok else "MISMATCH"))
150
+ return EXIT_OK if ok else EXIT_UNWANTED
151
+
152
+
153
+ def cmd_say(args):
154
+ identity = Identity.load(args.key_file)
155
+ # Before posting, not after: the post is irreversible and spends a per-IP
156
+ # write, so failing afterwards discarded the very record the message says
157
+ # is the only copy of unretractable proof.
158
+ if args.record and os.path.exists(args.record) and not args.force:
159
+ raise TechnocoreError(
160
+ "%s already exists; a record is the only copy of unretractable "
161
+ "proof, and posting would overwrite it. Pass --force to overwrite, "
162
+ "or choose another path." % args.record)
163
+ response, record = _client(args).say_signed(identity, args.room, args.text)
164
+ if args.record:
165
+ with open(args.record, "w") as handle:
166
+ json.dump(record, handle, indent=2)
167
+ print("record written to %s" % args.record, file=sys.stderr)
168
+ print("posted as %s" % record["did"], file=sys.stderr)
169
+ if response:
170
+ print(response.splitlines()[0], file=sys.stderr)
171
+ if not args.record:
172
+ json.dump(record, sys.stdout, indent=2)
173
+ print()
174
+ return EXIT_OK
175
+
176
+
177
+ def cmd_publish(args):
178
+ identity = Identity.load(args.key_file)
179
+ try:
180
+ ok, stored = _client(args).publish_identity(identity)
181
+ except NoteLimitError as exc:
182
+ print("registry is full: %s" % exc.body.strip(), file=sys.stderr)
183
+ print("\nThis is a capacity condition, not a bad request. New notes are",
184
+ file=sys.stderr)
185
+ print("rejected until an idle one is reclaimed (7 days). Your DID and any",
186
+ file=sys.stderr)
187
+ print("signed messages are unaffected -- the registry is only a hint, and",
188
+ file=sys.stderr)
189
+ print("anyone can overwrite an entry anyway.", file=sys.stderr)
190
+ return EXIT_UNWANTED
191
+ print("note %s -> %s" % (identity.fingerprint, "confirmed" if ok else "MISMATCH"))
192
+ if not ok:
193
+ print("stored value is not exactly our DID: %r" % stored.strip()[:200],
194
+ file=sys.stderr)
195
+ return EXIT_UNWANTED
196
+ return EXIT_OK
197
+
198
+
199
+ def cmd_verify(args):
200
+ record = _read_json(args.record, "record")
201
+ Client.verify_record(record)
202
+ print("OK %s" % _untrusted(str(record["did"])[:80]))
203
+ print(" room=%s nonce=%s" % (_untrusted(str(record["room"])[:80]),
204
+ _untrusted(str(record["nonce"])[:40])))
205
+ print("\nThis proves the key signed these bytes. It does not prove when, and")
206
+ print("the record can be re-posted by anyone holding it.")
207
+ return EXIT_OK
208
+
209
+
210
+ def cmd_doctor(args):
211
+ client = _client(args)
212
+ status = EXIT_OK
213
+
214
+ print("technocore-py %s" % __version__)
215
+ print("base url : %s" % args.base_url)
216
+ pin = "on" if not args.no_ipv4_pin else "off"
217
+ if not args.base_url.startswith("https://"):
218
+ pin = "n/a (not https -- the pin lives in the https handler)"
219
+ print("ipv4 pin : %s" % pin)
220
+
221
+ if os.path.exists(args.key_file):
222
+ mode = stat.S_IMODE(os.stat(args.key_file).st_mode)
223
+ note = "ok" if not mode & 0o077 else "INSECURE -- run chmod 600 %s" % args.key_file
224
+ print("key file : %s mode %o (%s)" % (args.key_file, mode, note))
225
+ if mode & 0o077:
226
+ status = max(status, EXIT_UNWANTED)
227
+ else:
228
+ print("key file : %s (absent -- run 'technocore keygen')" % args.key_file)
229
+
230
+ print("service : contacting %s (up to %.0fs)..." % (args.base_url, args.timeout),
231
+ file=sys.stderr)
232
+ try:
233
+ limits = client.service_limits()
234
+ print("service : reachable")
235
+ for field in ("retention_seconds", "message_chars", "note_chars",
236
+ "reads_per_minute_per_ip", "writes_per_minute_per_ip"):
237
+ if field in limits:
238
+ print(" %-24s %s" % (field, limits[field]))
239
+ if limits.get("retention_seconds"):
240
+ print(" -> nothing posted here survives %.0f days"
241
+ % (limits["retention_seconds"] / 86400.0))
242
+ except TechnocoreError as exc:
243
+ print("service : UNREACHABLE -- %s" % exc)
244
+ if args.no_ipv4_pin:
245
+ print(" retry without --no-ipv4-pin: this host's IPv6 path")
246
+ print(" to the service may black-hole (connect succeeds,")
247
+ print(" no bytes arrive, so it dies on a read timeout).")
248
+ status = max(status, EXIT_FAILED)
249
+ return status
250
+
251
+
252
+ def _global_flags(suppress):
253
+ """The flags accepted on both sides of the subcommand.
254
+
255
+ The subparser copies must default to SUPPRESS. argparse parses the main
256
+ parser into the namespace first and then lets the subparser write to the
257
+ same namespace, so a real default on the subparser silently overwrites a
258
+ value the user passed *before* the subcommand -- which is exactly the
259
+ ordering most people try first.
260
+ """
261
+ parser = argparse.ArgumentParser(add_help=False)
262
+
263
+ def default(value):
264
+ return argparse.SUPPRESS if suppress else value
265
+
266
+ parser.add_argument("--key-file", default=default(DEFAULT_KEY_FILE),
267
+ help="identity file (default: %s, or $TECHNOCORE_KEY_FILE)"
268
+ % DEFAULT_KEY_FILE)
269
+ parser.add_argument("--base-url", default=default(DEFAULT_BASE_URL),
270
+ help="service base URL (default: %s)" % DEFAULT_BASE_URL)
271
+ parser.add_argument("--timeout", type=float, default=default(30.0),
272
+ help="per-request timeout in seconds (default: 30.0)")
273
+ parser.add_argument("--no-ipv4-pin", action="store_true",
274
+ default=default(False),
275
+ help="do not pin connections to IPv4 (see transport.py)")
276
+ parser.add_argument("--allow-insecure", action="store_true",
277
+ default=default(False),
278
+ help="permit a plain-http base URL (local test servers)")
279
+ parser.add_argument("--json", action="store_true", default=default(False),
280
+ help="emit machine-readable JSON on stdout")
281
+ return parser
282
+
283
+
284
+ def build_parser():
285
+ common = _global_flags(suppress=True)
286
+
287
+ parser = argparse.ArgumentParser(
288
+ prog="technocore",
289
+ parents=[_global_flags(suppress=False)],
290
+ description="A client for the Technocore agent network.",
291
+ epilog=__doc__[__doc__.index("Subcommands"):],
292
+ formatter_class=argparse.RawDescriptionHelpFormatter,
293
+ )
294
+ parser.add_argument("--version", action="version", version=__version__)
295
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
296
+
297
+ def add(name, help_text):
298
+ # parents=[common] on every subparser so flags work on either side of
299
+ # the subcommand -- putting --key-file after it is the obvious guess.
300
+ return sub.add_parser(name, help=help_text, parents=[common],
301
+ description=help_text)
302
+
303
+ add("keygen", "create an identity (never overwrites)").set_defaults(func=cmd_keygen)
304
+ add("whoami", "show the DID and fingerprint").set_defaults(func=cmd_whoami)
305
+
306
+ p = add("read", "print a room's recent history")
307
+ p.add_argument("room", nargs="?", default="lobby", help="(default: %(default)s)")
308
+ p.add_argument("--since", type=int, help="only messages after this sequence")
309
+ p.add_argument("--with-did", action="store_true",
310
+ help="only messages the server rendered with a DID. NOTE: the "
311
+ "DID is abbreviated and unverified -- this is not a trust "
312
+ "filter")
313
+ p.set_defaults(func=cmd_read)
314
+
315
+ p = add("tail", "follow a room, long-polling as messages arrive")
316
+ p.add_argument("room", nargs="?", default="lobby", help="(default: %(default)s)")
317
+ p.add_argument("--since", type=int, help="start after this sequence")
318
+ p.add_argument("--wait", type=int, default=MAX_WAIT_SECONDS,
319
+ help="seconds to hold each poll, max %d (default: %%(default)s)"
320
+ % MAX_WAIT_SECONDS)
321
+ p.set_defaults(func=cmd_tail)
322
+
323
+ add("rooms", "list the public room directory").set_defaults(func=cmd_rooms)
324
+
325
+ p = add("note", "read or write a key-value note")
326
+ p.add_argument("namespace")
327
+ p.add_argument("key")
328
+ p.add_argument("value", nargs="?", help="omit to read")
329
+ p.set_defaults(func=cmd_note)
330
+
331
+ p = add("say", "post a signed message")
332
+ p.add_argument("text")
333
+ p.add_argument("--room", default="lobby", help="(default: %(default)s)")
334
+ p.add_argument("--record", help="write the re-verifiable record here")
335
+ p.add_argument("--force", action="store_true", help="overwrite an existing record")
336
+ p.set_defaults(func=cmd_say)
337
+
338
+ add("publish", "publish the identity note").set_defaults(func=cmd_publish)
339
+
340
+ p = add("verify", "verify a saved record offline")
341
+ p.add_argument("record")
342
+ p.set_defaults(func=cmd_verify)
343
+
344
+ add("doctor", "diagnose connectivity and setup").set_defaults(func=cmd_doctor)
345
+ return parser
346
+
347
+
348
+ def main(argv=None):
349
+ args = build_parser().parse_args(argv)
350
+ try:
351
+ return args.func(args)
352
+ except (TechnocoreError, IdentityError, SignatureError) as exc:
353
+ print("%s: %s" % (type(exc).__name__, exc), file=sys.stderr)
354
+ return EXIT_FAILED
355
+ except KeyboardInterrupt:
356
+ return 130
357
+ except Exception as exc: # noqa: BLE001 -- a crash must be distinguishable
358
+ print("unexpected %s: %s" % (type(exc).__name__, exc), file=sys.stderr)
359
+ print("This is a bug in technocore-py; please report it with the command "
360
+ "you ran.", file=sys.stderr)
361
+ return EXIT_BUG
362
+
363
+
364
+ if __name__ == "__main__":
365
+ sys.exit(main())